第 2 阶段:实名认证,mock测试ok

This commit is contained in:
yml
2026-05-22 15:29:24 +08:00
parent 9a79603e10
commit b3205cf2a5
14 changed files with 559 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
package middleware
import (
"errors"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RequireRealname(users *auth.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) {
if users == nil {
response.ServiceUnavailable(c, "数据库未连接")
c.Abort()
return
}
value, ok := c.Get(ContextUserID)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
c.Abort()
return
}
user, err := users.FindByID(value.(uint64))
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在")
c.Abort()
return
}
if err != nil {
response.ServiceUnavailable(c, "用户查询失败")
c.Abort()
return
}
if user.RealnameStatus != "verified" {
response.Error(c, 403, "realname_required", "请先完成实名认证")
c.Abort()
return
}
c.Next()
}
}
+21
View File
@@ -0,0 +1,21 @@
package model
import "time"
type UserRealname struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"`
Provider string `gorm:"size:32;not null" json:"provider"`
ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"`
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"`
MaskedIDNo string `gorm:"size:64;not null;default:''" json:"masked_id_no"`
VerifiedAt *time.Time `json:"verified_at"`
FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserRealname) TableName() string {
return "user_realname"
}
@@ -0,0 +1,3 @@
# Realname Module
实名认证状态流转、第三方实名服务适配器、发布前实名拦截。
@@ -0,0 +1,80 @@
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(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) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidRealnameInput):
response.BadRequest(c, "姓名或身份证号格式不正确")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用")
}
}
@@ -0,0 +1,78 @@
package realname
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
)
type Provider interface {
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
MaskedIDNo string
VerifiedAt *time.Time
FailReason string
}
type MockProvider struct{}
func NewMockProvider() *MockProvider {
return &MockProvider{}
}
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
}
now := time.Now()
return ProviderResult{
ProviderOrderNo: orderNo,
Status: StatusVerified,
MaskedName: maskName(req.Name),
MaskedIDNo: maskIDNo(req.IDNo),
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,70 @@
package realname
import (
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) FindByUserID(userID uint64) (*model.UserRealname, error) {
var record model.UserRealname
if err := r.db.Where("user_id = ?", userID).First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *Repository) SaveResult(userID uint64, provider string, result ProviderResult) (*model.UserRealname, error) {
record := model.UserRealname{
UserID: userID,
Provider: provider,
ProviderOrderNo: result.ProviderOrderNo,
Status: result.Status,
MaskedName: result.MaskedName,
MaskedIDNo: result.MaskedIDNo,
VerifiedAt: result.VerifiedAt,
FailReason: result.FailReason,
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{
"provider": record.Provider,
"provider_order_no": record.ProviderOrderNo,
"status": record.Status,
"masked_name": record.MaskedName,
"masked_id_no": record.MaskedIDNo,
"verified_at": record.VerifiedAt,
"fail_reason": record.FailReason,
"updated_at": time.Now(),
}),
}).Create(&record).Error; err != nil {
return err
}
userStatus := "realname_failed"
if result.Status == StatusVerified {
userStatus = "verified"
}
if result.Status == StatusPending {
userStatus = "pending"
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("realname_status", userStatus).Error
})
if err != nil {
return nil, err
}
return r.FindByUserID(userID)
}
@@ -0,0 +1,96 @@
package realname
import (
"context"
"errors"
"regexp"
"strings"
"hfb_sys/backend/internal/model"
)
const (
StatusPending = "pending"
StatusVerified = "verified"
StatusFailed = "failed"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidRealnameInput = errors.New("invalid realname input")
)
type Service struct {
repo *Repository
provider Provider
}
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) *Service {
return &Service{repo: repo, provider: provider}
}
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 = strings.TrimSpace(idNo)
if !validName(name) || !validIDNo(idNo) {
return nil, ErrInvalidRealnameInput
}
result, err := s.provider.Start(ctx, StartRequest{
UserID: userID,
Name: name,
IDNo: idNo,
})
if err != nil {
return nil, err
}
return s.repo.SaveResult(userID, "mock", result)
}
func (s *Service) Status(userID uint64) (PublicStatus, error) {
if s.repo == nil {
return PublicStatus{}, ErrDependencyUnavailable
}
record, err := s.repo.FindByUserID(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)
}
+13
View File
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/handler"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/realname"
"hfb_sys/backend/internal/modules/user"
"github.com/gin-gonic/gin"
@@ -31,6 +32,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
authService := auth.NewService(userRepo, deps.Redis, jwtManager, logger)
authHandler := auth.NewHandler(authService)
userHandler := user.NewHandler(userRepo)
var realnameRepo *realname.Repository
if deps.DB != nil {
realnameRepo = realname.NewRepository(deps.DB)
}
realnameService := realname.NewService(realnameRepo, realname.NewMockProvider())
realnameHandler := realname.NewHandler(realnameService)
requireAuth := middleware.Auth(jwtManager)
api := engine.Group("/api")
@@ -46,6 +53,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
api.GET("/me", requireAuth, userHandler.Me)
realnameRoutes := api.Group("/realname", requireAuth)
{
realnameRoutes.POST("/start", realnameHandler.Start)
realnameRoutes.GET("/status", realnameHandler.Status)
}
}
return engine