From 90abb0ad4237104371ee6cbbda491b67b43813ad Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 29 Aug 2026 17:53:02 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=AF=81=E5=AE=9E=E5=90=8D?= =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E3=80=81=E7=BF=BB=E8=AF=91=E4=B8=8E=E6=96=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=BC=80=E6=88=B7=E7=A4=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次会话开始前就已存在于工作区的改动,一并提交。主要是三块: 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) --- apps/admin/app/pages/serviceconfig.vue | 12 + apps/admin/app/pages/users.vue | 5 + apps/proto/errorcode.proto | 12 + apps/services/comm/const.go | 1 + apps/services/comm/lang.go | 53 +++ apps/services/comm/lang_test.go | 23 ++ apps/services/comm/svcpool.go | 52 +++ apps/services/comm/svcpool_serveronly_test.go | 43 +++ .../modules/console/api_svctemplate.go | 35 ++ apps/services/modules/console/server.go | 25 +- apps/services/modules/echomeet/core.go | 3 + apps/services/modules/user/api_idverify.go | 177 ++++++++++ apps/services/modules/user/api_sgin.go | 39 ++- apps/services/modules/user/api_translate.go | 80 +++++ apps/services/modules/user/model_idverify.go | 157 +++++++++ apps/services/modules/user/model_translate.go | 111 +++++++ apps/services/modules/user/module.go | 4 + apps/services/modules/user/newuser_gift.go | 64 ++++ .../modules/user/newuser_gift_test.go | 49 +++ apps/services/modules/user/svcresolve.go | 7 + apps/services/pb/errorcode.pb.go | 38 ++- apps/services/sys/aliyun/translate/core.go | 3 + .../sys/aliyun/translate/translate.go | 36 +- apps/services/sys/idverify/aliyun.go | 112 +++++++ apps/services/sys/idverify/chuanglan.go | 240 ++++++++++++++ apps/services/sys/idverify/chuanglan_test.go | 312 ++++++++++++++++++ apps/services/sys/idverify/core.go | 81 +++++ apps/services/sys/idverify/idcard.go | 82 +++++ apps/services/sys/idverify/idcard_test.go | 78 +++++ apps/services/sys/idverify/tencent.go | 98 ++++++ deploy/app/env/env.example | 9 + 31 files changed, 2012 insertions(+), 29 deletions(-) create mode 100644 apps/services/comm/lang.go create mode 100644 apps/services/comm/lang_test.go create mode 100644 apps/services/comm/svcpool_serveronly_test.go create mode 100644 apps/services/modules/user/api_idverify.go create mode 100644 apps/services/modules/user/api_translate.go create mode 100644 apps/services/modules/user/model_idverify.go create mode 100644 apps/services/modules/user/model_translate.go create mode 100644 apps/services/modules/user/newuser_gift.go create mode 100644 apps/services/modules/user/newuser_gift_test.go create mode 100644 apps/services/sys/idverify/aliyun.go create mode 100644 apps/services/sys/idverify/chuanglan.go create mode 100644 apps/services/sys/idverify/chuanglan_test.go create mode 100644 apps/services/sys/idverify/core.go create mode 100644 apps/services/sys/idverify/idcard.go create mode 100644 apps/services/sys/idverify/idcard_test.go create mode 100644 apps/services/sys/idverify/tencent.go diff --git a/apps/admin/app/pages/serviceconfig.vue b/apps/admin/app/pages/serviceconfig.vue index c9bc4cfa..6d83dfdb 100644 --- a/apps/admin/app/pages/serviceconfig.vue +++ b/apps/admin/app/pages/serviceconfig.vue @@ -235,6 +235,7 @@
{{ sc.short }} {{ sc.label }} + 仅服务端调用
@@ -245,6 +246,9 @@

为「{{ scatInfo(editType).label }}」选择服务商:

