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.
 
 
 
 
 
 

169 lines
6.8 KiB

package user
import (
"context"
"errors"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"yunyan/sys/idverify"
)
// @Summary 实名认证(身份证二要素核验)
// @Description 提交姓名+身份证号,由服务端调用后台配置的核验服务商(阿里云/腾讯云/创蓝)核验
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.UserIdVerifyReq true "实名认证"
// @Success 200 {object} comm.HttpResult{data=pb.UserIdVerifyResp} "成功返回"
// @Router /api/home/user_idverify [post]
//
// 落库口径(合规):身份证号**全文不落库**,只存掩码与加盐 SHA-256 指纹。
// 一证可绑多号(不拦),指纹用于后台排查同一证件下的账号。
func (this *apiComp) IdVerify(session comm.IUserSession, req *pb.UserIdVerifyReq) (resp *pb.UserIdVerifyResp, errdata *pb.ErrorData) {
uid := session.GetUserId()
realname := strings.TrimSpace(req.Realname)
idcardno := strings.ToUpper(strings.TrimSpace(req.Idcardno))
// 1) 本地校验:挡在计费调用之前。
if realname == "" || len([]rune(realname)) > 32 || !idverify.ValidIdCard(idcardno) {
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyParamInvalid, Message: "姓名或身份证号格式不合法"}
return
}
record, err := this.module.idverifymodel.find(uid)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
// 2) 已实名 + 提交的还是同一个人:直接返回,不再走一次计费核验。
// 注意不能对「已实名」一律早返回 —— 客户端的「修改认证」就是靠再次提交换人,
// 早返回会让改绑永远改不动(表现为点了确定但姓名没变)。
salt := this.module.idverifymodel.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,
}
return
}
// 3) 限流:窗口内失败次数达上限即拒。
now := time.Now().Unix()
if record != nil && record.Failcount >= idVerifyFailLimit &&
now-record.Lastfailtime < int64(idVerifyFailWindow.Seconds()) {
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyTooFrequent, Message: "校验过于频繁,请稍后再试"}
return
}
// 窗口已过则清零,重新计数。
if record != nil && now-record.Lastfailtime >= int64(idVerifyFailWindow.Seconds()) {
record.Failcount = 0
}
// 4) 解析后台配置的核验服务。
svcId, verifier, err := this.module.idverifymodel.resolveVerifier()
if err != nil {
this.module.Error("IdVerify: 核验服务解析失败",
log.Field{Key: "uid", Value: uid}, log.Field{Key: "svc", Value: svcId}, log.Field{Key: "err", Value: err.Error()})
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyNotConfigured, Message: "实名认证服务不可用"}
return
}
if verifier == nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyNotConfigured, Message: "未配置实名认证服务"}
return
}
// 5) 调服务商。
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result, err := verifier.Verify(ctx, realname, idcardno)
if record == nil {
record = &pb.DBUserIdVerify{Uid: uid}
}
// 调用失败 ≠ 不一致:结论未知,不写 bizcode、不算作用户填错。
// 已实名记录上的身份字段在失败时一律不动(见 applyIdVerifyAttempt)。
if err != nil {
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()},
log.Field{Key: "svc", Value: svcId}, log.Field{Key: "err", Value: err.Error()})
code := pb.ErrorCode_IdVerifyProviderError
if errors.Is(err, idverify.ErrMissingCredential) || errors.Is(err, idverify.ErrUnsupportedProvider) {
code = pb.ErrorCode_IdVerifyNotConfigured
}
errdata = &pb.ErrorData{Code: code, Message: "实名认证服务暂时不可用,请稍后再试"}
return
}
if !result.Matched {
applyIdVerifyAttempt(record, false, ident, verifier.Provider(), svcId, result.BizCode, now)
_ = this.module.idverifymodel.save(record)
errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyMismatch, Message: "姓名与身份证号不一致"}
return
}
// 6) 通过:写明细 + 打 user 表标识 + 回写真实性别。
// 性别取身份证第 17 位(奇男偶女),比用户自己选的更可信,核验通过后以它为准。
// 换人(已实名账号改绑成另一张证)时留一条 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 {
// 明细已落库,标识没打上:不让用户重复走一次计费核验,只记日志由运维补。
this.module.Error("IdVerify: user 表实名标识回写失败",
log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()})
}
resp = &pb.UserIdVerifyResp{
Verified: true, Realname: record.Realname,
Idcardmask: record.Idcardmask, Verifytime: record.Verifytime,
Gender: gender,
}
return
}
// @Summary 查询实名状态
// @Description 查询本账号是否已通过身份证实名校验
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.UserGetIdVerifyReq true "查询实名状态"
// @Success 200 {object} comm.HttpResult{data=pb.UserGetIdVerifyResp} "成功返回"
// @Router /api/home/user_getidverify [post]
func (this *apiComp) GetIdVerify(session comm.IUserSession, req *pb.UserGetIdVerifyReq) (resp *pb.UserGetIdVerifyResp, errdata *pb.ErrorData) {
record, err := this.module.idverifymodel.find(session.GetUserId())
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
resp = &pb.UserGetIdVerifyResp{}
if record != nil && record.Verifytime > 0 {
resp.Verified = true
resp.Realname = record.Realname
resp.Idcardmask = record.Idcardmask
resp.Verifytime = record.Verifytime
}
return
}