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) { var total int64 countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query) 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) 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()) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // applyUserFilter 将列表筛选条件应用到查询(基于 users 表别名 u)。 func applyUserFilter(tx *gorm.DB, query ListQuery) *gorm.DB { if query.Status != "" { tx = tx.Where("u.status = ?", query.Status) } if query.HasDepositFreeQuota { 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 (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) 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 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) { 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() 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() UserDTO { remaining := row.DepositFreeQuotaCent - 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, DepositFreeUsedCent: row.DepositFreeUsedCent, DepositFreeRemainingCent: remaining, RenterGrowthPoints: row.RenterGrowthPoints, RenterGrowthLevel: effectiveRenterGrowthLevel(row.User), RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel), 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 effectiveRenterGrowthLevel(user model.User) string { if user.RenterGrowthLevel != "" { return user.RenterGrowthLevel } return rentergrowth.DefaultLevelCode } func renterGrowthLevelName(code string) string { switch code { case "platinum": return "铂金" case "diamond": return "钻石" case "peak": return "巅峰" default: return rentergrowth.DefaultLevelName } } 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) }