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)) }