Files
hfb_sys/backend/internal/modules/adminuser/repository.go
T

360 lines
13 KiB
Go

package adminuser
import (
"context"
"errors"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"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`).
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")
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`).
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").
Where("u.id = ?", userID).
First(&row).Error
if err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
type userRow struct {
model.User
OrderCount int64
ListingCount int64
DisputeCount int64
DepositFreeUsedCent 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,
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)
}