You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
263 lines
11 KiB
263 lines
11 KiB
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>)优先于全局行(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
|
|
}
|
|
|