From 0b220829bbdde9d6ea6bb38d2dd2847a142e6a95 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 2 Oct 2026 11:53:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(realname):=20=E5=AE=9E=E5=90=8D=E6=A0=B8?= =?UTF-8?q?=E9=AA=8C=E5=88=87=E6=8D=A2=E5=88=B0=E6=95=B0=E8=84=89=E4=BA=8C?= =?UTF-8?q?=E8=A6=81=E7=B4=A0=E6=96=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务商旧接口 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 --- backend/.env.example | 2 +- backend/.env.prod.example | 2 +- backend/internal/config/config.go | 2 +- .../modules/realname/cloudmarket_provider.go | 125 ++++++----- .../realname/cloudmarket_provider_test.go | 203 +++++++++++------- docs/实名认证.md | 171 ++++++++------- 6 files changed, 305 insertions(+), 200 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index b68228a..6d6b394 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= # 支付商户、网关和回调等业务配置通过后台支付配置页维护。 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index ca46136..f36104f 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -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= # 支付商户、网关和回调等业务配置通过后台支付配置页维护。 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ba4fa25..f0490ba 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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{ diff --git a/backend/internal/modules/realname/cloudmarket_provider.go b/backend/internal/modules/realname/cloudmarket_provider.go index db65cdc..cec1d58 100644 --- a/backend/internal/modules/realname/cloudmarket_provider.go +++ b/backend/internal/modules/realname/cloudmarket_provider.go @@ -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 { diff --git a/backend/internal/modules/realname/cloudmarket_provider_test.go b/backend/internal/modules/realname/cloudmarket_provider_test.go index 30f78ec..e7e019f 100644 --- a/backend/internal/modules/realname/cloudmarket_provider_test.go +++ b/backend/internal/modules/realname/cloudmarket_provider_test.go @@ -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) } diff --git a/docs/实名认证.md b/docs/实名认证.md index d18d658..ab2ba3c 100644 --- a/docs/实名认证.md +++ b/docs/实名认证.md @@ -1,91 +1,116 @@ -接口信息 -调用地址: -https://sinocheck2.market.alicloudapi.com/fortest/ttttt +# 实名认证接口(身份证二要素核验) -请求方式: -POST -返回类型: -JSON -API 调用: -API 简单身份认证调用方法(APPCODE) -API 签名认证调用方法(AppKey & AppSecret) +服务商:阿里云市场 - 天眼数聚「身份证实名核验优质版」。 +传入姓名、身份证号,校验二要素是否一致,同时返回生日、性别、籍贯等信息。 -获取认证: -AppKey & AppCode -接口参数 -请求参数(Header) -请求参数(Query) -字段名称 -必填 -字段名称 必填 字段详情 -name string -Y -姓名 +> 旧接口 `https://sinocheck2.market.alicloudapi.com/fortest/ttttt` 已下线,参数名为 `id_number`、 +> 响应为顶层 `status`/`result` 的那一版不再使用,全部改走下面的新接口。 -id_number string -Y -身份证号 +## 接口信息 +| 项 | 值 | +| --- | --- | +| 调用地址 | `https://eid.shumaidata.com/eid/check` | +| 请求方式 | POST | +| 返回类型 | JSON | +| 鉴权方式 | 请求头 `Authorization: APPCODE `(阿里云市场简单身份认证) | -成功响应 +## 请求参数(Query) + +| 字段名称 | 类型 | 必填 | 字段详情 | +| --- | --- | --- | --- | +| name | string | Y | 姓名 | +| idcard | string | Y | 身份证号 | + +Body 无参数。 + +## 成功响应 + +解析结果时,先判断 `code`:`code=0` 时再判断 `result.res`;`code!=0` 表示调用已失败,无需再继续。 + +```json { - "request_id": "TIDxxxxxxx", //请求ID,联系提供给客服,有助于问题排查 - "status": "OK", //状态参数,OK为完成查询请求。 - "result": 1 //身份核验结果,"result": 1(验证一致);2(不一致); 3 (库无);110 请求次数过多:同一身份证号一天内最多请求【10】次; + "code": "0", + "message": "成功", + "result": { + "name": "冯**", + "idcard": "350*********9422", + "res": "1", + "description": "一致", + "sex": "男", + "birthday": "19*****0", + "address": "江西省****区" + } } -温馨提示: -1.当 "status": "OK"时,仅代表接口请求成功,"result"的返回值为身份核验结果 +``` -2.当 "result": 3 ,核验结果为'库无',有以下几种原因 - (1)现役军人、武警官兵、特殊部门人员及特殊级别官员; - (2)退役不到2年的军人和士兵(根据军衔、兵种不同,时间会有所不同,一般为2年); - (3)户口迁出,且没有在新的迁入地迁入; - (4)户口迁入新迁入地,当地公安系统未将迁移信息上报到公安部(上报时间地域不同而有所差异); - (5)更改姓名,当地公安系统未将更改信息上报到公安部(上报时间因地域不同而有所差异); - (6)移民; - (7)未更换二代身份证; - (8)死亡。 - (9)身份证号确实不存在 +`result.res` 核验结果状态码: +| res | 含义 | +| --- | --- | +| 1 | 一致 | +| 2 | 不一致 | +| 3 | 无记录(预留) | -失败响应 +`res=3`(库无)的常见原因: +1. 现役军人、武警官兵、特殊部门人员及特殊级别官员; +2. 退役不到 2 年的军人和士兵(根据军衔、兵种不同,一般为 2 年); +3. 户口迁出,且没有在新的迁入地迁入; +4. 户口迁入新迁入地,当地公安系统未将迁移信息上报到公安部; +5. 更改姓名,当地公安系统未将更改信息上报到公安部; +6. 移民; +7. 未更换二代身份证; +8. 死亡; +9. 身份证号确实不存在。 + +## 失败响应 + +```json { - "status": "INVALID_ARGUMENT", - "reason": "Invalid id_number", - "request_id": "TID2af751b4bff24be781d60af10bf84101" + "code": "20310", + "message": "请求姓名不标准:姓名为空或者包含特殊字符", + "result": {} } +``` +注意 `code` 是业务返回码,并非 HTTP 状态码。 -==== -错误码 -错误码 -错误信息 -描述 -200 -请求次数过多:同一身份证号一天内最多请求【4】次 -请求次数过多:同一身份证号一天内最多请求【10】次 -400 -请求参数错误,原因:未输入姓名 -请求参数错误,原因:未输入姓名 -401 -账号或密钥错误 -账号或密钥错误 -403 -调用频率过高 -调用频率过高 -404 -请求路径错误 -请求路径错误 -500 -数据服务异常 -数据服务异常 -400 -参数非UTF-8编码 -参数非UTF-8编码 -400 -请求参数错误,原因:姓名为空 -请求参数错误,原因:姓名为空 +## 错误码 +| 错误码 | 错误信息 | 描述 | +| --- | --- | --- | +| 0 | 成功 | 成功 | +| 400 | 参数错误 | 参数错误 | +| 20010 | 身份证号为空或非法 | 身份证号为空或非法 | +| 20310 | 姓名为空或非法 | 姓名为空或非法 | +| 404 | 请求资源不存在 | 请求资源不存在 | +| 500 | 系统内部错误,请联系服务商 | 系统内部错误,请联系服务商 | +| 501 | 第三方服务异常 | 第三方服务异常 | +| 604 | 接口停用 | 接口停用 | +| 1001 | 其他,以实际返回为准 | 其他,以实际返回为准 | +## 本项目的接入 +实现位于 `backend/internal/modules/realname/cloudmarket_provider.go`,配置项: + +``` +REALNAME_PROVIDER=cloudmarket +REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check +REALNAME_CLOUDMARKET_APPCODE=<阿里云市场 AppCode> +``` + +响应与内部状态、错误的映射关系: + +| 服务商返回 | 内部结果 | +| --- | --- | +| `code=0` 且 `res=1` | 认证通过(`verified`),记录通过时间 | +| `code=0` 且 `res=2` 或 `3` | 认证驳回(`rejected`),失败原因取 `description` | +| `code` 为 400 / 20010 / 20310 | `ErrInvalidRealnameInput`,接口返回 400 | +| `code` 为 404 / 604 | `ErrProviderConfigInvalid`,接口返回 503 | +| `code` 为 500 / 501 / 1001 等其他值 | `ErrProviderUnavailable`,接口返回 502 | +| HTTP 403 / 429(网关限流) | `ErrProviderRateLimited`,接口返回 429 | +| HTTP 401 / 404(AppCode 或路径错误) | `ErrProviderConfigInvalid`,接口返回 503 | + +该接口不返回服务商流水号,因此 `user_realname.provider_order_no` 对 cloudmarket 渠道为空。 +姓名与身份证号由本项目自行脱敏入库,并用 `pkg/crypto` 加密保存完整值。