暂存一下,防止丢失
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t).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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user