package wallet import ( "context" "crypto/rand" "errors" "fmt" "strings" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/money" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } type Entry struct { UserID uint64 DistributionSiteID uint64 OrderID *uint64 Direction string AmountCent int64 BalanceType string BizType string BizNo string Remark string // AllowNegative 仅用于结算回冲等追偿场景:余额不足时允许扣成负数, // 由后续收益抵扣,避免回冲失败导致账实不一致。 AllowNegative bool } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } func (r *Repository) resolveSiteID(ctx context.Context, siteCode string) (uint64, error) { siteCode = strings.TrimSpace(siteCode) if siteCode == "" { return 0, nil } var site model.DistributionSite if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, errors.New("distribution site unavailable") } return 0, err } return site.ID, nil } func (r *Repository) Account(ctx context.Context, userID uint64, siteCode ...string) (*AccountDTO, error) { var code string if len(siteCode) > 0 { code = siteCode[0] } siteID, err := r.resolveSiteID(ctx, code) if err != nil { return nil, err } var account model.WalletAccount err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := ensureAccount(tx, userID, siteID); err != nil { return err } return tx.Where("user_id = ? AND distribution_site_id = ?", userID, siteID).First(&account).Error }) if err != nil { return nil, err } return toAccountDTO(account), nil } func (r *Repository) Ledger(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { var code string if len(siteCode) > 0 { code = siteCode[0] } siteID, err := r.resolveSiteID(ctx, code) if err != nil { return nil, err } query := r.db.WithContext(ctx).Model(&model.WalletLedger{}).Where("user_id = ?", userID) if siteID > 0 { query = query.Where("distribution_site_id = ?", siteID) } else { query = query.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") } var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var rows []model.WalletLedger dataQuery := r.db.WithContext(ctx).Where("user_id = ?", userID) if siteID > 0 { dataQuery = dataQuery.Where("distribution_site_id = ?", siteID) } else { dataQuery = dataQuery.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") } if err := dataQuery.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, err } items := make([]LedgerDTO, 0, len(rows)) for _, row := range rows { items = append(items, toLedgerDTO(row)) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // Withdraw 保留仓库能力;当前公开提现入口在 service 层标记为待开发,不会调用到这里。 func (r *Repository) Withdraw(ctx context.Context, userID uint64, amountCent int64) (*AccountDTO, error) { if userID == 0 || amountCent <= 0 { return nil, ErrInvalidAmount } err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return AppendEntries(tx, Entry{ UserID: userID, Direction: "out", AmountCent: amountCent, BalanceType: "available", BizType: "withdraw_apply", BizNo: fmt.Sprintf("WD%d", time.Now().UnixNano()), Remark: "卖家申请提现", }) }) if err != nil { return nil, err } return r.Account(ctx, userID) } func (r *Repository) AdminLedger(ctx context.Context, query AdminLedgerQuery) (*PaginatedResult, error) { db := r.db.WithContext(ctx).Table("wallet_ledger AS wl"). Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone, COALESCE(u.nickname, '') AS user_nickname, wl.order_id, COALESCE(ro.order_no, '') AS order_no, wl.direction, wl.amount_cent, wl.balance_after_cent, wl.balance_type, wl.biz_type, wl.biz_no, wl.remark, wl.created_at`). Joins("LEFT JOIN users AS u ON u.id = wl.user_id"). Joins("LEFT JOIN rental_orders AS ro ON ro.id = wl.order_id") countDB := r.db.WithContext(ctx).Model(&model.WalletLedger{}) if query.UserID > 0 { db = db.Where("wl.user_id = ?", query.UserID) countDB = countDB.Where("user_id = ?", query.UserID) } if query.OrderID > 0 { db = db.Where("wl.order_id = ?", query.OrderID) countDB = countDB.Where("order_id = ?", query.OrderID) } if query.BizType != "" { db = db.Where("wl.biz_type = ?", query.BizType) countDB = countDB.Where("biz_type = ?", query.BizType) } var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, err } offset := (query.Page - 1) * query.PageSize items := make([]AdminLedgerDTO, 0, query.PageSize) if err := db.Order("wl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&items).Error; err != nil { return nil, err } return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil } func AppendEntries(tx *gorm.DB, entries ...Entry) error { for _, entry := range entries { if entry.AmountCent <= 0 { continue } if err := ensureAccount(tx, entry.UserID, entry.DistributionSiteID); err != nil { return err } var account model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ? AND distribution_site_id = ?", entry.UserID, entry.DistributionSiteID).First(&account).Error; err != nil { return err } balanceAfterCent, err := applyEntry(&account, entry) if err != nil { return err } if err := tx.Save(&account).Error; err != nil { return err } ledgerNo, err := newLedgerNo() if err != nil { return err } ledger := model.WalletLedger{ LedgerNo: ledgerNo, UserID: entry.UserID, DistributionSiteID: entry.DistributionSiteID, OrderID: entry.OrderID, Direction: entry.Direction, AmountCent: entry.AmountCent, BalanceAfterCent: balanceAfterCent, BalanceType: entry.BalanceType, BizType: entry.BizType, BizNo: entry.BizNo, Remark: entry.Remark, } if err := tx.Create(&ledger).Error; err != nil { return err } } return nil } func ensureAccount(tx *gorm.DB, userID uint64, siteID ...uint64) error { return EnsureAccountTx(tx, userID, siteID...) } // EnsureAccountTx 在事务内确保用户钱包账户存在(幂等)。 func EnsureAccountTx(tx *gorm.DB, userID uint64, siteID ...uint64) error { var sID uint64 if len(siteID) > 0 { sID = siteID[0] } account := model.WalletAccount{ UserID: userID, DistributionSiteID: sID, AvailableBalanceCent: 0, FrozenBalanceCent: 0, Status: "active", } return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error } func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) { switch entry.BalanceType { case "available": if entry.Direction == "in" { account.AvailableBalanceCent += entry.AmountCent } else { if account.AvailableBalanceCent < entry.AmountCent && !entry.AllowNegative { return 0, ErrInsufficientBalance } account.AvailableBalanceCent -= entry.AmountCent } return account.AvailableBalanceCent, nil case "frozen": if entry.Direction == "in" { account.FrozenBalanceCent += entry.AmountCent } else { if account.FrozenBalanceCent < entry.AmountCent { return 0, ErrInsufficientBalance } account.FrozenBalanceCent -= entry.AmountCent } return account.FrozenBalanceCent, nil default: return 0, fmt.Errorf("unknown balance type: %s", entry.BalanceType) } } func roundWalletMoney(yuan float64) float64 { return money.Round(yuan) } func toAccountDTO(account model.WalletAccount) *AccountDTO { return &AccountDTO{ UserID: account.UserID, DistributionSiteID: account.DistributionSiteID, AvailableBalanceCent: account.AvailableBalanceCent, FrozenBalanceCent: account.FrozenBalanceCent, Status: account.Status, } } func toLedgerDTO(ledger model.WalletLedger) LedgerDTO { return LedgerDTO{ ID: ledger.ID, LedgerNo: ledger.LedgerNo, UserID: ledger.UserID, DistributionSiteID: ledger.DistributionSiteID, OrderID: ledger.OrderID, Direction: ledger.Direction, AmountCent: ledger.AmountCent, BalanceAfterCent: ledger.BalanceAfterCent, BalanceType: ledger.BalanceType, BizType: ledger.BizType, BizNo: ledger.BizNo, Remark: ledger.Remark, CreatedAt: ledger.CreatedAt, } } func newLedgerNo() (string, error) { now := timeutil.ShanghaiNow() prefix := "WL" + now.Format("20060102150405") randomBytes := make([]byte, 4) if _, err := rand.Read(randomBytes); err != nil { return "", err } suffix := fmt.Sprintf("%08d", uint32(randomBytes[0])<<24|uint32(randomBytes[1])<<16|uint32(randomBytes[2])<<8|uint32(randomBytes[3])) return prefix + suffix[:7], nil }