后台用户管理新增“人工实名
This commit is contained in:
@@ -28,6 +28,12 @@ type RevokeRealnameRequest struct {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManualRealnameRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IDNo string `json:"id_no"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type DepositFreeQuotaRequest struct {
|
type DepositFreeQuotaRequest struct {
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,29 @@ func (h *Handler) RevokeRealname(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextAdminID)
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -172,6 +195,10 @@ func writeAdminUserError(c *gin.Context, err error) {
|
|||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
case errors.Is(err, ErrInvalidUser):
|
case errors.Is(err, ErrInvalidUser):
|
||||||
response.BadRequest(c, "用户不符合规则")
|
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):
|
case errors.Is(err, ErrRealnameNotRevocable):
|
||||||
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/pkg/crypto"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -15,12 +17,17 @@ import (
|
|||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditMeta = auditlog.Meta
|
type AuditMeta = auditlog.Meta
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
|
||||||
return &Repository{db: db}
|
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) {
|
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)
|
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) {
|
func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error) {
|
||||||
var row userRow
|
var row userRow
|
||||||
err := r.db.WithContext(ctx).Table("users AS u").
|
err := r.db.WithContext(ctx).Table("users AS u").
|
||||||
@@ -234,6 +321,27 @@ func depositFreeQuotaAmountCent(req DepositFreeQuotaRequest) int64 {
|
|||||||
return req.AmountCent
|
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 {
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
||||||
return auditlog.Append(tx, auditlog.Entry{
|
return auditlog.Append(tx, auditlog.Entry{
|
||||||
ActorType: "admin",
|
ActorType: "admin",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package adminuser
|
package adminuser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -76,3 +79,106 @@ func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
|
|||||||
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ package adminuser
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidUser = errors.New("invalid user")
|
ErrInvalidUser = errors.New("invalid user")
|
||||||
|
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||||
|
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
||||||
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
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)
|
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)
|
resp, err := p.client.Do(httpReq)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ func cloudMarketHTTPError(statusCode int, reason string) error {
|
|||||||
|
|
||||||
func parseCloudMarketResult(raw json.RawMessage) (int, error) {
|
func parseCloudMarketResult(raw json.RawMessage) (int, error) {
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
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
|
var number int
|
||||||
if err := json.Unmarshal(raw, &number); err == nil {
|
if err := json.Unmarshal(raw, &number); err == nil {
|
||||||
@@ -184,7 +184,7 @@ func parseCloudMarketResult(raw json.RawMessage) (int, error) {
|
|||||||
return 110, nil
|
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 {
|
func firstNonEmpty(values ...string) string {
|
||||||
|
|||||||
@@ -110,3 +110,28 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) {
|
|||||||
t.Fatalf("期望限流错误,实际:%v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func writeRealnameError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, ErrProviderRateLimited):
|
case errors.Is(err, ErrProviderRateLimited):
|
||||||
response.Error(c, http.StatusTooManyRequests, "realname_rate_limited", "实名认证请求过于频繁,请稍后再试")
|
response.Error(c, http.StatusTooManyRequests, "realname_rate_limited", "实名认证请求过于频繁,请稍后再试")
|
||||||
case errors.Is(err, ErrProviderUnavailable):
|
case errors.Is(err, ErrProviderUnavailable):
|
||||||
response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后再试")
|
response.Error(c, http.StatusBadGateway, "realname_provider_unavailable", "实名认证服务调用失败,请稍后重试或联系客服处理")
|
||||||
default:
|
default:
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "实名认证服务暂时不可用,请稍后重试或联系客服处理")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminFinanceHandler := adminfinance.NewHandler(adminFinanceService)
|
adminFinanceHandler := adminfinance.NewHandler(adminFinanceService)
|
||||||
var adminUserRepo *adminuser.Repository
|
var adminUserRepo *adminuser.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
adminUserRepo = adminuser.NewRepository(deps.DB)
|
adminUserRepo = adminuser.NewRepository(deps.DB, fieldEncryptor)
|
||||||
}
|
}
|
||||||
adminUserService := adminuser.NewService(adminUserRepo)
|
adminUserService := adminuser.NewService(adminUserRepo)
|
||||||
adminUserHandler := adminuser.NewHandler(adminUserService)
|
adminUserHandler := adminuser.NewHandler(adminUserService)
|
||||||
@@ -511,6 +511,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
||||||
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
|
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
|
||||||
adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota)
|
adminRoutes.POST("/users/:id/deposit-free-quota", requirePerm("user:deposit_free"), adminUserHandler.SetDepositFreeQuota)
|
||||||
|
adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
||||||
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
|
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
|
||||||
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 新增“人工实名认证”权限:第三方实名服务不可用或异常时,
|
||||||
|
-- 由后台用户管理补录已核验的实名信息。
|
||||||
|
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('user:manual_realname', '人工实名认证', 'user', 'manual_realname')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
resource = VALUES(resource),
|
||||||
|
action = VALUES(action);
|
||||||
|
|
||||||
|
-- super_admin 拥有全部权限,补授新权限。
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id FROM roles r, permissions p
|
||||||
|
WHERE r.code = 'super_admin' AND p.code = 'user:manual_realname';
|
||||||
|
|
||||||
|
-- ops(运营)负责用户管理,授予人工实名权限。
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id FROM roles r, permissions p
|
||||||
|
WHERE r.code = 'ops' AND p.code = 'user:manual_realname';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DELETE rp FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code = 'user:manual_realname';
|
||||||
|
|
||||||
|
DELETE FROM permissions WHERE code = 'user:manual_realname';
|
||||||
@@ -69,3 +69,18 @@ export async function revokeAdminUserRealname(id: number, reason: string) {
|
|||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function manualAdminUserRealname(
|
||||||
|
id: number,
|
||||||
|
payload: { name: string; idNo: string; reason: string }
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||||
|
`/admin/users/${id}/manual-realname`,
|
||||||
|
{
|
||||||
|
name: payload.name,
|
||||||
|
id_no: payload.idNo,
|
||||||
|
reason: payload.reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { reactive, ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
freezeAdminUser,
|
freezeAdminUser,
|
||||||
|
manualAdminUserRealname,
|
||||||
revokeAdminUserRealname,
|
revokeAdminUserRealname,
|
||||||
setAdminUserDepositFreeQuota,
|
setAdminUserDepositFreeQuota,
|
||||||
unfreezeAdminUser,
|
unfreezeAdminUser,
|
||||||
@@ -23,9 +24,15 @@ const submitting = ref(false)
|
|||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
const quotaUser = ref<AdminUserItem | null>(null)
|
const quotaUser = ref<AdminUserItem | null>(null)
|
||||||
const realnameUser = ref<AdminUserItem | null>(null)
|
const realnameUser = ref<AdminUserItem | null>(null)
|
||||||
|
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
const revokeRealnameReason = ref('')
|
const revokeRealnameReason = ref('')
|
||||||
const quotaAmount = ref(0)
|
const quotaAmount = ref(0)
|
||||||
|
const manualRealnameForm = reactive({
|
||||||
|
name: '',
|
||||||
|
idNo: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
const filters = reactive<AdminUserQuery>({
|
const filters = reactive<AdminUserQuery>({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -114,6 +121,40 @@ function openRevokeRealname(row: AdminUserItem) {
|
|||||||
revokeRealnameReason.value = ''
|
revokeRealnameReason.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManualRealname(row: AdminUserItem) {
|
||||||
|
manualRealnameUser.value = row
|
||||||
|
manualRealnameForm.name = ''
|
||||||
|
manualRealnameForm.idNo = ''
|
||||||
|
manualRealnameForm.reason = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManualRealname() {
|
||||||
|
if (!manualRealnameUser.value) return
|
||||||
|
if (!manualRealnameForm.name.trim()) {
|
||||||
|
ElMessage.warning('请输入真实姓名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!manualRealnameForm.idNo.trim() || manualRealnameForm.idNo.trim().length !== 18) {
|
||||||
|
ElMessage.warning('请输入18位身份证号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await manualAdminUserRealname(manualRealnameUser.value.id, {
|
||||||
|
name: manualRealnameForm.name,
|
||||||
|
idNo: manualRealnameForm.idNo,
|
||||||
|
reason: manualRealnameForm.reason,
|
||||||
|
})
|
||||||
|
ElMessage.success('已完成人工实名')
|
||||||
|
manualRealnameUser.value = null
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '人工实名失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRevokeRealname() {
|
async function handleRevokeRealname() {
|
||||||
if (!realnameUser.value) return
|
if (!realnameUser.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -206,9 +247,18 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="注册时间" min-width="180">
|
<el-table-column label="注册时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320">
|
<el-table-column label="操作" width="400">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.realname_status !== 'verified'"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openManualRealname(row)"
|
||||||
|
>
|
||||||
|
人工实名
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.realname_status !== 'unverified'"
|
v-if="row.realname_status !== 'unverified'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -271,6 +321,51 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="!!manualRealnameUser"
|
||||||
|
title="人工实名认证"
|
||||||
|
width="560px"
|
||||||
|
@update:model-value="manualRealnameUser = null"
|
||||||
|
>
|
||||||
|
<div v-if="manualRealnameUser" class="dialog-body">
|
||||||
|
<p>
|
||||||
|
<strong>{{ manualRealnameUser.phone }}</strong> · {{ manualRealnameUser.nickname }}
|
||||||
|
</p>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="请确认已通过线下或客服材料核验姓名与身份证号,提交后该用户会直接变为已实名。"
|
||||||
|
/>
|
||||||
|
<el-form label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="真实姓名">
|
||||||
|
<el-input v-model="manualRealnameForm.name" clearable placeholder="请输入真实姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="身份证号">
|
||||||
|
<el-input
|
||||||
|
v-model="manualRealnameForm.idNo"
|
||||||
|
clearable
|
||||||
|
maxlength="18"
|
||||||
|
placeholder="请输入18位身份证号"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="处理原因">
|
||||||
|
<el-input
|
||||||
|
v-model="manualRealnameForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写处理原因,便于审计追踪"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="manualRealnameUser = null">取消</el-button>
|
||||||
|
<el-button type="success" :loading="submitting" @click="handleManualRealname"
|
||||||
|
>确认实名</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="!!realnameUser"
|
:model-value="!!realnameUser"
|
||||||
title="撤销实名认证"
|
title="撤销实名认证"
|
||||||
|
|||||||
Reference in New Issue
Block a user