first commit
This commit is contained in:
commit
c24d87a988
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
9
.idea/batch.iml
generated
Normal file
9
.idea/batch.iml
generated
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/batch.iml" filepath="$PROJECT_DIR$/.idea/batch.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
268
addSyncPddReject/main.go
Normal file
268
addSyncPddReject/main.go
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxOpenConns = 20
|
||||||
|
MaxIdleConns = 10
|
||||||
|
ConnMaxLifetime = 10 * time.Minute
|
||||||
|
ConnMaxIdleTime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// 雪花算法ID生成器
|
||||||
|
type Snowflake struct {
|
||||||
|
sequence int64
|
||||||
|
lastTimestamp int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var snowflake = &Snowflake{}
|
||||||
|
|
||||||
|
// 生成19位雪花算法ID
|
||||||
|
func (s *Snowflake) GenerateID() int64 {
|
||||||
|
const (
|
||||||
|
epoch int64 = 1704067200000 // 2024-01-01 00:00:00
|
||||||
|
machineID int64 = 1
|
||||||
|
sequenceMask int64 = 4095
|
||||||
|
timestampShift = 22
|
||||||
|
machineIDShift = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now().UnixNano() / 1e6
|
||||||
|
timestamp := now - epoch
|
||||||
|
|
||||||
|
if timestamp == s.lastTimestamp {
|
||||||
|
s.sequence = (s.sequence + 1) & sequenceMask
|
||||||
|
if s.sequence == 0 {
|
||||||
|
for timestamp <= s.lastTimestamp {
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
now = time.Now().UnixNano() / 1e6
|
||||||
|
timestamp = now - epoch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.sequence = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastTimestamp = timestamp
|
||||||
|
|
||||||
|
id := (timestamp << timestampShift) | (machineID << machineIDShift) | s.sequence
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println(" ISBN数据迁移工具")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 连接源数据库 (Master)
|
||||||
|
fmt.Println("正在连接源数据库 (Master)...")
|
||||||
|
dsnMaster := "zhishu:XsRR4K3ATizyc5BK@tcp(146.56.227.42:3306)/zhishu?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||||
|
dbMaster, err := sql.Open("mysql", dsnMaster)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 打开源数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer dbMaster.Close()
|
||||||
|
|
||||||
|
dbMaster.SetMaxOpenConns(MaxOpenConns)
|
||||||
|
dbMaster.SetMaxIdleConns(MaxIdleConns)
|
||||||
|
dbMaster.SetConnMaxLifetime(ConnMaxLifetime)
|
||||||
|
dbMaster.SetConnMaxIdleTime(ConnMaxIdleTime)
|
||||||
|
|
||||||
|
err = dbMaster.Ping()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 连接源数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✅ 源数据库连接成功")
|
||||||
|
|
||||||
|
// 连接目标数据库 (Slave)
|
||||||
|
fmt.Println("正在连接目标数据库 (Slave)...")
|
||||||
|
dsnSlave := "zhishu_slave:7DpixiEdCs5p3PEr@tcp(36.212.12.247:3306)/zhishu_slave?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||||
|
dbSlave, err := sql.Open("mysql", dsnSlave)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 打开目标数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer dbSlave.Close()
|
||||||
|
|
||||||
|
dbSlave.SetMaxOpenConns(MaxOpenConns)
|
||||||
|
dbSlave.SetMaxIdleConns(MaxIdleConns)
|
||||||
|
dbSlave.SetConnMaxLifetime(ConnMaxLifetime)
|
||||||
|
dbSlave.SetConnMaxIdleTime(ConnMaxIdleTime)
|
||||||
|
|
||||||
|
err = dbSlave.Ping()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 连接目标数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✅ 目标数据库连接成功\n")
|
||||||
|
|
||||||
|
// 从源数据库查询数据
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println(" 步骤1: 从源数据库读取数据")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
|
||||||
|
query := `SELECT isbn, create_time FROM shop_goods_rejection
|
||||||
|
WHERE rejection_reason = '您发布的商品涉嫌违规,请您检查相关商品并立即整改'
|
||||||
|
ORDER BY create_time ASC`
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
rows, err := dbMaster.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 查询源数据失败: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
ISBN string
|
||||||
|
CreateTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []Record
|
||||||
|
for rows.Next() {
|
||||||
|
var r Record
|
||||||
|
var isbn sql.NullString
|
||||||
|
var createTime sql.NullTime
|
||||||
|
|
||||||
|
if err := rows.Scan(&isbn, &createTime); err != nil {
|
||||||
|
log.Printf("读取数据失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isbn.Valid && isbn.String != "" {
|
||||||
|
r.ISBN = isbn.String
|
||||||
|
if createTime.Valid {
|
||||||
|
r.CreateTime = createTime.Time
|
||||||
|
}
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
fmt.Printf("查询完成,耗时: %v\n", elapsed)
|
||||||
|
fmt.Printf("共读取到 %d 条记录\n\n", len(records))
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
fmt.Println("没有需要迁移的数据")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询目标表中已有的ISBN(去重)
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println(" 步骤2: 检查目标表已有数据")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
|
||||||
|
existingISBNs := make(map[string]bool)
|
||||||
|
existingRows, err := dbSlave.Query("SELECT add_txt FROM t_filter_set WHERE limitation_type = '0'")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ 查询已有数据失败(表可能为空): %v", err)
|
||||||
|
} else {
|
||||||
|
for existingRows.Next() {
|
||||||
|
var isbn string
|
||||||
|
if err := existingRows.Scan(&isbn); err == nil {
|
||||||
|
existingISBNs[isbn] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existingRows.Close()
|
||||||
|
}
|
||||||
|
fmt.Printf("目标表中已有 %d 条ISBN记录\n\n", len(existingISBNs))
|
||||||
|
|
||||||
|
// 过滤掉已存在的ISBN
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println(" 步骤3: 过滤重复数据")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
|
||||||
|
var newRecords []Record
|
||||||
|
duplicateCount := 0
|
||||||
|
for _, r := range records {
|
||||||
|
if !existingISBNs[r.ISBN] {
|
||||||
|
newRecords = append(newRecords, r)
|
||||||
|
} else {
|
||||||
|
duplicateCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("过滤完成\n")
|
||||||
|
fmt.Printf("需要插入的新记录: %d 条\n", len(newRecords))
|
||||||
|
fmt.Printf("已存在的重复记录: %d 条\n\n", duplicateCount)
|
||||||
|
|
||||||
|
if len(newRecords) == 0 {
|
||||||
|
fmt.Println("没有新数据需要插入")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入数据
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Println(" 步骤4: 批量插入数据")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
|
||||||
|
batchSize := 100
|
||||||
|
totalInserted := 0
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := 0; i < len(newRecords); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(newRecords) {
|
||||||
|
end = len(newRecords)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := newRecords[i:end]
|
||||||
|
|
||||||
|
// 构建批量插入SQL
|
||||||
|
var valueStrings []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
for _, r := range batch {
|
||||||
|
// 这里不需要单独生成id,后面会重新构建
|
||||||
|
createTime := now.Format("2006-01-02 15:04:05")
|
||||||
|
valueStrings = append(valueStrings, "(?, '1', '0', '0', ?, 1, ?, 1, ?, '0', '0', '000000', NULL, NULL, '0', NULL)")
|
||||||
|
args = append(args, r.ISBN, createTime, createTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 重新构建包含ID的SQL
|
||||||
|
valueStrings = make([]string, 0)
|
||||||
|
args = make([]interface{}, 0)
|
||||||
|
|
||||||
|
for _, r := range batch {
|
||||||
|
id := snowflake.GenerateID()
|
||||||
|
createTime := now.Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
valueStrings = append(valueStrings, "(?, '1', '0', '0', ?, 1, ?, 1, ?, '0', '0', '000000', NULL, NULL, '0', NULL)")
|
||||||
|
args = append(args, id, r.ISBN, createTime, createTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSQL := fmt.Sprintf("INSERT INTO t_filter_set (id, filter_type, limitation_type, add_way, add_txt, create_by, create_time, update_by, update_time, status, del_flag, tenant_id, create_dept, reason, sort, shop_type) VALUES %s",
|
||||||
|
strings.Join(valueStrings, ", "))
|
||||||
|
|
||||||
|
result, err := dbSlave.Exec(insertSQL, args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ 批次 %d-%d 插入失败: %v", i+1, end, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
totalInserted += int(rowsAffected)
|
||||||
|
|
||||||
|
fmt.Printf("进度: %d/%d ( %.1f%% )\n", totalInserted, len(newRecords), float64(totalInserted)/float64(len(newRecords))*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalElapsed := time.Since(startTime)
|
||||||
|
|
||||||
|
fmt.Println("\n========================================")
|
||||||
|
fmt.Println(" 迁移完成")
|
||||||
|
fmt.Println("========================================")
|
||||||
|
fmt.Printf("源数据总数: %d 条\n", len(records))
|
||||||
|
fmt.Printf("重复过滤: %d 条\n", duplicateCount)
|
||||||
|
fmt.Printf("成功插入: %d 条\n", totalInserted)
|
||||||
|
fmt.Printf("总耗时: %v\n", totalElapsed)
|
||||||
|
fmt.Println("\n✅ 数据迁移完成!")
|
||||||
|
}
|
||||||
722
addSyncSuitBook/main.go
Normal file
722
addSyncSuitBook/main.go
Normal file
@ -0,0 +1,722 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ 配置 ============
|
||||||
|
|
||||||
|
// 源库
|
||||||
|
const (
|
||||||
|
srcHost = "36.134.185.139"
|
||||||
|
srcPort = 6603
|
||||||
|
srcUser = "tao_zhuang_bo"
|
||||||
|
srcPassword = "Bi54NTUi0i"
|
||||||
|
srcDatabase = "kong_book_set"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 目标MySQL库
|
||||||
|
const (
|
||||||
|
dstHost = "175.27.224.66"
|
||||||
|
dstPort = 3306
|
||||||
|
dstUser = "suit_book"
|
||||||
|
dstPassword = "2X8b8cBE6SmEECnm"
|
||||||
|
dstDatabase = "suit_book"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 目标ES
|
||||||
|
const (
|
||||||
|
esURL = "http://36.212.12.92:9527"
|
||||||
|
esUser = "elastic"
|
||||||
|
esPassword = "+Tz5qR_KushZ-bPgZ_H-"
|
||||||
|
esIndexV2 = "books-from-mysql-v2"
|
||||||
|
esIndexV3 = "books-from-mysql-v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 批量大小
|
||||||
|
const batchSize = 2000
|
||||||
|
|
||||||
|
// 增量同步状态文件
|
||||||
|
const syncStateFile = "sync_state.json"
|
||||||
|
|
||||||
|
// ============ 数据结构 ============
|
||||||
|
|
||||||
|
type BookRow struct {
|
||||||
|
KongID int64
|
||||||
|
ISBN string
|
||||||
|
BookName string
|
||||||
|
Author string
|
||||||
|
Press string
|
||||||
|
PressTime interface{}
|
||||||
|
Binding string
|
||||||
|
Price float64
|
||||||
|
ImgURL string
|
||||||
|
Edition string
|
||||||
|
Format string
|
||||||
|
Pages int64
|
||||||
|
WordCount int64
|
||||||
|
Category string
|
||||||
|
CreateTime interface{}
|
||||||
|
UpdateTime interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESDoc struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Fid int64 `json:"fid"`
|
||||||
|
ISBN string `json:"isbn"`
|
||||||
|
BookName string `json:"book_name"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
PublicationTime string `json:"publication_time"`
|
||||||
|
BindingLayout string `json:"binding_layout"`
|
||||||
|
FixPrice float64 `json:"fix_price"`
|
||||||
|
BookPic ESBookPic `json:"book_pic"`
|
||||||
|
BookPicS ESBookPicS `json:"book_pic_s"`
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
BookFormat string `json:"book_format"`
|
||||||
|
PageCount int64 `json:"page_count"`
|
||||||
|
WordCount int64 `json:"word_count"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
CatID ESCatID `json:"cat_id"`
|
||||||
|
UpdateTime string `json:"update_time"`
|
||||||
|
IsSuit int64 `json:"is_suit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESBookPic struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESBookPicS struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddResponse string `json:"pddResponse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESCatID struct {
|
||||||
|
KongFuZiCatID string `json:"kong_fu_zi_cat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImgURLJSON struct {
|
||||||
|
BookPic ImgBookPic `json:"book_pic"`
|
||||||
|
BookPicS ImgBookPicS `json:"book_pic_s"`
|
||||||
|
}
|
||||||
|
type ImgBookPic struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
}
|
||||||
|
type ImgBookPicS struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddResponse string `json:"pddResponse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncState struct {
|
||||||
|
LastSyncTime string `json:"last_sync_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助函数 ============
|
||||||
|
|
||||||
|
func buildImgURLJSON(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
b, _ := json.Marshal(ImgURLJSON{
|
||||||
|
BookPic: ImgBookPic{LocalPath: "", PddPath: ""},
|
||||||
|
BookPicS: ImgBookPicS{LocalPath: "", PddResponse: ""},
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(ImgURLJSON{
|
||||||
|
BookPic: ImgBookPic{LocalPath: "", PddPath: ""},
|
||||||
|
BookPicS: ImgBookPicS{LocalPath: "", PddResponse: raw},
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowToESDoc(r BookRow) ESDoc {
|
||||||
|
var imgJSON ImgURLJSON
|
||||||
|
json.Unmarshal([]byte(r.ImgURL), &imgJSON)
|
||||||
|
|
||||||
|
var pubTimeStr string
|
||||||
|
if r.PressTime != nil {
|
||||||
|
if t, ok := r.PressTime.(string); ok {
|
||||||
|
if parsed, err := time.Parse("2006-01-02 15:04:05", t); err == nil {
|
||||||
|
pubTimeStr = fmt.Sprintf("%d", parsed.Unix()+5364000000)
|
||||||
|
} else if parsed, err := time.Parse("2006-01-02", t); err == nil {
|
||||||
|
pubTimeStr = fmt.Sprintf("%d", parsed.Unix()+5364000000)
|
||||||
|
} else {
|
||||||
|
pubTimeStr = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateTimeStr string
|
||||||
|
if r.UpdateTime != nil {
|
||||||
|
if t, ok := r.UpdateTime.(string); ok {
|
||||||
|
if parsed, err := time.Parse("2006-01-02 15:04:05", t); err == nil {
|
||||||
|
updateTimeStr = fmt.Sprintf("%d", parsed.Unix())
|
||||||
|
} else {
|
||||||
|
updateTimeStr = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESDoc{
|
||||||
|
ID: r.KongID,
|
||||||
|
Fid: 0,
|
||||||
|
ISBN: r.ISBN,
|
||||||
|
BookName: r.BookName,
|
||||||
|
Author: r.Author,
|
||||||
|
Publisher: r.Press,
|
||||||
|
PublicationTime: pubTimeStr,
|
||||||
|
BindingLayout: r.Binding,
|
||||||
|
FixPrice: r.Price,
|
||||||
|
BookPic: ESBookPic{LocalPath: "", PddPath: ""},
|
||||||
|
BookPicS: ESBookPicS{LocalPath: "", PddResponse: imgJSON.BookPicS.PddResponse},
|
||||||
|
Edition: r.Edition,
|
||||||
|
BookFormat: r.Format,
|
||||||
|
PageCount: r.Pages,
|
||||||
|
WordCount: r.WordCount,
|
||||||
|
Category: r.Category,
|
||||||
|
CatID: ESCatID{KongFuZiCatID: r.Category},
|
||||||
|
UpdateTime: updateTimeStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 同步状态读写 ============
|
||||||
|
|
||||||
|
func loadSyncState() *SyncState {
|
||||||
|
data, err := os.ReadFile(syncStateFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var state SyncState
|
||||||
|
if json.Unmarshal(data, &state) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &state
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSyncState(t time.Time) {
|
||||||
|
state := SyncState{
|
||||||
|
LastSyncTime: t.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(state, "", " ")
|
||||||
|
os.WriteFile(syncStateFile, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ES操作 ============
|
||||||
|
|
||||||
|
func queryV2ByISBNs(isbns []string) (map[string]bool, error) {
|
||||||
|
if len(isbns) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTerms := 500
|
||||||
|
result := make(map[string]bool)
|
||||||
|
|
||||||
|
for i := 0; i < len(isbns); i += maxTerms {
|
||||||
|
end := i + maxTerms
|
||||||
|
if end > len(isbns) {
|
||||||
|
end = len(isbns)
|
||||||
|
}
|
||||||
|
batch := isbns[i:end]
|
||||||
|
|
||||||
|
query := map[string]interface{}{
|
||||||
|
"query": map[string]interface{}{
|
||||||
|
"terms": map[string]interface{}{
|
||||||
|
"isbn": batch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"_source": []string{"isbn"},
|
||||||
|
"size": len(batch),
|
||||||
|
}
|
||||||
|
queryBytes, _ := json.Marshal(query)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", esURL+"/"+esIndexV2+"/_search", bytes.NewReader(queryBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(esUser, esPassword)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("ES v2查询失败 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Hits struct {
|
||||||
|
Hits []struct {
|
||||||
|
Source struct {
|
||||||
|
ISBN string `json:"isbn"`
|
||||||
|
} `json:"_source"`
|
||||||
|
} `json:"hits"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
json.Unmarshal(body, &searchResp)
|
||||||
|
|
||||||
|
for _, hit := range searchResp.Hits.Hits {
|
||||||
|
result[hit.Source.ISBN] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func esBulkUpdateIsSuit(docs []ESDoc) (int, error) {
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, doc := range docs {
|
||||||
|
meta := fmt.Sprintf(`{"update":{"_index":"%s","_id":"%d"}}`, esIndexV3, doc.ID)
|
||||||
|
buf.WriteString(meta)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
buf.WriteString(`{"script":{"source":"ctx._source.is_suit=params.val","params":{"val":1}}}`)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", esURL+"/_bulk", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-ndjson")
|
||||||
|
req.SetBasicAuth(esUser, esPassword)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("ES bulk更新失败 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var bulkResp struct {
|
||||||
|
Items []struct {
|
||||||
|
Update struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
} `json:"update"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
json.Unmarshal(body, &bulkResp)
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
for _, item := range bulkResp.Items {
|
||||||
|
if item.Update.Status >= 200 && item.Update.Status < 300 {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func esBulkInsert(docs []ESDoc, index string) (int, error) {
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, doc := range docs {
|
||||||
|
meta := fmt.Sprintf(`{"index":{"_index":"%s","_id":"%d"}}`, index, doc.ID)
|
||||||
|
buf.WriteString(meta)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
docBytes, _ := json.Marshal(doc)
|
||||||
|
buf.Write(docBytes)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", esURL+"/_bulk", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-ndjson")
|
||||||
|
req.SetBasicAuth(esUser, esPassword)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("ES bulk失败 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var bulkResp struct {
|
||||||
|
Items []struct {
|
||||||
|
Index struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
} `json:"index"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
json.Unmarshal(body, &bulkResp)
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
for _, item := range bulkResp.Items {
|
||||||
|
if item.Index.Status >= 200 && item.Index.Status < 300 {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ MySQL批量操作 ============
|
||||||
|
|
||||||
|
func batchUpsertMySQL(db *sql.DB, rows []BookRow) (int, error) {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSQL := `REPLACE INTO suit_book_lib_merged
|
||||||
|
(kong_id, fid, isbn, book_name, subtitle, author, press, press_time, binding, price, img_url, edition, format, pages, word_count, category, create_time, update_time)
|
||||||
|
VALUES `
|
||||||
|
|
||||||
|
rowPlaceholder := "(?, 0, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
placeholders := make([]string, len(rows))
|
||||||
|
for i := range placeholders {
|
||||||
|
placeholders[i] = rowPlaceholder
|
||||||
|
}
|
||||||
|
fullSQL := baseSQL + strings.Join(placeholders, ", ")
|
||||||
|
|
||||||
|
args := make([]interface{}, 0, len(rows)*18)
|
||||||
|
for _, r := range rows {
|
||||||
|
args = append(args,
|
||||||
|
r.KongID, r.ISBN, r.BookName, r.Author, r.Press,
|
||||||
|
r.PressTime, r.Binding, r.Price, r.ImgURL,
|
||||||
|
r.Edition, r.Format, r.Pages, r.WordCount, r.Category,
|
||||||
|
r.CreateTime, r.UpdateTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.Exec(fullSQL, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
return int(affected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ NULL处理 ============
|
||||||
|
|
||||||
|
func nullStr(ns sql.NullString) string {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt64(ns sql.NullInt64) int64 {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.Int64
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullFloat64(ns sql.NullFloat64) float64 {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.Float64
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullTimeInterface(ns sql.NullTime) interface{} {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.Time.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 同步任务函数 ============
|
||||||
|
|
||||||
|
func runSync() error {
|
||||||
|
state := loadSyncState()
|
||||||
|
var sinceTime string
|
||||||
|
if state != nil && state.LastSyncTime != "" {
|
||||||
|
sinceTime = state.LastSyncTime
|
||||||
|
log.Printf("📅 增量同步:从 %s 开始", sinceTime)
|
||||||
|
} else {
|
||||||
|
log.Println("🚀 首次运行:全量同步")
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStart := time.Now()
|
||||||
|
|
||||||
|
srcDSN := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&timeout=60s&parseTime=true&readTimeout=300s",
|
||||||
|
srcUser, srcPassword, srcHost, srcPort, srcDatabase)
|
||||||
|
srcDB, err := sql.Open("mysql", srcDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接源库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer srcDB.Close()
|
||||||
|
srcDB.SetMaxOpenConns(2)
|
||||||
|
srcDB.SetMaxIdleConns(2)
|
||||||
|
|
||||||
|
dstDSN := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&timeout=60s&parseTime=true&writeTimeout=300s",
|
||||||
|
dstUser, dstPassword, dstHost, dstPort, dstDatabase)
|
||||||
|
dstDB, err := sql.Open("mysql", dstDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接目标库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer dstDB.Close()
|
||||||
|
dstDB.SetMaxOpenConns(10)
|
||||||
|
dstDB.SetMaxIdleConns(10)
|
||||||
|
|
||||||
|
if err := srcDB.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("源库连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ 源MySQL连接成功")
|
||||||
|
|
||||||
|
if err := dstDB.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("目标MySQL连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ 目标MySQL连接成功")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", esURL, nil)
|
||||||
|
req.SetBasicAuth(esUser, esPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("ES连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Println("✅ ES连接成功")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
b.id, b.isbn, b.book_name, b.author, b.press, b.press_time,
|
||||||
|
b.binding, b.price, b.img_url, b.create_time, b.update_time,
|
||||||
|
d.kong_id, d.edition, d.format, d.pages, d.word_count, d.category
|
||||||
|
FROM book_lib b
|
||||||
|
INNER JOIN book_lib_details d ON b.isbn = d.isbn
|
||||||
|
`
|
||||||
|
var rows *sql.Rows
|
||||||
|
if sinceTime != "" {
|
||||||
|
query += " WHERE b.update_time > ?"
|
||||||
|
rows, err = srcDB.Query(query, sinceTime)
|
||||||
|
} else {
|
||||||
|
rows, err = srcDB.Query(query)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询源库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
if sinceTime != "" {
|
||||||
|
log.Println("✅ 查询增量数据成功,开始同步 ...")
|
||||||
|
} else {
|
||||||
|
log.Println("✅ 查询全量数据成功,开始同步 ...")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMySQL := 0
|
||||||
|
totalESV3 := 0
|
||||||
|
totalIsSuit := 0
|
||||||
|
failCount := 0
|
||||||
|
batch := make([]BookRow, 0, batchSize)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id, kongID, pages, wordCount sql.NullInt64
|
||||||
|
isbn, bookName, author, press, binding, imgURL,
|
||||||
|
edition, format, category sql.NullString
|
||||||
|
price sql.NullFloat64
|
||||||
|
pressTime, createTime, updateTime sql.NullTime
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&id, &isbn, &bookName, &author, &press, &pressTime,
|
||||||
|
&binding, &price, &imgURL, &createTime, &updateTime,
|
||||||
|
&kongID, &edition, &format, &pages, &wordCount, &category,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
failCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
row := BookRow{
|
||||||
|
KongID: nullInt64(kongID),
|
||||||
|
ISBN: nullStr(isbn),
|
||||||
|
BookName: nullStr(bookName),
|
||||||
|
Author: nullStr(author),
|
||||||
|
Press: nullStr(press),
|
||||||
|
PressTime: nullTimeInterface(pressTime),
|
||||||
|
Binding: nullStr(binding),
|
||||||
|
Price: nullFloat64(price),
|
||||||
|
ImgURL: buildImgURLJSON(nullStr(imgURL)),
|
||||||
|
Edition: nullStr(edition),
|
||||||
|
Format: nullStr(format),
|
||||||
|
Pages: nullInt64(pages),
|
||||||
|
WordCount: nullInt64(wordCount),
|
||||||
|
Category: nullStr(category),
|
||||||
|
CreateTime: nullTimeInterface(createTime),
|
||||||
|
UpdateTime: nullTimeInterface(updateTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = append(batch, row)
|
||||||
|
|
||||||
|
if len(batch) >= batchSize {
|
||||||
|
mysqlAffected, err := batchUpsertMySQL(dstDB, batch)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("MySQL批量写入失败: %v", err)
|
||||||
|
}
|
||||||
|
totalMySQL += mysqlAffected
|
||||||
|
|
||||||
|
esDocsV3 := make([]ESDoc, len(batch))
|
||||||
|
isbns := make([]string, len(batch))
|
||||||
|
for i, r := range batch {
|
||||||
|
esDocsV3[i] = rowToESDoc(r)
|
||||||
|
isbns[i] = r.ISBN
|
||||||
|
}
|
||||||
|
v3Count, err := esBulkInsert(esDocsV3, esIndexV3)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ES v3 bulk失败: %v", err)
|
||||||
|
}
|
||||||
|
totalESV3 += v3Count
|
||||||
|
|
||||||
|
v2Matches, err := queryV2ByISBNs(isbns)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("查询v2失败: %v", err)
|
||||||
|
} else {
|
||||||
|
var needUpdate []ESDoc
|
||||||
|
for _, doc := range esDocsV3 {
|
||||||
|
if v2Matches[doc.ISBN] {
|
||||||
|
doc.IsSuit = 1
|
||||||
|
needUpdate = append(needUpdate, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(needUpdate) > 0 {
|
||||||
|
updateCount, err := esBulkUpdateIsSuit(needUpdate)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("更新is_suit失败: %v", err)
|
||||||
|
}
|
||||||
|
totalIsSuit += updateCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
log.Printf("MySQL: %d | ES-v3: %d | is_suit: %d | 耗时 %v",
|
||||||
|
totalMySQL, totalESV3, totalIsSuit, elapsed.Round(time.Second))
|
||||||
|
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(batch) > 0 {
|
||||||
|
mysqlAffected, _ := batchUpsertMySQL(dstDB, batch)
|
||||||
|
totalMySQL += mysqlAffected
|
||||||
|
|
||||||
|
esDocsV3 := make([]ESDoc, len(batch))
|
||||||
|
isbns := make([]string, len(batch))
|
||||||
|
for i, r := range batch {
|
||||||
|
esDocsV3[i] = rowToESDoc(r)
|
||||||
|
isbns[i] = r.ISBN
|
||||||
|
}
|
||||||
|
v3Count, _ := esBulkInsert(esDocsV3, esIndexV3)
|
||||||
|
totalESV3 += v3Count
|
||||||
|
|
||||||
|
v2Matches, _ := queryV2ByISBNs(isbns)
|
||||||
|
if v2Matches != nil {
|
||||||
|
var needUpdate []ESDoc
|
||||||
|
for _, doc := range esDocsV3 {
|
||||||
|
if v2Matches[doc.ISBN] {
|
||||||
|
doc.IsSuit = 1
|
||||||
|
needUpdate = append(needUpdate, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(needUpdate) > 0 {
|
||||||
|
updateCount, _ := esBulkUpdateIsSuit(needUpdate)
|
||||||
|
totalIsSuit += updateCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalElapsed := time.Since(startTime)
|
||||||
|
log.Printf("🎉 同步完成! MySQL: %d | ES v3: %d | is_suit: %d | 跳过: %d | 耗时 %v",
|
||||||
|
totalMySQL, totalESV3, totalIsSuit, failCount, totalElapsed.Round(time.Millisecond))
|
||||||
|
|
||||||
|
saveSyncState(syncStart)
|
||||||
|
log.Printf("📝 已记录同步时间点: %s", syncStart.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 主流程 ============
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 启动时先执行一次
|
||||||
|
log.Println("======== 启动同步任务 ========")
|
||||||
|
log.Println("任务计划:每天凌晨 01:00 执行")
|
||||||
|
log.Printf("当前时间:%s\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
// 先执行一次同步
|
||||||
|
if err := runSync(); err != nil {
|
||||||
|
log.Printf("❌ 同步任务执行失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✅ 启动同步完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算下一次执行时间(每天凌晨1点)
|
||||||
|
nextRun := func() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day()+1, 1, 0, 0, 0, now.Location())
|
||||||
|
if now.Hour() < 1 {
|
||||||
|
next = time.Date(now.Year(), now.Month(), now.Day(), 1, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTime := nextRun()
|
||||||
|
log.Printf("⏰ 下一次执行时间:%s (等待 %v)", nextTime.Format("2006-01-02 15:04:05"), time.Until(nextTime).Round(time.Second))
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
timer := time.NewTimer(time.Until(nextTime))
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
// 信号处理
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
log.Println("\n======== 开始执行定时同步 ========")
|
||||||
|
log.Printf("执行时间:%s", time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
if err := runSync(); err != nil {
|
||||||
|
log.Printf("❌ 同步任务执行失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✅ 定时同步完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算下一次执行时间
|
||||||
|
nextTime = nextRun()
|
||||||
|
log.Printf("⏰ 下一次执行时间:%s (等待 %v)", nextTime.Format("2006-01-02 15:04:05"), time.Until(nextTime).Round(time.Second))
|
||||||
|
timer.Reset(time.Until(nextTime))
|
||||||
|
|
||||||
|
case sig := <-sigChan:
|
||||||
|
log.Printf("\n⚠️ 收到信号 %v,程序退出", sig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
module batch
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.19.6
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||||
|
)
|
||||||
57
go.sum
Normal file
57
go.sum
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
||||||
|
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk=
|
||||||
|
github.com/elastic/go-elasticsearch/v8 v8.19.6/go.mod h1:jeWebApE1oFEW/hKZqx/IRYmP/aa2+WMJkOfk+AduSI=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||||
|
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||||
|
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||||
|
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||||
|
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
180
go_files_explanation.md
Normal file
180
go_files_explanation.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# Go 文件功能说明
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
该项目是一个 **Go 批处理工具集**,主要用于数据同步和迁移任务,涉及 **MySQL**、**Elasticsearch** 和 **Redis** 之间的数据传输。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件列表与功能
|
||||||
|
|
||||||
|
### 1. `main.go` — 项目入口(占位模板)
|
||||||
|
|
||||||
|
- **位置**:根目录
|
||||||
|
- **作用**:GoLand 自动生成的默认入口文件,仅包含一个 `Hello, world` 打印程序。
|
||||||
|
- **说明**:不属于实际的批处理系统,可忽略或用作文档占位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `util/dbConnectUtil/dbConnectUtil.go` — 数据库连接工具包
|
||||||
|
|
||||||
|
- **位置**:`util/dbConnectUtil/`
|
||||||
|
- **包名**:`dbConnectUtil`
|
||||||
|
- **作用**:提供可复用的 MySQL 数据库连接初始化函数。
|
||||||
|
- **核心功能**:
|
||||||
|
- `InitDB(username, password, host, port)` — 创建并测试 MySQL 连接
|
||||||
|
- 配置连接池参数(最大打开 20 个、最大空闲 10 个)
|
||||||
|
- 返回全局变量 `DB`(`*sql.DB`)
|
||||||
|
- **被依赖**:`shop_mysql_to_redis/` 下的两个子项目都导入此包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `addSyncSuitBook/main.go` — 图书数据同步工具
|
||||||
|
|
||||||
|
- **位置**:`addSyncSuitBook/`
|
||||||
|
- **包名**:`main`(独立可执行程序)
|
||||||
|
- **作用**:将源 MySQL 中的图书数据同步到目标 MySQL 和 Elasticsearch。
|
||||||
|
- **核心功能**:
|
||||||
|
- **数据源**:源库 `book_lib` JOIN `book_lib_details`(IP: `36.134.185.139:6603`)
|
||||||
|
- **目标 MySQL**:写入 `suit_book_lib_merged` 表(IP: `175.27.224.66:3306`)
|
||||||
|
- **目标 ES**:写入 `books-from-mysql-v3` 索引(IP: `36.212.12.92:9527`)
|
||||||
|
- **同步模式**:全量同步 / 增量同步(基于 `update_time` 记录上次同步时间到 `sync_state.json`)
|
||||||
|
- **套装书标记**:查询 v2 索引中已存在的 ISBN,在 v3 索引中通过 script 更新 `is_suit = 1`
|
||||||
|
- **执行计划**:程序启动时立即执行一次,之后每天凌晨 01:00 自动执行
|
||||||
|
- **批量操作**:MySQL 使用 `REPLACE INTO`;ES 使用 `_bulk` API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `addSyncPddReject/main.go` — 拼多多违规商品迁移工具
|
||||||
|
|
||||||
|
- **位置**:`addSyncPddReject/`
|
||||||
|
- **包名**:`main`(独立可执行程序)
|
||||||
|
- **作用**:将主库中因"涉嫌违规"被驳回的商品 ISBN 迁移到从库的过滤表中。
|
||||||
|
- **核心功能**:
|
||||||
|
- **源库**:查 `shop_goods_rejection` 表(原因:`'您发布的商品涉嫌违规,请您检查相关商品并立即整改'`)
|
||||||
|
- **目标库**:写入 `t_filter_set` 表(`limitation_type = '0'` 表示 ISBN 过滤)
|
||||||
|
- **去重逻辑**:先查询目标表已有 ISBN,仅插入不重复的新数据
|
||||||
|
- **ID 生成**:雪花算法(Snowflake)生成 19 位分布式 ID
|
||||||
|
- **批量插入**:每批 100 条,带进度打印
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `goods_es_to_redis/main.go` — ES 到 Redis 全量同步工具
|
||||||
|
|
||||||
|
- **位置**:`goods_es_to_redis/`
|
||||||
|
- **包名**:`main`(独立可执行程序)
|
||||||
|
- **作用**:从 Elasticsearch `books-from-mysql-v2` 索引全量拉取图书数据,写入 Redis DB 1。
|
||||||
|
- **核心功能**:
|
||||||
|
- **数据读取**:使用 ES Scroll API 全量遍历(每批 10,000 条)
|
||||||
|
- **并发模型**:
|
||||||
|
- 8 个 ES 读取协程(m 并行处理 scroll 分页)
|
||||||
|
- 1 个批处理协程(聚合成 500 条/批)
|
||||||
|
- 32 个 Redis 写入协程(使用 Pipeline 批量 SET)
|
||||||
|
- **数据转换**:
|
||||||
|
- 自定义 `FlexInt64` JSON 反序列化(兼容数字/字符串)
|
||||||
|
- 自定义 `StringOrArray` 类型(兼容字符串/数组)
|
||||||
|
- 分类 ID 清洗:`A > B > C` → `A/B/C`
|
||||||
|
- 出版时间时间戳转换
|
||||||
|
- 图片信息聚合(轮播图、白底图、详情图、目录图等)
|
||||||
|
- **Redis 结构**:`key = ISBN`,`value = BookInfo JSON`(book_name/author/publisher/price/images/category 等)
|
||||||
|
- **数据校验**:同步完成后用 SCAN 命令校验 Redis 中的 key 数量是否与 ES 文档数一致
|
||||||
|
- **进度报告**:每 5 秒打印一次同步进度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `shop_mysql_to_redis/CameiCase/mysqlToRedisCameiCase.go` — 店铺数据迁移(驼峰版)
|
||||||
|
|
||||||
|
- **位置**:`shop_mysql_to_redis/CameiCase/`
|
||||||
|
- **包名**:`main`(独立可执行程序)
|
||||||
|
- **作用**:将 MySQL 中的店铺相关数据迁移到 Redis,字段名为**驼峰命名**。
|
||||||
|
- **核心功能**:
|
||||||
|
- **查询数据**:遍历 `t_shop_detail` 中的所有 `shop_id`
|
||||||
|
- **关联表**:
|
||||||
|
- `t_shop` — 店铺主表
|
||||||
|
- `t_shop_detail` — 店铺详情
|
||||||
|
- `t_shop_context` — 店铺上下文(商品描述)
|
||||||
|
- `t_spec` — 规格设置
|
||||||
|
- `t_price_template` — 价格模板(根据 `sale_template_id` 关联)
|
||||||
|
- **数据格式**:
|
||||||
|
- 每条记录以 `{"source_table": "表名", "data": {字段: 值...}}` 格式存入 Redis
|
||||||
|
- 字段名通过 `snakeToCamel()` 转为驼峰(如 `shop_id` → `shopId`)
|
||||||
|
- 价格模板的 `rangePrice` 字段做浮点精度修复
|
||||||
|
- **Redis 结构**:`key = shopID`,`value = List`,每元素为一条 JSON 记录
|
||||||
|
- **数据库连接**:使用 `dbConnectUtil` 工具包
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `shop_mysql_to_redis/SanckCase/mysqlToRedis.go` — 店铺数据迁移(下划线版,优化版)
|
||||||
|
|
||||||
|
- **位置**:`shop_mysql_to_redis/SanckCase/`
|
||||||
|
- **包名**:`main`(独立可执行程序)
|
||||||
|
- **作用**:与驼峰版类似,但字段名为**下划线命名**,且架构更完善。
|
||||||
|
- **增强功能**:
|
||||||
|
- **并发处理**:3 个 Worker 并发处理店铺,每个店铺间 sleep 100ms 限流
|
||||||
|
- **循环执行**:每 5~10 分钟自动重跑(根据耗时动态调整间隔)
|
||||||
|
- **优雅退出**:监听 `SIGINT`/`SIGTERM` 信号,打印运行次数后退出
|
||||||
|
- **字段标准化**(`normalizeFieldValue`):
|
||||||
|
- ID 类字段(`id`, `*_id`, `create_by`, `update_by`)→ 字符串
|
||||||
|
- 时间字段(`create_time`, `update_time`, `add_time`, `expiration_time`)→ `"2006-01-02 15:04:05"` 格式
|
||||||
|
- `mall_id`、`district_id` 保持 `int64` 数字
|
||||||
|
- **图片处理**:从 `t_shop_img` 表查询图片,按 type 分类:
|
||||||
|
- `type=1` → 水印图
|
||||||
|
- `type=3/4` → 详情首/尾图
|
||||||
|
- `type=5` → 末尾轮播图
|
||||||
|
- `type=7` → SKU 水印图
|
||||||
|
- **价格处理**:
|
||||||
|
- `range_price` 字段做浮点精度修复
|
||||||
|
- `add_amount` 字段 `×100` 转为 `int64`(分单位)
|
||||||
|
- **Redis 写入**:使用 Pipeline 批量操作(先 DEL 旧 key,再 RPUSH 所有记录)
|
||||||
|
- **数据库连接**:使用 `dbConnectUtil` 工具包
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流向总图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ addSyncSuitBook │
|
||||||
|
│ MySQL (36.134.185.139:6603) │
|
||||||
|
│ book_lib + book_lib_details │
|
||||||
|
│ ┌──────────┴──────────┐ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ MySQL (175.27.224.66) ES (36.212.12.92:9527) │
|
||||||
|
│ suit_book_lib_merged books-from-mysql-v3 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ addSyncPddReject │
|
||||||
|
│ MySQL Master (146.56.227.42:3306) │
|
||||||
|
│ shop_goods_rejection │
|
||||||
|
│ ▼ │
|
||||||
|
│ MySQL Slave (36.212.12.247:3306) │
|
||||||
|
│ t_filter_set │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ goods_es_to_redis │
|
||||||
|
│ ES (36.212.12.92:9527) │
|
||||||
|
│ books-from-mysql-v2 │
|
||||||
|
│ ▼ │
|
||||||
|
│ Redis (36.212.12.247:6379 DB 1) │
|
||||||
|
│ key=ISBN → BookInfo JSON │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ shop_mysql_to_redis │
|
||||||
|
│ MySQL (146.56.227.42:3306) │
|
||||||
|
│ t_shop / t_shop_detail / t_shop_context │
|
||||||
|
│ t_spec / t_price_template / t_shop_img │
|
||||||
|
│ ▼ │
|
||||||
|
│ Redis (CameiCase: 36.212.20.113:7963 DB 7) │
|
||||||
|
│ (SanckCase: 36.212.12.247:6379 DB 8) │
|
||||||
|
│ key=shopID → List of JSON records │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
- `dbConnectUtil` 被 `shop_mysql_to_redis/` 下的两个子项目导入
|
||||||
|
- 所有可执行程序均为 `package main`,各自独立运行
|
||||||
|
- 外部依赖:`go-sql-driver/mysql`、`go-elasticsearch`、`go-redis`
|
||||||
698
goods_es_to_redis/main.go
Normal file
698
goods_es_to_redis/main.go
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/elastic/go-elasticsearch/v8"
|
||||||
|
"github.com/elastic/go-elasticsearch/v8/esapi"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
esAddress = "http://36.212.12.92:9527"
|
||||||
|
esUsername = "elastic"
|
||||||
|
esPassword = "+Tz5qR_KushZ-bPgZ_H-"
|
||||||
|
|
||||||
|
redisAddr = "36.212.12.247:6379"
|
||||||
|
redisPassword = "long6166@@"
|
||||||
|
redisDB = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ================= Client ================= */
|
||||||
|
|
||||||
|
type ESClient struct {
|
||||||
|
client *elasticsearch.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewESClient(addresses []string, username, password string) (*ESClient, error) {
|
||||||
|
cfg := elasticsearch.Config{
|
||||||
|
Addresses: addresses,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
MaxIdleConnsPerHost: 100,
|
||||||
|
ResponseHeaderTimeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cli, err := elasticsearch.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ESClient{client: cli}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisClient struct {
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisClient(addr, password string, db int) (*RedisClient, error) {
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: password,
|
||||||
|
DB: db,
|
||||||
|
PoolSize: 100,
|
||||||
|
DialTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解决 Redis MISCONF 错误:禁用 bgsave 错误时的写入限制
|
||||||
|
if err := rdb.ConfigSet(ctx, "stop-writes-on-bgsave-error", "no").Err(); err != nil {
|
||||||
|
log.Printf("警告:无法设置 Redis 配置 stop-writes-on-bgsave-error=no: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("已设置 Redis 配置:stop-writes-on-bgsave-error=no")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RedisClient{client: rdb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Data ================= */
|
||||||
|
|
||||||
|
type BookPic struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookPicS struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
PddResponse string `json:"pddResponse"`
|
||||||
|
Localh string `json:"localh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookDetailImage struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookDirectoryImage struct {
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
PddPath string `json:"pddPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESCatIdObject struct {
|
||||||
|
PinDuoDuoCatId string `json:"pin_duo_duo_cat_id"`
|
||||||
|
KongFuZiCatId string `json:"kong_fu_zi_cat_id"`
|
||||||
|
XianYuCatId string `json:"xian_yu_cat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookData struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
BookName StringOrArray `json:"book_name"`
|
||||||
|
BookPic BookPic `json:"book_pic"`
|
||||||
|
BookDefPic BookPic `json:"book_def_pic"`
|
||||||
|
BookPicS BookPicS `json:"book_pic_s"`
|
||||||
|
BookPicObj string `json:"book_pic_obj"`
|
||||||
|
BookDetailImage BookDetailImage `json:"book_detail_image"`
|
||||||
|
BookPicB string `json:"book_pic_b"`
|
||||||
|
BookDirectoryImage BookDirectoryImage `json:"book_directory_image"`
|
||||||
|
ISBN string `json:"isbn"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
PublicationTime string `json:"publication_time"`
|
||||||
|
BindingLayout string `json:"binding_layout"`
|
||||||
|
FixPrice FlexInt64 `json:"fix_price"`
|
||||||
|
PageCount StringOrArray `json:"page_count"`
|
||||||
|
WordCount StringOrArray `json:"word_count"`
|
||||||
|
CatId ESCatIdObject `json:"cat_id"`
|
||||||
|
IsSuit int64 `json:"is_suit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ESResponse struct {
|
||||||
|
ScrollID string `json:"_scroll_id"`
|
||||||
|
Hits struct {
|
||||||
|
Total struct {
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
} `json:"total"`
|
||||||
|
Hits []struct {
|
||||||
|
Source BookData `json:"_source"`
|
||||||
|
} `json:"hits"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Custom Types ================= */
|
||||||
|
|
||||||
|
type StringOrArray string
|
||||||
|
|
||||||
|
type FlexInt64 int64
|
||||||
|
|
||||||
|
func (f *FlexInt64) UnmarshalJSON(data []byte) error {
|
||||||
|
// 尝试直接解析为数字
|
||||||
|
var num int64
|
||||||
|
if err := json.Unmarshal(data, &num); err == nil {
|
||||||
|
*f = FlexInt64(num)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 尝试解析为字符串
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err == nil {
|
||||||
|
if str == "" {
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if val, err := strconv.ParseInt(str, 10, 64); err == nil {
|
||||||
|
*f = FlexInt64(val)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if val, err := strconv.ParseFloat(str, 64); err == nil {
|
||||||
|
*f = FlexInt64(int64(val))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 默认为0
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
|
||||||
|
if len(data) > 0 && data[0] == '"' {
|
||||||
|
var str string
|
||||||
|
_ = json.Unmarshal(data, &str)
|
||||||
|
*s = StringOrArray(str)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if json.Unmarshal(data, &arr) == nil && len(arr) > 0 {
|
||||||
|
*s = StringOrArray(arr[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Float64OrString float64
|
||||||
|
|
||||||
|
func (f *Float64OrString) UnmarshalJSON(data []byte) error {
|
||||||
|
var n float64
|
||||||
|
if json.Unmarshal(data, &n) == nil {
|
||||||
|
*f = Float64OrString(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(data, &s) == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||||
|
*f = Float64OrString(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Main ================= */
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.Println("开始全量同步 ES → Redis(强制全量写入)")
|
||||||
|
|
||||||
|
esClient, err := NewESClient([]string{esAddress}, esUsername, esPassword)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
redisClient, err := NewRedisClient(redisAddr, redisPassword, redisDB)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer redisClient.client.Close()
|
||||||
|
|
||||||
|
// 执行同步并获取总数
|
||||||
|
totalDocs, err := fetchAndWriteToRedis(context.Background(), esClient, redisClient)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据完整性
|
||||||
|
verifyDataCount(context.Background(), redisClient, totalDocs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndWriteToRedis(ctx context.Context, esClient *ESClient, redisClient *RedisClient) (int64, error) {
|
||||||
|
// 详细统计变量
|
||||||
|
var (
|
||||||
|
readCount int64 // 从ES读取的总文档数
|
||||||
|
skipEmptyIsbn int64 // ISBN为空跳过的数量
|
||||||
|
convertFailed int64 // 转换或序列化失败的数量
|
||||||
|
writeSuccess int64 // 实际写入Redis成功的数量
|
||||||
|
writeFailed int64 // 写入失败的数量
|
||||||
|
sentToBatchChan int64 // 发送到batchChan的文档总数
|
||||||
|
receivedByWorkers int64 // 写入协程接收到的文档总数
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
batchSize = 500
|
||||||
|
esWorkerCount = 8
|
||||||
|
redisWorkerCount = 32
|
||||||
|
bookChanSize = 10000
|
||||||
|
batchChanSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
bookChan := make(chan BookData, bookChanSize)
|
||||||
|
batchChan := make(chan []BookData, batchChanSize)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// ================= Redis批量写入协程 =================
|
||||||
|
for i := 0; i < redisWorkerCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for batch := range batchChan {
|
||||||
|
atomic.AddInt64(&receivedByWorkers, int64(len(batch)))
|
||||||
|
|
||||||
|
// 准备要写入的键值对
|
||||||
|
type kv struct {
|
||||||
|
key string
|
||||||
|
val []byte
|
||||||
|
}
|
||||||
|
kvs := make([]kv, 0, len(batch))
|
||||||
|
|
||||||
|
for _, book := range batch {
|
||||||
|
// 跳过空ISBN
|
||||||
|
if book.ISBN == "" {
|
||||||
|
atomic.AddInt64(&skipEmptyIsbn, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全转换(带panic恢复)
|
||||||
|
bookInfo, err := safeConvertBookData(book)
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&convertFailed, 1)
|
||||||
|
log.Printf("Worker %d 转换失败 ISBN=%s: %v", workerID, book.ISBN, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(bookInfo)
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&convertFailed, 1)
|
||||||
|
log.Printf("Worker %d JSON序列化失败 ISBN=%s: %v", workerID, book.ISBN, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kvs = append(kvs, kv{key: book.ISBN, val: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(kvs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用Pipeline批量写入,并逐个检查结果
|
||||||
|
pipe := redisClient.client.Pipeline()
|
||||||
|
cmds := make([]*redis.StatusCmd, 0, len(kvs))
|
||||||
|
for _, kv := range kvs {
|
||||||
|
cmd := pipe.Set(ctx, kv.key, kv.val, 0)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行Pipeline
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
// Pipeline整体失败(如网络错误),整批标记为失败
|
||||||
|
atomic.AddInt64(&writeFailed, int64(len(kvs)))
|
||||||
|
log.Printf("Worker %d Pipeline执行失败: %v, 影响 %d 条", workerID, err, len(kvs))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐个检查每条命令的结果
|
||||||
|
successInBatch := 0
|
||||||
|
for i, cmd := range cmds {
|
||||||
|
if cmd.Err() != nil {
|
||||||
|
atomic.AddInt64(&writeFailed, 1)
|
||||||
|
log.Printf("Worker %d 单条写入失败 key=%s: %v", workerID, kvs[i].key, cmd.Err())
|
||||||
|
} else {
|
||||||
|
successInBatch++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&writeSuccess, int64(successInBatch))
|
||||||
|
if successInBatch < len(kvs) {
|
||||||
|
log.Printf("Worker %d 批次部分失败: 预期 %d, 成功 %d", workerID, len(kvs), successInBatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Redis写入协程 %d 退出,累计接收文档数 %d", workerID, atomic.LoadInt64(&receivedByWorkers))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 批处理协程 =================
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
var batch []BookData
|
||||||
|
batchSeq := 0
|
||||||
|
for book := range bookChan {
|
||||||
|
batch = append(batch, book)
|
||||||
|
if len(batch) >= batchSize {
|
||||||
|
batchSeq++
|
||||||
|
atomic.AddInt64(&sentToBatchChan, int64(len(batch)))
|
||||||
|
batchChan <- batch
|
||||||
|
batch = []BookData{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理剩余数据
|
||||||
|
if len(batch) > 0 {
|
||||||
|
batchSeq++
|
||||||
|
atomic.AddInt64(&sentToBatchChan, int64(len(batch)))
|
||||||
|
batchChan <- batch
|
||||||
|
}
|
||||||
|
close(batchChan)
|
||||||
|
log.Printf("批处理协程退出,共发送 %d 个批次,总文档数 %d", batchSeq, atomic.LoadInt64(&sentToBatchChan))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ================= ES 初始查询 =================
|
||||||
|
size := 10000
|
||||||
|
req := esapi.SearchRequest{
|
||||||
|
Index: []string{"books-from-mysql-v2"},
|
||||||
|
Size: &size,
|
||||||
|
Scroll: time.Minute,
|
||||||
|
}
|
||||||
|
res, err := req.Do(ctx, esClient.client)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("初始查询失败: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var esResp ESResponse
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&esResp); err != nil {
|
||||||
|
return 0, fmt.Errorf("解析初始响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDocs := esResp.Hits.Total.Value
|
||||||
|
log.Printf("ES 总文档数: %d", totalDocs)
|
||||||
|
|
||||||
|
scrollID := esResp.ScrollID
|
||||||
|
defer clearScroll(ctx, esClient, scrollID)
|
||||||
|
|
||||||
|
// ================= 进度报告协程 =================
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
read := atomic.LoadInt64(&readCount)
|
||||||
|
skip := atomic.LoadInt64(&skipEmptyIsbn)
|
||||||
|
convFail := atomic.LoadInt64(&convertFailed)
|
||||||
|
writeOK := atomic.LoadInt64(&writeSuccess)
|
||||||
|
writeErr := atomic.LoadInt64(&writeFailed)
|
||||||
|
sent := atomic.LoadInt64(&sentToBatchChan)
|
||||||
|
recv := atomic.LoadInt64(&receivedByWorkers)
|
||||||
|
progress := float64(read) / float64(totalDocs) * 100
|
||||||
|
log.Printf("进度: %.2f%% | 已读: %d | 跳过空ISBN: %d | 转换失败: %d | 写入成功: %d | 写入失败: %d | 发送到批处理: %d | 写入协程接收: %d",
|
||||||
|
progress, read, skip, convFail, writeOK, writeErr, sent, recv)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ================= 处理初始结果 =================
|
||||||
|
for _, hit := range esResp.Hits.Hits {
|
||||||
|
bookChan <- hit.Source
|
||||||
|
atomic.AddInt64(&readCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 并行处理ES数据转换 =================
|
||||||
|
var processWg sync.WaitGroup
|
||||||
|
processChan := make(chan []BookData, esWorkerCount*2)
|
||||||
|
|
||||||
|
for i := 0; i < esWorkerCount; i++ {
|
||||||
|
processWg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer processWg.Done()
|
||||||
|
for books := range processChan {
|
||||||
|
for _, book := range books {
|
||||||
|
bookChan <- book
|
||||||
|
atomic.AddInt64(&readCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("ES处理协程 %d 退出", workerID)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= Scroll 循环拉取剩余数据 =================
|
||||||
|
currentScrollID := scrollID
|
||||||
|
batchCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
scrollReq := esapi.ScrollRequest{
|
||||||
|
ScrollID: currentScrollID,
|
||||||
|
Scroll: time.Minute,
|
||||||
|
}
|
||||||
|
scrollRes, err := scrollReq.Do(ctx, esClient.client)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Scroll请求失败: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollResp ESResponse
|
||||||
|
if err := json.NewDecoder(scrollRes.Body).Decode(&scrollResp); err != nil {
|
||||||
|
scrollRes.Body.Close()
|
||||||
|
log.Printf("解析Scroll响应失败: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
scrollRes.Body.Close()
|
||||||
|
|
||||||
|
if len(scrollResp.Hits.Hits) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新scrollID
|
||||||
|
currentScrollID = scrollResp.ScrollID
|
||||||
|
|
||||||
|
// 收集本批数据
|
||||||
|
books := make([]BookData, 0, len(scrollResp.Hits.Hits))
|
||||||
|
for _, hit := range scrollResp.Hits.Hits {
|
||||||
|
books = append(books, hit.Source)
|
||||||
|
}
|
||||||
|
processChan <- books
|
||||||
|
batchCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭处理通道,等待所有处理协程完成
|
||||||
|
close(processChan)
|
||||||
|
processWg.Wait()
|
||||||
|
close(bookChan)
|
||||||
|
wg.Wait()
|
||||||
|
ticker.Stop()
|
||||||
|
|
||||||
|
// ================= 最终统计 =================
|
||||||
|
log.Printf("同步完成汇总: 总读取=%d, 发送到批处理=%d, 写入协程接收=%d, 跳过空ISBN=%d, 转换失败=%d, 写入成功=%d, 写入失败=%d",
|
||||||
|
readCount, sentToBatchChan, receivedByWorkers, skipEmptyIsbn, convertFailed, writeSuccess, writeFailed)
|
||||||
|
|
||||||
|
// 检查数据完整性
|
||||||
|
if writeSuccess+writeFailed+skipEmptyIsbn+convertFailed != readCount {
|
||||||
|
log.Printf("⚠️ 统计不一致: 写入成功(%d)+失败(%d)+跳过(%d)+转换失败(%d)=%d, 但读取总数=%d",
|
||||||
|
writeSuccess, writeFailed, skipEmptyIsbn, convertFailed,
|
||||||
|
writeSuccess+writeFailed+skipEmptyIsbn+convertFailed, readCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDocs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanKongFuZiCatId 清理孔夫子分类ID:将 > 替换为 /,并去除各段两端的空格
|
||||||
|
func cleanKongFuZiCatId(raw string) string {
|
||||||
|
parts := strings.Split(raw, ">")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(p)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeConvertBookData 带 panic 恢复的转换函数
|
||||||
|
func safeConvertBookData(bookData BookData) (bookInfo BookInfo, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic in convertBookDataToBookInfoSafe: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return convertBookDataToBookInfoSafe(bookData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全的转换函数,返回error
|
||||||
|
func convertBookDataToBookInfoSafe(bookData BookData) (BookInfo, error) {
|
||||||
|
// 检查必要字段
|
||||||
|
if bookData.ISBN == "" {
|
||||||
|
return BookInfo{}, fmt.Errorf("ISBN为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bookInfo := BookInfo{
|
||||||
|
Isbn: bookData.ISBN,
|
||||||
|
BookName: string(bookData.BookName),
|
||||||
|
Author: bookData.Author,
|
||||||
|
Publishing: bookData.Publisher,
|
||||||
|
PublicationDate: bookData.PublicationTime,
|
||||||
|
Binding: bookData.BindingLayout,
|
||||||
|
Format: 0,
|
||||||
|
CatIdObject: CatIdObject{
|
||||||
|
PinDuoDuoCatId: bookData.CatId.PinDuoDuoCatId,
|
||||||
|
KongFuZiCatId: cleanKongFuZiCatId(bookData.CatId.KongFuZiCatId),
|
||||||
|
XianYuCatId: bookData.CatId.XianYuCatId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页数转换
|
||||||
|
if pageCountStr := strings.TrimSpace(string(bookData.PageCount)); pageCountStr != "" {
|
||||||
|
if pageCount, err := strconv.ParseInt(pageCountStr, 10, 64); err == nil {
|
||||||
|
bookInfo.PagesCount = pageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字数转换
|
||||||
|
if wordCountStr := strings.TrimSpace(string(bookData.WordCount)); wordCountStr != "" {
|
||||||
|
if wordCount, err := strconv.ParseInt(wordCountStr, 10, 64); err == nil {
|
||||||
|
bookInfo.WordsCount = wordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出版时间转换(修复逻辑:支持多种格式)
|
||||||
|
if publicationTimeStr := strings.TrimSpace(bookData.PublicationTime); publicationTimeStr != "" {
|
||||||
|
publicationTime, err := strconv.ParseInt(publicationTimeStr, 10, 64)
|
||||||
|
publicationTime = publicationTime - 5364000000
|
||||||
|
timestamp, err := strconv.ParseInt(strconv.FormatInt(publicationTime, 10), 10, 64)
|
||||||
|
//fmt.Println(timestamp)
|
||||||
|
if err == nil {
|
||||||
|
var t time.Time
|
||||||
|
// 判断是秒级还是毫秒级时间戳(假设大于 1e9 的是毫秒级)
|
||||||
|
//if timestamp > 1e12 { // 毫秒级
|
||||||
|
// t = time.UnixMilli(timestamp)
|
||||||
|
//} else { // 秒级
|
||||||
|
t = time.Unix(timestamp, 0)
|
||||||
|
//}
|
||||||
|
|
||||||
|
bookInfo.PublicationDate = t.Format("2006-01")
|
||||||
|
|
||||||
|
//fmt.Println("=======PublicationDate", bookInfo.PublicationDate, bookData.ISBN)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 转换失败,赋值为空字符串
|
||||||
|
bookInfo.PublicationDate = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 价格
|
||||||
|
bookInfo.Price = int64(bookData.FixPrice)
|
||||||
|
|
||||||
|
// 构建图片对象
|
||||||
|
imageObject := ImageObject{
|
||||||
|
CarouselUrlArray: []string{},
|
||||||
|
DetailUrlObject: DetailImageObject{
|
||||||
|
IntroductionUrl: []string{},
|
||||||
|
CatalogueUrl: []string{},
|
||||||
|
LiveShootingUrl: []string{},
|
||||||
|
OtherUrl: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if bookData.BookPic.PddPath != "" {
|
||||||
|
imageObject.CarouselUrlArray = append(imageObject.CarouselUrlArray, bookData.BookPic.PddPath)
|
||||||
|
}
|
||||||
|
if bookData.BookDefPic.PddPath != "" {
|
||||||
|
imageObject.DefaultImageUrl = bookData.BookDefPic.PddPath
|
||||||
|
}
|
||||||
|
if bookData.BookPicS.PddResponse != "" {
|
||||||
|
imageObject.WhiteBackgroundUrl = bookData.BookPicS.PddResponse
|
||||||
|
}
|
||||||
|
if bookData.BookDetailImage.PddPath != "" {
|
||||||
|
imageObject.DetailUrlObject.IntroductionUrl = append(imageObject.DetailUrlObject.IntroductionUrl, bookData.BookDetailImage.PddPath)
|
||||||
|
}
|
||||||
|
if bookData.BookDirectoryImage.PddPath != "" {
|
||||||
|
imageObject.DetailUrlObject.CatalogueUrl = append(imageObject.DetailUrlObject.CatalogueUrl, bookData.BookDirectoryImage.PddPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookInfo.ImageObject = &imageObject
|
||||||
|
return bookInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据完整性
|
||||||
|
func verifyDataCount(ctx context.Context, redisClient *RedisClient, expectedCount int64) {
|
||||||
|
log.Println("开始验证数据完整性...")
|
||||||
|
|
||||||
|
var cursor uint64
|
||||||
|
var totalCount int64
|
||||||
|
var batchSize int64 = 1000
|
||||||
|
|
||||||
|
for {
|
||||||
|
var keys []string
|
||||||
|
var err error
|
||||||
|
keys, cursor, err = redisClient.client.Scan(ctx, cursor, "*", batchSize).Result()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Scan失败: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount += int64(len(keys))
|
||||||
|
log.Printf("Scan进度: 已获取 %d 个key", totalCount)
|
||||||
|
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("验证结果 - ES文档数: %d, Redis存储数: %d", expectedCount, totalCount)
|
||||||
|
|
||||||
|
if totalCount == expectedCount {
|
||||||
|
log.Println("✅ 数据完整性验证通过,所有数据已完整写入")
|
||||||
|
} else if totalCount < expectedCount {
|
||||||
|
log.Printf("⚠️ 数据不完整,缺失 %d 条", expectedCount-totalCount)
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Redis数据多于ES,多出 %d 条", totalCount-expectedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearScroll(ctx context.Context, esClient *ESClient, scrollID string) {
|
||||||
|
if scrollID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := esapi.ClearScrollRequest{ScrollID: []string{scrollID}}
|
||||||
|
res, err := req.Do(ctx, esClient.client)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("清除Scroll失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
log.Println("Scroll已清除")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookInfo Redis中存储的书籍信息结构
|
||||||
|
type BookInfo struct {
|
||||||
|
Isbn string `json:"isbn"` // ISBN
|
||||||
|
BookName string `json:"book_name"` // 书名
|
||||||
|
Author string `json:"author"` // 作者
|
||||||
|
Publishing string `json:"publishing"` // 出版社
|
||||||
|
PublicationDate string `json:"publication_date"` // 出版时间
|
||||||
|
Binding string `json:"binding"` // 装帧
|
||||||
|
PagesCount int64 `json:"pages_count"` // 页数
|
||||||
|
WordsCount int64 `json:"words_count"` // 字数
|
||||||
|
Format int64 `json:"format"` // 开本
|
||||||
|
ImageObject *ImageObject `json:"image_object"` // 图片
|
||||||
|
Price int64 `json:"price"` // 售价(分)
|
||||||
|
CatIdObject CatIdObject `json:"cat_id"` // 分类
|
||||||
|
IsSuit int64 `json:"is_suit"` // 套装书
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageObject 图片对象结构
|
||||||
|
type ImageObject struct {
|
||||||
|
CarouselUrlArray []string `json:"carousel_url_array"` // 轮播图
|
||||||
|
WhiteBackgroundUrl string `json:"white_background_url"` // 白底图
|
||||||
|
DetailUrlObject DetailImageObject `json:"detail_url_object"` // 详情对象
|
||||||
|
DefaultImageUrl string `json:"default_image_url"` // 默认图
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailImageObject 详情图片对象结构
|
||||||
|
type DetailImageObject struct {
|
||||||
|
IntroductionUrl []string `json:"introduction_url"` // 简介图
|
||||||
|
CatalogueUrl []string `json:"catalogue_url"` // 目录图
|
||||||
|
LiveShootingUrl []string `json:"live_shooting_url"` // 实拍图
|
||||||
|
OtherUrl []string `json:"other_url"` // 其他图
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatIdObject 分类ID对象
|
||||||
|
type CatIdObject struct {
|
||||||
|
PinDuoDuoCatId string `json:"pin_duo_duo_cat_id"` // 拼多多分类 ID
|
||||||
|
KongFuZiCatId string `json:"kong_fu_zi_cat_id"` // 孔夫子分类 ID
|
||||||
|
XianYuCatId string `json:"xian_yu_cat_id"` // 闲鱼分类 ID
|
||||||
|
}
|
||||||
21
main.go
Normal file
21
main.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
//TIP <p>To run your code, right-click the code and select <b>Run</b>.</p> <p>Alternatively, click
|
||||||
|
// the <icon src="AllIcons.Actions.Execute"/> icon in the gutter and select the <b>Run</b> menu item from here.</p>
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
//TIP <p>Press <shortcut actionId="ShowIntentionActions"/> when your caret is at the underlined text
|
||||||
|
// to see how GoLand suggests fixing the warning.</p><p>Alternatively, if available, click the lightbulb to view possible fixes.</p>
|
||||||
|
s := "gopher"
|
||||||
|
fmt.Printf("Hello and welcome, %s!\n", s)
|
||||||
|
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
//TIP <p>To start your debugging session, right-click your code in the editor and select the Debug option.</p> <p>We have set one <icon src="AllIcons.Debugger.Db_set_breakpoint"/> breakpoint
|
||||||
|
// for you, but you can always add more by pressing <shortcut actionId="ToggleLineBreakpoint"/>.</p>
|
||||||
|
fmt.Println("i =", 100/i)
|
||||||
|
}
|
||||||
|
}
|
||||||
539
shop_mysql_to_redis/CameiCase/mysqlToRedisCameiCase.go
Normal file
539
shop_mysql_to_redis/CameiCase/mysqlToRedisCameiCase.go
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"batch/util/dbConnectUtil"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 嵌套的JSON数据结构
|
||||||
|
type ShopDataRecord struct {
|
||||||
|
SourceTable string `json:"source_table"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 价格范围项结构
|
||||||
|
type PriceRangeItem struct {
|
||||||
|
MinPrice interface{} `json:"minPrice"` // 可以是数字或字符串
|
||||||
|
MaxPrice interface{} `json:"maxPrice"` // 可以是数字或字符串
|
||||||
|
AdjustPercent interface{} `json:"adjustPercent"` // 可以是数字或字符串
|
||||||
|
AdjustAmount interface{} `json:"adjustAmount"` // 可以是数字或字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库配置
|
||||||
|
func main() {
|
||||||
|
// 配置数据库连接
|
||||||
|
db, err := dbConnectUtil.InitDB("zhishu", "XsRR4K3ATizyc5BK", "146.56.227.42", 3306)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("数据库连接失败:", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// 测试数据库连接
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Fatal("数据库ping失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis连接
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: "36.212.20.113:7963",
|
||||||
|
Password: "j8nZ4jra2E",
|
||||||
|
DB: 7,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试Redis连接
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||||
|
log.Fatal("Redis连接失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行数据迁移
|
||||||
|
if err := migrateData(db, rdb); err != nil {
|
||||||
|
log.Fatal("数据迁移失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("数据迁移完成!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateData(db *sql.DB, rdb *redis.Client) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("开始查询店铺数据...")
|
||||||
|
|
||||||
|
// 第一步:查询所有店铺ID
|
||||||
|
shopQuery := `
|
||||||
|
SELECT DISTINCT shop_id
|
||||||
|
FROM t_shop_detail
|
||||||
|
WHERE shop_id IS NOT NULL AND del_flag = 0
|
||||||
|
`
|
||||||
|
|
||||||
|
shopRows, err := db.Query(shopQuery)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询店铺失败: %v", err)
|
||||||
|
}
|
||||||
|
defer shopRows.Close()
|
||||||
|
|
||||||
|
var shopIDs []int64
|
||||||
|
for shopRows.Next() {
|
||||||
|
var shopID int64
|
||||||
|
if err := shopRows.Scan(&shopID); err != nil {
|
||||||
|
return fmt.Errorf("扫描店铺数据失败: %v", err)
|
||||||
|
}
|
||||||
|
shopIDs = append(shopIDs, shopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("找到 %d 个店铺\n", len(shopIDs))
|
||||||
|
|
||||||
|
// 第二步:为每个店铺收集数据并存储到Redis
|
||||||
|
for i, shopID := range shopIDs {
|
||||||
|
fmt.Printf("[%d/%d] 处理店铺: %d\n", i+1, len(shopIDs), shopID)
|
||||||
|
|
||||||
|
// 收集该店铺的所有数据
|
||||||
|
var allRecords []ShopDataRecord
|
||||||
|
|
||||||
|
// 1. 尝试查询店铺主表数据
|
||||||
|
shopMainData, err := queryTableToShopData(db, "t_shop", `
|
||||||
|
SELECT * FROM t_shop WHERE id = ?
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 查询店铺主表失败 (店铺 %d): %v", shopID, err)
|
||||||
|
} else if len(shopMainData) > 0 {
|
||||||
|
allRecords = append(allRecords, shopMainData...)
|
||||||
|
fmt.Printf(" 找到店铺主表数据\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 未找到店铺主表数据 (id=%d)\n", shopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询该店铺的所有店铺详情数据
|
||||||
|
shopDetails, err := queryTableToShopData(db, "t_shop_detail", `
|
||||||
|
SELECT * FROM t_shop_detail WHERE shop_id = ?
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 查询店铺详情失败 (店铺 %d): %v", shopID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allRecords = append(allRecords, shopDetails...)
|
||||||
|
|
||||||
|
if len(shopDetails) == 0 {
|
||||||
|
fmt.Printf(" 店铺 %d 没有详情数据,跳过\n", shopID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试查询店铺上下文数据(商品描述)
|
||||||
|
shopContextData, err := queryTableToShopData(db, "t_shop_context", `
|
||||||
|
SELECT * FROM t_shop_context WHERE shop_id = ?
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 查询店铺上下文数据失败 (店铺 %d): %v", shopID, err)
|
||||||
|
} else if len(shopContextData) > 0 {
|
||||||
|
allRecords = append(allRecords, shopContextData...)
|
||||||
|
fmt.Printf(" 找到店铺上下文数据 (%d条)\n", len(shopContextData))
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 未找到店铺上下文数据\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 尝试查询规格设置数据
|
||||||
|
specData, err := queryTableToShopData(db, "t_spec", `
|
||||||
|
SELECT * FROM t_spec WHERE shop_id = ?
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 查询规格设置数据失败 (店铺 %d): %v", shopID, err)
|
||||||
|
} else if len(specData) > 0 {
|
||||||
|
allRecords = append(allRecords, specData...)
|
||||||
|
fmt.Printf(" 找到规格设置数据 (%d条)\n", len(specData))
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 未找到规格设置数据\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 从第一条店铺详情记录中获取 sale_template_id
|
||||||
|
var saleTemplateID sql.NullInt64
|
||||||
|
if shopDetails[0].Data["saleTemplateId"] != nil {
|
||||||
|
switch v := shopDetails[0].Data["saleTemplateId"].(type) {
|
||||||
|
case string:
|
||||||
|
if v != "" && v != "0" && v != "null" {
|
||||||
|
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
saleTemplateID = sql.NullInt64{Int64: id, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v > 0 {
|
||||||
|
saleTemplateID = sql.NullInt64{Int64: v, Valid: true}
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if v > 0 {
|
||||||
|
saleTemplateID = sql.NullInt64{Int64: int64(v), Valid: true}
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
strVal := string(v)
|
||||||
|
if strVal != "" && strVal != "0" && strVal != "null" {
|
||||||
|
if id, err := strconv.ParseInt(strVal, 10, 64); err == nil {
|
||||||
|
saleTemplateID = sql.NullInt64{Int64: id, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 根据 sale_template_id 查询价格模板
|
||||||
|
if saleTemplateID.Valid && saleTemplateID.Int64 > 0 {
|
||||||
|
priceTemplates, err := queryPriceTemplateData(db, saleTemplateID.Int64)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 查询价格模板失败 (店铺 %d, sale_template_id: %d): %v",
|
||||||
|
shopID, saleTemplateID.Int64, err)
|
||||||
|
} else if len(priceTemplates) == 0 {
|
||||||
|
fmt.Printf(" 警告: 店铺 %d 的 sale_template_id %d 没有对应的价格模板\n",
|
||||||
|
shopID, saleTemplateID.Int64)
|
||||||
|
} else {
|
||||||
|
allRecords = append(allRecords, priceTemplates...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 店铺 %d 没有有效的 sale_template_id\n", shopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将数据存储到Redis List
|
||||||
|
redisKey := strconv.FormatInt(shopID, 10)
|
||||||
|
|
||||||
|
// 删除旧的Key
|
||||||
|
rdb.Del(ctx, redisKey)
|
||||||
|
|
||||||
|
// 添加每条数据到List
|
||||||
|
for _, record := range allRecords {
|
||||||
|
// 转换为JSON字符串
|
||||||
|
jsonBytes, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: JSON编码失败 (店铺 %d): %v", shopID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到Redis List
|
||||||
|
if err := rdb.RPush(ctx, redisKey, jsonBytes).Err(); err != nil {
|
||||||
|
log.Printf("警告: Redis写入失败 (店铺 %d): %v", shopID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期时间
|
||||||
|
//rdb.Expire(ctx, redisKey, 24*3600*time.Second)
|
||||||
|
|
||||||
|
// 统计各类数据数量
|
||||||
|
counts := make(map[string]int)
|
||||||
|
for _, record := range allRecords {
|
||||||
|
counts[record.SourceTable]++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" 店铺 %d: 存储了 %d 条记录\n", shopID, len(allRecords))
|
||||||
|
for table, count := range counts {
|
||||||
|
fmt.Printf(" - %s: %d 条\n", table, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示匹配的模板ID
|
||||||
|
if saleTemplateID.Valid && saleTemplateID.Int64 > 0 {
|
||||||
|
fmt.Printf(" 匹配的saleTemplateId: %d\n", saleTemplateID.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询价格模板数据,特殊处理rangePrice字段
|
||||||
|
func queryPriceTemplateData(db *sql.DB, templateID int64) ([]ShopDataRecord, error) {
|
||||||
|
query := `SELECT * FROM t_price_template WHERE id = ?`
|
||||||
|
rows, err := db.Query(query, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 获取列信息
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备扫描结果
|
||||||
|
var records []ShopDataRecord
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
// 准备扫描指针
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描行
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建data map,使用驼峰格式的字段名
|
||||||
|
dataMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
val := values[i]
|
||||||
|
|
||||||
|
// 转换字段名为驼峰格式
|
||||||
|
camelCol := snakeToCamel(col)
|
||||||
|
|
||||||
|
// 特殊处理rangePrice字段
|
||||||
|
if camelCol == "rangePrice" {
|
||||||
|
// 处理rangePrice字段
|
||||||
|
processedVal, err := processRangePriceField(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 处理rangePrice字段失败: %v", err)
|
||||||
|
// 如果处理失败,保留原始值
|
||||||
|
dataMap[camelCol] = val
|
||||||
|
} else {
|
||||||
|
dataMap[camelCol] = processedVal
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他字段正常处理
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []byte:
|
||||||
|
// 字节数组直接转为字符串
|
||||||
|
dataMap[camelCol] = string(v)
|
||||||
|
default:
|
||||||
|
dataMap[camelCol] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建ShopDataRecord
|
||||||
|
record := ShopDataRecord{
|
||||||
|
SourceTable: "t_price_template",
|
||||||
|
Data: dataMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理rangePrice字段,修复浮点数精度问题
|
||||||
|
func processRangePriceField(val interface{}) (interface{}, error) {
|
||||||
|
if val == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将值转换为字符串
|
||||||
|
var jsonStr string
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
jsonStr = v
|
||||||
|
case []byte:
|
||||||
|
jsonStr = string(v)
|
||||||
|
default:
|
||||||
|
// 如果无法转换为字符串,直接返回原值
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果为空字符串,直接返回
|
||||||
|
if jsonStr == "" {
|
||||||
|
return jsonStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试解析为通用的[]map[string]interface{}
|
||||||
|
var rawRanges []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &rawRanges); err != nil {
|
||||||
|
return jsonStr, fmt.Errorf("JSON解析失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个价格范围项,将字符串数字转换为float64
|
||||||
|
fixedRanges := make([]map[string]interface{}, len(rawRanges))
|
||||||
|
for i, item := range rawRanges {
|
||||||
|
fixedItem := make(map[string]interface{})
|
||||||
|
|
||||||
|
// 处理MinPrice
|
||||||
|
if minPrice, ok := item["minPrice"]; ok {
|
||||||
|
fixedItem["minPrice"] = toFloat64(minPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理MaxPrice
|
||||||
|
if maxPrice, ok := item["maxPrice"]; ok {
|
||||||
|
fixedItem["maxPrice"] = toFloat64(maxPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理AdjustPercent
|
||||||
|
if adjustPercent, ok := item["adjustPercent"]; ok {
|
||||||
|
fixedItem["adjustPercent"] = toFloat64(adjustPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理AdjustAmount
|
||||||
|
if adjustAmount, ok := item["adjustAmount"]; ok {
|
||||||
|
fixedItem["adjustAmount"] = toFloat64(adjustAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedRanges[i] = fixedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新编码为JSON字符串
|
||||||
|
fixedJSON, err := json.Marshal(fixedRanges)
|
||||||
|
if err != nil {
|
||||||
|
return jsonStr, fmt.Errorf("JSON编码失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(fixedJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复浮点数:如果包含小数点,只保留整数部分
|
||||||
|
func fixFloatToInt(value float64) float64 {
|
||||||
|
// 使用math.Trunc直接截断小数部分
|
||||||
|
// 注意:这里使用float64返回,但值是整数
|
||||||
|
return math.Trunc(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将下划线命名转换为驼峰命名
|
||||||
|
func snakeToCamel(s string) string {
|
||||||
|
parts := strings.Split(s, "_")
|
||||||
|
for i := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
// 首字母大写
|
||||||
|
if len(parts[i]) > 0 {
|
||||||
|
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询表数据并返回ShopDataRecord切片,自动转换字段名为驼峰格式
|
||||||
|
func queryTableToShopData(db *sql.DB, tableName string, query string, args ...interface{}) ([]ShopDataRecord, error) {
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 获取列信息
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备扫描结果
|
||||||
|
var records []ShopDataRecord
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
// 准备扫描指针
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描行
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建data map,使用驼峰格式的字段名
|
||||||
|
dataMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
val := values[i]
|
||||||
|
|
||||||
|
// 转换字段名为驼峰格式
|
||||||
|
camelCol := snakeToCamel(col)
|
||||||
|
|
||||||
|
// 直接赋值,不进行任何转换
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []byte:
|
||||||
|
// 字节数组直接转为字符串
|
||||||
|
dataMap[camelCol] = string(v)
|
||||||
|
default:
|
||||||
|
dataMap[camelCol] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建ShopDataRecord
|
||||||
|
record := ShopDataRecord{
|
||||||
|
SourceTable: tableName,
|
||||||
|
Data: dataMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原有的查询函数保持不变
|
||||||
|
func queryTableToMap(db *sql.DB, query string, args ...interface{}) ([]map[string]interface{}, error) {
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 获取列信息
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备扫描结果
|
||||||
|
var results []map[string]interface{}
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
// 准备扫描指针
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描行
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建map
|
||||||
|
rowMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
val := values[i]
|
||||||
|
|
||||||
|
// 直接赋值,不进行任何转换
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []byte:
|
||||||
|
// 字节数组直接转为字符串
|
||||||
|
rowMap[col] = string(v)
|
||||||
|
default:
|
||||||
|
rowMap[col] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, rowMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat64(v interface{}) float64 {
|
||||||
|
if v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return fixFloatToInt(val)
|
||||||
|
case float32:
|
||||||
|
return fixFloatToInt(float64(val))
|
||||||
|
case int:
|
||||||
|
return float64(val)
|
||||||
|
case int64:
|
||||||
|
return float64(val)
|
||||||
|
case string:
|
||||||
|
// 尝试将字符串转换为float64
|
||||||
|
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||||
|
return fixFloatToInt(f)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
918
shop_mysql_to_redis/SanckCase/mysqlToRedis.go
Normal file
918
shop_mysql_to_redis/SanckCase/mysqlToRedis.go
Normal file
@ -0,0 +1,918 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"batch/util/dbConnectUtil"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShopMsg 店铺信息结构体
|
||||||
|
type ShopMsg struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ShopAliasName string `json:"shop_alias_name"`
|
||||||
|
ShopName string `json:"shop_name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
GoodsNamePrefix string `json:"goods_name_prefix"`
|
||||||
|
GoodsNameSuffix string `json:"goods_name_suffix"`
|
||||||
|
TitleConsistOf string `json:"title_consist_of"`
|
||||||
|
SpaceCharacter string `json:"space_character"`
|
||||||
|
WatermarkImgUrl string `json:"watermark_img_url"`
|
||||||
|
CarouseLastImgUrlArray []string `json:"carouse_last_img_url_array"`
|
||||||
|
GoodsDetailFirstImgUrlArray []string `json:"goods_detail_first_img_url_array"`
|
||||||
|
GoodsDetailLastImgUrlArray []string `json:"goods_detail_last_img_url_array"`
|
||||||
|
SpecName string `json:"spec_name"`
|
||||||
|
SpecId int64 `json:"spec_id"`
|
||||||
|
SpecChildName string `json:"spec_child_name"`
|
||||||
|
IsFolt bool `json:"is_fotl"`
|
||||||
|
IsPreSale bool `json:"is_pre_sale"`
|
||||||
|
IsRefundable bool `json:"is_refundable"`
|
||||||
|
IsSecondHand bool `json:"is_second_hand"`
|
||||||
|
ShipmentLimitSecond int64 `json:"shipment_limit_second"`
|
||||||
|
CostTemplateId int64 `json:"cost_template_id"`
|
||||||
|
DefStock int64 `json:"def_stock"`
|
||||||
|
TwoDiscount int64 `json:"two_discount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 店铺主表数据结构
|
||||||
|
type ShopMainData struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
ShopAliasName sql.NullString `json:"shop_alias_name"`
|
||||||
|
ShopName sql.NullString `json:"shop_name"`
|
||||||
|
Token sql.NullString `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 店铺详情数据结构
|
||||||
|
type ShopDetailData struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
ShopId int64 `json:"shop_id"`
|
||||||
|
TemplateId *int64 `json:"template_id"`
|
||||||
|
TitlePrefix sql.NullString `json:"title_prefix"`
|
||||||
|
TitleSuffix sql.NullString `json:"title_suffix"`
|
||||||
|
TitleConsistOf sql.NullString `json:"title_consist_of"`
|
||||||
|
SpaceCharacter sql.NullString `json:"space_character"`
|
||||||
|
StockDeff *int64 `json:"stock_deff"`
|
||||||
|
TwoDiscount *int64 `json:"two_discount"`
|
||||||
|
Presale sql.NullString `json:"presale"`
|
||||||
|
Fake sql.NullString `json:"fake"`
|
||||||
|
SevenDays sql.NullString `json:"seven_days"`
|
||||||
|
IsSecondHand sql.NullString `json:"is_second_hand"`
|
||||||
|
DeliveryTime sql.NullString `json:"delivery_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规格数据结构
|
||||||
|
type SpecData struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
ShopId int64 `json:"shop_id"`
|
||||||
|
SpecName sql.NullString `json:"spec_name"`
|
||||||
|
SpecPrefix sql.NullString `json:"spec_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片数据结构
|
||||||
|
type ShopImageData struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Pid int64 `json:"pid"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AbsolutePath string `json:"absolute_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 嵌套的JSON数据结构
|
||||||
|
type ShopDataRecord struct {
|
||||||
|
SourceTable string `json:"source_table"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发处理结果
|
||||||
|
type ShopProcessResult struct {
|
||||||
|
ShopID int64
|
||||||
|
Count int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeFieldValue 根据字段名和表名对值进行标准化:
|
||||||
|
// - 特定表特定字段保持 int64 数字:t_shop_context.id, t_spec.id, t_price_template.id, t_shop_detail.district_id, 以及 mall_id
|
||||||
|
// - 其他 id / *_id / create_by / update_by 字段 → 字符串
|
||||||
|
// - create_time/update_time/add_time/expiration_time → 格式化为 "2006-01-02 15:04:05"(无时区)
|
||||||
|
// - 其他字段:[]byte → string
|
||||||
|
func normalizeFieldValue(colName string, val interface{}, tableName string) interface{} {
|
||||||
|
if val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊处理:保持 int64 数字的字段(不转为字符串)
|
||||||
|
// 1. mall_id 保持数字
|
||||||
|
// 2. t_shop_context.id
|
||||||
|
// 3. t_spec.id
|
||||||
|
// 4. t_price_template.id
|
||||||
|
// 5. t_shop_detail.district_id
|
||||||
|
if colName == "mall_id" {
|
||||||
|
return ensureInt64(val)
|
||||||
|
}
|
||||||
|
if tableName == "t_shop_detail" && colName == "district_id" {
|
||||||
|
return ensureInt64(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) ID 类字段(除上述保留数字的字段外):转为字符串
|
||||||
|
if colName == "id" || colName == "create_by" || colName == "update_by" || strings.HasSuffix(colName, "_id") {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case []byte:
|
||||||
|
return string(v)
|
||||||
|
case int, int8, int16, int32, int64:
|
||||||
|
return strconv.FormatInt(reflect.ValueOf(v).Int(), 10)
|
||||||
|
case uint, uint8, uint16, uint32, uint64:
|
||||||
|
return strconv.FormatUint(reflect.ValueOf(v).Uint(), 10)
|
||||||
|
case float32, float64:
|
||||||
|
f := reflect.ValueOf(v).Float()
|
||||||
|
return strconv.FormatInt(int64(f), 10)
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 时间字段:格式化为 "2006-01-02 15:04:05"
|
||||||
|
if colName == "create_time" || colName == "update_time" || colName == "add_time" || colName == "expiration_time" {
|
||||||
|
var t time.Time
|
||||||
|
switch v := val.(type) {
|
||||||
|
case time.Time:
|
||||||
|
t = v
|
||||||
|
case []byte:
|
||||||
|
s := string(v)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t = parseTime(s)
|
||||||
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t = parseTime(v)
|
||||||
|
default:
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 其他字段:[]byte 转为 string
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []byte:
|
||||||
|
return string(v)
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureInt64 将各种类型转换为 int64,用于保持数字的字段
|
||||||
|
func ensureInt64(val interface{}) interface{} {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
case int32:
|
||||||
|
return int64(v)
|
||||||
|
case float64:
|
||||||
|
return int64(v)
|
||||||
|
case string:
|
||||||
|
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case []byte:
|
||||||
|
s := string(v)
|
||||||
|
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTime 尝试多种布局解析时间字符串
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
layouts := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05.999999",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询店铺主表数据
|
||||||
|
func queryShopMainData(db *sql.DB, shopID int64) (ShopMainData, error) {
|
||||||
|
query := `SELECT id, shop_alias_name, shop_name, token FROM t_shop WHERE id = ? AND del_flag = '0'`
|
||||||
|
row := db.QueryRow(query, shopID)
|
||||||
|
|
||||||
|
var data ShopMainData
|
||||||
|
err := row.Scan(&data.Id, &data.ShopAliasName, &data.ShopName, &data.Token)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询店铺详情数据
|
||||||
|
func queryShopDetailData(db *sql.DB, shopID int64) ([]ShopDetailData, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, shop_id, template_id, title_prefix, title_suffix, title_consist_of,
|
||||||
|
space_character, stock_deff, two_discount, presale, fake, seven_days,
|
||||||
|
is_second_hand, delivery_time
|
||||||
|
FROM t_shop_detail
|
||||||
|
WHERE shop_id = ? AND del_flag = '0'
|
||||||
|
`
|
||||||
|
rows, err := db.Query(query, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var data []ShopDetailData
|
||||||
|
for rows.Next() {
|
||||||
|
var item ShopDetailData
|
||||||
|
err := rows.Scan(
|
||||||
|
&item.Id, &item.ShopId, &item.TemplateId, &item.TitlePrefix, &item.TitleSuffix,
|
||||||
|
&item.TitleConsistOf, &item.SpaceCharacter, &item.StockDeff, &item.TwoDiscount,
|
||||||
|
&item.Presale, &item.Fake, &item.SevenDays, &item.IsSecondHand, &item.DeliveryTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询规格数据
|
||||||
|
func querySpecData(db *sql.DB, shopID int64) ([]SpecData, error) {
|
||||||
|
query := `SELECT id, shop_id, spec_name, spec_prefix FROM t_spec WHERE shop_id = ? AND del_flag = '0'`
|
||||||
|
rows, err := db.Query(query, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var data []SpecData
|
||||||
|
for rows.Next() {
|
||||||
|
var item SpecData
|
||||||
|
err := rows.Scan(&item.Id, &item.ShopId, &item.SpecName, &item.SpecPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询图片数据
|
||||||
|
func queryShopImageData(db *sql.DB, shopID int64) ([]ShopImageData, error) {
|
||||||
|
query := `SELECT id, pid, type, absolute_path FROM t_shop_img WHERE pid = ? AND del_flag = '0'`
|
||||||
|
rows, err := db.Query(query, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var data []ShopImageData
|
||||||
|
for rows.Next() {
|
||||||
|
var item ShopImageData
|
||||||
|
err := rows.Scan(&item.Id, &item.Pid, &item.Type, &item.AbsolutePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 配置数据库连接
|
||||||
|
db, err := dbConnectUtil.InitDB("zhishu", "XsRR4K3ATizyc5BK", "146.56.227.42", 3306)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("数据库连接失败:", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// 【优化1】数据库连接池配置 - 降低并发避免瞬时带宽压力
|
||||||
|
db.SetMaxOpenConns(10) // 最大打开连接数(降低到10)
|
||||||
|
db.SetMaxIdleConns(5) // 最大空闲连接数(降低到5)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute) // 连接最大生命周期
|
||||||
|
db.SetConnMaxIdleTime(10 * time.Minute) // 空闲连接最大存活时间
|
||||||
|
|
||||||
|
// 测试数据库连接
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Fatal("数据库ping失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis连接 - 降低连接池大小
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: "36.212.12.247:6379",
|
||||||
|
Password: "long6166@@",
|
||||||
|
DB: 8,
|
||||||
|
PoolSize: 10, // 连接池大小(降低到10)
|
||||||
|
MinIdleConns: 5, // 最小空闲连接数(降低到5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试Redis连接
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||||
|
log.Fatal("Redis连接失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【新增】设置信号捕获,优雅退出
|
||||||
|
stopChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// 【新增】循环运行,每次间隔5分钟
|
||||||
|
runCount := 0
|
||||||
|
for {
|
||||||
|
runCount++
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
fmt.Printf("\n%s\n", strings.Repeat("=", 60))
|
||||||
|
fmt.Printf("第 %d 次运行开始 - %s\n", runCount, startTime.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Printf("%s\n", strings.Repeat("=", 60))
|
||||||
|
|
||||||
|
// 执行数据迁移
|
||||||
|
if err := migrateDataOptimized(db, rdb); err != nil {
|
||||||
|
log.Printf("数据迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
fmt.Printf("\n第 %d 次运行完成,耗时: %v\n", runCount, elapsed)
|
||||||
|
fmt.Printf("下次运行时间: %s\n", time.Now().Add(5*time.Minute).Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
// 等待间隔时间,但可以提前退出
|
||||||
|
// 根据运行耗时动态调整间隔
|
||||||
|
var waitInterval time.Duration
|
||||||
|
if elapsed < 3*time.Minute {
|
||||||
|
waitInterval = 5 * time.Minute
|
||||||
|
} else if elapsed < 6*time.Minute {
|
||||||
|
waitInterval = 8 * time.Minute
|
||||||
|
} else {
|
||||||
|
waitInterval = 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n本次耗时: %v, 下次运行间隔: %v (按 Ctrl+C 退出)...\n", elapsed, waitInterval)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stopChan:
|
||||||
|
fmt.Printf("\n\n收到退出信号,程序即将退出...\n")
|
||||||
|
fmt.Printf("总共运行了 %d 次\n", runCount)
|
||||||
|
return
|
||||||
|
case <-time.After(waitInterval):
|
||||||
|
// 继续下一次循环
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【优化版本】并发处理数据迁移
|
||||||
|
func migrateDataOptimized(db *sql.DB, rdb *redis.Client) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("开始查询店铺数据...")
|
||||||
|
|
||||||
|
// 第一步:查询所有店铺ID
|
||||||
|
shopQuery := `
|
||||||
|
SELECT DISTINCT id
|
||||||
|
FROM t_shop
|
||||||
|
WHERE id IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
shopRows, err := db.Query(shopQuery)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询店铺失败: %v", err)
|
||||||
|
}
|
||||||
|
defer shopRows.Close()
|
||||||
|
|
||||||
|
var shopIDs []int64
|
||||||
|
for shopRows.Next() {
|
||||||
|
var shopID int64
|
||||||
|
if err := shopRows.Scan(&shopID); err != nil {
|
||||||
|
return fmt.Errorf("扫描店铺数据失败: %v", err)
|
||||||
|
}
|
||||||
|
shopIDs = append(shopIDs, shopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("找到 %d 个店铺\n", len(shopIDs))
|
||||||
|
|
||||||
|
// 【优化2】使用Worker Pool并发处理 - 降低并发避免带宽压力
|
||||||
|
workers := 3 // 并发worker数量(降低到3,减少瞬时压力)
|
||||||
|
jobs := make(chan int64, len(shopIDs))
|
||||||
|
results := make(chan ShopProcessResult, len(shopIDs))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// 启动worker
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for shopID := range jobs {
|
||||||
|
count, err := processShop(db, rdb, ctx, shopID, workerID)
|
||||||
|
results <- ShopProcessResult{
|
||||||
|
ShopID: shopID,
|
||||||
|
Count: count,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
// 【新增】添加处理延迟,降低瞬时带宽压力
|
||||||
|
time.Sleep(100 * time.Millisecond) // 每个店铺处理完后休息100ms
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送任务
|
||||||
|
go func() {
|
||||||
|
for _, id := range shopIDs {
|
||||||
|
jobs <- id
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待所有worker完成
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 收集结果并统计
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
totalRecords := 0
|
||||||
|
|
||||||
|
for result := range results {
|
||||||
|
if result.Err != nil {
|
||||||
|
failCount++
|
||||||
|
log.Printf("[失败] 店铺 %d: %v", result.ShopID, result.Err)
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
totalRecords += result.Count
|
||||||
|
if successCount%10 == 0 {
|
||||||
|
fmt.Printf("[进度] 已完成: %d/%d, 总记录: %d\n",
|
||||||
|
successCount, len(shopIDs), totalRecords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n========== 迁移完成 ==========\n")
|
||||||
|
fmt.Printf("成功: %d 店铺\n", successCount)
|
||||||
|
fmt.Printf("失败: %d 店铺\n", failCount)
|
||||||
|
fmt.Printf("总记录数: %d\n", totalRecords)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processShop 处理单个店铺的所有数据
|
||||||
|
func processShop(db *sql.DB, rdb *redis.Client, ctx context.Context, shopID int64, workerID int) (int, error) {
|
||||||
|
// 收集该店铺的所有数据
|
||||||
|
var allRecords []ShopDataRecord
|
||||||
|
|
||||||
|
// 1. 查询店铺主表数据
|
||||||
|
shopMainData, err := queryTableToShopData(db, "t_shop", `
|
||||||
|
SELECT * FROM t_shop WHERE id = ? AND del_flag = '0'
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("查询店铺主表失败: %v", err)
|
||||||
|
}
|
||||||
|
allRecords = append(allRecords, shopMainData...)
|
||||||
|
|
||||||
|
// 2. 查询该店铺的所有店铺详情数据
|
||||||
|
shopDetails, err := queryTableToShopData(db, "t_shop_detail", `
|
||||||
|
SELECT * FROM t_shop_detail WHERE shop_id = ? AND del_flag = '0'
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("查询店铺详情失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(shopDetails) == 0 {
|
||||||
|
return 0, nil // 没有详情数据,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询图片数据以获取watermark_img_url
|
||||||
|
shopImgDataList, err := queryShopImageData(db, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Worker-%d] 警告: 查询图片数据失败 (店铺 %d): %v", workerID, shopID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片数据
|
||||||
|
var watermarkImgUrl string
|
||||||
|
var skuWatermarkImgUrl string
|
||||||
|
var carouseLastImgUrlArray = []string{}
|
||||||
|
var goodsDetailFirstImgUrlArray = []string{}
|
||||||
|
var goodsDetailLastImgUrlArray = []string{}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
for _, img := range shopImgDataList {
|
||||||
|
if img.AbsolutePath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch img.Type {
|
||||||
|
case "1":
|
||||||
|
watermarkImgUrl = img.AbsolutePath
|
||||||
|
case "3":
|
||||||
|
goodsDetailFirstImgUrlArray = append(goodsDetailFirstImgUrlArray, img.AbsolutePath)
|
||||||
|
case "4":
|
||||||
|
goodsDetailLastImgUrlArray = append(goodsDetailLastImgUrlArray, img.AbsolutePath)
|
||||||
|
case "5":
|
||||||
|
carouseLastImgUrlArray = append(carouseLastImgUrlArray, img.AbsolutePath)
|
||||||
|
case "7":
|
||||||
|
skuWatermarkImgUrl = img.AbsolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将watermark_img_url等添加到shopdetail记录中
|
||||||
|
for i := range shopDetails {
|
||||||
|
if shopDetails[i].Data == nil {
|
||||||
|
shopDetails[i].Data = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
shopDetails[i].Data["watermark_img_url"] = watermarkImgUrl
|
||||||
|
shopDetails[i].Data["carouse_last_img_url_array"] = carouseLastImgUrlArray
|
||||||
|
shopDetails[i].Data["goods_detail_first_img_url_array"] = goodsDetailFirstImgUrlArray
|
||||||
|
shopDetails[i].Data["goods_detail_last_img_url_array"] = goodsDetailLastImgUrlArray
|
||||||
|
shopDetails[i].Data["sku_watermark_img_url"] = skuWatermarkImgUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
allRecords = append(allRecords, shopDetails...)
|
||||||
|
|
||||||
|
// 3. 查询店铺上下文数据
|
||||||
|
shopContextData, err := queryTableToShopData(db, "t_shop_context", `
|
||||||
|
SELECT * FROM t_shop_context WHERE shop_id = ? AND del_flag = '0'
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Worker-%d] 警告: 查询店铺上下文数据失败 (店铺 %d): %v", workerID, shopID, err)
|
||||||
|
}
|
||||||
|
allRecords = append(allRecords, shopContextData...)
|
||||||
|
|
||||||
|
// 4. 查询规格设置数据
|
||||||
|
specData, err := queryTableToShopData(db, "t_spec", `
|
||||||
|
SELECT * FROM t_spec WHERE shop_id = ? AND del_flag = '0'
|
||||||
|
`, shopID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Worker-%d] 警告: 查询规格设置数据失败 (店铺 %d): %v", workerID, shopID, err)
|
||||||
|
}
|
||||||
|
allRecords = append(allRecords, specData...)
|
||||||
|
|
||||||
|
// 5. 从第一条店铺详情记录中获取 sale_template_id
|
||||||
|
var saleTemplateID int64
|
||||||
|
if firstDetail := shopDetails[0].Data; firstDetail != nil {
|
||||||
|
if v, ok := firstDetail["sale_template_id"]; ok && v != nil {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
if val != "" && val != "0" && val != "null" {
|
||||||
|
if id, err := strconv.ParseInt(val, 10, 64); err == nil && id > 0 {
|
||||||
|
saleTemplateID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if val > 0 {
|
||||||
|
saleTemplateID = val
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if val > 0 {
|
||||||
|
saleTemplateID = int64(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 根据 sale_template_id 查询价格模板
|
||||||
|
if saleTemplateID > 0 {
|
||||||
|
priceTemplates, err := queryPriceTemplateData(db, saleTemplateID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Worker-%d] 警告: 查询价格模板失败 (店铺 %d, sale_template_id: %d): %v",
|
||||||
|
workerID, shopID, saleTemplateID, err)
|
||||||
|
} else {
|
||||||
|
allRecords = append(allRecords, priceTemplates...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【优化3】批量写入Redis
|
||||||
|
if err := saveToRedisBatch(rdb, ctx, shopID, allRecords); err != nil {
|
||||||
|
return 0, fmt.Errorf("Redis写入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(allRecords), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【优化3】使用Pipeline批量写入Redis
|
||||||
|
func saveToRedisBatch(rdb *redis.Client, ctx context.Context, shopID int64, records []ShopDataRecord) error {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
redisKey := strconv.FormatInt(shopID, 10)
|
||||||
|
|
||||||
|
// 使用Pipeline批量执行
|
||||||
|
pipe := rdb.Pipeline()
|
||||||
|
|
||||||
|
// 删除旧key
|
||||||
|
pipe.Del(ctx, redisKey)
|
||||||
|
|
||||||
|
// 批量添加数据
|
||||||
|
for _, record := range records {
|
||||||
|
jsonBytes, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: JSON编码失败 (店铺 %d): %v", shopID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pipe.RPush(ctx, redisKey, jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次性执行所有命令
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询价格模板数据,特殊处理 range_price 和 add_amount 字段
|
||||||
|
func queryPriceTemplateData(db *sql.DB, templateID int64) ([]ShopDataRecord, error) {
|
||||||
|
query := `SELECT * FROM t_price_template WHERE id = ?`
|
||||||
|
rows, err := db.Query(query, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []ShopDataRecord
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
snakeCol := camelToSnake(col)
|
||||||
|
|
||||||
|
if snakeCol == "range_price" {
|
||||||
|
processedVal, err := processRangePriceField(values[i])
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 处理 range_price 字段失败: %v", err)
|
||||||
|
processedVal = values[i]
|
||||||
|
}
|
||||||
|
dataMap[snakeCol] = normalizeFieldValue(snakeCol, processedVal, "t_price_template")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if snakeCol == "add_amount" {
|
||||||
|
processedVal, err := processAddAmountField(values[i])
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告: 处理 add_amount 字段失败: %v", err)
|
||||||
|
processedVal = values[i]
|
||||||
|
}
|
||||||
|
dataMap[snakeCol] = normalizeFieldValue(snakeCol, processedVal, "t_price_template")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap[snakeCol] = normalizeFieldValue(snakeCol, values[i], "t_price_template")
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, ShopDataRecord{
|
||||||
|
SourceTable: "t_price_template",
|
||||||
|
Data: dataMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAddAmountField(val interface{}) (interface{}, error) {
|
||||||
|
if val == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
switch v := val.(type) {
|
||||||
|
case float64:
|
||||||
|
f = v
|
||||||
|
case float32:
|
||||||
|
f = float64(v)
|
||||||
|
case int:
|
||||||
|
f = float64(v)
|
||||||
|
case int64:
|
||||||
|
f = float64(v)
|
||||||
|
case int32:
|
||||||
|
f = float64(v)
|
||||||
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无法解析字符串为浮点数: %v", err)
|
||||||
|
}
|
||||||
|
f = parsed
|
||||||
|
case []byte:
|
||||||
|
s := string(v)
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无法解析字节数组为浮点数: %v", err)
|
||||||
|
}
|
||||||
|
f = parsed
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的类型: %T", v)
|
||||||
|
}
|
||||||
|
scaled := math.Round(f * 100)
|
||||||
|
return int64(scaled), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processRangePriceField(val interface{}) (interface{}, error) {
|
||||||
|
if val == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var jsonStr string
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
jsonStr = v
|
||||||
|
case []byte:
|
||||||
|
jsonStr = string(v)
|
||||||
|
default:
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
if jsonStr == "" {
|
||||||
|
return jsonStr, nil
|
||||||
|
}
|
||||||
|
var rawItems []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &rawItems); err != nil {
|
||||||
|
return jsonStr, fmt.Errorf("JSON解析失败: %v", err)
|
||||||
|
}
|
||||||
|
fixedRanges := make([]map[string]interface{}, len(rawItems))
|
||||||
|
for i, item := range rawItems {
|
||||||
|
fixedItem := make(map[string]interface{})
|
||||||
|
if minPrice, ok := item["minPrice"]; ok {
|
||||||
|
fixedItem["minPrice"] = convertToFloat64(minPrice)
|
||||||
|
}
|
||||||
|
if maxPrice, ok := item["maxPrice"]; ok {
|
||||||
|
fixedItem["maxPrice"] = convertToFloat64(maxPrice)
|
||||||
|
}
|
||||||
|
if adjustPercent, ok := item["adjustPercent"]; ok {
|
||||||
|
fixedItem["adjustPercent"] = convertToFloat64(adjustPercent)
|
||||||
|
}
|
||||||
|
if adjustAmount, ok := item["adjustAmount"]; ok {
|
||||||
|
fixedItem["adjustAmount"] = convertToFloat64(adjustAmount)
|
||||||
|
}
|
||||||
|
fixedRanges[i] = fixedItem
|
||||||
|
}
|
||||||
|
fixedJSON, err := json.Marshal(fixedRanges)
|
||||||
|
if err != nil {
|
||||||
|
return jsonStr, fmt.Errorf("JSON编码失败: %v", err)
|
||||||
|
}
|
||||||
|
return string(fixedJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertToFloat64(v interface{}) float64 {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return fixFloatToInt(val)
|
||||||
|
case float32:
|
||||||
|
return fixFloatToInt(float64(val))
|
||||||
|
case int:
|
||||||
|
return float64(val)
|
||||||
|
case int64:
|
||||||
|
return float64(val)
|
||||||
|
case int32:
|
||||||
|
return float64(val)
|
||||||
|
case string:
|
||||||
|
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||||
|
return fixFloatToInt(f)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixFloatToInt(value float64) float64 {
|
||||||
|
return math.Floor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func camelToSnake(s string) string {
|
||||||
|
var result []rune
|
||||||
|
for i, r := range s {
|
||||||
|
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||||
|
result = append(result, '_')
|
||||||
|
}
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
return strings.ToLower(string(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用表查询函数,自动应用字段标准化
|
||||||
|
func queryTableToShopData(db *sql.DB, tableName string, query string, args ...interface{}) ([]ShopDataRecord, error) {
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []ShopDataRecord
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dataMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
snakeCol := camelToSnake(col)
|
||||||
|
dataMap[snakeCol] = normalizeFieldValue(snakeCol, values[i], tableName)
|
||||||
|
}
|
||||||
|
records = append(records, ShopDataRecord{
|
||||||
|
SourceTable: tableName,
|
||||||
|
Data: dataMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有函数(如需使用)
|
||||||
|
func queryTableToMap(db *sql.DB, query string, args ...interface{}) ([]map[string]interface{}, error) {
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []map[string]interface{}
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePtrs := make([]interface{}, len(columns))
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
for i := range columns {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rowMap := make(map[string]interface{})
|
||||||
|
for i, col := range columns {
|
||||||
|
switch v := values[i].(type) {
|
||||||
|
case []byte:
|
||||||
|
rowMap[col] = string(v)
|
||||||
|
default:
|
||||||
|
rowMap[col] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, rowMap)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
40
util/dbConnectUtil/dbConnectUtil.go
Normal file
40
util/dbConnectUtil/dbConnectUtil.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package dbConnectUtil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB 数据库连接池
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
// InitDB 初始化数据库连接
|
||||||
|
func InitDB(username, password, host string, port int) (*sql.DB, error) {
|
||||||
|
log.Printf("开始初始化数据库连接")
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/zhishu?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
username, password, host, port)
|
||||||
|
|
||||||
|
log.Printf("数据库连接参数: %s", dsn)
|
||||||
|
log.Print("开始连接数据库")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return DB, fmt.Errorf("数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试数据库连接
|
||||||
|
err = DB.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return DB, fmt.Errorf("数据库连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置连接池参数
|
||||||
|
DB.SetMaxOpenConns(20)
|
||||||
|
DB.SetMaxIdleConns(10)
|
||||||
|
|
||||||
|
return DB, nil
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user