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
Rodger-Wang 1 month ago
parent
commit
90abb0ad42
  1. 12
      apps/admin/app/pages/serviceconfig.vue
  2. 5
      apps/admin/app/pages/users.vue
  3. 12
      apps/proto/errorcode.proto
  4. 1
      apps/services/comm/const.go
  5. 53
      apps/services/comm/lang.go
  6. 23
      apps/services/comm/lang_test.go
  7. 52
      apps/services/comm/svcpool.go
  8. 43
      apps/services/comm/svcpool_serveronly_test.go
  9. 35
      apps/services/modules/console/api_svctemplate.go
  10. 25
      apps/services/modules/console/server.go
  11. 3
      apps/services/modules/echomeet/core.go
  12. 177
      apps/services/modules/user/api_idverify.go
  13. 39
      apps/services/modules/user/api_sgin.go
  14. 80
      apps/services/modules/user/api_translate.go
  15. 157
      apps/services/modules/user/model_idverify.go
  16. 111
      apps/services/modules/user/model_translate.go
  17. 4
      apps/services/modules/user/module.go
  18. 64
      apps/services/modules/user/newuser_gift.go
  19. 49
      apps/services/modules/user/newuser_gift_test.go
  20. 7
      apps/services/modules/user/svcresolve.go
  21. 38
      apps/services/pb/errorcode.pb.go
  22. 3
      apps/services/sys/aliyun/translate/core.go
  23. 36
      apps/services/sys/aliyun/translate/translate.go
  24. 112
      apps/services/sys/idverify/aliyun.go
  25. 240
      apps/services/sys/idverify/chuanglan.go
  26. 312
      apps/services/sys/idverify/chuanglan_test.go
  27. 81
      apps/services/sys/idverify/core.go
  28. 82
      apps/services/sys/idverify/idcard.go
  29. 78
      apps/services/sys/idverify/idcard_test.go
  30. 98
      apps/services/sys/idverify/tencent.go
  31. 9
      deploy/app/env/env.example

12
apps/admin/app/pages/serviceconfig.vue

@ -235,6 +235,7 @@
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
<span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</span>
</div>
</div>
</template>
@ -245,6 +246,9 @@
<p class="text-sm text-slate-500">为「{{ scatInfo(editType).label }}」选择服务商:</p>
<button class="btn btn-ghost btn-xs" @click="wizardStep = 1">← 上一步</button>
</div>
<div v-if="isServerOnlyCat(editType)" class="alert alert-warning py-2 text-xs mb-3">
该类服务的凭据仅在服务端使用,<b>不会下发给客户端</b>。请填写云账号的 AccessKey / SecretKey。
</div>
<div class="type-grid">
<div v-for="t in templatesForType" :key="t.id" class="type-card" :class="{ on: selectedTemplateId === t.id }" @click="chooseProvider(t)">
<span class="provider-chip">{{ t.provider }}</span>
@ -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

5
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'],
] },

12
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; //翻译服务商调用失败
}

1
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服务

53
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)
}

23
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)
}
}
}

52
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(均明文)。

43
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)")
}
}

35
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):支持语言 + 默认语言;

25
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 加

3
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

177
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
}

39
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})

80
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
}

157
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>)优先于全局行(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
}

111
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>)优先于全局行(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
}

4
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)
}

64
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",
})
}

49
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)
}
}

7
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)

38
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

3
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

36
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

112
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)
}
}

240
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] + "..."
}

312
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: `<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)
}
}
}

81
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
}

82
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
}

78
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)
}
}
}

98
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)
}
}

9
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

Loading…
Cancel
Save