Browse Source
本次会话开始前就已存在于工作区的改动,一并提交。主要是三块:
1) 身份证实名核验(sys/idverify + user 模块)
接入阿里云 Id2MetaVerify、腾讯云 IdCardVerification、创蓝三家 provider,
无状态工厂——配置在 svc_config 里按应用作用域存,调用时才解析。
客户端接口 user_idverify / user_getidverify。
凭据是云账号主 AK/SK,故新增服务端专用类别 comm.SvcCatIdVerify=11,
由 svcresolve.go 在下发口整条跳过,svcpool_serveronly_test.go 守着这条底线。
2) 翻译(sys/aliyun/translate + comm/lang.go)
语言码归一与阿里云翻译调用。
3) 新用户开户礼(newuser_gift.go)
配套 api_sgin 建号流程。
注:go test ./modules/user/ 里的 TestApiProbeAppConfigV3 会失败,但与本次改动无关
——那是打线上接口的联调探针,硬编码的域名 app-dev.voitrans.net 已废弃、JWT 也过期了,
该文件自 b2577e16 起未改动过。go test -short 会跳过它,其余全部通过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
31 changed files with 2012 additions and 29 deletions
@ -0,0 +1,53 @@ |
|||||
|
package comm |
||||
|
|
||||
|
import "strings" |
||||
|
|
||||
|
// 语言码归一:客户端与各服务商用的码不是一套。
|
||||
|
//
|
||||
|
// 客户端一律传 BCP-47(zh-CN / en-US / ja-JP…),而阿里云机器翻译要的是短 ISO 码
|
||||
|
// (zh / en / ja…)。传错格式阿里云不会报错,只会返回空结果或原文,
|
||||
|
// 排查起来非常费劲,所以这里统一收口。
|
||||
|
//
|
||||
|
// 这份表原先在 modules/echomeet 里私有,实时翻译接口也要用,提到 comm 共用。
|
||||
|
|
||||
|
var bcp47ToShort = map[string]string{ |
||||
|
"zh-CN": "zh", "zh-TW": "zh", "zh-HK": "zh", |
||||
|
"en-US": "en", "en-GB": "en", |
||||
|
"ja-JP": "ja", |
||||
|
"ko-KR": "ko", |
||||
|
"id-ID": "id", |
||||
|
"es-MX": "es", "es-ES": "es", |
||||
|
"pt-BR": "pt", "pt-PT": "pt", |
||||
|
"de-DE": "de", |
||||
|
"fr-FR": "fr", |
||||
|
"fil-PH": "fil", |
||||
|
"ms-MY": "ms", |
||||
|
"th-TH": "th", |
||||
|
"ar-SA": "ar", |
||||
|
"ru-RU": "ru", "ru-UA": "ru", |
||||
|
"vi-VN": "vi", |
||||
|
"tr-TR": "tr", |
||||
|
"pl-PL": "pl", |
||||
|
"nl-NL": "nl", |
||||
|
"it-IT": "it", |
||||
|
"uk-UA": "uk", |
||||
|
} |
||||
|
|
||||
|
// BCP47ToShortLang 把 BCP-47 语言码转成短 ISO 码(阿里云机器翻译用)。
|
||||
|
//
|
||||
|
// 表里没有的原样返回:一是客户端可能已经传的就是短码,二是新语种在补表之前
|
||||
|
// 至少还能透传给服务商试一把,比硬变成空串强。
|
||||
|
func BCP47ToShortLang(code string) string { |
||||
|
c := strings.TrimSpace(code) |
||||
|
if c == "" { |
||||
|
return "" |
||||
|
} |
||||
|
if v, ok := bcp47ToShort[c]; ok { |
||||
|
return v |
||||
|
} |
||||
|
// 已经是短码(en / zh)或没收录的地区变体(xx-YY):取主语言子标签
|
||||
|
if i := strings.IndexByte(c, '-'); i > 0 { |
||||
|
return strings.ToLower(c[:i]) |
||||
|
} |
||||
|
return strings.ToLower(c) |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package comm |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
// 语言码传错阿里云不会报错,只会返回空或原文——排查极费劲,所以用测试钉住。
|
||||
|
func TestBCP47ToShortLang(t *testing.T) { |
||||
|
cases := map[string]string{ |
||||
|
"zh-CN": "zh", "zh-TW": "zh", "zh-HK": "zh", |
||||
|
"en-US": "en", "ja-JP": "ja", "ko-KR": "ko", |
||||
|
"es-MX": "es", "pt-BR": "pt", "fil-PH": "fil", |
||||
|
"en": "en", // 已经是短码,原样
|
||||
|
"zh": "zh", |
||||
|
"EN-US": "en", // 大小写不敏感(表未命中时走主子标签并小写)
|
||||
|
"xx-YY": "xx", // 未收录的地区变体:取主语言,别变空串
|
||||
|
"": "", // 空进空出
|
||||
|
" en ": "en", // 去空白
|
||||
|
} |
||||
|
for in, want := range cases { |
||||
|
if got := BCP47ToShortLang(in); got != want { |
||||
|
t.Errorf("BCP47ToShortLang(%q) = %q, want %q", in, got, want) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package comm |
||||
|
|
||||
|
import ( |
||||
|
"strconv" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
// 这个测试守的是一条安全底线:服务端专用类别(实名认证等,凭据是云账号主 AK/SK)
|
||||
|
// 绝不能被 resolveThirdSvcs 下发给客户端。改动 IsServerOnlySvc 时必须让它继续通过。
|
||||
|
func TestIsServerOnlySvc(t *testing.T) { |
||||
|
idv := strconv.Itoa(int(SvcCatIdVerify)) |
||||
|
mcp := strconv.Itoa(int(SvcCatMCP)) |
||||
|
|
||||
|
cases := []struct { |
||||
|
categories string |
||||
|
want bool |
||||
|
why string |
||||
|
}{ |
||||
|
{idv, true, "纯身份证校验类别"}, |
||||
|
{mcp + "," + idv, true, "混在其它类别里也要拦住"}, |
||||
|
{idv + "," + mcp, true, "顺序无关"}, |
||||
|
{" " + idv + " ", true, "带空白也要识别"}, |
||||
|
{mcp, false, "MCP 是给客户端用的,照常下发"}, |
||||
|
{"1,2,3", false, "普通 STT/TTS/MT 照常下发"}, |
||||
|
{"", false, "空类别不拦"}, |
||||
|
{"abc", false, "非数字忽略、不误判"}, |
||||
|
} |
||||
|
for _, c := range cases { |
||||
|
if got := IsServerOnlySvc(c.categories); got != c.want { |
||||
|
t.Errorf("IsServerOnlySvc(%q) = %v, want %v (%s)", c.categories, got, c.want, c.why) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestCategoriesHas(t *testing.T) { |
||||
|
if !CategoriesHas("1,11,3", SvcCatIdVerify) { |
||||
|
t.Error("应识别出含 SvcCatIdVerify") |
||||
|
} |
||||
|
// 不能被子串匹配骗到:类别 1 不等于类别 11
|
||||
|
if CategoriesHas("11", 1) { |
||||
|
t.Error("类别 11 被误判为含类别 1(子串匹配 bug)") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,177 @@ |
|||||
|
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 |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/pb" |
||||
|
) |
||||
|
|
||||
|
// @Summary 实时机器翻译
|
||||
|
// @Description 同声传译/面对面翻译用;服务端调用后台「第三方服务配置」里启用的 MT 服务
|
||||
|
// @Tags User
|
||||
|
// @Accept json
|
||||
|
// @Produce json
|
||||
|
// @Security BearerAuth
|
||||
|
// @Param user body pb.UserTranslateReq true "翻译请求"
|
||||
|
// @Success 200 {object} comm.HttpResult{data=pb.UserTranslateResp} "成功返回"
|
||||
|
// @Router /api/home/user_translate [post]
|
||||
|
//
|
||||
|
// 语言码:客户端传 BCP-47(zh-CN/en-US),这里统一归一成服务商要的短码。
|
||||
|
// 阿里云对语言码传错不会报错,只会返回空或原文,所以归一放服务端收口。
|
||||
|
func (this *apiComp) Translate(session comm.IUserSession, req *pb.UserTranslateReq) (resp *pb.UserTranslateResp, errdata *pb.ErrorData) { |
||||
|
text := strings.TrimSpace(req.Text) |
||||
|
to := comm.BCP47ToShortLang(req.To) |
||||
|
from := comm.BCP47ToShortLang(req.From) |
||||
|
|
||||
|
if text == "" || to == "" { |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateParamInvalid, Message: "原文或目标语言为空"} |
||||
|
return |
||||
|
} |
||||
|
// 同语种直接回原文,别浪费一次调用(同传里源=目标的情况很常见)
|
||||
|
if from != "" && from == to { |
||||
|
resp = &pb.UserTranslateResp{Text: req.Text} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
svcId, provider, tr, err := this.module.translatemodel.resolveTranslator() |
||||
|
if err != nil { |
||||
|
this.module.Error("Translate: 翻译服务解析失败", |
||||
|
log.Field{Key: "svc", Value: svcId}, log.Field{Key: "provider", Value: provider}, |
||||
|
log.Field{Key: "err", Value: err.Error()}) |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateNotConfigured, Message: "翻译服务不可用"} |
||||
|
return |
||||
|
} |
||||
|
if tr == nil { |
||||
|
// 没配,或者配的是还没接的服务商
|
||||
|
this.module.Warn("Translate: 无可用翻译服务", |
||||
|
log.Field{Key: "svc", Value: svcId}, log.Field{Key: "provider", Value: provider}) |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateNotConfigured, Message: "未配置机器翻译服务"} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// from 为空即自动检测:交给服务商识别,省掉一次单独的语种识别调用,
|
||||
|
// 检测出的短码随响应回带,客户端据此把「自动检测」显示成具体语种。
|
||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
|
defer cancel() |
||||
|
translated, detected, err := tr.TranslateDetect(ctx, from, to, text) |
||||
|
if err != nil { |
||||
|
this.module.Error("Translate: 服务商调用失败", |
||||
|
log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "svc", Value: svcId}, |
||||
|
log.Field{Key: "from", Value: from}, log.Field{Key: "to", Value: to}, |
||||
|
log.Field{Key: "err", Value: err.Error()}) |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateProviderError, Message: "翻译失败,请稍后再试"} |
||||
|
return |
||||
|
} |
||||
|
if strings.TrimSpace(translated) == "" { |
||||
|
// 返回空通常意味着语言码不被支持——报错比悄悄返回空串强,
|
||||
|
// 否则客户端会显示一片空白,谁也不知道发生了什么。
|
||||
|
this.module.Warn("Translate: 服务商返回空结果", |
||||
|
log.Field{Key: "svc", Value: svcId}, log.Field{Key: "from", Value: from}, log.Field{Key: "to", Value: to}) |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateProviderError, Message: "翻译结果为空"} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
resp = &pb.UserTranslateResp{Text: translated, Provider: provider, Svcid: svcId, DetectedFrom: detected} |
||||
|
return |
||||
|
} |
||||
@ -0,0 +1,157 @@ |
|||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 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 |
||||
|
} |
||||
@ -0,0 +1,111 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"os" |
||||
|
"strings" |
||||
|
"sync" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/core" |
||||
|
"yunyan/lego/core/cbase" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
alitranslate "yunyan/sys/aliyun/translate" |
||||
|
) |
||||
|
|
||||
|
// 实时机器翻译(同声传译 / 面对面翻译)的服务解析。
|
||||
|
//
|
||||
|
// 与实名认证同一套路子:翻译服务配在后台「第三方服务配置」(类别 comm.SvcCatMT=3),
|
||||
|
// 存 console 共享库 postgres,凭据在服务端解密使用,**不下发客户端**。
|
||||
|
//
|
||||
|
// 为什么放服务端而不是像以前那样客户端直连:
|
||||
|
// 以前客户端用火山翻译,AK/SK 是通过 AppConfig.env **明文下发给每一个客户端**的,
|
||||
|
// 任何人抓个包或反编译就能拿到云账号凭据。改成服务端代调后凭据不再出网关。
|
||||
|
|
||||
|
// mtCacheTTL 翻译客户端的缓存时长。
|
||||
|
//
|
||||
|
// 同传是流式高频调用,每句都去查一次库、重建一次 SDK 客户端太浪费;
|
||||
|
// 但也不能永久缓存——后台改了配置得能生效,所以给个短 TTL。
|
||||
|
const mtCacheTTL = 60 * time.Second |
||||
|
|
||||
|
type modelTranslateComp struct { |
||||
|
cbase.ModuleCompBase |
||||
|
module *User |
||||
|
|
||||
|
mu sync.RWMutex |
||||
|
cached alitranslate.ISys |
||||
|
cachedId string |
||||
|
cachedAt time.Time |
||||
|
} |
||||
|
|
||||
|
func (this *modelTranslateComp) 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) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// resolveTranslator 取本应用可用的机器翻译服务。
|
||||
|
//
|
||||
|
// 作用域口径与 resolveThirdSvcs 一致:应用行(app_name=<app>)优先于全局行(app_name=''),
|
||||
|
// 只取启用的、类别含 comm.SvcCatMT 的服务。
|
||||
|
// 返回 svcId/provider 供落日志与响应回带,便于排查"到底用的哪家"。
|
||||
|
func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t alitranslate.ISys, err error) { |
||||
|
this.mu.RLock() |
||||
|
if this.cached != nil && time.Since(this.cachedAt) < mtCacheTTL { |
||||
|
svcId, t = this.cachedId, this.cached |
||||
|
this.mu.RUnlock() |
||||
|
return svcId, "alibaba", t, nil |
||||
|
} |
||||
|
this.mu.RUnlock() |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var picked *comm.ThirdSvcConfig |
||||
|
for _, s := range svcs { |
||||
|
if !s.Enable || !comm.CategoriesHas(s.Categories, comm.SvcCatMT) { |
||||
|
continue |
||||
|
} |
||||
|
if picked == nil || (picked.AppName == "" && s.AppName == app && app != "") { |
||||
|
picked = s |
||||
|
} |
||||
|
} |
||||
|
if picked == nil { |
||||
|
return "", "", nil, nil // 未配置:调用方回 TranslateNotConfigured
|
||||
|
} |
||||
|
|
||||
|
fields, ferr := comm.ResolveSvcPlainFields(picked, nil, os.Getenv("FIELD_ENCRYPT_KEY")) |
||||
|
if ferr != nil { |
||||
|
return picked.Id, picked.Provider, nil, ferr |
||||
|
} |
||||
|
|
||||
|
// 目前只实现了阿里云。换别家时在这里按 provider 分支即可
|
||||
|
// (sys/ 下已有 bytedance/google/microsoft 的翻译实现可接)。
|
||||
|
if strings.TrimSpace(strings.ToLower(picked.Provider)) != "alibaba" { |
||||
|
return picked.Id, picked.Provider, nil, nil |
||||
|
} |
||||
|
|
||||
|
opts := []alitranslate.Option{ |
||||
|
alitranslate.SetAccessKeyId(strings.TrimSpace(fields["access_key_id"])), |
||||
|
alitranslate.SetAccessKeySecret(strings.TrimSpace(fields["access_key_secret"])), |
||||
|
} |
||||
|
if r := strings.TrimSpace(fields["region"]); r != "" { |
||||
|
opts = append(opts, alitranslate.SetRegion(r)) |
||||
|
} |
||||
|
sys, serr := alitranslate.NewSys(opts...) |
||||
|
if serr != nil { |
||||
|
return picked.Id, picked.Provider, nil, serr |
||||
|
} |
||||
|
|
||||
|
this.mu.Lock() |
||||
|
this.cached, this.cachedId, this.cachedAt = sys, picked.Id, time.Now() |
||||
|
this.mu.Unlock() |
||||
|
return picked.Id, picked.Provider, sys, nil |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/mysql" |
||||
|
"yunyan/pb" |
||||
|
) |
||||
|
|
||||
|
// 新用户开户礼:注册即到账,不需要任何操作。
|
||||
|
//
|
||||
|
// ⚠️ 这是**临时**的拉新政策(2026-08-28 起)。要停掉就把 newUserGiftEnabled 置 false,
|
||||
|
// 或直接删掉 applyNewUserGift 的调用点(api_sgin.go 里创建用户那一处)。
|
||||
|
//
|
||||
|
// 单位坑(三个额度桶并不同构,照抄会送错量级):
|
||||
|
// - Tradeintegral / Meetintegral 是**秒**,所以「100 分钟」= 100*60;
|
||||
|
// - Aichatintegral 是**次数**,每次 AI 对话扣 1(见 api_usages.go 的 UsageType_AI 分支),
|
||||
|
// 「分钟」在这个桶里没有意义,按 100 次给。
|
||||
|
const ( |
||||
|
newUserGiftEnabled = true |
||||
|
|
||||
|
newUserGiftVipDays = 30 // 赠送 VIP 天数
|
||||
|
newUserGiftTradeMin = 100 // 翻译额度(分钟)——同传/面对面/通话/影音共用这一个桶
|
||||
|
newUserGiftMeetMin = 100 // 会议额度(分钟)
|
||||
|
newUserGiftAiTimes = 100 // 智能体额度(次数,不是分钟)
|
||||
|
) |
||||
|
|
||||
|
// applyNewUserGift 把开户礼写进尚未落库的新用户对象。
|
||||
|
// 只在「用户不存在 → 新建」的分支调用,老用户不受影响。
|
||||
|
//
|
||||
|
// 直接赋值而非累加:这是刚 new 出来的对象,几个额度字段都还是零值。
|
||||
|
func applyNewUserGift(user *pb.DBUser, now int64) { |
||||
|
if !newUserGiftEnabled || user == nil { |
||||
|
return |
||||
|
} |
||||
|
user.Vipexptime = now + int64(newUserGiftVipDays)*24*60*60 |
||||
|
|
||||
|
tradeSec := int64(newUserGiftTradeMin) * 60 |
||||
|
meetSec := int64(newUserGiftMeetMin) * 60 |
||||
|
|
||||
|
user.Tradeintegral = tradeSec |
||||
|
user.Tradetotalintegral = tradeSec |
||||
|
user.Meetintegral = meetSec |
||||
|
user.Meettotalintegral = meetSec |
||||
|
user.Aichatintegral = int64(newUserGiftAiTimes) |
||||
|
user.Aichattotalintegral = int64(newUserGiftAiTimes) |
||||
|
} |
||||
|
|
||||
|
// logNewUserGift 记一条活动奖励流水,便于后台对账「送出去多少」。
|
||||
|
// 礼包已经随用户一起落库了,这条流水写失败不回滚、只忽略。
|
||||
|
func logNewUserGift(uid string, now int64) { |
||||
|
if !newUserGiftEnabled { |
||||
|
return |
||||
|
} |
||||
|
_ = mysql.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{ |
||||
|
Uid: uid, |
||||
|
Ts: now, |
||||
|
Logtype: pb.UserLogType_ActivityReward, |
||||
|
Addvipday: int64(newUserGiftVipDays), |
||||
|
Addtradesecond: int64(newUserGiftTradeMin) * 60, |
||||
|
Addmeetsecond: int64(newUserGiftMeetMin) * 60, |
||||
|
Addagentintegral: int64(newUserGiftAiTimes), |
||||
|
Extra: "new user register gift", |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"testing" |
||||
|
|
||||
|
"yunyan/pb" |
||||
|
) |
||||
|
|
||||
|
// 守住单位口径:翻译/会议是**秒**,智能体是**次**。
|
||||
|
// 三个桶不同构,照抄隔壁桶的写法就会送错量级(比如给 AI 送 6000 次)。
|
||||
|
func TestApplyNewUserGift(t *testing.T) { |
||||
|
const now int64 = 1_700_000_000 |
||||
|
u := &pb.DBUser{} |
||||
|
applyNewUserGift(u, now) |
||||
|
|
||||
|
if got, want := u.Vipexptime, now+30*24*60*60; got != want { |
||||
|
t.Errorf("Vipexptime = %d, want %d(30 天)", got, want) |
||||
|
} |
||||
|
if got, want := u.Tradeintegral, int64(6000); got != want { |
||||
|
t.Errorf("Tradeintegral = %d, want %d(100 分钟 = 6000 秒)", got, want) |
||||
|
} |
||||
|
if got, want := u.Meetintegral, int64(6000); got != want { |
||||
|
t.Errorf("Meetintegral = %d, want %d(100 分钟 = 6000 秒)", got, want) |
||||
|
} |
||||
|
if got, want := u.Aichatintegral, int64(100); got != want { |
||||
|
t.Errorf("Aichatintegral = %d, want %d(100 **次**,不是秒)", got, want) |
||||
|
} |
||||
|
// total 系列是「累计获得」,前端拿它算进度条,必须同步给上
|
||||
|
if u.Tradetotalintegral != u.Tradeintegral || |
||||
|
u.Meettotalintegral != u.Meetintegral || |
||||
|
u.Aichattotalintegral != u.Aichatintegral { |
||||
|
t.Error("total 系列未与余额同步,前端进度条会算错") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 老用户不该被碰:applyNewUserGift 只在「新建用户」分支调用,
|
||||
|
// 这里顺带守住它是直接赋值而非累加(新对象都是零值,累加与赋值等价;
|
||||
|
// 万一将来有人挪去老用户路径,这个测试会提醒他先想清楚语义)。
|
||||
|
func TestApplyNewUserGiftIsAssignNotAccumulate(t *testing.T) { |
||||
|
const now int64 = 1_700_000_000 |
||||
|
u := &pb.DBUser{Tradeintegral: 999, Aichatintegral: 7, Vipexptime: now + 12345} |
||||
|
applyNewUserGift(u, now) |
||||
|
if u.Tradeintegral != 6000 || u.Aichatintegral != 100 { |
||||
|
t.Errorf("应为赋值语义,得到 Trade=%d Ai=%d", u.Tradeintegral, u.Aichatintegral) |
||||
|
} |
||||
|
if u.Vipexptime != now+30*24*60*60 { |
||||
|
t.Errorf("VIP 应为赋值语义,得到 %d", u.Vipexptime) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk" |
||||
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials" |
||||
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" |
||||
|
) |
||||
|
|
||||
|
// 阿里云 实人认证 - 身份二要素核验 Id2MetaVerify
|
||||
|
// 文档:https://help.aliyun.com/zh/id-verification/information-verification/developer-reference/vatsl9lfmbwe74iv
|
||||
|
//
|
||||
|
// Action Id2MetaVerify
|
||||
|
// Version 2019-03-07
|
||||
|
// Domain cloudauth.aliyuncs.com(也可用 cloudauth.cn-beijing / cn-shanghai.aliyuncs.com)
|
||||
|
// 入参 ParamType(normal|sm2) / UserName(姓名) / IdentifyNum(身份证号)
|
||||
|
// 出参 Code(200 成功) / Message / ResultObject.BizCode(1=一致,2=不一致)
|
||||
|
const ( |
||||
|
aliyunDefaultRegion = "cn-shanghai" |
||||
|
aliyunDefaultDomain = "cloudauth.aliyuncs.com" |
||||
|
aliyunAPIVersion = "2019-03-07" |
||||
|
aliyunAction = "Id2MetaVerify" |
||||
|
|
||||
|
aliyunBizCodeMatched = "1" // 校验一致
|
||||
|
aliyunBizCodeMismatch = "2" // 校验不一致
|
||||
|
) |
||||
|
|
||||
|
type aliyunVerifier struct { |
||||
|
client *sdk.Client |
||||
|
domain string |
||||
|
} |
||||
|
|
||||
|
// aliyunResp 只取需要的字段;其余忽略。
|
||||
|
type aliyunResp struct { |
||||
|
RequestId string `json:"RequestId"` |
||||
|
Code string `json:"Code"` |
||||
|
Message string `json:"Message"` |
||||
|
ResultObject struct { |
||||
|
BizCode string `json:"BizCode"` |
||||
|
} `json:"ResultObject"` |
||||
|
} |
||||
|
|
||||
|
func newAliyun(fields map[string]string) (Verifier, error) { |
||||
|
if err := requireFields(fields, "access_key_id", "access_key_secret"); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
region := field(fields, "region") |
||||
|
if region == "" { |
||||
|
region = aliyunDefaultRegion |
||||
|
} |
||||
|
domain := field(fields, "domain") |
||||
|
if domain == "" { |
||||
|
domain = aliyunDefaultDomain |
||||
|
} |
||||
|
|
||||
|
c := sdk.NewConfig() |
||||
|
c.HttpTransport = &http.Transport{IdleConnTimeout: 10 * time.Second} |
||||
|
c.Timeout = 10 * time.Second |
||||
|
cred := credentials.NewAccessKeyCredential(field(fields, "access_key_id"), field(fields, "access_key_secret")) |
||||
|
client, err := sdk.NewClientWithOptions(region, c, cred) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("idverify/aliyun: 初始化客户端失败: %w", err) |
||||
|
} |
||||
|
return &aliyunVerifier{client: client, domain: domain}, nil |
||||
|
} |
||||
|
|
||||
|
func (v *aliyunVerifier) Provider() string { return ProviderAliyun } |
||||
|
|
||||
|
func (v *aliyunVerifier) Verify(ctx context.Context, realname, idcardno string) (Result, error) { |
||||
|
req := requests.NewCommonRequest() |
||||
|
req.Method = http.MethodPost |
||||
|
// ⚠️ 必须显式设成 HTTPS:NewCommonRequest 默认走 HTTP,而 Cloudauth 强制 SSL,
|
||||
|
// 用 HTTP 调会被拒并返回 InvalidProtocol.NeedSsl("lack of ssl protect"),
|
||||
|
// 表象是一个笼统的 SDK.ServerError,很容易被误当成凭据或额度问题。
|
||||
|
req.Scheme = "https" |
||||
|
req.Domain = v.domain |
||||
|
req.Version = aliyunAPIVersion |
||||
|
req.ApiName = aliyunAction |
||||
|
req.QueryParams["ParamType"] = "normal" // 明文传参;sm2 需另配国密公钥,当前不启用
|
||||
|
req.QueryParams["UserName"] = strings.TrimSpace(realname) |
||||
|
req.QueryParams["IdentifyNum"] = strings.TrimSpace(idcardno) |
||||
|
|
||||
|
resp, err := v.client.ProcessCommonRequest(req) |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/aliyun: 调用失败: %w", err) |
||||
|
} |
||||
|
body := resp.GetHttpContentString() |
||||
|
|
||||
|
var r aliyunResp |
||||
|
if err := json.Unmarshal([]byte(body), &r); err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/aliyun: 响应解析失败: %w", err) |
||||
|
} |
||||
|
// Code 非 200 表示调用层面失败(鉴权/参数/额度),结论未知——绝不能当成"不一致"。
|
||||
|
if r.Code != "200" { |
||||
|
return Result{}, fmt.Errorf("idverify/aliyun: 调用返回 Code=%s Message=%s RequestId=%s", r.Code, r.Message, r.RequestId) |
||||
|
} |
||||
|
switch r.ResultObject.BizCode { |
||||
|
case aliyunBizCodeMatched: |
||||
|
return Result{Matched: true, BizCode: r.ResultObject.BizCode, Message: r.Message}, nil |
||||
|
case aliyunBizCodeMismatch: |
||||
|
return Result{Matched: false, BizCode: r.ResultObject.BizCode, Message: r.Message}, nil |
||||
|
default: |
||||
|
// 文档只定义了 1/2;出现别的值说明接口有变更,按"结论未知"处理而不是默默判不一致。
|
||||
|
return Result{}, fmt.Errorf("idverify/aliyun: 未知 BizCode=%q RequestId=%s", r.ResultObject.BizCode, r.RequestId) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,240 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"crypto/rand" |
||||
|
"crypto/sha1" |
||||
|
"encoding/hex" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
// 创蓝云智(253)OM2.0 - 身份证二要素核验
|
||||
|
//
|
||||
|
// 接口 POST https://wsauth.253.com/api/v2/auth/idcard/id-card-auth
|
||||
|
// 编码 application/json,UTF-8
|
||||
|
// 鉴权 **走请求头**,不是 body:
|
||||
|
// AppID 控制台 → 账号中心 → API Key
|
||||
|
// Nonce 随机数(最大 128 字符)
|
||||
|
// CurTime 当前 UTC 时间戳(秒)
|
||||
|
// CheckSum SHA1(AppSecret + Nonce + CurTime),十六进制小写
|
||||
|
// body 只有 name(姓名) / idNum(身份证号)
|
||||
|
// 出参 code("000000" 成功,OM 标准码)/ msg / chargeStatus / chargeCount / requestId / data.result
|
||||
|
// data.result:01=一致(收费) 02=不一致(收费) 03=认证不确定(不收费) 04=认证失败(不收费)
|
||||
|
//
|
||||
|
// ⚠️ OM2.0 的成功码是 **"000000"**,老版是 "200000";说明字段是 **msg**,老版是 message。
|
||||
|
// 沿用老版那两个常量会让**每一次成功核验都被判成调用失败**,且日志里 message 恒为空。
|
||||
|
//
|
||||
|
// ⚠️ 别接成老版的 `/open/idcard/id-card-auth`(form-urlencoded、凭据放 body)——
|
||||
|
// 那套已废弃,拿 OM2.0 的 AppID 去打会回 500902「用户不存在」,
|
||||
|
// 看起来像凭据配错,实则是接口版本用错了。
|
||||
|
//
|
||||
|
// ⚠️ 与阿里/腾讯不同,创蓝把「不确定」「失败」也放在 data.result 里作为业务结果码返回,
|
||||
|
// 且此时 code 仍是 200000。**03/04 绝不能当成"不一致"**——那是查无结论,
|
||||
|
// 按不一致处理会让用户看到"您填的信息有误",而实际上只是上游库没查到或临时故障。
|
||||
|
// 这两种情况一律返回 error,走「服务暂时不可用」的口径。
|
||||
|
// 服务商自己也是这么划的:03/04 的 chargeStatus=0(不收费),只有 01/02 才计费。
|
||||
|
const ( |
||||
|
chuanglanDefaultURL = "https://wsauth.253.com/api/v2/auth/idcard/id-card-auth" |
||||
|
|
||||
|
chuanglanCodeOK = "000000" // 调用成功(业务结论看 data.result)——OM2.0 标准码,不是老版的 200000
|
||||
|
// 120001 CheckSum 校验失败:AppSecret 配错,或本机时钟与标准时间偏差超过 5 分钟。
|
||||
|
chuanglanCodeBadCheckSum = "120001" |
||||
|
// 500902「用户不存在」= AppID 不被识别(填错、或用在了错误的接口版本上)。
|
||||
|
chuanglanCodeUserNotFound = "500902" |
||||
|
// 190004 参数校验异常:姓名/身份证号不合规(姓名须为中文汉字 1~30 字符、不能以间隔符开头结尾)。
|
||||
|
// 这是**我们传错了参数**,不是服务故障,得让排查的人一眼分清。
|
||||
|
chuanglanCodeInvalidParam = "190004" |
||||
|
|
||||
|
chuanglanResultMatched = "01" // 一致(收费)
|
||||
|
chuanglanResultMismatch = "02" // 不一致(收费)
|
||||
|
chuanglanResultUnknown = "03" // 认证不确定(不收费)
|
||||
|
chuanglanResultFailed = "04" // 认证失败(不收费)
|
||||
|
) |
||||
|
|
||||
|
type chuanglanVerifier struct { |
||||
|
appID string |
||||
|
appSecret string |
||||
|
url string |
||||
|
cli *http.Client |
||||
|
} |
||||
|
|
||||
|
// chuanglanResp 只取需要的字段;data 里的省市/生日/年龄等一律不接,
|
||||
|
// 避免无谓地把个人信息带进进程——本服务只关心「一致与否」。
|
||||
|
type chuanglanResp struct { |
||||
|
Code string `json:"code"` |
||||
|
Msg string `json:"msg"` // OM2.0 是 msg,不是老版的 message
|
||||
|
ChargeStatus int `json:"chargeStatus"` // 1=收费 0=不收费
|
||||
|
ChargeCount int `json:"chargeCount"` // 计费条数
|
||||
|
RequestId string `json:"requestId"` // 订单号,对账用
|
||||
|
Data struct { |
||||
|
OrderNo string `json:"orderNo"` |
||||
|
Result string `json:"result"` |
||||
|
Remark string `json:"remark"` |
||||
|
} `json:"data"` |
||||
|
} |
||||
|
|
||||
|
func newChuanglan(fields map[string]string) (Verifier, error) { |
||||
|
if err := requireFields(fields, "appid", "appkey"); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
endpoint := field(fields, "url") |
||||
|
if endpoint == "" { |
||||
|
endpoint = chuanglanDefaultURL |
||||
|
} |
||||
|
return &chuanglanVerifier{ |
||||
|
appID: field(fields, "appid"), |
||||
|
// 后台字段沿用 appkey 这个键名(存量配置已经在用),值就是 OM2.0 的 AppSecret。
|
||||
|
appSecret: field(fields, "appkey"), |
||||
|
url: endpoint, |
||||
|
cli: &http.Client{Timeout: 10 * time.Second}, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (v *chuanglanVerifier) Provider() string { return ProviderChuanglan } |
||||
|
|
||||
|
func (v *chuanglanVerifier) Verify(ctx context.Context, realname, idcardno string) (Result, error) { |
||||
|
payload, err := json.Marshal(map[string]string{ |
||||
|
"name": strings.TrimSpace(realname), |
||||
|
"idNum": strings.TrimSpace(idcardno), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 构造请求失败: %w", err) |
||||
|
} |
||||
|
|
||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.url, strings.NewReader(string(payload))) |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 构造请求失败: %w", err) |
||||
|
} |
||||
|
nonce, err := chuanglanNonce() |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 生成 Nonce 失败: %w", err) |
||||
|
} |
||||
|
curTime := strconv.FormatInt(time.Now().Unix(), 10) |
||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8") |
||||
|
// ⚠️ 鉴权头**直接写 map,不用 Header.Set**:Set 会做 MIME 规范化,把
|
||||
|
// AppID→Appid、CurTime→Curtime、CheckSum→Checksum。HTTP 头本该大小写无关,
|
||||
|
// 但创蓝网关是按字面匹配的,被改写后它认不出来,回 120301
|
||||
|
// 「request header is missing AppID or incorrect」——看着像 AppID 填错,实则头名没对上。
|
||||
|
// 该接口走 HTTP/1.1,大小写能原样上线;若哪天改走 HTTP/2(强制小写头),这里要另想办法。
|
||||
|
req.Header["AppID"] = []string{v.appID} |
||||
|
req.Header["Nonce"] = []string{nonce} |
||||
|
req.Header["CurTime"] = []string{curTime} |
||||
|
req.Header["CheckSum"] = []string{chuanglanCheckSum(v.appSecret, nonce, curTime)} |
||||
|
|
||||
|
resp, err := v.cli.Do(req) |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 调用失败: %w", err) |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
||||
|
if err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 读取响应失败: %w", err) |
||||
|
} |
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: HTTP %d: %s", resp.StatusCode, truncate(string(raw), 200)) |
||||
|
} |
||||
|
|
||||
|
res, err := parseChuanglanBody(raw) |
||||
|
if err != nil { |
||||
|
// 鉴权类错误光看报错分不清是哪一项配错了,带上脱敏指纹让运维一眼能对上后台里那把。
|
||||
|
// 只打首尾各 2 位和长度,不打明文。
|
||||
|
if hint := chuanglanCredHint(raw); hint != "" { |
||||
|
return res, fmt.Errorf("%w(%s;本服务当前使用 AppID=%s AppSecret=%s)", |
||||
|
err, hint, maskCred(v.appID), maskCred(v.appSecret)) |
||||
|
} |
||||
|
} |
||||
|
return res, err |
||||
|
} |
||||
|
|
||||
|
// chuanglanCredHint 针对鉴权类错误码给出「该查哪里」的提示;非鉴权错误返回空串。
|
||||
|
func chuanglanCredHint(raw []byte) string { |
||||
|
s := string(raw) |
||||
|
switch { |
||||
|
case strings.Contains(s, chuanglanCodeUserNotFound): |
||||
|
return "AppID 不被创蓝识别:确认后台填的是 OM2.0 控制台「账号中心 → API Key」里的 AppID" |
||||
|
case strings.Contains(s, chuanglanCodeBadCheckSum): |
||||
|
return "CheckSum 校验失败:AppSecret 填错,或本机时钟与标准时间偏差超过 5 分钟" |
||||
|
case strings.Contains(s, chuanglanCodeInvalidParam): |
||||
|
return "参数校验异常(姓名须为中文汉字 1~30 字符、不能以间隔符开头或结尾)——是入参问题,不是服务故障" |
||||
|
default: |
||||
|
return "" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// chuanglanCheckSum 按 OM2.0 规则算签名:SHA1(AppSecret + Nonce + CurTime) 十六进制小写。
|
||||
|
func chuanglanCheckSum(appSecret, nonce, curTime string) string { |
||||
|
sum := sha1.Sum([]byte(appSecret + nonce + curTime)) |
||||
|
return hex.EncodeToString(sum[:]) |
||||
|
} |
||||
|
|
||||
|
// chuanglanNonce 生成随机数(文档限制最长 128 字符,这里取 32 位十六进制)。
|
||||
|
// 用 crypto/rand:Nonce 参与签名,可预测的随机源会让签名可被重放。
|
||||
|
func chuanglanNonce() (string, error) { |
||||
|
b := make([]byte, 16) |
||||
|
if _, err := rand.Read(b); err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
return hex.EncodeToString(b), nil |
||||
|
} |
||||
|
|
||||
|
// parseChuanglanBody 把创蓝的响应体解析成 Result。抽出来单测,不用打真接口。
|
||||
|
func parseChuanglanBody(raw []byte) (Result, error) { |
||||
|
var r chuanglanResp |
||||
|
if err := json.Unmarshal(raw, &r); err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 响应解析失败: %w (body=%s)", err, truncate(string(raw), 200)) |
||||
|
} |
||||
|
// code 非 200000 表示调用层面失败(鉴权/参数/余额不足),结论未知——不能当成"不一致"。
|
||||
|
if r.Code != chuanglanCodeOK { |
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 调用返回 code=%s msg=%s chargeStatus=%d requestId=%s", |
||||
|
r.Code, r.Msg, r.ChargeStatus, r.RequestId) |
||||
|
} |
||||
|
|
||||
|
msg := strings.TrimSpace(r.Data.Remark) |
||||
|
if msg == "" { |
||||
|
msg = r.Msg |
||||
|
} |
||||
|
switch r.Data.Result { |
||||
|
case chuanglanResultMatched: |
||||
|
return Result{Matched: true, BizCode: r.Data.Result, Message: msg}, nil |
||||
|
case chuanglanResultMismatch: |
||||
|
return Result{Matched: false, BizCode: r.Data.Result, Message: msg}, nil |
||||
|
case chuanglanResultUnknown, chuanglanResultFailed: |
||||
|
// 查无结论:上游库没覆盖到,或服务商侧临时失败。既未计费也无结论,
|
||||
|
// 必须当调用失败上报,绝不能退化成"不一致"。
|
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 未得出结论 result=%s msg=%s orderNo=%s requestId=%s chargeStatus=%d", |
||||
|
r.Data.Result, msg, r.Data.OrderNo, r.RequestId, r.ChargeStatus) |
||||
|
default: |
||||
|
// 文档只定义了 01..04;出现别的值说明接口有变更,同样按"结论未知"处理。
|
||||
|
// 带上原始响应,省得再为「返回了什么」跑一趟线上。
|
||||
|
return Result{}, fmt.Errorf("idverify/chuanglan: 未知 result=%q body=%s", |
||||
|
r.Data.Result, truncate(string(raw), 300)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// maskCred 只保留首尾各 2 位 + 长度,够对账、不泄露。
|
||||
|
func maskCred(s string) string { |
||||
|
r := []rune(s) |
||||
|
switch n := len(r); { |
||||
|
case n == 0: |
||||
|
return "(空)" |
||||
|
case n <= 4: |
||||
|
return fmt.Sprintf("****(len=%d)", n) |
||||
|
default: |
||||
|
return fmt.Sprintf("%s****%s(len=%d)", string(r[:2]), string(r[n-2:]), n) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// truncate 截断超长文本,避免把整个响应体灌进日志。
|
||||
|
func truncate(s string, n int) string { |
||||
|
if len(s) <= n { |
||||
|
return s |
||||
|
} |
||||
|
return s[:n] + "..." |
||||
|
} |
||||
@ -0,0 +1,312 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
// 创蓝把「不确定/失败」也放进 data.result,且此时 code 仍是成功码 000000。
|
||||
|
// 这组用例守住最要命的一条:03/04 必须是 error,不能退化成「不一致」——
|
||||
|
// 否则上游库没覆盖到的用户会被告知"您填的信息有误"。
|
||||
|
func TestParseChuanglanBody(t *testing.T) { |
||||
|
tests := []struct { |
||||
|
name string |
||||
|
body string |
||||
|
wantErr bool |
||||
|
wantMatched bool |
||||
|
wantBizCode string |
||||
|
}{ |
||||
|
{ |
||||
|
// 文档「请求成功-结果示例」原样照抄。成功码是 000000(不是老版的 200000)。
|
||||
|
name: "一致(文档原样示例)", |
||||
|
body: `{"msg":"success","chargeCount":1,"code":"000000","data":{"result":"01","birthday":"19930404","country":"金溪县","orderNo":"YQis1222128184616292352","handleTime":"2026-06-25 10:21:05","province":"江西省","gender":"1","city":"抚州地区","remark":"一致","age":"34"},"requestId":"YQis1222128184616292352","chargeStatus":1}`, |
||||
|
wantMatched: true, |
||||
|
wantBizCode: "01", |
||||
|
}, |
||||
|
{ |
||||
|
// 注意 result=02(不一致)时 code 仍是 000000、chargeStatus 仍是 1
|
||||
|
// —— 调用成功 ≠ 核验一致,这两层结论必须分开读。
|
||||
|
name: "不一致", |
||||
|
body: `{"msg":"success","chargeCount":1,"code":"000000","data":{"orderNo":"N2","result":"02","remark":"不一致"},"requestId":"R2","chargeStatus":1}`, |
||||
|
wantMatched: false, |
||||
|
wantBizCode: "02", |
||||
|
}, |
||||
|
{ |
||||
|
name: "认证不确定必须报错而不是判不一致", |
||||
|
body: `{"msg":"success","code":"000000","data":{"orderNo":"N3","result":"03","remark":"认证不确定"},"requestId":"R3","chargeStatus":0}`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "认证失败必须报错而不是判不一致", |
||||
|
body: `{"msg":"success","code":"000000","data":{"orderNo":"N4","result":"04"},"requestId":"R4","chargeStatus":0}`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
{ |
||||
|
// 文档「请求失败-结果示例」原样照抄:没有 data 对象
|
||||
|
name: "参数校验异常(文档原样示例,无 data)", |
||||
|
body: `{"msg":"invalid parameter:校验异常: 身份证格式不正确","code":"190004","requestId":"YQis1222128941818187776","chargeStatus":0}`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
{ |
||||
|
// 老版的成功码,OM2.0 下必须**不**被当成成功
|
||||
|
name: "老版成功码 200000 不再视为成功", |
||||
|
body: `{"code":"200000","msg":"成功","data":{"result":"01"}}`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "未知 result 按结论未知处理", |
||||
|
body: `{"code":"000000","msg":"success","data":{"result":"09"}}`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "响应不是 JSON", |
||||
|
body: `<html>502 Bad Gateway</html>`, |
||||
|
wantErr: true, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
got, err := parseChuanglanBody([]byte(tt.body)) |
||||
|
if tt.wantErr { |
||||
|
if err == nil { |
||||
|
t.Fatalf("期望报错,实际得到 %+v", got) |
||||
|
} |
||||
|
// 报错时绝不能顺带给出一个「不一致」的结论
|
||||
|
if got.Matched { |
||||
|
t.Fatalf("报错时 Matched 必须为 false,实际 %+v", got) |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
if err != nil { |
||||
|
t.Fatalf("不该报错: %v", err) |
||||
|
} |
||||
|
if got.Matched != tt.wantMatched { |
||||
|
t.Errorf("Matched = %v, 期望 %v", got.Matched, tt.wantMatched) |
||||
|
} |
||||
|
if got.BizCode != tt.wantBizCode { |
||||
|
t.Errorf("BizCode = %q, 期望 %q", got.BizCode, tt.wantBizCode) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 起一个假服务端,端到端校验 OM2.0 的请求形态。
|
||||
|
//
|
||||
|
// 这几项是最容易写错又最难从线上现象反推的:
|
||||
|
// 鉴权走**请求头**(AppID/Nonce/CurTime/CheckSum)而不是 body,
|
||||
|
// body 是 JSON 且**只有** name/idNum——把凭据也塞进 body 是老版接口的写法。
|
||||
|
func TestChuanglanRequestWireFormat(t *testing.T) { |
||||
|
const ( |
||||
|
appID = "AID12345" |
||||
|
appSecret = "s3cr3t" |
||||
|
) |
||||
|
var ( |
||||
|
gotMethod string |
||||
|
gotHeaders http.Header |
||||
|
gotBody map[string]any |
||||
|
) |
||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
gotMethod = r.Method |
||||
|
gotHeaders = r.Header.Clone() |
||||
|
b, _ := io.ReadAll(r.Body) |
||||
|
_ = json.Unmarshal(b, &gotBody) |
||||
|
_, _ = io.WriteString(w, `{"code":"000000","msg":"success","chargeStatus":1,"chargeCount":1,"requestId":"R1","data":{"orderNo":"N1","result":"01","remark":"一致"}}`) |
||||
|
})) |
||||
|
defer srv.Close() |
||||
|
|
||||
|
v, err := New(ProviderChuanglan, map[string]string{ |
||||
|
"appid": appID, "appkey": appSecret, "url": srv.URL, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("装配失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
res, err := v.Verify(context.Background(), "廖江龙", "430422199001138812") |
||||
|
if err != nil { |
||||
|
t.Fatalf("Verify 失败: %v", err) |
||||
|
} |
||||
|
if !res.Matched { |
||||
|
t.Errorf("期望 Matched=true,实际 %+v", res) |
||||
|
} |
||||
|
|
||||
|
if gotMethod != http.MethodPost { |
||||
|
t.Errorf("method = %s, 期望 POST", gotMethod) |
||||
|
} |
||||
|
if ct := gotHeaders.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { |
||||
|
t.Errorf("Content-Type = %q, 期望 application/json", ct) |
||||
|
} |
||||
|
if got := gotHeaders.Get("AppID"); got != appID { |
||||
|
t.Errorf("AppID 头 = %q, 期望 %q", got, appID) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
nonce, curTime := gotHeaders.Get("Nonce"), gotHeaders.Get("CurTime") |
||||
|
if nonce == "" { |
||||
|
t.Error("缺少 Nonce 头") |
||||
|
} |
||||
|
if len(nonce) > 128 { |
||||
|
t.Errorf("Nonce 长度 %d 超过文档上限 128", len(nonce)) |
||||
|
} |
||||
|
ts, err := strconv.ParseInt(curTime, 10, 64) |
||||
|
if err != nil { |
||||
|
t.Errorf("CurTime = %q 不是秒级时间戳", curTime) |
||||
|
} else if d := time.Since(time.Unix(ts, 0)); d < 0 || d > time.Minute { |
||||
|
// 签名 5 分钟内有效,时间戳必须是「现在」——写成毫秒会直接超期
|
||||
|
t.Errorf("CurTime 偏离当前时间 %v,疑似单位写错(应为秒)", d) |
||||
|
} |
||||
|
// 签名必须能被服务端用同样规则复算出来
|
||||
|
if want := chuanglanCheckSum(appSecret, nonce, curTime); gotHeaders.Get("CheckSum") != want { |
||||
|
t.Errorf("CheckSum = %q, 期望 %q", gotHeaders.Get("CheckSum"), want) |
||||
|
} |
||||
|
|
||||
|
if gotBody["name"] != "廖江龙" || gotBody["idNum"] != "430422199001138812" { |
||||
|
t.Errorf("body = %+v, 期望只含 name/idNum", gotBody) |
||||
|
} |
||||
|
// 凭据绝不能出现在 body 里(老版接口才那么传)
|
||||
|
for _, k := range []string{"appId", "appKey", "appid", "appkey", "AppID", "AppSecret"} { |
||||
|
if _, ok := gotBody[k]; ok { |
||||
|
t.Errorf("body 里不该出现凭据字段 %q", k) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// CheckSum = SHA1(AppSecret + Nonce + CurTime),十六进制**小写**。
|
||||
|
// 拼接顺序错、或输出成大写,服务端一律回 120001,且报错里看不出是哪种。
|
||||
|
func TestChuanglanCheckSum(t *testing.T) { |
||||
|
// 对照:printf 'secretnonce123' | shasum -a 1
|
||||
|
const ( |
||||
|
secret = "secret" |
||||
|
nonce = "nonce" |
||||
|
curTime = "123" |
||||
|
want = "4355af677cad2d478986b25d8fab707b41fbcee5" |
||||
|
) |
||||
|
got := chuanglanCheckSum(secret, nonce, curTime) |
||||
|
if got != want { |
||||
|
t.Errorf("CheckSum = %q, 期望 %q", got, want) |
||||
|
} |
||||
|
if got != strings.ToLower(got) { |
||||
|
t.Error("CheckSum 必须是小写十六进制") |
||||
|
} |
||||
|
if len(got) != 40 { |
||||
|
t.Errorf("SHA1 十六进制应为 40 字符,实际 %d", len(got)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Nonce 每次都要新的:它参与签名,固定值会让签名可被重放。
|
||||
|
func TestChuanglanNonceIsRandom(t *testing.T) { |
||||
|
seen := make(map[string]bool, 100) |
||||
|
for i := 0; i < 100; i++ { |
||||
|
n, err := chuanglanNonce() |
||||
|
if err != nil { |
||||
|
t.Fatalf("生成失败: %v", err) |
||||
|
} |
||||
|
if seen[n] { |
||||
|
t.Fatalf("Nonce 重复: %s", n) |
||||
|
} |
||||
|
seen[n] = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 默认地址必须是 OM2.0 那条带 /api/v2/ 的路径。
|
||||
|
// 老版 /open/idcard/id-card-auth 已废弃,拿 OM2.0 的 AppID 去打会回
|
||||
|
// 500902「用户不存在」——看着像凭据错,实则是接口版本用错了。
|
||||
|
func TestChuanglanDefaultURL(t *testing.T) { |
||||
|
const want = "https://wsauth.253.com/api/v2/auth/idcard/id-card-auth" |
||||
|
if chuanglanDefaultURL != want { |
||||
|
t.Errorf("默认接口地址 = %q, 期望 %q", chuanglanDefaultURL, want) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 缺凭据要在装配阶段就失败,不能等到打接口才发现。
|
||||
|
func TestNewChuanglanRequiresCredentials(t *testing.T) { |
||||
|
if _, err := New(ProviderChuanglan, map[string]string{"appid": "x"}); err == nil { |
||||
|
t.Error("缺 appkey 时应报错") |
||||
|
} |
||||
|
if _, err := New(ProviderChuanglan, map[string]string{"appkey": "x"}); err == nil { |
||||
|
t.Error("缺 appid 时应报错") |
||||
|
} |
||||
|
|
||||
|
v, err := New(ProviderChuanglan, map[string]string{"appid": "a", "appkey": "b"}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("凭据齐全不该报错: %v", err) |
||||
|
} |
||||
|
if v.Provider() != ProviderChuanglan { |
||||
|
t.Errorf("Provider() = %q, 期望 %q", v.Provider(), ProviderChuanglan) |
||||
|
} |
||||
|
|
||||
|
// url 留空要回落到默认接口地址
|
||||
|
cl, ok := v.(*chuanglanVerifier) |
||||
|
if !ok { |
||||
|
t.Fatalf("类型不对: %T", v) |
||||
|
} |
||||
|
if cl.url != chuanglanDefaultURL { |
||||
|
t.Errorf("url = %q, 期望默认 %q", cl.url, chuanglanDefaultURL) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 鉴权头名的**字面大小写**必须与文档一致:AppID / Nonce / CurTime / CheckSum。
|
||||
|
//
|
||||
|
// HTTP 头名本该大小写无关,但创蓝网关是按字面匹配的。Go 的 Header.Set 会做 MIME
|
||||
|
// 规范化,把 AppID→Appid、CurTime→Curtime、CheckSum→Checksum,网关就认不出来,
|
||||
|
// 回 120301「request header is missing AppID or incorrect」——报错指向 AppID,
|
||||
|
// 实际原因却是头名被改写,极难反推。所以必须直接写 Header map。
|
||||
|
//
|
||||
|
// 这里用裸 TCP 监听读原始请求字节:net/http 的 server 会把收到的头名规范化,
|
||||
|
// 用 httptest 根本看不出线上真正发的是什么大小写。
|
||||
|
func TestChuanglanAuthHeaderLiteralCase(t *testing.T) { |
||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0") |
||||
|
if err != nil { |
||||
|
t.Fatalf("监听失败: %v", err) |
||||
|
} |
||||
|
defer ln.Close() |
||||
|
|
||||
|
const body = `{"code":"000000","msg":"success","chargeStatus":1,"data":{"result":"01"}}` |
||||
|
rawCh := make(chan string, 1) |
||||
|
go func() { |
||||
|
conn, err := ln.Accept() |
||||
|
if err != nil { |
||||
|
rawCh <- "" |
||||
|
return |
||||
|
} |
||||
|
defer conn.Close() |
||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second)) |
||||
|
buf := make([]byte, 4096) |
||||
|
n, _ := conn.Read(buf) |
||||
|
rawCh <- string(buf[:n]) |
||||
|
fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body) |
||||
|
}() |
||||
|
|
||||
|
v, err := New(ProviderChuanglan, map[string]string{ |
||||
|
"appid": "AID", "appkey": "SEC", "url": "http://" + ln.Addr().String(), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("装配失败: %v", err) |
||||
|
} |
||||
|
_, _ = v.Verify(context.Background(), "廖江龙", "430422199001138812") |
||||
|
|
||||
|
raw := <-rawCh |
||||
|
if raw == "" { |
||||
|
t.Fatal("没收到请求") |
||||
|
} |
||||
|
for _, want := range []string{"AppID:", "Nonce:", "CurTime:", "CheckSum:"} { |
||||
|
if !strings.Contains(raw, want) { |
||||
|
t.Errorf("原始请求里没有字面头名 %q——多半是误用了 Header.Set 导致大小写被规范化\n实际报文:\n%s", want, raw) |
||||
|
} |
||||
|
} |
||||
|
// 规范化后的形态一旦出现,说明改回 Header.Set 了
|
||||
|
for _, bad := range []string{"Appid:", "Curtime:", "Checksum:"} { |
||||
|
if strings.Contains(raw, bad) { |
||||
|
t.Errorf("出现了被规范化的头名 %q,创蓝网关不认", bad) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
// Package idverify 身份证二要素(姓名 + 身份证号)实名核验。
|
||||
|
//
|
||||
|
// 与其它 sys 子系统不同,本包**不是启动时初始化的单例**:核验服务配在后台
|
||||
|
// 「第三方服务配置」里(类别 comm.SvcCatIdVerify),按应用作用域存在 svc_config,
|
||||
|
// 调用时才解析出凭据。所以这里只提供无状态的工厂 + 接口,由业务侧每次带配置进来。
|
||||
|
//
|
||||
|
// ⚠️ 这类服务的凭据是**云账号主 AK/SK**,comm.IsServerOnlySvc 会保证它们
|
||||
|
// 永不随 user_getthirdsvcs / user_getappconfig 下发给客户端。
|
||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
// 服务商标识(与 console 内置模板 ThirdSvcTemplate.Provider 一致)。
|
||||
|
const ( |
||||
|
ProviderAliyun = "aliyun" |
||||
|
ProviderTencent = "tencent" |
||||
|
ProviderChuanglan = "chuanglan" |
||||
|
) |
||||
|
|
||||
|
var ( |
||||
|
// ErrUnsupportedProvider 配了本包不认识的服务商。
|
||||
|
ErrUnsupportedProvider = errors.New("idverify: 不支持的服务商") |
||||
|
// ErrMissingCredential 服务商凭据字段缺失。
|
||||
|
ErrMissingCredential = errors.New("idverify: 凭据字段缺失") |
||||
|
) |
||||
|
|
||||
|
// Result 一次核验的结果。
|
||||
|
//
|
||||
|
// 注意区分两种"失败":
|
||||
|
// - Matched=false 且 err=nil:服务商**明确判定不一致**(正常业务结果,已计费)。
|
||||
|
// - err!=nil:调用本身失败(网络/鉴权/额度耗尽/参数被拒),**结论未知**,不能当作"不一致"。
|
||||
|
//
|
||||
|
// 把后者当成"不一致"会让用户在服务商欠费时看到"您填的信息有误",是最难排查的一类线上问题。
|
||||
|
type Result struct { |
||||
|
Matched bool // 姓名与身份证号是否一致
|
||||
|
BizCode string // 服务商返回的原始结果码(阿里 ResultObject.BizCode / 腾讯 Result / 创蓝 data.result),便于对账排查
|
||||
|
Message string // 服务商返回的可读说明
|
||||
|
} |
||||
|
|
||||
|
// Verifier 一个已装配好凭据的核验客户端。
|
||||
|
type Verifier interface { |
||||
|
// Verify 执行二要素核验。realname/idcardno 为明文,调用方负责不落库。
|
||||
|
Verify(ctx context.Context, realname, idcardno string) (Result, error) |
||||
|
// Provider 返回服务商标识,用于落库记录来源。
|
||||
|
Provider() string |
||||
|
} |
||||
|
|
||||
|
// New 按服务商与字段表装配一个核验客户端。
|
||||
|
// fields 来自 comm.ResolveSvcPlainFields 的解密结果(键名与 console 内置模板一致)。
|
||||
|
func New(provider string, fields map[string]string) (Verifier, error) { |
||||
|
switch strings.TrimSpace(strings.ToLower(provider)) { |
||||
|
case ProviderAliyun: |
||||
|
return newAliyun(fields) |
||||
|
case ProviderTencent: |
||||
|
return newTencent(fields) |
||||
|
case ProviderChuanglan: |
||||
|
return newChuanglan(fields) |
||||
|
default: |
||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, provider) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// field 取字段并去空白;缺失返回空串。
|
||||
|
func field(fields map[string]string, key string) string { |
||||
|
return strings.TrimSpace(fields[key]) |
||||
|
} |
||||
|
|
||||
|
// requireFields 校验必填凭据字段齐全,返回第一个缺失的字段名。
|
||||
|
func requireFields(fields map[string]string, keys ...string) error { |
||||
|
for _, k := range keys { |
||||
|
if field(fields, k) == "" { |
||||
|
return fmt.Errorf("%w: %s", ErrMissingCredential, k) |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,82 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"crypto/sha256" |
||||
|
"encoding/hex" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
// 身份证号的本地处理:格式校验、掩码、加盐指纹。
|
||||
|
//
|
||||
|
// 本地校验的意义不只是"友好提示"——服务商核验是**按次计费**的,把明显不合法的号码
|
||||
|
// (位数不对、校验位算不上)挡在调用之前,既省额度也避免把垃圾请求算进失败次数。
|
||||
|
|
||||
|
// 加权因子与校验码表(GB 11643-1999 附录A,ISO 7064:1983 MOD 11-2)。
|
||||
|
var ( |
||||
|
idWeights = [17]int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2} |
||||
|
idCheckCode = [11]byte{'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'} |
||||
|
) |
||||
|
|
||||
|
// ValidIdCard 校验 18 位二代身份证号:前 17 位为数字,末位为数字或 X/x,且校验位正确。
|
||||
|
// 只支持二代证——阿里云 Id2MetaVerify 与腾讯云 IdCardVerification 都只认二代证。
|
||||
|
func ValidIdCard(no string) bool { |
||||
|
no = strings.TrimSpace(no) |
||||
|
if len(no) != 18 { |
||||
|
return false |
||||
|
} |
||||
|
sum := 0 |
||||
|
for i := 0; i < 17; i++ { |
||||
|
c := no[i] |
||||
|
if c < '0' || c > '9' { |
||||
|
return false |
||||
|
} |
||||
|
sum += int(c-'0') * idWeights[i] |
||||
|
} |
||||
|
last := no[17] |
||||
|
if last == 'x' { |
||||
|
last = 'X' |
||||
|
} |
||||
|
return last == idCheckCode[sum%11] |
||||
|
} |
||||
|
|
||||
|
// MaskIdCard 生成用于展示与落库的掩码:保留前 4 位与后 4 位,中间一律 *。
|
||||
|
// 例:440301199001011234 -> 4403**********1234
|
||||
|
// 非 18 位的输入原样返回掩码化的兜底(全 *),避免意外把原文写进库。
|
||||
|
func MaskIdCard(no string) string { |
||||
|
no = strings.TrimSpace(no) |
||||
|
if len(no) != 18 { |
||||
|
return strings.Repeat("*", len(no)) |
||||
|
} |
||||
|
return no[:4] + strings.Repeat("*", 10) + no[14:] |
||||
|
} |
||||
|
|
||||
|
// HashIdCard 生成加盐 SHA-256 指纹(小写十六进制),用于后台排查"同一证件绑了多个账号"。
|
||||
|
//
|
||||
|
// 必须加盐:身份证号空间有限(约 10^17,且前 6 位地区码、中间 8 位生日高度可枚举),
|
||||
|
// 裸 SHA-256 可以被彻底反查,等同于明文存储。salt 由 ID_HASH_SALT 环境变量提供。
|
||||
|
// salt 为空时返回空串——调用方据此跳过写指纹,绝不退化成裸哈希。
|
||||
|
func HashIdCard(no, salt string) string { |
||||
|
no = strings.TrimSpace(no) |
||||
|
if no == "" || salt == "" { |
||||
|
return "" |
||||
|
} |
||||
|
sum := sha256.Sum256([]byte(salt + "|" + strings.ToUpper(no))) |
||||
|
return hex.EncodeToString(sum[:]) |
||||
|
} |
||||
|
|
||||
|
// GenderFromIdCard 从身份证号解析性别:第 17 位(顺序码末位)奇数为男、偶数为女。
|
||||
|
// 返回值对齐 pb.DBUser.Gender 的口径:1=男,2=女,0=解析不出。
|
||||
|
func GenderFromIdCard(no string) int32 { |
||||
|
no = strings.TrimSpace(no) |
||||
|
if len(no) != 18 { |
||||
|
return 0 |
||||
|
} |
||||
|
c := no[16] |
||||
|
if c < '0' || c > '9' { |
||||
|
return 0 |
||||
|
} |
||||
|
if (c-'0')%2 == 1 { |
||||
|
return 1 |
||||
|
} |
||||
|
return 2 |
||||
|
} |
||||
@ -0,0 +1,78 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
func TestValidIdCard(t *testing.T) { |
||||
|
// 校验位由 MOD 11-2 算出,这几个号码的末位都是按算法推出来的合法值。
|
||||
|
cases := []struct { |
||||
|
no string |
||||
|
want bool |
||||
|
why string |
||||
|
}{ |
||||
|
{"11010519491231002X", true, "末位 X 的合法号码"}, |
||||
|
{"440301199001011234", true, "普通合法号码(加权和 184,184%%11=8 → 校验码 4)"}, |
||||
|
{"440301199001011239", false, "校验位错误"}, |
||||
|
{"44030119900101123", false, "17 位,一代证不支持"}, |
||||
|
{"4403011990010112349", false, "19 位"}, |
||||
|
{"44030119900101123A", false, "末位非数字非 X"}, |
||||
|
{"4403011990010A1239", false, "中间含字母"}, |
||||
|
{"", false, "空串"}, |
||||
|
} |
||||
|
for _, c := range cases { |
||||
|
if got := ValidIdCard(c.no); got != c.want { |
||||
|
t.Errorf("ValidIdCard(%q) = %v, want %v (%s)", c.no, got, c.want, c.why) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestMaskIdCard(t *testing.T) { |
||||
|
if got, want := MaskIdCard("440301199001011234"), "4403**********1234"; got != want { |
||||
|
t.Errorf("MaskIdCard = %q, want %q", got, want) |
||||
|
} |
||||
|
// 非 18 位不能把原文漏出去
|
||||
|
if got := MaskIdCard("12345"); got != "*****" { |
||||
|
t.Errorf("非法长度应全掩码,得到 %q", got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestHashIdCard(t *testing.T) { |
||||
|
const no, salt = "440301199001011234", "s3cr3t" |
||||
|
h1 := HashIdCard(no, salt) |
||||
|
if len(h1) != 64 { |
||||
|
t.Fatalf("指纹应为 64 位十六进制,得到 %d 位", len(h1)) |
||||
|
} |
||||
|
// 同号同盐稳定 —— 否则查重功能失效
|
||||
|
if h1 != HashIdCard(no, salt) { |
||||
|
t.Error("同一号码+盐两次哈希结果不一致") |
||||
|
} |
||||
|
// 大小写归一(末位 x/X 视为同一个号)
|
||||
|
if HashIdCard("11010519491231002x", salt) != HashIdCard("11010519491231002X", salt) { |
||||
|
t.Error("末位 x/X 应归一为同一指纹") |
||||
|
} |
||||
|
// 换盐必须变 —— 保证盐真的参与了计算
|
||||
|
if h1 == HashIdCard(no, "other") { |
||||
|
t.Error("换盐后指纹未变,盐没生效") |
||||
|
} |
||||
|
// 无盐时必须返回空串,绝不退化成裸哈希(裸哈希对身份证号等同明文)
|
||||
|
if HashIdCard(no, "") != "" { |
||||
|
t.Error("salt 为空时必须返回空串,不得退化为裸哈希") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestGenderFromIdCard(t *testing.T) { |
||||
|
cases := []struct { |
||||
|
no string |
||||
|
want int32 |
||||
|
why string |
||||
|
}{ |
||||
|
{"440301199001011234", 1, "第17位 3 为奇数 → 男"}, |
||||
|
{"11010519491231002X", 2, "第17位 2 为偶数 → 女"}, |
||||
|
{"12345", 0, "长度不对"}, |
||||
|
{"", 0, "空串"}, |
||||
|
} |
||||
|
for _, c := range cases { |
||||
|
if got := GenderFromIdCard(c.no); got != c.want { |
||||
|
t.Errorf("GenderFromIdCard(%q) = %d, want %d (%s)", c.no, got, c.want, c.why) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
package idverify |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" |
||||
|
tchttp "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/http" |
||||
|
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" |
||||
|
) |
||||
|
|
||||
|
// 腾讯云 人脸核身 - 身份证二要素核验 IdCardVerification
|
||||
|
// 文档:https://cloud.tencent.com/document/product/1007/33188
|
||||
|
//
|
||||
|
// Action IdCardVerification
|
||||
|
// Version 2018-03-01
|
||||
|
// Service faceid(域名 faceid.tencentcloudapi.com)
|
||||
|
// 入参 Name(姓名) / IdCard(身份证号)
|
||||
|
// 出参 Result / Description
|
||||
|
// Result "0" = 一致
|
||||
|
// Result "-1" = 不一致
|
||||
|
// Result "-2".."-7" = 各类错误(格式错误、查无此人、系统升级、当日调用超限等)
|
||||
|
//
|
||||
|
// 走 common 包的通用调用(NewCommonClient + CommonRequest),不额外引入 faceid 子包依赖。
|
||||
|
const ( |
||||
|
tencentService = "faceid" |
||||
|
tencentAPIVersion = "2018-03-01" |
||||
|
tencentAction = "IdCardVerification" |
||||
|
|
||||
|
tencentResultMatched = "0" // 一致
|
||||
|
tencentResultMismatch = "-1" // 不一致
|
||||
|
) |
||||
|
|
||||
|
type tencentVerifier struct { |
||||
|
client *common.Client |
||||
|
} |
||||
|
|
||||
|
// tencentResp 腾讯云响应外层统一包了一层 Response。
|
||||
|
type tencentResp struct { |
||||
|
Response struct { |
||||
|
Result string `json:"Result"` |
||||
|
Description string `json:"Description"` |
||||
|
RequestId string `json:"RequestId"` |
||||
|
Error *struct { |
||||
|
Code string `json:"Code"` |
||||
|
Message string `json:"Message"` |
||||
|
} `json:"Error"` |
||||
|
} `json:"Response"` |
||||
|
} |
||||
|
|
||||
|
func newTencent(fields map[string]string) (Verifier, error) { |
||||
|
if err := requireFields(fields, "secret_id", "secret_key"); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
cred := common.NewCredential(field(fields, "secret_id"), field(fields, "secret_key")) |
||||
|
cpf := profile.NewClientProfile() |
||||
|
cpf.HttpProfile.ReqMethod = "POST" |
||||
|
cpf.HttpProfile.ReqTimeout = 10 |
||||
|
// 本接口不需要传 Region(文档明示),留空即可;仍允许后台配置以备将来变化。
|
||||
|
return &tencentVerifier{client: common.NewCommonClient(cred, field(fields, "region"), cpf)}, nil |
||||
|
} |
||||
|
|
||||
|
func (v *tencentVerifier) Provider() string { return ProviderTencent } |
||||
|
|
||||
|
func (v *tencentVerifier) Verify(ctx context.Context, realname, idcardno string) (Result, error) { |
||||
|
req := tchttp.NewCommonRequest(tencentService, tencentAPIVersion, tencentAction) |
||||
|
if err := req.SetActionParameters(map[string]interface{}{ |
||||
|
"Name": strings.TrimSpace(realname), |
||||
|
"IdCard": strings.TrimSpace(idcardno), |
||||
|
}); err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/tencent: 组装请求失败: %w", err) |
||||
|
} |
||||
|
resp := tchttp.NewCommonResponse() |
||||
|
if err := v.client.Send(req, resp); err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/tencent: 调用失败: %w", err) |
||||
|
} |
||||
|
|
||||
|
var r tencentResp |
||||
|
if err := json.Unmarshal(resp.GetBody(), &r); err != nil { |
||||
|
return Result{}, fmt.Errorf("idverify/tencent: 响应解析失败: %w", err) |
||||
|
} |
||||
|
if e := r.Response.Error; e != nil && e.Code != "" { |
||||
|
return Result{}, fmt.Errorf("idverify/tencent: 调用返回错误 Code=%s Message=%s RequestId=%s", e.Code, e.Message, r.Response.RequestId) |
||||
|
} |
||||
|
switch r.Response.Result { |
||||
|
case tencentResultMatched: |
||||
|
return Result{Matched: true, BizCode: r.Response.Result, Message: r.Response.Description}, nil |
||||
|
case tencentResultMismatch: |
||||
|
return Result{Matched: false, BizCode: r.Response.Result, Message: r.Response.Description}, nil |
||||
|
default: |
||||
|
// -2..-7 是"查无此人/格式错误/当日超限/系统升级"等,结论未知,不能判成"不一致",
|
||||
|
// 否则服务商当日额度用尽时,所有用户都会看到"信息有误"。
|
||||
|
return Result{}, fmt.Errorf("idverify/tencent: 核验未得出结论 Result=%s Description=%s RequestId=%s", |
||||
|
r.Response.Result, r.Response.Description, r.Response.RequestId) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue