Browse Source

services: 梳理并修「账号 ↔ 实名记录」的绑定口径

useridverify 明细是权威,user.idverified/idverifiedtime 只是后台筛选用的派生
缓存,两边是两次独立写,此前会各自漂移。纯逻辑收进 idverify_binding.go:

- reconcileUserFlags:home 启动时双向校正派生标识,幂等。
- applyIdVerifyAttempt:已实名账号「修改认证」失败时,明细上的身份字段一律
  不动。原实现先无条件覆盖姓名/掩码/指纹再看结果,一次填错就把账号上的实名
  身份换成了没核验通过的人,verifytime 却还停在通过状态;再提交回原来那个人
  又被当成换人重新计费。
- scrubCancelledUserIdVerify:注销同时清 idverified/idverifiedtime/gender。
  原先只删明细行,账号在后台仍显示「已实名」、详情里却没有姓名证件。
- 换人/注销留痕到 useridverify_log,只存掩码+指纹,不存姓名(注销要清 PII);
  留痕失败只告警不阻断注销。
- alignCollation:user 表是 utf8mb4_general_ci(老库导入),gorm 新建的表是
  utf8mb4_0900_ai_ci,联查直接报 1267。启动时把实名两张表 CONVERT 对齐。

CLAUDE.md 补这一节,含两条未修、属产品决策的边界(实名跟 uid 走、按应用库隔离)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
76aa2032bf
  1. 21
      CLAUDE.md
  2. 1
      apps/services/comm/const.go
  3. 40
      apps/services/modules/user/api_idverify.go
  4. 102
      apps/services/modules/user/idverify_binding.go
  5. 106
      apps/services/modules/user/model_idverify.go
  6. 10
      apps/services/modules/user/model_user.go
  7. 108
      apps/services/modules/user/module_test.go

21
CLAUDE.md

@ -1738,6 +1738,27 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
- 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。
- 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 - 本地先校验身份证格式(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 改) ## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改)
**客户端不再持有对象存储的长期密钥。** **客户端不再持有对象存储的长期密钥。**

1
apps/services/comm/const.go

@ -48,6 +48,7 @@ const (
TableUser = "user" //用户表 TableUser = "user" //用户表
TableUserdevice = "userdevice" //用户设备列表 TableUserdevice = "userdevice" //用户设备列表
TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希) TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希)
TableUserIdVerifyLog = "useridverify_log" //实名绑定变更留痕(换人/注销),只存掩码+指纹,不存姓名
TableRecord = "record" //记录表 TableRecord = "record" //记录表
TableAgent = "agent" //智能体id TableAgent = "agent" //智能体id
TableMcp = "mcp" //Mcp服务 TableMcp = "mcp" //Mcp服务

