后台用户管理新增“人工实名

This commit is contained in:
yml2213
2026-06-28 01:06:35 +08:00
parent 21aabd77f1
commit 18eba36ef9
12 changed files with 453 additions and 16 deletions
@@ -28,6 +28,12 @@ type RevokeRealnameRequest struct {
Reason string `json:"reason"`
}
type ManualRealnameRequest struct {
Name string `json:"name"`
IDNo string `json:"id_no"`
Reason string `json:"reason"`
}
type DepositFreeQuotaRequest struct {
AmountCent int64 `json:"amount_cent"`
}
@@ -140,6 +140,29 @@ func (h *Handler) RevokeRealname(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) ManualRealname(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req ManualRealnameRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "姓名和身份证号不能为空")
return
}
item, err := h.service.ManualRealname(c.Request.Context(), adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
@@ -172,6 +195,10 @@ func writeAdminUserError(c *gin.Context, err error) {
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidUser):
response.BadRequest(c, "用户不符合规则")
case errors.Is(err, ErrInvalidRealnameInput):
response.BadRequest(c, "姓名或身份证号格式不正确")
case errors.Is(err, ErrRealnameAlreadyVerified):
response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名")
case errors.Is(err, ErrRealnameNotRevocable):
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
case IsNotFound(err):
@@ -5,22 +5,29 @@ import (
"errors"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/crypto"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
db *gorm.DB
encryptor crypto.Encryptor
}
type AuditMeta = auditlog.Meta
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
encryptor := crypto.Encryptor(&crypto.MockEncryptor{})
if len(encryptors) > 0 && encryptors[0] != nil {
encryptor = encryptors[0]
}
return &Repository{db: db, encryptor: encryptor}
}
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
@@ -176,6 +183,86 @@ func (r *Repository) RevokeRealname(ctx context.Context, adminID uint64, userID
return r.Find(ctx, userID)
}
// ManualRealname 人工实名:第三方实名服务不可用或异常时,由后台审核后补录实名信息。
func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID uint64, req ManualRealnameRequest, meta AuditMeta) (*UserDTO, error) {
name := strings.TrimSpace(req.Name)
idNo := strings.ToUpper(strings.TrimSpace(req.IDNo))
reason := strings.TrimSpace(req.Reason)
if !validManualRealname(name, idNo) {
return nil, ErrInvalidRealnameInput
}
encryptedName, err := r.encryptor.Encrypt(name)
if err != nil {
return nil, err
}
encryptedIDNo, err := r.encryptor.Encrypt(idNo)
if err != nil {
return nil, err
}
now := time.Now()
record := model.UserRealname{
UserID: userID,
Provider: "admin_manual",
ProviderOrderNo: manualRealnameOrderNo(adminID, now),
Status: "verified",
MaskedName: maskManualName(name),
EncryptedName: encryptedName,
MaskedIDNo: maskManualIDNo(idNo),
EncryptedIDNo: encryptedIDNo,
VerifiedAt: &now,
FailReason: "",
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return err
}
if user.RealnameStatus == "verified" {
return ErrRealnameAlreadyVerified
}
beforeRealnameStatus := user.RealnameStatus
user.RealnameStatus = "verified"
if err := tx.Save(&user).Error; err != nil {
return err
}
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,
"encrypted_name": record.EncryptedName,
"masked_id_no": record.MaskedIDNo,
"encrypted_id_no": record.EncryptedIDNo,
"verified_at": record.VerifiedAt,
"fail_reason": record.FailReason,
"updated_at": now,
}),
}).Create(&record).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.manual_realname", user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": reason,
"before_realname_status": beforeRealnameStatus,
"after_realname_status": user.RealnameStatus,
"masked_name": record.MaskedName,
"masked_id_no": record.MaskedIDNo,
"provider_order_no": record.ProviderOrderNo,
})
})
if err != nil {
return nil, err
}
return r.Find(ctx, userID)
}
func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) {
var row userRow
err := r.db.WithContext(ctx).Table("users AS u").
@@ -234,6 +321,27 @@ func depositFreeQuotaAmountCent(req DepositFreeQuotaRequest) int64 {
return req.AmountCent
}
func manualRealnameOrderNo(adminID uint64, verifiedAt time.Time) string {
return "manual_" + strconv.FormatUint(adminID, 10) + "_" + strconv.FormatInt(verifiedAt.UnixNano(), 10)
}
func maskManualName(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 maskManualIDNo(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:]
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
@@ -1,9 +1,12 @@
package adminuser
import (
"errors"
"strings"
"testing"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -76,3 +79,106 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
}
}
func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000001",
Nickname: "待实名用户",
RealnameStatus: "rejected",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{
Name: "周奕男",
IDNo: "130682200604080615",
Reason: "第三方服务异常,客服人工核验通过",
}, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-1"})
if err != nil {
t.Fatalf("ManualRealname() error = %v", err)
}
if got.RealnameStatus != "verified" {
t.Fatalf("用户实名状态 = %s, want verified", got.RealnameStatus)
}
var savedUser model.User
if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("查询用户失败:%v", err)
}
if savedUser.RealnameStatus != "verified" {
t.Fatalf("数据库用户实名状态 = %s, want verified", savedUser.RealnameStatus)
}
var record model.UserRealname
if err := db.Where("user_id = ?", user.ID).First(&record).Error; err != nil {
t.Fatalf("查询实名记录失败:%v", err)
}
if record.Provider != "admin_manual" || record.Status != "verified" {
t.Fatalf("实名记录不正确:provider=%s status=%s", record.Provider, record.Status)
}
if record.MaskedName != "周**" || record.MaskedIDNo != "1306**********0615" {
t.Fatalf("脱敏信息不正确:name=%s id=%s", record.MaskedName, record.MaskedIDNo)
}
if record.EncryptedName != "周奕男" || record.EncryptedIDNo != "130682200604080615" {
t.Fatalf("加密字段未写入:name=%s id=%s", record.EncryptedName, record.EncryptedIDNo)
}
if record.VerifiedAt == nil {
t.Fatal("VerifiedAt 不能为空")
}
var audit model.AuditLog
if err := db.Where("action = ?", "admin_user.manual_realname").First(&audit).Error; err != nil {
t.Fatalf("查询审计日志失败:%v", err)
}
if audit.ActorID != 99 || audit.BizID == nil || *audit.BizID != user.ID {
t.Fatalf("审计日志主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID)
}
if strings.Contains(string(audit.Detail), "130682200604080615") {
t.Fatalf("审计日志不应包含完整身份证号:%s", string(audit.Detail))
}
}
func TestManualRealnameRejectsVerifiedUser(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000002",
Nickname: "已实名用户",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
_, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{
Name: "周奕男",
IDNo: "130682200604080615",
}, AuditMeta{})
if !errors.Is(err, ErrRealnameAlreadyVerified) {
t.Fatalf("ManualRealname() error = %v, want ErrRealnameAlreadyVerified", err)
}
}
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
}
if err := db.AutoMigrate(
&model.User{},
&model.UserRealname{},
&model.AuditLog{},
&model.RentalOrder{},
&model.RentalListing{},
&model.Dispute{},
); err != nil {
t.Fatalf("数据库迁移失败:%v", err)
}
return db
}
+29 -3
View File
@@ -3,14 +3,20 @@ package adminuser
import (
"context"
"errors"
"regexp"
"strings"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
ErrRealnameNotRevocable = errors.New("realname not revocable")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameNotRevocable = errors.New("realname not revocable")
)
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
type Service struct {
repo *Repository
}
@@ -65,3 +71,23 @@ func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uin
}
return s.repo.RevokeRealname(ctx, adminID, userID, req, meta)
}
func (s *Service) ManualRealname(ctx context.Context, adminID uint64, userID uint64, req ManualRealnameRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
if !validManualRealname(req.Name, req.IDNo) {
return nil, ErrInvalidRealnameInput
}
return s.repo.ManualRealname(ctx, adminID, userID, req, meta)
}
func validManualRealname(name string, idNo string) bool {
name = strings.TrimSpace(name)
idNo = strings.TrimSpace(idNo)
nameLen := len([]rune(name))
return nameLen >= 2 && nameLen <= 30 && manualRealnameIDPattern.MatchString(idNo)
}
@@ -79,7 +79,7 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov
resp, err := p.client.Do(httpReq)
if err != nil {
return ProviderResult{}, fmt.Errorf("realname cloud market request failed: %w", err)
return ProviderResult{}, fmt.Errorf("%w: realname cloud market request failed: %v", ErrProviderUnavailable, err)
}
defer resp.Body.Close()
@@ -165,7 +165,7 @@ func cloudMarketHTTPError(statusCode int, reason string) error {
func parseCloudMarketResult(raw json.RawMessage) (int, error) {
if len(raw) == 0 || string(raw) == "null" {
return 0, fmt.Errorf("realname cloud market result missing")
return 0, fmt.Errorf("%w: realname cloud market result missing", ErrProviderUnavailable)
}
var number int
if err := json.Unmarshal(raw, &number); err == nil {
@@ -184,7 +184,7 @@ func parseCloudMarketResult(raw json.RawMessage) (int, error) {
return 110, nil
}
}
return 0, fmt.Errorf("realname cloud market result invalid: %s", string(raw))
return 0, fmt.Errorf("%w: realname cloud market result invalid: %s", ErrProviderUnavailable, string(raw))
}
func firstNonEmpty(values ...string) string {
@@ -28,7 +28,7 @@ func TestCloudMarketProviderStartVerified(t *testing.T) {
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
})
@@ -62,7 +62,7 @@ func TestCloudMarketProviderStartRejected(t *testing.T) {
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
})
@@ -93,7 +93,7 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) {
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
})
@@ -110,3 +110,28 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) {
t.Fatalf("期望限流错误,实际:%v", err)
}
}
func TestCloudMarketProviderStartUnavailableOnInvalidResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"request_id":"TID999","status":"OK","result":"busy"}`))
}))
defer server.Close()
provider, err := NewCloudMarketProvider(CloudMarketConfig{
URL: server.URL,
AppCode: "test-code",
Encryptor: &crypto.MockEncryptor{},
})
if err != nil {
t.Fatalf("创建 Provider 失败:%v", err)
}
_, err = provider.Start(t.Context(), StartRequest{
UserID: 1,
Name: "赵六",
IDNo: "110101199001011234",
})
if !errors.Is(err, ErrProviderUnavailable) {
t.Fatalf("期望服务不可用错误,实际:%v", err)
}
}
+2 -2
View File
@@ -79,8 +79,8 @@ func writeRealnameError(c *gin.Context, err error) {
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", "实名认证服务调用失败,请稍后再试")
response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后重试或联系客服处理")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用")
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用,请稍后重试或联系客服处理")
}
}