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