package adminuser import ( "context" "errors" "fmt" "strconv" "strings" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "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 } 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} } 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 } var total int64 countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig) if err := countTx.Count(&total).Error; 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") listTx = applyUserFilter(listTx, query, growthConfig) err = listTx. Order("u.id DESC"). Offset(offset).Limit(pageSize). Scan(&rows).Error if 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 != "" { like := "%" + keyword + "%" // 关键词为纯数字时一并按用户 ID 精确匹配 if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like) } else { 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) { 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 user.Status = status user.RiskStatus = riskStatus if user.TokenVersion < 1 { user.TokenVersion = 1 } user.TokenVersion++ 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, }) }) if err != nil { return nil, err } 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 } 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) { 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"). 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) }