chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user