Files
junhong_cmp_fiber/internal/service/shop_commission/service.go
huang 623a622298
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 业务逻辑补全 — 佣金待审记录、C端订单重构、支付抽象、富友支付、卡设备状态联动
F-1: 佣金链断裂时创建 status=99 零额待审记录,新增修正接口 POST /commission-records/:id/resolve
F-4: C端订单查询从 Handler 迁移至 Service 层,移除 SkipPermissionCtx
J-1: 富友支付 JSAPI/MiniApp 预下单实现,回调补全签名验证
J-2: 平台钱包代购支持(buyerType 为空时使用资产所属代理钱包)
J-3: 套餐激活后自动更新卡/设备 status=3,最后套餐过期后更新 status=4
支付抽象: 引入 PaymentProvider 接口 + 微信/富友适配器,CreateOrder 支持多支付渠道
修复: 设备/IoT卡响应 DTO 移除 omitempty,空值字段返回 null 而非省略
2026-03-28 16:57:39 +08:00

510 lines
15 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"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Service struct {
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
agentWalletStore *postgres.AgentWalletStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
commissionRecordStore *postgres.CommissionRecordStore
db *gorm.DB
logger *zap.Logger
}
func New(
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
agentWalletStore *postgres.AgentWalletStore,
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
commissionRecordStore *postgres.CommissionRecordStore,
db *gorm.DB,
logger *zap.Logger,
) *Service {
return &Service{
shopStore: shopStore,
accountStore: accountStore,
agentWalletStore: agentWalletStore,
commissionWithdrawalReqStore: commissionWithdrawalReqStore,
commissionRecordStore: commissionRecordStore,
db: db,
logger: logger,
}
}
func (s *Service) ListShopCommissionSummary(ctx context.Context, req *dto.ShopCommissionSummaryListReq) (*dto.ShopCommissionSummaryPageResult, 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 := make(map[string]interface{})
if req.ShopName != "" {
filters["shop_name"] = req.ShopName
}
shops, total, err := s.shopStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺列表失败")
}
if len(shops) == 0 {
return &dto.ShopCommissionSummaryPageResult{
Items: []dto.ShopCommissionSummaryItem{},
Total: 0,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
shopIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
shopIDs = append(shopIDs, shop.ID)
}
walletSummaries, err := s.agentWalletStore.GetShopCommissionSummaryBatch(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败")
}
withdrawnAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusApproved)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询已提现金额失败")
}
withdrawingAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusPending)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询提现中金额失败")
}
primaryAccounts, err := s.accountStore.GetPrimaryAccountsByShopIDs(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主账号失败")
}
accountMap := make(map[uint]*model.Account)
for _, acc := range primaryAccounts {
if acc.ShopID != nil {
accountMap[*acc.ShopID] = acc
}
}
items := make([]dto.ShopCommissionSummaryItem, 0, len(shops))
for _, shop := range shops {
if req.Username != "" {
acc := accountMap[shop.ID]
if acc == nil || !containsSubstring(acc.Username, req.Username) {
total--
continue
}
}
item := s.buildCommissionSummaryItem(shop, walletSummaries[shop.ID], withdrawnAmounts[shop.ID], withdrawingAmounts[shop.ID], accountMap[shop.ID])
items = append(items, item)
}
return &dto.ShopCommissionSummaryPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
func (s *Service) buildCommissionSummaryItem(shop *model.Shop, walletSummary *model.AgentWallet, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopCommissionSummaryItem {
var balance, frozenBalance int64
if walletSummary != nil {
balance = walletSummary.Balance
frozenBalance = walletSummary.FrozenBalance
}
totalCommission := balance + frozenBalance + withdrawnAmount
unwithdrawCommission := totalCommission - withdrawnAmount
availableCommission := balance - withdrawingAmount
if availableCommission < 0 {
availableCommission = 0
}
var username, phone string
if account != nil {
username = account.Username
phone = account.Phone
}
return dto.ShopCommissionSummaryItem{
ShopID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
Username: username,
Phone: phone,
TotalCommission: totalCommission,
WithdrawnCommission: withdrawnAmount,
UnwithdrawCommission: unwithdrawCommission,
FrozenCommission: frozenBalance,
WithdrawingCommission: withdrawingAmount,
AvailableCommission: availableCommission,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) {
_, 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
}
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: 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,
}
}
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) ListShopCommissionRecords(ctx context.Context, shopID uint, req *dto.ShopCommissionRecordListReq) (*dto.ShopCommissionRecordPageResult, error) {
_, 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.CommissionRecordListFilters{
ShopID: shopID,
CommissionSource: req.CommissionSource,
ICCID: req.ICCID,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
}
records, total, err := s.commissionRecordStore.ListByShopID(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细失败")
}
items := make([]dto.ShopCommissionRecordItem, 0, len(records))
for _, r := range records {
item := dto.ShopCommissionRecordItem{
ID: r.ID,
Amount: r.Amount,
BalanceAfter: r.BalanceAfter,
CommissionSource: r.CommissionSource,
Status: r.Status,
StatusName: getCommissionStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: "",
VirtualNo: "",
ICCID: "",
OrderCreatedAt: "",
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
}
items = append(items, item)
}
return &dto.ShopCommissionRecordPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
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 getCommissionStatusName(status int) string {
switch status {
case constants.CommissionStatusFrozen:
return "已冻结"
case constants.CommissionStatusUnfreezing:
return "解冻中"
case constants.CommissionStatusReleased:
return "已发放"
case constants.CommissionStatusInvalid:
return "已失效"
default:
return "未知"
}
}
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && contains(s, substr)))
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// 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 {
return errors.New(errors.CodeInvalidParam, "该记录不是待修正状态")
}
now := time.Now()
resolveRemark := record.Remark
if req.Remark != "" {
resolveRemark += " | 处理备注: " + req.Remark
}
if req.Action == "invalidate" {
return s.commissionRecordStore.UpdateByID(ctx, nil, recordID, map[string]any{
"status": model.CommissionStatusInvalid,
"remark": resolveRemark,
})
}
// release 入账
if req.Amount == nil || *req.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "入账操作必须指定金额")
}
amount := *req.Amount
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, record.ShopID)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在")
}
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新佣金记录
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"status": model.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 nil
})
}