40
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() salt := this.module.idverifymodel.salt()
if record != nil && record.Verifytime > 0 && ident := idVerifyIdentity{
record.Realname == realname && sameIdCard(record, idcardno, salt) { realname: realname,
idcardmask: idverify.MaskIdCard(idcardno),
idcardhash: idverify.HashIdCard(idcardno, salt),
}
if record != nil && record.Verifytime > 0 && sameIdentity(record, ident) {
resp = &pb.UserIdVerifyResp{ resp = &pb.UserIdVerifyResp{
Verified: true, Realname: record.Realname, Verified: true, Realname: record.Realname,
Idcardmask: record.Idcardmask, Verifytime: record.Verifytime, Idcardmask: record.Idcardmask, Verifytime: record.Verifytime,
@ -87,16 +91,11 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq
if record == nil { if record == nil {
record = &pb.DBUserIdVerify{Uid: uid} 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、不算作用户填错。 // 调用失败 ≠ 不一致:结论未知,不写 bizcode、不算作用户填错。
// 已实名记录上的身份字段在失败时一律不动(见 applyIdVerifyAttempt)。
if err != nil { if err != nil {
record.Failcount++ applyIdVerifyAttempt(record, false, ident, verifier.Provider(), svcId, "", now)
record.Lastfailtime = now
_ = this.module.idverifymodel.save(record) _ = this.module.idverifymodel.save(record)
this.module.Error("IdVerify: 服务商调用失败", this.module.Error("IdVerify: 服务商调用失败",
log.Field{Key: "uid", Value: uid}, log.Field{Key: "provider", Value: verifier.Provider()}, 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 return
} }
record.Bizcode = result.BizCode
if !result.Matched { if !result.Matched {
record.Failcount++ applyIdVerifyAttempt(record, false, ident, verifier.Provider(), svcId, result.BizCode, now)
record.Lastfailtime = now
_ = this.module.idverifymodel.save(record) _ = this.module.idverifymodel.save(record)
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyMismatch, Message: "姓名与身份证号不一致"} errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyMismatch, Message: "姓名与身份证号不一致"}
return return
@ -120,12 +117,17 @@ func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq
// 6) 通过:写明细 + 打 user 表标识 + 回写真实性别。 // 6) 通过:写明细 + 打 user 表标识 + 回写真实性别。
// 性别取身份证第 17 位(奇男偶女),比用户自己选的更可信,核验通过后以它为准。 // 性别取身份证第 17 位(奇男偶女),比用户自己选的更可信,核验通过后以它为准。
record.Verifytime = now // 换人(已实名账号改绑成另一张证)时留一条 rebind 痕迹,主表不保留历史。
record.Failcount = 0 replaced := applyIdVerifyAttempt(record, true, ident, verifier.Provider(), svcId, result.BizCode, now)
if err = this.module.idverifymodel.save(record); err != nil { if err = this.module.idverifymodel.save(record); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return 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) gender := idverify.GenderFromIdCard(idcardno)
if err = this.module.idverifymodel.markUserVerified(uid, now, gender); err != nil { 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 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 查询实名状态 // @Summary 查询实名状态
// @Description 查询本账号是否已通过身份证实名校验 // @Description 查询本账号是否已通过身份证实名校验
// @Tags User // @Tags User

102
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
}

106
apps/services/modules/user/model_idverify.go

@ -37,15 +37,121 @@ type modelIdVerifyComp struct {
module *User 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) { 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.ModuleCompBase.Init(service, module, comp, opt)
this.module = module.(*User) this.module = module.(*User)
if err = mysql.CreateTable(comm.TableUserIdVerify, &pb.DBUserIdVerify{}); err != nil { if err = mysql.CreateTable(comm.TableUserIdVerify, &pb.DBUserIdVerify{}); err != nil {
this.module.Errorln(err) 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 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。 // find 取某账号的实名记录;无记录返回 nil,nil。
func (this *modelIdVerifyComp) find(uid string) (*pb.DBUserIdVerify, error) { func (this *modelIdVerifyComp) find(uid string) (*pb.DBUserIdVerify, error) {
model := &pb.DBUserIdVerify{} model := &pb.DBUserIdVerify{}

10
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 { if err = mysql.Delete(comm.TableMemoryReport, "uid=?", uid); err != nil {
return 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 { if err = mysql.Delete(comm.TableUserIdVerify, "uid=?", uid); err != nil {
return return
} }
// user 行上由实名派生的三个字段同步清掉,否则后台列表仍显示「已实名」、性别也留着上一张证的值。
scrubCancelledUserIdVerify(user)
// 3) 清理 DBUser 上的业务字段与 PII,保留身份锚点、Createtime、Isgiveaway // 3) 清理 DBUser 上的业务字段与 PII,保留身份锚点、Createtime、Isgiveaway
user.Name = "" user.Name = ""
user.Avatar = "" user.Avatar = ""
@ -201,6 +208,7 @@ func (this *modelUserComp) cancel(user *pb.DBUser) (err error) {
user.Canceltime = time.Now().Unix() user.Canceltime = time.Now().Unix()
// 保留:Uid / Phone / Mail / Wxopenid / Googleopenid / Facebookopenid / Appleopenid // 保留:Uid / Phone / Mail / Wxopenid / Googleopenid / Facebookopenid / Appleopenid
// Createtime / Isgiveaway / Isactivatecode / Language / Channel / Version / Lastsgintime // Createtime / Isgiveaway / Isactivatecode / Language / Channel / Version / Lastsgintime
// 清掉:Idverified / Idverifiedtime / Gender(见 scrubCancelledUserIdVerify)
err = mysql.Save(comm.TableUser, user) err = mysql.Save(comm.TableUser, user)
return return
} }

108
apps/services/modules/user/module_test.go

@ -893,3 +893,111 @@ func TestToPbChannelAppBothNil(t *testing.T) {
t.Fatalf("两边都没配时应返回 nil,实际 %+v", got) 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)
}
}
}

Loading…
Cancel
Save