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:
@@ -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=
|
||||
|
||||
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
||||
|
||||
@@ -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=
|
||||
|
||||
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+98
-73
@@ -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 <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` 加密保存完整值。
|
||||
|
||||
Reference in New Issue
Block a user