diff --git a/README.md b/README.md index 70c5771..8e7021b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ npm install npm run dev ``` +## 开发态短信与实名 + +- 短信验证码使用 mock 适配器,验证码会打印在后端日志中。 +- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 +- 实名状态可通过 `GET /api/realname/status` 查询。 + ## 文档 - [项目计划](docs/project-plan.md) diff --git a/backend/internal/middleware/realname.go b/backend/internal/middleware/realname.go new file mode 100644 index 0000000..fd8c9e1 --- /dev/null +++ b/backend/internal/middleware/realname.go @@ -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() + } +} diff --git a/backend/internal/model/user_realname.go b/backend/internal/model/user_realname.go new file mode 100644 index 0000000..ee59987 --- /dev/null +++ b/backend/internal/model/user_realname.go @@ -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" +} diff --git a/backend/internal/modules/realname/README.md b/backend/internal/modules/realname/README.md new file mode 100644 index 0000000..2874062 --- /dev/null +++ b/backend/internal/modules/realname/README.md @@ -0,0 +1,3 @@ +# Realname Module + +实名认证状态流转、第三方实名服务适配器、发布前实名拦截。 diff --git a/backend/internal/modules/realname/handler.go b/backend/internal/modules/realname/handler.go new file mode 100644 index 0000000..ee9a00c --- /dev/null +++ b/backend/internal/modules/realname/handler.go @@ -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", "实名认证服务暂时不可用") + } +} diff --git a/backend/internal/modules/realname/provider.go b/backend/internal/modules/realname/provider.go new file mode 100644 index 0000000..470c89d --- /dev/null +++ b/backend/internal/modules/realname/provider.go @@ -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:] +} diff --git a/backend/internal/modules/realname/repository.go b/backend/internal/modules/realname/repository.go new file mode 100644 index 0000000..3222a0a --- /dev/null +++ b/backend/internal/modules/realname/repository.go @@ -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) +} diff --git a/backend/internal/modules/realname/service.go b/backend/internal/modules/realname/service.go new file mode 100644 index 0000000..d2e300d --- /dev/null +++ b/backend/internal/modules/realname/service.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b3b760b..d8d5cf3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 diff --git a/docs/api.md b/docs/api.md index fd97198..c547d66 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,3 +12,13 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - 钱包:余额、流水、提现预留。 - 通知:站内信列表、标记已读。 - 后台:审核、订单、仲裁、资金流水、系统配置、审计日志。 + +## 已实现接口 + +- `POST /api/auth/sms/send` +- `POST /api/auth/sms/login` +- `POST /api/auth/refresh` +- `POST /api/auth/logout` +- `GET /api/me` +- `POST /api/realname/start` +- `GET /api/realname/status` diff --git a/docs/business-rules.md b/docs/business-rules.md index fc2ba29..4680e80 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -10,3 +10,11 @@ - 账号交接采用号主手动交接。 - 资金先做账务模型,不直接接真实支付和提现。 - 押金、交接超时、归还超时、短信限流等阈值进入系统配置。 + +## 开发态实名认证 + +- 当前实现为 mock 实名服务。 +- 提交合法姓名和 18 位身份证号后直接通过认证。 +- 后端只返回脱敏姓名和脱敏证件号。 +- 用户表 `realname_status` 会同步更新为 `verified`。 +- 后续接入阿里云或腾讯云实名服务时,保持 `POST /api/realname/start` 和 `GET /api/realname/status` 的业务语义不变。 diff --git a/frontend/src/api/realname.ts b/frontend/src/api/realname.ts new file mode 100644 index 0000000..daff440 --- /dev/null +++ b/frontend/src/api/realname.ts @@ -0,0 +1,30 @@ +import { apiClient } from './client' + +export interface RealnameStatus { + status: string + provider?: string + provider_order_no?: string + masked_name?: string + masked_id_no?: string + verified_at?: string + fail_reason?: string +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function startRealname(name: string, idNo: string) { + const { data } = await apiClient.post>('/realname/start', { + name, + id_no: idNo, + }) + return data.data +} + +export async function getRealnameStatus() { + const { data } = await apiClient.get>('/realname/status') + return data.data +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index edb4a60..f6ee3cc 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -151,6 +151,37 @@ h1 { width: 100%; } +.notice { + max-width: 520px; + margin-top: 24px; +} + +.status-panel { + max-width: 520px; + margin-top: 24px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 18px; +} + +.status-panel span { + color: #6b7785; + font-size: 13px; +} + +.status-panel strong { + display: block; + margin-top: 6px; + color: #0f766e; + font-size: 22px; +} + +.status-panel p { + margin: 8px 0 0; + color: #52616f; +} + @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; diff --git a/frontend/src/views/account/RealnameView.vue b/frontend/src/views/account/RealnameView.vue index 86a2bca..fce3ad8 100644 --- a/frontend/src/views/account/RealnameView.vue +++ b/frontend/src/views/account/RealnameView.vue @@ -1,9 +1,77 @@ + +