chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,3 @@
# Wallet Module
押金冻结、订单金额结算、不可变资金流水。
+64
View File
@@ -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"`
}
+152
View File
@@ -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)
}
}