diff --git a/backend/.env.example b/backend/.env.example index 5991729..a5127d4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,3 +24,7 @@ ALIYUN_ACCESS_KEY_SECRET= ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_LOGIN_TEMPLATE_CODE= + +REALNAME_PROVIDER=mock +REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt +REALNAME_CLOUDMARKET_APPCODE= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5fe8086..c113ccb 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { JWTSecret string Storage StorageConfig SMS SMSConfig + Realname RealnameConfig Log LogConfig } @@ -34,6 +35,12 @@ type SMSConfig struct { AliyunLoginTemplateCode string } +type RealnameConfig struct { + Provider string + CloudMarketURL string + CloudMarketAppCode string +} + type LogConfig struct { Level string Dir string @@ -64,6 +71,11 @@ func Load() Config { AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""), AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""), }, + Realname: RealnameConfig{ + Provider: getEnv("REALNAME_PROVIDER", "mock"), + CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"), + CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""), + }, Log: LogConfig{ Level: getEnv("LOG_LEVEL", "info"), Dir: getEnv("LOG_DIR", "logs"), diff --git a/backend/internal/modules/realname/cloudmarket_provider.go b/backend/internal/modules/realname/cloudmarket_provider.go new file mode 100644 index 0000000..6d565b0 --- /dev/null +++ b/backend/internal/modules/realname/cloudmarket_provider.go @@ -0,0 +1,177 @@ +package realname + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const cloudMarketProviderName = "aliyun_cloudmarket" + +type CloudMarketConfig struct { + URL string + AppCode string +} + +type CloudMarketProvider struct { + url string + appCode string + client *http.Client +} + +type cloudMarketResponse struct { + RequestID string `json:"request_id"` + Status string `json:"status"` + Result json.RawMessage `json:"result"` + Reason string `json:"reason"` +} + +func NewCloudMarketProvider(cfg CloudMarketConfig) (*CloudMarketProvider, error) { + endpoint := strings.TrimSpace(cfg.URL) + appCode := strings.TrimSpace(cfg.AppCode) + if endpoint == "" || appCode == "" { + return nil, ErrProviderConfigInvalid + } + if _, err := url.ParseRequestURI(endpoint); err != nil { + return nil, fmt.Errorf("realname cloud market url invalid: %w", err) + } + return &CloudMarketProvider{ + url: endpoint, + appCode: appCode, + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (p *CloudMarketProvider) Name() string { + return cloudMarketProviderName +} + +func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (ProviderResult, error) { + endpoint, err := url.Parse(p.url) + if err != nil { + return ProviderResult{}, err + } + query := endpoint.Query() + query.Set("name", strings.TrimSpace(req.Name)) + query.Set("id_number", strings.TrimSpace(req.IDNo)) + endpoint.RawQuery = query.Encode() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) + if err != nil { + return ProviderResult{}, err + } + httpReq.Header.Set("Authorization", "APPCODE "+p.appCode) + httpReq.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(httpReq) + if err != nil { + return ProviderResult{}, fmt.Errorf("realname cloud market request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusForbidden { + return ProviderResult{}, ErrProviderRateLimited + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return ProviderResult{}, fmt.Errorf("%w: read cloud market response: %v", ErrProviderUnavailable, err) + } + + var payload cloudMarketResponse + if err := json.Unmarshal(body, &payload); err != nil { + 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) + } + 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 + } + + result, err := parseCloudMarketResult(payload.Result) + if err != nil { + return ProviderResult{}, err + } + + now := time.Now() + base := ProviderResult{ + ProviderOrderNo: payload.RequestID, + MaskedName: maskName(req.Name), + MaskedIDNo: maskIDNo(req.IDNo), + } + switch result { + case 1: + base.Status = StatusVerified + base.VerifiedAt = &now + case 2: + base.Status = StatusRejected + base.FailReason = "姓名与身份证号不一致" + case 3: + base.Status = StatusRejected + base.FailReason = "公安库无匹配记录" + case 110: + return ProviderResult{}, ErrProviderRateLimited + default: + base.Status = StatusRejected + base.FailReason = fmt.Sprintf("未知核验结果:%d", result) + } + return base, nil +} + +func cloudMarketHTTPError(statusCode int, reason string) error { + reason = strings.TrimSpace(reason) + switch statusCode { + case http.StatusBadRequest: + return fmt.Errorf("%w: %s", ErrInvalidRealnameInput, firstNonEmpty(reason, "cloud market bad request")) + case http.StatusUnauthorized, http.StatusNotFound: + return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderConfigInvalid, statusCode, reason) + default: + return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderUnavailable, statusCode, reason) + } +} + +func parseCloudMarketResult(raw json.RawMessage) (int, error) { + if len(raw) == 0 || string(raw) == "null" { + return 0, fmt.Errorf("realname cloud market result missing") + } + var number int + if err := json.Unmarshal(raw, &number); err == nil { + return number, nil + } + 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 + } + } + return 0, fmt.Errorf("realname cloud market result invalid: %s", string(raw)) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/modules/realname/cloudmarket_provider_test.go b/backend/internal/modules/realname/cloudmarket_provider_test.go new file mode 100644 index 0000000..1f862c2 --- /dev/null +++ b/backend/internal/modules/realname/cloudmarket_provider_test.go @@ -0,0 +1,107 @@ +package realname + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +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() + + provider, err := NewCloudMarketProvider(CloudMarketConfig{ + URL: server.URL, + AppCode: "test-code", + }) + if err != nil { + t.Fatalf("创建 Provider 失败:%v", err) + } + + 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) + } + if result.ProviderOrderNo != "TID123" { + t.Fatalf("请求流水不正确:%s", result.ProviderOrderNo) + } + if result.VerifiedAt == nil { + t.Fatal("通过时间不能为空") + } +} + +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", + }) + if err != nil { + t.Fatalf("创建 Provider 失败:%v", err) + } + + 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("拒绝原因不能为空") + } +} + +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", + }) + if err != nil { + t.Fatalf("创建 Provider 失败:%v", err) + } + + _, err = provider.Start(t.Context(), StartRequest{ + UserID: 1, + Name: "王五", + IDNo: "110101199001011234", + }) + if !errors.Is(err, ErrProviderRateLimited) { + t.Fatalf("期望限流错误,实际:%v", err) + } +} diff --git a/backend/internal/modules/realname/handler.go b/backend/internal/modules/realname/handler.go index ee9a00c..5030f7b 100644 --- a/backend/internal/modules/realname/handler.go +++ b/backend/internal/modules/realname/handler.go @@ -74,6 +74,12 @@ func writeRealnameError(c *gin.Context, err error) { response.ServiceUnavailable(c, "数据库未连接") case errors.Is(err, ErrInvalidRealnameInput): response.BadRequest(c, "姓名或身份证号格式不正确") + case errors.Is(err, ErrProviderConfigInvalid): + response.ServiceUnavailable(c, "实名认证服务未配置") + case errors.Is(err, ErrProviderRateLimited): + response.Error(c, http.StatusTooManyRequests, "realname_rate_limited", "实名认证请求过于频繁,请稍后再试") + case errors.Is(err, ErrProviderUnavailable): + response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后再试") default: response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用") } diff --git a/backend/internal/modules/realname/provider.go b/backend/internal/modules/realname/provider.go index 470c89d..88f0bde 100644 --- a/backend/internal/modules/realname/provider.go +++ b/backend/internal/modules/realname/provider.go @@ -10,6 +10,7 @@ import ( ) type Provider interface { + Name() string Start(ctx context.Context, req StartRequest) (ProviderResult, error) } @@ -34,6 +35,10 @@ func NewMockProvider() *MockProvider { return &MockProvider{} } +func (p *MockProvider) Name() string { + return "mock" +} + func (p *MockProvider) Start(_ context.Context, req StartRequest) (ProviderResult, error) { if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.IDNo) == "" { return ProviderResult{}, errors.New("name and id no are required") diff --git a/backend/internal/modules/realname/repository.go b/backend/internal/modules/realname/repository.go index 3222a0a..54c4da8 100644 --- a/backend/internal/modules/realname/repository.go +++ b/backend/internal/modules/realname/repository.go @@ -54,7 +54,7 @@ func (r *Repository) SaveResult(userID uint64, provider string, result ProviderR return err } - userStatus := "realname_failed" + userStatus := StatusRejected if result.Status == StatusVerified { userStatus = "verified" } diff --git a/backend/internal/modules/realname/service.go b/backend/internal/modules/realname/service.go index d2e300d..4d4799d 100644 --- a/backend/internal/modules/realname/service.go +++ b/backend/internal/modules/realname/service.go @@ -7,22 +7,28 @@ import ( "strings" "hfb_sys/backend/internal/model" + + "go.uber.org/zap" ) const ( StatusPending = "pending" StatusVerified = "verified" - StatusFailed = "failed" + StatusRejected = "rejected" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidRealnameInput = errors.New("invalid realname input") + ErrProviderConfigInvalid = errors.New("realname provider config invalid") + ErrProviderRateLimited = errors.New("realname provider rate limited") + ErrProviderUnavailable = errors.New("realname provider unavailable") ) type Service struct { repo *Repository provider Provider + log *zap.Logger } type PublicStatus struct { @@ -35,8 +41,8 @@ type PublicStatus struct { FailReason string `json:"fail_reason,omitempty"` } -func NewService(repo *Repository, provider Provider) *Service { - return &Service{repo: repo, provider: provider} +func NewService(repo *Repository, provider Provider, log *zap.Logger) *Service { + return &Service{repo: repo, provider: provider, log: log} } func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo string) (*model.UserRealname, error) { @@ -54,9 +60,18 @@ func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo st IDNo: idNo, }) if err != nil { + if s.log != nil { + s.log.Warn( + "realname provider verify failed", + zap.String("provider", s.provider.Name()), + zap.Uint64("user_id", userID), + zap.String("masked_id_no", maskIDNo(idNo)), + zap.Error(err), + ) + } return nil, err } - return s.repo.SaveResult(userID, "mock", result) + return s.repo.SaveResult(userID, s.provider.Name(), result) } func (s *Service) Status(userID uint64) (PublicStatus, error) { diff --git a/backend/internal/modules/realname/unavailable_provider.go b/backend/internal/modules/realname/unavailable_provider.go new file mode 100644 index 0000000..277d2b1 --- /dev/null +++ b/backend/internal/modules/realname/unavailable_provider.go @@ -0,0 +1,25 @@ +package realname + +import "context" + +type UnavailableProvider struct { + err error +} + +func NewUnavailableProvider(err error) *UnavailableProvider { + return &UnavailableProvider{err: err} +} + +func (p *UnavailableProvider) Name() string { + return "unavailable" +} + +func (p *UnavailableProvider) Start(ctx context.Context, req StartRequest) (ProviderResult, error) { + if err := ctx.Err(); err != nil { + return ProviderResult{}, err + } + if p.err != nil { + return ProviderResult{}, p.err + } + return ProviderResult{}, ErrProviderConfigInvalid +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index addf705..b8f3742 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -79,7 +79,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { realnameRepo = realname.NewRepository(deps.DB) } - realnameService := realname.NewService(realnameRepo, realname.NewMockProvider()) + realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, logger), logger) realnameHandler := realname.NewHandler(realnameService) var listingRepo *listing.Repository if deps.DB != nil { @@ -360,3 +360,20 @@ func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provid return smsintegration.NewMockProvider(logger) } } + +func newRealnameProvider(cfg config.Config, logger *zap.Logger) realname.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { + case "cloudmarket", "aliyun_cloudmarket": + provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ + URL: cfg.Realname.CloudMarketURL, + AppCode: cfg.Realname.CloudMarketAppCode, + }) + if err != nil { + logger.Warn("cloud market realname provider unavailable; realname verify will fail", zap.Error(err)) + return realname.NewUnavailableProvider(err) + } + return provider + default: + return realname.NewMockProvider() + } +} diff --git a/docs/实名认证.md b/docs/实名认证.md new file mode 100644 index 0000000..d18d658 --- /dev/null +++ b/docs/实名认证.md @@ -0,0 +1,91 @@ +接口信息 +调用地址: +https://sinocheck2.market.alicloudapi.com/fortest/ttttt + +请求方式: +POST +返回类型: +JSON +API 调用: +API 简单身份认证调用方法(APPCODE) +API 签名认证调用方法(AppKey & AppSecret) + +获取认证: +AppKey & AppCode +接口参数 +请求参数(Header) +请求参数(Query) +字段名称 +必填 +字段名称 必填 字段详情 +name string +Y +姓名 + +id_number string +Y +身份证号 + + +成功响应 +{ + "request_id": "TIDxxxxxxx", //请求ID,联系提供给客服,有助于问题排查 + "status": "OK", //状态参数,OK为完成查询请求。 + "result": 1 //身份核验结果,"result": 1(验证一致);2(不一致); 3 (库无);110 请求次数过多:同一身份证号一天内最多请求【10】次; +} +温馨提示: +1.当 "status": "OK"时,仅代表接口请求成功,"result"的返回值为身份核验结果 + +2.当 "result": 3 ,核验结果为'库无',有以下几种原因 + (1)现役军人、武警官兵、特殊部门人员及特殊级别官员; + (2)退役不到2年的军人和士兵(根据军衔、兵种不同,时间会有所不同,一般为2年); + (3)户口迁出,且没有在新的迁入地迁入; + (4)户口迁入新迁入地,当地公安系统未将迁移信息上报到公安部(上报时间地域不同而有所差异); + (5)更改姓名,当地公安系统未将更改信息上报到公安部(上报时间因地域不同而有所差异); + (6)移民; + (7)未更换二代身份证; + (8)死亡。 + (9)身份证号确实不存在 + + +失败响应 + +{ + "status": "INVALID_ARGUMENT", + "reason": "Invalid id_number", + "request_id": "TID2af751b4bff24be781d60af10bf84101" +} + + +==== +错误码 +错误码 +错误信息 +描述 +200 +请求次数过多:同一身份证号一天内最多请求【4】次 +请求次数过多:同一身份证号一天内最多请求【10】次 +400 +请求参数错误,原因:未输入姓名 +请求参数错误,原因:未输入姓名 +401 +账号或密钥错误 +账号或密钥错误 +403 +调用频率过高 +调用频率过高 +404 +请求路径错误 +请求路径错误 +500 +数据服务异常 +数据服务异常 +400 +参数非UTF-8编码 +参数非UTF-8编码 +400 +请求参数错误,原因:姓名为空 +请求参数错误,原因:姓名为空 + + +