package wallet import ( "crypto/rand" "fmt" "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 OrderID *uint64 Direction string AmountCent int64 BalanceType string BizType string BizNo string Remark string } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } func (r *Repository) Account(userID uint64) (*AccountDTO, error) { var account model.WalletAccount err := r.db.Transaction(func(tx *gorm.DB) error { if err := ensureAccount(tx, userID); err != nil { return err } return tx.Where("user_id = ?", userID).First(&account).Error }) if err != nil { return nil, err } return toAccountDTO(account), nil } func (r *Repository) Ledger(userID uint64, page, pageSize int) (*PaginatedResult, error) { var total int64 if err := r.db.Model(&model.WalletLedger{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var rows []model.WalletLedger if err := r.db.Where("user_id = ?", userID).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 } func (r *Repository) Recharge(userID uint64, amountCent int64) (*AccountDTO, error) { err := r.db.Transaction(func(tx *gorm.DB) error { return AppendEntries(tx, Entry{ UserID: userID, Direction: "in", AmountCent: amountCent, BalanceType: "available", BizType: "dev_recharge", BizNo: "DEV", Remark: "开发环境充值", }) }) if err != nil { return nil, err } return r.Account(userID) } func (r *Repository) ConfirmRechargeFromChannel(userID uint64, bizNo string, amountCent int64) error { if userID == 0 || amountCent <= 0 || bizNo == "" { return ErrInvalidAmount } return r.db.Transaction(func(tx *gorm.DB) error { if err := ensureAccount(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 } var existing int64 if err := tx.Model(&model.WalletLedger{}). Where("user_id = ? AND biz_type = ? AND biz_no = ?", userID, "channel_recharge", bizNo). Count(&existing).Error; err != nil { return err } if existing > 0 { return nil } return AppendEntries(tx, Entry{ UserID: userID, Direction: "in", AmountCent: amountCent, BalanceType: "available", BizType: "channel_recharge", BizNo: bizNo, Remark: "渠道充值入账", }) }) } // Withdraw 保留仓库能力;当前公开提现入口在 service 层标记为待开发,不会调用到这里。 func (r *Repository) Withdraw(userID uint64, amountCent int64) (*AccountDTO, error) { if userID == 0 || amountCent <= 0 { return nil, ErrInvalidAmount } err := r.db.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(userID) } func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) { db := r.db.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.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); err != nil { return err } var account model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", entry.UserID).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, 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) error { account := model.WalletAccount{ UserID: userID, 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 { 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("unsupported balance type: %s", entry.BalanceType) } } func roundWalletMoney(value float64) float64 { return money.Round(value) } func toAccountDTO(account model.WalletAccount) *AccountDTO { return &AccountDTO{ UserID: account.UserID, AvailableBalanceCent: account.AvailableBalanceCent, FrozenBalanceCent: account.FrozenBalanceCent, Status: account.Status, } } func toLedgerDTO(row model.WalletLedger) LedgerDTO { return LedgerDTO{ ID: row.ID, LedgerNo: row.LedgerNo, UserID: row.UserID, OrderID: row.OrderID, Direction: row.Direction, AmountCent: row.AmountCent, BalanceAfterCent: row.BalanceAfterCent, BalanceType: row.BalanceType, BizType: row.BizType, BizNo: row.BizNo, Remark: row.Remark, CreatedAt: row.CreatedAt, } } func newLedgerNo() (string, error) { // 生成格式:WL + YYYYMMDDHHMMSS + 毫秒 + 4位随机数 // 例如:WL202606051234561230456,便于用户和客服识别。 now := timeutil.ShanghaiNow() buf := make([]byte, 2) if _, err := rand.Read(buf); err != nil { return "", err } randomNum := (int(buf[0])<<8 | int(buf[1])) % 10000 return fmt.Sprintf("WL%s%03d%04d", now.Format("20060102150405"), now.Nanosecond()/1_000_000, randomNum), nil }