实测服务商业务失败时 HTTP 状态码会跟着变(身份证号错误返回 400 且 result 为 null),按状态码判断会把第三方异常误判成入参错误。 改为以业务 code 为准,仅在拿不到 code 时退回状态码与网关错误头。 额度耗尽(x-ca-error-code=B403MQ)此前与限流同样提示「请求过于频繁, 请稍后再试」,但重试不会恢复,新增 ErrProviderQuotaExhausted 并提示 联系客服。网关错误原因只在响应头,一并带进错误信息便于排查。 Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
7.7 KiB
Go
249 lines
7.7 KiB
Go
package realname
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/pkg/crypto"
|
|
)
|
|
|
|
const cloudMarketProviderName = "aliyun_cloudmarket"
|
|
|
|
type CloudMarketConfig struct {
|
|
URL string
|
|
AppCode string
|
|
Encryptor crypto.Encryptor
|
|
}
|
|
|
|
type CloudMarketProvider struct {
|
|
url string
|
|
appCode string
|
|
client *http.Client
|
|
encryptor crypto.Encryptor
|
|
}
|
|
|
|
type cloudMarketResponse struct {
|
|
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) {
|
|
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)
|
|
}
|
|
encryptor := cfg.Encryptor
|
|
if encryptor == nil {
|
|
encryptor = &crypto.MockEncryptor{}
|
|
}
|
|
return &CloudMarketProvider{
|
|
url: endpoint,
|
|
appCode: appCode,
|
|
client: &http.Client{Timeout: 10 * time.Second},
|
|
encryptor: encryptor,
|
|
}, 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("idcard", 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("%w: realname cloud market request failed: %v", ErrProviderUnavailable, err)
|
|
}
|
|
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 {
|
|
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))
|
|
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 {
|
|
payload = cloudMarketResponse{}
|
|
}
|
|
|
|
// 服务商失败时 HTTP 状态码也会跟着变(如身份证号错误返回 400),
|
|
// 但业务 code 才是权威结果,只有拿不到 code 时才退回按状态码判断。
|
|
code := strings.TrimSpace(string(payload.Code))
|
|
if code == "" {
|
|
return ProviderResult{}, cloudMarketHTTPError(resp.StatusCode, firstNonEmpty(gatewayMessage, truncateForLog(body)))
|
|
}
|
|
if code != "0" {
|
|
return ProviderResult{}, cloudMarketCodeError(code, payload.Message)
|
|
}
|
|
|
|
result, err := parseCloudMarketResult(payload.Result)
|
|
if err != nil {
|
|
return ProviderResult{}, err
|
|
}
|
|
|
|
// 加密完整信息
|
|
encryptedName, err := p.encryptor.Encrypt(req.Name)
|
|
if err != nil {
|
|
return ProviderResult{}, err
|
|
}
|
|
encryptedIDNo, err := p.encryptor.Encrypt(req.IDNo)
|
|
if err != nil {
|
|
return ProviderResult{}, err
|
|
}
|
|
|
|
now := time.Now()
|
|
base := ProviderResult{
|
|
MaskedName: maskName(req.Name),
|
|
EncryptedName: encryptedName,
|
|
MaskedIDNo: maskIDNo(req.IDNo),
|
|
EncryptedIDNo: encryptedIDNo,
|
|
}
|
|
switch string(result.Res) {
|
|
case "1":
|
|
base.Status = StatusVerified
|
|
base.VerifiedAt = &now
|
|
case "2":
|
|
base.Status = StatusRejected
|
|
base.FailReason = firstNonEmpty(result.Description, "姓名与身份证号不一致")
|
|
case "3":
|
|
base.Status = StatusRejected
|
|
base.FailReason = firstNonEmpty(result.Description, "公安库无匹配记录")
|
|
default:
|
|
base.Status = StatusRejected
|
|
base.FailReason = firstNonEmpty(result.Description, fmt.Sprintf("未知核验结果:%s", result.Res))
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// 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。
|
|
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 cloudMarketCheckResult{}, fmt.Errorf("%w: realname cloud market result missing", ErrProviderUnavailable)
|
|
}
|
|
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))
|
|
}
|
|
if strings.TrimSpace(string(result.Res)) == "" {
|
|
return cloudMarketCheckResult{}, fmt.Errorf("%w: realname cloud market res missing: %s", ErrProviderUnavailable, string(raw))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|