Files
junhong_cmp_fiber/internal/service/shop_commission/service.go
break 62419d4b17
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。

临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。

佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。

冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。

归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。

验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
2026-09-17 18:37:02 +08:00

823 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"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"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, startTime, endTime *time.Time) (*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,
StartTime: startTime,
EndTime: endTime,
}
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, startTime, endTime *time.Time) (*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,
StartTime: formatLedgerTimeFilter(startTime),
EndTime: formatLedgerTimeFilter(endTime),
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
}
// formatLedgerTimeFilter 把入口严格解析后的 UTC 瞬时转换为佣金明细列表的时间筛选值。
func formatLedgerTimeFilter(value *time.Time) *string {
if value == nil {
return nil
}
formatted := utils.FormatTimeFilterValue(*value)
return &formatted
}
// 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))
}