chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Realname Module
|
||||
|
||||
实名认证状态流转、第三方实名服务适配器、发布前实名拦截。
|
||||
@@ -0,0 +1,248 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
)
|
||||
|
||||
func newTestCloudMarketProvider(t *testing.T, handler http.HandlerFunc) (*CloudMarketProvider, func()) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
||||
URL: server.URL,
|
||||
AppCode: "test-code",
|
||||
Encryptor: &crypto.MockEncryptor{},
|
||||
})
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatalf("创建 Provider 失败:%v", err)
|
||||
}
|
||||
return provider, server.Close
|
||||
}
|
||||
|
||||
func TestCloudMarketProviderStartVerified(t *testing.T) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("请求方法不正确:%s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "APPCODE test-code" {
|
||||
t.Errorf("APPCODE 请求头不正确:%s", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.URL.Query().Get("name") != "张三" {
|
||||
t.Errorf("姓名参数不正确:%s", r.URL.Query().Get("name"))
|
||||
}
|
||||
if r.URL.Query().Get("idcard") != "110101199001011234" {
|
||||
t.Errorf("身份证参数不正确:%s", r.URL.Query().Get("idcard"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"name":"张*","idcard":"1101********1234","res":"1","description":"一致","sex":"男","birthday":"19900101","address":"北京市****区"}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
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.VerifiedAt == nil {
|
||||
t.Fatal("通过时间不能为空")
|
||||
}
|
||||
if result.MaskedName == "" || result.MaskedIDNo == "" {
|
||||
t.Fatalf("脱敏信息不能为空:%s %s", result.MaskedName, result.MaskedIDNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMarketProviderStartAcceptsNumericCode(t *testing.T) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"成功","result":{"res":1,"description":"一致"}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMarketProviderStartRejected(t *testing.T) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"res":"2","description":"不一致"}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
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.Fatalf("拒绝原因不正确:%s", result.FailReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMarketProviderStartNoRecord(t *testing.T) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{"res":"3","description":""}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
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.Fatalf("拒绝原因不正确:%s", result.FailReason)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 服务商在业务失败时会同时返回非 200 状态码,result 也可能是 null。
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":"` + code + `","message":"身份证号错误","result":null}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张", IDNo: "1101011990"})
|
||||
if !errors.Is(err, ErrInvalidRealnameInput) {
|
||||
t.Fatalf("期望入参错误,实际:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 业务 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) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"` + code + `","message":"接口停用","result":{}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
|
||||
if !errors.Is(err, ErrProviderConfigInvalid) {
|
||||
t.Fatalf("期望配置错误,实际:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudMarketProviderStartUnavailableCode(t *testing.T) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"501","message":"第三方服务异常","result":{}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
|
||||
if !errors.Is(err, ErrProviderUnavailable) {
|
||||
t.Fatalf("期望服务不可用错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "王五", IDNo: "110101199001011234"})
|
||||
if !errors.Is(err, ErrProviderRateLimited) {
|
||||
t.Fatalf("期望限流错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 额度耗尽同样是 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)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "张三", IDNo: "110101199001011234"})
|
||||
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) {
|
||||
provider, closeServer := newTestCloudMarketProvider(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"成功","result":{}}`))
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
_, err := provider.Start(t.Context(), StartRequest{UserID: 1, Name: "赵六", IDNo: "110101199001011234"})
|
||||
if !errors.Is(err, ErrProviderUnavailable) {
|
||||
t.Fatalf("期望服务不可用错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
type StartRealnameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
IDNo string `json:"id_no" binding:"required"`
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Start(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
|
||||
var req StartRealnameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "姓名和身份证号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.service.Start(c.Request.Context(), userID, req.Name, req.IDNo)
|
||||
if err != nil {
|
||||
writeRealnameError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, ToPublicStatus(record))
|
||||
}
|
||||
|
||||
func (h *Handler) Status(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.service.Status(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
writeRealnameError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, status)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func writeRealnameError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidRealnameInput):
|
||||
response.BadRequest(c, "姓名或身份证号格式不正确")
|
||||
case errors.Is(err, ErrAlreadyVerified):
|
||||
response.Error(c, http.StatusConflict, "realname_already_verified", "当前账号已完成实名认证,无需重复认证")
|
||||
case errors.Is(err, ErrIDNoAlreadyUsed):
|
||||
response.Error(c, http.StatusConflict, "realname_id_no_used", "该身份证号已绑定其他实名账号,不能重复认证")
|
||||
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, ErrProviderQuotaExhausted):
|
||||
response.Error(c, http.StatusServiceUnavailable, "realname_quota_exhausted", "实名认证服务额度已用尽,请联系客服处理")
|
||||
case errors.Is(err, ErrProviderUnavailable):
|
||||
response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后重试或联系客服处理")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用,请稍后重试或联系客服处理")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Start(ctx context.Context, req StartRequest) (ProviderResult, error)
|
||||
}
|
||||
|
||||
type StartRequest struct {
|
||||
UserID uint64
|
||||
Name string
|
||||
IDNo string
|
||||
}
|
||||
|
||||
type ProviderResult struct {
|
||||
ProviderOrderNo string
|
||||
Status string
|
||||
MaskedName string
|
||||
EncryptedName string // 加密的完整姓名
|
||||
MaskedIDNo string
|
||||
EncryptedIDNo string // 加密的完整身份证号
|
||||
VerifiedAt *time.Time
|
||||
FailReason string
|
||||
}
|
||||
|
||||
type MockProvider struct {
|
||||
encryptor crypto.Encryptor
|
||||
}
|
||||
|
||||
func NewMockProvider(encryptor crypto.Encryptor) *MockProvider {
|
||||
if encryptor == nil {
|
||||
encryptor = &crypto.MockEncryptor{}
|
||||
}
|
||||
return &MockProvider{encryptor: encryptor}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
orderNo, err := randomOrderNo()
|
||||
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()
|
||||
return ProviderResult{
|
||||
ProviderOrderNo: orderNo,
|
||||
Status: StatusVerified,
|
||||
MaskedName: maskName(req.Name),
|
||||
EncryptedName: encryptedName,
|
||||
MaskedIDNo: maskIDNo(req.IDNo),
|
||||
EncryptedIDNo: encryptedIDNo,
|
||||
VerifiedAt: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomOrderNo() (string, error) {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "mock_rn_" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func maskName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if len([]rune(name)) <= 1 {
|
||||
return name
|
||||
}
|
||||
runes := []rune(name)
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
|
||||
}
|
||||
|
||||
func maskIDNo(idNo string) string {
|
||||
idNo = strings.TrimSpace(idNo)
|
||||
if len(idNo) <= 8 {
|
||||
return idNo
|
||||
}
|
||||
return idNo[:4] + strings.Repeat("*", len(idNo)-8) + idNo[len(idNo)-4:]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/invitation"
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const idNoFingerprintDomain = "realname_id"
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
encryptor crypto.Encryptor
|
||||
idHashKey string
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB, encryptor crypto.Encryptor, idHashKey string) *Repository {
|
||||
if encryptor == nil {
|
||||
encryptor = &crypto.MockEncryptor{}
|
||||
}
|
||||
return &Repository{db: db, encryptor: encryptor, idHashKey: strings.TrimSpace(idHashKey)}
|
||||
}
|
||||
|
||||
func (r *Repository) FindByUserID(ctx context.Context, userID uint64) (*model.UserRealname, error) {
|
||||
var record model.UserRealname
|
||||
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// IsVerifiedUser 判断当前账号是否已经完成实名认证。
|
||||
func (r *Repository) IsVerifiedUser(ctx context.Context, userID uint64) (bool, error) {
|
||||
var userStatus string
|
||||
if err := r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("id = ?", userID).Pluck("realname_status", &userStatus).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if userStatus == "verified" {
|
||||
return true, nil
|
||||
}
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.UserRealname{}).
|
||||
Where("user_id = ? AND status = ?", userID, StatusVerified).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ExistsVerifiedIdentity 判断身份证是否已绑定到其他已实名账号。
|
||||
// 新记录直接查 HMAC 指纹;存量记录没有指纹时按脱敏号取候选并解密精确比对,
|
||||
// 命中后顺手补写指纹,避免每次重复解密。
|
||||
func (r *Repository) ExistsVerifiedIdentity(ctx context.Context, userID uint64, idNo string) (bool, error) {
|
||||
idNo = normalizeIDNo(idNo)
|
||||
if idNo == "" {
|
||||
return false, nil
|
||||
}
|
||||
idNoHash := FingerprintIDNo(r.idHashKey, idNo)
|
||||
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.UserRealname{}).
|
||||
Where("status = ? AND id_no_hash = ? AND user_id <> ?", StatusVerified, idNoHash, userID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
masked := maskIDNo(idNo)
|
||||
var candidates []model.UserRealname
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("status = ? AND user_id <> ? AND masked_id_no = ?", StatusVerified, userID, masked).
|
||||
Find(&candidates).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
plain, err := r.encryptor.Decrypt(candidate.EncryptedIDNo)
|
||||
if err != nil {
|
||||
logging.FromContext(ctx).Warn("实名存量身份证解密失败,无法完成唯一性比对",
|
||||
zap.String("module", "realname"),
|
||||
zap.Uint64("realname_id", candidate.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if normalizeIDNo(plain) != idNo {
|
||||
continue
|
||||
}
|
||||
if candidate.IDNoHash == nil || *candidate.IDNoHash != idNoHash {
|
||||
r.backfillIDNoHash(ctx, candidate.ID, idNoHash)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Repository) backfillIDNoHash(ctx context.Context, recordID uint64, idNoHash string) {
|
||||
err := r.db.WithContext(ctx).Model(&model.UserRealname{}).
|
||||
Where("id = ? AND (id_no_hash IS NULL OR id_no_hash <> ?)", recordID, idNoHash).
|
||||
Update("id_no_hash", idNoHash).Error
|
||||
if err != nil && !isDuplicateIDNoError(err) {
|
||||
logging.FromContext(ctx).Warn("实名身份证指纹回填失败",
|
||||
zap.String("module", "realname"),
|
||||
zap.Uint64("realname_id", recordID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveResult 保存实名结果并更新用户实名状态。idNoHash 仅认证通过时写入。
|
||||
func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider string, result ProviderResult, idNoHash string) (*model.UserRealname, error) {
|
||||
record := model.UserRealname{
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
ProviderOrderNo: result.ProviderOrderNo,
|
||||
Status: result.Status,
|
||||
MaskedName: result.MaskedName,
|
||||
EncryptedName: result.EncryptedName,
|
||||
MaskedIDNo: result.MaskedIDNo,
|
||||
EncryptedIDNo: result.EncryptedIDNo,
|
||||
VerifiedAt: result.VerifiedAt,
|
||||
FailReason: result.FailReason,
|
||||
}
|
||||
if result.Status == StatusVerified && strings.TrimSpace(idNoHash) != "" {
|
||||
hash := strings.TrimSpace(idNoHash)
|
||||
record.IDNoHash = &hash
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.UserRealname
|
||||
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ?", userID).First(&existing).Error
|
||||
switch {
|
||||
case findErr == nil:
|
||||
existing.Provider = record.Provider
|
||||
existing.ProviderOrderNo = record.ProviderOrderNo
|
||||
existing.Status = record.Status
|
||||
existing.MaskedName = record.MaskedName
|
||||
existing.EncryptedName = record.EncryptedName
|
||||
existing.MaskedIDNo = record.MaskedIDNo
|
||||
existing.EncryptedIDNo = record.EncryptedIDNo
|
||||
existing.IDNoHash = record.IDNoHash
|
||||
existing.VerifiedAt = record.VerifiedAt
|
||||
existing.FailReason = record.FailReason
|
||||
if err := tx.Save(&existing).Error; err != nil {
|
||||
if isDuplicateIDNoError(err) {
|
||||
return ErrIDNoAlreadyUsed
|
||||
}
|
||||
return err
|
||||
}
|
||||
case errors.Is(findErr, gorm.ErrRecordNotFound):
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
if isDuplicateIDNoError(err) {
|
||||
return ErrIDNoAlreadyUsed
|
||||
}
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return findErr
|
||||
}
|
||||
|
||||
userStatus := StatusRejected
|
||||
if result.Status == StatusVerified {
|
||||
userStatus = "verified"
|
||||
}
|
||||
if result.Status == StatusPending {
|
||||
userStatus = "pending"
|
||||
}
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("realname_status", userStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if userStatus == "verified" {
|
||||
_ = invitation.OnUserRealnameVerifiedTx(tx, userID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// FingerprintIDNo 生成身份证号的稳定盲索引。
|
||||
func FingerprintIDNo(secret, idNo string) string {
|
||||
return crypto.FingerprintHMAC(secret, idNoFingerprintDomain, normalizeIDNo(idNo))
|
||||
}
|
||||
|
||||
func normalizeIDNo(idNo string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(idNo))
|
||||
}
|
||||
|
||||
func isDuplicateIDNoError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "1062") && strings.Contains(text, "uk_user_realname_id_no_hash")
|
||||
}
|
||||
|
||||
// IsIDNoDuplicateError 供其他模块复用身份证唯一键冲突判断。
|
||||
func IsIDNoDuplicateError(err error) bool {
|
||||
return isDuplicateIDNoError(err)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusVerified = "verified"
|
||||
StatusRejected = "rejected"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||
ErrAlreadyVerified = errors.New("realname already verified")
|
||||
ErrIDNoAlreadyUsed = errors.New("id no already used")
|
||||
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 {
|
||||
repo *Repository
|
||||
provider Provider
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type PublicStatus struct {
|
||||
Status string `json:"status"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderOrderNo string `json:"provider_order_no,omitempty"`
|
||||
MaskedName string `json:"masked_name,omitempty"`
|
||||
MaskedIDNo string `json:"masked_id_no,omitempty"`
|
||||
VerifiedAt *string `json:"verified_at,omitempty"`
|
||||
FailReason string `json:"fail_reason,omitempty"`
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.repo == nil || s.provider == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
idNo = normalizeIDNo(idNo)
|
||||
if !validName(name) || !validIDNo(idNo) {
|
||||
return nil, ErrInvalidRealnameInput
|
||||
}
|
||||
verified, err := s.repo.IsVerifiedUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if verified {
|
||||
return nil, ErrAlreadyVerified
|
||||
}
|
||||
used, err := s.repo.ExistsVerifiedIdentity(ctx, userID, idNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if used {
|
||||
return nil, ErrIDNoAlreadyUsed
|
||||
}
|
||||
result, err := s.provider.Start(ctx, StartRequest{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
IDNo: idNo,
|
||||
})
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
fields := []zap.Field{
|
||||
zap.String("module", "realname"),
|
||||
zap.String("provider", s.provider.Name()),
|
||||
zap.Uint64("user_id", userID),
|
||||
zap.String("masked_id_no", maskIDNo(idNo)),
|
||||
zap.Error(err),
|
||||
}
|
||||
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
s.log.Warn("实名认证服务调用失败", fields...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.SaveResult(ctx, userID, s.provider.Name(), result, FingerprintIDNo(s.repo.idHashKey, idNo))
|
||||
}
|
||||
|
||||
func (s *Service) Status(ctx context.Context, userID uint64) (PublicStatus, error) {
|
||||
if s.repo == nil {
|
||||
return PublicStatus{}, ErrDependencyUnavailable
|
||||
}
|
||||
record, err := s.repo.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return PublicStatus{Status: "unverified"}, nil
|
||||
}
|
||||
return ToPublicStatus(record), nil
|
||||
}
|
||||
|
||||
func ToPublicStatus(record *model.UserRealname) PublicStatus {
|
||||
var verifiedAt *string
|
||||
if record.VerifiedAt != nil {
|
||||
value := record.VerifiedAt.Format("2006-01-02 15:04:05")
|
||||
verifiedAt = &value
|
||||
}
|
||||
return PublicStatus{
|
||||
Status: record.Status,
|
||||
Provider: record.Provider,
|
||||
ProviderOrderNo: record.ProviderOrderNo,
|
||||
MaskedName: record.MaskedName,
|
||||
MaskedIDNo: record.MaskedIDNo,
|
||||
VerifiedAt: verifiedAt,
|
||||
FailReason: record.FailReason,
|
||||
}
|
||||
}
|
||||
|
||||
func validName(name string) bool {
|
||||
return len([]rune(name)) >= 2 && len([]rune(name)) <= 30
|
||||
}
|
||||
|
||||
func validIDNo(idNo string) bool {
|
||||
return regexp.MustCompile(`^\d{17}[\dXx]$`).MatchString(idNo)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupRealnameTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDB()
|
||||
if err := database.AutoMigrateIfMissing(db,
|
||||
&model.User{},
|
||||
&model.UserRealname{},
|
||||
&model.UserInvitation{},
|
||||
); err != nil {
|
||||
t.Fatalf("迁移实名测试表失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newRealnameTestService(t *testing.T, db *gorm.DB) *Service {
|
||||
t.Helper()
|
||||
encryptor := &crypto.MockEncryptor{}
|
||||
repo := NewRepository(db, encryptor, "test-realname-id-hash-key")
|
||||
return NewService(repo, NewMockProvider(encryptor), zap.NewNop())
|
||||
}
|
||||
|
||||
func TestRealnameIdentityMustBeUnique(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
user1 := model.User{Phone: "13700000001", Status: "active"}
|
||||
user2 := model.User{Phone: "13700000002", Status: "active"}
|
||||
if err := db.Create(&user1).Error; err != nil {
|
||||
t.Fatalf("创建用户1失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&user2).Error; err != nil {
|
||||
t.Fatalf("创建用户2失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "110101199001010011"
|
||||
if _, err := svc.Start(t.Context(), user1.ID, "张三", idNo); err != nil {
|
||||
t.Fatalf("用户1首次实名失败: %v", err)
|
||||
}
|
||||
if _, err := svc.Start(t.Context(), user1.ID, "张三", idNo); !errors.Is(err, ErrAlreadyVerified) {
|
||||
t.Fatalf("同一用户重复实名 error = %v, want ErrAlreadyVerified", err)
|
||||
}
|
||||
if _, err := svc.Start(t.Context(), user2.ID, "张三", idNo); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("同一身份证绑定第二账号 error = %v, want ErrIDNoAlreadyUsed", err)
|
||||
}
|
||||
|
||||
var record model.UserRealname
|
||||
if err := db.Where("user_id = ?", user1.ID).First(&record).Error; err != nil {
|
||||
t.Fatalf("读取用户1实名记录失败: %v", err)
|
||||
}
|
||||
if record.IDNoHash == nil || *record.IDNoHash == "" {
|
||||
t.Fatal("实名通过后必须写入身份证指纹")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameIdentityCheckSupportsLegacyEncryptedRecord(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
legacyUser := model.User{Phone: "13700000011", Status: "active"}
|
||||
otherUser := model.User{Phone: "13700000012", Status: "active"}
|
||||
if err := db.Create(&legacyUser).Error; err != nil {
|
||||
t.Fatalf("创建存量用户失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&otherUser).Error; err != nil {
|
||||
t.Fatalf("创建新用户失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "11010119900101002X"
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: legacyUser.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusVerified,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
VerifiedAt: &now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建存量实名记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), otherUser.ID, "李四", idNo); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("存量实名身份证重复绑定 error = %v, want ErrIDNoAlreadyUsed", err)
|
||||
}
|
||||
|
||||
var legacy model.UserRealname
|
||||
if err := db.Where("user_id = ?", legacyUser.ID).First(&legacy).Error; err != nil {
|
||||
t.Fatalf("读取存量实名记录失败: %v", err)
|
||||
}
|
||||
if legacy.IDNoHash == nil || *legacy.IDNoHash == "" {
|
||||
t.Fatal("命中存量记录后应补写身份证指纹")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResultRejectsDuplicateIdentityAtDBLevel(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
encryptor := &crypto.MockEncryptor{}
|
||||
repo := NewRepository(db, encryptor, "test-realname-id-hash-key")
|
||||
|
||||
user1 := model.User{Phone: "13700000031", Status: "active"}
|
||||
user2 := model.User{Phone: "13700000032", Status: "active"}
|
||||
if err := db.Create(&user1).Error; err != nil {
|
||||
t.Fatalf("创建用户1失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&user2).Error; err != nil {
|
||||
t.Fatalf("创建用户2失败: %v", err)
|
||||
}
|
||||
const idNo = "110101199001010049"
|
||||
now := time.Now()
|
||||
result := ProviderResult{
|
||||
ProviderOrderNo: "mock_unique",
|
||||
Status: StatusVerified,
|
||||
MaskedName: "赵*",
|
||||
EncryptedName: "赵六",
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
VerifiedAt: &now,
|
||||
}
|
||||
hash := FingerprintIDNo(repo.idHashKey, idNo)
|
||||
if _, err := repo.SaveResult(t.Context(), user1.ID, "mock", result, hash); err != nil {
|
||||
t.Fatalf("用户1保存实名失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SaveResult(t.Context(), user2.ID, "mock", result, hash); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("数据库唯一索引未拦截重复身份证: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameRejectedRecordCanRetryOnSameAccount(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
user := model.User{Phone: "13700000025", Status: "active", RealnameStatus: "rejected"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
const idNo = "110101199001010022"
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: user.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusRejected,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
FailReason: "旧记录",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建驳回记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), user.ID, "孙七", idNo); err != nil {
|
||||
t.Fatalf("驳回后重新实名失败: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.UserRealname{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计实名记录失败: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("实名记录条数 = %d, want 1", count)
|
||||
}
|
||||
var record model.UserRealname
|
||||
if err := db.Where("user_id = ?", user.ID).First(&record).Error; err != nil {
|
||||
t.Fatalf("读取实名记录失败: %v", err)
|
||||
}
|
||||
if record.Status != StatusVerified || record.IDNoHash == nil {
|
||||
t.Fatalf("重新实名后状态/指纹 = %s/%v", record.Status, record.IDNoHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameRejectedRecordDoesNotBlockAnotherAccount(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
rejectedUser := model.User{Phone: "13700000021", Status: "active"}
|
||||
otherUser := model.User{Phone: "13700000022", Status: "active"}
|
||||
if err := db.Create(&rejectedUser).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&otherUser).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "110101199001010030"
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: rejectedUser.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusRejected,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
FailReason: "姓名不一致",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建驳回记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), otherUser.ID, "王五", idNo); err != nil {
|
||||
t.Fatalf("驳回记录不应占用身份证,实名失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user