实名认证对接--ok

This commit is contained in:
yml2213
2026-05-29 14:00:52 +08:00
parent f89e694fd0
commit f386fcc552
11 changed files with 465 additions and 6 deletions
+12
View File
@@ -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"),
@@ -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 ""
}
@@ -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)
}
}
@@ -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", "实名认证服务暂时不可用")
}
@@ -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")
@@ -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"
}
+19 -4
View File
@@ -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) {
@@ -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
}
+18 -1
View File
@@ -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()
}
}