package console import ( "fmt" "sort" "strconv" "strings" "sync" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" ) // registryComp 应用业务库连接管理器:按 appId 把注册项的 ServiceDsn 物化成业务库连接并缓存。 // // 设备库内容(厂家/产品/设备码/公码/出货单)统一在 console 主库(supabase),不分应用、无需选; // 只有「重置设备码」要清各应用自己的 userdevice/product_stat 时,才按 X-App-Id 取应用业务库。 type registryComp struct { cbase.ModuleCompBase module *Console mu sync.RWMutex conns map[uint32]mysql.ISys // appId → 业务库(ServiceDsn) 连接 } func (this *registryComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { this.ModuleCompBase.Init(service, module, comp, opt) this.module = module.(*Console) this.conns = make(map[uint32]mysql.ISys) return } // getServiceDB 取(或惰性建)指定应用的业务库连接(用于清 userdevice/product_stat)。 func (this *registryComp) getServiceDB(appId uint32) (mysql.ISys, error) { if appId == 0 { return nil, fmt.Errorf("未指定应用(appId=0),重置设备码需先在切换器选择应用(清理该应用的用户绑定)") } this.mu.RLock() if conn, ok := this.conns[appId]; ok { this.mu.RUnlock() return conn, nil } this.mu.RUnlock() app, err := this.module.model.getApp(appId) if err != nil { return nil, fmt.Errorf("应用未注册或读取失败(appId=%d): %w", appId, err) } if !app.Enabled { return nil, fmt.Errorf("应用已停用(appId=%d, name=%s)", appId, app.Name) } this.mu.Lock() defer this.mu.Unlock() // 双重检查:可能在抢锁期间别的请求已建好。 if conn, ok := this.conns[appId]; ok { return conn, nil } conn, err := openDriver(app.ServiceDsn) if err != nil { return nil, fmt.Errorf("应用业务库连接失败(appId=%d, name=%s): %w", appId, app.Name, err) } // 建连后补齐 console 会直接读写的业务库新列:console 用最新的 pb 结构操作 user/userdevice/ // payorder,而这些表的迁移平时由【业务服务启动时 AutoMigrate】完成。只升级 console、 // 业务服务还是老镜像时,缺列会让赠送资源等接口直接报 Unknown column。这里兜一层。 ensureServiceColumns(conn, app.Name) this.conns[appId] = conn return conn, nil } // serviceColumn 一个需要在业务库补齐的列(表名 + 用于取 schema 的模型 + 列名)。 type serviceColumn struct { table string model any col string } // serviceColumns console 直接读写业务库时依赖的、由渠道分成体系新增的列。 // 与 pb 结构一致;补列走 gorm Migrator,跨 MySQL/Postgres 通用(MySQL 无 ADD COLUMN IF NOT EXISTS)。 var serviceColumns = []serviceColumn{ {comm.TableUser, &pb.DBUser{}, "lastbindchannelid"}, {comm.TableUserdevice, &pb.DBUserDivice{}, "channelid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_license"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_mac"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_productid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_brandid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_channelid"}, // 2026-09 会员与算力(业务服务启动会 AutoMigrate,这里只为 console 先升级的窗口兜底) {comm.TableUser, &pb.DBUser{}, "computebalance"}, {comm.TableUserUseLog, &pb.DBUserUseLog{}, "compute"}, {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computedevice"}, {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeuser"}, {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeover"}, {comm.TableUserUseLog, &pb.DBUserUseLog{}, "devicemac"}, {comm.TableUserStatistics, &pb.DBUserStatistics{}, "computetotal"}, {comm.TableGoods, &pb.DBGoods{}, "compute"}, } // ensureServiceColumns 幂等补齐业务库缺失的列。全程 best-effort: // 表不存在(该应用没这块业务)或补列失败都只告警,不阻断连接建立。 func ensureServiceColumns(conn mysql.ISys, appName string) { for _, sc := range serviceColumns { m := conn.Table(sc.table).Migrator() if !m.HasTable(sc.table) { continue } if m.HasColumn(sc.model, sc.col) { continue } if err := m.AddColumn(sc.model, sc.col); err != nil { log.Warn("console: 业务库补列失败", log.Field{Key: "app", Value: appName}, log.Field{Key: "table", Value: sc.table}, log.Field{Key: "column", Value: sc.col}, log.Field{Key: "err", Value: err.Error()}) continue } log.Infof("console: 业务库 %s 补列 %s.%s 完成", appName, sc.table, sc.col) } } // invalidate 失效某应用的业务库连接缓存(注册项被改/删时调用),下次访问按新配置重建。 func (this *registryComp) invalidate(appId uint32) { this.mu.Lock() delete(this.conns, appId) this.mu.Unlock() } // ============================ 设备域连接 ============================ // appConn 设备管理域用的连接句柄: // - device 恒为 console 主库(supabase)——厂家/产品/设备码/公码/出货单都在这里; // - service 仅「重置设备码」时按选中应用填入其业务库——清 userdevice/product_stat。 // // 暴露 AdminDB()/ServiceDB() 使 model_device.go 的 handler 调用方式保持不变。 type appConn struct { device mysql.ISys // 设备库 = console 主库(supabase) service mysql.ISys // 业务库 = 选中应用的 MySQL(仅重置类用到) } func (a *appConn) AdminDB() mysql.ISys { return a.device } func (a *appConn) ServiceDB() mysql.ISys { return a.service } // consoleDeviceConn 设备域默认连接:device 指向 console 主库(supabase),service 留空。 // 重置类操作再把 service 补成选中应用的业务库。 func consoleDeviceConn() *appConn { return &appConn{device: postgres.GetSys()} } // openDriver 按 DSN 前缀选驱动:postgres:// 走 PostgreSQL,否则 MySQL(应用业务库通常为 MySQL)。 // 直接用子系统的 NewSys 工厂建连;两驱动 ISys 方法集一致,统一用 mysql.ISys 承载。 func openDriver(dsn string) (mysql.ISys, error) { if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { return postgres.NewSys(postgres.SetDsn(dsn)) } return mysql.NewSys(mysql.SetMySQLDsn(dsn)) } // ensureDeviceTables 在 console 主库(supabase)幂等建好设备管理域所需的表(含按 product 的 license 分表)。 // 启动时跑一次:设备库内容已集中到 console 自己的库,不再按应用建。 func ensureDeviceTables() error { adb := postgres.GetSys() // 芯片厂商:方案商的 vendor 列指向它,故先于 solution_provider 建好。 if err := adb.CreateTable(comm.TableChipVendor, &ChipVendor{}); err != nil { return err } // 补种原 SolutionVendor 枚举的 4 个取值(id 与枚举数值一一对应,存量方案商靠它归组)。 // 幂等:已存在的行不覆盖,运营改过的名字不会被启动时刷回去。 // 一次性迁移:上一版把 CID 做成独立列(id 自增 + cid 另存),本版改成「id 即 CID」。 // 已部署过上一版的库要把带 cid 的行搬到以 CID 为主键,并级联改方案商的 vendor 引用。 // 迁移完会删掉 cid 列,之后本调用直接跳过。必须先于补种执行。 if err := migrateChipVendorCidToId(); err != nil { return err } if err := ensureChipVendorSeed(); err != nil { return err } // id 即 CID、由人工指定,不再自增,故不设自增下限。 // 代工厂/组装厂档案(与品牌商并列的独立实体)。 // ⚠️ 表名是 oem_factory,不是库里那张无人引用的遗留 factory 表,别混。 if err := adb.CreateTable(comm.TableOemFactory, &OemFactory{}); err != nil { return err } if err := adb.CreateTable(comm.TableSolutionProvider, &pb.DBSolutionProvider{}); err != nil { return err } // 方案商 id 从 0xAB01 起:Floor 语义,已有历史行(id 1、2、3…)保留,只把序列抬上去, // 后台新建的方案商从 0xAB01 开始发号。 _ = adb.AutoIncrementFloor(comm.TableSolutionProvider, "id", 0xAB01) // 2026-09-12 品牌商(brand)整体下线:后台「品牌商管理」页、console/api 的 brand 接口、 // 品牌商账号角色(identity=3) 全部删除,表一并 DROP。 // // ⚠️ 不可逆。删表前请确认这三件事已经不需要了:品牌商档案本身、它的联系邮箱 //(原先是 MAC生成 产品通知邮件的收件人之一)、以及 productlists 那份产品归属清单。 // // ⚠️ 其它表的 brandid 列**刻意保留**,不在这里删: // · product.brandid —— modules/user/compat.go 靠它给老 App 回填 DBProduct.factoryid, // 不下发会让已发布的客户端整个 fromJson 抛 TypeError; // · production_batch.brandid / device_mac.brandid —— 历史批次的归属留痕; // · payorder.src_brandid —— 订单分成溯源。 // 这些列今后只写 0,不再指向任何档案。 if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableBrand); res.Error != nil { return res.Error } // 唤醒语音(wakeupvoice)2026-09-14 下线:这张表没有任何应用维度(两个 app 看到同一份), // 后台从来没有管理它的页面,客户端 api.dart 里那个 getwakeupvoices 也零调用方—— // 纯粹是一条没人用、又会跨应用串数据的链路。服务端接口、model、建表全部已删,这里收尾删表。 // ⚠️ 不可逆。业务库(mysql)里同名的那张由各应用自己清理,不在这里动。 if res := adb.Exec("DROP TABLE IF EXISTS wakeupvoice"); res.Error != nil { return res.Error } if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil { return err } // official/sharerate 是后加字段(品牌官方渠道标记、[遗留]渠道商级分成比例):CreateTable 对已存在的表 // 跳过 AutoMigrate,故显式补列。历史行 official=false(存量品牌商没有官方渠道,需要的话在 // 后台手工建一条)、sharerate=0。 // ⚠️ sharerate 已停止在后台维护,分成比例改按生产批次配(production_batch.sharerate); // 列不删是因为月结算暂时还读它,且删列会丢掉存量比例。 for _, ddl := range []string{ "ALTER TABLE " + comm.TableChannel + " ADD COLUMN IF NOT EXISTS official boolean DEFAULT false", "ALTER TABLE " + comm.TableChannel + " ADD COLUMN IF NOT EXISTS sharerate integer DEFAULT 0", } { if res := adb.Exec(ddl); res.Error != nil { return res.Error } } if err := adb.CreateTable(comm.TableProductionBatch, &pb.DBProductionBatch{}); err != nil { return err } // factoryid / sharerate 是后加字段(这批货由哪家代工厂生产、给渠道商的分成比例万分比): // CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。 // 存量批次 factoryid=0 = 未指定(建这批时还没有工厂档案);sharerate=0 = 不分成 // (这些批次是在「比例挂在渠道商上」的年代生产的,比例得回 channel.sharerate 看)。 for _, ddl := range []string{ "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS factoryid bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS sharerate integer DEFAULT 0", // 绑定赠送三项:存量批次全 0,绑定时会回退到产品上的旧字段,故不需要 backfill。 "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardvip bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardtranslate bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardmeeting bigint DEFAULT 0", // 2026-09 会员与算力:批次只配「VIP 天数(rewardvip 复用) + VIP 等级 + 赠送算力」,翻译/会议两列不再读写。 "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 1", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0", } { if res := adb.Exec(ddl); res.Error != nil { return res.Error } } if err := adb.CreateTable(comm.TableProduct, &pb.DBProduct{}); err != nil { return err } _ = adb.AutoIncrementStart(comm.TableProduct, "id", 0xB001) // appnames 是后加字段(产品归属的应用名 CSV):CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。 // 历史行留空,语义为「未绑定应用」——此时账号的应用下拉退化为不限制(见 accountAppNames)。 // oemfactoryid 是后加字段(这款产品默认由哪家代工厂生产,oem_factory.id)。 // ⚠️ 不叫 factoryid:DBProduct 里那个 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。 if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0"); res.Error != nil { return res.Error } if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS appnames varchar(255) DEFAULT ''"); res.Error != nil { return res.Error } // providerid/createtime/description 同为后加字段(产品级绑定方案商、建档时间、产品说明),同样要显式补列。 // providerid 历史行为 0 = 未绑定方案商:生产设备码前必须在后台补选,否则 CID 取不到。 for _, ddl := range []string{ "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS providerid bigint DEFAULT 0", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''", // pb 里 providerid 带 gorm index;建表走 AutoMigrate 会自动建,补列这条路要自己补上 "CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)", // devicetype 已于 2026-09-08 废弃并删列:服务端没有任何逻辑读它,后台表单也去掉了这一项。 // pb 里那个 Devicetype 已改成 gorm:"-" 的老客户端兼容字段(恒为 0,只为老 App 包能解析), // 新库根本不会建这一列,这条只负责把存量库里的旧列清掉。幂等。 // ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。 "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS devicetype", // 2026-09 会员与算力:批次未配置时的回退(bindrewardvpitime 复用为 VIP 天数)。 "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindviplevel integer DEFAULT 1", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindcompute bigint DEFAULT 0", // scanuuid 已于 2026-09-08 废弃并删列:设备校验早就改成按 MAC 走(device_mac 全局表), // 绑定奖励也是按 MAC 判「这台设备有没有被绑过」,跟这个 UUID 没有任何关系。 // pb 里的 Scanuuid 同样改成了 gorm:"-" 的老客户端兼容字段(恒为空串,只为老 App 包能解析)。 // ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。 "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS scanuuid", } { if res := adb.Exec(ddl); res.Error != nil { return res.Error } } if err := adb.CreateTable(comm.TableProductVersion, &pb.DBProductVersion{}); err != nil { return err } // 2026-09-12 厂家公码下线:后台入口、console/api 接口、客户端 user_bindauthcode 全部删除, // 表一并 DROP(本次是用户明确要求的彻底清除,不同于 license 分表那种"搬完留着当回退依据")。 // ⚠️ 不可逆:表里若还有已发给客户、尚未扫的公码,DROP 之后无法找回。 // api 服务那边的建表也已删除,否则它一重启就把空表建回来。 if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableFactoryPublicCode); res.Error != nil { return res.Error } // 设备 MAC 表:**全局一张**,不再按产品切分表。 // 设备的身份是它自己的 MAC(主键 code = devicemac),productid 只是归属列。 // 分表时代的教训:pid 既是查询键又要客户端猜,猜错就报「这台 MAC 没登记在该产品下」, // 指向数据缺失、实际是 pid 错了,极难排查。详见 migrateLicenseToDeviceMac。 if err := adb.CreateTable(comm.TableDeviceMac, &pb.DBAuthCode{}); err != nil { return err } // 2026-09 会员与算力:设备行上的权益快照(激活时从批次固化)。CreateTable 对已存在的表跳过 // AutoMigrate,故显式补列。随后把**已激活**的存量设备按其批次回填一次——设备 VIP 期是 // [usedtime, usedtime+vipdays),与首绑用户当年拿到的那段 VIP 基本重合,不算二次赠送; // 但转手后的下一个绑定者从此能接着享有剩余天数与剩余算力。WHERE 里的全 0 条件保证幂等。 for _, ddl := range []string{ "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS vipdays integer DEFAULT 0", "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 0", "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0", "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computeused bigint DEFAULT 0", "UPDATE " + comm.TableDeviceMac + " d SET vipdays = b.rewardvip, viplevel = GREATEST(COALESCE(b.viplevel,1),1), computegrant = COALESCE(b.computegrant,0)" + " FROM " + comm.TableProductionBatch + " b" + " WHERE d.batchno = b.batchno AND d.usedtime > 0 AND d.vipdays = 0 AND d.computegrant = 0" + " AND (b.rewardvip > 0 OR COALESCE(b.computegrant,0) > 0)", } { if res := adb.Exec(ddl); res.Error != nil { return res.Error } } // 设备必须挂在产品下:应用归属完全靠 device_mac.productid → product.appnames 推出来 // (这张表是全平台一张、没有应用列),productid=0 的行等于「不属于任何应用」—— // 绑定查不到、解绑也解不掉,只会变成查不清来路的脏数据。后台生成/导入入口已显式拦截, // 这里再加一道库级 CHECK 兜底,堵住直接写库和将来新增的写入路径。 // // ⚠️ best-effort:表里若已有 productid=0 的存量行,ADD CONSTRAINT 会失败——那要在后台 // 把这些设备补上产品(或删掉)后才能生效,但不能因此阻断 console 启动,所以只告警。 if res := adb.Exec(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'device_mac_productid_positive') THEN ALTER TABLE ` + comm.TableDeviceMac + ` ADD CONSTRAINT device_mac_productid_positive CHECK (productid > 0); END IF; END $$;`); res.Error != nil { log.Warnf("console: device_mac 的 productid>0 约束未能建立(多半是表里还有 productid=0 的存量行,"+ "补上产品或清掉后重启即可生效): %v", res.Error) } var products []*pb.DBProduct if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments { return err } // 渠道商维度是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故批次台账与设备表 // 都要显式补列(幂等)。历史行 channelid 留空,语义为「未分配渠道」。 for _, t := range []string{comm.TableProductionBatch, comm.TableDeviceMac} { if res := adb.Exec("ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS channelid varchar(16) DEFAULT ''"); res.Error != nil { return res.Error } } return migrateLicenseToDeviceMac(products) } // migrateLicenseToDeviceMac 把按产品切的 license_ 分表**合并**进全局 // device_mac 表。幂等,每次启动跑,跑完即空转。 // // 为什么要合:设备的身份是它自己的 MAC,本来就全局唯一。分表把 pid 变成了查询键, // 于是客户端得先猜出 pid 才能校验一台设备——猜错就报「这台 MAC 没登记在该产品下」, // 提示词指向数据缺失、实际是 pid 错了。2026-09-04 就踩了这个:一台已正确导入的耳机 // 因为所属产品的 devicetype 配成了 2(BLE双端)而不是 1(经典蓝牙),被客户端的 // 「只在 devicetype==1 的产品里挑」过滤掉,兜底退回了另一个产品的 pid, // 服务端拿着错 pid 去错的分表里查,自然查不到。 // // 迁移步骤(每张 license_* 表,含没有对应产品的孤儿表——那里面也是真实发出去的设备): // ① code := devicemac —— 老数据里 code 是 20 字符的 license 串、devicemac 另存, // 两个都指向同一台设备。授权码那套已经删了(utils/license 包不复存在)。 // ② 按两表**列名交集**做 INSERT ... SELECT ... ON CONFLICT DO NOTHING。 // 不能 SELECT *:各分表建于不同时期,列不一致(有的多 factoryid/probatch 两列)。 // ③ productid 为空/0 的行,用表名里的 pid 补上——它是绑定礼、埋点、结算的归属依据。 // // ⚠️ **搬完不删旧表**。这是不可逆数据(已经发到用户手上的设备),留着旧表是出问题时 // 唯一的回退依据。确认新表跑稳之后再由人手工 DROP。 func migrateLicenseToDeviceMac(products []*pb.DBProduct) error { adb := postgres.GetSys() // device_mac 的列集合,用于和各源表求交集。 dstCols, err := tableColumns(adb, comm.TableDeviceMac) if err != nil { return err } if len(dstCols) == 0 { return fmt.Errorf("console.registry: %s 建表失败或无列", comm.TableDeviceMac) } var names []string if err := adb.Raw( "SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND tablename LIKE ?", comm.TableLicense+`\_%`).Scan(&names).Error; err != nil { return err } for _, t := range names { // 表名形如 license_b023,后缀是 pid 的十六进制。解不出就当 0, // 那种表的行必须自带 productid,否则归属列会是空——只告警不中断。 pid := pidFromLicenseTable(t) // ① 存量 code 改写成 MAC。WHERE 带 code<>devicemac,跑过一遍后就是 0 行。 if res := adb.Exec("UPDATE " + t + " SET code = devicemac WHERE devicemac <> '' AND code <> devicemac"); res.Error != nil { return res.Error } else if res.RowsAffected > 0 { log.Infof("console.registry: %s 有 %d 条存量授权码已改写成 MAC", t, res.RowsAffected) } srcCols, err := tableColumns(adb, t) if err != nil { return err } // ② 列名交集。productid 单独处理(要用表名兜底),其余按名字直搬。 cols := make([]string, 0, len(srcCols)) for c := range srcCols { if c == "productid" { continue // 单独处理,见 ③ } if dstCols[c] { cols = append(cols, c) } } if len(cols) == 0 { continue } sort.Strings(cols) // map 迭代无序,排一下让生成的 SQL 与日志可复现 // ③ productid:源表有这一列就用它、为空则回落到表名里的 pid;没这一列就直接用表名。 srcPid := fmt.Sprintf("%d", pid) if srcCols["productid"] { srcPid = fmt.Sprintf("COALESCE(NULLIF(productid, 0), %d)", pid) } sql := fmt.Sprintf( "INSERT INTO %s (productid, %s) SELECT %s, %s FROM %s WHERE code <> '' ON CONFLICT (code) DO NOTHING", comm.TableDeviceMac, strings.Join(cols, ", "), srcPid, strings.Join(cols, ", "), t) res := adb.Exec(sql) if res.Error != nil { return fmt.Errorf("console.registry: 迁移 %s -> %s 失败: %w", t, comm.TableDeviceMac, res.Error) } if res.RowsAffected > 0 { log.Infof("console.registry: 已把 %s 的 %d 行合并进 %s(旧表保留)", t, res.RowsAffected, comm.TableDeviceMac) } } return nil } // tableColumns 取一张表的列名集合。表不存在返回空 map(不报错)。 func tableColumns(adb postgres.ISys, table string) (map[string]bool, error) { var cols []string if err := adb.Raw( "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?", table).Scan(&cols).Error; err != nil { return nil, err } out := make(map[string]bool, len(cols)) for _, c := range cols { out[c] = true } return out, nil } // pidFromLicenseTable 从 license_ 解出 pid。解不出返回 0。 func pidFromLicenseTable(table string) uint32 { suffix := strings.TrimPrefix(table, comm.TableLicense+"_") if suffix == table || suffix == "" { return 0 } n, err := strconv.ParseUint(suffix, 16, 32) if err != nil { log.Warn("console.registry: 授权码表名解不出 pid,归属列将依赖表内数据", log.Field{Key: "table", Value: table}) return 0 } return uint32(n) }