diff --git a/CLAUDE.md b/CLAUDE.md index d3af5b4d..4cc6e6b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1738,6 +1738,27 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后): - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 +#### 账号 ↔ 实名记录的绑定口径(2026-09-19 梳理并修) + +纯逻辑集中在 [idverify_binding.go](apps/services/modules/user/idverify_binding.go),落库在 `model_idverify.go`。 + +- **`useridverify` 明细是权威,`user.idverified/idverifiedtime` 只是派生缓存**(后台列表筛选用)。 + 客户端闸门 `user_getidverify` 只读明细。两边是两次独立写,所以 home 启动时 `reconcileUserFlags` + 双向校正(明细通过而标识没打 → 补;标识打了而明细没通过 → 清),幂等。 +- ⚠️ **已实名账号「修改认证」失败(不一致 / 服务商报错)时,明细上的身份字段一律不动** + (`applyIdVerifyAttempt`)。原实现先无条件覆盖姓名/掩码/指纹再看结果,一次填错就把账号上的 + 实名身份换成了没核验通过的人,`verifytime` 却还是通过状态;再提交回原来那个人又被当换人再计费。 +- **注销必须同时清 `idverified / idverifiedtime / gender`**(`scrubCancelledUserIdVerify`)。 + 原先只删明细行,账号在后台仍显示「已实名」、详情却没有姓名证件;复活分支不碰这三个字段,只能在注销时清。 +- **换人 / 注销留痕到 `useridverify_log`**(本地 gorm 结构,不进 pb):只存掩码 + 指纹,**不存姓名** + (注销要清 PII)。留痕失败只告警不阻断注销。用户投诉「身份证被别的账号用过」时按 `oldhash/newhash` 查。 +- ⚠️ **`user` 表是 `utf8mb4_general_ci`(老库导入),gorm 新建的表是 `utf8mb4_0900_ai_ci`**, + 两表联查直接报 `1267 Illegal mix of collations`。`alignCollation` 启动时把实名两张表 CONVERT 到 + 与 `user` 表一致;手写联表 SQL 时记得加 `COLLATE`。其它 gorm 新建表与 `user` 联查同样会踩这个。 +- **未修、属产品决策**:① 实名跟 uid 走,游客号实名后换手机号登录是另一个 uid,要再实名一次(再计费), + 没有账号合并/转正接口——这是「同证多号」最自然的来源,后台「同证件账号数」分不清是一人多号还是多人共证; + ② 实名按应用库隔离,同一个人在 ym / deepGlass 要各实名一次。 + ## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改) **客户端不再持有对象存储的长期密钥。** diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index c1f9fb9e..48bc5dd2 100644 --- a/apps/services/comm/const.go +++ b/apps/services/comm/const.go @@ -48,6 +48,7 @@ const ( TableUser = "user" //用户表 TableUserdevice = "userdevice" //用户设备列表 TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希) + TableUserIdVerifyLog = "useridverify_log" //实名绑定变更留痕(换人/注销),只存掩码+指纹,不存姓名 TableRecord = "record" //记录表 TableAgent = "agent" //智能体id TableMcp = "mcp" //Mcp服务 diff --git a/apps/services/modules/user/api_idverify.go b/apps/services/modules/user/api_idverify.go index 38c2cf9a..7758fba6 100644 --- a/apps/services/modules/user/api_idverify.go +++ b/apps/services/modules/user/api_idverify.go @@ -45,8 +45,12 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq // 注意不能对「已实名」一律早返回 —— 客户端的「修改认证」就是靠再次提交换人, // 早返回会让改绑永远改不动(表现为点了确定但姓名没变)。 salt := this.module.idverifymodel.salt() - if record != nil && record.Verifytime > 0 && - record.Realname == realname && sameIdCard(record, idcardno, salt) { + ident := idVerifyIdentity{ + realname: realname, + idcardmask: idverify.MaskIdCard(idcardno), + idcardhash: idverify.HashIdCard(idcardno, salt), + } + if record != nil && record.Verifytime > 0 && sameIdentity(record, ident) { resp = &pb.UserIdVerifyResp{ Verified: true, Realname: record.Realname, Idcardmask: record.Idcardmask, Verifytime: record.Verifytime, @@ -87,16 +91,11 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq if record == nil { record = &pb.DBUserIdVerify{Uid: uid} } - record.Realname = realname - record.Idcardmask = idverify.MaskIdCard(idcardno) - record.Idcardhash = idverify.HashIdCard(idcardno, this.module.idverifymodel.salt()) - record.Provider = verifier.Provider() - record.Svcid = svcId // 调用失败 ≠ 不一致:结论未知,不写 bizcode、不算作用户填错。 + // 已实名记录上的身份字段在失败时一律不动(见 applyIdVerifyAttempt)。 if err != nil { - record.Failcount++ - record.Lastfailtime = now + applyIdVerifyAttempt(record, false, ident, verifier.Provider(), svcId, "", now) _ = this.module.idverifymodel.save(record) this.module.Error("IdVerify: 服务商调用失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "provider", Value: verifier.Provider()}, @@ -109,10 +108,8 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq return } - record.Bizcode = result.BizCode if !result.Matched { - record.Failcount++ - record.Lastfailtime = now + applyIdVerifyAttempt(record, false, ident, verifier.Provider(), svcId, result.BizCode, now) _ = this.module.idverifymodel.save(record) errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyMismatch, Message: "姓名与身份证号不一致"} return @@ -120,12 +117,17 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq // 6) 通过:写明细 + 打 user 表标识 + 回写真实性别。 // 性别取身份证第 17 位(奇男偶女),比用户自己选的更可信,核验通过后以它为准。 - record.Verifytime = now - record.Failcount = 0 + // 换人(已实名账号改绑成另一张证)时留一条 rebind 痕迹,主表不保留历史。 + replaced := applyIdVerifyAttempt(record, true, ident, verifier.Provider(), svcId, result.BizCode, now) if err = this.module.idverifymodel.save(record); err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} return } + if replaced != nil { + if lerr := this.module.idverifymodel.logRebind(uid, replaced, record, now); lerr != nil { + this.module.Error("IdVerify: 换人留痕失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: lerr.Error()}) + } + } gender := idverify.GenderFromIdCard(idcardno) if err = this.module.idverifymodel.markUserVerified(uid, now, gender); err != nil { // 明细已落库,标识没打上:不让用户重复走一次计费核验,只记日志由运维补。 @@ -141,16 +143,6 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq return } -// sameIdCard 判断提交的号码是否就是记录里那张证件。 -// 有盐时比指纹(最准);没配盐时指纹为空,退而比掩码——掩码只保留首尾各 4 位, -// 中间 10 位不同的两张证件会被误判成同一张,所以这只是降级兜底,生产务必配 ID_HASH_SALT。 -func sameIdCard(record *pb.DBUserIdVerify, idcardno, salt string) bool { - if h := idverify.HashIdCard(idcardno, salt); h != "" && record.Idcardhash != "" { - return h == record.Idcardhash - } - return record.Idcardmask == idverify.MaskIdCard(idcardno) -} - // @Summary 查询实名状态 // @Description 查询本账号是否已通过身份证实名校验 // @Tags User diff --git a/apps/services/modules/user/idverify_binding.go b/apps/services/modules/user/idverify_binding.go new file mode 100644 index 00000000..82b0075c --- /dev/null +++ b/apps/services/modules/user/idverify_binding.go @@ -0,0 +1,102 @@ +package user + +import ( + "regexp" + "strings" + + "yunyan/pb" +) + +// 用户账号 ↔ 实名记录 的绑定口径,集中在这一个文件里(2026-09-19 梳理)。 +// +// 两张表: +// - useridverify(明细,一 uid 一行)是**权威**:客户端闸门 user_getidverify 只读它, +// 计费核验的结论也只落在它上面; +// - user.idverified / idverifiedtime 是明细的**派生缓存**,只为后台列表按实名筛选, +// 方向永远是「明细 → 标识」,标识不能反过来推出明细。 +// +// 这里放的都是不碰数据库的纯逻辑,好写测试;落库的在 model_idverify.go。 + +// idVerifyIdentity 一次提交的身份三元组(姓名 + 掩码 + 指纹)。 +type idVerifyIdentity struct { + realname string + idcardmask string + idcardhash string +} + +// sameIdentity 提交的是不是记录上那个人。指纹优先(最准),没配盐时指纹为空、退回比掩码 +// (掩码只留首尾各 4 位,会碰撞,所以生产务必配 ID_HASH_SALT)。 +func sameIdentity(record *pb.DBUserIdVerify, id idVerifyIdentity) bool { + if record.Realname != id.realname { + return false + } + if id.idcardhash != "" && record.Idcardhash != "" { + return id.idcardhash == record.Idcardhash + } + return record.Idcardmask == id.idcardmask +} + +// applyIdVerifyAttempt 把一次核验的结果合并进记录,返回被顶掉的旧身份(换人时非 nil,供留痕)。 +// +// ⚠️ 失败时**不能改写已实名记录上的身份字段**。原实现是先无条件把姓名/掩码/指纹 +// 换成本次提交的,再看结果——于是已实名的人「修改认证」填错一次,明细行就变成 +// 「verifytime 还是通过、身份却是那个没通过的人」:user_getidverify 回的是未核验的姓名, +// 后台看到的也是;再提交原来那个人又被当成换人、再计费一次。 +// 未实名的记录(verifytime==0)记下最后一次尝试的身份是可以的,排查刷接口时有用。 +// +// bizcode 为空表示服务商没给结论(调用失败),不覆盖已有的结果码。 +func applyIdVerifyAttempt(record *pb.DBUserIdVerify, matched bool, id idVerifyIdentity, + provider, svcId, bizcode string, now int64) (replaced *pb.DBUserIdVerify) { + if !matched { + record.Failcount++ + record.Lastfailtime = now + if record.Verifytime > 0 { + return nil + } + record.Realname, record.Idcardmask, record.Idcardhash = id.realname, id.idcardmask, id.idcardhash + record.Provider, record.Svcid = provider, svcId + if bizcode != "" { + record.Bizcode = bizcode + } + return nil + } + if record.Verifytime > 0 && !sameIdentity(record, id) { + // 只复制留痕要用的身份字段,别整体拷 pb 结构(内含 mutex,vet 会报 copylocks)。 + replaced = &pb.DBUserIdVerify{ + Uid: record.Uid, Realname: record.Realname, + Idcardmask: record.Idcardmask, Idcardhash: record.Idcardhash, + Verifytime: record.Verifytime, + } + } + record.Realname, record.Idcardmask, record.Idcardhash = id.realname, id.idcardmask, id.idcardhash + record.Provider, record.Svcid, record.Bizcode = provider, svcId, bizcode + record.Verifytime = now + record.Failcount = 0 + return replaced +} + +// scrubCancelledUserIdVerify 注销时把 user 行上由实名派生出来的三个字段一并清掉。 +// +// 注销删的是明细行(见 modelUserComp.cancel),标识若不同步清,这个账号在后台列表里 +// 仍是「已实名」、详情却没有姓名证件;性别也还是上一张身份证回写的值。 +// 复活(api_sgin 的 Cancelled 分支)不会再碰这三个字段,所以只能在这里清。 +func scrubCancelledUserIdVerify(user *pb.DBUser) { + user.Idverified = false + user.Idverifiedtime = 0 + user.Gender = 0 +} + +// collationCharset 由排序规则名推字符集名(utf8mb4_0900_ai_ci → utf8mb4)。 +// 名字来自 information_schema,仍按白名单字符校验一次再拼进 DDL。 +var collationNameRe = regexp.MustCompile(`^[a-z0-9_]+$`) + +func collationCharset(collation string) (charset string, ok bool) { + if !collationNameRe.MatchString(collation) { + return "", false + } + i := strings.IndexByte(collation, '_') + if i <= 0 { + return "", false + } + return collation[:i], true +} diff --git a/apps/services/modules/user/model_idverify.go b/apps/services/modules/user/model_idverify.go index 767ef671..67c447fa 100644 --- a/apps/services/modules/user/model_idverify.go +++ b/apps/services/modules/user/model_idverify.go @@ -37,15 +37,121 @@ type modelIdVerifyComp struct { 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{} diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go index 98487e34..2c64aa8e 100644 --- a/apps/services/modules/user/model_user.go +++ b/apps/services/modules/user/model_user.go @@ -180,10 +180,17 @@ func (this *modelUserComp) cancel(user *pb.DBUser) (err error) { if err = mysql.Delete(comm.TableMemoryReport, "uid=?", uid); err != nil { return } - // 实名认证记录(身份证掩码 + 加盐指纹)。既有遗漏,本次一并补上。 + // 实名认证记录(身份证掩码 + 加盐指纹)。删之前留一条 cancel 痕迹(只有掩码+指纹), + // 用户日后投诉「我的身份证被别的账号用过」时还能按指纹查到曾绑在哪个 uid 上。 + // 留痕失败只记日志不阻断——注销是用户的法定权利,不能被一张审计表卡住。 + if lerr := this.module.idverifymodel.logCancel(uid); lerr != nil { + this.module.Warn("注销:实名留痕写入失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: lerr.Error()}) + } if err = mysql.Delete(comm.TableUserIdVerify, "uid=?", uid); err != nil { return } + // user 行上由实名派生的三个字段同步清掉,否则后台列表仍显示「已实名」、性别也留着上一张证的值。 + scrubCancelledUserIdVerify(user) // 3) 清理 DBUser 上的业务字段与 PII,保留身份锚点、Createtime、Isgiveaway user.Name = "" user.Avatar = "" @@ -201,6 +208,7 @@ func (this *modelUserComp) cancel(user *pb.DBUser) (err error) { user.Canceltime = time.Now().Unix() // 保留:Uid / Phone / Mail / Wxopenid / Googleopenid / Facebookopenid / Appleopenid // Createtime / Isgiveaway / Isactivatecode / Language / Channel / Version / Lastsgintime + // 清掉:Idverified / Idverifiedtime / Gender(见 scrubCancelledUserIdVerify) err = mysql.Save(comm.TableUser, user) return } diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go index 8e68463f..ba5d85e5 100644 --- a/apps/services/modules/user/module_test.go +++ b/apps/services/modules/user/module_test.go @@ -893,3 +893,111 @@ func TestToPbChannelAppBothNil(t *testing.T) { t.Fatalf("两边都没配时应返回 nil,实际 %+v", got) } } + +// --------------------------------------------------------------------------- +// ⑧ 实名绑定口径(idverify_binding.go) +// --------------------------------------------------------------------------- + +// 已实名账号「修改认证」填错人:明细上的身份字段必须原样保留,只累计失败。 +// 原实现先无条件覆盖身份再看结果,一次失败提交就把账号上的实名身份换成了没核验通过的人。 +func TestIdVerifyFailureKeepsVerifiedIdentity(t *testing.T) { + rec := &pb.DBUserIdVerify{Uid: "u1", Realname: "张三", Idcardmask: "4403**********1234", Idcardhash: "h-old", + Provider: "aliyun", Svcid: "idverify_aliyun", Bizcode: "1", Verifytime: 1000} + wrong := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h-new"} + for _, bizcode := range []string{"2", ""} { // 不一致 / 服务商调用失败 + replaced := applyIdVerifyAttempt(rec, false, wrong, "tencent", "idverify_tencent", bizcode, 2000) + if replaced != nil { + t.Fatalf("失败不算换人,不该返回旧身份") + } + if rec.Realname != "张三" || rec.Idcardhash != "h-old" || rec.Idcardmask != "4403**********1234" { + t.Fatalf("失败的提交改写了已实名身份:%+v", rec) + } + if rec.Provider != "aliyun" || rec.Bizcode != "1" || rec.Verifytime != 1000 { + t.Fatalf("失败的提交改写了通过时的核验信息:%+v", rec) + } + } + if rec.Failcount != 2 || rec.Lastfailtime != 2000 { + t.Fatalf("失败计数应累计:%+v", rec) + } + // 再提交回原来那个人 → 与记录一致,调用方据此免计费直接返回。 + if !sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4403**********1234", idcardhash: "h-old"}) { + t.Fatal("失败之后原身份应仍能匹配") + } +} + +// 未实名的记录可以记下最后一次尝试的身份(排查刷接口用),失败调用没有 bizcode 时不覆盖已有结果码。 +func TestIdVerifyFailureOnUnverifiedRecord(t *testing.T) { + rec := &pb.DBUserIdVerify{Uid: "u1", Bizcode: "2"} + id := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h"} + applyIdVerifyAttempt(rec, false, id, "aliyun", "s", "", 10) + if rec.Realname != "李四" || rec.Idcardhash != "h" || rec.Bizcode != "2" || rec.Failcount != 1 { + t.Fatalf("未实名记录应记下尝试身份且保留结果码:%+v", rec) + } +} + +// 通过:首次实名不算换人;已实名换成另一张证要把旧身份交出去留痕;同一个人重验不留痕。 +func TestIdVerifySuccessRebind(t *testing.T) { + rec := &pb.DBUserIdVerify{Uid: "u1"} + a := idVerifyIdentity{realname: "张三", idcardmask: "4403**********1234", idcardhash: "h-a"} + if r := applyIdVerifyAttempt(rec, true, a, "aliyun", "s", "1", 100); r != nil { + t.Fatal("首次实名不该视为换人") + } + if rec.Verifytime != 100 || rec.Failcount != 0 || rec.Idcardhash != "h-a" { + t.Fatalf("通过后记录未更新:%+v", rec) + } + rec.Failcount = 3 + if r := applyIdVerifyAttempt(rec, true, a, "aliyun", "s", "1", 200); r != nil || rec.Failcount != 0 { + t.Fatalf("同一个人重验不留痕且清零失败计数,实际 replaced=%v rec=%+v", r, rec) + } + b := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h-b"} + r := applyIdVerifyAttempt(rec, true, b, "tencent", "s2", "0", 300) + if r == nil || r.Idcardhash != "h-a" || r.Idcardmask != "4403**********1234" || r.Verifytime != 200 { + t.Fatalf("换人应交出旧身份留痕,实际 %+v", r) + } + if rec.Idcardhash != "h-b" || rec.Realname != "李四" || rec.Verifytime != 300 || rec.Provider != "tencent" { + t.Fatalf("换人后记录应是新身份:%+v", rec) + } +} + +// 没配盐时指纹为空,退回比掩码;配了盐一律比指纹,掩码相同指纹不同是两个人。 +func TestSameIdentityFallback(t *testing.T) { + rec := &pb.DBUserIdVerify{Realname: "张三", Idcardmask: "4304**********8812", Idcardhash: ""} + if !sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4304**********8812", idcardhash: "x"}) { + t.Fatal("记录无指纹时应退回比掩码") + } + rec.Idcardhash = "h1" + if sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4304**********8812", idcardhash: "h2"}) { + t.Fatal("掩码相同但指纹不同是两张证件(测试机上真有这样一对)") + } + if sameIdentity(rec, idVerifyIdentity{realname: "张四", idcardmask: "4304**********8812", idcardhash: "h1"}) { + t.Fatal("姓名不同不算同一个人") + } +} + +// 注销必须把 user 行上由实名派生的三个字段清掉,其余身份锚点不动。 +func TestScrubCancelledUserIdVerify(t *testing.T) { + u := &pb.DBUser{Uid: "u1", Phone: "+8613800000000", Idverified: true, Idverifiedtime: 123, Gender: 1, Isgiveaway: true} + scrubCancelledUserIdVerify(u) + if u.Idverified || u.Idverifiedtime != 0 || u.Gender != 0 { + t.Fatalf("实名派生字段未清:%+v", u) + } + if u.Phone == "" || !u.Isgiveaway { + t.Fatalf("不该动身份锚点与防刷标记:%+v", u) + } +} + +// 排序规则 → 字符集,且只接受白名单字符(它要拼进 DDL)。 +func TestCollationCharset(t *testing.T) { + for coll, want := range map[string]string{ + "utf8mb4_0900_ai_ci": "utf8mb4", "utf8mb4_general_ci": "utf8mb4", "latin1_swedish_ci": "latin1", + } { + if got, ok := collationCharset(coll); !ok || got != want { + t.Fatalf("%s → %q ok=%v,期望 %q", coll, got, ok, want) + } + } + for _, bad := range []string{"", "utf8mb4", "utf8mb4_0900_ai_ci; DROP TABLE user", "UTF8MB4_0900_AI_CI"} { + if _, ok := collationCharset(bad); ok { + t.Fatalf("%q 不该被接受", bad) + } + } +}