package user import ( "os" "sort" "strings" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" "yunyan/sys/idverify" ) // 实名认证的数据访问 + 服务解析。 // // 两个库都要碰: // - useridverify / user 在**业务库 mysql**(随部署,按应用分离); // - svc_config 在 **console 共享库 postgres**(后台「第三方服务配置」维护)。 // idHashSaltEnv 身份证号指纹的盐。见 idverify.HashIdCard 的说明:不加盐等于明文。 const idHashSaltEnv = "ID_HASH_SALT" // idVerifyFailWindow / idVerifyFailLimit 限流:同一账号在窗口内失败达上限即拒绝。 // 服务商按次计费,不限流的话一个脚本就能把额度刷干净。 const ( idVerifyFailWindow = 10 * time.Minute idVerifyFailLimit = 5 ) type modelIdVerifyComp struct { cbase.ModuleCompBase module *User } // dbUserIdVerifyLog 实名绑定变更留痕(表 useridverify_log)。本地 gorm 结构,不进 pb: // 只给后台排查用,客户端不读。**刻意不存姓名**——注销要清 PII,留痕只留掩码 + 指纹, // 按指纹能查到「这张证件曾绑过哪些 uid」就够了。 type dbUserIdVerifyLog struct { Id uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Uid string `gorm:"column:uid;size:50;index" json:"uid"` Action string `gorm:"column:action;size:16" json:"action"` // rebind=换人 / cancel=注销 Oldmask string `gorm:"column:oldmask;size:32" json:"oldmask"` Oldhash string `gorm:"column:oldhash;size:64;index" json:"oldhash"` Newmask string `gorm:"column:newmask;size:32" json:"newmask"` Newhash string `gorm:"column:newhash;size:64;index" json:"newhash"` Createtime int64 `gorm:"column:createtime" json:"createtime"` } func (this *modelIdVerifyComp) 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.(*User) if err = mysql.CreateTable(comm.TableUserIdVerify, &pb.DBUserIdVerify{}); err != nil { this.module.Errorln(err) } if err = mysql.CreateTable(comm.TableUserIdVerifyLog, &dbUserIdVerifyLog{}); err != nil { this.module.Errorln(err) } // 先对齐排序规则再做联表巡检——不对齐的库上 JOIN 直接报 Illegal mix of collations。 this.alignCollation() this.reconcileUserFlags() return } // alignCollation 把实名两张表的排序规则对齐到 user 表。 // // user 表多半是从老库导入的(utf8mb4_general_ci),useridverify 由 gorm 按服务器默认建 // (utf8mb4_0900_ai_ci),2026-09-19 在测试机实测两表 uid 列排序规则不同,任何联表 // (后台按姓名/证件搜用户、本文件的巡检)都会报 1267。幂等:一致就什么都不做。 // 表很小(一用户一行),CONVERT 在线上是秒级。 func (this *modelIdVerifyComp) alignCollation() { target := this.tableCollation(comm.TableUser) charset, ok := collationCharset(target) if !ok { this.module.Warn("实名表排序规则对齐:user 表排序规则读不到或不合法,跳过", log.Field{Key: "collation", Value: target}) return } for _, t := range []string{comm.TableUserIdVerify, comm.TableUserIdVerifyLog} { cur := this.tableCollation(t) if cur == "" || cur == target { continue } if res := mysql.Exec("ALTER TABLE `" + t + "` CONVERT TO CHARACTER SET " + charset + " COLLATE " + target); res.Error != nil { this.module.Error("实名表排序规则对齐失败", log.Field{Key: "table", Value: t}, log.Field{Key: "from", Value: cur}, log.Field{Key: "to", Value: target}, log.Field{Key: "err", Value: res.Error.Error()}) continue } this.module.Infof("实名表排序规则对齐:%s %s → %s", t, cur, target) } } // tableCollation 读当前库里某张表的排序规则;读不到返回空串。 func (this *modelIdVerifyComp) tableCollation(table string) string { var coll string res := mysql.Raw("SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", table).Scan(&coll) if res.Error != nil { this.module.Warn("读表排序规则失败", log.Field{Key: "table", Value: table}, log.Field{Key: "err", Value: res.Error.Error()}) return "" } return coll } // reconcileUserFlags 启动时把 user 表的实名标识按明细表校正(明细是权威,标识是派生缓存)。 // // 标识与明细是两次独立写(api_idverify 第 6 步:明细落库后再打标识,失败只记日志), // 中间任何一环出问题都会分裂,而分裂后两边各读各的:后台列表按标识筛、客户端闸门读明细。 // 两个方向都校:明细通过而标识没打 → 补上;标识打了而明细不存在/未通过 → 清掉。 // 幂等,多实例同时启动无害。性别不在这里补(要身份证号才推得出,库里没有)。 func (this *modelIdVerifyComp) reconcileUserFlags() { up := mysql.Exec("UPDATE `" + comm.TableUser + "` u JOIN `" + comm.TableUserIdVerify + "` v ON v.uid=u.uid" + " SET u.idverified=1, u.idverifiedtime=v.verifytime" + " WHERE v.verifytime>0 AND (u.idverified=0 OR u.idverifiedtime<>v.verifytime)") if up.Error != nil { this.module.Error("实名标识巡检:补标识失败", log.Field{Key: "err", Value: up.Error.Error()}) } else if up.RowsAffected > 0 { this.module.Warnf("实名标识巡检:%d 个账号明细已通过但 user 表标识缺失/不一致,已补上", up.RowsAffected) } down := mysql.Exec("UPDATE `" + comm.TableUser + "` u LEFT JOIN `" + comm.TableUserIdVerify + "` v ON v.uid=u.uid AND v.verifytime>0" + " SET u.idverified=0, u.idverifiedtime=0" + " WHERE u.idverified=1 AND v.uid IS NULL") if down.Error != nil { this.module.Error("实名标识巡检:清悬空标识失败", log.Field{Key: "err", Value: down.Error.Error()}) } else if down.RowsAffected > 0 { this.module.Warnf("实名标识巡检:%d 个账号 user 表标记已实名但没有通过的明细,已清掉标识", down.RowsAffected) } } // logRebind 记一条「换人」痕迹:已实名账号改绑成另一张证。 func (this *modelIdVerifyComp) logRebind(uid string, old, cur *pb.DBUserIdVerify, now int64) error { return mysql.Insert(comm.TableUserIdVerifyLog, &dbUserIdVerifyLog{ Uid: uid, Action: "rebind", Oldmask: old.Idcardmask, Oldhash: old.Idcardhash, Newmask: cur.Idcardmask, Newhash: cur.Idcardhash, Createtime: now, }) } // logCancel 注销前记一条「注销」痕迹;账号没有通过的实名记录则什么都不写。 func (this *modelIdVerifyComp) logCancel(uid string) error { record, err := this.find(uid) if err != nil || record == nil || record.Verifytime == 0 { return err } return mysql.Insert(comm.TableUserIdVerifyLog, &dbUserIdVerifyLog{ Uid: uid, Action: "cancel", Oldmask: record.Idcardmask, Oldhash: record.Idcardhash, Createtime: time.Now().Unix(), }) } // find 取某账号的实名记录;无记录返回 nil,nil。 func (this *modelIdVerifyComp) find(uid string) (*pb.DBUserIdVerify, error) { model := &pb.DBUserIdVerify{} err := mysql.FindOne(comm.TableUserIdVerify, model, "uid=?", uid) if err == mysql.ErrNoDocuments { return nil, nil } if err != nil { return nil, err } return model, nil } // save 落库实名记录(有则更新、无则插入)。 func (this *modelIdVerifyComp) save(model *pb.DBUserIdVerify) error { now := time.Now().Unix() model.Updatetime = now if model.Createtime == 0 { model.Createtime = now return mysql.Insert(comm.TableUserIdVerify, model) } return mysql.Save(comm.TableUserIdVerify, model) } // markUserVerified 把 user 表的实名标识打上(与 useridverify 明细表配套, // 便于列表/后台按 idverified 直接过滤,不用每次 join 明细表)。 // gender>0 时一并回写真实性别(身份证第 17 位奇男偶女),0 表示解析不出、保留用户原选择。 func (this *modelIdVerifyComp) markUserVerified(uid string, at int64, gender int32) error { cols := map[string]interface{}{ "idverified": true, "idverifiedtime": at, } if gender > 0 { cols["gender"] = gender } return mysql.Table(comm.TableUser).Where("uid=?", uid).UpdateColumns(cols).Error } // salt 读取指纹盐。 func (this *modelIdVerifyComp) salt() string { return os.Getenv(idHashSaltEnv) } // resolveVerifier 从「第三方服务配置」里解析出本应用可用的实名核验服务。 // // 口径与 resolveThirdSvcs 的作用域一致:应用行(app_name=)优先于全局行(app_name=''), // 只取启用的、类别含 comm.SvcCatIdVerify 的服务。返回 svcId 供落库记录用哪家核验的。 // // ⚠️ 这类服务不走 resolveThirdSvcs(那是客户端下发口,已按 IsServerOnlySvc 把它们拦掉了), // 凭据只在这里、服务端进程内解密使用。 func (this *modelIdVerifyComp) resolveVerifier() (svcId string, v idverify.Verifier, err error) { app := comm.AppName() svcs := make([]*comm.ThirdSvcConfig, 0) if err = postgres.Find(comm.TableSvcConfig, &svcs, "(app_name=? OR app_name='')", app); err != nil { if err == postgres.ErrNoDocuments { err = nil } else { return } } // 候选 = 启用的、类别含实名核验的服务。 candidates := make([]*comm.ThirdSvcConfig, 0, len(svcs)) for _, s := range svcs { if !s.Enable || !comm.CategoriesHas(s.Categories, comm.SvcCatIdVerify) { continue } candidates = append(candidates, s) } if len(candidates) == 0 { return "", nil, nil // 未配置:由调用方回 IdVerifyNotConfigured } // 应用行覆盖全局行;同作用域内多条同时启用属于配置错误—— // 这里按 Id 字典序取第一条,保证**每次重启选的是同一家**。 // 原先是「取遍历到的第一条」,而 Find 没有 ORDER BY,同作用域两条都启用时 // 选谁取决于 Postgres 的返回顺序,可能在重启后悄悄换一家服务商(还各自计费)。 sort.SliceStable(candidates, func(i, j int) bool { ai := candidates[i].AppName == app && app != "" aj := candidates[j].AppName == app && app != "" if ai != aj { return ai // 应用行排前面 } return candidates[i].Id < candidates[j].Id }) picked := candidates[0] // 同作用域内还有别的启用项时必须喊出来:运营多半是想换一家却忘了把旧的停用, // 静默择一会让「已启用的新服务商」看起来完全没生效。 if len(candidates) > 1 { others := make([]string, 0, len(candidates)-1) for _, c := range candidates[1:] { if (c.AppName == app && app != "") == (picked.AppName == app && app != "") { others = append(others, c.Id) } } if len(others) > 0 { this.module.Warn("实名核验服务有多条同时启用,已按 Id 取第一条;请在后台只保留一条启用", log.Field{Key: "picked", Value: picked.Id}, log.Field{Key: "provider", Value: picked.Provider}, log.Field{Key: "ignored", Value: strings.Join(others, ",")}) } } fields, ferr := comm.ResolveSvcPlainFields(picked, nil, os.Getenv("FIELD_ENCRYPT_KEY")) if ferr != nil { return picked.Id, nil, ferr } v, err = idverify.New(picked.Provider, fields) return picked.Id, v, err }