Files
junhong_cmp_fiber/internal/service/shop_commission/service.go
break 1aa4eacee2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变,
回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。

- 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束
  (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。
- 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常
  标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、
  分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差;
  终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。
- 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影
  并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、
  审批异常转人工。
- 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 →
  扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点,
  refund.invalidate_commission 仅保留常量与注册供历史审计读取。
- 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作
  末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。
- 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource
  与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。
- 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) /
  Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。

Refs: AUG26-012
2026-09-14 13:40:34 +08:00

822 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package shop_commission
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Service 代理商资金管理服务
type Service struct {
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
agentWalletStore *postgres.AgentWalletStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore
commissionRecordStore *postgres.CommissionRecordStore
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
db *gorm.DB
auditWriter *audit.Writer
withdrawalApprovalService *distributionwithdrawal.WithdrawalService
logger *zap.Logger
}
// SetAuditWriter 注入佣金与提现统一审计 Writer。
func (s *Service) SetAuditWriter(writer *audit.Writer) {
s.auditWriter = writer
}
// SetWithdrawalApprovalService 注入接入企业微信终审的提现申请用例。
// 未注入时发起提现失败关闭,避免绕过资料资格校验与审批实例创建。
func (s *Service) SetWithdrawalApprovalService(service *distributionwithdrawal.WithdrawalService) {
s.withdrawalApprovalService = service
}
// New 创建代理商资金管理服务
func New(
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
agentWalletStore *postgres.AgentWalletStore,
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore,
commissionRecordStore *postgres.CommissionRecordStore,
agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
db *gorm.DB,
logger *zap.Logger,
) *Service {
return &Service{
shopStore: shopStore,
accountStore: accountStore,
agentWalletStore: agentWalletStore,
commissionWithdrawalReqStore: commissionWithdrawalReqStore,
commissionWithdrawalSettingStore: commissionWithdrawalSettingStore,
commissionRecordStore: commissionRecordStore,
agentWalletTransactionStore: agentWalletTransactionStore,
db: db,
logger: logger,
}
}
// ListShopWithdrawalRequests 查询代理商提现记录
// GET /shops/:id/withdrawal-requests
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
_, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
}
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{
ShopID: shopID,
WithdrawalNo: req.WithdrawalNo,
}
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.ListByShopID(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询提现记录失败")
}
shop, _ := s.shopStore.GetByID(ctx, shopID)
shopHierarchy := s.buildShopHierarchyPath(ctx, shop)
applicantIDs := make([]uint, 0)
processorIDs := make([]uint, 0)
for _, r := range requests {
if r.ApplicantID > 0 {
applicantIDs = append(applicantIDs, r.ApplicantID)
}
if r.ProcessorID > 0 {
processorIDs = append(processorIDs, r.ProcessorID)
}
}
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.ShopWithdrawalRequestItem, 0, len(requests))
for _, r := range requests {
item := s.buildWithdrawalRequestItem(r, shop.ShopName, shopHierarchy, applicantMap, processorMap)
items = append(items, item)
}
return &dto.ShopWithdrawalRequestPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// buildWithdrawalRequestItem 构造提现记录条目
func (s *Service) buildWithdrawalRequestItem(r *model.CommissionWithdrawalRequest, shopName, shopHierarchy string, applicantMap, processorMap map[uint]string) dto.ShopWithdrawalRequestItem {
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, paidAt string
if r.ProcessedAt != nil {
processedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
}
if r.PaidAt != nil {
paidAt = r.PaidAt.Format("2006-01-02 15:04:05")
}
return dto.ShopWithdrawalRequestItem{
ID: r.ID,
WithdrawalNo: r.WithdrawalNo,
Amount: r.Amount,
FeeRate: r.FeeRate,
Fee: r.Fee,
ActualAmount: r.ActualAmount,
Status: r.Status,
StatusName: constants.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,
PaidAt: paidAt,
}
}
// buildShopHierarchyPath 构造店铺层级路径(最多两层上级)
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
}
// ListShopCommissionRecords 查询代理商佣金明细
// GET /shops/:id/commission-records
// 原佣金与回溯明细按同一分页与排序口径合并返回,筛选与数据范围在合并前各自应用。
func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, req *dto.ShopCommissionRecordListReq) (*dto.ShopCommissionRecordPageResult, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
_, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
}
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
if opts.PageSize > constants.MaxPageSize {
opts.PageSize = constants.MaxPageSize
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
CommissionSource: req.CommissionSource,
ICCID: req.ICCID,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
Status: req.Status,
}
rows, total, err := s.commissionRecordStore.ListLedgerByShopID(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细失败")
}
originalIDs := make([]uint, 0, len(rows))
for _, row := range rows {
if row.Source == postgres.CommissionLedgerSourceOriginal {
originalIDs = append(originalIDs, row.ID)
}
}
summaries, err := s.commissionRecordStore.ListClawbackSummaries(ctx, originalIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金回溯摘要失败")
}
shopNameMap, err := s.sellerShopNames(ctx, rows)
if err != nil {
return nil, err
}
items := make([]dto.ShopCommissionRecordItem, 0, len(rows))
for _, row := range rows {
item := buildShopCommissionRecordItem(row, shopNameMap)
if row.Source == postgres.CommissionLedgerSourceOriginal {
item.ClawbackRecords = buildClawbackItems(summaries[row.ID])
for _, clawback := range item.ClawbackRecords {
item.ClawbackTotalAmount += clawback.Amount
}
}
items = append(items, item)
}
return &dto.ShopCommissionRecordPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// GetShopCommissionRecord 查询单条佣金明细详情
// GET /shops/:shop_id/commission-records/:id
// source 区分原佣金与回溯明细;越权与不存在返回同一结果,不泄露存在性。
func (s *Service) GetShopCommissionRecord(ctx context.Context, req *dto.ShopCommissionRecordDetailReq) (*dto.ShopCommissionRecordDetailResp, error) {
if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if _, err := s.shopStore.GetByID(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
}
source := req.Source
if source == "" {
source = postgres.CommissionLedgerSourceOriginal
}
row, err := s.commissionRecordStore.GetLedgerRowByID(ctx, source, req.ID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "佣金明细不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细详情失败")
}
// 记录必须属于请求路径中的店铺,避免借已知 ID 跨店铺枚举。
if row.ShopID != req.ShopID {
return nil, errors.New(errors.CodeNotFound, "佣金明细不存在")
}
rows := []*postgres.CommissionLedgerRow{row}
shopNameMap, err := s.sellerShopNames(ctx, rows)
if err != nil {
return nil, err
}
resp := &dto.ShopCommissionRecordDetailResp{
Source: source,
Record: buildShopCommissionRecordItem(row, shopNameMap),
}
if source == postgres.CommissionLedgerSourceClawback {
if row.OriginalCommissionID != nil {
original, err := s.commissionRecordStore.GetLedgerRowByID(ctx, postgres.CommissionLedgerSourceOriginal, *row.OriginalCommissionID)
if err == nil {
originalItem := buildShopCommissionRecordItem(original, shopNameMap)
resp.OriginalCommission = &originalItem
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询回溯明细关联原佣金失败")
}
}
return resp, nil
}
summaries, err := s.commissionRecordStore.ListClawbackSummaries(ctx, []uint{row.ID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金回溯摘要失败")
}
resp.ClawbackRecords = buildClawbackItems(summaries[row.ID])
return resp, nil
}
// sellerShopNames 批量解析合并行涉及的销售来源店铺名称。
func (s *Service) sellerShopNames(ctx context.Context, rows []*postgres.CommissionLedgerRow) (map[uint]string, error) {
sellerShopIDs := make([]uint, 0, len(rows))
for _, row := range rows {
if row.SellerShopID != nil && *row.SellerShopID > 0 {
sellerShopIDs = append(sellerShopIDs, *row.SellerShopID)
}
}
shopNameMap := make(map[uint]string)
if len(sellerShopIDs) == 0 {
return shopNameMap, nil
}
shops, err := s.shopStore.GetByIDs(ctx, sellerShopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询销售来源店铺失败")
}
for _, shop := range shops {
shopNameMap[shop.ID] = shop.ShopName
}
return shopNameMap, nil
}
// buildShopCommissionRecordItem 把合并行投影为统一明细项。
func buildShopCommissionRecordItem(row *postgres.CommissionLedgerRow, shopNameMap map[uint]string) dto.ShopCommissionRecordItem {
var orderCreatedAt string
if row.OrderCreatedAt != nil {
orderCreatedAt = row.OrderCreatedAt.Format("2006-01-02 15:04:05")
}
var sellerShopID uint
if row.SellerShopID != nil {
sellerShopID = *row.SellerShopID
}
item := dto.ShopCommissionRecordItem{
Source: row.Source,
ID: row.ID,
Amount: row.Amount,
BalanceAfter: row.BalanceAfter,
CommissionSource: row.CommissionSource,
Status: row.Status,
StatusName: constants.GetCommissionRecordStatusName(row.Status),
OrderID: row.OrderID,
OrderNo: row.OrderNo,
VirtualNo: row.VirtualNo,
ICCID: row.ICCID,
OrderCreatedAt: orderCreatedAt,
SellerShopID: sellerShopID,
SellerShopName: shopNameMap[sellerShopID],
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04:05"),
}
if row.ReleasedAt != nil {
item.ReleasedAt = row.ReleasedAt.Format("2006-01-02 15:04:05")
}
if row.OriginalCommissionID != nil {
item.OriginalCommissionID = *row.OriginalCommissionID
}
if row.RefundID != nil {
item.RefundID = *row.RefundID
}
item.RefundNo = row.RefundNo
item.Withdrawable = row.Withdrawable
return item
}
// buildClawbackItems 把回溯摘要投影为明细项。
func buildClawbackItems(summaries []postgres.CommissionClawbackSummary) []dto.ShopCommissionClawbackItem {
if len(summaries) == 0 {
return nil
}
items := make([]dto.ShopCommissionClawbackItem, 0, len(summaries))
for _, summary := range summaries {
items = append(items, dto.ShopCommissionClawbackItem{
ID: summary.ID,
OriginalCommissionID: summary.OriginalCommissionID,
RefundID: summary.RefundID,
RefundNo: summary.RefundNo,
Amount: summary.Amount,
BalanceAfter: summary.BalanceAfter,
Withdrawable: summary.Withdrawable,
Status: summary.Status,
StatusName: constants.GetCommissionRecordStatusName(summary.Status),
CreatedAt: summary.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return items
}
// GetStats 获取佣金统计
// GET /shops/:id/commission-stats
func (s *Service) GetStats(ctx context.Context, shopID uint, req *dto.CommissionStatsRequest) (*dto.CommissionStatsResponse, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
stats, err := s.commissionRecordStore.GetStats(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "获取佣金统计失败")
}
if stats == nil {
return &dto.CommissionStatsResponse{}, nil
}
var costDiffPercent, oneTimePercent int64
if stats.TotalAmount > 0 {
costDiffPercent = stats.CostDiffAmount * 1000 / stats.TotalAmount
oneTimePercent = stats.OneTimeAmount * 1000 / stats.TotalAmount
}
return &dto.CommissionStatsResponse{
TotalAmount: stats.TotalAmount,
CostDiffAmount: stats.CostDiffAmount,
OneTimeAmount: stats.OneTimeAmount,
CostDiffPercent: costDiffPercent,
OneTimePercent: oneTimePercent,
TotalCount: stats.TotalCount,
CostDiffCount: stats.CostDiffCount,
OneTimeCount: stats.OneTimeCount,
}, nil
}
// GetDailyStats 获取每日佣金统计
// GET /shops/:id/commission-daily-stats
func (s *Service) GetDailyStats(ctx context.Context, shopID uint, req *dto.DailyCommissionStatsRequest) ([]*dto.DailyCommissionStatsResponse, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
days := 30
if req.Days != nil && *req.Days > 0 {
days = *req.Days
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
StartTime: req.StartDate,
EndTime: req.EndDate,
}
dailyStats, err := s.commissionRecordStore.GetDailyStats(ctx, filters, days)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "获取每日佣金统计失败")
}
result := make([]*dto.DailyCommissionStatsResponse, 0, len(dailyStats))
for _, stat := range dailyStats {
result = append(result, &dto.DailyCommissionStatsResponse{
Date: stat.Date,
TotalAmount: stat.TotalAmount,
TotalCount: stat.TotalCount,
})
}
return result, nil
}
// CreateWithdrawalRequest 代理发起提现申请
// POST /shops/:id/withdrawal-requests
// 先校验提现配置与本人代理身份,再在同一事务内校验有效提现资料资格、冻结余额、
// 写审批尝试记录并创建企业微信审批实例。任何校验失败都不创建申请、审批实例或冻结。
func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req *dto.CreateMyWithdrawalReq) (*dto.CreateMyWithdrawalResp, error) {
if s.withdrawalApprovalService == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "提现审批能力尚未配置")
}
// 提现权限比查询更严格:必须是代理账号本人操作,不允许平台人员或顶级代理替下级提现
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可发起提现")
}
if shopID != middleware.GetShopIDFromContext(ctx) {
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
policy, err := s.currentWithdrawalPolicy(ctx)
if err != nil {
return nil, err
}
result, err := s.withdrawalApprovalService.Create(ctx, shopID, policy, distributionwithdrawal.WithdrawalInput{
Amount: req.Amount,
WithdrawalMethod: req.WithdrawalMethod,
AccountName: req.AccountName,
AccountNumber: req.AccountNumber,
})
if err != nil {
return nil, err
}
return withdrawalApprovalResp(result), nil
}
// ResubmitWithdrawalRequest 代理修改金额、收款信息与本次发票后重提已被驳回的提现申请
// PUT /shops/:shop_id/withdrawal-requests/:id
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史尝试与审批结果不被覆盖。
func (s *Service) ResubmitWithdrawalRequest(ctx context.Context, shopID uint, requestID uint, req *dto.ResubmitWithdrawalReq) (*dto.CreateMyWithdrawalResp, error) {
if s.withdrawalApprovalService == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "提现审批能力尚未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可重提提现")
}
if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) {
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺重提提现")
}
// 先复核申请属于该店铺,越权与不存在返回同一结果。
existing, err := s.commissionWithdrawalReqStore.GetByID(ctx, requestID)
if err != nil || existing == nil || existing.ShopID != shopID {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
policy, err := s.currentWithdrawalPolicy(ctx)
if err != nil {
return nil, err
}
result, err := s.withdrawalApprovalService.Resubmit(ctx, requestID, policy, distributionwithdrawal.WithdrawalInput{
Amount: req.Amount,
WithdrawalMethod: req.WithdrawalMethod,
AccountName: req.AccountName,
AccountNumber: req.AccountNumber,
InvoiceKeys: req.InvoiceKeys,
})
if err != nil {
return nil, err
}
return withdrawalApprovalResp(result), nil
}
// currentWithdrawalPolicy 读取当前提现配置为调用方策略快照,未开放提现时返回稳定错误。
func (s *Service) currentWithdrawalPolicy(ctx context.Context) (distributionwithdrawal.WithdrawalPolicy, error) {
setting, err := s.commissionWithdrawalSettingStore.GetCurrent(ctx)
if err != nil {
return distributionwithdrawal.WithdrawalPolicy{}, errors.New(errors.CodeInvalidParam, "暂未开放提现功能")
}
return distributionwithdrawal.WithdrawalPolicy{
MinAmount: setting.MinWithdrawalAmount,
FeeRate: setting.FeeRate,
DailyWithdrawalLimit: setting.DailyWithdrawalLimit,
}, nil
}
// withdrawalApprovalResp 把提现用例结果映射为发起提现响应。
func withdrawalApprovalResp(result *distributionwithdrawal.WithdrawalResult) *dto.CreateMyWithdrawalResp {
return &dto.CreateMyWithdrawalResp{
ID: result.RequestID,
WithdrawalNo: result.WithdrawalNo,
Amount: result.Amount,
FeeRate: result.FeeRate,
Fee: result.Fee,
ActualAmount: result.ActualAmount,
Status: result.Status,
StatusName: constants.GetWithdrawalStatusName(result.Status),
CreatedAt: result.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
// ListMainWalletTransactions 查询代理主钱包(预充值钱包)流水
// GET /shops/:id/main-wallet/transactions
func (s *Service) ListMainWalletTransactions(ctx context.Context, shopID uint, req *dto.MainWalletTransactionListRequest) (*dto.MainWalletTransactionListResponse, error) {
// 越权校验
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
// 获取主钱包,不存在则返回空列表
mainWallet, err := s.agentWalletStore.GetMainWallet(ctx, shopID)
if err != nil {
return &dto.MainWalletTransactionListResponse{
Items: []dto.MainWalletTransactionItem{},
Total: 0,
Page: page,
Size: pageSize,
}, nil
}
filters := &postgres.AgentWalletTransactionListFilters{
TransactionType: req.TransactionType,
StartDate: req.StartDate,
EndDate: req.EndDate,
AssetIdentifier: req.AssetIdentifier,
}
offset := (page - 1) * pageSize
transactions, err := s.agentWalletTransactionStore.ListByWalletIDWithFilters(ctx, mainWallet.ID, offset, pageSize, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包流水失败")
}
total, err := s.agentWalletTransactionStore.CountByWalletID(ctx, mainWallet.ID, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "统计主钱包流水失败")
}
items := make([]dto.MainWalletTransactionItem, 0, len(transactions))
for _, t := range transactions {
var remark string
if t.Remark != nil {
remark = *t.Remark
}
var subtype string
if t.TransactionSubtype != nil {
subtype = *t.TransactionSubtype
}
items = append(items, dto.MainWalletTransactionItem{
ID: t.ID,
TransactionType: t.TransactionType,
TransactionSubtype: subtype,
Amount: t.Amount,
BalanceBefore: t.BalanceBefore,
BalanceAfter: t.BalanceAfter,
AssetType: t.AssetType,
AssetID: t.AssetID,
AssetIdentifier: t.AssetIdentifier,
Remark: remark,
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return &dto.MainWalletTransactionListResponse{
Items: items,
Total: total,
Page: page,
Size: pageSize,
}, nil
}
// ResolveCommissionRecord 修正待审佣金记录status=99
// release: 填入金额并入账到代理佣金钱包
// invalidate: 标记为已失效
func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, req *dto.CommissionRecordResolveRequest) error {
record, err := s.commissionRecordStore.GetByID(ctx, recordID)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "佣金记录不存在")
}
if record.Status != constants.CommissionStatusPendingReview {
actionCode := constants.AuditActionCommissionInvalidated
if req.Action == "release" {
actionCode = constants.AuditActionCommissionCredited
}
businessErr := errors.New(errors.CodeInvalidParam, "该记录不是待修正状态")
s.recordCommissionResolutionFailure(ctx, record, actionCode, "修正待审佣金失败", businessErr)
return businessErr
}
now := time.Now()
resolveRemark := record.Remark
if req.Remark != "" {
resolveRemark += " | 处理备注: " + req.Remark
}
if req.Action == "invalidate" {
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"status": constants.CommissionStatusInvalid,
"remark": resolveRemark,
}); err != nil {
return err
}
return s.appendCommissionResolutionAudit(ctx, tx, record, nil, constants.AuditActionCommissionInvalidated, "待审佣金已失效")
})
if err != nil {
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionInvalidated, "失效待审佣金失败", err)
}
return err
}
// release 入账
if req.Amount == nil || *req.Amount <= 0 {
businessErr := errors.New(errors.CodeInvalidParam, "入账操作必须指定金额")
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr)
return businessErr
}
amount := *req.Amount
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, record.ShopID)
if err != nil {
businessErr := errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在")
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr)
return businessErr
}
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"status": constants.CommissionStatusReleased,
"amount": amount,
"released_at": now,
"remark": resolveRemark,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
}
balanceBefore := wallet.Balance
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance + ?", amount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新佣金钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "佣金钱包版本冲突,请重试")
}
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"balance_after": balanceBefore + amount,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新入账后余额失败")
}
return s.appendCommissionResolutionAudit(ctx, tx, record, wallet, constants.AuditActionCommissionCredited, "待审佣金已入账")
})
if err != nil {
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", err)
}
return err
}
// generateWithdrawalNo 生成提现单号
func generateWithdrawalNo() string {
now := time.Now()
return fmt.Sprintf("W%s%04d", now.Format("20060102150405"), rand.Intn(10000))
}