Browse Source
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
7 changed files with 363 additions and 25 deletions
@ -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 |
|||
} |
|||
Loading…
Reference in new issue