All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s
## 变更概述 将统一钱包系统拆分为代理钱包和卡钱包两个独立系统,实现数据表和代码层面的完全隔离。 ## 数据库变更 - 新增 6 张表:tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record、tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record - 删除 3 张旧表:tb_wallet、tb_wallet_transaction、tb_recharge_record - 代理钱包:按 (shop_id, wallet_type) 唯一标识,支持主钱包和分佣钱包 - 卡钱包:按 (resource_type, resource_id) 唯一标识,支持物联网卡和设备 ## 代码变更 - Model 层:新增 AgentWallet、AgentWalletTransaction、AgentRechargeRecord、CardWallet、CardWalletTransaction、CardRechargeRecord 模型 - Store 层:新增 6 个独立 Store,支持事务、乐观锁、Redis 缓存 - Service 层:重构 commission_calculation、commission_withdrawal、order、recharge 等 8 个服务 - Bootstrap 层:更新 Store 和 Service 依赖注入 - 常量层:按钱包类型重新组织常量和 Redis Key 生成函数 ## 技术特性 - 乐观锁:使用 version 字段防止并发冲突 - 多租户:支持 shop_id_tag 和 enterprise_id_tag 过滤 - 事务管理:所有余额变动使用事务保证 ACID - 缓存策略:Cache-Aside 模式,余额变动后删除缓存 ## 业务影响 - 代理钱包和卡钱包业务完全隔离,互不影响 - 为独立监控、优化、扩展打下基础 - 提升代理钱包的稳定性和独立性 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
428 lines
12 KiB
Go
428 lines
12 KiB
Go
package commission_withdrawal
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
shopStore *postgres.ShopStore
|
|
accountStore *postgres.AccountStore
|
|
agentWalletStore *postgres.AgentWalletStore
|
|
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
|
|
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
|
|
}
|
|
|
|
func New(
|
|
db *gorm.DB,
|
|
shopStore *postgres.ShopStore,
|
|
accountStore *postgres.AccountStore,
|
|
agentWalletStore *postgres.AgentWalletStore,
|
|
agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
|
|
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
|
|
) *Service {
|
|
return &Service{
|
|
db: db,
|
|
shopStore: shopStore,
|
|
accountStore: accountStore,
|
|
agentWalletStore: agentWalletStore,
|
|
agentWalletTransactionStore: agentWalletTransactionStore,
|
|
commissionWithdrawalReqStore: commissionWithdrawalReqStore,
|
|
}
|
|
}
|
|
|
|
func (s *Service) ListWithdrawalRequests(ctx context.Context, req *dto.WithdrawalRequestListReq) (*dto.WithdrawalRequestPageResult, error) {
|
|
opts := &store.QueryOptions{
|
|
Page: req.Page,
|
|
PageSize: req.PageSize,
|
|
OrderBy: "created_at DESC",
|
|
}
|
|
if opts.Page == 0 {
|
|
opts.Page = 1
|
|
}
|
|
if opts.PageSize == 0 {
|
|
opts.PageSize = constants.DefaultPageSize
|
|
}
|
|
|
|
filters := &postgres.WithdrawalRequestListFilters{
|
|
WithdrawalNo: req.WithdrawalNo,
|
|
Status: req.Status,
|
|
}
|
|
|
|
if req.StartTime != "" {
|
|
t, err := time.Parse("2006-01-02 15:04:05", req.StartTime)
|
|
if err == nil {
|
|
filters.StartTime = &t
|
|
}
|
|
}
|
|
if req.EndTime != "" {
|
|
t, err := time.Parse("2006-01-02 15:04:05", req.EndTime)
|
|
if err == nil {
|
|
filters.EndTime = &t
|
|
}
|
|
}
|
|
|
|
requests, total, err := s.commissionWithdrawalReqStore.List(ctx, opts, filters)
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询提现申请列表失败")
|
|
}
|
|
|
|
shopIDs := make([]uint, 0)
|
|
applicantIDs := make([]uint, 0)
|
|
processorIDs := make([]uint, 0)
|
|
for _, r := range requests {
|
|
if r.ShopID > 0 {
|
|
shopIDs = append(shopIDs, r.ShopID)
|
|
}
|
|
if r.ApplicantID > 0 {
|
|
applicantIDs = append(applicantIDs, r.ApplicantID)
|
|
}
|
|
if r.ProcessorID > 0 {
|
|
processorIDs = append(processorIDs, r.ProcessorID)
|
|
}
|
|
}
|
|
|
|
shopMap := make(map[uint]*model.Shop)
|
|
for _, id := range shopIDs {
|
|
shop, err := s.shopStore.GetByID(ctx, id)
|
|
if err == nil {
|
|
shopMap[id] = shop
|
|
}
|
|
}
|
|
|
|
applicantMap := make(map[uint]string)
|
|
processorMap := make(map[uint]string)
|
|
if len(applicantIDs) > 0 {
|
|
accounts, _ := s.accountStore.GetByIDs(ctx, applicantIDs)
|
|
for _, acc := range accounts {
|
|
applicantMap[acc.ID] = acc.Username
|
|
}
|
|
}
|
|
if len(processorIDs) > 0 {
|
|
accounts, _ := s.accountStore.GetByIDs(ctx, processorIDs)
|
|
for _, acc := range accounts {
|
|
processorMap[acc.ID] = acc.Username
|
|
}
|
|
}
|
|
|
|
items := make([]dto.WithdrawalRequestItem, 0, len(requests))
|
|
for _, r := range requests {
|
|
shop := shopMap[r.ShopID]
|
|
shopName := ""
|
|
shopHierarchy := ""
|
|
if shop != nil {
|
|
shopName = shop.ShopName
|
|
shopHierarchy = s.buildShopHierarchyPath(ctx, shop)
|
|
if req.ShopName != "" && !containsSubstring(shopName, req.ShopName) {
|
|
total--
|
|
continue
|
|
}
|
|
}
|
|
|
|
item := s.buildWithdrawalRequestItem(r, shopName, shopHierarchy, applicantMap, processorMap)
|
|
items = append(items, item)
|
|
}
|
|
|
|
return &dto.WithdrawalRequestPageResult{
|
|
Items: items,
|
|
Total: total,
|
|
Page: opts.Page,
|
|
Size: opts.PageSize,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdrawalReq) (*dto.WithdrawalApprovalResp, error) {
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
withdrawal, err := s.commissionWithdrawalReqStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
|
}
|
|
|
|
if withdrawal.Status != constants.WithdrawalStatusPending {
|
|
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
|
}
|
|
|
|
// 获取店铺分佣钱包
|
|
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
|
}
|
|
|
|
amount := withdrawal.Amount
|
|
if req.Amount != nil {
|
|
amount = *req.Amount
|
|
}
|
|
|
|
if wallet.FrozenBalance < amount {
|
|
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
|
|
}
|
|
|
|
now := time.Now()
|
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
// 从冻结余额扣款
|
|
if err := s.agentWalletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
|
|
}
|
|
|
|
// 创建代理钱包交易记录
|
|
refType := "withdrawal"
|
|
refID := withdrawal.ID
|
|
transaction := &model.AgentWalletTransaction{
|
|
AgentWalletID: wallet.ID,
|
|
ShopID: withdrawal.ShopID,
|
|
UserID: currentUserID,
|
|
TransactionType: "withdrawal",
|
|
Amount: -amount,
|
|
BalanceBefore: wallet.Balance,
|
|
BalanceAfter: wallet.Balance,
|
|
Status: 1,
|
|
ReferenceType: &refType,
|
|
ReferenceID: &refID,
|
|
Creator: currentUserID,
|
|
ShopIDTag: withdrawal.ShopID,
|
|
}
|
|
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "创建交易流水失败")
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"status": constants.WithdrawalStatusApproved,
|
|
"processor_id": currentUserID,
|
|
"processed_at": now,
|
|
"payment_type": req.PaymentType,
|
|
"remark": req.Remark,
|
|
}
|
|
|
|
if req.Amount != nil {
|
|
feeRate := withdrawal.FeeRate
|
|
fee := amount * feeRate / 10000
|
|
actualAmount := amount - fee
|
|
updates["amount"] = amount
|
|
updates["fee"] = fee
|
|
updates["actual_amount"] = actualAmount
|
|
}
|
|
|
|
if req.WithdrawalMethod != nil {
|
|
updates["withdrawal_method"] = *req.WithdrawalMethod
|
|
}
|
|
if req.AccountName != nil || req.AccountNumber != nil {
|
|
accountInfo := make(map[string]interface{})
|
|
if withdrawal.AccountInfo != nil {
|
|
_ = json.Unmarshal(withdrawal.AccountInfo, &accountInfo)
|
|
}
|
|
if req.AccountName != nil {
|
|
accountInfo["account_name"] = *req.AccountName
|
|
}
|
|
if req.AccountNumber != nil {
|
|
accountInfo["account_number"] = *req.AccountNumber
|
|
}
|
|
infoBytes, _ := json.Marshal(accountInfo)
|
|
updates["account_info"] = infoBytes
|
|
}
|
|
|
|
if err := s.commissionWithdrawalReqStore.UpdateStatusWithTx(ctx, tx, id, updates); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &dto.WithdrawalApprovalResp{
|
|
ID: withdrawal.ID,
|
|
WithdrawalNo: withdrawal.WithdrawalNo,
|
|
Status: constants.WithdrawalStatusApproved,
|
|
StatusName: "已通过",
|
|
ProcessedAt: now.Format("2006-01-02 15:04:05"),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawalReq) (*dto.WithdrawalApprovalResp, error) {
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
withdrawal, err := s.commissionWithdrawalReqStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
|
}
|
|
|
|
if withdrawal.Status != constants.WithdrawalStatusPending {
|
|
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
|
}
|
|
|
|
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
|
}
|
|
|
|
now := time.Now()
|
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, withdrawal.Amount); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "解冻余额失败")
|
|
}
|
|
|
|
refType := "withdrawal"
|
|
refID := withdrawal.ID
|
|
transaction := &model.AgentWalletTransaction{
|
|
AgentWalletID: wallet.ID,
|
|
ShopID: withdrawal.ShopID,
|
|
UserID: currentUserID,
|
|
TransactionType: "refund",
|
|
Amount: withdrawal.Amount,
|
|
BalanceBefore: wallet.Balance,
|
|
BalanceAfter: wallet.Balance + withdrawal.Amount,
|
|
Status: 1,
|
|
ReferenceType: &refType,
|
|
ReferenceID: &refID,
|
|
Creator: currentUserID,
|
|
ShopIDTag: withdrawal.ShopID,
|
|
}
|
|
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "创建交易流水失败")
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"status": constants.WithdrawalStatusRejected,
|
|
"processor_id": currentUserID,
|
|
"processed_at": now,
|
|
"reject_reason": req.Remark,
|
|
"remark": req.Remark,
|
|
}
|
|
if err := s.commissionWithdrawalReqStore.UpdateStatusWithTx(ctx, tx, id, updates); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &dto.WithdrawalApprovalResp{
|
|
ID: withdrawal.ID,
|
|
WithdrawalNo: withdrawal.WithdrawalNo,
|
|
Status: constants.WithdrawalStatusRejected,
|
|
StatusName: "已拒绝",
|
|
ProcessedAt: now.Format("2006-01-02 15:04:05"),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) buildShopHierarchyPath(ctx context.Context, shop *model.Shop) string {
|
|
if shop == nil {
|
|
return ""
|
|
}
|
|
path := shop.ShopName
|
|
current := shop
|
|
depth := 0
|
|
for current.ParentID != nil && depth < 2 {
|
|
parent, err := s.shopStore.GetByID(ctx, *current.ParentID)
|
|
if err != nil {
|
|
break
|
|
}
|
|
path = parent.ShopName + "_" + path
|
|
current = parent
|
|
depth++
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (s *Service) buildWithdrawalRequestItem(r *model.CommissionWithdrawalRequest, shopName, shopHierarchy string, applicantMap, processorMap map[uint]string) dto.WithdrawalRequestItem {
|
|
var processorID *uint
|
|
if r.ProcessorID > 0 {
|
|
processorID = &r.ProcessorID
|
|
}
|
|
|
|
var accountName, accountNumber, bankName string
|
|
if len(r.AccountInfo) > 0 {
|
|
var info map[string]interface{}
|
|
if err := json.Unmarshal(r.AccountInfo, &info); err == nil {
|
|
if v, ok := info["account_name"].(string); ok {
|
|
accountName = v
|
|
}
|
|
if v, ok := info["account_number"].(string); ok {
|
|
accountNumber = v
|
|
}
|
|
if v, ok := info["bank_name"].(string); ok {
|
|
bankName = v
|
|
}
|
|
}
|
|
}
|
|
|
|
var processedAt string
|
|
if r.ProcessedAt != nil {
|
|
processedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
return dto.WithdrawalRequestItem{
|
|
ID: r.ID,
|
|
WithdrawalNo: r.WithdrawalNo,
|
|
Amount: r.Amount,
|
|
FeeRate: r.FeeRate,
|
|
Fee: r.Fee,
|
|
ActualAmount: r.ActualAmount,
|
|
Status: r.Status,
|
|
StatusName: getWithdrawalStatusName(r.Status),
|
|
ShopID: r.ShopID,
|
|
ShopName: shopName,
|
|
ShopHierarchy: shopHierarchy,
|
|
ApplicantID: r.ApplicantID,
|
|
ApplicantName: applicantMap[r.ApplicantID],
|
|
ProcessorID: processorID,
|
|
ProcessorName: processorMap[r.ProcessorID],
|
|
WithdrawalMethod: r.WithdrawalMethod,
|
|
PaymentType: r.PaymentType,
|
|
AccountName: accountName,
|
|
AccountNumber: accountNumber,
|
|
BankName: bankName,
|
|
RejectReason: r.RejectReason,
|
|
Remark: r.Remark,
|
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
ProcessedAt: processedAt,
|
|
}
|
|
}
|
|
|
|
func getWithdrawalStatusName(status int) string {
|
|
switch status {
|
|
case constants.WithdrawalStatusPending:
|
|
return "待审核"
|
|
case constants.WithdrawalStatusApproved:
|
|
return "已通过"
|
|
case constants.WithdrawalStatusRejected:
|
|
return "已拒绝"
|
|
case constants.WithdrawalStatusPaid:
|
|
return "已到账"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
|
|
func containsSubstring(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|