package main import ( "context" "encoding/csv" "flag" "fmt" "math" "math/big" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) const ( defaultLegacySchema = "kyhl" defaultBatchSize = 500 timeFormat = "2006-01-02 15:04:05" sourceCardLife = "tbl_card_life" sourceNextMonthLife = "tbl_next_month_card_life" ) var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) type inputAsset struct { Line int DeviceVirtualNo string IMEI string SN string SlotPosition int ICCID string ResolvedBy string ResolveNote string } type cardSnapshot struct { ICCID string `gorm:"column:iccid"` CardID string `gorm:"column:card_id"` Phone string `gorm:"column:phone"` PhoneZuhe string `gorm:"column:phone_zuhe"` OldIMEI string `gorm:"column:old_imei"` OldVCode string `gorm:"column:old_vcode"` Status string `gorm:"column:status"` Type string `gorm:"column:type"` Category string `gorm:"column:category"` CategoryName string `gorm:"column:category_name"` CardFlow string `gorm:"column:card_flow"` SetMealID string `gorm:"column:set_meal_id"` CardPackage string `gorm:"column:card_package"` ExpireTime string `gorm:"column:expire_time"` FlowSize string `gorm:"column:flow_size"` TotalBytesCnt string `gorm:"column:total_bytes_cnt"` SynchroTime string `gorm:"column:synchro_time"` ServCreateAt *time.Time `gorm:"column:serv_create_at"` BalanceMoney string `gorm:"column:balance_money"` AgentID string `gorm:"column:agent_id"` AgentName string `gorm:"column:agent_name"` AccountID string `gorm:"column:account_id"` AccountName string `gorm:"column:account_name"` AgentThreeID string `gorm:"column:agent_three_id"` } type packageSnapshot struct { SourceTable string `gorm:"column:source_table"` ID string `gorm:"column:id"` ICCID string `gorm:"column:iccid"` MealID string `gorm:"column:meal_id"` MealName string `gorm:"column:meal_name"` OrderID string `gorm:"column:order_id"` StartAt *time.Time `gorm:"column:start_at"` ExpiresAt *time.Time `gorm:"column:expires_at"` Status string `gorm:"column:status"` Type string `gorm:"column:type"` FlowSize string `gorm:"column:flow_size"` EffectType string `gorm:"column:effect_type"` UpdateDateTime string `gorm:"column:update_date_time"` EffectBeginTime *time.Time `gorm:"column:effect_begin_time"` EffectCompleteTime *time.Time `gorm:"column:effect_complete_time"` OrderType string `gorm:"column:order_type"` CreatedAt *time.Time `gorm:"column:created_at"` UpdatedAt *time.Time `gorm:"column:updated_at"` } type walletSnapshot struct { ICCID string `gorm:"column:iccid"` MoneyBalance string `gorm:"column:money_balance"` TxVersion string `gorm:"column:tx_version"` MealAutoRenewStatus string `gorm:"column:meal_auto_renew_status"` AutoRenewMealID string `gorm:"column:auto_renew_meal_id"` AddonAutoRenewStatus string `gorm:"column:addon_auto_renew_status"` AddonAutoRenewMealID string `gorm:"column:addon_auto_renew_meal_id"` MealAutoRenewUpdatedAt *time.Time `gorm:"column:meal_auto_renew_updated_at"` CreatedAt *time.Time `gorm:"column:created_at"` } type commissionAccountSnapshot struct { AgentID string AgentName string AgentLoginAccount string TotalCommissionAmount string TotalActiveAwardCommissionAmount string MFCommissionAmount string NoMFCommissionAmount string TotalDrawAmount string CanDrawAmount string ApplyingDrawAmount string Remark string TxVersion string CreatedAt *time.Time UpdatedAt *time.Time } type commissionDetailSnapshot struct { ID string `gorm:"column:id"` CommissionAccountID string `gorm:"column:commission_account_id"` WxOrderID string `gorm:"column:wx_order_id"` WxOrderNumber string `gorm:"column:wx_order_number"` ICCID string `gorm:"column:iccid"` Phone string `gorm:"column:phone"` WxOrderAt *time.Time `gorm:"column:wx_order_at"` CreatedAt *time.Time `gorm:"column:created_at"` CommissionAmount string `gorm:"column:commission_amount"` TotalCommissionAmount string `gorm:"column:total_commission_amount"` MFStatus string `gorm:"column:mf_status"` CommissionType string `gorm:"column:commission_type"` ActiveAwardCommissionAmount string `gorm:"column:active_award_commission_amount"` TotalActiveAwardCommissionAmount string `gorm:"column:total_active_award_commission_amount"` } type legacyCardIdentity struct { ICCID string `gorm:"column:iccid"` IMEI string `gorm:"column:imei"` VCode string `gorm:"column:vcode"` ResolvedBy string ResolveNote string SlotPosition int } type cardRelation struct { MainICCID string `gorm:"column:main_iccid"` FuICCID string `gorm:"column:fu_iccid"` FuICCID2 string `gorm:"column:fu_iccid2"` } type exportData struct { Inputs []inputAsset UnresolvedInputs []inputAsset InputByICCID map[string][]inputAsset Cards map[string]cardSnapshot PackagesByICCID map[string][]packageSnapshot Wallets map[string]walletSnapshot CommissionAccounts map[string]commissionAccountSnapshot CommissionDetails []commissionDetailSnapshot MissingICCIDs []string } func main() { inputPath := flag.String("input", "", "设备/卡关系 CSV 路径") outputDir := flag.String("out", "legacy_device_export_output", "导出目录") dsn := flag.String("dsn", os.Getenv("LEGACY_MYSQL_DSN"), "老 MySQL DSN,也可使用 LEGACY_MYSQL_DSN") schema := flag.String("schema", getenvDefault("LEGACY_MYSQL_SCHEMA", defaultLegacySchema), "老库 schema/database 名称") includeCommissionDetails := flag.Bool("include-commission-details", false, "是否导出输入 ICCID 对应的佣金明细") flag.Parse() if *inputPath == "" { exitWithError("请通过 -input 指定设备/卡关系 CSV") } if *dsn == "" { exitWithError("请通过 -dsn 或 LEGACY_MYSQL_DSN 提供老 MySQL 连接串") } if !identifierPattern.MatchString(*schema) { exitWithError("schema 只能包含字母、数字和下划线") } ctx := context.Background() inputs, err := readInputCSV(*inputPath) if err != nil { exitWithError("读取输入 CSV 失败: %v", err) } if len(inputs) == 0 { exitWithError("输入 CSV 中没有识别到任何设备/卡线索") } db, err := openLegacyDB(*dsn) if err != nil { exitWithError("连接老 MySQL 失败: %v", err) } resolvedInputs, unresolvedInputs, err := resolveInputAssets(ctx, db, *schema, inputs) if err != nil { exitWithError("按设备号/IMEI 解析 ICCID 失败: %v", err) } data := &exportData{ Inputs: resolvedInputs, UnresolvedInputs: unresolvedInputs, InputByICCID: groupInputsByICCID(resolvedInputs), Cards: make(map[string]cardSnapshot), PackagesByICCID: make(map[string][]packageSnapshot), Wallets: make(map[string]walletSnapshot), CommissionAccounts: make(map[string]commissionAccountSnapshot), } iccids := sortedKeys(data.InputByICCID) if err := loadCardSnapshots(ctx, db, *schema, iccids, data.Cards); err != nil { exitWithError("查询老卡本体失败: %v", err) } if err := loadPackageSnapshots(ctx, db, *schema, iccids, data.PackagesByICCID); err != nil { exitWithError("查询老套餐快照失败: %v", err) } if err := loadWalletSnapshots(ctx, db, *schema, iccids, data.Wallets); err != nil { exitWithError("查询老卡钱包失败: %v", err) } agentIDs := collectAgentIDs(data.Cards) if len(agentIDs) > 0 { if err := loadCommissionAccounts(ctx, db, *schema, agentIDs, data.CommissionAccounts); err != nil { exitWithError("查询老佣金账户失败: %v", err) } } if *includeCommissionDetails { details, err := loadCommissionDetails(ctx, db, *schema, iccids) if err != nil { exitWithError("查询老佣金明细失败: %v", err) } data.CommissionDetails = details } for _, iccid := range iccids { if _, ok := data.Cards[iccid]; !ok { data.MissingICCIDs = append(data.MissingICCIDs, iccid) } } if err := os.MkdirAll(*outputDir, 0755); err != nil { exitWithError("创建导出目录失败: %v", err) } if err := writeOutputs(*outputDir, data, *includeCommissionDetails); err != nil { exitWithError("写入导出文件失败: %v", err) } fmt.Printf("导出完成: %s\n", *outputDir) fmt.Printf("已解析关系: %d 条,ICCID: %d 个,未解析输入: %d 条,老库缺失: %d 个\n", len(data.Inputs), len(iccids), len(data.UnresolvedInputs), len(data.MissingICCIDs)) fmt.Println("已跳过日记录导出,仅保留当前套餐/流量/钱包/佣金快照。") } func openLegacyDB(rawDSN string) (*gorm.DB, error) { dsn := ensureMySQLDSNOptions(rawDSN) return gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) } func ensureMySQLDSNOptions(dsn string) string { options := []string{"parseTime=true", "loc=Asia%2FShanghai", "charset=utf8mb4"} sep := "?" if strings.Contains(dsn, "?") { sep = "&" } lower := strings.ToLower(dsn) for _, option := range options { key := strings.ToLower(strings.SplitN(option, "=", 2)[0]) + "=" if strings.Contains(lower, key) { continue } dsn += sep + option sep = "&" } return dsn } func readInputCSV(path string) ([]inputAsset, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() reader := csv.NewReader(file) reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true rows, err := reader.ReadAll() if err != nil { return nil, err } if len(rows) < 2 { return nil, nil } header := make(map[string]int) for i, h := range rows[0] { header[normalizeHeader(h)] = i } var result []inputAsset for rowIndex := 1; rowIndex < len(rows); rowIndex++ { row := rows[rowIndex] base := inputAsset{ Line: rowIndex + 1, DeviceVirtualNo: pickValue(row, header, "device_virtual_no", "device_no", "virtual_no", "mchno", "设备号", "设备虚拟号", "机器码"), IMEI: pickValue(row, header, "imei", "device_imei", "mchno", "设备imei", "机器码"), SN: pickValue(row, header, "sn", "设备sn"), } if singleICCID := pickValue(row, header, "iccid", "卡号", "主卡", "main_iccid"); singleICCID != "" { slot := parseIntDefault(pickValue(row, header, "slot", "slot_position", "卡槽", "槽位"), 0) result = append(result, withICCID(base, slot, singleICCID, "input_iccid")) } for slot := 1; slot <= 4; slot++ { keys := slotHeaderCandidates(slot) iccid := pickValue(row, header, keys...) if iccid == "" { continue } result = append(result, withICCID(base, slot, iccid, "input_slot_iccid")) } if base.ICCID == "" && base.DeviceVirtualNo == "" && base.IMEI == "" && base.SN == "" { continue } if !rowHasICCID(result, base.Line) { base.ResolvedBy = "pending_device_key" if base.IMEI == "" && base.DeviceVirtualNo == "" { base.ResolvedBy = "unresolved" base.ResolveNote = "未提供 ICCID 或设备号/IMEI,无法从老库反查卡号" } result = append(result, base) } } return dedupeInputAssets(result), nil } func normalizeHeader(s string) string { s = strings.TrimSpace(strings.ToLower(s)) s = strings.TrimPrefix(s, "\ufeff") replacer := strings.NewReplacer(" ", "_", "-", "_", "(", "", ")", "", "(", "", ")", "") return replacer.Replace(s) } func slotHeaderCandidates(slot int) []string { n := strconv.Itoa(slot) return []string{ "iccid" + n, "iccid_" + n, "slot" + n + "_iccid", "slot_" + n + "_iccid", "card" + n, "card_" + n, "sim" + n, "sim_" + n, "卡" + n, "卡" + n + "iccid", "卡" + n + "_iccid", } } func pickValue(row []string, header map[string]int, keys ...string) string { for _, key := range keys { idx, ok := header[normalizeHeader(key)] if !ok || idx >= len(row) { continue } if value := normalizeCell(row[idx]); value != "" { return value } } return "" } func normalizeCell(s string) string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "'") s = strings.ReplaceAll(s, "\u00a0", "") return s } func withICCID(base inputAsset, slot int, iccid string, resolvedBy string) inputAsset { base.SlotPosition = slot base.ICCID = normalizeCell(iccid) base.ResolvedBy = resolvedBy return base } func dedupeInputAssets(inputs []inputAsset) []inputAsset { seen := make(map[string]bool) result := make([]inputAsset, 0, len(inputs)) for _, item := range inputs { key := fmt.Sprintf("%d|%s|%s|%s|%d", item.Line, item.DeviceVirtualNo, item.IMEI, item.ICCID, item.SlotPosition) if seen[key] { continue } seen[key] = true result = append(result, item) } return result } func groupInputsByICCID(inputs []inputAsset) map[string][]inputAsset { result := make(map[string][]inputAsset) for _, item := range inputs { if item.ICCID == "" { continue } result[item.ICCID] = append(result[item.ICCID], item) } return result } func rowHasICCID(inputs []inputAsset, line int) bool { for _, item := range inputs { if item.Line == line && item.ICCID != "" { return true } } return false } func resolveInputAssets(ctx context.Context, db *gorm.DB, schema string, inputs []inputAsset) ([]inputAsset, []inputAsset, error) { deviceKeys := collectPendingDeviceKeys(inputs) cardsByDeviceKey := make(map[string][]legacyCardIdentity) if len(deviceKeys) > 0 { var err error cardsByDeviceKey, err = loadCardIdentitiesByDeviceKey(ctx, db, schema, deviceKeys) if err != nil { return nil, nil, err } } resolved := make([]inputAsset, 0, len(inputs)) unresolved := make([]inputAsset, 0) for _, item := range inputs { if item.ICCID != "" { if item.ResolvedBy == "" { item.ResolvedBy = "input_iccid" } resolved = append(resolved, item) continue } keys := inputDeviceKeys(item) if len(keys) == 0 { item.ResolvedBy = "unresolved" if item.ResolveNote == "" { item.ResolveNote = "未提供 ICCID 或设备号/IMEI,无法从老库反查卡号" } unresolved = append(unresolved, item) continue } matches := uniqueIdentitiesForKeys(cardsByDeviceKey, keys) if len(matches) == 0 { item.ResolvedBy = "unresolved_device_key" item.ResolveNote = "老库 tbl_card 未按 vcode/imei 找到设备号对应的 ICCID" unresolved = append(unresolved, item) continue } for _, match := range matches { resolvedItem := item resolvedItem.ICCID = match.ICCID resolvedItem.ResolvedBy = match.ResolvedBy resolvedItem.ResolveNote = match.ResolveNote if match.SlotPosition > 0 { resolvedItem.SlotPosition = match.SlotPosition } if resolvedItem.ResolveNote == "" && len(matches) > 1 { resolvedItem.ResolveNote = fmt.Sprintf("同一设备号在老库命中 %d 张卡,已按 tbl_card_relate 展开", len(matches)) } if resolvedItem.ResolveNote == "" { if match.VCode != "" { resolvedItem.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID" } else if match.IMEI != "" { resolvedItem.ResolveNote = "按老库 tbl_card.imei 反查 ICCID" } } resolved = append(resolved, resolvedItem) } } return dedupeInputAssets(resolved), dedupeInputAssets(unresolved), nil } func collectPendingDeviceKeys(inputs []inputAsset) []string { set := make(map[string]bool) for _, item := range inputs { if item.ICCID == "" { for _, key := range inputDeviceKeys(item) { addNonEmpty(set, key) } } } return sortedKeys(set) } func inputDeviceKeys(item inputAsset) []string { set := make(map[string]bool) addNonEmpty(set, item.IMEI) addNonEmpty(set, item.DeviceVirtualNo) return sortedKeys(set) } func uniqueIdentitiesForKeys(source map[string][]legacyCardIdentity, keys []string) []legacyCardIdentity { byICCID := make(map[string]legacyCardIdentity) for _, key := range keys { for _, item := range source[key] { if item.ICCID == "" { continue } existing, ok := byICCID[item.ICCID] if !ok || identityPriority(item) > identityPriority(existing) { byICCID[item.ICCID] = item } } } iccids := sortedKeys(byICCID) result := make([]legacyCardIdentity, 0, len(iccids)) for _, iccid := range iccids { result = append(result, byICCID[iccid]) } sort.SliceStable(result, func(i, j int) bool { if result[i].SlotPosition != result[j].SlotPosition { if result[i].SlotPosition == 0 { return false } if result[j].SlotPosition == 0 { return true } return result[i].SlotPosition < result[j].SlotPosition } return result[i].ICCID < result[j].ICCID }) return result } func identityPriority(item legacyCardIdentity) int { if item.SlotPosition > 0 && strings.Contains(item.ResolvedBy, "relate") { return 3 } if item.VCode != "" { return 2 } return 1 } func loadCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) { directByKey, err := loadDirectCardIdentitiesByDeviceKey(ctx, db, schema, keys) if err != nil { return nil, err } if len(directByKey) == 0 { return directByKey, nil } directICCIDs := collectDirectIdentityICCIDs(directByKey) relations, err := loadCardRelationsByICCIDs(ctx, db, schema, directICCIDs) if err != nil { return nil, err } if len(relations) == 0 { return directByKey, nil } expanded := make(map[string][]legacyCardIdentity, len(directByKey)) for key, directRows := range directByKey { byICCID := make(map[string]legacyCardIdentity) for _, row := range directRows { byICCID[row.ICCID] = row for _, relation := range relations { if !relationContains(relation, row.ICCID) { continue } addRelationIdentity(byICCID, relation.MainICCID, 1, "主卡,按 tbl_card_relate 从设备号命中卡展开") addRelationIdentity(byICCID, relation.FuICCID, 2, "副卡1,按 tbl_card_relate 从设备号命中卡展开") addRelationIdentity(byICCID, relation.FuICCID2, 3, "副卡2,按 tbl_card_relate 从设备号命中卡展开") } } expanded[key] = identitiesFromMap(byICCID) } return expanded, nil } func loadDirectCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) { result := make(map[string][]legacyCardIdentity) keySet := make(map[string]bool, len(keys)) for _, key := range keys { keySet[key] = true } for _, batch := range chunkStrings(keys, defaultBatchSize) { var rows []legacyCardIdentity sql := fmt.Sprintf(` SELECT iccid_mark AS iccid, imei, vcode FROM %s.tbl_card WHERE (vcode IN ? OR imei IN ?) AND iccid_mark IS NOT NULL AND iccid_mark <> '' ORDER BY vcode ASC, imei ASC, iccid_mark ASC `, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch, batch).Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { if row.ICCID == "" { continue } if row.VCode != "" && keySet[row.VCode] { item := row item.ResolvedBy = "old_tbl_card_vcode" item.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID" result[row.VCode] = append(result[row.VCode], item) } if row.IMEI != "" && keySet[row.IMEI] { item := row item.ResolvedBy = "old_tbl_card_imei" item.ResolveNote = "按老库 tbl_card.imei 反查 ICCID" result[row.IMEI] = append(result[row.IMEI], item) } } } return result, nil } func collectDirectIdentityICCIDs(directByKey map[string][]legacyCardIdentity) []string { set := make(map[string]bool) for _, rows := range directByKey { for _, row := range rows { addNonEmpty(set, row.ICCID) } } return sortedKeys(set) } func loadCardRelationsByICCIDs(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]cardRelation, error) { var result []cardRelation for _, batch := range chunkStrings(iccids, defaultBatchSize) { var rows []cardRelation sql := fmt.Sprintf(` SELECT main_iccid, fu_iccid, fu_iccid2 FROM %s.tbl_card_relate WHERE main_iccid IN ? OR fu_iccid IN ? OR fu_iccid2 IN ? `, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch, batch, batch).Scan(&rows).Error; err != nil { return nil, err } result = append(result, rows...) } return result, nil } func relationContains(relation cardRelation, iccid string) bool { return relation.MainICCID == iccid || relation.FuICCID == iccid || relation.FuICCID2 == iccid } func addRelationIdentity(byICCID map[string]legacyCardIdentity, iccid string, slot int, note string) { if iccid == "" { return } item := legacyCardIdentity{ ICCID: iccid, ResolvedBy: "old_tbl_card_relate", ResolveNote: note, SlotPosition: slot, } existing, ok := byICCID[iccid] if ok { item.IMEI = existing.IMEI item.VCode = existing.VCode if existing.ResolvedBy == "old_tbl_card_vcode" || existing.ResolvedBy == "old_tbl_card_imei" { item.ResolvedBy = existing.ResolvedBy + "+old_tbl_card_relate" } } if !ok || identityPriority(item) > identityPriority(existing) { byICCID[iccid] = item } } func identitiesFromMap(byICCID map[string]legacyCardIdentity) []legacyCardIdentity { iccids := sortedKeys(byICCID) result := make([]legacyCardIdentity, 0, len(iccids)) for _, iccid := range iccids { result = append(result, byICCID[iccid]) } sort.SliceStable(result, func(i, j int) bool { if result[i].SlotPosition != result[j].SlotPosition { if result[i].SlotPosition == 0 { return false } else { if result[j].SlotPosition == 0 { return true } } return result[i].SlotPosition < result[j].SlotPosition } return result[i].ICCID < result[j].ICCID }) return result } func loadCardSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]cardSnapshot) error { for _, batch := range chunkStrings(iccids, defaultBatchSize) { var rows []cardSnapshot sql := fmt.Sprintf(` SELECT c.id AS card_id, c.iccid_mark AS iccid, c.phone, c.phone_zuhe, c.imei AS old_imei, c.vcode AS old_vcode, CAST(c.status AS CHAR) AS status, CAST(c.type AS CHAR) AS type, CAST(c.category AS CHAR) AS category, vc.category_name AS category_name, c.card_flow, c.set_meal_id, c.card_package, c.expire_time, CAST(c.flow_size AS CHAR) AS flow_size, c.total_bytes_cnt, c.synchro_time, c.serv_create_date AS serv_create_at, c.balance_money, c.agent_id, c.agent_name, c.account_id, c.account_name, c.agent_three_id FROM %s.tbl_card c LEFT JOIN %s.tbl_vendor_category vc ON vc.category = c.category WHERE c.iccid_mark IN ? `, quoteSchema(schema), quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { return err } for _, row := range rows { if row.ICCID == "" { continue } out[row.ICCID] = row } } return nil } func loadPackageSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string][]packageSnapshot) error { for _, batch := range chunkStrings(iccids, defaultBatchSize) { var currentRows []packageSnapshot currentSQL := fmt.Sprintf(` SELECT '%s' AS source_table, id, iccid_mark AS iccid, meal_id, meal_name, order_id, start_date AS start_at, expire_date AS expires_at, CAST(status AS CHAR) AS status, CAST(type AS CHAR) AS type, CAST(flow_size AS CHAR) AS flow_size, CAST(effect_type AS CHAR) AS effect_type, update_date_time, create_time AS created_at, update_time AS updated_at FROM %s.tbl_card_life WHERE iccid_mark IN ? `, sourceCardLife, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(currentSQL, batch).Scan(¤tRows).Error; err != nil { return err } for _, row := range currentRows { if row.ICCID == "" { continue } out[row.ICCID] = append(out[row.ICCID], row) } var pendingRows []packageSnapshot pendingSQL := fmt.Sprintf(` SELECT '%s' AS source_table, id, iccid_mark AS iccid, meal_id, meal_name, agent_wx_order_id AS order_id, effect_begin_time AS effect_begin_time, effect_complete_time AS effect_complete_time, CAST(status AS CHAR) AS status, CAST(flow_size AS CHAR) AS flow_size, order_type, create_date AS created_at FROM %s.tbl_next_month_card_life WHERE iccid_mark IN ? `, sourceNextMonthLife, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(pendingSQL, batch).Scan(&pendingRows).Error; err != nil { return err } for _, row := range pendingRows { if row.ICCID == "" { continue } out[row.ICCID] = append(out[row.ICCID], row) } } for iccid := range out { sort.SliceStable(out[iccid], func(i, j int) bool { left := out[iccid][i] right := out[iccid][j] if left.SourceTable != right.SourceTable { return left.SourceTable == sourceCardLife } return timePtrBefore(left.StartAt, right.StartAt) }) } return nil } func loadWalletSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]walletSnapshot) error { for _, batch := range chunkStrings(iccids, defaultBatchSize) { var rows []walletSnapshot sql := fmt.Sprintf(` SELECT iccid_mark AS iccid, CAST(money_balance AS CHAR) AS money_balance, CAST(tx_version AS CHAR) AS tx_version, CAST(meal_auto_renew_status AS CHAR) AS meal_auto_renew_status, auto_renew_meal_id, CAST(djb_meal_auto_renew_status AS CHAR) AS addon_auto_renew_status, djb_auto_renew_meal_id AS addon_auto_renew_meal_id, meal_auto_renew_udpate_time AS meal_auto_renew_updated_at, create_date AS created_at FROM %s.tbl_card_wallet WHERE iccid_mark IN ? `, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { return err } for _, row := range rows { if row.ICCID == "" { continue } out[row.ICCID] = row } } return nil } func loadCommissionAccounts(ctx context.Context, db *gorm.DB, schema string, agentIDs []string, out map[string]commissionAccountSnapshot) error { for _, batch := range chunkStrings(agentIDs, defaultBatchSize) { var rows []commissionAccountSnapshot sql := fmt.Sprintf(` SELECT agent_id, agent_name, agent_login_account, CAST(total_commision_amount AS CHAR) AS total_commission_amount, CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount, CAST(mf_commision_amount AS CHAR) AS mf_commission_amount, CAST(nomf_commision_amount AS CHAR) AS no_mf_commission_amount, CAST(total_draw_amount AS CHAR) AS total_draw_amount, CAST(can_draw_amount AS CHAR) AS can_draw_amount, CAST(applying_draw_amount AS CHAR) AS applying_draw_amount, remark, CAST(tx_version AS CHAR) AS tx_version, create_date AS created_at, update_date AS updated_at FROM %s.tbl_agent_commission_account WHERE agent_id IN ? `, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { return err } for _, row := range rows { out[row.AgentID] = row } } return nil } func loadCommissionDetails(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]commissionDetailSnapshot, error) { var result []commissionDetailSnapshot for _, batch := range chunkStrings(iccids, defaultBatchSize) { var rows []commissionDetailSnapshot sql := fmt.Sprintf(` SELECT id, commission_account_id, wx_order_id, wx_order_number, iccid_mark AS iccid, phone, wx_order_date AS wx_order_at, create_date AS created_at, CAST(commission_amount AS CHAR) AS commission_amount, CAST(total_commission_amount AS CHAR) AS total_commission_amount, CAST(mf_status AS CHAR) AS mf_status, CAST(commission_type AS CHAR) AS commission_type, CAST(active_award_commision_amount AS CHAR) AS active_award_commission_amount, CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount FROM %s.tbl_agent_commission_detail WHERE iccid_mark IN ? ORDER BY create_date ASC `, quoteSchema(schema)) if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { if row.ICCID == "" { continue } result = append(result, row) } } return result, nil } func writeOutputs(outputDir string, data *exportData, includeCommissionDetails bool) error { writers := []struct { name string writer func(string, *exportData) error }{ {"input_assets.csv", writeInputAssets}, {"unresolved_inputs.csv", writeUnresolvedInputs}, {"card_snapshot.csv", writeCardSnapshot}, {"package_usage_snapshot.csv", writePackageUsageSnapshot}, {"wallet_snapshot.csv", writeWalletSnapshot}, {"commission_account_snapshot.csv", writeCommissionAccountSnapshot}, {"missing_cards.csv", writeMissingCards}, {"summary.txt", writeSummary}, {"field_dictionary.csv", writeFieldDictionary}, {"enum_dictionary.csv", writeEnumDictionary}, {"README_zh.md", writeOutputGuide}, } if includeCommissionDetails { writers = append(writers, struct { name string writer func(string, *exportData) error }{"commission_detail_snapshot.csv", writeCommissionDetailSnapshot}) } for _, item := range writers { if err := item.writer(filepath.Join(outputDir, item.name), data); err != nil { return fmt.Errorf("%s: %w", item.name, err) } } return nil } func writeInputAssets(path string, data *exportData) error { return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error { for _, item := range data.Inputs { if err := w.Write([]string{ strconv.Itoa(item.Line), item.DeviceVirtualNo, item.IMEI, item.SN, intString(item.SlotPosition), slotPositionName(item.SlotPosition), item.ICCID, item.ResolvedBy, resolvedByName(item.ResolvedBy), item.ResolveNote, }); err != nil { return err } } return nil }) } func writeUnresolvedInputs(path string, data *exportData) error { return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error { for _, item := range data.UnresolvedInputs { if err := w.Write([]string{ strconv.Itoa(item.Line), item.DeviceVirtualNo, item.IMEI, item.SN, intString(item.SlotPosition), slotPositionName(item.SlotPosition), item.ICCID, item.ResolvedBy, resolvedByName(item.ResolvedBy), item.ResolveNote, }); err != nil { return err } } return nil }) } func writeCardSnapshot(path string, data *exportData) error { header := []string{ "iccid", "input_device_virtual_no", "input_imei", "input_sn", "input_slot_positions", "old_card_id", "old_phone", "old_phone_zuhe", "old_imei", "old_vcode", "old_status", "old_status_name", "old_type", "old_type_name", "old_category", "old_category_name", "old_account_id", "old_account_name", "old_agent_id", "old_agent_name", "old_agent_three_id", "old_set_meal_id", "old_card_package", "old_expire_time", "old_flow_size_mb", "old_total_bytes_cnt_mb", "current_used_mb_decimal", "current_used_mb_int", "old_synchro_time", "old_serv_create_at", "old_balance_money", "suggested_tb_iot_card_data_usage_mb", "suggested_tb_iot_card_current_month_usage_mb", "suggested_tb_iot_card_last_gateway_reading_mb", } return writeCSV(path, header, func(w *csv.Writer) error { for _, iccid := range sortedKeys(data.InputByICCID) { card := data.Cards[iccid] inputSummary := summarizeInputs(data.InputByICCID[iccid]) usedDecimal := card.TotalBytesCnt usedInt := int64FloorString(usedDecimal) if err := w.Write([]string{ iccid, inputSummary.DeviceVirtualNos, inputSummary.IMEIs, inputSummary.SNs, inputSummary.Slots, card.CardID, card.Phone, card.PhoneZuhe, card.OldIMEI, card.OldVCode, card.Status, oldCardStatusName(card.Status), card.Type, oldCardTypeName(card.Type), card.Category, oldCardCategoryName(card.Category, card.CategoryName), card.AccountID, card.AccountName, card.AgentID, card.AgentName, card.AgentThreeID, card.SetMealID, card.CardPackage, card.ExpireTime, card.FlowSize, card.TotalBytesCnt, usedDecimal, int64String(usedInt), card.SynchroTime, formatTime(card.ServCreateAt), card.BalanceMoney, int64String(usedInt), usedDecimal, usedDecimal, }); err != nil { return err } } return nil }) } func writePackageUsageSnapshot(path string, data *exportData) error { header := []string{ "iccid", "input_device_virtual_no", "source_table", "source_table_name", "old_usage_id", "old_meal_id", "old_meal_name", "old_order_id", "start_at", "expires_at", "effective_start_at", "effective_expires_at", "date_source_note", "effect_begin_time", "effect_complete_time", "record_created_at", "record_updated_at", "old_status", "old_status_name", "old_type", "old_type_name", "effect_type", "effect_type_name", "order_type", "order_type_name", "flow_limit_mb", "card_current_used_mb_decimal", "allocated_used_mb", "remaining_mb", "suggested_new_status", "suggested_new_status_name", "is_pending_next_month", } now := time.Now() return writeCSV(path, header, func(w *csv.Writer) error { for _, iccid := range sortedKeys(data.InputByICCID) { card := data.Cards[iccid] currentUsed := int64FloorString(card.TotalBytesCnt) remainingUsed := currentUsed inputSummary := summarizeInputs(data.InputByICCID[iccid]) rows := data.PackagesByICCID[iccid] for _, pkg := range rows { limit := int64FloorString(pkg.FlowSize) allocatedUsed := int64(0) if pkg.SourceTable == sourceCardLife && isActiveOldPackage(pkg, now) { allocatedUsed = minInt64(remainingUsed, limit) remainingUsed -= allocatedUsed } remaining := limit - allocatedUsed if remaining < 0 { remaining = 0 } suggestedStatus := suggestPackageUsageStatus(pkg, allocatedUsed, limit, now) isPendingNextMonth := "" if pkg.SourceTable == sourceNextMonthLife { isPendingNextMonth = "true" } else { isPendingNextMonth = "false" } if err := w.Write([]string{ iccid, inputSummary.DeviceVirtualNos, pkg.SourceTable, sourceTableName(pkg.SourceTable), pkg.ID, pkg.MealID, pkg.MealName, pkg.OrderID, formatTime(pkg.StartAt), formatTime(pkg.ExpiresAt), formatTime(effectivePackageStartAt(pkg)), formatTime(effectivePackageExpiresAt(pkg)), packageDateSourceNote(pkg), formatTime(pkg.EffectBeginTime), formatTime(pkg.EffectCompleteTime), formatTime(pkg.CreatedAt), formatTime(pkg.UpdatedAt), pkg.Status, oldPackageStatusName(pkg.SourceTable, pkg.Status), pkg.Type, oldPackageTypeName(pkg.Type), pkg.EffectType, oldEffectTypeName(pkg.EffectType), pkg.OrderType, oldOrderTypeName(pkg.OrderType), int64String(limit), card.TotalBytesCnt, int64String(allocatedUsed), int64String(remaining), suggestedStatus, newPackageUsageStatusName(suggestedStatus), isPendingNextMonth, }); err != nil { return err } } } return nil }) } func writeWalletSnapshot(path string, data *exportData) error { header := []string{ "iccid", "money_balance_yuan", "money_balance_fen", "tx_version", "meal_auto_renew_status", "meal_auto_renew_status_name", "auto_renew_meal_id", "addon_auto_renew_status", "addon_auto_renew_status_name", "addon_auto_renew_meal_id", "meal_auto_renew_updated_at", "created_at", } return writeCSV(path, header, func(w *csv.Writer) error { for _, iccid := range sortedKeys(data.InputByICCID) { wallet := data.Wallets[iccid] if err := w.Write([]string{ iccid, wallet.MoneyBalance, decimalStringToCentsString(wallet.MoneyBalance), wallet.TxVersion, wallet.MealAutoRenewStatus, autoRenewStatusName(wallet.MealAutoRenewStatus), wallet.AutoRenewMealID, wallet.AddonAutoRenewStatus, autoRenewStatusName(wallet.AddonAutoRenewStatus), wallet.AddonAutoRenewMealID, formatTime(wallet.MealAutoRenewUpdatedAt), formatTime(wallet.CreatedAt), }); err != nil { return err } } return nil }) } func writeCommissionAccountSnapshot(path string, data *exportData) error { header := []string{ "old_agent_id", "old_agent_name", "old_agent_login_account", "suggested_new_shop_id", "total_commission_yuan", "total_commission_fen", "total_active_award_commission_yuan", "total_active_award_commission_fen", "mf_commission_yuan", "mf_commission_fen", "nomf_commission_yuan", "nomf_commission_fen", "total_draw_yuan", "total_draw_fen", "can_draw_yuan", "can_draw_fen", "applying_draw_yuan", "applying_draw_fen", "remark", "tx_version", "created_at", "updated_at", } return writeCSV(path, header, func(w *csv.Writer) error { for _, agentID := range sortedKeys(data.CommissionAccounts) { account := data.CommissionAccounts[agentID] if err := w.Write([]string{ account.AgentID, account.AgentName, account.AgentLoginAccount, "", account.TotalCommissionAmount, decimalStringToCentsString(account.TotalCommissionAmount), account.TotalActiveAwardCommissionAmount, decimalStringToCentsString(account.TotalActiveAwardCommissionAmount), account.MFCommissionAmount, decimalStringToCentsString(account.MFCommissionAmount), account.NoMFCommissionAmount, decimalStringToCentsString(account.NoMFCommissionAmount), account.TotalDrawAmount, decimalStringToCentsString(account.TotalDrawAmount), account.CanDrawAmount, decimalStringToCentsString(account.CanDrawAmount), account.ApplyingDrawAmount, decimalStringToCentsString(account.ApplyingDrawAmount), account.Remark, account.TxVersion, formatTime(account.CreatedAt), formatTime(account.UpdatedAt), }); err != nil { return err } } return nil }) } func writeCommissionDetailSnapshot(path string, data *exportData) error { header := []string{ "id", "commission_account_id", "wx_order_id", "wx_order_number", "iccid", "phone", "wx_order_at", "created_at", "commission_amount_yuan", "commission_amount_fen", "total_commission_amount_yuan", "total_commission_amount_fen", "mf_status", "mf_status_name", "commission_type", "commission_type_name", "active_award_commission_amount_yuan", "active_award_commission_amount_fen", "total_active_award_commission_amount_yuan", "total_active_award_commission_amount_fen", } return writeCSV(path, header, func(w *csv.Writer) error { for _, item := range data.CommissionDetails { if err := w.Write([]string{ item.ID, item.CommissionAccountID, item.WxOrderID, item.WxOrderNumber, item.ICCID, item.Phone, formatTime(item.WxOrderAt), formatTime(item.CreatedAt), item.CommissionAmount, decimalStringToCentsString(item.CommissionAmount), item.TotalCommissionAmount, decimalStringToCentsString(item.TotalCommissionAmount), item.MFStatus, mfStatusName(item.MFStatus), item.CommissionType, commissionTypeName(item.CommissionType), item.ActiveAwardCommissionAmount, decimalStringToCentsString(item.ActiveAwardCommissionAmount), item.TotalActiveAwardCommissionAmount, decimalStringToCentsString(item.TotalActiveAwardCommissionAmount), }); err != nil { return err } } return nil }) } func writeMissingCards(path string, data *exportData) error { return writeCSV(path, []string{"iccid"}, func(w *csv.Writer) error { for _, iccid := range data.MissingICCIDs { if err := w.Write([]string{iccid}); err != nil { return err } } return nil }) } func writeSummary(path string, data *exportData) error { packageCount := 0 for _, rows := range data.PackagesByICCID { packageCount += len(rows) } content := strings.Builder{} content.WriteString("老系统设备迁移快照导出摘要\n") content.WriteString("================================\n") content.WriteString(fmt.Sprintf("生成时间: %s\n", time.Now().Format(timeFormat))) content.WriteString(fmt.Sprintf("已解析输入关系行数: %d\n", len(data.Inputs))) content.WriteString(fmt.Sprintf("未解析输入行数: %d\n", len(data.UnresolvedInputs))) content.WriteString(fmt.Sprintf("输入 ICCID 数: %d\n", len(data.InputByICCID))) content.WriteString(fmt.Sprintf("老库卡本体命中数: %d\n", len(data.Cards))) content.WriteString(fmt.Sprintf("老库缺失 ICCID 数: %d\n", len(data.MissingICCIDs))) content.WriteString(fmt.Sprintf("套餐快照行数: %d\n", packageCount)) content.WriteString(fmt.Sprintf("钱包快照行数: %d\n", len(data.Wallets))) content.WriteString(fmt.Sprintf("佣金账户行数: %d\n", len(data.CommissionAccounts))) content.WriteString(fmt.Sprintf("佣金明细行数: %d\n", len(data.CommissionDetails))) content.WriteString("\n说明:\n") content.WriteString("- 本工具只导出当前业务快照,不导出日流量记录。\n") content.WriteString("- 输入 CSV 可以不提供 SN 和 ICCID;不提供 ICCID 时会按老库 tbl_card.vcode/imei 反查 ICCID,并通过 tbl_card_relate 展开关联卡。\n") content.WriteString("- unresolved_inputs.csv 中的行没有解析出 ICCID,需要补设备号/IMEI/ICCID 后再重新导出。\n") content.WriteString("- 字段含义看 field_dictionary.csv;状态码和类型码看 enum_dictionary.csv;阅读入口看 README_zh.md。\n") content.WriteString("- package_usage_snapshot.csv 中 allocated_used_mb/remaining_mb 是按老卡当前已用量顺序分摊后的建议值,后续导入新库前仍需结合套餐映射复核。\n") content.WriteString("- commission_account_snapshot.csv 预留 suggested_new_shop_id 空列,后续导入时填新系统 shop_id。\n") return os.WriteFile(path, []byte(content.String()), 0644) } func writeCSV(path string, header []string, fill func(*csv.Writer) error) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() writer := csv.NewWriter(file) if err := writer.Write(header); err != nil { return err } if err := fill(writer); err != nil { return err } writer.Flush() return writer.Error() } type inputSummary struct { DeviceVirtualNos string IMEIs string SNs string Slots string } func summarizeInputs(inputs []inputAsset) inputSummary { deviceNos := make(map[string]bool) imeis := make(map[string]bool) sns := make(map[string]bool) slots := make(map[string]bool) for _, item := range inputs { addNonEmpty(deviceNos, item.DeviceVirtualNo) addNonEmpty(imeis, item.IMEI) addNonEmpty(sns, item.SN) if item.SlotPosition > 0 { slots[strconv.Itoa(item.SlotPosition)] = true } } return inputSummary{ DeviceVirtualNos: strings.Join(sortedKeys(deviceNos), "|"), IMEIs: strings.Join(sortedKeys(imeis), "|"), SNs: strings.Join(sortedKeys(sns), "|"), Slots: strings.Join(sortedKeys(slots), "|"), } } func addNonEmpty(set map[string]bool, value string) { if value != "" { set[value] = true } } func collectAgentIDs(cards map[string]cardSnapshot) []string { set := make(map[string]bool) for _, card := range cards { addNonEmpty(set, card.AgentID) } return sortedKeys(set) } func isActiveOldPackage(pkg packageSnapshot, now time.Time) bool { if pkg.Status != "1" { return false } if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) { return false } return true } func suggestPackageUsageStatus(pkg packageSnapshot, used, limit int64, now time.Time) string { if pkg.SourceTable == sourceNextMonthLife { return "0" } if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) { return "3" } if limit > 0 && used >= limit { return "2" } if pkg.Status == "1" { return "1" } return pkg.Status } func effectivePackageStartAt(pkg packageSnapshot) *time.Time { switch pkg.SourceTable { case sourceNextMonthLife: if pkg.EffectBeginTime != nil { return pkg.EffectBeginTime } case sourceCardLife: if pkg.StartAt != nil { return pkg.StartAt } } return pkg.CreatedAt } func effectivePackageExpiresAt(pkg packageSnapshot) *time.Time { if pkg.ExpiresAt != nil { return pkg.ExpiresAt } return nil } func packageDateSourceNote(pkg packageSnapshot) string { if pkg.SourceTable == sourceNextMonthLife { if pkg.EffectBeginTime != nil { return "待生效套餐开始时间来自 tbl_next_month_card_life.effect_begin_time;老库未提供到期时间" } if pkg.CreatedAt != nil { return "待生效套餐 effect_begin_time 为空,effective_start_at 使用 create_date 兜底;老库未提供到期时间" } return "待生效套餐没有开始/到期时间" } if pkg.StartAt != nil && pkg.ExpiresAt != nil { return "开始时间来自 tbl_card_life.start_date,到期时间来自 tbl_card_life.expire_date" } if pkg.StartAt == nil && pkg.CreatedAt != nil && pkg.ExpiresAt != nil { return "tbl_card_life.start_date 为空,effective_start_at 使用 create_time 兜底;到期时间来自 expire_date" } if pkg.StartAt == nil && pkg.CreatedAt != nil { return "tbl_card_life.start_date 为空,effective_start_at 使用 create_time 兜底" } if pkg.ExpiresAt != nil { return "开始时间为空;到期时间来自 tbl_card_life.expire_date" } return "老库套餐记录没有开始/到期时间" } func decimalStringToCentsString(value string) string { if strings.TrimSpace(value) == "" { return "" } r, ok := new(big.Rat).SetString(strings.TrimSpace(value)) if !ok { return "" } r.Mul(r, big.NewRat(100, 1)) result := new(big.Int) // 金额只用于迁移快照,采用四舍五入到分。 if r.Sign() >= 0 { r.Add(r, big.NewRat(1, 2)) } else { r.Sub(r, big.NewRat(1, 2)) } result.Quo(r.Num(), r.Denom()) return result.String() } func int64FloorString(value string) int64 { value = strings.TrimSpace(value) if value == "" { return 0 } f, err := strconv.ParseFloat(value, 64) if err != nil { return 0 } return int64(math.Floor(f)) } func parseIntDefault(value string, defaultValue int) int { if value == "" { return defaultValue } n, err := strconv.Atoi(value) if err != nil { return defaultValue } return n } func formatTime(t *time.Time) string { if t == nil || t.IsZero() { return "" } return t.In(time.Local).Format(timeFormat) } func timePtrBefore(left, right *time.Time) bool { if left == nil && right == nil { return false } if left == nil { return false } if right == nil { return true } return left.Before(*right) } func chunkStrings(values []string, size int) [][]string { if size <= 0 { size = defaultBatchSize } var chunks [][]string for start := 0; start < len(values); start += size { end := start + size if end > len(values) { end = len(values) } chunks = append(chunks, values[start:end]) } return chunks } func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) } sort.Strings(keys) return keys } func quoteSchema(schema string) string { return "`" + schema + "`" } func minInt64(a, b int64) int64 { if a < b { return a } return b } func intString(value int) string { if value == 0 { return "" } return strconv.Itoa(value) } func int64String(value int64) string { return strconv.FormatInt(value, 10) } func getenvDefault(key, defaultValue string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return defaultValue } return value } func exitWithError(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) } type fieldDefinition struct { FileName string ColumnName string ChineseName string Description string } type enumDefinition struct { FileName string ColumnName string Value string Meaning string Source string Note string } var oldCardStatusNames = map[string]string{ "1": "待激活", "2": "正常", "3": "断网", "4": "停机", "5": "异常", "6": "强制停机", "7": "注销", } var oldCardTypeNames = map[string]string{ "1": "包月", "2": "包年", } var oldCategoryNames = map[string]string{ "1": "电信", "2": "其他卡务", "3": "移动", "4": "联通", "5": "dcp电信", "9": "中国联通", "10": "D移动", "21": "S物联", "22": "W物联", "23": "L物联", "24": "P物联", "25": "D物联", "26": "M物联", "27": "WX物联", "28": "CH物联", "31": "YW物联", "40": "XS物联", "56": "ZT物联", "67": "TY物联", "70": "XR物联", "71": "ZYM物联", "89": "LW物联", "94": "DC物联", "108": "CMP5G", "112": "GD物联", "113": "GDIot", "118": "GDWL", "124": "GS电信", "125": "GS联通", "126": "GS移动", } var oldCardLifeStatusNames = map[string]string{ "1": "正常", "2": "停卡", } var oldNextMonthLifeStatusNames = map[string]string{ "1": "未生效", "2": "已生效,已插入 tbl_card_life", } var oldPackageTypeNames = map[string]string{ "1": "叠加包", "2": "月卡", "3": "季卡", "4": "半年卡", "5": "年卡", } var oldEffectTypeNames = map[string]string{ "1": "本月生效", "2": "次月生效", } var oldOrderTypeNames = map[string]string{ "1": "微信订单", "2": "后台订单", } var autoRenewStatusNames = map[string]string{ "1": "启用", "2": "关闭", } var newPackageUsageStatusNames = map[string]string{ "0": "待生效", "1": "生效中", "2": "已用完", "3": "已过期", "4": "已失效", } var mfStatusNames = map[string]string{ "0": "成功", "1": "失败", } var commissionTypeNames = map[string]string{ "1": "微信订单佣金", "2": "激活奖励佣金", "3": "微信订单佣金扣回", "4": "激活奖励佣金扣回", } func writeFieldDictionary(path string, data *exportData) error { fields := []fieldDefinition{ {"input_assets.csv", "line", "输入行号", "原始输入 CSV 中的行号,便于回查业务给的源数据。"}, {"input_assets.csv", "device_virtual_no", "输入设备号", "输入 CSV 中识别到的设备号/机器码。"}, {"input_assets.csv", "imei", "输入 IMEI", "输入 CSV 中识别到的 IMEI;老系统接口参数 mchno 实际通常对应 tbl_card.vcode。"}, {"input_assets.csv", "sn", "输入 SN", "输入 CSV 中透传的 SN,可为空。"}, {"input_assets.csv", "slot_position", "卡槽序号", "脚本按老系统关联表展开后的卡槽:1 主卡,2 副卡1,3 副卡2。"}, {"input_assets.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"}, {"input_assets.csv", "iccid", "ICCID", "解析出来的老系统卡 ICCID。"}, {"input_assets.csv", "resolved_by", "解析方式", "说明 ICCID 是由输入直接提供,还是按 tbl_card.vcode/imei 及 tbl_card_relate 反查得到。"}, {"input_assets.csv", "resolved_by_name", "解析方式名称", "解析方式的中文摘要。"}, {"input_assets.csv", "resolve_note", "解析说明", "更详细的解析过程说明。"}, {"unresolved_inputs.csv", "line", "输入行号", "原始输入 CSV 中无法解析出 ICCID 的行号。"}, {"unresolved_inputs.csv", "device_virtual_no", "输入设备号", "未解析行中的设备号/机器码。"}, {"unresolved_inputs.csv", "imei", "输入 IMEI", "未解析行中的 IMEI。"}, {"unresolved_inputs.csv", "sn", "输入 SN", "未解析行中的 SN。"}, {"unresolved_inputs.csv", "slot_position", "卡槽序号", "未解析行中的卡槽序号。"}, {"unresolved_inputs.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"}, {"unresolved_inputs.csv", "iccid", "ICCID", "未解析行通常为空;若有值仍未解析,需要人工核对。"}, {"unresolved_inputs.csv", "resolved_by", "解析方式", "未解析状态码。"}, {"unresolved_inputs.csv", "resolved_by_name", "解析方式名称", "未解析状态的中文摘要。"}, {"unresolved_inputs.csv", "resolve_note", "未解析原因", "说明为什么无法从老系统反查到 ICCID。"}, {"card_snapshot.csv", "iccid", "ICCID", "老系统卡 ICCID,也是后续新系统导入时对齐卡的关键标识。"}, {"card_snapshot.csv", "input_device_virtual_no", "输入设备号", "该卡来自哪些输入设备号;多个值用 | 分隔。"}, {"card_snapshot.csv", "input_imei", "输入 IMEI", "该卡来自哪些输入 IMEI;多个值用 | 分隔。"}, {"card_snapshot.csv", "input_sn", "输入 SN", "该卡来自哪些输入 SN;多个值用 | 分隔。"}, {"card_snapshot.csv", "input_slot_positions", "输入卡槽", "该卡在设备关联中的卡槽序号;多个值用 | 分隔。"}, {"card_snapshot.csv", "old_card_id", "老卡 ID", "老系统 tbl_card.id。"}, {"card_snapshot.csv", "old_phone", "老系统号码", "老系统 tbl_card.phone,一般为 MSISDN/卡号展示字段。"}, {"card_snapshot.csv", "old_phone_zuhe", "组合号码", "老系统 tbl_card.phone_zuhe。"}, {"card_snapshot.csv", "old_imei", "老系统 IMEI", "老系统 tbl_card.imei;本批样本中很多为空。"}, {"card_snapshot.csv", "old_vcode", "老系统设备码", "老系统 tbl_card.vcode;老接口 mchno 通常按这个字段查询。"}, {"card_snapshot.csv", "old_status", "老卡状态值", "老系统 tbl_card.status 原值。"}, {"card_snapshot.csv", "old_status_name", "老卡状态", "old_status 的中文含义。"}, {"card_snapshot.csv", "old_type", "老卡类型值", "老系统 tbl_card.type 原值。"}, {"card_snapshot.csv", "old_type_name", "老卡类型", "old_type 的中文含义。"}, {"card_snapshot.csv", "old_category", "老运营商分类值", "老系统 tbl_card.category 原值。"}, {"card_snapshot.csv", "old_category_name", "老运营商分类", "优先来自 tbl_vendor_category.category_name。"}, {"card_snapshot.csv", "old_account_id", "老供应账号 ID", "老系统 tbl_card.account_id。"}, {"card_snapshot.csv", "old_account_name", "老供应账号名称", "老系统 tbl_card.account_name。"}, {"card_snapshot.csv", "old_agent_id", "老代理 ID", "老系统 tbl_card.agent_id。"}, {"card_snapshot.csv", "old_agent_name", "老代理名称", "老系统 tbl_card.agent_name。"}, {"card_snapshot.csv", "old_agent_three_id", "三级代理 ID", "老系统 tbl_card.agent_three_id。"}, {"card_snapshot.csv", "old_set_meal_id", "老套餐 ID", "老系统 tbl_card.set_meal_id。"}, {"card_snapshot.csv", "old_card_package", "老套餐名称", "老系统 tbl_card.card_package。"}, {"card_snapshot.csv", "old_expire_time", "老卡到期时间", "老系统 tbl_card.expire_time;行业卡可能有值,普通设备套餐以 package_usage_snapshot.csv 为准。"}, {"card_snapshot.csv", "old_flow_size_mb", "老套餐总量 MB", "老系统 tbl_card.flow_size。"}, {"card_snapshot.csv", "old_total_bytes_cnt_mb", "老网关累计已用 MB", "老系统 tbl_card.total_bytes_cnt 原值。"}, {"card_snapshot.csv", "current_used_mb_decimal", "当前已用 MB", "迁移时建议保留的小数已用量。"}, {"card_snapshot.csv", "current_used_mb_int", "当前已用 MB 整数", "向下取整后的已用量,便于新系统整数/DECIMAL 字段导入。"}, {"card_snapshot.csv", "old_synchro_time", "老同步时间", "老系统 tbl_card.synchro_time。"}, {"card_snapshot.csv", "old_serv_create_at", "运营商开户时间", "老系统 tbl_card.serv_create_date。"}, {"card_snapshot.csv", "old_balance_money", "老余额字段", "老系统 tbl_card.balance_money。"}, {"card_snapshot.csv", "suggested_tb_iot_card_data_usage_mb", "建议卡累计用量", "建议导入新系统 tb_iot_card.data_usage_mb。"}, {"card_snapshot.csv", "suggested_tb_iot_card_current_month_usage_mb", "建议自然月已用量", "建议导入新系统 tb_iot_card.current_month_usage_mb;需结合新系统口径复核。"}, {"card_snapshot.csv", "suggested_tb_iot_card_last_gateway_reading_mb", "建议网关读数基线", "建议导入新系统 tb_iot_card.last_gateway_reading_mb,避免首轮轮询重复扣历史流量。"}, {"package_usage_snapshot.csv", "iccid", "ICCID", "套餐所属老卡 ICCID。"}, {"package_usage_snapshot.csv", "input_device_virtual_no", "输入设备号", "该套餐所属卡来自哪些输入设备号;多个值用 | 分隔。"}, {"package_usage_snapshot.csv", "source_table", "来源表", "tbl_card_life 表示当前/历史套餐,tbl_next_month_card_life 表示次月待生效套餐。"}, {"package_usage_snapshot.csv", "source_table_name", "来源表名称", "来源表的中文说明。"}, {"package_usage_snapshot.csv", "old_usage_id", "老套餐记录 ID", "老系统套餐使用记录主键。"}, {"package_usage_snapshot.csv", "old_meal_id", "老套餐 ID", "老系统套餐 ID。"}, {"package_usage_snapshot.csv", "old_meal_name", "老套餐名称", "老系统套餐名称。"}, {"package_usage_snapshot.csv", "old_order_id", "老订单 ID", "老系统订单 ID。"}, {"package_usage_snapshot.csv", "start_at", "开始时间", "tbl_card_life.start_date。"}, {"package_usage_snapshot.csv", "expires_at", "到期时间", "tbl_card_life.expire_date。"}, {"package_usage_snapshot.csv", "effective_start_at", "有效开始时间", "迁移参考开始时间;优先使用 start_at,老库 start_at 为空时使用记录创建时间兜底。"}, {"package_usage_snapshot.csv", "effective_expires_at", "有效到期时间", "迁移参考到期时间;当前仅使用老库 expire_date,待生效套餐老库通常没有到期时间。"}, {"package_usage_snapshot.csv", "date_source_note", "时间来源说明", "说明开始/到期时间来自哪个老库字段,以及是否使用兜底。"}, {"package_usage_snapshot.csv", "effect_begin_time", "待生效开始时间", "tbl_next_month_card_life.effect_begin_time。"}, {"package_usage_snapshot.csv", "effect_complete_time", "待生效完成时间", "tbl_next_month_card_life.effect_complete_time。"}, {"package_usage_snapshot.csv", "record_created_at", "套餐记录创建时间", "tbl_card_life.create_time 或 tbl_next_month_card_life.create_date。"}, {"package_usage_snapshot.csv", "record_updated_at", "套餐记录更新时间", "tbl_card_life.update_time;次月待生效表没有对应字段。"}, {"package_usage_snapshot.csv", "old_status", "老套餐状态值", "来源表中的 status 原值。"}, {"package_usage_snapshot.csv", "old_status_name", "老套餐状态", "old_status 的中文含义;不同来源表含义不同。"}, {"package_usage_snapshot.csv", "old_type", "老套餐类型值", "tbl_card_life.type 原值。"}, {"package_usage_snapshot.csv", "old_type_name", "老套餐类型", "old_type 的中文含义。"}, {"package_usage_snapshot.csv", "effect_type", "生效类型值", "tbl_card_life.effect_type 原值。"}, {"package_usage_snapshot.csv", "effect_type_name", "生效类型", "effect_type 的中文含义。"}, {"package_usage_snapshot.csv", "order_type", "订单类型值", "tbl_next_month_card_life.order_type 原值。"}, {"package_usage_snapshot.csv", "order_type_name", "订单类型", "order_type 的中文含义。"}, {"package_usage_snapshot.csv", "flow_limit_mb", "套餐总量 MB", "老套餐记录 flow_size。"}, {"package_usage_snapshot.csv", "card_current_used_mb_decimal", "卡当前已用 MB", "来自 card_snapshot 的当前已用量。"}, {"package_usage_snapshot.csv", "allocated_used_mb", "建议套餐已用 MB", "按当前生效套餐顺序从卡已用量中分摊出来的迁移建议值。"}, {"package_usage_snapshot.csv", "remaining_mb", "建议套餐剩余 MB", "flow_limit_mb - allocated_used_mb。"}, {"package_usage_snapshot.csv", "suggested_new_status", "建议新套餐状态值", "建议导入新系统 tb_package_usage.status。"}, {"package_usage_snapshot.csv", "suggested_new_status_name", "建议新套餐状态", "suggested_new_status 的中文含义。"}, {"package_usage_snapshot.csv", "is_pending_next_month", "是否次月待生效", "true 表示来源于 tbl_next_month_card_life。"}, {"wallet_snapshot.csv", "iccid", "ICCID", "钱包所属老卡 ICCID。"}, {"wallet_snapshot.csv", "money_balance_yuan", "钱包余额元", "老系统 tbl_card_wallet.money_balance。"}, {"wallet_snapshot.csv", "money_balance_fen", "钱包余额分", "钱包余额按分换算后的整数值。"}, {"wallet_snapshot.csv", "tx_version", "钱包版本号", "老系统钱包乐观锁版本。"}, {"wallet_snapshot.csv", "meal_auto_renew_status", "主套餐自动续费状态值", "老系统 tbl_card_wallet.meal_auto_renew_status。"}, {"wallet_snapshot.csv", "meal_auto_renew_status_name", "主套餐自动续费状态", "1 启用,2 关闭。"}, {"wallet_snapshot.csv", "auto_renew_meal_id", "主套餐自动续费套餐 ID", "老系统主套餐自动续费绑定的套餐 ID。"}, {"wallet_snapshot.csv", "addon_auto_renew_status", "叠加包自动续费状态值", "老系统 tbl_card_wallet.djb_meal_auto_renew_status。"}, {"wallet_snapshot.csv", "addon_auto_renew_status_name", "叠加包自动续费状态", "1 启用,2 关闭。"}, {"wallet_snapshot.csv", "addon_auto_renew_meal_id", "叠加包自动续费套餐 ID", "老系统叠加包自动续费绑定的套餐 ID。"}, {"wallet_snapshot.csv", "meal_auto_renew_updated_at", "自动续费更新时间", "老系统 tbl_card_wallet.meal_auto_renew_udpate_time。"}, {"wallet_snapshot.csv", "created_at", "钱包创建时间", "老系统 tbl_card_wallet.create_date。"}, {"commission_account_snapshot.csv", "old_agent_id", "老代理 ID", "老系统佣金账户 agent_id。"}, {"commission_account_snapshot.csv", "old_agent_name", "老代理名称", "老系统佣金账户 agent_name。"}, {"commission_account_snapshot.csv", "old_agent_login_account", "老代理登录账号", "老系统佣金账户 agent_login_account。"}, {"commission_account_snapshot.csv", "suggested_new_shop_id", "建议新店铺 ID", "预留给后续导入新系统时填写 shop_id。"}, {"commission_account_snapshot.csv", "total_commission_yuan", "累计佣金元", "老佣金账户累计佣金。"}, {"commission_account_snapshot.csv", "total_commission_fen", "累计佣金分", "累计佣金按分换算后的整数值。"}, {"commission_account_snapshot.csv", "total_active_award_commission_yuan", "累计激活奖励元", "老佣金账户累计激活奖励佣金。"}, {"commission_account_snapshot.csv", "total_active_award_commission_fen", "累计激活奖励分", "累计激活奖励按分换算后的整数值。"}, {"commission_account_snapshot.csv", "mf_commission_yuan", "秒返佣金元", "老佣金账户秒返佣金。"}, {"commission_account_snapshot.csv", "mf_commission_fen", "秒返佣金分", "秒返佣金按分换算后的整数值。"}, {"commission_account_snapshot.csv", "nomf_commission_yuan", "非秒返佣金元", "老佣金账户非秒返佣金。"}, {"commission_account_snapshot.csv", "nomf_commission_fen", "非秒返佣金分", "非秒返佣金按分换算后的整数值。"}, {"commission_account_snapshot.csv", "total_draw_yuan", "累计提现元", "老佣金账户累计提现金额。"}, {"commission_account_snapshot.csv", "total_draw_fen", "累计提现分", "累计提现金额按分换算后的整数值。"}, {"commission_account_snapshot.csv", "can_draw_yuan", "可提现金额元", "老佣金账户可提现余额。"}, {"commission_account_snapshot.csv", "can_draw_fen", "可提现金额分", "可提现余额按分换算后的整数值。"}, {"commission_account_snapshot.csv", "applying_draw_yuan", "提现中金额元", "老佣金账户申请提现中金额。"}, {"commission_account_snapshot.csv", "applying_draw_fen", "提现中金额分", "提现中金额按分换算后的整数值。"}, {"commission_account_snapshot.csv", "remark", "备注", "老佣金账户备注。"}, {"commission_account_snapshot.csv", "tx_version", "佣金账户版本号", "老佣金账户乐观锁版本。"}, {"commission_account_snapshot.csv", "created_at", "佣金账户创建时间", "老佣金账户 create_date。"}, {"commission_account_snapshot.csv", "updated_at", "佣金账户更新时间", "老佣金账户 update_date。"}, {"commission_detail_snapshot.csv", "id", "佣金明细 ID", "老系统佣金明细主键。"}, {"commission_detail_snapshot.csv", "commission_account_id", "佣金账户 ID", "老系统佣金账户 ID。"}, {"commission_detail_snapshot.csv", "wx_order_id", "微信订单 ID", "老系统微信订单 ID。"}, {"commission_detail_snapshot.csv", "wx_order_number", "微信订单号", "老系统微信订单号。"}, {"commission_detail_snapshot.csv", "iccid", "ICCID", "佣金明细关联的老卡 ICCID。"}, {"commission_detail_snapshot.csv", "phone", "手机号/卡号", "佣金明细记录的老系统 phone。"}, {"commission_detail_snapshot.csv", "wx_order_at", "微信订单时间", "老系统 wx_order_date。"}, {"commission_detail_snapshot.csv", "created_at", "佣金明细创建时间", "老系统 create_date。"}, {"commission_detail_snapshot.csv", "commission_amount_yuan", "本次佣金元", "本条佣金金额。"}, {"commission_detail_snapshot.csv", "commission_amount_fen", "本次佣金分", "本条佣金金额按分换算后的整数值。"}, {"commission_detail_snapshot.csv", "total_commission_amount_yuan", "累计佣金元", "该明细发生后的累计佣金金额。"}, {"commission_detail_snapshot.csv", "total_commission_amount_fen", "累计佣金分", "累计佣金金额按分换算后的整数值。"}, {"commission_detail_snapshot.csv", "mf_status", "秒返状态值", "老系统 tbl_agent_commission_detail.mf_status。"}, {"commission_detail_snapshot.csv", "mf_status_name", "秒返状态", "0 成功,1 失败。"}, {"commission_detail_snapshot.csv", "commission_type", "佣金类型值", "老系统 tbl_agent_commission_detail.commission_type。"}, {"commission_detail_snapshot.csv", "commission_type_name", "佣金类型", "commission_type 的中文含义。"}, {"commission_detail_snapshot.csv", "active_award_commission_amount_yuan", "本次激活奖励元", "本条激活奖励佣金金额。"}, {"commission_detail_snapshot.csv", "active_award_commission_amount_fen", "本次激活奖励分", "本条激活奖励佣金按分换算后的整数值。"}, {"commission_detail_snapshot.csv", "total_active_award_commission_amount_yuan", "累计激活奖励元", "该明细发生后的累计激活奖励佣金。"}, {"commission_detail_snapshot.csv", "total_active_award_commission_amount_fen", "累计激活奖励分", "累计激活奖励佣金按分换算后的整数值。"}, {"missing_cards.csv", "iccid", "缺失 ICCID", "输入中解析到但老系统 tbl_card 未命中的 ICCID。"}, } return writeCSV(path, []string{"file_name", "column_name", "chinese_name", "description"}, func(w *csv.Writer) error { for _, item := range fields { if err := w.Write([]string{item.FileName, item.ColumnName, item.ChineseName, item.Description}); err != nil { return err } } return nil }) } func writeEnumDictionary(path string, data *exportData) error { enums := baseEnumDefinitions() enums = append(enums, observedCategoryEnums(data)...) return writeCSV(path, []string{"file_name", "column_name", "value", "meaning", "source", "note"}, func(w *csv.Writer) error { for _, item := range enums { if err := w.Write([]string{item.FileName, item.ColumnName, item.Value, item.Meaning, item.Source, item.Note}); err != nil { return err } } return nil }) } func writeOutputGuide(path string, data *exportData) error { packageCount := 0 for _, rows := range data.PackagesByICCID { packageCount += len(rows) } content := strings.Builder{} content.WriteString("# 老系统设备迁移导出说明\n\n") content.WriteString(fmt.Sprintf("生成时间:%s\n\n", time.Now().Format(timeFormat))) content.WriteString("## 优先查看\n\n") content.WriteString("- `card_snapshot.csv`:卡本体、老卡状态、老套餐字段、当前已用量,以及新系统卡流量字段建议值。\n") content.WriteString("- `package_usage_snapshot.csv`:老套餐记录、已用/剩余分摊建议,以及新系统套餐状态建议值。\n") content.WriteString("- `field_dictionary.csv`:每个输出字段的中文说明。\n") content.WriteString("- `enum_dictionary.csv`:状态码、类型码、来源表等枚举值说明。\n\n") content.WriteString("## 本次摘要\n\n") content.WriteString(fmt.Sprintf("- 已解析输入关系:%d 行\n", len(data.Inputs))) content.WriteString(fmt.Sprintf("- 输入 ICCID:%d 个\n", len(data.InputByICCID))) content.WriteString(fmt.Sprintf("- 老库卡本体命中:%d 张\n", len(data.Cards))) content.WriteString(fmt.Sprintf("- 套餐快照:%d 行\n", packageCount)) content.WriteString(fmt.Sprintf("- 钱包快照:%d 行\n", len(data.Wallets))) content.WriteString(fmt.Sprintf("- 佣金账户:%d 行\n", len(data.CommissionAccounts))) content.WriteString(fmt.Sprintf("- 未解析输入:%d 行\n", len(data.UnresolvedInputs))) content.WriteString(fmt.Sprintf("- 老库缺失 ICCID:%d 个\n\n", len(data.MissingICCIDs))) content.WriteString("## 口径提醒\n\n") content.WriteString("- 英文字段名保留给后续脚本处理,中文含义请看同表的 `*_name` 列和 `field_dictionary.csv`。\n") content.WriteString("- 老系统状态值来自老库字段注释;新系统建议状态来自当前项目常量。\n") content.WriteString("- `suggested_tb_iot_card_last_gateway_reading_mb` 是新系统轮询的基线,导入时要保留,避免首轮轮询重复计算历史流量。\n") content.WriteString("- `allocated_used_mb` 是迁移建议值,正式生成导入 SQL 前仍要结合新系统供应商、套餐系列、套餐映射复核。\n") return os.WriteFile(path, []byte(content.String()), 0644) } func baseEnumDefinitions() []enumDefinition { var result []enumDefinition appendMapEnums(&result, "card_snapshot.csv", "old_status", oldCardStatusNames, "老库 tbl_card.status 字段注释", "") appendMapEnums(&result, "card_snapshot.csv", "old_type", oldCardTypeNames, "老库 tbl_card.type 字段注释", "") appendMapEnums(&result, "package_usage_snapshot.csv", "source_table", map[string]string{ sourceCardLife: sourceTableName(sourceCardLife), sourceNextMonthLife: sourceTableName(sourceNextMonthLife), }, "脚本来源表约定", "") appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldCardLifeStatusNames, "老库 tbl_card_life.status 字段注释", "仅 source_table=tbl_card_life 时适用") appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldNextMonthLifeStatusNames, "老库 tbl_next_month_card_life.status 字段注释", "仅 source_table=tbl_next_month_card_life 时适用") appendMapEnums(&result, "package_usage_snapshot.csv", "old_type", oldPackageTypeNames, "老库 tbl_card_life.type 字段注释", "") appendMapEnums(&result, "package_usage_snapshot.csv", "effect_type", oldEffectTypeNames, "老库 tbl_card_life.effect_type 字段注释", "") appendMapEnums(&result, "package_usage_snapshot.csv", "order_type", oldOrderTypeNames, "老库 tbl_next_month_card_life.order_type 字段注释", "") appendMapEnums(&result, "package_usage_snapshot.csv", "suggested_new_status", newPackageUsageStatusNames, "新系统 pkg/constants PackageUsageStatus*", "建议导入值,生成 SQL 前需复核") appendMapEnums(&result, "wallet_snapshot.csv", "meal_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.meal_auto_renew_status 字段注释", "") appendMapEnums(&result, "wallet_snapshot.csv", "addon_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.djb_meal_auto_renew_status 字段注释", "") appendMapEnums(&result, "commission_detail_snapshot.csv", "mf_status", mfStatusNames, "老库 tbl_agent_commission_detail.mf_status 字段注释", "") appendMapEnums(&result, "commission_detail_snapshot.csv", "commission_type", commissionTypeNames, "老库 tbl_agent_commission_detail.commission_type 字段注释", "") return result } func appendMapEnums(result *[]enumDefinition, fileName, columnName string, values map[string]string, source, note string) { keys := sortedStringMapKeys(values) for _, key := range keys { *result = append(*result, enumDefinition{ FileName: fileName, ColumnName: columnName, Value: key, Meaning: values[key], Source: source, Note: note, }) } } func observedCategoryEnums(data *exportData) []enumDefinition { seen := make(map[string]string) for _, card := range data.Cards { if card.Category == "" { continue } seen[card.Category] = oldCardCategoryName(card.Category, card.CategoryName) } keys := sortedStringMapKeys(seen) result := make([]enumDefinition, 0, len(keys)) for _, key := range keys { result = append(result, enumDefinition{ FileName: "card_snapshot.csv", ColumnName: "old_category", Value: key, Meaning: seen[key], Source: "老库 tbl_vendor_category.category_name", Note: "仅列出本次导出中实际出现的运营商分类", }) } return result } func oldCardStatusName(value string) string { return enumName(oldCardStatusNames, value) } func oldCardTypeName(value string) string { return enumName(oldCardTypeNames, value) } func oldCardCategoryName(value, dbName string) string { if strings.TrimSpace(dbName) != "" { return dbName } return enumName(oldCategoryNames, value) } func oldPackageStatusName(sourceTable, value string) string { if sourceTable == sourceNextMonthLife { return enumName(oldNextMonthLifeStatusNames, value) } return enumName(oldCardLifeStatusNames, value) } func oldPackageTypeName(value string) string { return enumName(oldPackageTypeNames, value) } func oldEffectTypeName(value string) string { return enumName(oldEffectTypeNames, value) } func oldOrderTypeName(value string) string { return enumName(oldOrderTypeNames, value) } func autoRenewStatusName(value string) string { return enumName(autoRenewStatusNames, value) } func newPackageUsageStatusName(value string) string { return enumName(newPackageUsageStatusNames, value) } func mfStatusName(value string) string { return enumName(mfStatusNames, value) } func commissionTypeName(value string) string { return enumName(commissionTypeNames, value) } func sourceTableName(value string) string { switch value { case sourceCardLife: return "当前/历史套餐记录" case sourceNextMonthLife: return "次月待生效套餐记录" default: return "" } } func slotPositionName(slot int) string { switch slot { case 1: return "主卡" case 2: return "副卡1" case 3: return "副卡2" case 4: return "卡槽4" default: return "" } } func resolvedByName(value string) string { switch { case value == "input_iccid": return "输入直接提供 ICCID" case value == "input_slot_iccid": return "输入按卡槽提供 ICCID" case strings.Contains(value, "old_tbl_card_vcode") && strings.Contains(value, "old_tbl_card_relate"): return "按设备码命中后展开关联卡" case value == "old_tbl_card_vcode": return "按老库 tbl_card.vcode 反查" case value == "old_tbl_card_imei": return "按老库 tbl_card.imei 反查" case value == "old_tbl_card_relate": return "按老库 tbl_card_relate 展开" case value == "pending_device_key": return "待按设备号/IMEI 解析" case value == "unresolved": return "无法解析" case value == "unresolved_device_key": return "设备号/IMEI 未命中老库" default: return "" } } func enumName(values map[string]string, value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if name, ok := values[value]; ok { return name } return "未知值" } func sortedStringMapKeys(values map[string]string) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys }