chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Wallet Module
|
||||
|
||||
押金冻结、订单金额结算、不可变资金流水。
|
||||
@@ -0,0 +1,64 @@
|
||||
package wallet
|
||||
|
||||
import "time"
|
||||
|
||||
type AccountDTO struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
DistributionSiteID uint64 `json:"distribution_site_id"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type WithdrawRequest struct {
|
||||
AmountCent int64 `json:"amount_cent" binding:"required"`
|
||||
}
|
||||
|
||||
type LedgerDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
LedgerNo string `json:"ledger_no"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
DistributionSiteID uint64 `json:"distribution_site_id"`
|
||||
OrderID *uint64 `json:"order_id"`
|
||||
Direction string `json:"direction"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
BalanceAfterCent int64 `json:"balance_after_cent"`
|
||||
BalanceType string `json:"balance_type"`
|
||||
BizType string `json:"biz_type"`
|
||||
BizNo string `json:"biz_no"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AdminLedgerQuery struct {
|
||||
UserID uint64
|
||||
OrderID uint64
|
||||
BizType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type PaginatedResult struct {
|
||||
Items interface{} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type AdminLedgerDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
LedgerNo string `json:"ledger_no"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserPhone string `json:"user_phone"`
|
||||
UserNickname string `json:"user_nickname"`
|
||||
OrderID *uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Direction string `json:"direction"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
BalanceAfterCent int64 `json:"balance_after_cent"`
|
||||
BalanceType string `json:"balance_type"`
|
||||
BizType string `json:"biz_type"`
|
||||
BizNo string `json:"biz_no"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/sitecode"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func parseSiteCode(c *gin.Context) string {
|
||||
return sitecode.FromRequest(c)
|
||||
}
|
||||
|
||||
func (h *Handler) Balance(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
siteCode := parseSiteCode(c)
|
||||
account, err := h.service.Account(c.Request.Context(), userID, siteCode)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, account)
|
||||
}
|
||||
|
||||
func parsePagination(c *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
func (h *Handler) Ledger(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
page, pageSize := parsePagination(c)
|
||||
siteCode := parseSiteCode(c)
|
||||
result, err := h.service.Ledger(c.Request.Context(), userID, page, pageSize, siteCode)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Withdraw(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req WithdrawRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "提现金额不正确")
|
||||
return
|
||||
}
|
||||
account, err := h.service.Withdraw(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, account)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminLedger(c *gin.Context) {
|
||||
query, ok := parseAdminLedgerQuery(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.AdminLedger(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
||||
var query AdminLedgerQuery
|
||||
if raw := c.Query("user_id"); raw != "" {
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
response.BadRequest(c, "用户 ID 不正确")
|
||||
return query, false
|
||||
}
|
||||
query.UserID = value
|
||||
}
|
||||
if raw := c.Query("order_id"); raw != "" {
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
response.BadRequest(c, "订单 ID 不正确")
|
||||
return query, false
|
||||
}
|
||||
query.OrderID = value
|
||||
}
|
||||
query.BizType = c.Query("biz_type")
|
||||
page, pageSize := parsePagination(c)
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
return query, true
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func writeWalletError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidAmount):
|
||||
response.BadRequest(c, "充值金额不正确")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(c, 409, "insufficient_balance", "钱包余额不足")
|
||||
case errors.Is(err, ErrFeaturePending):
|
||||
response.Error(c, 501, "feature_pending", "提现功能待开发")
|
||||
default:
|
||||
response.ServiceUnavailable(c, "钱包服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// setupTestDB 创建测试数据库连接(MySQL 测试库)
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
db := database.NewTestDB()
|
||||
|
||||
// 迁移必要的表
|
||||
if err := database.AutoMigrateIfMissing(db,
|
||||
&model.WalletAccount{},
|
||||
&model.WalletLedger{},
|
||||
&model.DistributionSite{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// cleanupTestDB 清理测试数据(内存数据库无需清理)
|
||||
func cleanupTestDB(t *testing.T, db *gorm.DB) {
|
||||
// 内存数据库,测试结束后自动清理
|
||||
}
|
||||
|
||||
// TestRepositoryAccountCreatesAccountIfNotExists 测试账户自动创建
|
||||
func TestRepositoryAccountCreatesAccountIfNotExists(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := uint64(1001)
|
||||
|
||||
account, err := repo.Account(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("Account() error = %v", err)
|
||||
}
|
||||
|
||||
if account.UserID != userID {
|
||||
t.Fatalf("UserID = %d, want %d", account.UserID, userID)
|
||||
}
|
||||
if account.AvailableBalanceCent != 0 {
|
||||
t.Fatalf("AvailableBalanceCent = %d, want 0", account.AvailableBalanceCent)
|
||||
}
|
||||
if account.FrozenBalanceCent != 0 {
|
||||
t.Fatalf("FrozenBalanceCent = %d, want 0", account.FrozenBalanceCent)
|
||||
}
|
||||
if account.Status != "active" {
|
||||
t.Fatalf("Status = %s, want active", account.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendEntriesUpdatesBalanceCorrectly 测试 AppendEntries 余额计算
|
||||
func TestAppendEntriesUpdatesBalanceCorrectly(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
userID := uint64(1005)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
entries []Entry
|
||||
wantAvailable int64
|
||||
wantFrozen int64
|
||||
}{
|
||||
{
|
||||
name: "可用余额入账",
|
||||
entries: []Entry{
|
||||
{UserID: userID, Direction: "in", AmountCent: 10000, BalanceType: "available", BizType: "test", BizNo: "T1"},
|
||||
},
|
||||
wantAvailable: 10000,
|
||||
wantFrozen: 0,
|
||||
},
|
||||
{
|
||||
name: "冻结余额入账",
|
||||
entries: []Entry{
|
||||
{UserID: userID, Direction: "in", AmountCent: 5000, BalanceType: "frozen", BizType: "test", BizNo: "T2"},
|
||||
},
|
||||
wantAvailable: 10000,
|
||||
wantFrozen: 5000,
|
||||
},
|
||||
{
|
||||
name: "可用余额出账",
|
||||
entries: []Entry{
|
||||
{UserID: userID, Direction: "out", AmountCent: 3000, BalanceType: "available", BizType: "test", BizNo: "T3"},
|
||||
},
|
||||
wantAvailable: 7000,
|
||||
wantFrozen: 5000,
|
||||
},
|
||||
{
|
||||
name: "冻结余额出账",
|
||||
entries: []Entry{
|
||||
{UserID: userID, Direction: "out", AmountCent: 2000, BalanceType: "frozen", BizType: "test", BizNo: "T4"},
|
||||
},
|
||||
wantAvailable: 7000,
|
||||
wantFrozen: 3000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, tc.entries...)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEntries() error = %v", err)
|
||||
}
|
||||
|
||||
var account model.WalletAccount
|
||||
db.Where("user_id = ?", userID).First(&account)
|
||||
|
||||
if account.AvailableBalanceCent != tc.wantAvailable {
|
||||
t.Fatalf("AvailableBalanceCent = %d, want %d", account.AvailableBalanceCent, tc.wantAvailable)
|
||||
}
|
||||
if account.FrozenBalanceCent != tc.wantFrozen {
|
||||
t.Fatalf("FrozenBalanceCent = %d, want %d", account.FrozenBalanceCent, tc.wantFrozen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendEntriesRejectsInsufficientBalance 测试余额不足时拒绝扣款
|
||||
func TestAppendEntriesRejectsInsufficientBalance(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
userID := uint64(1006)
|
||||
|
||||
// 先入账 1000
|
||||
db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
Direction: "in",
|
||||
AmountCent: 1000,
|
||||
BalanceType: "available",
|
||||
BizType: "test",
|
||||
BizNo: "INIT",
|
||||
})
|
||||
})
|
||||
|
||||
// 尝试扣款 2000(余额不足)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
Direction: "out",
|
||||
AmountCent: 2000,
|
||||
BalanceType: "available",
|
||||
BizType: "test",
|
||||
BizNo: "FAIL",
|
||||
})
|
||||
})
|
||||
|
||||
if err != ErrInsufficientBalance {
|
||||
t.Fatalf("error = %v, want ErrInsufficientBalance", err)
|
||||
}
|
||||
|
||||
// 验证余额未变化
|
||||
var account model.WalletAccount
|
||||
db.Where("user_id = ?", userID).First(&account)
|
||||
if account.AvailableBalanceCent != 1000 {
|
||||
t.Fatalf("余额 = %d, want 1000(回滚后应保持不变)", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendEntriesSkipsZeroAmount 测试跳过零金额条目
|
||||
func TestAppendEntriesSkipsZeroAmount(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
userID := uint64(1007)
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx,
|
||||
Entry{UserID: userID, Direction: "in", AmountCent: 0, BalanceType: "available", BizType: "test", BizNo: "ZERO"},
|
||||
Entry{UserID: userID, Direction: "in", AmountCent: 1000, BalanceType: "available", BizType: "test", BizNo: "VALID"},
|
||||
)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEntries() error = %v", err)
|
||||
}
|
||||
|
||||
// 验证只有 1 条账本记录(零金额被跳过)
|
||||
var count int64
|
||||
db.Model(&model.WalletLedger{}).Where("user_id = ?", userID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("账本记录数 = %d, want 1(零金额应被跳过)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepositoryLedgerPagination 测试账本分页
|
||||
func TestRepositoryLedgerPagination(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := uint64(1008)
|
||||
|
||||
// 创建 25 条记录
|
||||
entries := make([]Entry, 25)
|
||||
for i := 0; i < 25; i++ {
|
||||
entries[i] = Entry{
|
||||
UserID: userID,
|
||||
Direction: "in",
|
||||
AmountCent: 100,
|
||||
BalanceType: "available",
|
||||
BizType: "test",
|
||||
BizNo: "PAGE" + string(rune(i)),
|
||||
}
|
||||
}
|
||||
db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, entries...)
|
||||
})
|
||||
|
||||
// 测试第一页
|
||||
page1, err := repo.Ledger(ctx, userID, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Ledger() page 1 error = %v", err)
|
||||
}
|
||||
if page1.Total != 25 {
|
||||
t.Fatalf("Total = %d, want 25", page1.Total)
|
||||
}
|
||||
|
||||
// 断言 Items 类型并检查长度
|
||||
items1, ok := page1.Items.([]LedgerDTO)
|
||||
if !ok {
|
||||
t.Fatalf("Items type = %T, want []LedgerDTO", page1.Items)
|
||||
}
|
||||
if len(items1) != 10 {
|
||||
t.Fatalf("Page 1 items = %d, want 10", len(items1))
|
||||
}
|
||||
|
||||
// 测试第三页
|
||||
page3, err := repo.Ledger(ctx, userID, 3, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Ledger() page 3 error = %v", err)
|
||||
}
|
||||
|
||||
items3, ok := page3.Items.([]LedgerDTO)
|
||||
if !ok {
|
||||
t.Fatalf("Items type = %T, want []LedgerDTO", page3.Items)
|
||||
}
|
||||
if len(items3) != 5 {
|
||||
t.Fatalf("Page 3 items = %d, want 5", len(items3))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletTenantIsolation(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := uint64(2001)
|
||||
|
||||
// 创建测试分销站点
|
||||
site := model.DistributionSite{
|
||||
ID: 10,
|
||||
SiteCode: "branch_alpha",
|
||||
SiteName: "阿尔法分站",
|
||||
Domain: "alpha.hfb.com",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatalf("创建分销站点失败: %v", err)
|
||||
}
|
||||
|
||||
// 1. 在主站入账 5000 分
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
DistributionSiteID: 0,
|
||||
Direction: "in",
|
||||
AmountCent: 5000,
|
||||
BalanceType: "available",
|
||||
BizType: "main_recharge",
|
||||
BizNo: "MAIN_001",
|
||||
Remark: "主站充值",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("主站充值失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 在分销站点入账 3000 分
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
DistributionSiteID: site.ID,
|
||||
Direction: "in",
|
||||
AmountCent: 3000,
|
||||
BalanceType: "available",
|
||||
BizType: "site_income",
|
||||
BizNo: "SITE_001",
|
||||
Remark: "分销站收益",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("分站入账失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 校验主站钱包余额
|
||||
mainAccount, err := repo.Account(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询主站钱包失败: %v", err)
|
||||
}
|
||||
if mainAccount.AvailableBalanceCent != 5000 {
|
||||
t.Fatalf("主站余额 = %d, want 5000", mainAccount.AvailableBalanceCent)
|
||||
}
|
||||
if mainAccount.DistributionSiteID != 0 {
|
||||
t.Fatalf("主站账号 DistributionSiteID = %d, want 0", mainAccount.DistributionSiteID)
|
||||
}
|
||||
|
||||
// 4. 校验分销站点钱包余额
|
||||
siteAccount, err := repo.Account(ctx, userID, site.SiteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("查询分站钱包失败: %v", err)
|
||||
}
|
||||
if siteAccount.AvailableBalanceCent != 3000 {
|
||||
t.Fatalf("分站余额 = %d, want 3000", siteAccount.AvailableBalanceCent)
|
||||
}
|
||||
if siteAccount.DistributionSiteID != site.ID {
|
||||
t.Fatalf("分站账号 DistributionSiteID = %d, want %d", siteAccount.DistributionSiteID, site.ID)
|
||||
}
|
||||
|
||||
// 5. 校验流水隔离
|
||||
mainLedger, err := repo.Ledger(ctx, userID, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("查询主站流水失败: %v", err)
|
||||
}
|
||||
if mainLedger.Total != 1 {
|
||||
t.Fatalf("主站流水条数 = %d, want 1", mainLedger.Total)
|
||||
}
|
||||
mainItems := mainLedger.Items.([]LedgerDTO)
|
||||
if mainItems[0].BizNo != "MAIN_001" {
|
||||
t.Fatalf("主站流水第一条 BizNo = %s, want MAIN_001", mainItems[0].BizNo)
|
||||
}
|
||||
|
||||
siteLedger, err := repo.Ledger(ctx, userID, 1, 10, site.SiteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("查询分站流水失败: %v", err)
|
||||
}
|
||||
if siteLedger.Total != 1 {
|
||||
t.Fatalf("分站流水条数 = %d, want 1", siteLedger.Total)
|
||||
}
|
||||
siteItems := siteLedger.Items.([]LedgerDTO)
|
||||
if siteItems[0].BizNo != "SITE_001" {
|
||||
t.Fatalf("分站流水第一条 BizNo = %s, want SITE_001", siteItems[0].BizNo)
|
||||
}
|
||||
|
||||
// 6. 分站扣款 2000 分,主站余额不受任何影响
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
DistributionSiteID: site.ID,
|
||||
Direction: "out",
|
||||
AmountCent: 2000,
|
||||
BalanceType: "available",
|
||||
BizType: "site_withdraw",
|
||||
BizNo: "SITE_WD_001",
|
||||
Remark: "分站提现支出",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("分站扣款失败: %v", err)
|
||||
}
|
||||
|
||||
siteAccountAfter, err := repo.Account(ctx, userID, site.SiteCode)
|
||||
if err != nil {
|
||||
t.Fatalf("查询扣款后分站钱包失败: %v", err)
|
||||
}
|
||||
if siteAccountAfter.AvailableBalanceCent != 1000 {
|
||||
t.Fatalf("分站扣款后余额 = %d, want 1000", siteAccountAfter.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
mainAccountAfter, err := repo.Account(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("再次查询主站钱包失败: %v", err)
|
||||
}
|
||||
if mainAccountAfter.AvailableBalanceCent != 5000 {
|
||||
t.Fatalf("主站余额受影响: got %d, want 5000", mainAccountAfter.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
// TestAppendEntriesLogic 测试 AppendEntries 核心逻辑(不依赖数据库)
|
||||
func TestAppendEntriesValidatesEntry(t *testing.T) {
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "in",
|
||||
AmountCent: 10000,
|
||||
BalanceType: "available",
|
||||
BizType: "test",
|
||||
BizNo: "TEST001",
|
||||
}
|
||||
|
||||
if entry.UserID == 0 {
|
||||
t.Fatal("UserID should not be 0")
|
||||
}
|
||||
if entry.AmountCent <= 0 {
|
||||
t.Fatal("AmountCent should be positive")
|
||||
}
|
||||
if entry.Direction != "in" && entry.Direction != "out" {
|
||||
t.Fatal("Direction should be 'in' or 'out'")
|
||||
}
|
||||
if entry.BalanceType != "available" && entry.BalanceType != "frozen" {
|
||||
t.Fatal("BalanceType should be 'available' or 'frozen'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryAvailableBalanceIn 测试可用余额入账
|
||||
func TestApplyEntryAvailableBalanceIn(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 5000,
|
||||
FrozenBalanceCent: 2000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "in",
|
||||
AmountCent: 3000,
|
||||
BalanceType: "available",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if account.AvailableBalanceCent != 8000 {
|
||||
t.Fatalf("AvailableBalanceCent = %d, want 8000", account.AvailableBalanceCent)
|
||||
}
|
||||
if balanceAfter != 8000 {
|
||||
t.Fatalf("balanceAfter = %d, want 8000", balanceAfter)
|
||||
}
|
||||
if account.FrozenBalanceCent != 2000 {
|
||||
t.Fatalf("FrozenBalanceCent changed to %d, want unchanged 2000", account.FrozenBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryAvailableBalanceOut 测试可用余额出账
|
||||
func TestApplyEntryAvailableBalanceOut(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 5000,
|
||||
FrozenBalanceCent: 2000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 3000,
|
||||
BalanceType: "available",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if account.AvailableBalanceCent != 2000 {
|
||||
t.Fatalf("AvailableBalanceCent = %d, want 2000", account.AvailableBalanceCent)
|
||||
}
|
||||
if balanceAfter != 2000 {
|
||||
t.Fatalf("balanceAfter = %d, want 2000", balanceAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryFrozenBalanceIn 测试冻结余额入账
|
||||
func TestApplyEntryFrozenBalanceIn(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 5000,
|
||||
FrozenBalanceCent: 2000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "in",
|
||||
AmountCent: 1000,
|
||||
BalanceType: "frozen",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if account.FrozenBalanceCent != 3000 {
|
||||
t.Fatalf("FrozenBalanceCent = %d, want 3000", account.FrozenBalanceCent)
|
||||
}
|
||||
if balanceAfter != 3000 {
|
||||
t.Fatalf("balanceAfter = %d, want 3000", balanceAfter)
|
||||
}
|
||||
if account.AvailableBalanceCent != 5000 {
|
||||
t.Fatalf("AvailableBalanceCent changed to %d, want unchanged 5000", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryFrozenBalanceOut 测试冻结余额出账
|
||||
func TestApplyEntryFrozenBalanceOut(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 5000,
|
||||
FrozenBalanceCent: 3000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 1500,
|
||||
BalanceType: "frozen",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if account.FrozenBalanceCent != 1500 {
|
||||
t.Fatalf("FrozenBalanceCent = %d, want 1500", account.FrozenBalanceCent)
|
||||
}
|
||||
if balanceAfter != 1500 {
|
||||
t.Fatalf("balanceAfter = %d, want 1500", balanceAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryInsufficientAvailableBalance 测试可用余额不足
|
||||
func TestApplyEntryInsufficientAvailableBalance(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 1000,
|
||||
FrozenBalanceCent: 2000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 2000,
|
||||
BalanceType: "available",
|
||||
}
|
||||
|
||||
_, err := applyEntry(account, entry)
|
||||
if !errors.Is(err, ErrInsufficientBalance) {
|
||||
t.Fatalf("error = %v, want ErrInsufficientBalance", err)
|
||||
}
|
||||
|
||||
// 验证余额未变化
|
||||
if account.AvailableBalanceCent != 1000 {
|
||||
t.Fatalf("AvailableBalanceCent = %d, should remain 1000 on error", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryInsufficientFrozenBalance 测试冻结余额不足
|
||||
func TestApplyEntryInsufficientFrozenBalance(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 5000,
|
||||
FrozenBalanceCent: 1000,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 2000,
|
||||
BalanceType: "frozen",
|
||||
}
|
||||
|
||||
_, err := applyEntry(account, entry)
|
||||
if !errors.Is(err, ErrInsufficientBalance) {
|
||||
t.Fatalf("error = %v, want ErrInsufficientBalance", err)
|
||||
}
|
||||
|
||||
// 验证余额未变化
|
||||
if account.FrozenBalanceCent != 1000 {
|
||||
t.Fatalf("FrozenBalanceCent = %d, should remain 1000 on error", account.FrozenBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryMultipleOperations 测试连续多次操作
|
||||
func TestApplyEntryMultipleOperations(t *testing.T) {
|
||||
account := &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 0,
|
||||
FrozenBalanceCent: 0,
|
||||
}
|
||||
|
||||
operations := []struct {
|
||||
entry Entry
|
||||
wantAvl int64
|
||||
wantFrz int64
|
||||
}{
|
||||
{
|
||||
entry: Entry{UserID: 1001, Direction: "in", AmountCent: 10000, BalanceType: "available"},
|
||||
wantAvl: 10000,
|
||||
wantFrz: 0,
|
||||
},
|
||||
{
|
||||
entry: Entry{UserID: 1001, Direction: "in", AmountCent: 5000, BalanceType: "frozen"},
|
||||
wantAvl: 10000,
|
||||
wantFrz: 5000,
|
||||
},
|
||||
{
|
||||
entry: Entry{UserID: 1001, Direction: "out", AmountCent: 3000, BalanceType: "available"},
|
||||
wantAvl: 7000,
|
||||
wantFrz: 5000,
|
||||
},
|
||||
{
|
||||
entry: Entry{UserID: 1001, Direction: "out", AmountCent: 2000, BalanceType: "frozen"},
|
||||
wantAvl: 7000,
|
||||
wantFrz: 3000,
|
||||
},
|
||||
}
|
||||
|
||||
for i, op := range operations {
|
||||
_, err := applyEntry(account, op.entry)
|
||||
if err != nil {
|
||||
t.Fatalf("operation %d: applyEntry() error = %v", i, err)
|
||||
}
|
||||
|
||||
if account.AvailableBalanceCent != op.wantAvl {
|
||||
t.Fatalf("operation %d: AvailableBalanceCent = %d, want %d",
|
||||
i, account.AvailableBalanceCent, op.wantAvl)
|
||||
}
|
||||
if account.FrozenBalanceCent != op.wantFrz {
|
||||
t.Fatalf("operation %d: FrozenBalanceCent = %d, want %d",
|
||||
i, account.FrozenBalanceCent, op.wantFrz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEntryEdgeCases 测试边界情况
|
||||
func TestApplyEntryEdgeCases(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
account *model.WalletAccount
|
||||
entry Entry
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "余额刚好够扣",
|
||||
account: &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 1000,
|
||||
},
|
||||
entry: Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 1000,
|
||||
BalanceType: "available",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "余额差1分不够扣",
|
||||
account: &model.WalletAccount{
|
||||
UserID: 1001,
|
||||
AvailableBalanceCent: 999,
|
||||
},
|
||||
entry: Entry{
|
||||
UserID: 1001,
|
||||
Direction: "out",
|
||||
AmountCent: 1000,
|
||||
BalanceType: "available",
|
||||
},
|
||||
wantErr: ErrInsufficientBalance,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := applyEntry(tc.account, tc.entry)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestApplyEntryRoundsMoneyBeforeComparing(t *testing.T) {
|
||||
account := model.WalletAccount{
|
||||
UserID: 7,
|
||||
FrozenBalanceCent: 60641,
|
||||
}
|
||||
entry := Entry{
|
||||
UserID: 7,
|
||||
Direction: "out",
|
||||
AmountCent: 60641,
|
||||
BalanceType: "frozen",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(&account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
if balanceAfter != 0 {
|
||||
t.Fatalf("balanceAfter = %v, want 0", balanceAfter)
|
||||
}
|
||||
if account.FrozenBalanceCent != 0 {
|
||||
t.Fatalf("FrozenBalanceCent = %d, want 0", account.FrozenBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEntryKeepsWalletMoneyAtJiaoPrecision(t *testing.T) {
|
||||
account := model.WalletAccount{
|
||||
UserID: 8,
|
||||
AvailableBalanceCent: 0,
|
||||
}
|
||||
entry := Entry{
|
||||
UserID: 8,
|
||||
Direction: "in",
|
||||
AmountCent: 230,
|
||||
BalanceType: "available",
|
||||
}
|
||||
|
||||
balanceAfter, err := applyEntry(&account, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("applyEntry() error = %v", err)
|
||||
}
|
||||
if balanceAfter != 230 {
|
||||
t.Fatalf("balanceAfter = %d, want 230", balanceAfter)
|
||||
}
|
||||
if account.AvailableBalanceCent != 230 {
|
||||
t.Fatalf("AvailableBalanceCent = %d, want 230", account.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
if rounded := roundWalletMoney(2.36); rounded != 2.4 {
|
||||
t.Fatalf("roundWalletMoney(2.36) = %v, want 2.4", rounded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLedgerNoUsesReadableFormat(t *testing.T) {
|
||||
ledgerNo, err := newLedgerNo()
|
||||
if err != nil {
|
||||
t.Fatalf("newLedgerNo() error = %v", err)
|
||||
}
|
||||
matched := regexp.MustCompile(`^WL\d{21}$`).MatchString(ledgerNo)
|
||||
if !matched {
|
||||
t.Fatalf("ledgerNo = %q, want WL + 21 digits", ledgerNo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidAmount = errors.New("invalid amount")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrFeaturePending = errors.New("feature pending")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Account(ctx context.Context, userID uint64, siteCode ...string) (*AccountDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.Account(ctx, userID, siteCode...)
|
||||
}
|
||||
|
||||
func (s *Service) Ledger(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.Ledger(ctx, userID, page, pageSize, siteCode...)
|
||||
}
|
||||
|
||||
func (s *Service) Withdraw(ctx context.Context, userID uint64, req WithdrawRequest) (*AccountDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
// 提现功能已迁移到 withdrawal 模块
|
||||
// 用户应通过 /api/withdrawals 接口提交提现申请
|
||||
return nil, ErrFeaturePending
|
||||
}
|
||||
|
||||
func (s *Service) AdminLedger(ctx context.Context, query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.AdminLedger(ctx, query)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestServiceAccountWithNilRepo 测试依赖检查
|
||||
func TestServiceAccountWithNilRepo(t *testing.T) {
|
||||
svc := &Service{repo: nil}
|
||||
ctx := context.Background()
|
||||
_, err := svc.Account(ctx, 1)
|
||||
if !errors.Is(err, ErrDependencyUnavailable) {
|
||||
t.Fatalf("Account() error = %v, want ErrDependencyUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLedgerWithNilRepo(t *testing.T) {
|
||||
svc := &Service{repo: nil}
|
||||
ctx := context.Background()
|
||||
_, err := svc.Ledger(ctx, 1, 1, 10)
|
||||
if !errors.Is(err, ErrDependencyUnavailable) {
|
||||
t.Fatalf("Ledger() error = %v, want ErrDependencyUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithdrawIsPending(t *testing.T) {
|
||||
svc := &Service{repo: &Repository{}}
|
||||
ctx := context.Background()
|
||||
_, err := svc.Withdraw(ctx, 1, WithdrawRequest{AmountCent: 100})
|
||||
if !errors.Is(err, ErrFeaturePending) {
|
||||
t.Fatalf("Withdraw() error = %v, want ErrFeaturePending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAdminLedgerWithNilRepo(t *testing.T) {
|
||||
svc := &Service{repo: nil}
|
||||
ctx := context.Background()
|
||||
_, err := svc.AdminLedger(ctx, AdminLedgerQuery{Page: 1, PageSize: 10})
|
||||
if !errors.Is(err, ErrDependencyUnavailable) {
|
||||
t.Fatalf("AdminLedger() error = %v, want ErrDependencyUnavailable", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user