feat(realname): 实名核验切换到数脉二要素新接口

服务商旧接口 sinocheck2 已下线,改走 eid.shumaidata.com/eid/check:

- 身份证入参 id_number 改为 idcard
- 响应改为先判业务 code、再读 result.res 的两级结构,
  code/res 兼容字符串与数字两种返回形式
- 按服务商错误码表分流:400/20010/20310 归入参错误,
  404/604 归服务未配置,其余归服务不可用
- 驳回原因优先取服务商 description
- 新接口不返回流水号,provider_order_no 对该渠道留空
- 同步更新默认 URL 与 docs/实名认证.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-02 11:53:20 +08:00
co-authored by Cursor
parent 590bf1cae3
commit 0b220829bb
6 changed files with 305 additions and 200 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
# 实名认证:本地开发使用 mock;生产可改为 cloudmarket 并填写云市场 AppCode。
REALNAME_PROVIDER=mock
REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
REALNAME_CLOUDMARKET_APPCODE=
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
+1 -1
View File
@@ -87,7 +87,7 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
# 生产环境建议接入真实实名服务;未配置时不要使用 mock 对外运营。
REALNAME_PROVIDER=cloudmarket
REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
REALNAME_CLOUDMARKET_APPCODE=
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
+1 -1
View File
@@ -127,7 +127,7 @@ func Load() Config {
},
Realname: RealnameConfig{
Provider: getEnv("REALNAME_PROVIDER", "mock"),
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"),
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://eid.shumaidata.com/eid/check"),
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
},
Log: LogConfig{
@@ -29,10 +29,35 @@ type CloudMarketProvider struct {
}
type cloudMarketResponse struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Result json.RawMessage `json:"result"`
Reason string `json:"reason"`
Code flexString `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
type cloudMarketCheckResult struct {
Res flexString `json:"res"`
Description string `json:"description"`
}
// flexString 兼容服务商把 code、res 返回成字符串或数字两种形式。
type flexString string
func (f *flexString) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" || trimmed == "null" {
*f = ""
return nil
}
if trimmed[0] == '"' {
var value string
if err := json.Unmarshal(data, &value); err != nil {
return err
}
*f = flexString(strings.TrimSpace(value))
return nil
}
*f = flexString(trimmed)
return nil
}
func NewCloudMarketProvider(cfg CloudMarketConfig) (*CloudMarketProvider, error) {
@@ -67,7 +92,7 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov
}
query := endpoint.Query()
query.Set("name", strings.TrimSpace(req.Name))
query.Set("id_number", strings.TrimSpace(req.IDNo))
query.Set("idcard", strings.TrimSpace(req.IDNo))
endpoint.RawQuery = query.Encode()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil)
@@ -94,19 +119,17 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov
var payload cloudMarketResponse
if err := json.Unmarshal(body, &payload); err != nil {
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, "")
}
return ProviderResult{}, fmt.Errorf("%w: invalid cloud market response: %v", ErrProviderUnavailable, err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, payload.Reason)
return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, payload.Message)
}
if strings.ToUpper(strings.TrimSpace(payload.Status)) != "OK" {
return ProviderResult{
ProviderOrderNo: payload.RequestID,
Status: StatusRejected,
MaskedName: maskName(req.Name),
MaskedIDNo: maskIDNo(req.IDNo),
FailReason: firstNonEmpty(payload.Reason, "实名认证请求失败"),
}, nil
// 先判断 code,code 非 0 表示调用失败,无需再看 result。
if code := string(payload.Code); code != "0" {
return ProviderResult{}, cloudMarketCodeError(code, payload.Message)
}
result, err := parseCloudMarketResult(payload.Result)
@@ -126,65 +149,65 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov
now := time.Now()
base := ProviderResult{
ProviderOrderNo: payload.RequestID,
MaskedName: maskName(req.Name),
EncryptedName: encryptedName,
MaskedIDNo: maskIDNo(req.IDNo),
EncryptedIDNo: encryptedIDNo,
MaskedName: maskName(req.Name),
EncryptedName: encryptedName,
MaskedIDNo: maskIDNo(req.IDNo),
EncryptedIDNo: encryptedIDNo,
}
switch result {
case 1:
switch string(result.Res) {
case "1":
base.Status = StatusVerified
base.VerifiedAt = &now
case 2:
case "2":
base.Status = StatusRejected
base.FailReason = "姓名与身份证号不一致"
case 3:
base.FailReason = firstNonEmpty(result.Description, "姓名与身份证号不一致")
case "3":
base.Status = StatusRejected
base.FailReason = "公安库无匹配记录"
case 110:
return ProviderResult{}, ErrProviderRateLimited
base.FailReason = firstNonEmpty(result.Description, "公安库无匹配记录")
default:
base.Status = StatusRejected
base.FailReason = fmt.Sprintf("未知核验结果:%d", result)
base.FailReason = firstNonEmpty(result.Description, fmt.Sprintf("未知核验结果:%s", result.Res))
}
return base, nil
}
func cloudMarketHTTPError(statusCode int, reason string) error {
reason = strings.TrimSpace(reason)
func cloudMarketHTTPError(statusCode int, message string) error {
message = strings.TrimSpace(message)
switch statusCode {
case http.StatusBadRequest:
return fmt.Errorf("%w: %s", ErrInvalidRealnameInput, firstNonEmpty(reason, "cloud market bad request"))
return fmt.Errorf("%w: %s", ErrInvalidRealnameInput, firstNonEmpty(message, "cloud market bad request"))
case http.StatusUnauthorized, http.StatusNotFound:
return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderConfigInvalid, statusCode, reason)
return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderConfigInvalid, statusCode, message)
default:
return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderUnavailable, statusCode, reason)
return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderUnavailable, statusCode, message)
}
}
func parseCloudMarketResult(raw json.RawMessage) (int, error) {
// cloudMarketCodeError 按服务商错误码表映射,code 取值见 docs/实名认证.md。
func cloudMarketCodeError(code, message string) error {
message = strings.TrimSpace(message)
switch code {
case "400", "20010", "20310":
return fmt.Errorf("%w: cloud market code %s: %s", ErrInvalidRealnameInput, code, message)
case "404", "604":
return fmt.Errorf("%w: cloud market code %s: %s", ErrProviderConfigInvalid, code, message)
default:
return fmt.Errorf("%w: cloud market code %s: %s", ErrProviderUnavailable, firstNonEmpty(code, "empty"), message)
}
}
func parseCloudMarketResult(raw json.RawMessage) (cloudMarketCheckResult, error) {
if len(raw) == 0 || string(raw) == "null" {
return 0, fmt.Errorf("%w: realname cloud market result missing", ErrProviderUnavailable)
return cloudMarketCheckResult{}, fmt.Errorf("%w: realname cloud market result missing", ErrProviderUnavailable)
}
var number int
if err := json.Unmarshal(raw, &number); err == nil {
return number, nil
var result cloudMarketCheckResult
if err := json.Unmarshal(raw, &result); err != nil {
return cloudMarketCheckResult{}, fmt.Errorf("%w: realname cloud market result invalid: %s", ErrProviderUnavailable, string(raw))
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
switch strings.TrimSpace(text) {
case "1":
return 1, nil
case "2":
return 2, nil
case "3":
return 3, nil
case "110":
return 110, nil
}
if strings.TrimSpace(string(result.Res)) == "" {
return cloudMarketCheckResult{}, fmt.Errorf("%w: realname cloud market res missing: %s", ErrProviderUnavailable, string(raw))
}
return 0, fmt.Errorf("%w: realname cloud market result invalid: %s", ErrProviderUnavailable, string(raw))
return result, nil
}
func firstNonEmpty(values ...string) string {
@@ -9,32 +9,38 @@ import (
"hfb_sys/backend/pkg/crypto"
)
func TestCloudMarketProviderStartVerified(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("请求方法不正确:%s", r.Method)
}
if r.Header.Get("Authorization") != "APPCODE test-code" {
t.Fatalf("APPCODE 请求头不正确:%s", r.Header.Get("Authorization"))
}
if r.URL.Query().Get("name") != "张三" {
t.Fatalf("姓名参数不正确:%s", r.URL.Query().Get("name"))
}
if r.URL.Query().Get("id_number") != "110101199001011234" {
t.Fatalf("身份证参数不正确:%s", r.URL.Query().Get("id_number"))
}
_, _ = w.Write([]byte(`{"request_id":"TID123","status":"OK","result":1}`))
}))
defer server.Close()
func newTestCloudMarketProvider(t *testing.T, handler http.HandlerFunc) (*CloudMarketProvider, func()) {
t.Helper()
server := httptest.NewServer(handler)
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
})
if err != nil {
server.Close()
t.Fatalf("创建 Provider 失败:%v", err)
}
return provider, server.Close
}
func TestCloudMarketProviderStartVerified(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("请求方法不正确:%s", r.Method)
}
if r.Header.Get("Authorization") != "APPCODE test-code" {
t.Errorf("APPCODE 请求头不正确:%s", r.Header.Get("Authorization"))
}
if r.URL.Query().Get("name") != "张三" {
t.Errorf("姓名参数不正确:%s", r.URL.Query().Get("name"))
}
if r.URL.Query().Get("idcard") != "110101199001011234" {
t.Errorf("身份证参数不正确:%s", r.URL.Query().Get("idcard"))
}
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"name":"张*","idcard":"1101********1234","res":"1","description":"一致","sex":"男","birthday":"19900101","address":"北京市****区"}}`))
})
defer closeServer()
result, err := provider.Start(t.Context(), StartRequest{
UserID: 1,
@@ -47,90 +53,141 @@ func TestCloudMarketProviderStartVerified(t *testing.T) {
if result.Status != StatusVerified {
t.Fatalf("状态不正确:%s", result.Status)
}
if result.ProviderOrderNo != "TID123" {
t.Fatalf("请求流水不正确:%s", result.ProviderOrderNo)
}
if result.VerifiedAt == nil {
t.Fatal("通过时间不能为空")
}
if result.MaskedName == "" || result.MaskedIDNo == "" {
t.Fatalf("脱敏信息不能为空:%s %s", result.MaskedName, result.MaskedIDNo)
}
}
func TestCloudMarketProviderStartAcceptsNumericCode(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"message":"成功","result":{"res":1,"description":"一致"}}`))
})
defer closeServer()
result, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
if err != nil {
t.Fatalf("实名认证请求失败:%v", err)
}
if result.Status != StatusVerified {
t.Fatalf("状态不正确:%s", result.Status)
}
}
func TestCloudMarketProviderStartRejected(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"request_id":"TID456","status":"OK","result":"2"}`))
}))
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"res":"2","description":"不一致"}}`))
})
if err != nil {
t.Fatalf("创建 Provider 失败:%v", err)
}
defer closeServer()
result, err := provider.Start(t.Context(), StartRequest{
UserID: 1,
Name: "李四",
IDNo: "110101199001011234",
})
result, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "李四", IDNo: "110101199001011234"})
if err != nil {
t.Fatalf("实名认证请求失败:%v", err)
}
if result.Status != StatusRejected {
t.Fatalf("状态不正确:%s", result.Status)
}
if result.FailReason == "" {
t.Fatal("拒绝原因不能为空")
if result.FailReason != "不一致" {
t.Fatalf("拒绝原因不正确:%s", result.FailReason)
}
}
func TestCloudMarketProviderStartNoRecord(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"res":"3","description":""}}`))
})
defer closeServer()
result, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "王五", IDNo: "110101199001011234"})
if err != nil {
t.Fatalf("实名认证请求失败:%v", err)
}
if result.Status != StatusRejected {
t.Fatalf("状态不正确:%s", result.Status)
}
if result.FailReason != "公安库无匹配记录" {
t.Fatalf("拒绝原因不正确:%s", result.FailReason)
}
}
func TestCloudMarketProviderStartInvalidInputCode(t *testing.T) {
for _, code := range []string{"400", "20010", "20310"} {
t.Run(code, func(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"` + code + `","message":"请求姓名不标准:姓名为空或者包含特殊字符","result":{}}`))
})
defer closeServer()
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张", IDNo: "1101011990"})
if !errors.Is(err, ErrInvalidRealnameInput) {
t.Fatalf("期望入参错误,实际:%v", err)
}
})
}
}
func TestCloudMarketProviderStartConfigInvalidCode(t *testing.T) {
for _, code := range []string{"404", "604"} {
t.Run(code, func(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"` + code + `","message":"接口停用","result":{}}`))
})
defer closeServer()
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
if !errors.Is(err, ErrProviderConfigInvalid) {
t.Fatalf("期望配置错误,实际:%v", err)
}
})
}
}
func TestCloudMarketProviderStartUnavailableCode(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"501","message":"第三方服务异常","result":{}}`))
})
defer closeServer()
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
if !errors.Is(err, ErrProviderUnavailable) {
t.Fatalf("期望服务不可用错误,实际:%v", err)
}
}
func TestCloudMarketProviderStartRateLimited(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"request_id":"TID789","status":"OK","result":110}`))
}))
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
})
if err != nil {
t.Fatalf("创建 Provider 失败:%v", err)
}
defer closeServer()
_, err = provider.Start(t.Context(), StartRequest{
UserID: 1,
Name: "王五",
IDNo: "110101199001011234",
})
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "王五", IDNo: "110101199001011234"})
if !errors.Is(err, ErrProviderRateLimited) {
t.Fatalf("期望限流错误,实际:%v", err)
}
}
func TestCloudMarketProviderStartUnavailableOnInvalidResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"request_id":"TID999","status":"OK","result":"busy"}`))
}))
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
func TestCloudMarketProviderStartUnauthorized(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`InvalidAppCode`))
})
if err != nil {
t.Fatalf("创建 Provider 失败:%v", err)
defer closeServer()
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
if !errors.Is(err, ErrProviderConfigInvalid) {
t.Fatalf("期望配置错误,实际:%v", err)
}
}
_, err = provider.Start(t.Context(), StartRequest{
UserID: 1,
Name: "赵六",
IDNo: "110101199001011234",
func TestCloudMarketProviderStartUnavailableOnMissingRes(t *testing.T) {
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{}}`))
})
defer closeServer()
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "赵六", IDNo: "110101199001011234"})
if !errors.Is(err, ErrProviderUnavailable) {
t.Fatalf("期望服务不可用错误,实际:%v", err)
}