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() if record != nil && record.Verifytime > 0 && record.Realname == realname && sameIdCard(record, idcardno, salt) { 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} } 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、不算作用户填错。 if err != nil { record.Failcount++ record.Lastfailtime = 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 } record.Bizcode = result.BizCode if !result.Matched { record.Failcount++ record.Lastfailtime = now _ = this.module.idverifymodel.save(record) errdata = &pb.ErrorData{Code: pb.ErrorCode_IdVerifyMismatch, Message: "姓名与身份证号不一致"} return } // 6) 通过:写明细 + 打 user 表标识 + 回写真实性别。 // 性别取身份证第 17 位(奇男偶女),比用户自己选的更可信,核验通过后以它为准。 record.Verifytime = now record.Failcount = 0 if err = this.module.idverifymodel.save(record); err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} return } 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 } // 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 // @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 }