fix(realname): 业务 code 优先于 HTTP 状态码,并区分额度耗尽
实测服务商业务失败时 HTTP 状态码会跟着变(身份证号错误返回 400 且 result 为 null),按状态码判断会把第三方异常误判成入参错误。 改为以业务 code 为准,仅在拿不到 code 时退回状态码与网关错误头。 额度耗尽(x-ca-error-code=B403MQ)此前与限流同样提示「请求过于频繁, 请稍后再试」,但重试不会恢复,新增 ErrProviderQuotaExhausted 并提示 联系客服。网关错误原因只在响应头,一并带进错误信息便于排查。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+23
-3
@@ -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` 加密保存完整值。
|
||||
|
||||
Reference in New Issue
Block a user