From 281bb4be596749ef3ba605e68abb864fa28820fb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 2 Oct 2026 12:03:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(realname):=20=E4=B8=9A=E5=8A=A1=20code=20?= =?UTF-8?q?=E4=BC=98=E5=85=88=E4=BA=8E=20HTTP=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E5=B9=B6=E5=8C=BA=E5=88=86=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E8=80=97=E5=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测服务商业务失败时 HTTP 状态码会跟着变(身份证号错误返回 400 且 result 为 null),按状态码判断会把第三方异常误判成入参错误。 改为以业务 code 为准,仅在拿不到 code 时退回状态码与网关错误头。 额度耗尽(x-ca-error-code=B403MQ)此前与限流同样提示「请求过于频繁, 请稍后再试」,但重试不会恢复,新增 ErrProviderQuotaExhausted 并提示 联系客服。网关错误原因只在响应头,一并带进错误信息便于排查。 Co-authored-by: Cursor --- .../modules/realname/cloudmarket_provider.go | 63 +++++++++++++------ .../realname/cloudmarket_provider_test.go | 49 ++++++++++++++- backend/internal/modules/realname/handler.go | 2 + backend/internal/modules/realname/service.go | 11 ++-- docs/实名认证.md | 26 +++++++- 5 files changed, 123 insertions(+), 28 deletions(-) diff --git a/backend/internal/modules/realname/cloudmarket_provider.go b/backend/internal/modules/realname/cloudmarket_provider.go index cec1d58..e7b5d29 100644 --- a/backend/internal/modules/realname/cloudmarket_provider.go +++ b/backend/internal/modules/realname/cloudmarket_provider.go @@ -108,8 +108,15 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov } defer resp.Body.Close() + // 网关层错误不会带业务 code,原因只在这两个响应头里。 + gatewayCode := strings.TrimSpace(resp.Header.Get("X-Ca-Error-Code")) + gatewayMessage := strings.TrimSpace(resp.Header.Get("X-Ca-Error-Message")) + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusForbidden { - return ProviderResult{}, ErrProviderRateLimited + if isQuotaExhausted(gatewayCode, gatewayMessage) { + return ProviderResult{}, fmt.Errorf("%w: cloud market %s: %s", ErrProviderQuotaExhausted, gatewayCode, gatewayMessage) + } + return ProviderResult{}, fmt.Errorf("%w: cloud market %s: %s", ErrProviderRateLimited, gatewayCode, gatewayMessage) } body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) @@ -119,16 +126,16 @@ 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) + payload = cloudMarketResponse{} } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, payload.Message) + + // 服务商失败时 HTTP 状态码也会跟着变(如身份证号错误返回 400), + // 但业务 code 才是权威结果,只有拿不到 code 时才退回按状态码判断。 + code := strings.TrimSpace(string(payload.Code)) + if code == "" { + return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, firstNonEmpty(gatewayMessage, truncateForLog(body))) } - // 先判断 code,code 非 0 表示调用失败,无需再看 result。 - if code := string(payload.Code); code != "0" { + if code != "0" { return ProviderResult{}, cloudMarketCodeError(code, payload.Message) } @@ -171,16 +178,36 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov return base, nil } -func cloudMarketHTTPError(statusCode int, message string) error { - message = strings.TrimSpace(message) - switch statusCode { - case http.StatusBadRequest: - 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, message) - default: - return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderUnavailable, statusCode, message) +// isQuotaExhausted 区分额度耗尽与调用频率过高:两者都是 HTTP 403,但额度耗尽靠重试不会恢复。 +func isQuotaExhausted(gatewayCode, gatewayMessage string) bool { + if strings.EqualFold(gatewayCode, "B403MQ") { + return true } + return strings.Contains(strings.ToLower(gatewayMessage), "quota exhausted") +} + +func cloudMarketHTTPError(statusCode int, detail string) error { + detail = strings.TrimSpace(detail) + switch { + case statusCode == http.StatusBadRequest: + return fmt.Errorf("%w: %s", ErrInvalidRealnameInput, firstNonEmpty(detail, "cloud market bad request")) + case statusCode == http.StatusUnauthorized, statusCode == http.StatusNotFound: + return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderConfigInvalid, statusCode, detail) + case statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices: + return fmt.Errorf("%w: cloud market response without code: %s", ErrProviderUnavailable, detail) + default: + return fmt.Errorf("%w: cloud market http status %d: %s", ErrProviderUnavailable, statusCode, detail) + } +} + +func truncateForLog(body []byte) string { + const limit = 200 + text := strings.TrimSpace(string(body)) + runes := []rune(text) + if len(runes) <= limit { + return text + } + return string(runes[:limit]) + "..." } // cloudMarketCodeError 按服务商错误码表映射,code 取值见 docs/实名认证.md。 diff --git a/backend/internal/modules/realname/cloudmarket_provider_test.go b/backend/internal/modules/realname/cloudmarket_provider_test.go index e7e019f..96f2c97 100644 --- a/backend/internal/modules/realname/cloudmarket_provider_test.go +++ b/backend/internal/modules/realname/cloudmarket_provider_test.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "hfb_sys/backend/pkg/crypto" @@ -116,7 +117,9 @@ 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":{}}`)) + // 服务商在业务失败时会同时返回非 200 状态码,result 也可能是 null。 + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"` + code + `","message":"身份证号错误","result":null}`)) }) defer closeServer() @@ -128,6 +131,23 @@ func TestCloudMarketProviderStartInvalidInputCode(t *testing.T) { } } +// 业务 code 的优先级高于 HTTP 状态码:400 状态码配 501 业务码应判为服务不可用。 +func TestCloudMarketProviderStartPrefersBusinessCodeOverHTTPStatus(t *testing.T) { + provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"501","message":"第三方服务异常","result":null}`)) + }) + defer closeServer() + + _, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"}) + if !errors.Is(err, ErrProviderUnavailable) { + t.Fatalf("期望服务不可用错误,实际:%v", err) + } + if errors.Is(err, ErrInvalidRealnameInput) { + t.Fatal("不应按 HTTP 400 判为入参错误") + } +} + func TestCloudMarketProviderStartConfigInvalidCode(t *testing.T) { for _, code := range []string{"404", "604"} { t.Run(code, func(t *testing.T) { @@ -158,6 +178,8 @@ func TestCloudMarketProviderStartUnavailableCode(t *testing.T) { func TestCloudMarketProviderStartRateLimited(t *testing.T) { provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Ca-Error-Code", "B403FQ") + w.Header().Set("X-Ca-Error-Message", "Throttled by Flow Control") w.WriteHeader(http.StatusForbidden) }) defer closeServer() @@ -168,10 +190,29 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) { } } +// 额度耗尽同样是 403,但重试不会恢复,必须和限流区分开。 +func TestCloudMarketProviderStartQuotaExhausted(t *testing.T) { + provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Ca-Error-Code", "B403MQ") + w.Header().Set("X-Ca-Error-Message", "Api Market Subscription quota exhausted") + w.WriteHeader(http.StatusForbidden) + }) + defer closeServer() + + _, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "王五", IDNo: "110101199001011234"}) + if !errors.Is(err, ErrProviderQuotaExhausted) { + t.Fatalf("期望额度耗尽错误,实际:%v", err) + } + if errors.Is(err, ErrProviderRateLimited) { + t.Fatal("额度耗尽不应判为限流") + } +} + func TestCloudMarketProviderStartUnauthorized(t *testing.T) { provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Ca-Error-Code", "A401AC") + w.Header().Set("X-Ca-Error-Message", "Invalid AppCode `not exists`") w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`InvalidAppCode`)) }) defer closeServer() @@ -179,6 +220,10 @@ func TestCloudMarketProviderStartUnauthorized(t *testing.T) { if !errors.Is(err, ErrProviderConfigInvalid) { t.Fatalf("期望配置错误,实际:%v", err) } + // 网关错误原因只在响应头里,必须带进错误信息,否则日志里只剩状态码。 + if !strings.Contains(err.Error(), "Invalid AppCode") { + t.Fatalf("错误信息缺少网关原因:%v", err) + } } func TestCloudMarketProviderStartUnavailableOnMissingRes(t *testing.T) { diff --git a/backend/internal/modules/realname/handler.go b/backend/internal/modules/realname/handler.go index 6241597..1a30c32 100644 --- a/backend/internal/modules/realname/handler.go +++ b/backend/internal/modules/realname/handler.go @@ -79,6 +79,8 @@ func writeRealnameError(c *gin.Context, err error) { response.ServiceUnavailable(c, "实名认证服务未配置") case errors.Is(err, ErrProviderRateLimited): response.Error(c, http.StatusTooManyRequests, "realname_rate_limited", "实名认证请求过于频繁,请稍后再试") + case errors.Is(err, ErrProviderQuotaExhausted): + response.Error(c, http.StatusServiceUnavailable, "realname_quota_exhausted", "实名认证服务额度已用尽,请联系客服处理") case errors.Is(err, ErrProviderUnavailable): response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后重试或联系客服处理") default: diff --git a/backend/internal/modules/realname/service.go b/backend/internal/modules/realname/service.go index ddcbeb1..763b76e 100644 --- a/backend/internal/modules/realname/service.go +++ b/backend/internal/modules/realname/service.go @@ -19,11 +19,12 @@ const ( ) 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") + 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") + ErrProviderQuotaExhausted = errors.New("realname provider quota exhausted") + ErrProviderUnavailable = errors.New("realname provider unavailable") ) type Service struct { diff --git a/docs/实名认证.md b/docs/实名认证.md index ab2ba3c..1c67774 100644 --- a/docs/实名认证.md +++ b/docs/实名认证.md @@ -74,7 +74,18 @@ Body 无参数。 } ``` -注意 `code` 是业务返回码,并非 HTTP 状态码。 +注意 `code` 是业务返回码,并非 HTTP 状态码。实测业务失败时 HTTP 状态码会跟着变(例如身份证号错误时 +返回 `400` 且 `"result": null`),因此解析必须以 `code` 为准,不能按 HTTP 状态码判断失败类型。 + +网关层错误(AppCode 无效、限流、额度耗尽)不会返回上面的 JSON,响应体为空,原因只在响应头: + +```text +x-ca-error-code: A401AC +x-ca-error-message: Invalid AppCode `not exists` +``` + +常见网关错误码:`A401AC`(AppCode 无效,HTTP 401)、`B403MQ`(套餐额度耗尽,HTTP 403)、 +`B403FQ`(调用频率过高,HTTP 403)。额度耗尽与限流都是 403,但额度耗尽靠重试不会恢复,必须续购。 ## 错误码 @@ -109,8 +120,17 @@ REALNAME_CLOUDMARKET_APPCODE=<阿里云市场 AppCode> | `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 | +| 403 且 `x-ca-error-code=B403MQ`(额度耗尽) | `ErrProviderQuotaExhausted`,接口返回 503「额度已用尽」 | +| 其他 403 / 429(网关限流) | `ErrProviderRateLimited`,接口返回 429 | +| HTTP 401 / 404 且无业务 code(AppCode 或路径错误) | `ErrProviderConfigInvalid`,接口返回 503 | + +业务 `code` 的优先级高于 HTTP 状态码,只有响应体里拿不到 `code` 时才按状态码与网关错误头判断。 + +## 更换 AppCode + +AppCode 与阿里云市场的订购关系绑定,**换商品必须换 AppCode**:旧商品的 AppCode 调用本接口会得到 +`401 Invalid AppCode 'not exists'`。在「阿里云市场 → 已购买的服务 → 对应商品」获取 AppCode 后, +只需更新服务器 `backend/.env` 的 `REALNAME_CLOUDMARKET_APPCODE` 并重启后端,无需重新发版。 该接口不返回服务商流水号,因此 `user_realname.provider_order_no` 对 cloudmarket 渠道为空。 姓名与身份证号由本项目自行脱敏入库,并用 `pkg/crypto` 加密保存完整值。