Files
hfb_sys/backend/internal/modules/withdrawal/user.go
T
yml2213 e024319860 feat(distribution): 分销站点消息中心与钱包提现多租户独立分支隔离
- 新增 000070 数据库迁移:支持 chat_conversations、wallet_accounts、wallet_ledger、withdrawal_requests 按 distribution_site_id 隔离
- 消息中心:会话列表、未读数统计、站点专属客服及订单群聊与主站实现双向完全隔离
- 资金账户与提现:独立按 (user_id, distribution_site_id) 建账,分销订单自动结算至对应分站钱包,提现与流水相互独立
- 前台界面:Axios 自动透传 X-Distribution-Site-Code,钱包、提现与消息页面呈现分站专属标牌与独立说明
2026-09-07 18:11:27 +08:00

241 lines
6.6 KiB
Go

package withdrawal
import (
"context"
"errors"
"strings"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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) Create(ctx context.Context, userID uint64, req CreateWithdrawalRequest, siteCode ...string) (*WithdrawalDTO, error) {
db := r.db.WithContext(ctx)
// 验证收款账号
var paymentAccount model.UserPaymentAccount
if err := db.Where("id = ? AND user_id = ? AND status = ?", req.PaymentAccountID, userID, "active").
First(&paymentAccount).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("payment account not found")
}
return nil, err
}
var code string
if len(siteCode) > 0 {
code = siteCode[0]
}
siteID, err := r.resolveSiteID(ctx, code)
if err != nil {
return nil, err
}
// 计算手续费和实际到账金额(分)
feeCent := int64(float64(req.AmountCent) * WithdrawalFeeRate)
actualAmountCent := req.AmountCent - feeCent
// 生成提现单号
withdrawNo, err := generateWithdrawNo()
if err != nil {
return nil, err
}
// 解密账号(用于存储快照)
decryptedNo, err := r.decryptAccountNo(paymentAccount.AccountNo)
if err != nil {
decryptedNo = paymentAccount.AccountNo
}
withdrawal := model.WithdrawalRequest{
WithdrawNo: withdrawNo,
UserID: userID,
DistributionSiteID: siteID,
AmountCent: req.AmountCent,
FeeCent: feeCent,
ActualAmountCent: actualAmountCent,
PaymentAccountID: &req.PaymentAccountID,
AccountType: paymentAccount.AccountType,
AccountName: paymentAccount.AccountName,
AccountNo: maskAccountNo(decryptedNo, paymentAccount.AccountType),
BankName: paymentAccount.BankName,
BankBranch: paymentAccount.BankBranch,
Status: "pending",
}
// 事务处理
err = db.Transaction(func(tx *gorm.DB) error {
// 创建提现申请
if err := tx.Create(&withdrawal).Error; err != nil {
return err
}
// 冻结用户余额
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: userID,
DistributionSiteID: siteID,
Direction: "out",
AmountCent: req.AmountCent,
BalanceType: "available",
BizType: "withdraw_freeze",
BizNo: withdrawNo,
Remark: "提现冻结",
}, wallet.Entry{
UserID: userID,
DistributionSiteID: siteID,
Direction: "in",
AmountCent: req.AmountCent,
BalanceType: "frozen",
BizType: "withdraw_freeze",
BizNo: withdrawNo,
Remark: "提现冻结",
}); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return r.FindByID(ctx, userID, withdrawal.ID)
}
// 用户查询提现列表
func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) {
db := r.db.WithContext(ctx)
var code string
if len(siteCode) > 0 {
code = siteCode[0]
}
siteID, err := r.resolveSiteID(ctx, code)
if err != nil {
return nil, err
}
countDB := db.Model(&model.WithdrawalRequest{}).Where("user_id = ?", userID)
dataDB := db.Where("user_id = ?", userID)
if siteID > 0 {
countDB = countDB.Where("distribution_site_id = ?", siteID)
dataDB = dataDB.Where("distribution_site_id = ?", siteID)
} else {
countDB = countDB.Where("distribution_site_id = 0 OR distribution_site_id IS NULL")
dataDB = dataDB.Where("distribution_site_id = 0 OR distribution_site_id IS NULL")
}
var total int64
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var withdrawals []model.WithdrawalRequest
if err := dataDB.Order("created_at DESC").
Offset(offset).Limit(pageSize).
Find(&withdrawals).Error; err != nil {
return nil, err
}
items := make([]WithdrawalDTO, 0, len(withdrawals))
for _, w := range withdrawals {
items = append(items, toDTO(w))
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// 用户查询提现详情
// 用户查询提现详情
func (r *Repository) FindByID(ctx context.Context, userID, id uint64) (*WithdrawalDTO, error) {
var withdrawal model.WithdrawalRequest
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).
First(&withdrawal).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWithdrawalNotFound
}
return nil, err
}
dto := toDTO(withdrawal)
return &dto, nil
}
// 用户取消提现
// 用户取消提现
func (r *Repository) Cancel(ctx context.Context, userID, id uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var withdrawal model.WithdrawalRequest
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND user_id = ?", id, userID).
First(&withdrawal).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrWithdrawalNotFound
}
return err
}
// 事务内加行锁后重新校验,只有待审核状态可以取消,避免与管理员审核并发重复解冻
if withdrawal.Status != "pending" {
return ErrWithdrawalLocked
}
// 更新状态
if err := tx.Model(&withdrawal).Update("status", "cancelled").Error; err != nil {
return err
}
// 解冻余额
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: userID,
DistributionSiteID: withdrawal.DistributionSiteID,
Direction: "out",
AmountCent: withdrawal.AmountCent,
BalanceType: "frozen",
BizType: "withdraw_cancel",
BizNo: withdrawal.WithdrawNo,
Remark: "用户取消提现",
}, wallet.Entry{
UserID: userID,
DistributionSiteID: withdrawal.DistributionSiteID,
Direction: "in",
AmountCent: withdrawal.AmountCent,
BalanceType: "available",
BizType: "withdraw_cancel",
BizNo: withdrawal.WithdrawNo,
Remark: "用户取消提现",
}); err != nil {
return err
}
return nil
})
}
// 管理员查询提现列表