暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -267,12 +268,39 @@ func (s *Service) GetWalletBalance(ctx context.Context) (*dto.AgentOpenAPIWallet
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败")
}
aggregate := domainwallet.AgentWallet{
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
Status: wallet.Status, Version: wallet.Version,
}
if err := aggregate.Validate(); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "代理主钱包资金状态异常")
}
cashAvailable, err := aggregate.CashAvailableBalance()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包现金可用金额失败")
}
available, err := aggregate.AvailableBalance()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包总可用金额失败")
}
debtAmount, err := aggregate.DebtAmount()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包欠款金额失败")
}
return &dto.AgentOpenAPIWalletBalanceResponse{
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
AvailableBalance: wallet.GetAvailableBalance(),
Currency: walletCurrency(wallet.Currency),
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
CashAvailableBalance: cashAvailable,
CreditEnabled: wallet.CreditEnabled,
CreditLimit: wallet.CreditLimit,
AvailableBalance: available,
IsInDebt: aggregate.IsInDebt(),
DebtAmount: debtAmount,
Version: wallet.Version,
Currency: walletCurrency(wallet.Currency),
}, nil
}

View File

@@ -6,12 +6,14 @@ import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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/postgres"
@@ -41,7 +43,7 @@ type Service struct {
db *gorm.DB
agentRechargeStore *postgres.AgentRechargeStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTxStore *postgres.AgentWalletTransactionStore
agentWalletPosting *walletapp.PostingService
shopStore *postgres.ShopStore
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
@@ -55,7 +57,6 @@ func New(
db *gorm.DB,
agentRechargeStore *postgres.AgentRechargeStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTxStore *postgres.AgentWalletTransactionStore,
shopStore *postgres.ShopStore,
wechatConfigService WechatConfigServiceInterface,
auditService AuditServiceInterface,
@@ -67,7 +68,6 @@ func New(
db: db,
agentRechargeStore: agentRechargeStore,
agentWalletStore: agentWalletStore,
agentWalletTxStore: agentWalletTxStore,
shopStore: shopStore,
wechatConfigService: wechatConfigService,
auditService: auditService,
@@ -77,6 +77,11 @@ func New(
}
}
// SetAgentWalletPostingService 注入代理主钱包统一入账用例。
func (s *Service) SetAgentWalletPostingService(service *walletapp.PostingService) {
s.agentWalletPosting = service
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
@@ -199,12 +204,6 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
}
// 查询钱包(事务内需要用到 version
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新充值记录状态
@@ -222,41 +221,20 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
return errors.New(errors.CodeInvalidParam, "充值记录状态已变更")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
if s.agentWalletPosting == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
// 创建钱包交易记录
remark := "线下充值确认"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID,
Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
return err
}
return nil
@@ -293,7 +271,8 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
// HandlePaymentCallback 处理支付回调
// 幂等处理status != 待支付则直接返回成功
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error {
paymentTransactionID = strings.TrimSpace(paymentTransactionID)
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -302,25 +281,23 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
// 幂等检查
if err := validateAgentRechargePayment(record, paymentMethod, paymentTransactionID, paidAmount); err != nil {
return err
}
// 已完成订单仅在第三方交易号一致时按同一回调幂等成功。
if record.Status != constants.RechargeStatusPending {
s.logger.Info("代理充值订单已处理,跳过",
zap.String("recharge_no", rechargeNo),
zap.Int("status", record.Status),
)
return nil
if record.Status == constants.RechargeStatusCompleted && record.PaymentTransactionID != nil && *record.PaymentTransactionID == strings.TrimSpace(paymentTransactionID) {
s.logger.Info("代理充值支付回调幂等命中", zap.String("recharge_no", rechargeNo), zap.Int("status", record.Status))
return nil
}
return errors.New(errors.CodeInvalidStatus, "充值订单状态不允许确认支付")
}
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新WHERE status = 1
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Where("id = ? AND status = ? AND payment_method <> ? AND amount = ?", record.ID, constants.RechargeStatusPending, constants.RechargeMethodOffline, paidAmount).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"payment_transaction_id": paymentTransactionID,
@@ -331,44 +308,31 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return nil
var current model.AgentRechargeRecord
if err := tx.WithContext(ctx).Unscoped().
Where("id = ?", record.ID).First(&current).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核充值记录终态失败")
}
if current.Status == constants.RechargeStatusCompleted && current.PaymentTransactionID != nil && *current.PaymentTransactionID == paymentTransactionID {
return nil
}
return errors.New(errors.CodeConflict, "充值订单已被其他请求处理")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
if s.agentWalletPosting == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
// 创建交易记录
remark := "在线支付充值"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: record.UserID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: record.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID,
Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
return err
}
return nil
@@ -387,6 +351,29 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return nil
}
func validateAgentRechargePayment(record *model.AgentRechargeRecord, paymentMethod, paymentTransactionID string, paidAmount int64) error {
if record == nil || strings.TrimSpace(paymentTransactionID) == "" || paidAmount <= 0 || paidAmount != record.Amount {
return errors.New(errors.CodeInvalidParam, "代理充值支付回调金额或交易号不匹配")
}
if record.PaymentMethod == constants.RechargeMethodOffline || record.PaymentConfigID == nil || record.PaymentChannel == nil {
return errors.New(errors.CodeInvalidStatus, "线下充值订单不能通过支付回调确认")
}
channel := strings.TrimSpace(*record.PaymentChannel)
switch strings.TrimSpace(paymentMethod) {
case model.PaymentMethodWechat:
if record.PaymentMethod != constants.RechargeMethodWechat || (channel != model.ProviderTypeWechat && channel != model.ProviderTypeWechatV2) {
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配")
}
case model.ProviderTypeFuiou:
if record.PaymentMethod != constants.RechargeMethodWechat || channel != model.ProviderTypeFuiou {
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配")
}
default:
return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不受支持")
}
return nil
}
// Reject 驳回代理充值订单
// 仅 status=1待支付的订单可驳回驳回后状态变为 6已驳回为终态
func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) error {

View File

@@ -6,6 +6,8 @@ import (
"strings"
"time"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -16,6 +18,7 @@ import (
"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/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -40,13 +43,6 @@ type PollingCallback interface {
OnCardDisabled(ctx context.Context, cardID uint)
}
// DataDeductor 流量扣减回调接口
// 用于在手动刷新流量后触发套餐扣减,避免循环依赖
type DataDeductor interface {
// DeductDataUsage 按优先级扣减套餐流量
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
// RealnameActivator 实名激活回调接口
// 用于在手动实名后触发待实名套餐激活,避免循环依赖
type RealnameActivator interface {
@@ -65,7 +61,6 @@ type Service struct {
gatewayClient *gateway.Client
logger *zap.Logger
pollingCallback PollingCallback
dataDeductor DataDeductor
realnameActivator RealnameActivator
stopResumeService StopResumeServiceInterface
deviceSimBindingStore *postgres.DeviceSimBindingStore
@@ -75,6 +70,12 @@ type Service struct {
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
}
// SetCardObservationService 注入统一卡实名观测写入用例。
func (s *Service) SetCardObservationService(service *cardapp.Service) {
s.cardObservation = service
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
@@ -129,12 +130,6 @@ func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) {
s.enterpriseStore = store
}
// SetDataDeductor 设置流量扣减回调
// 在应用启动时由 bootstrap 调用,注入套餐扣减服务
func (s *Service) SetDataDeductor(deductor DataDeductor) {
s.dataDeductor = deductor
}
// SetRealnameActivator 设置实名激活回调
// 在应用启动时由 bootstrap 注入套餐激活服务
func (s *Service) SetRealnameActivator(activator RealnameActivator) {
@@ -1563,17 +1558,29 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err))
} else if strings.TrimSpace(statusResp.ICCID) == "" {
s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID跳过本次网络观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡网络观测能力未配置")
} else {
gatewayExtend := strings.TrimSpace(statusResp.Extend)
updates["gateway_extend"] = gatewayExtend
networkStatus, ok := gateway.ParseCardNetworkStatus(statusResp.CardStatus, gatewayExtend)
if !ok {
requestID := requestIDFromContext(ctx)
decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: statusResp.CardStatus,
GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
if !decision.StatusKnown {
s.logger.Warn("刷新卡数据:未知 Gateway 卡状态",
zap.String("iccid", iccid),
zap.String("card_status", statusResp.CardStatus),
zap.String("extend", gatewayExtend))
} else {
updates["network_status"] = networkStatus
zap.String("extend", strings.TrimSpace(statusResp.Extend)))
}
}
@@ -1583,12 +1590,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err))
} else if strings.TrimSpace(realnameResp.ICCID) == "" {
s.logger.Warn("刷新卡数据:实名响应缺少 ICCID跳过本次实名观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
} else {
realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus)
updates["real_name_status"] = realNameStatus
// 检测 0→1 变化:旧状态非已实名且新状态为已实名,补写 first_realname_at
if card.RealNameStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified {
updates["first_realname_at"] = syncTime
requestID := requestIDFromContext(ctx)
_, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID,
Verified: realnameResp.RealStatus,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
}
@@ -1598,8 +1616,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
})
if err != nil {
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err))
} else if s.cardObservation == nil {
return errors.New(errors.CodeInternalError, "卡流量观测能力未配置")
} else {
flowIncrementMB = s.calculateRefreshFlowUpdates(card, float64(flowResp.Used), syncTime, updates)
requestID := requestIDFromContext(ctx)
resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID)
decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
return applyErr
}
flowIncrementMB = decision.IncrementMB
}
}
@@ -1607,16 +1640,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
}
// 有增量时触发套餐流量扣减
if flowIncrementMB > 0 && s.dataDeductor != nil {
if err := s.dataDeductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, card.ID, flowIncrementMB); err != nil {
s.logger.Warn("手动刷新:套餐流量扣减失败",
zap.Uint("card_id", card.ID),
zap.Float64("increment_mb", flowIncrementMB),
zap.Error(err))
}
}
// 失效轮询缓存,确保轮询系统读到最新的 network_status/real_name_status/流量
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
@@ -1629,97 +1652,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return nil
}
// calculateRefreshFlowUpdates 计算手动刷新时的流量增量并填充 updates
// 算法与轮询 calculateFlowUpdates 一致:增量 = 当前网关读数 - 上次网关读数
// 返回本次增量值MB用于触发套餐扣减
func (s *Service) calculateRefreshFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, updates map[string]any) float64 {
increment := gatewayFlowMB - card.LastGatewayReadingMB
shouldUpdateGatewayReading := true
if increment < 0 {
// 当前值比上次小,检查是否为上游运营商正常重置
resetDay := s.getCarrierResetDay(card.CarrierID)
if isRefreshResetWindow(now, resetDay) {
// 在重置日窗口内,本次原始值即为增量
increment = gatewayFlowMB
s.logger.Info("手动刷新:检测到上游运营商重置",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB),
zap.Int("reset_day", resetDay))
} else {
// 非重置日出现值下降,异常,不计入
s.logger.Warn("手动刷新:流量异常,非重置日出现值下降",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB))
increment = 0
shouldUpdateGatewayReading = false
}
}
if shouldUpdateGatewayReading {
updates["last_gateway_reading_mb"] = gatewayFlowMB
}
// 检测跨自然月
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
s.logger.Info("手动刷新:检测到跨月,重置流量计数",
zap.Uint("card_id", card.ID),
zap.Float64("last_month_total", card.CurrentMonthUsageMB))
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
if increment > 0 {
updates["current_month_usage_mb"] = increment
} else {
updates["current_month_usage_mb"] = float64(0)
}
} else if increment > 0 {
// 同月内,增量累加(使用 gorm.Expr 保证原子性)
updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
}
// 更新卡生命周期总用量
if increment > 0 {
updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
}
// 首次流量查询初始化
if card.CurrentMonthStartDate == nil {
updates["current_month_start_date"] = currentMonthStart
}
return increment
}
// getCarrierResetDay 读取运营商的上游流量重置日
func (s *Service) getCarrierResetDay(carrierID uint) int {
var carrier model.Carrier
if err := s.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil {
return 1
}
if carrier.DataResetDay == 0 {
return 1
}
return carrier.DataResetDay
}
// isRefreshResetWindow 判断今天是否在运营商重置日窗口内
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟)
func isRefreshResetWindow(now time.Time, resetDay int) bool {
today := now.Day()
if today == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
prevDay := resetDate.AddDate(0, 0, -1).Day()
return today == prevDay
}
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
// true=已实名(1)false=未实名(0)
func parseGatewayRealnameStatus(realStatus bool) int {
@@ -2190,22 +2122,20 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
}
oldStatus := card.RealNameStatus
statusChanged := oldStatus != realNameStatus
now := time.Now()
fields := map[string]any{
"last_real_name_check_at": now,
if s.cardObservation == nil {
return nil, errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
}
if statusChanged {
fields["real_name_status"] = realNameStatus
}
if statusChanged &&
oldStatus != constants.RealNameStatusVerified &&
realNameStatus == constants.RealNameStatusVerified {
fields["first_realname_at"] = now
}
if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil {
requestID := requestIDFromContext(ctx)
if _, err := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: cardID,
Verified: realNameStatus == constants.RealNameStatusVerified,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualOverride,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: now,
RequestID: requestID, CorrelationID: requestID,
},
}); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -2248,10 +2178,6 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
return nil, wrapErr
}
if statusChanged {
s.handleManualRealnameStatusChanged(ctx, oldStatus, freshCard)
}
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
@@ -2281,6 +2207,14 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
return freshCard, nil
}
func requestIDFromContext(ctx context.Context) string {
requestID := middleware.GetRequestIDFromContext(ctx)
if requestID == nil {
return ""
}
return *requestID
}
// handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑
func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStatus int, card *model.IotCard) {
if card == nil {

View File

@@ -2,12 +2,15 @@ package order
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"sort"
"strconv"
"strings"
"time"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -23,6 +26,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -60,6 +64,18 @@ type Service struct {
assetIdentifierStore *postgres.AssetIdentifierStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService
}
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
func (s *Service) SetAgentWalletDebitService(service *walletapp.DebitService) {
s.agentWalletDebit = service
}
// SetAgentWalletReservationService 注入代理主钱包统一预占用例。
func (s *Service) SetAgentWalletReservationService(service *walletapp.ReservationService) {
s.agentWalletReservation = service
}
func New(
@@ -188,16 +204,17 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
}
carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
if lockToken != "" {
defer s.releaseOrderCreateLock(ctx, lockKey, lockToken)
}
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
// offline 订单不检查强充:平台直接操作,不涉及消费者支付门槛,不产生一次性佣金触发条件
if req.PaymentMethod != model.PaymentMethodOffline {
@@ -486,10 +503,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
operatorShopID = *operatorID
}
buyerShopID := orderBuyerID
order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey)
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
s.markOrderCreated(ctx, idempotencyKey, existingOrderID)
return s.Get(ctx, existingOrderID)
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
@@ -548,16 +571,17 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
carrierType, carrierID := resolveCarrierInfo(req)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
if lockToken != "" {
defer s.releaseOrderCreateLock(ctx, lockKey, lockToken)
}
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
@@ -767,10 +791,16 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey)
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
s.markOrderCreated(ctx, idempotencyKey, existingOrderID)
return s.Get(ctx, existingOrderID)
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
@@ -989,32 +1019,23 @@ func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint)
return allocation.CostPrice, nil
}
// createWalletTransaction 创建钱包流水记录
// ctx: 上下文
// tx: 事务对象
// wallet: 扣款前的钱包快照
// order: 订单快照
// amount: 扣款金额(正数)
// purchaseRole: 订单角色
// relatedShopID: 关联店铺ID代购场景填充下级店铺ID
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, order *model.Order, amount int64, purchaseRole string, relatedShopID *uint) error {
var subtype *string
// debitAgentMainWalletInTx 通过统一 Wallet Application 接缝扣款并写入流水与 Outbox。
func (s *Service) debitAgentMainWalletInTx(ctx context.Context, tx *gorm.DB, order *model.Order, shopID uint, amount int64, relatedShopID *uint) error {
if s.agentWalletDebit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款能力未配置")
}
subtype := ""
remark := "购买套餐"
purchaseRole := order.PurchaseRole
if purchaseRole == "" {
purchaseRole = model.PurchaseRoleSelfPurchase
}
// 根据订单角色确定交易子类型和备注
switch purchaseRole {
case model.PurchaseRoleSelfPurchase:
subtypeVal := constants.WalletTransactionSubtypeSelfPurchase
subtype = &subtypeVal
subtype = constants.WalletTransactionSubtypeSelfPurchase
case model.PurchaseRolePurchaseForSubordinate:
subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate
subtype = &subtypeVal
// 查询下级店铺名称,填充到备注
subtype = constants.WalletTransactionSubtypePurchaseForSubordinate
if relatedShopID != nil {
var shop model.Shop
if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil {
@@ -1027,34 +1048,19 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
userID := middleware.GetUserIDFromContext(ctx)
assetType, assetID, assetIdentifier := buildAgentWalletTransactionAssetSnapshot(order)
// 创建钱包流水记录
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: wallet.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount, // 扣款为负数
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: strPtr(constants.ReferenceTypeOrder),
ReferenceID: &order.ID,
RelatedShopID: relatedShopID,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: assetIdentifier,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败")
_, err := s.agentWalletDebit.DebitInTx(ctx, tx, walletapp.DebitCommand{
ShopID: shopID, Amount: amount, ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID,
UserID: userID, Creator: userID, TransactionSubtype: subtype, RelatedShopID: relatedShopID,
AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier, Remark: remark,
RequestID: requestID, CorrelationID: order.OrderNo,
})
if err != nil {
return err
}
return nil
}
@@ -1108,44 +1114,33 @@ func strPtr(s string) *string {
return &s
}
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 订单、扣款流水、Payment、套餐处理与 Outbox 在同一事务提交。
// ctx: 上下文
// order: 订单对象
// items: 订单明细列表
// operatorShopID: 操作者店铺ID扣款的店铺
// buyerShopID: 买家店铺ID代购场景下级店铺ID
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) error {
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) (uint, error) {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "实际支付金额不能为空")
return 0, errors.New(errors.CodeInternalError, "实际支付金额不能为空")
}
actualAmount := *order.ActualPaidAmount
var existingOrderID uint
// 事务内:加锁读取钱包 + 余额检查 + 创建订单 + 扣款 + 创建流水 + 激活套餐
// 使用 FOR UPDATE 悲观锁替代乐观锁,避免并发请求因 version 过期导致误报余额不足
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 加锁读取钱包,并发请求排队等待,不会冲突
var wallet model.AgentWallet
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND wallet_type = ?", operatorShopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey)
if err != nil {
return err
}
// 2. 余额检查(加锁后读到的是最新余额,结果准确)
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
if matchedOrderID > 0 {
existingOrderID = matchedOrderID
return nil
}
// 3. 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 4. 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
@@ -1153,24 +1148,16 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 5. 扣减钱包余额FOR UPDATE 已保证串行,无需 version 条件)
if err := tx.Model(&wallet).Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "扣减钱包余额失败")
}
// 6. 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, &wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil {
return err
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, actualAmount); err != nil {
return err
}
// 7. 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
@@ -1179,7 +1166,10 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
})
if err != nil {
return err
return 0, err
}
if existingOrderID > 0 {
return existingOrderID, nil
}
// 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算
@@ -1187,7 +1177,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
return 0, nil
}
func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
@@ -1480,16 +1470,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
})
}
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 根据买家类型和订单金额确定解冻金额和目标钱包
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 代理主钱包以预占事实中的钱包和金额为权威,避免代购订单误用买方店铺。
func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if order.BuyerType == model.BuyerTypeAgent {
// 代理商钱包(店铺钱包)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, order.BuyerID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询代理钱包失败")
if s.agentWalletReservation == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未配置")
}
return s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, order.TotalAmount)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.agentWalletReservation.ReleaseInTx(ctx, tx, walletapp.ReservationCommand{
ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID,
UserID: middleware.GetUserIDFromContext(ctx), Creator: middleware.GetUserIDFromContext(ctx),
RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金",
})
} else if order.BuyerType == model.BuyerTypePersonal {
// 个人客户钱包(卡/设备钱包)
var resourceType string
@@ -1524,16 +1520,25 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde
return nil
}
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string) error {
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error {
paidAt := order.PaidAt
if paidAt == nil {
now := time.Now()
paidAt = &now
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: paymentMethod,
Amount: order.TotalAmount,
Amount: amount,
Status: model.PaymentRecordStatusPaid,
PaidAt: paidAt,
}
return tx.Create(payment).Error
if err := tx.Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包支付记录失败")
}
return nil
}
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
@@ -1553,7 +1558,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.New(errors.CodeInvalidStatus, "代购订单无需支付")
}
existingPayment, _ := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid)
existingPayment, err := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid)
if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录失败")
}
if existingPayment != nil {
return nil
}
@@ -1584,19 +1592,6 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
shouldEnqueueCommission := false
if resourceType == "shop" {
// 代理钱包系统(店铺)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return err
}
if wallet.Balance < order.TotalAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
@@ -1631,26 +1626,14 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
order.PaidAt = &now
shouldEnqueueCommission = true
walletResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
}
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
if err := s.createWalletTransaction(ctx, tx, wallet, order, order.TotalAmount, order.PurchaseRole, nil); err != nil {
if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil {
return err
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil {
return err
}
@@ -1742,7 +1725,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.Wrap(errors.CodeDatabaseError, err, "创建扣款流水失败")
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil {
return err
}
@@ -2160,14 +2143,24 @@ func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string,
buyerType, buyerID, orderType, carrierType, carrierID, strings.Join(idStrs, ","))
}
func orderIdempotencyDigest(key string) string {
digest := sha256.Sum256([]byte(key))
return fmt.Sprintf("%x", digest)
}
func orderIdempotencyAdvisoryKey(digest string) int64 {
value := sha256.Sum256([]byte(digest))
return int64(binary.BigEndian.Uint64(value[:8]))
}
const (
orderIdempotencyTTL = 3 * time.Minute
orderCreateLockTTL = 10 * time.Second
orderCreateLockTTL = orderIdempotencyTTL
)
// checkOrderIdempotency 检查订单是否已创建Redis SETNX + 分布式锁)
// 返回已存在的 orderID>0 表示重复请求),或 0 表示可以创续创建
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, error) {
// 返回已存在的 orderID、当前请求持有的锁令牌以及错误。
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, string, error) {
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, packageIDs)
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
@@ -2179,21 +2172,22 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b
s.logger.Info("订单幂等性命中,返回已有订单",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
return uint(orderID), "", nil
}
}
if err != nil && err != redis.Nil {
return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "订单幂等缓存不可用")
}
// 第 2 层:分布式锁,防止并发创建
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
locked, err := s.redis.SetNX(ctx, lockKey, time.Now().String(), orderCreateLockTTL).Result()
lockToken := uuid.NewString()
locked, err := s.redis.SetNX(ctx, lockKey, lockToken, orderCreateLockTTL).Result()
if err != nil {
s.logger.Warn("获取订单创建分布式锁失败,继续执行",
zap.Error(err),
zap.String("lock_key", lockKey))
return 0, nil
return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "获取订单创建锁失败")
}
if !locked {
return 0, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
return 0, "", errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
// 第 3 层:加锁后二次检测,防止锁等待期间已被处理
@@ -2204,14 +2198,50 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b
s.logger.Info("订单幂等性二次检测命中",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
return uint(orderID), lockToken, nil
}
}
if err != nil && err != redis.Nil {
return 0, lockToken, errors.Wrap(errors.CodeServiceUnavailable, err, "复核订单幂等标记失败")
}
return 0, nil
return 0, lockToken, nil
}
// markOrderCreated 订单创建成功后标记 Redis 并释放分布式锁
// releaseOrderCreateLock 仅释放当前请求持有的订单创建锁。
func (s *Service) releaseOrderCreateLock(ctx context.Context, lockKey, lockToken string) {
const compareAndDelete = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`
if err := s.redis.Eval(ctx, compareAndDelete, []string{lockKey}, lockToken).Err(); err != nil {
s.logger.Warn("释放订单创建锁失败", zap.String("lock_key", lockKey), zap.Error(err))
}
}
func lockAndFindRecentWalletOrder(ctx context.Context, tx *gorm.DB, idempotencyKey string) (uint, error) {
if idempotencyKey == "" {
return 0, errors.New(errors.CodeInternalError, "钱包订单缺少持久化幂等指纹")
}
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(?)", orderIdempotencyAdvisoryKey(idempotencyKey)).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定钱包订单幂等指纹失败")
}
var existing model.Order
err := tx.WithContext(ctx).
Where("idempotency_key = ? AND created_at >= CURRENT_TIMESTAMP - (? * INTERVAL '1 second')",
idempotencyKey, int64(orderIdempotencyTTL/time.Second)).
Order("id DESC").First(&existing).Error
if err == nil {
return existing.ID, nil
}
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询近期重复钱包订单失败")
}
// markOrderCreated 在订单事务提交后写入 Redis 快速幂等标记。
func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, orderID uint) {
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
if err := s.redis.Set(ctx, redisKey, strconv.FormatUint(uint64(orderID), 10), orderIdempotencyTTL).Err(); err != nil {

View File

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
@@ -41,6 +42,7 @@ type Service struct {
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
logger *zap.Logger
}
@@ -77,6 +79,11 @@ func New(
}
}
// SetAgentWalletRefundService 注入代理主钱包统一退款回充用例。
func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) {
s.agentWalletRefundService = service
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
@@ -299,75 +306,33 @@ func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *
// refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。
func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
wallet, relatedShopID, subtype, err := s.resolveAgentRefundWallet(tx, order)
if err != nil {
return err
if s.agentWalletRefundService == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款能力未配置")
}
balanceBefore := wallet.Balance
if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "退回代理预充值钱包失败")
legacyPayerShopID, legacyRelatedShopID, _ := resolveAgentWalletRefundShopID(order)
legacyDeductAmount := order.TotalAmount
if order.ActualPaidAmount != nil && *order.ActualPaidAmount > 0 {
legacyDeductAmount = *order.ActualPaidAmount
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
correlationID := refund.RefundNo
if correlationID == "" {
correlationID = order.OrderNo
}
refType := constants.ReferenceTypeRefund
refID := refund.ID
remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo)
assetType, assetID, assetIdentifier := buildRefundWalletTransactionAssetSnapshot(order)
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: wallet.ShopID,
UserID: operatorID,
TransactionType: constants.AgentTransactionTypeRefund,
TransactionSubtype: subtype,
Amount: amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore + amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &refID,
RelatedShopID: relatedShopID,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: assetIdentifier,
Remark: &remark,
Creator: operatorID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理钱包退款流水失败")
}
return nil
}
// resolveAgentRefundWallet 优先按原扣款流水定位回款钱包,兼容历史缺失扣款流水的订单。
func (s *Service) resolveAgentRefundWallet(tx *gorm.DB, order *model.Order) (*model.AgentWallet, *uint, *string, error) {
var deductTx model.AgentWalletTransaction
err := tx.Where("reference_type = ? AND reference_id = ? AND transaction_type = ?",
constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct).
Order("id ASC").
First(&deductTx).Error
if err == nil {
wallet, err := lockAgentMainWalletByID(tx, deductTx.AgentWalletID)
if err != nil {
return nil, nil, nil, err
}
return wallet, deductTx.RelatedShopID, deductTx.TransactionSubtype, nil
}
if err != gorm.ErrRecordNotFound {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理钱包扣款流水失败")
}
payerShopID, relatedShopID, err := resolveAgentWalletRefundShopID(order)
if err != nil {
return nil, nil, nil, err
}
wallet, err := lockAgentMainWalletByShopID(tx, payerShopID)
if err != nil {
return nil, nil, nil, err
}
return wallet, relatedShopID, nil, nil
_, err := s.agentWalletRefundService.RefundInTx(ctx, tx, walletapp.RefundCommand{
OrderID: order.ID, RefundID: refund.ID, Amount: amount,
LegacyPayerShopID: legacyPayerShopID, LegacyDeductAmount: legacyDeductAmount,
LegacyRelatedShopID: legacyRelatedShopID, UserID: operatorID, Creator: operatorID,
AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier,
Remark: remark, RequestID: requestID, CorrelationID: correlationID,
})
return err
}
// resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。
@@ -389,34 +354,6 @@ func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) {
return order.BuyerID, nil, nil
}
// lockAgentMainWalletByID 锁定代理主钱包,保证余额快照与后续入账一致。
func lockAgentMainWalletByID(tx *gorm.DB, walletID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败")
}
return &wallet, nil
}
// lockAgentMainWalletByShopID 按店铺锁定代理主钱包。
func lockAgentMainWalletByShopID(tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败")
}
return &wallet, nil
}
// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。
func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
wallet, err := s.resolveAssetRefundWallet(tx, order)

View File

@@ -160,7 +160,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
}
// List 查询角色列表
func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model.Role, int64, error) {
func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*dto.RoleResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
@@ -184,7 +184,15 @@ func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model.
filters["status"] = *req.Status
}
return s.roleStore.List(ctx, opts, filters)
roles, total, err := s.roleStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询角色列表失败")
}
result := make([]*dto.RoleResponse, 0, len(roles))
for _, role := range roles {
result = append(result, toResponse(role))
}
return result, total, nil
}
// AssignPermissions 为角色分配权限
@@ -358,15 +366,18 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
// toResponse 将 model.Role 转换为 dto.RoleResponse
func toResponse(role *model.Role) *dto.RoleResponse {
return &dto.RoleResponse{
ID: role.ID,
RoleName: role.RoleName,
RoleDesc: role.RoleDesc,
RoleType: role.RoleType,
Status: role.Status,
Creator: role.Creator,
Updater: role.Updater,
CreatedAt: role.CreatedAt.Format(time.RFC3339),
UpdatedAt: role.UpdatedAt.Format(time.RFC3339),
ID: role.ID,
RoleName: role.RoleName,
RoleDesc: role.RoleDesc,
RoleType: role.RoleType,
Status: role.Status,
DefaultCreditEnabled: role.DefaultCreditEnabled,
DefaultCreditLimit: role.DefaultCreditLimit,
DefaultCreditScope: "new_shops_only",
Creator: role.Creator,
Updater: role.Updater,
CreatedAt: role.CreatedAt.Format(time.RFC3339),
UpdatedAt: role.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -10,17 +10,14 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
accountRoleStore *postgres.AccountRoleStore
agentWalletStore *postgres.AgentWalletStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
}
func New(
@@ -28,177 +25,15 @@ func New(
accountStore *postgres.AccountStore,
shopRoleStore *postgres.ShopRoleStore,
roleStore *postgres.RoleStore,
accountRoleStore *postgres.AccountRoleStore,
agentWalletStore *postgres.AgentWalletStore,
) *Service {
return &Service{
shopStore: shopStore,
accountStore: accountStore,
shopRoleStore: shopRoleStore,
roleStore: roleStore,
accountRoleStore: accountRoleStore,
agentWalletStore: agentWalletStore,
shopStore: shopStore,
accountStore: accountStore,
shopRoleStore: shopRoleStore,
roleStore: roleStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto.ShopResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
existing, err := s.shopStore.GetByCode(ctx, req.ShopCode)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
level := 1
parentShopName := ""
if req.ParentID != nil {
parent, err := s.shopStore.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
}
parentShopName = parent.ShopName
level = parent.Level + 1
if level > constants.ShopMaxLevel {
return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
}
existingAccount, err := s.accountStore.GetByUsername(ctx, req.InitUsername)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
}
existingAccount, err = s.accountStore.GetByPhone(ctx, req.InitPhone)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
}
// 验证默认角色:必须存在、是客户角色且已启用
defaultRole, err := s.roleStore.GetByID(ctx, req.DefaultRoleID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "请选择默认角色")
}
if defaultRole.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺默认角色必须是客户角色")
}
if defaultRole.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "默认角色已禁用")
}
shop := &model.Shop{
ShopName: req.ShopName,
ShopCode: req.ShopCode,
ParentID: req.ParentID,
Level: level,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
Status: constants.ShopStatusEnabled,
}
shop.Creator = currentUserID
shop.Updater = currentUserID
if err := s.shopStore.Create(ctx, shop); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
account := &model.Account{
Username: req.InitUsername,
Phone: req.InitPhone,
Password: string(hashedPassword),
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
Status: constants.StatusEnabled,
IsPrimary: true,
}
account.Creator = currentUserID
account.Updater = currentUserID
if err := s.accountStore.Create(ctx, account); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建初始账号失败")
}
// 为初始账号分配默认角色
accountRole := &model.AccountRole{
AccountID: account.ID,
RoleID: req.DefaultRoleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
if err := s.accountRoleStore.Create(ctx, accountRole); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "为初始账号分配角色失败")
}
// 设置店铺默认角色
shopRole := &model.ShopRole{
ShopID: shop.ID,
RoleID: req.DefaultRoleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
if err := s.shopRoleStore.BatchCreate(ctx, []*model.ShopRole{shopRole}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "设置店铺默认角色失败")
}
// 初始化店铺代理钱包:主钱包 + 分佣钱包
// 新店铺必须有两个钱包才能参与充值和分佣体系
wallets := []*model.AgentWallet{
{
ShopID: shop.ID,
WalletType: constants.AgentWalletTypeMain,
Balance: 0,
Currency: "CNY",
Status: constants.AgentWalletStatusNormal,
ShopIDTag: shop.ID,
},
{
ShopID: shop.ID,
WalletType: constants.AgentWalletTypeCommission,
Balance: 0,
Currency: "CNY",
Status: constants.AgentWalletStatusNormal,
ShopIDTag: shop.ID,
},
}
for _, wallet := range wallets {
if err := s.agentWalletStore.Create(ctx, wallet); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "初始化店铺钱包失败")
}
}
return &dto.ShopResponse{
ID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
ParentID: shop.ParentID,
ParentShopName: parentShopName,
Level: shop.Level,
ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone,
Province: shop.Province,
City: shop.City,
District: shop.District,
Address: shop.Address,
Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status),
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {

View File

@@ -56,147 +56,6 @@ func New(
}
}
// ListShopFundSummary 代理商资金概况列表(含预充值余额和佣金钱包)
// GET /shops/fund-summary
func (s *Service) ListShopFundSummary(ctx context.Context, req *dto.ShopFundSummaryListReq) (*dto.ShopFundSummaryPageResult, 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.ShopFundSummaryPageResult{
Items: []dto.ShopFundSummaryItem{},
Total: 0,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
shopIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
shopIDs = append(shopIDs, shop.ID)
}
// 批量获取主钱包(预充值钱包)余额
mainWallets, err := s.agentWalletStore.GetShopMainWalletBatch(ctx, shopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败")
}
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.ShopFundSummaryItem, 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.buildFundSummaryItem(shop, mainWallets[shop.ID], walletSummaries[shop.ID], withdrawnAmounts[shop.ID], withdrawingAmounts[shop.ID], accountMap[shop.ID])
items = append(items, item)
}
return &dto.ShopFundSummaryPageResult{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// buildFundSummaryItem 构造资金概况条目
func (s *Service) buildFundSummaryItem(shop *model.Shop, mainWallet, commissionWallet *model.AgentWallet, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopFundSummaryItem {
// 主钱包余额若未充值则为0
var mainBalance, mainFrozenBalance int64
if mainWallet != nil {
mainBalance = mainWallet.Balance
mainFrozenBalance = mainWallet.FrozenBalance
}
// 佣金钱包
var balance, frozenBalance int64
if commissionWallet != nil {
balance = commissionWallet.Balance
frozenBalance = commissionWallet.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.ShopFundSummaryItem{
ShopID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
Username: username,
Phone: phone,
MainBalance: mainBalance,
MainFrozenBalance: mainFrozenBalance,
TotalCommission: totalCommission,
WithdrawnCommission: withdrawnAmount,
UnwithdrawCommission: unwithdrawCommission,
FrozenCommission: frozenBalance,
WithdrawingCommission: withdrawingAmount,
AvailableCommission: availableCommission,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
// ListShopWithdrawalRequests 查询代理商提现记录
// GET /shops/:id/withdrawal-requests
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) {
@@ -832,16 +691,3 @@ func generateWithdrawalNo() string {
now := time.Now()
return fmt.Sprintf("W%s%04d", now.Format("20060102150405"), rand.Intn(10000))
}
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
}