chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
package adminuser
import "time"
type UserDTO struct {
ID uint64 `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
RealnameStatus string `json:"realname_status"`
RiskStatus string `json:"risk_status"`
CreditScore int `json:"credit_score"`
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent"`
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent"`
DepositFreeEffectiveQuotaCent int64 `json:"deposit_free_effective_quota_cent"`
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
RenterGrowthPoints int64 `json:"renter_growth_points"`
RenterGrowthLevel string `json:"renter_growth_level"`
RenterGrowthLevelName string `json:"renter_growth_level_name"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
Status string `json:"status"`
OrderCount int64 `json:"order_count"`
ListingCount int64 `json:"listing_count"`
DisputeCount int64 `json:"dispute_count"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FreezeRequest struct {
Reason string `json:"reason"`
}
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"`
}
type GrowthPointsAdjustRequest struct {
TargetPoints int64 `json:"target_points"`
Reason string `json:"reason" binding:"required"`
}
// WalletAdjustRequest 后台人工调整用户可用余额。
// Direction: in=加款 out=扣款;仅操作 available,不改 frozen。
type WalletAdjustRequest struct {
Direction string `json:"direction" binding:"required"`
AmountCent int64 `json:"amount_cent" binding:"required"`
Reason string `json:"reason" binding:"required"`
ReferenceNo string `json:"reference_no"`
}
// ListQuery 用户列表筛选条件。Keyword 同时匹配手机号/昵称(模糊)与用户 ID(精确)。
type ListQuery struct {
Keyword string
Status string
HasDepositFreeQuota bool
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
@@ -0,0 +1,272 @@
package adminuser
import (
"errors"
"net/http"
"strconv"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func parsePagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func (h *Handler) List(c *gin.Context) {
page, pageSize := parsePagination(c)
query := ListQuery{
Keyword: strings.TrimSpace(c.Query("keyword")),
Status: strings.TrimSpace(c.Query("status")),
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
}
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, result)
}
func parseBoolQuery(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (h *Handler) Freeze(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req FreezeRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.Freeze(c.Request.Context(), adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Unfreeze(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.Unfreeze(c.Request.Context(), adminID, userID, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) SetDepositFreeQuota(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req DepositFreeQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "免押额度不正确")
return
}
item, err := h.service.SetDepositFreeQuota(c.Request.Context(), adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdjustGrowthPoints(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req GrowthPointsAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "成长积分调整参数不正确")
return
}
item, err := h.service.AdjustGrowthPoints(c.Request.Context(), adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) RevokeRealname(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req RevokeRealnameRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.RevokeRealname(c.Request.Context(), adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
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 (h *Handler) AdjustWallet(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req WalletAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "余额调整参数不正确")
return
}
item, err := h.service.AdjustWallet(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 {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func auditMeta(c *gin.Context) AuditMeta {
return AuditMeta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RequestID: middleware.GetRequestID(c),
}
}
func writeAdminUserError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
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, ErrRealnameIDNoUsed):
response.Error(c, http.StatusConflict, "realname_id_no_used", "该身份证号已绑定其他实名账号,不能重复认证")
case errors.Is(err, ErrRealnameNotRevocable):
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
case errors.Is(err, ErrInvalidWalletDirection):
response.BadRequest(c, "调账方向不正确,仅支持加款(in)或扣款(out)")
case errors.Is(err, ErrInvalidWalletAmount):
response.BadRequest(c, "调账金额不正确,需大于 0 且不超过 10 万元")
case errors.Is(err, ErrInvalidWalletReason):
response.BadRequest(c, "请填写 2-200 字的调账原因")
case errors.Is(err, ErrInsufficientBalance):
response.Error(c, http.StatusConflict, "insufficient_balance", "用户可用余额不足,无法扣款")
case errors.Is(err, ErrInvalidGrowthPoints):
response.BadRequest(c, "目标积分不正确,需在 0 到 10 亿之间")
case errors.Is(err, ErrInvalidGrowthReason):
response.BadRequest(c, "请填写 2-200 字的积分调整原因")
case errors.Is(err, ErrGrowthPointsUnchanged):
response.Error(c, http.StatusConflict, "growth_points_unchanged", "目标积分与当前积分相同")
case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "用户管理服务暂时不可用")
}
}
@@ -0,0 +1,627 @@
package adminuser
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/invitation"
"hfb_sys/backend/internal/modules/realname"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
encryptor crypto.Encryptor
realnameIDHashKey string
statusChangeNotifier func(userID uint64)
}
type AuditMeta = auditlog.Meta
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}
}
// SetRealnameIDHashKey 设置身份证盲索引密钥,后台人工实名同样执行唯一性校验。
func (r *Repository) SetRealnameIDHashKey(key string) {
r.realnameIDHashKey = strings.TrimSpace(key)
}
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
r.statusChangeNotifier = notifier
}
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var rows []userRow
listTx := r.db.WithContext(ctx).Table("users AS u").
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count,
COALESCE(df.deposit_free_used_cent, 0) AS deposit_free_used_cent,
COALESCE(w.available_balance_cent, 0) AS available_balance_cent,
COALESCE(w.frozen_balance_cent, 0) AS frozen_balance_cent`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id AND w.distribution_site_id = 0")
listTx = applyUserFilter(listTx, query, growthConfig)
err = listTx.
Order("u.id DESC").
Offset(offset).Limit(pageSize).
Scan(&rows).Error
if err != nil {
return nil, err
}
var total int64
// 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描
if page == 1 && len(rows) < pageSize {
total = int64(len(rows))
} else {
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig)
if err := countTx.Count(&total).Error; err != nil {
return nil, err
}
}
items := make([]UserDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO(growthConfig))
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。
func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Config) *gorm.DB {
if query.Status != "" {
tx = tx.Where("u.status = ?", query.Status)
}
if query.HasDepositFreeQuota {
if minPoints, ok := minimumGrowthQuotaPoints(growthConfig); ok {
tx = tx.Where("u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ?", minPoints)
} else {
tx = tx.Where("u.deposit_free_quota_cent > 0")
}
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
// 11 位手机号:命中 users(phone) 唯一索引
tx = tx.Where("u.phone = ?", keyword)
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
// 纯数字 ID:一并按用户 ID 精确匹配
like := "%" + keyword + "%"
tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like)
} else {
like := "%" + keyword + "%"
tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like)
}
}
return tx
}
func minimumGrowthQuotaPoints(cfg rentergrowth.Config) (int64, bool) {
if !cfg.Enabled {
return 0, false
}
var minPoints int64
found := false
for _, level := range cfg.Levels {
if level.DepositFreeQuotaCent <= 0 {
continue
}
if !found || level.MinPoints < minPoints {
minPoints = level.MinPoints
found = true
}
}
return minPoints, found
}
func (r *Repository) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
return r.updateStatus(ctx, adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta)
}
func (r *Repository) Unfreeze(ctx context.Context, adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) {
return r.updateStatus(ctx, adminID, userID, "active", "normal", "admin_user.unfreeze", "", meta)
}
func (r *Repository) SetDepositFreeQuota(ctx context.Context, adminID uint64, userID uint64, req DepositFreeQuotaRequest, meta AuditMeta) (*UserDTO, error) {
amountCent := depositFreeQuotaAmountCent(req)
if amountCent < 0 {
return nil, ErrInvalidUser
}
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
}
beforeAmountCent := user.DepositFreeQuotaCent
user.DepositFreeQuotaCent = amountCent
if err := tx.Save(&user).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.set_deposit_free_quota", user.ID, meta, map[string]any{
"user_id": user.ID,
"before_amount_cent": beforeAmountCent,
"after_amount_cent": amountCent,
})
})
if err != nil {
return nil, err
}
return r.Find(ctx, userID)
}
func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) {
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.RenterGrowthPoints == req.TargetPoints {
return ErrGrowthPointsUnchanged
}
cfg, err := rentergrowth.ConfigForTx(tx)
if err != nil {
return err
}
beforePoints := user.RenterGrowthPoints
beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints)
afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints)
if !cfg.Enabled {
beforeLevel = rentergrowth.LevelForPoints(cfg, 0)
afterLevel = beforeLevel
}
user.RenterGrowthPoints = req.TargetPoints
user.RenterGrowthLevel = afterLevel.Code
if err := tx.Save(&user).Error; err != nil {
return err
}
ledger := model.RenterGrowthLedger{
UserID: user.ID,
Points: req.TargetPoints - beforePoints,
BeforePoints: beforePoints,
AfterPoints: req.TargetPoints,
BeforeLevel: beforeLevel.Code,
AfterLevel: afterLevel.Code,
Source: rentergrowth.SourceAdminAdjustment,
OperatorAdminID: &adminID,
Remark: "后台调整成长积分: " + req.Reason,
CreatedAt: time.Now(),
}
if err := tx.Create(&ledger).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.adjust_growth_points", user.ID, meta, map[string]any{
"user_id": user.ID,
"before_points": beforePoints,
"after_points": req.TargetPoints,
"change_points": req.TargetPoints - beforePoints,
"before_level": beforeLevel.Code,
"after_level": afterLevel.Code,
"reason": req.Reason,
})
})
if err != nil {
return nil, err
}
return r.Find(ctx, userID)
}
func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
statusChanged := false
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
}
beforeStatus := user.Status
beforeRisk := user.RiskStatus
beforeTokenVersion := user.TokenVersion
user.Status = status
user.RiskStatus = riskStatus
if beforeStatus != status {
user.TokenVersion++
statusChanged = true
}
if err := tx.Save(&user).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": reason,
"before_status": beforeStatus,
"after_status": status,
"before_risk_status": beforeRisk,
"after_risk_status": riskStatus,
"before_token_version": beforeTokenVersion,
"after_token_version": user.TokenVersion,
})
})
if err != nil {
return nil, err
}
if statusChanged && r.statusChangeNotifier != nil {
r.statusChangeNotifier(userID)
}
return r.Find(ctx, userID)
}
// RevokeRealname 撤销用户实名:将用户实名状态重置为未认证,并删除实名记录,
// 使用户可重新发起实名认证(用于实名姓名与收款账户不一致导致收款码登记失败的场景)。
func (r *Repository) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) {
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
}
var realnameCount int64
if err := tx.Model(&model.UserRealname{}).Where("user_id = ?", userID).Count(&realnameCount).Error; err != nil {
return err
}
// 已是未认证且无实名记录,无可撤销内容。
if user.RealnameStatus == "unverified" && realnameCount == 0 {
return ErrRealnameNotRevocable
}
beforeRealnameStatus := user.RealnameStatus
user.RealnameStatus = "unverified"
if err := tx.Save(&user).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserRealname{}).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.revoke_realname", user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": req.Reason,
"before_realname_status": beforeRealnameStatus,
"after_realname_status": user.RealnameStatus,
})
})
if err != nil {
return nil, err
}
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
}
idNoHash := realname.FingerprintIDNo(r.realnameIDHashKey, idNo)
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,
IDNoHash: &idNoHash,
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
}
identityRepo := realname.NewRepository(tx, r.encryptor, r.realnameIDHashKey)
used, err := identityRepo.ExistsVerifiedIdentity(ctx, userID, idNo)
if err != nil {
return err
}
if used {
return realname.ErrIDNoAlreadyUsed
}
beforeRealnameStatus := user.RealnameStatus
user.RealnameStatus = "verified"
if err := tx.Save(&user).Error; err != nil {
return err
}
var existingRealname model.UserRealname
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", userID).First(&existingRealname).Error
switch {
case findErr == nil:
existingRealname.Provider = record.Provider
existingRealname.ProviderOrderNo = record.ProviderOrderNo
existingRealname.Status = record.Status
existingRealname.MaskedName = record.MaskedName
existingRealname.EncryptedName = record.EncryptedName
existingRealname.MaskedIDNo = record.MaskedIDNo
existingRealname.EncryptedIDNo = record.EncryptedIDNo
existingRealname.IDNoHash = record.IDNoHash
existingRealname.VerifiedAt = record.VerifiedAt
existingRealname.FailReason = record.FailReason
if err := tx.Save(&existingRealname).Error; err != nil {
if realname.IsIDNoDuplicateError(err) {
return realname.ErrIDNoAlreadyUsed
}
return err
}
case errors.Is(findErr, gorm.ErrRecordNotFound):
if err := tx.Create(&record).Error; err != nil {
if realname.IsIDNoDuplicateError(err) {
return realname.ErrIDNoAlreadyUsed
}
return err
}
default:
return findErr
}
_ = invitation.OnUserRealnameVerifiedTx(tx, user.ID)
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) {
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
if err != nil {
return nil, err
}
var row userRow
err = r.db.WithContext(ctx).Table("users AS u").
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count,
COALESCE(df.deposit_free_used_cent, 0) AS deposit_free_used_cent,
COALESCE(w.available_balance_cent, 0) AS available_balance_cent,
COALESCE(w.frozen_balance_cent, 0) AS frozen_balance_cent`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id AND w.distribution_site_id = 0").
Where("u.id = ?", userID).
First(&row).Error
if err != nil {
return nil, err
}
dto := row.toDTO(growthConfig)
return &dto, nil
}
// AdjustWallet 人工调整用户可用余额:写 wallet_ledger + 审计日志,同事务提交。
func (r *Repository) AdjustWallet(ctx context.Context, adminID uint64, userID uint64, req WalletAdjustRequest, meta AuditMeta) (*UserDTO, error) {
direction := req.Direction
amountCent := req.AmountCent
reason := req.Reason
referenceNo := req.ReferenceNo
bizType := "admin_credit"
remarkPrefix := "人工加款"
if direction == "out" {
bizType = "admin_debit"
remarkPrefix = "人工扣款"
}
bizNo := fmt.Sprintf("ADJ%d%d", adminID, time.Now().UnixNano())
remark := remarkPrefix + ": " + reason
if referenceNo != "" {
remark = remark + " [" + referenceNo + "]"
}
if len([]rune(remark)) > 255 {
runes := []rune(remark)
remark = string(runes[:255])
}
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 err := wallet.EnsureAccountTx(tx, userID); err != nil {
return err
}
var account model.WalletAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&account).Error; err != nil {
return err
}
beforeAvailable := account.AvailableBalanceCent
beforeFrozen := account.FrozenBalanceCent
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: userID,
Direction: direction,
AmountCent: amountCent,
BalanceType: "available",
BizType: bizType,
BizNo: bizNo,
Remark: remark,
}); err != nil {
if errors.Is(err, wallet.ErrInsufficientBalance) {
return ErrInsufficientBalance
}
if errors.Is(err, wallet.ErrInvalidAmount) {
return ErrInvalidWalletAmount
}
return err
}
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, "admin_user.wallet_adjust", user.ID, meta, map[string]any{
"user_id": user.ID,
"direction": direction,
"amount_cent": amountCent,
"before_available_cent": beforeAvailable,
"after_available_cent": account.AvailableBalanceCent,
"before_frozen_cent": beforeFrozen,
"after_frozen_cent": account.FrozenBalanceCent,
"reason": reason,
"reference_no": referenceNo,
"biz_type": bizType,
"biz_no": bizNo,
})
})
if err != nil {
return nil, err
}
return r.Find(ctx, userID)
}
type userRow struct {
model.User
OrderCount int64
ListingCount int64
DisputeCount int64
DepositFreeUsedCent int64
AvailableBalanceCent int64
FrozenBalanceCent int64
}
func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
level := rentergrowth.LevelForPoints(growthConfig, row.RenterGrowthPoints)
levelQuotaCent := level.DepositFreeQuotaCent
if !growthConfig.Enabled {
level = rentergrowth.LevelForPoints(growthConfig, 0)
levelQuotaCent = 0
}
effectiveQuotaCent := rentergrowth.EffectiveDepositFreeQuotaCent(levelQuotaCent, row.DepositFreeQuotaCent)
remaining := effectiveQuotaCent - row.DepositFreeUsedCent
if remaining < 0 {
remaining = 0
}
return UserDTO{
ID: row.ID,
Phone: row.Phone,
Nickname: row.Nickname,
RealnameStatus: row.RealnameStatus,
RiskStatus: row.RiskStatus,
CreditScore: row.CreditScore,
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
DepositFreeManualQuotaCent: row.DepositFreeQuotaCent,
DepositFreeLevelQuotaCent: levelQuotaCent,
DepositFreeEffectiveQuotaCent: effectiveQuotaCent,
DepositFreeUsedCent: row.DepositFreeUsedCent,
DepositFreeRemainingCent: remaining,
RenterGrowthPoints: row.RenterGrowthPoints,
RenterGrowthLevel: level.Code,
RenterGrowthLevelName: level.Name,
AvailableBalanceCent: row.AvailableBalanceCent,
FrozenBalanceCent: row.FrozenBalanceCent,
Status: row.Status,
OrderCount: row.OrderCount,
ListingCount: row.ListingCount,
DisputeCount: row.DisputeCount,
LastLoginAt: row.LastLoginAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
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",
ActorID: actorID,
Action: action,
BizType: "user",
BizID: &bizID,
Meta: meta,
Detail: detail,
})
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
@@ -0,0 +1,366 @@
package adminuser
import (
"errors"
"strings"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/gorm"
)
func TestDepositFreeQuotaAmountCent(t *testing.T) {
tests := []struct {
name string
req DepositFreeQuotaRequest
want int64
}{
{
name: "使用分字段",
req: DepositFreeQuotaRequest{AmountCent: 1234},
want: 1234,
},
{
name: "负分拒绝",
req: DepositFreeQuotaRequest{AmountCent: -1},
want: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := depositFreeQuotaAmountCent(tt.req); got != tt.want {
t.Fatalf("depositFreeQuotaAmountCent() = %d, want %d", got, tt.want)
}
})
}
}
func TestParseBoolQuery(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "true 开启", value: "true", want: true},
{name: "数字 1 开启", value: "1", want: true},
{name: "on 开启", value: "on", want: true},
{name: "空值关闭", value: "", want: false},
{name: "false 关闭", value: "false", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseBoolQuery(tt.value); got != tt.want {
t.Fatalf("parseBoolQuery(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
db := database.NewDryRunDB()
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
Status: "active",
HasDepositFreeQuota: true,
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") {
t.Fatalf("SQL 未包含免押额度过滤:%s", sql)
}
if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) {
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
}
}
func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000005",
Status: "active",
RenterGrowthPoints: 290,
RenterGrowthLevel: rentergrowth.DefaultLevelCode,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
if _, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
TargetPoints: 1000,
Reason: "运营活动赠送",
}, AuditMeta{RequestID: "growth-1"}); err != nil {
t.Fatalf("第一次调整积分失败:%v", err)
}
got, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
TargetPoints: 200,
Reason: "修正错误赠送",
}, AuditMeta{RequestID: "growth-2"})
if err != nil {
t.Fatalf("第二次调整积分失败:%v", err)
}
if got.RenterGrowthPoints != 200 || got.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
t.Fatalf("调整后成长信息 = %d/%s, want 200/normal", got.RenterGrowthPoints, got.RenterGrowthLevel)
}
var ledgers []model.RenterGrowthLedger
if err := db.Where("user_id = ? AND source = ?", user.ID, rentergrowth.SourceAdminAdjustment).Order("id ASC").Find(&ledgers).Error; err != nil {
t.Fatalf("查询积分流水失败:%v", err)
}
if len(ledgers) != 2 || ledgers[0].Points != 710 || ledgers[1].Points != -800 {
t.Fatalf("积分流水不正确:%+v", ledgers)
}
for _, ledger := range ledgers {
if ledger.OrderID != nil || ledger.OperatorAdminID == nil || *ledger.OperatorAdminID != 66 {
t.Fatalf("人工积分流水关联不正确:%+v", ledger)
}
}
}
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 TestManualRealnameRejectsIdentityUsedByAnotherUser(t *testing.T) {
db := setupAdminUserTestDB(t)
first := model.User{Phone: "13900000011", Nickname: "用户一", Status: "active"}
second := model.User{Phone: "13900000012", Nickname: "用户二", Status: "active"}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建用户一失败:%v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建用户二失败:%v", err)
}
repo := NewRepository(db)
repo.SetRealnameIDHashKey("test-realname-id-hash-key")
req := ManualRealnameRequest{Name: "周奕男", IDNo: "130682200604080615"}
if _, err := repo.ManualRealname(t.Context(), 99, first.ID, req, AuditMeta{}); err != nil {
t.Fatalf("用户一人工实名失败:%v", err)
}
if _, err := repo.ManualRealname(t.Context(), 99, second.ID, req, AuditMeta{}); !errors.Is(err, ErrRealnameIDNoUsed) {
t.Fatalf("同一身份证人工实名第二账号 error = %v, want ErrRealnameIDNoUsed", err)
}
}
func TestAdjustWalletDebitAndAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000003",
Nickname: "有余额用户",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
if err := db.Create(&model.WalletAccount{
UserID: user.ID,
AvailableBalanceCent: 15000,
FrozenBalanceCent: 3000,
Status: "active",
}).Error; err != nil {
t.Fatalf("创建钱包失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.AdjustWallet(t.Context(), 88, user.ID, WalletAdjustRequest{
Direction: "out",
AmountCent: 5000,
Reason: "线下已转账核销",
ReferenceNo: "OFFLINE-001",
}, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-wallet-1"})
if err != nil {
t.Fatalf("AdjustWallet() error = %v", err)
}
if got.AvailableBalanceCent != 10000 {
t.Fatalf("可用余额 = %d, want 10000", got.AvailableBalanceCent)
}
if got.FrozenBalanceCent != 3000 {
t.Fatalf("冻结余额不应变化,got %d", got.FrozenBalanceCent)
}
var ledger model.WalletLedger
if err := db.Where("user_id = ? AND biz_type = ?", user.ID, "admin_debit").First(&ledger).Error; err != nil {
t.Fatalf("查询资金流水失败:%v", err)
}
if ledger.Direction != "out" || ledger.AmountCent != 5000 || ledger.BalanceType != "available" {
t.Fatalf("流水不正确:%+v", ledger)
}
var audit model.AuditLog
if err := db.Where("action = ?", "admin_user.wallet_adjust").First(&audit).Error; err != nil {
t.Fatalf("查询审计日志失败:%v", err)
}
if audit.ActorID != 88 || audit.BizID == nil || *audit.BizID != user.ID {
t.Fatalf("审计主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID)
}
detail := string(audit.Detail)
if !strings.Contains(detail, "线下已转账核销") || !strings.Contains(detail, "OFFLINE-001") {
t.Fatalf("审计详情缺少原因或外部单号:%s", detail)
}
}
func TestAdjustWalletInsufficientBalance(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000004",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
if err := db.Create(&model.WalletAccount{
UserID: user.ID,
AvailableBalanceCent: 100,
Status: "active",
}).Error; err != nil {
t.Fatalf("创建钱包失败:%v", err)
}
repo := NewRepository(db)
_, err := repo.AdjustWallet(t.Context(), 1, user.ID, WalletAdjustRequest{
Direction: "out",
AmountCent: 200,
Reason: "超额扣款",
}, AuditMeta{})
if !errors.Is(err, ErrInsufficientBalance) {
t.Fatalf("AdjustWallet() error = %v, want ErrInsufficientBalance", err)
}
}
func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) {
db := setupAdminUserTestDB(t)
// 显式给出版本起点,避免 GORM 对零值字段应用 default:1 造成歧义。
user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 1}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{})
if err != nil {
t.Fatalf("冻结用户失败:%v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询冻结用户失败:%v", err)
}
if got.Status != "frozen" || saved.TokenVersion != 2 {
t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/2", got.Status, saved.TokenVersion)
}
got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{})
if err != nil {
t.Fatalf("解冻用户失败:%v", err)
}
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询解冻用户失败:%v", err)
}
if got.Status != "active" || saved.TokenVersion != 3 {
t.Fatalf("解冻后状态/版本 = %s/%d, want active/3", got.Status, saved.TokenVersion)
}
}
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db,
&model.User{},
&model.UserRealname{},
&model.AuditLog{},
&model.RentalOrder{},
&model.RentalListing{},
&model.Dispute{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.SystemConfig{},
&model.RenterGrowthLedger{},
); err != nil {
t.Fatalf("数据库迁移失败:%v", err)
}
return db
}
@@ -0,0 +1,153 @@
package adminuser
import (
"context"
"errors"
"regexp"
"strings"
"hfb_sys/backend/internal/modules/realname"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
ErrRealnameNotRevocable = errors.New("realname not revocable")
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
ErrInvalidWalletReason = errors.New("invalid wallet reason")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrInvalidGrowthPoints = errors.New("invalid growth points")
ErrInvalidGrowthReason = errors.New("invalid growth reason")
ErrGrowthPointsUnchanged = errors.New("growth points unchanged")
)
// 单笔人工调账上限:10 万元,防止误填。
const maxWalletAdjustAmountCent int64 = 10_000_000
const maxRenterGrowthPoints int64 = 1_000_000_000
var manualRealnameIDPattern = regexp.MustCompile(`^\d{17}[\dXx]$`)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。
func (s *Service) SetSessionRevoker(revoker func(userID uint64)) {
if s.repo != nil {
s.repo.SetStatusChangeNotifier(revoker)
}
}
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.List(ctx, page, pageSize, query)
}
func (s *Service) Freeze(ctx context.Context, adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
return s.repo.Freeze(ctx, adminID, userID, req, meta)
}
func (s *Service) Unfreeze(ctx context.Context, adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
return s.repo.Unfreeze(ctx, adminID, userID, meta)
}
func (s *Service) SetDepositFreeQuota(ctx context.Context, adminID uint64, userID uint64, req DepositFreeQuotaRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || req.AmountCent < 0 {
return nil, ErrInvalidUser
}
return s.repo.SetDepositFreeQuota(ctx, adminID, userID, req, meta)
}
func (s *Service) AdjustGrowthPoints(ctx context.Context, adminID uint64, userID uint64, req GrowthPointsAdjustRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || req.TargetPoints < 0 || req.TargetPoints > maxRenterGrowthPoints {
return nil, ErrInvalidGrowthPoints
}
reason := strings.TrimSpace(req.Reason)
if reasonLen := len([]rune(reason)); reasonLen < 2 || reasonLen > 200 {
return nil, ErrInvalidGrowthReason
}
req.Reason = reason
return s.repo.AdjustGrowthPoints(ctx, adminID, userID, req, meta)
}
func (s *Service) RevokeRealname(ctx context.Context, adminID uint64, userID uint64, req RevokeRealnameRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
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 (s *Service) AdjustWallet(ctx context.Context, adminID uint64, userID uint64, req WalletAdjustRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
direction := strings.TrimSpace(strings.ToLower(req.Direction))
if direction != "in" && direction != "out" {
return nil, ErrInvalidWalletDirection
}
if req.AmountCent <= 0 || req.AmountCent > maxWalletAdjustAmountCent {
return nil, ErrInvalidWalletAmount
}
if reasonLen := len([]rune(strings.TrimSpace(req.Reason))); reasonLen < 2 || reasonLen > 200 {
return nil, ErrInvalidWalletReason
}
req.Direction = direction
req.Reason = strings.TrimSpace(req.Reason)
req.ReferenceNo = strings.TrimSpace(req.ReferenceNo)
return s.repo.AdjustWallet(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)
}