+
+ 该类服务的凭据仅在服务端使用,不会下发给客户端。请填写云账号的 AccessKey / SecretKey。 +
{{ t.provider }} @@ -658,11 +662,19 @@ const SCAT = [ { v: 7, label: 'STS 端到端对话', short: 'STS', color: '#be185d', bg: '#fce7f3' }, { v: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' }, { v: 10, label: 'MCP 服务', short: 'MCP', color: '#0d9488', bg: '#ccfbf1' }, + { v: 11, label: '身份证校验', short: '实名', color: '#9f1239', bg: '#ffe4e6' }, ] // MCP 服务类别(与后端 svcCatMCP 一致):字段固定为 url/type/tools,type 用 HTTP/SSE 下拉。 const SCAT_MCP = 10 +// 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。 +// 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉, +// 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚, +// 真正的拦截在后端 —— 改这里不会改变下发行为。 +const SERVER_ONLY_SCATS = [11] +const isServerOnlyCat = (v: number) => SERVER_ONLY_SCATS.includes(v) + interface SvcField { key: string description: string diff --git a/apps/admin/app/pages/users.vue b/apps/admin/app/pages/users.vue index acfc3fe6..a7e9108b 100644 --- a/apps/admin/app/pages/users.vue +++ b/apps/admin/app/pages/users.vue @@ -184,6 +184,11 @@ const sections: Section[] = [ ['meetintegral', '会议积分'], ['meettotalintegral', '会议累计积分'], ['lastbindproductid', '最后绑定产品ID'], ['isactivatecode', '已领激活码', 'bool'], ['isgiveaway', '首次绑定赠送', 'bool'], ] }, + { title: '实名认证', rows: [ + ['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'], + ['idverify_realname', '姓名'], ['idverify_idcardmask', '身份证号'], + ['idverify_provider', '核验服务商'], ['idverify_samecardusers', '同证件账号数'], + ] }, { title: '时间', rows: [ ['createtime', '注册时间', 'ts'], ['lastsgintime', '最后登录', 'ts'], ['canceltime', '注销时间', 'ts'], ] }, diff --git a/apps/proto/errorcode.proto b/apps/proto/errorcode.proto index 9067cd66..ce5b1d0d 100644 --- a/apps/proto/errorcode.proto +++ b/apps/proto/errorcode.proto @@ -87,4 +87,16 @@ enum ErrorCode { //会议记录相关错误码 4001-4005 AudioUrlEmpty = 4001; //音频URL为空 + + //实名认证相关错误码 5001-5005 + IdVerifyNotConfigured = 5001; //未配置身份证校验服务(后台第三方服务配置里没有启用的实名认证服务) + IdVerifyParamInvalid = 5002; //姓名或身份证号格式不合法(本地校验未过,未消耗服务商额度) + IdVerifyMismatch = 5003; //姓名与身份证号不一致(服务商判定) + IdVerifyProviderError = 5004; //服务商调用失败(网络/额度/鉴权等,非"不一致") + IdVerifyTooFrequent = 5005; //校验过于频繁,已被限流 + + //实时机器翻译相关错误码 5101-5103 + TranslateNotConfigured = 5101; //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务) + TranslateParamInvalid = 5102; //翻译参数不合法(原文为空 / 目标语言为空) + TranslateProviderError = 5103; //翻译服务商调用失败 } diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index b31b4913..88036233 100644 --- a/apps/services/comm/const.go +++ b/apps/services/comm/const.go @@ -46,6 +46,7 @@ const ( const ( TableUser = "user" //用户表 TableUserdevice = "userdevice" //用户设备列表 + TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希) TableRecord = "record" //记录表 TableAgent = "agent" //智能体id TableMcp = "mcp" //Mcp服务 diff --git a/apps/services/comm/lang.go b/apps/services/comm/lang.go new file mode 100644 index 00000000..28de8a60 --- /dev/null +++ b/apps/services/comm/lang.go @@ -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) +} diff --git a/apps/services/comm/lang_test.go b/apps/services/comm/lang_test.go new file mode 100644 index 00000000..f138283a --- /dev/null +++ b/apps/services/comm/lang_test.go @@ -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) + } + } +} diff --git a/apps/services/comm/svcpool.go b/apps/services/comm/svcpool.go index 6e72b5b5..89edb2bc 100644 --- a/apps/services/comm/svcpool.go +++ b/apps/services/comm/svcpool.go @@ -145,6 +145,58 @@ func ResolveSvcPlainFields(svc *ThirdSvcConfig, ovr *SvcRegionOverride, encKey s return fields, nil } +// ============================== 服务端专用类别(凭据绝不下发客户端) ============================== +// +// svc_config 里的服务默认是「下发给客户端用的第三方能力」——resolveThirdSvcs 会把 encrypted 字段 +// **解密成明文**塞进 user_getthirdsvcs_v2 / user_getappconfig_v3 的响应里。 +// +// 但有些第三方能力天然只能由服务端调用,凭据是**主账号级**的(如实名认证用的阿里云 AccessKey、 +// 腾讯云 SecretKey,拿到就能操作整个云账号)。这类服务放进 svc_config 是为了统一管理入口 +// (后台「第三方服务配置 → 添加服务」),但**必须在下发口拦掉**。 +// +// 于是有了这份「服务端专用类别」名单:categories 命中任一即整条服务不下发。 +// 新增服务端专用类别时,只需在 serverOnlySvcCats 里加一行。 + +// SvcCatMT 机器翻译(与 console svcCatMT、admin 前端 SCAT 的 3 一致)。 +// 实时翻译接口 user_translate 按这个类别挑服务;它**不是**服务端专用类别—— +// 只是客户端现在不直接消费第三方服务下发,改由服务端代调,凭据因此不出网关。 +const SvcCatMT int32 = 3 + +// SvcCatIdVerify 身份证实名核验(阿里云 Id2MetaVerify / 腾讯云 IdCardVerification)。 +// 服务端专用——凭据是云账号主 AK/SK,绝不下发客户端。 +const SvcCatIdVerify int32 = 11 + +// serverOnlySvcCats 服务端专用类别集合:命中的服务不随任何客户端接口下发。 +var serverOnlySvcCats = map[int32]bool{ + SvcCatIdVerify: true, +} + +// IsServerOnlySvc 判断某服务(按其逗号分隔的 categories)是否服务端专用、不得下发客户端。 +// 只要类别里含任一服务端专用类别就返回 true——宁可少发也不能把主账号凭据发出去。 +func IsServerOnlySvc(categories string) bool { + for _, p := range strings.Split(categories, ",") { + v, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + continue + } + if serverOnlySvcCats[int32(v)] { + return true + } + } + return false +} + +// CategoriesHas 判断逗号分隔的 categories 是否含指定类别值。 +func CategoriesHas(categories string, cat int32) bool { + want := strconv.Itoa(int(cat)) + for _, p := range strings.Split(categories, ",") { + if strings.TrimSpace(p) == want { + return true + } + } + return false +} + // ============================== MCP 服务(并入 svc_config 的 MCP 服务类型) ============================== // // MCP(模型上下文协议)服务已并入第三方服务:categories 含 SvcCatMCP,字段固定为 url/type/tools(均明文)。 diff --git a/apps/services/comm/svcpool_serveronly_test.go b/apps/services/comm/svcpool_serveronly_test.go new file mode 100644 index 00000000..d8bd2d02 --- /dev/null +++ b/apps/services/comm/svcpool_serveronly_test.go @@ -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)") + } +} diff --git a/apps/services/modules/console/api_svctemplate.go b/apps/services/modules/console/api_svctemplate.go index d52bf82e..a04efd26 100644 --- a/apps/services/modules/console/api_svctemplate.go +++ b/apps/services/modules/console/api_svctemplate.go @@ -135,6 +135,7 @@ const ( svcCatASRFile int32 = 8 // 录音文件识别(会议记录离线转写,接口/凭据区别于实时 STT) svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结) svcCatMCP int32 = 10 // MCP 服务(模型上下文协议;url/type/tools 三字段,按区域 fork,全应用共享) + svcCatIdVerify int32 = 11 // 身份证实名核验(服务端专用:凭据是云账号主 AK/SK,comm.IsServerOnlySvc 保证永不下发客户端) ) // sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。 @@ -299,6 +300,40 @@ func builtinSvcTemplates() []ThirdSvcTemplate { // 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。 tpl("mcp_default", "custom", "MCP 服务", svcCatMCP, 0, sf("url", "服务地址,如 https://...", false), sfd("type", "传输类型:0=HTTP,1=SSE", false, "0"), sf("tools", "启用工具(逗号分隔,空=全部)", false)), + + // 身份证实名核验(二要素:姓名 + 身份证号)。 + // + // ⚠️ 这两家的凭据都是**云账号主 AK/SK**,拿到即可操作整个云账号。它们放在 + // svc_config 只是为了统一后台管理入口——comm.IsServerOnlySvc 会在 resolveThirdSvcs + // 里把类别 11 整条拦掉,绝不随任何客户端接口下发。改动那处过滤前请先想清楚后果。 + // + // 阿里云 Id2MetaVerify:域名 cloudauth.aliyuncs.com,Version 2019-03-07, + // 出参 ResultObject.BizCode(1=一致,2=不一致)。region/domain 留空走默认 cn-shanghai。 + tpl("idverify_aliyun", "aliyun", "阿里云 身份二要素核验", svcCatIdVerify, 0, + sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), + sfd("region", "地域(可选,默认 cn-shanghai)", false, "cn-shanghai"), + sfd("domain", "接口域名(可选)", false, "cloudauth.aliyuncs.com")), + // 腾讯云 IdCardVerification:域名 faceid.tencentcloudapi.com,Version 2018-03-01, + // 出参 Result("0"=一致,"-1"=不一致,-2..-7 为各类错误)。本接口不需要传 Region。 + tpl("idverify_tencent", "tencent", "腾讯云 身份二要素核验", svcCatIdVerify, 1, + sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), + sf("region", "地域(本接口不需要,留空即可)", false)), + // 创蓝云智(253) OM2.0 身份证二要素: + // POST wsauth.253.com/api/v2/auth/idcard/id-card-auth,application/json, + // 鉴权走**请求头**(AppID/Nonce/CurTime/CheckSum,CheckSum=SHA1(AppSecret+Nonce+CurTime)), + // 出参 data.result(01=一致 02=不一致 03=认证不确定 04=认证失败, + // 后两者不计费且**不代表不一致**)。 + // 字段键名沿用 appid/appkey(存量配置已在用),值分别是控制台 + // 「账号中心 → API Key」里的 AppID 与 AppSecret。 + // ⚠️ 别配成老版 /open/idcard/id-card-auth——拿 OM2.0 的 AppID 打老接口会回 + // 500902「用户不存在」,看着像凭据错,实则是接口版本用错了。 + // url 留空即走代码里的默认地址;服务商换地址时可在后台直接改,不用重新发版。 + // 这里刻意不预填默认值——预填过的值 seed 不会再覆盖(见 appendMissingFields), + // 一旦地址变更,老部署的表单里会永远留着旧地址。 + tpl("idverify_chuanglan", "chuanglan", "创蓝 身份二要素核验", svcCatIdVerify, 2, + sf("appid", "AppID(控制台→账号中心→API Key)", true), + sf("appkey", "AppSecret", true), + sf("url", "接口地址(可选,留空用默认)", false)), } // 前置语言字段(languages 存逗号分隔 BCP-47 如 zh-CN,en-US;default_lang 存单个码): // - 音频输出类(TTS/AST/STS):支持语言 + 默认语言; diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go index 1bdcc2a9..56c766d8 100644 --- a/apps/services/modules/console/server.go +++ b/apps/services/modules/console/server.go @@ -1114,7 +1114,30 @@ func (this *serverComp) getUserInfo(c *gin.Context) { return } user.Password = "" // 不下发密码 - writeOK(c, user) + + // 附上实名认证明细(同库的 useridverify 表)。身份证号只有掩码,全文本就不落库。 + // 同证件账号数:本次选的口径是「一证可绑多号、不拦,但后台可查」,所以这里按号码 + // 指纹回查同库还有几个账号用了同一张证件,>1 即值得运营看一眼。 + out := map[string]interface{}{} + if b, err := json.Marshal(user); err == nil { + _ = json.Unmarshal(b, &out) + } + iv := &pb.DBUserIdVerify{} + if err := conn.FindOne(comm.TableUserIdVerify, iv, "uid=?", uid); err == nil { + out["idverify_realname"] = iv.Realname + out["idverify_idcardmask"] = iv.Idcardmask + out["idverify_provider"] = iv.Provider + if iv.Idcardhash != "" { + same := make([]*pb.DBUserIdVerify, 0) + if err := conn.Find(comm.TableUserIdVerify, &same, "idcardhash=? AND verifytime>0", iv.Idcardhash); err == nil { + out["idverify_samecardusers"] = len(same) + } + } + } else if !errors.Is(err, mysql.ErrNoDocuments) { + // 明细查不到不影响主查询,记日志即可。 + this.module.Errorln("getUserInfo: 查实名明细失败:", err) + } + writeOK(c, out) } // giftUserResource 赠送资源点给终端用户:直连选中应用(X-App-Id)业务库,给 user 加 diff --git a/apps/services/modules/echomeet/core.go b/apps/services/modules/echomeet/core.go index c2a2c697..6c8b0494 100644 --- a/apps/services/modules/echomeet/core.go +++ b/apps/services/modules/echomeet/core.go @@ -118,6 +118,9 @@ func toAliTranslateLang(bcp47 string) string { return bcp47 } +// 说明:实时翻译接口(modules/user/api_translate.go)用的是 comm.BCP47ToShortLang, +// 与本表同源。本函数保持原样不动——echomeet 在跑生产,不为共用去改它的行为。 + // Google Cloud Translation:直接接受 BCP-47(en / en-US / zh-CN 都支持),原样透传 func toGoogleTranslateLang(bcp47 string) string { return bcp47 diff --git a/apps/services/modules/user/api_idverify.go b/apps/services/modules/user/api_idverify.go new file mode 100644 index 00000000..38c2cf9a --- /dev/null +++ b/apps/services/modules/user/api_idverify.go @@ -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 +} diff --git a/apps/services/modules/user/api_sgin.go b/apps/services/modules/user/api_sgin.go index e68a87b7..295ece94 100644 --- a/apps/services/modules/user/api_sgin.go +++ b/apps/services/modules/user/api_sgin.go @@ -174,21 +174,25 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp return } case pb.SginTyoe_Tourists: //游客登录 - // if req.Phonemac == "" { - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_ReqParameterError, - // Message: pb.ErrorCode_ReqParameterError.String(), - // } - // return - // } - // if user, err = this.module.model.findformac(req.Phonemac); err != nil && err != mysql.ErrNoDocuments { - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_DBError, - // Message: err.Error(), - // } - // return - // } - err = mysql.ErrNoDocuments + // 按设备硬件 id 找回同一个游客账号。 + // + // 这段原先是注释掉的,恒定走下面的"新建账号"分支,后果有两个: + // 游客每次登录都是新账号,实名认证记录留不住(客户端要求"实名过就能用功能" + // 就永远不成立);而且新账号分支紧接着就发新用户开户礼,等于可以无限白嫖。 + if req.Phonemac == "" { + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_ReqParameterError, + Message: "游客登录需要设备标识", + } + return + } + if user, err = this.module.model.findtouristformac(req.Phonemac); err != nil && err != mysql.ErrNoDocuments { + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_DBError, + Message: err.Error(), + } + return + } default: errdata = &pb.ErrorData{ Code: pb.ErrorCode_ReqParameterError, @@ -237,6 +241,10 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp // if user.Avatar != "" { // user.Avatar = aes.AesEncryptCBC(user.Avatar, this.options.CBCKey) // } + // 新用户开户礼:30 天 VIP + 翻译/会议各 100 分钟 + 智能体 100 次。 + // 在 add 之前赋值,随用户一起落库,不需要额外一次写。见 newuser_gift.go。 + giftNow := time.Now().Unix() + applyNewUserGift(user, giftNow) if err = this.module.model.add(user); err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, @@ -244,6 +252,7 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp } return } + logNewUserGift(user.Uid, giftNow) // 新增用户埋点 if this.module.analyze != nil { this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventRegister, Uid: user.Uid}) diff --git a/apps/services/modules/user/api_translate.go b/apps/services/modules/user/api_translate.go new file mode 100644 index 00000000..f3649f0b --- /dev/null +++ b/apps/services/modules/user/api_translate.go @@ -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 +} diff --git a/apps/services/modules/user/model_idverify.go b/apps/services/modules/user/model_idverify.go new file mode 100644 index 00000000..767ef671 --- /dev/null +++ b/apps/services/modules/user/model_idverify.go @@ -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_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 +} diff --git a/apps/services/modules/user/model_translate.go b/apps/services/modules/user/model_translate.go new file mode 100644 index 00000000..01e8e494 --- /dev/null +++ b/apps/services/modules/user/model_translate.go @@ -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_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 +} diff --git a/apps/services/modules/user/module.go b/apps/services/modules/user/module.go index dccdd8e4..9932a1c7 100644 --- a/apps/services/modules/user/module.go +++ b/apps/services/modules/user/module.go @@ -27,6 +27,8 @@ type User struct { apiv3 *apiV3Comp model *modelUserComp configmodel *modelConfigComp + idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验) + translatemodel *modelTranslateComp //实时机器翻译(走第三方服务配置里的 MT 服务) cache *modelCacheComp options *Options analyze comm.IAnalyze @@ -65,4 +67,6 @@ func (this *User) OnInstallComp() { this.model = this.RegisterComp(new(modelUserComp)).(*modelUserComp) this.cache = this.RegisterComp(new(modelCacheComp)).(*modelCacheComp) this.configmodel = this.RegisterComp(new(modelConfigComp)).(*modelConfigComp) + this.idverifymodel = this.RegisterComp(new(modelIdVerifyComp)).(*modelIdVerifyComp) + this.translatemodel = this.RegisterComp(new(modelTranslateComp)).(*modelTranslateComp) } diff --git a/apps/services/modules/user/newuser_gift.go b/apps/services/modules/user/newuser_gift.go new file mode 100644 index 00000000..6267186e --- /dev/null +++ b/apps/services/modules/user/newuser_gift.go @@ -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", + }) +} diff --git a/apps/services/modules/user/newuser_gift_test.go b/apps/services/modules/user/newuser_gift_test.go new file mode 100644 index 00000000..58bd1b1e --- /dev/null +++ b/apps/services/modules/user/newuser_gift_test.go @@ -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) + } +} diff --git a/apps/services/modules/user/svcresolve.go b/apps/services/modules/user/svcresolve.go index 749563f3..02195ab4 100644 --- a/apps/services/modules/user/svcresolve.go +++ b/apps/services/modules/user/svcresolve.go @@ -19,6 +19,9 @@ import ( // 密文当明文用会在客户端深处炸成不可理解的错误)。 // - MCP 服务同在 svc_config 里(categories 含 comm.SvcCatMCP),随本函数一并下发, // url/type/tools 就在 fields 中——v3 因此不再单独下发 mcps。 +// - 服务端专用类别(comm.IsServerOnlySvc,如实名认证 SvcCatIdVerify)**一律不下发**: +// 它们的凭据是云账号主 AK/SK,进 svc_config 只为统一后台管理入口,由服务端自己读取调用。 +// 这里是唯一的拦截点,删掉它等于把主账号密钥明文发给每个 App 用户。 // // 响应整体由网关 AES-CBC 加密(apiV2Comp/apiV3Comp 的 EncryptMsgs),故 fields 内不再逐个加密。 @@ -46,6 +49,10 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv if !s.Enable { continue } + // 服务端专用:整条跳过,连 id 都不进后续的区域覆盖查询。 + if comm.IsServerOnlySvc(s.Categories) { + continue + } if exist, ok := picked[s.Id]; !ok || (exist.AppName == "" && s.AppName == app && app != "") { if _, ok := picked[s.Id]; !ok { svcIds = append(svcIds, s.Id) diff --git a/apps/services/pb/errorcode.pb.go b/apps/services/pb/errorcode.pb.go index 10ec70ed..239ec73c 100644 --- a/apps/services/pb/errorcode.pb.go +++ b/apps/services/pb/errorcode.pb.go @@ -99,6 +99,16 @@ const ( ErrorCode_ResourceDeliveryError ErrorCode = 3006 //资源发放失败 // 会议记录相关错误码 4001-4005 ErrorCode_AudioUrlEmpty ErrorCode = 4001 //音频URL为空 + // 实名认证相关错误码 5001-5005 + ErrorCode_IdVerifyNotConfigured ErrorCode = 5001 //未配置身份证校验服务(后台第三方服务配置里没有启用的实名认证服务) + ErrorCode_IdVerifyParamInvalid ErrorCode = 5002 //姓名或身份证号格式不合法(本地校验未过,未消耗服务商额度) + ErrorCode_IdVerifyMismatch ErrorCode = 5003 //姓名与身份证号不一致(服务商判定) + ErrorCode_IdVerifyProviderError ErrorCode = 5004 //服务商调用失败(网络/额度/鉴权等,非"不一致") + ErrorCode_IdVerifyTooFrequent ErrorCode = 5005 //校验过于频繁,已被限流 + // 实时机器翻译相关错误码 5101-5103 + ErrorCode_TranslateNotConfigured ErrorCode = 5101 //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务) + ErrorCode_TranslateParamInvalid ErrorCode = 5102 //翻译参数不合法(原文为空 / 目标语言为空) + ErrorCode_TranslateProviderError ErrorCode = 5103 //翻译服务商调用失败 ) // Enum value maps for ErrorCode. @@ -169,6 +179,14 @@ var ( 3005: "PayNotifyDecryptFail", 3006: "ResourceDeliveryError", 4001: "AudioUrlEmpty", + 5001: "IdVerifyNotConfigured", + 5002: "IdVerifyParamInvalid", + 5003: "IdVerifyMismatch", + 5004: "IdVerifyProviderError", + 5005: "IdVerifyTooFrequent", + 5101: "TranslateNotConfigured", + 5102: "TranslateParamInvalid", + 5103: "TranslateProviderError", } ErrorCode_value = map[string]int32{ "Success": 0, @@ -236,6 +254,14 @@ var ( "PayNotifyDecryptFail": 3005, "ResourceDeliveryError": 3006, "AudioUrlEmpty": 4001, + "IdVerifyNotConfigured": 5001, + "IdVerifyParamInvalid": 5002, + "IdVerifyMismatch": 5003, + "IdVerifyProviderError": 5004, + "IdVerifyTooFrequent": 5005, + "TranslateNotConfigured": 5101, + "TranslateParamInvalid": 5102, + "TranslateProviderError": 5103, } ) @@ -270,7 +296,7 @@ var File_errorcode_proto protoreflect.FileDescriptor const file_errorcode_proto_rawDesc = "" + "\n" + - "\x0ferrorcode.proto*\x91\v\n" + + "\x0ferrorcode.proto*\xeb\f\n" + "\tErrorCode\x12\v\n" + "\aSuccess\x10\x00\x12\x14\n" + "\x10GatewayException\x10\x01\x12\x11\n" + @@ -339,7 +365,15 @@ const file_errorcode_proto_rawDesc = "" + "\x12PayCreateOrderFail\x10\xbc\x17\x12\x19\n" + "\x14PayNotifyDecryptFail\x10\xbd\x17\x12\x1a\n" + "\x15ResourceDeliveryError\x10\xbe\x17\x12\x12\n" + - "\rAudioUrlEmpty\x10\xa1\x1fB\x06Z\x04.;pbb\x06proto3" + "\rAudioUrlEmpty\x10\xa1\x1f\x12\x1a\n" + + "\x15IdVerifyNotConfigured\x10\x89'\x12\x19\n" + + "\x14IdVerifyParamInvalid\x10\x8a'\x12\x15\n" + + "\x10IdVerifyMismatch\x10\x8b'\x12\x1a\n" + + "\x15IdVerifyProviderError\x10\x8c'\x12\x18\n" + + "\x13IdVerifyTooFrequent\x10\x8d'\x12\x1b\n" + + "\x16TranslateNotConfigured\x10\xed'\x12\x1a\n" + + "\x15TranslateParamInvalid\x10\xee'\x12\x1b\n" + + "\x16TranslateProviderError\x10\xef'B\x06Z\x04.;pbb\x06proto3" var ( file_errorcode_proto_rawDescOnce sync.Once diff --git a/apps/services/sys/aliyun/translate/core.go b/apps/services/sys/aliyun/translate/core.go index 5e5fbb61..eba7053b 100644 --- a/apps/services/sys/aliyun/translate/core.go +++ b/apps/services/sys/aliyun/translate/core.go @@ -4,6 +4,9 @@ import "context" type ISys interface { Translate(ctx context.Context, from string, to string, text []string) (results []string, err error) + // TranslateDetect 单句翻译并回传检测出的源语种;from 传空串即自动检测。 + // 实时文本翻译的「自动检测」用它,省掉一次单独的语种识别调用。 + TranslateDetect(ctx context.Context, from, to, text string) (translated, detected string, err error) } var defsys ISys diff --git a/apps/services/sys/aliyun/translate/translate.go b/apps/services/sys/aliyun/translate/translate.go index fe5946c6..e0f98676 100644 --- a/apps/services/sys/aliyun/translate/translate.go +++ b/apps/services/sys/aliyun/translate/translate.go @@ -49,10 +49,13 @@ type translateGeneralResponse struct { Data struct { WordCount string `json:"WordCount"` Translated string `json:"Translated"` + // SourceLanguage 传 auto 时,阿里会在这里回填它检测出的源语种(短码)。 + // 文本翻译的「自动检测」靠它把语种显示回界面。 + DetectedLanguage string `json:"DetectedLanguage"` } `json:"Data"` } -func (this *AliTranslate) translateOne(from, to, text string) (string, error) { +func (this *AliTranslate) translateOne(from, to, text string) (translated, detected string, err error) { req := requests.NewCommonRequest() req.Method = "POST" req.Scheme = "https" @@ -67,17 +70,17 @@ func (this *AliTranslate) translateOne(from, to, text string) (string, error) { resp, err := this.client.ProcessCommonRequest(req) if err != nil { - return "", fmt.Errorf("translate request: %w", err) + return "", "", fmt.Errorf("translate request: %w", err) } body := resp.GetHttpContentString() var out translateGeneralResponse if err = json.Unmarshal([]byte(body), &out); err != nil { - return "", fmt.Errorf("unmarshal response: %w body=%s", err, body) + return "", "", fmt.Errorf("unmarshal response: %w body=%s", err, body) } if out.Code != "" && out.Code != "200" { - return "", fmt.Errorf("translate failed: code=%s msg=%s", out.Code, out.Message) + return "", "", fmt.Errorf("translate failed: code=%s msg=%s", out.Code, out.Message) } - return out.Data.Translated, nil + return out.Data.Translated, out.Data.DetectedLanguage, nil } // isThrottling 判断是否为阿里 MT 的用户级流控错误(ErrorCode: Throttling.User)。 @@ -96,25 +99,36 @@ var translateRetryBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Sec // translateOneWithRetry 单句翻译;遇到流控按 translateRetryBackoff 退避重试。 // 非流控错误(如参数错误)不重试,直接返回。 -func (this *AliTranslate) translateOneWithRetry(ctx context.Context, from, to, text string) (string, error) { +func (this *AliTranslate) translateOneWithRetry(ctx context.Context, from, to, text string) (translated, detected string, err error) { var lastErr error for attempt := 0; ; attempt++ { - r, e := this.translateOne(from, to, text) + r, d, e := this.translateOne(from, to, text) if e == nil { - return r, nil + return r, d, nil } lastErr = e if !isThrottling(e) || attempt >= len(translateRetryBackoff) { - return "", lastErr + return "", "", lastErr } select { case <-ctx.Done(): - return "", ctx.Err() + return "", "", ctx.Err() case <-time.After(translateRetryBackoff[attempt]): } } } +// TranslateDetect 单句翻译并回传阿里检测出的源语种。 +// +// from 传空串时按 "auto" 走自动检测(文本翻译的「自动检测」模式用这个); +// detected 是阿里回的短码(zh / en / ...),from 非空时通常为空。 +func (this *AliTranslate) TranslateDetect(ctx context.Context, from, to, text string) (translated, detected string, err error) { + if strings.TrimSpace(from) == "" { + from = "auto" + } + return this.translateOneWithRetry(ctx, from, to, text) +} + // Translate 批量翻译,按 Concurrency 并发执行 TranslateGeneral。 // from / to 为阿里 MT 语言码:zh / en / ja / ko / ...(保持调用方传入的标准) // @@ -149,7 +163,7 @@ func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts results[idx] = text return } - r, e := this.translateOneWithRetry(ctx, from, to, text) + r, _, e := this.translateOneWithRetry(ctx, from, to, text) if e != nil { results[idx] = text // 降级:保留原文,别让整批作废 errs[idx] = e diff --git a/apps/services/sys/idverify/aliyun.go b/apps/services/sys/idverify/aliyun.go new file mode 100644 index 00000000..23a6a852 --- /dev/null +++ b/apps/services/sys/idverify/aliyun.go @@ -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) + } +} diff --git a/apps/services/sys/idverify/chuanglan.go b/apps/services/sys/idverify/chuanglan.go new file mode 100644 index 00000000..e11b664b --- /dev/null +++ b/apps/services/sys/idverify/chuanglan.go @@ -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] + "..." +} diff --git a/apps/services/sys/idverify/chuanglan_test.go b/apps/services/sys/idverify/chuanglan_test.go new file mode 100644 index 00000000..9d9e9bd4 --- /dev/null +++ b/apps/services/sys/idverify/chuanglan_test.go @@ -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: `502 Bad Gateway`, + 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) + } + } +} diff --git a/apps/services/sys/idverify/core.go b/apps/services/sys/idverify/core.go new file mode 100644 index 00000000..72d0192a --- /dev/null +++ b/apps/services/sys/idverify/core.go @@ -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 +} diff --git a/apps/services/sys/idverify/idcard.go b/apps/services/sys/idverify/idcard.go new file mode 100644 index 00000000..1b57b770 --- /dev/null +++ b/apps/services/sys/idverify/idcard.go @@ -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 +} diff --git a/apps/services/sys/idverify/idcard_test.go b/apps/services/sys/idverify/idcard_test.go new file mode 100644 index 00000000..ef72031c --- /dev/null +++ b/apps/services/sys/idverify/idcard_test.go @@ -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) + } + } +} diff --git a/apps/services/sys/idverify/tencent.go b/apps/services/sys/idverify/tencent.go new file mode 100644 index 00000000..5c1e817d --- /dev/null +++ b/apps/services/sys/idverify/tencent.go @@ -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) + } +} diff --git a/deploy/app/env/env.example b/deploy/app/env/env.example index de4586bb..1aa170e9 100644 --- a/deploy/app/env/env.example +++ b/deploy/app/env/env.example @@ -35,6 +35,15 @@ NATS_URL=nats://nats:4222 # 发出去,表现为 401 "API key is invalid",而后台却显示配置已填好,极难排查。 FIELD_ENCRYPT_KEY= +# ── 身份证号指纹盐(实名认证功能用;只在业务后端需要,console 不用)── +# 实名认证**不落库身份证号全文**,只存掩码 + 加盐 SHA-256 指纹。指纹用于后台排查 +# 「同一张证件绑了几个账号」。 +# ⚠️ 必须加盐:身份证号空间有限(前 6 位地区码 + 8 位生日高度可枚举),裸 SHA-256 +# 可被彻底反查,等同明文存储。留空时代码**不会退化成裸哈希**,而是不写指纹—— +# 功能仍可用,只是后台查不了「同证件多账号」。 +# ⚠️ 一旦启用就不要再改:换了盐,之前存的指纹全部对不上,同证件查重从此断档。 +ID_HASH_SALT= + # ── 部署身份(同时也是统计上报的维度;须与 console 注册表 app_registry 的 app_name 一致)── # 这一组就是本部署的唯一身份声明,所有按应用/区域作用域解析的功能都用它(comm.AppName/AppRegion): # · 统计上报 → stats_global_day.app_id / region