代理在线充值
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s

This commit is contained in:
2026-07-27 16:02:55 +08:00
parent a2166c8011
commit cbf909b878
42 changed files with 3121 additions and 195 deletions

View File

@@ -0,0 +1,190 @@
package agentrecharge
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ConfirmOnlinePaymentCommand 描述验签或主动查单后得到的第三方收款事实。
type ConfirmOnlinePaymentCommand struct {
PaymentNo string
PaymentMethod string
ConfigID uint
MerchantIdentity string
ThirdPartyTradeNo string
Amount int64
PaidAt time.Time
RequestID string
CorrelationID string
}
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
type PaymentConfirmedEvent struct {
EventID string `json:"event_id"`
RechargeID uint `json:"recharge_id"`
RechargeNo string `json:"recharge_no"`
PaymentID uint `json:"payment_id"`
PaymentNo string `json:"payment_no"`
ShopID uint `json:"shop_id"`
WalletID uint `json:"wallet_id"`
UserID uint `json:"user_id"`
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
ThirdPartyTradeNo string `json:"third_party_trade_no"`
PaidAt time.Time `json:"paid_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
type PaymentConfirmedEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event PaymentConfirmedEvent) error
}
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
type ConfirmOnlinePaymentResult struct {
RechargeID uint
AlreadyConfirmed bool
}
// ConfirmOnlinePaymentService 统一处理回调和主动查单得到的代理充值支付事实。
type ConfirmOnlinePaymentService struct {
db *gorm.DB
eventWriter PaymentConfirmedEventWriter
}
// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter}
}
// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。
func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置")
}
command.PaymentNo = strings.TrimSpace(command.PaymentNo)
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
result := &ConfirmOnlinePaymentResult{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
payment, recharge, err := lockPaymentConfirmationFacts(ctx, tx, command.PaymentNo)
if err != nil {
return err
}
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
if err != nil {
return err
}
result.RechargeID = recharge.ID
if alreadyConfirmed {
result.AlreadyConfirmed = true
return nil
}
if err := ensureTradeNoAvailable(ctx, tx, payment, command); err != nil {
return err
}
paidAt := command.PaidAt.UTC()
paymentUpdate := tx.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt})
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "更新代理充值支付单失败")
}
if paymentUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
}
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "更新代理充值单支付状态失败")
}
if rechargeUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值单状态已变化")
}
event := PaymentConfirmedEvent{
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func lockPaymentConfirmationFacts(ctx context.Context, tx *gorm.DB, paymentNo string) (*model.Payment, *model.AgentRechargeRecord, error) {
var payment model.Payment
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "代理充值支付单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值支付单失败")
}
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", payment.OrderID).First(&recharge).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "支付单关联的代理充值单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值单失败")
}
return &payment, &recharge, nil
}
func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRechargeRecord, command ConfirmOnlinePaymentCommand) domain.PaymentConfirmationFacts {
paymentConfigID, rechargeConfigID, rechargeChannel := uint(0), uint(0), ""
if payment.PaymentConfigID != nil {
paymentConfigID = *payment.PaymentConfigID
}
if recharge.PaymentConfigID != nil {
rechargeConfigID = *recharge.PaymentConfigID
}
if recharge.PaymentChannel != nil {
rechargeChannel = *recharge.PaymentChannel
}
return domain.PaymentConfirmationFacts{
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,
}
}
func ensureTradeNoAvailable(ctx context.Context, tx *gorm.DB, payment *model.Payment, command ConfirmOnlinePaymentCommand) error {
var count int64
if err := tx.WithContext(ctx).Unscoped().Model(&model.Payment{}).
Where("id <> ? AND payment_method = ? AND third_party_trade_no = ?", payment.ID, command.PaymentMethod, command.ThirdPartyTradeNo).
Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验第三方交易号唯一性失败")
}
if count > 0 {
return errors.New(errors.CodeConflict, "第三方交易号已被其他支付单使用")
}
return nil
}

View File

@@ -0,0 +1,331 @@
package agentrecharge
import (
"context"
cryptorand "crypto/rand"
stderrors "errors"
"fmt"
"math/big"
"strings"
"time"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/idempotency"
)
const onlineCreationOperation = "agent-recharge.create-online"
// CreateOnlineCommand 描述从认证上下文构造的在线充值命令。
type CreateOnlineCommand struct {
AccountID uint
UserType int
CurrentShopID uint
Amount int64
PaymentMethod string
RequestID string
}
// CreateOnlineResult 返回在线充值单、支付单及原始付款内容。
type CreateOnlineResult struct {
Recharge *model.AgentRechargeRecord
Payment *model.Payment
}
// AvailablePaymentMethodsResult 返回当前可用渠道及在线金额边界。
type AvailablePaymentMethodsResult struct {
Methods []string
MinAmount int64
MaxAmount int64
}
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay}
}
// Execute 以短事务建单,事务外预下单,再条件保存付款内容或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.RequestID = strings.TrimSpace(command.RequestID)
if err := domain.ValidateOnlineCreation(command.UserType, command.Amount, command.PaymentMethod); err != nil {
return nil, err
}
if command.AccountID == 0 || command.CurrentShopID == 0 || len(command.RequestID) > 64 ||
!idempotency.ValidateScope(onlineCreationScope(command.AccountID), command.RequestID) {
return nil, apperrors.New(apperrors.CodeInvalidParam)
}
fingerprint, err := idempotency.Fingerprint(struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}{command.Amount, command.PaymentMethod})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
}
return nil, err
}
paymentResult, err := adapter.PreCreate(ctx, OnlinePaymentRequest{
PaymentNo: result.Payment.PaymentNo, Description: "代理主钱包充值", Amount: command.Amount,
ExpireAt: *result.Payment.ExpireAt, Config: config,
})
if err != nil {
if !isUnknownPaymentResult(err) {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付预下单失败且关闭本地订单失败")
}
}
return nil, err
}
if strings.TrimSpace(paymentResult.QRContent) == "" {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, closeErr
}
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回付款内容")
}
update := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND qr_content = ''", result.Payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", paymentResult.QRContent)
if update.Error != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存扫码付款内容失败")
}
if update.RowsAffected != 1 {
return nil, apperrors.New(apperrors.CodeConflict, "在线充值付款内容已变化")
}
result.Payment.QRContent = paymentResult.QRContent
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的扫码支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
}
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
}
func (s *OnlineCreationService) createLocalFacts(
ctx context.Context,
command CreateOnlineCommand,
fingerprint string,
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
return nil, err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := command.PaymentMethod, command.RequestID
record := &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment := &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(record).Error; err != nil {
return err
}
payment.OrderID = record.ID
if err := tx.Create(payment).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
if config == nil {
return ""
}
if paymentMethod == constants.RechargeMethodWechat {
return config.WxMchID
}
if paymentMethod == constants.RechargeMethodAlipay {
return config.AliAppID
}
return ""
}
func (s *OnlineCreationService) loadReplay(
ctx context.Context,
command CreateOnlineCommand,
fingerprint idempotency.FingerprintValue,
) (*CreateOnlineResult, bool, error) {
var record model.AgentRechargeRecord
err := s.db.WithContext(ctx).Where("user_id = ? AND request_id = ?", command.AccountID, command.RequestID).First(&record).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值幂等记录失败")
}
existingFingerprint := ""
if record.RequestFingerprint != nil {
existingFingerprint = *record.RequestFingerprint
}
if existingFingerprint != fingerprint.Value {
return nil, true, apperrors.New(apperrors.CodeConflict, "同一请求标识对应的充值内容不一致")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("order_id = ? AND order_type = ?", record.ID, model.PaymentOrderTypeAgentRecharge).First(&payment).Error; err != nil {
return nil, true, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值支付单失败")
}
if payment.QRContent == "" {
if record.Status == constants.RechargeStatusClosed || payment.Status == model.PaymentRecordStatusFailed {
return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求预下单失败")
}
return nil, true, apperrors.New(apperrors.CodeConflict, "在线充值请求正在处理中,请稍后重试")
}
return &CreateOnlineResult{Recharge: &record, Payment: &payment}, true, nil
}
func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result *CreateOnlineResult) error {
if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPreCreateFailure(result.Recharge.Status) {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", result.Payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, paymentUpdate.Error, "关闭失败支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", result.Recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败")
}
return nil
})
}
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {
return s.alipay
}
return nil
}
func onlineCreationScope(accountID uint) idempotency.Scope {
return idempotency.Scope{Subject: fmt.Sprintf("account:%d", accountID), Operation: onlineCreationOperation}
}
func newBusinessNo(prefix, timestamp string) (string, error) {
random, err := cryptorand.Int(cryptorand.Reader, big.NewInt(1000000))
if err != nil {
return "", apperrors.Wrap(apperrors.CodeInternalError, err, "生成业务单号失败")
}
return fmt.Sprintf("%s%s%06d", prefix, timestamp, random.Int64()), nil
}
func isUnknownPaymentResult(err error) bool {
var appErr *apperrors.AppError
return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTimeout
}

View File

@@ -0,0 +1,48 @@
package agentrecharge
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
)
const (
// OnlinePaymentStatePending 表示渠道仍在等待付款。
OnlinePaymentStatePending = "pending"
// OnlinePaymentStatePaid 表示渠道已确认收款。
OnlinePaymentStatePaid = "paid"
// OnlinePaymentStateClosed 表示渠道订单已明确关闭。
OnlinePaymentStateClosed = "closed"
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
OnlinePaymentStateUnknown = "unknown"
)
// OnlinePaymentRequest 描述扫码预下单所需的最小事实。
type OnlinePaymentRequest struct {
PaymentNo string
Description string
Amount int64
ExpireAt time.Time
Config *model.WechatConfig
}
// OnlinePaymentResult 描述渠道返回的扫码付款内容。
type OnlinePaymentResult struct {
QRContent string
}
// OnlinePaymentQueryResult 描述统一后的渠道支付状态。
type OnlinePaymentQueryResult struct {
State string
ThirdPartyTradeNo string
Amount int64
PaidAt *time.Time
}
// OnlinePaymentPort 定义代理扫码充值需要的最小渠道能力。
type OnlinePaymentPort interface {
Available(config *model.WechatConfig) bool
PreCreate(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error)
Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (OnlinePaymentQueryResult, error)
}

View File

@@ -0,0 +1,189 @@
package agentrecharge
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecoverOnlinePaymentService 批量收敛长期待预下单或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
var payments []model.Payment
if err := s.db.WithContext(ctx).
Where("order_type = ? AND status = ? AND created_at <= ?", model.PaymentOrderTypeAgentRecharge, model.PaymentRecordStatusPending, now.Add(-constants.AgentRechargeRecoveryMinimumAge)).
Order("created_at ASC, id ASC").Limit(constants.AgentRechargeRecoveryBatchSize).
Find(&payments).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "扫描待恢复代理充值支付单失败")
}
if len(payments) == 0 {
return 0, nil
}
recharges, configs, err := s.loadRecoveryFacts(ctx, payments)
if err != nil {
return 0, err
}
processed := 0
var firstErr error
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config := recoveryConfig(payment, configs)
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
}
continue
}
if err := s.recoverOne(ctx, payment, recharge, config, now); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
processed++
}
return processed, firstErr
}
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
adapter := s.adapter(payment.PaymentMethod)
if adapter == nil {
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
}
if payment.QRContent == "" {
if !adapter.Available(config) {
return errors.New(errors.CodeNoPaymentConfig, "代理充值预下单配置不可用")
}
expireAt := now.Add(30 * time.Minute)
if payment.ExpireAt != nil && payment.ExpireAt.After(now) {
expireAt = *payment.ExpireAt
}
result, err := adapter.PreCreate(ctx, OnlinePaymentRequest{
PaymentNo: payment.PaymentNo, Description: "代理主钱包充值", Amount: payment.Amount,
ExpireAt: expireAt, Config: config,
})
if err != nil {
// 恢复阶段不能仅凭预下单错误推断未收款,保留本地状态等待下次查单。
return nil
}
if result.QRContent == "" {
return nil
}
update := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND qr_content = ''", payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", result.QRContent)
if update.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值扫码付款内容失败")
}
return nil
}
queryResult, err := adapter.Query(ctx, payment.PaymentNo, config)
if err != nil {
return nil
}
switch queryResult.State {
case OnlinePaymentStatePaid:
if queryResult.PaidAt == nil {
return errors.New(errors.CodeConflict, "支付渠道成功结果缺少支付时间")
}
_, err = s.confirm.Execute(ctx, ConfirmOnlinePaymentCommand{
PaymentNo: payment.PaymentNo, PaymentMethod: payment.PaymentMethod, ConfigID: config.ID,
MerchantIdentity: paymentMerchantIdentity(payment.PaymentMethod, config),
ThirdPartyTradeNo: queryResult.ThirdPartyTradeNo, Amount: queryResult.Amount, PaidAt: *queryResult.PaidAt,
CorrelationID: payment.PaymentNo,
})
return err
case OnlinePaymentStateClosed:
return s.closePending(ctx, payment.ID, recharge.ID)
default:
return nil
}
}
func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, payments []model.Payment) (map[uint]*model.AgentRechargeRecord, map[uint]*model.WechatConfig, error) {
rechargeIDs := make([]uint, 0, len(payments))
configIDs := make([]uint, 0, len(payments))
for index := range payments {
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
if payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
var rechargeRows []model.AgentRechargeRecord
if err := s.db.WithContext(ctx).Where("id IN ?", rechargeIDs).Find(&rechargeRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询待恢复代理充值单失败")
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
}
}
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
for index := range rechargeRows {
recharges[rechargeRows[index].ID] = &rechargeRows[index]
}
configs := make(map[uint]*model.WechatConfig, len(configRows))
for index := range configRows {
configs[configRows[index].ID] = &configRows[index]
}
return recharges, configs, nil
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil
}
return configs[*payment.PaymentConfigID]
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败")
}
return nil
})
}
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {
return s.alipay
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
@@ -215,7 +216,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
PaymentCallback: callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,
svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger,
),
CTCCRealnameCallback: callback.NewCTCCRealnameHandler(
carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
@@ -261,8 +266,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)
handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB))
return handler
}(),
Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate),

View File

@@ -16,6 +16,7 @@ import (
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling"
@@ -37,6 +38,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
@@ -123,6 +125,8 @@ type services struct {
StopResumeService *iotCardSvc.StopResumeService
WechatConfig *wechatConfigSvc.Service
AgentRecharge *agentRechargeSvc.Service
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
TrafficQuery *trafficSvc.QueryService
@@ -265,6 +269,16 @@ func initServices(s *stores, deps *Dependencies) *services {
)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
paymentInfra.NewWechatNativeAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayPreCreateAdapter(paymentIntegration),
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
deps.DB,
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
)
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
@@ -361,51 +375,53 @@ func initServices(s *stores, deps *Dependencies) *services {
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeService,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeService,
AgentRechargeOnline: agentRechargeOnline,
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -0,0 +1,30 @@
// Package agentrecharge 定义代理在线充值的纯业务规则。
package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ValidateOnlineCreation 校验代理在线充值的角色、金额和支付方式不变量。
func ValidateOnlineCreation(userType int, amount int64, paymentMethod string) error {
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理账号可以创建在线扫码充值")
}
if amount < constants.AgentOnlineRechargeMinAmount || amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "在线充值金额必须在100元至100万元之间")
}
switch strings.TrimSpace(paymentMethod) {
case constants.RechargeMethodWechat, constants.RechargeMethodAlipay:
return nil
default:
return errors.New(errors.CodeInvalidParam, "在线充值支付方式无效")
}
}
// CanCloseAfterPreCreateFailure 判断预下单明确失败后能否关闭充值单。
func CanCloseAfterPreCreateFailure(status int) bool {
return status == constants.RechargeStatusPending
}

View File

@@ -0,0 +1,82 @@
package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PaymentState 是支付单在确认用例中的领域状态。
type PaymentState int
const (
// PaymentStatePending 表示支付单仍待确认。
PaymentStatePending PaymentState = iota
// PaymentStatePaid 表示支付单已确认收款。
PaymentStatePaid
// PaymentStateFailed 表示支付单曾被明确关闭。
PaymentStateFailed
// PaymentStateRefunded 表示支付单已经退款。
PaymentStateRefunded
)
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
type PaymentConfirmationFacts struct {
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
}
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
if facts.ExpectedOrderType == "" || facts.OrderType != facts.ExpectedOrderType ||
facts.RechargeID == 0 || facts.PaymentOrderID != facts.RechargeID {
return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致")
}
method := strings.TrimSpace(facts.PaymentMethod)
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) ||
method != strings.TrimSpace(facts.RechargePaymentChannel) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")
}
tradeNo := strings.TrimSpace(facts.ConfirmedTradeNo)
if tradeNo == "" || facts.ConfirmedAmount <= 0 || facts.PaymentAmount != facts.RechargeAmount ||
facts.PaymentAmount != facts.ConfirmedAmount {
return false, errors.New(errors.CodeConflict, "支付金额或第三方交易号无效")
}
if facts.PaymentState == PaymentStatePaid {
if strings.TrimSpace(facts.StoredTradeNo) == tradeNo &&
(facts.RechargeStatus == constants.RechargeStatusPaid || facts.RechargeStatus == constants.RechargeStatusCompleted) {
return true, nil
}
return false, errors.New(errors.CodeConflict, "支付单已存在不一致的确认事实")
}
validPending := facts.PaymentState == PaymentStatePending && facts.RechargeStatus == constants.RechargeStatusPending
validLateSuccess := facts.PaymentState == PaymentStateFailed && facts.RechargeStatus == constants.RechargeStatusClosed
if !validPending && !validLateSuccess {
return false, errors.New(errors.CodeInvalidStatus, "代理充值单当前状态不可确认支付")
}
return false, nil
}

View File

@@ -1,23 +1,42 @@
package admin
import (
"bytes"
"strconv"
"strings"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AgentRechargeHandler 代理预充值 Handler
type AgentRechargeHandler struct {
service *agentRechargeSvc.Service
online *agentrechargeapp.OnlineCreationService
status *agentrechargequery.PaymentStatusQuery
validator *validator.Validate
}
// SetOnlineCreationService 注入代理在线扫码充值用例。
func (h *AgentRechargeHandler) SetOnlineCreationService(service *agentrechargeapp.OnlineCreationService) {
h.online = service
}
// SetPaymentStatusQuery 注入代理充值本地支付状态 Query。
func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.PaymentStatusQuery) {
h.status = query
}
// NewAgentRechargeHandler 创建代理预充值 Handler
func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler {
return &AgentRechargeHandler{service: service, validator: validator}
@@ -27,13 +46,21 @@ func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *valid
// POST /api/admin/agent-recharges
func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAgentRechargeRequest
if err := c.BodyParser(&req); err != nil {
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.PaymentMethod == constants.RechargeMethodWechat || req.PaymentMethod == constants.RechargeMethodAlipay {
return h.createOnline(c, req)
}
if strings.TrimSpace(req.RequestID) != "" {
return errors.New(errors.CodeInvalidParam, "线下充值不能传入在线请求标识")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
@@ -42,6 +69,45 @@ func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
return response.Success(c, result)
}
func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRechargeRequest) error {
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注")
}
result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{
AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()),
CurrentShopID: middleware.GetShopIDFromContext(c.UserContext()), Amount: req.Amount,
PaymentMethod: req.PaymentMethod, RequestID: req.RequestID,
})
if err != nil {
return err
}
rechargeSource, rechargeSourceName := constants.GetAgentRechargeSource(result.Payment.PaymentMethod)
return response.Success(c, &dto.AgentRechargeOnlineResponse{
RechargeID: result.Recharge.ID, RechargeNo: result.Recharge.RechargeNo, PaymentNo: result.Payment.PaymentNo,
PaymentMethod: result.Payment.PaymentMethod, Amount: result.Payment.Amount, QRContent: result.Payment.QRContent,
RechargeSource: rechargeSource, RechargeSourceName: rechargeSourceName,
Status: result.Recharge.Status, StatusName: constants.GetRechargeStatusName(result.Recharge.Status),
})
}
// PaymentMethods 查询代理在线充值可用支付方式。
// GET /api/admin/agent-recharges/payment-methods
func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error {
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
result, err := h.online.AvailablePaymentMethods(c.UserContext(), middleware.GetUserTypeFromContext(c.UserContext()))
if err != nil {
return err
}
return response.Success(c, &dto.AgentRechargePaymentMethodsResponse{
Methods: result.Methods, MinAmount: result.MinAmount, MaxAmount: result.MaxAmount,
})
}
// List 查询代理充值订单列表
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
@@ -49,6 +115,9 @@ func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
list, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
@@ -74,6 +143,23 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, result)
}
// PaymentStatus 查询代理充值本地支付与到账状态。
// GET /api/admin/agent-recharges/:id/payment-status
func (h *AgentRechargeHandler) PaymentStatus(c *fiber.Ctx) error {
if h.status == nil {
return errors.New(errors.CodeServiceUnavailable, "代理充值支付状态查询未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
result, err := h.status.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Reject 驳回代理充值订单
// POST /api/admin/agent-recharges/:id/reject
func (h *AgentRechargeHandler) Reject(c *fiber.Ctx) error {

View File

@@ -2,16 +2,18 @@ package callback
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
"go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
@@ -21,6 +23,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/fuiou"
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"gorm.io/gorm"
)
@@ -44,6 +47,8 @@ type PaymentHandler struct {
wechatPayment wechat.PaymentServiceInterface
wechatConfigService WechatConfigServiceInterface
paymentStore *postgres.PaymentStore
agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
integration *integrationlog.Repository
logger *zap.Logger
}
@@ -55,6 +60,8 @@ func NewPaymentHandler(
wechatPayment wechat.PaymentServiceInterface,
wechatConfigService WechatConfigServiceInterface,
paymentStore *postgres.PaymentStore,
agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService,
integration *integrationlog.Repository,
logger *zap.Logger,
) *PaymentHandler {
return &PaymentHandler{
@@ -65,15 +72,30 @@ func NewPaymentHandler(
wechatPayment: wechatPayment,
wechatConfigService: wechatConfigService,
paymentStore: paymentStore,
agentPaymentConfirm: agentPaymentConfirm,
integration: integration,
logger: logger,
}
}
type verifiedPaymentCallback struct {
PaymentNo string
PaymentMethod string
TransactionID string
Amount int64
ConfigID uint
MerchantIdentity string
PaidAt time.Time
Provider string
RawPayload []byte
ContentType string
}
// WechatPayCallback 微信支付回调(带签名验证)
// POST /api/callback/wechat-pay
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
body := c.Body()
ctx := context.Background()
ctx := c.UserContext()
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
orderNo, err := wechat.PeekOrderNo(body)
@@ -103,7 +125,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
}
// TotalFee 为字符串格式的分,解析失败则降级为 0后续 handlePaymentCallback 会记录日志)
totalFee, _ := strconv.ParseInt(result.TotalFee, 10, 64)
if err := h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID, totalFee); err != nil {
paidAt, _ := time.ParseInLocation("20060102150405", result.SuccessTime, time.Local)
if err := h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat,
TransactionID: result.TransactionID, Amount: totalFee, ConfigID: cfg.ID,
MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay,
RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
}
@@ -117,7 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
if result.TradeState != "SUCCESS" {
return nil
}
return h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID, result.TotalAmount)
paidAt, _ := time.Parse(time.RFC3339, result.SuccessTime)
return h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat,
TransactionID: result.TransactionID, Amount: result.TotalAmount, ConfigID: cfg.ID,
MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay,
RawPayload: body, ContentType: c.Get("Content-Type"),
})
})
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
@@ -130,20 +164,22 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
return h.wechatV2SuccessResponse(c)
}
func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, paymentNo, paymentMethod, transactionID string, paidAmount int64) (bool, error) {
func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, callback verifiedPaymentCallback) (bool, error) {
if h.paymentStore != nil {
payment, err := h.paymentStore.GetByPaymentNo(ctx, paymentNo)
payment, err := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo)
if err == nil {
switch payment.OrderType {
case model.PaymentOrderTypePackage:
return true, h.orderService.HandlePaymentRecordCallback(ctx, paymentNo, paymentMethod, transactionID, paidAmount)
return true, h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
case model.PaymentOrderTypeRecharge:
if h.rechargeOrderService != nil {
return true, h.rechargeOrderService.HandlePaymentCallback(ctx, paymentNo, paymentMethod, transactionID)
return true, h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
}
return true, fmt.Errorf("充值订单服务未配置,无法处理支付单: %s", paymentNo)
return true, errors.New(errors.CodeInternalError, "充值订单服务未配置")
case model.PaymentOrderTypeAgentRecharge:
return true, h.confirmAgentRechargePayment(ctx, callback)
default:
return true, fmt.Errorf("未知支付记录类型: %s", payment.OrderType)
return true, errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
}
}
if err != gorm.ErrRecordNotFound {
@@ -154,27 +190,83 @@ func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, paym
}
// dispatchWechatCallback 优先按支付单分发,旧单号继续按前缀兼容处理。
func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo, transactionID string, paidAmount int64) error {
handled, err := h.dispatchPaymentRecordCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount)
func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, callback verifiedPaymentCallback) error {
handled, err := h.dispatchPaymentRecordCallback(ctx, callback)
if handled || err != nil {
return err
}
switch {
case strings.HasPrefix(outTradeNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, paidAmount)
case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix):
case strings.HasPrefix(callback.PaymentNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.Amount)
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
return h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByWechat, transactionID)
return h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentByWechat, callback.TransactionID)
}
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount)
return h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.TransactionID, callback.Amount)
}
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
return errors.New(errors.CodeInternalError, "代理充值服务未配置")
default:
return fmt.Errorf("未知订单号前缀: %s", outTradeNo)
return errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
}
}
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback) error {
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
}
resourceID, correlationID := callback.PaymentNo, callback.PaymentNo
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
}
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceID: &resourceID,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil {
return err
}
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
Amount: callback.Amount, PaidAt: callback.PaidAt, CorrelationID: correlationID,
})
if confirmErr != nil {
h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderMessage: confirmErr.Error(),
ResponseSummary: map[string]any{"confirmed": false},
})
return confirmErr
}
if log.Result == constants.IntegrationResultPending {
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
StateChanged: !result.AlreadyConfirmed,
})
if err != nil {
return err
}
}
return nil
}
func (h *PaymentHandler) completePaymentCallbackLog(ctx context.Context, log *model.IntegrationLog, completion integrationlog.Completion) {
if log == nil || log.Result != constants.IntegrationResultPending {
return
}
if _, err := h.integration.Complete(ctx, log.IntegrationID, completion); err != nil {
h.logger.Error("支付回调 Integration Log 终结失败",
zap.String("integration_id", log.IntegrationID),
zap.Error(err),
)
}
}
@@ -305,14 +397,16 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
}
// 写入支付宝交易流水号
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
// 不中断业务,继续分发(幂等业务层会处理状态)
// 新代理充值由统一确认事务原子写入交易号,旧业务保持原处理方式。
if payment.OrderType != model.PaymentOrderTypeAgentRecharge {
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
// 不中断存量业务,继续由原幂等业务层处理状态。
}
}
// 按支付单 order_type 分发业务
@@ -353,6 +447,28 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
zap.String("order_type", payment.OrderType),
)
case model.PaymentOrderTypeAgentRecharge:
paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local)
if parseErr != nil {
h.logger.Error("支付宝回调:付款时间格式无效",
zap.String("out_trade_no", outTradeNo),
zap.Error(parseErr),
)
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝付款时间格式错误")
}
if err := h.confirmAgentRechargePayment(ctx, verifiedPaymentCallback{
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
MerchantIdentity: notification.AppId, PaidAt: paidAt, Provider: constants.IntegrationProviderAlipay,
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}); err != nil {
h.logger.Error("支付宝回调:确认代理充值支付失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝代理充值回调失败")
}
default:
h.logger.Error("支付宝回调:未知支付记录类型",
zap.String("out_trade_no", outTradeNo),
@@ -452,7 +568,11 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
orderNo := notify.MchntOrderNo
// OrderAmt 为字符串格式的分,解析失败则降级为 0
orderAmt, _ := strconv.ParseInt(notify.OrderAmt, 10, 64)
if handled, err := h.dispatchPaymentRecordCallback(ctx, orderNo, "fuiou", notify.TransactionId, orderAmt); err != nil {
if handled, err := h.dispatchPaymentRecordCallback(ctx, verifiedPaymentCallback{
PaymentNo: orderNo, PaymentMethod: "fuiou", TransactionID: notify.TransactionId, Amount: orderAmt,
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, Provider: model.ProviderTypeFuiou,
RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
} else if handled {
return c.Send(fuiou.BuildNotifySuccessResponse())

View File

@@ -0,0 +1,110 @@
package payment
import (
"context"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AgentRechargePaymentConsumer 将已确认收款的代理在线充值幂等入账主钱包。
type AgentRechargePaymentConsumer struct {
db *gorm.DB
posting *walletapp.PostingService
}
// NewAgentRechargePaymentConsumer 创建代理在线充值入账消费者。
func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService) *AgentRechargePaymentConsumer {
return &AgentRechargePaymentConsumer{db: db, posting: posting}
}
// Consume 校验支付与充值权威事实后,在独立事务中完成唯一入账和充值终态。
func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.posting == nil {
return errors.New(errors.CodeInternalError, "代理在线充值入账消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentRechargePaymentConfirmed ||
envelope.PayloadVersion != constants.AgentRechargePaymentConfirmedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件类型或版本不受支持")
}
var event agentrecharge.PaymentConfirmedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理充值支付确认事件载荷无法解析")
}
if event.EventID == "" || event.EventID != envelope.EventID || event.RechargeID == 0 || event.PaymentID == 0 ||
event.ShopID == 0 || event.WalletID == 0 || event.Amount <= 0 || event.ThirdPartyTradeNo == "" {
return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件载荷不完整")
}
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
recharge, _, err := lockCreditingFacts(ctx, tx, event)
if err != nil {
return err
}
if recharge.Status != constants.RechargeStatusPaid && recharge.Status != constants.RechargeStatusCompleted {
return errors.New(errors.CodeInvalidStatus, "代理在线充值当前状态不可入账")
}
if _, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, Amount: recharge.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: recharge.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: recharge.UserID, Creator: recharge.UserID, Remark: "代理在线扫码充值",
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID,
}); err != nil {
return err
}
if recharge.Status == constants.RechargeStatusCompleted {
return nil
}
completedAt := time.Now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPaid).
Updates(map[string]any{"status": constants.RechargeStatusCompleted, "completed_at": completedAt})
if update.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, update.Error, "完成代理在线充值单失败")
}
if update.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理在线充值状态已变化")
}
return nil
})
}
func lockCreditingFacts(ctx context.Context, tx *gorm.DB, event agentrecharge.PaymentConfirmedEvent) (*model.AgentRechargeRecord, *model.Payment, error) {
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.RechargeID).First(&recharge).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "代理在线充值单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理在线充值单失败")
}
var payment model.Payment
if err := tx.WithContext(ctx).Where("id = ?", event.PaymentID).First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "代理在线充值支付单不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理在线充值支付单失败")
}
if recharge.ID != event.RechargeID || recharge.RechargeNo != event.RechargeNo || recharge.ShopID != event.ShopID ||
recharge.AgentWalletID != event.WalletID || recharge.UserID != event.UserID || recharge.Amount != event.Amount ||
recharge.PaymentTransactionID == nil || *recharge.PaymentTransactionID != event.ThirdPartyTradeNo ||
(recharge.PaymentMethod != constants.RechargeMethodWechat && recharge.PaymentMethod != constants.RechargeMethodAlipay) {
return nil, nil, errors.New(errors.CodeConflict, "代理充值事件与充值权威事实不一致")
}
if payment.OrderType != model.PaymentOrderTypeAgentRecharge || payment.OrderID != recharge.ID ||
payment.PaymentNo != event.PaymentNo || payment.PaymentMethod != event.PaymentMethod || payment.Amount != event.Amount ||
payment.Status != model.PaymentRecordStatusPaid || payment.ThirdPartyTradeNo != event.ThirdPartyTradeNo {
return nil, nil, errors.New(errors.CodeConflict, "代理充值事件与支付权威事实不一致")
}
return &recharge, &payment, nil
}
var _ outbox.EventConsumer = (*AgentRechargePaymentConsumer)(nil)

View File

@@ -0,0 +1,38 @@
package payment
import (
"context"
"strconv"
"gorm.io/gorm"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AgentRechargePaymentEventWriter 将支付确认事实写入公共 Outbox。
type AgentRechargePaymentEventWriter struct {
outbox *outbox.Repository
}
// NewAgentRechargePaymentEventWriter 创建代理充值支付确认事件 Writer。
func NewAgentRechargePaymentEventWriter(repository *outbox.Repository) *AgentRechargePaymentEventWriter {
return &AgentRechargePaymentEventWriter{outbox: repository}
}
// Append 在支付确认事务内追加稳定的代理充值入账事件。
func (w *AgentRechargePaymentEventWriter) Append(ctx context.Context, tx *gorm.DB, event agentrecharge.PaymentConfirmedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理充值支付确认 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentRechargePaymentConfirmed,
PayloadVersion: constants.AgentRechargePaymentConfirmedPayloadVersionV1,
AggregateType: "agent_recharge", AggregateID: strconv.FormatUint(uint64(event.RechargeID), 10),
ResourceType: "payment", ResourceID: strconv.FormatUint(uint64(event.PaymentID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,29 @@
package payment
import (
"context"
"github.com/hibiken/asynq"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AgentRechargeRecoveryTaskHandler 执行代理在线充值支付恢复任务。
type AgentRechargeRecoveryTaskHandler struct {
service *agentrecharge.RecoverOnlinePaymentService
}
// NewAgentRechargeRecoveryTaskHandler 创建代理在线充值支付恢复任务 Handler。
func NewAgentRechargeRecoveryTaskHandler(service *agentrecharge.RecoverOnlinePaymentService) *AgentRechargeRecoveryTaskHandler {
return &AgentRechargeRecoveryTaskHandler{service: service}
}
// Handle 扫描长期待支付记录并复用原支付单号收敛渠道状态。
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复任务未配置")
}
_, err := h.service.ProcessBatch(ctx)
return err
}

View File

@@ -0,0 +1,178 @@
package payment
import (
"context"
"strings"
"time"
sdkalipay "github.com/smartwalle/alipay/v3"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
alipaypkg "github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AlipayPreCreateAdapter 使用现有 smartwalle/alipay 实现当面付预下单与查单。
type AlipayPreCreateAdapter struct {
integration *integrationlog.Repository
}
// NewAlipayPreCreateAdapter 创建支付宝当面付适配器。
func NewAlipayPreCreateAdapter(integration *integrationlog.Repository) *AlipayPreCreateAdapter {
return &AlipayPreCreateAdapter{integration: integration}
}
// Available 判断配置是否完整支持支付宝预下单、验签与查单。
func (a *AlipayPreCreateAdapter) Available(config *model.WechatConfig) bool {
return alipayConfigComplete(config, true)
}
// PreCreate 创建支付宝当面付扫码订单。
func (a *AlipayPreCreateAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) {
client, err := a.client(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
startedAt := time.Now()
response, callErr := client.TradePreCreate(ctx, sdkalipay.TradePreCreate{Trade: sdkalipay.Trade{
NotifyURL: request.Config.AliNotifyURL, Subject: request.Description, OutTradeNo: request.PaymentNo,
TotalAmount: alipaypkg.FenToYuan(request.Amount), ProductCode: "FACE_TO_FACE_PAYMENT",
TimeExpire: request.ExpireAt.Format("2006-01-02 15:04:05"),
}})
if callErr != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
if response == nil || response.IsFailure() || strings.TrimSpace(response.QRCode) == "" {
providerCode, providerMessage := "empty_qr_code", "支付宝预下单未返回付款内容"
responseSummary := map[string]any{"success": false}
if response != nil && response.IsFailure() {
providerCode, providerMessage = string(response.Code), response.SubMsg
if response.SubCode != "" {
providerCode += ":" + response.SubCode
responseSummary["sub_code"] = response.SubCode
}
}
_, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: providerCode, ProviderMessage: providerMessage,
ResponseSummary: responseSummary, DurationMS: time.Since(startedAt).Milliseconds(),
})
if completeErr != nil {
return agentrecharge.OnlinePaymentResult{}, completeErr
}
return agentrecharge.OnlinePaymentResult{}, apperrors.New(apperrors.CodeServiceUnavailable, "支付宝预下单失败")
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: string(response.Code),
ResponseSummary: map[string]any{"success": true}, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true,
}); err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
return agentrecharge.OnlinePaymentResult{QRContent: response.QRCode}, nil
}
// Query 查询支付宝当面付订单状态。
func (a *AlipayPreCreateAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) {
client, err := a.queryClient(config)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
startedAt := time.Now()
response, callErr := client.TradeQuery(ctx, sdkalipay.TradeQuery{OutTradeNo: paymentNo})
if callErr != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
if response == nil || response.IsFailure() {
providerCode, providerMessage := "empty_response", "支付宝查单未返回有效响应"
if response != nil {
providerCode, providerMessage = string(response.Code), response.SubMsg
}
_, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: providerCode, ProviderMessage: providerMessage,
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
})
if completeErr != nil {
return agentrecharge.OnlinePaymentQueryResult{}, completeErr
}
return agentrecharge.OnlinePaymentQueryResult{}, apperrors.New(apperrors.CodeServiceUnavailable, "支付宝查单失败")
}
result := agentrecharge.OnlinePaymentQueryResult{
State: mapAlipayTradeState(response.TradeStatus), ThirdPartyTradeNo: response.TradeNo,
}
result.Amount, _ = alipaypkg.YuanToFen(response.TotalAmount)
if paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", response.SendPayDate, time.Local); parseErr == nil {
result.PaidAt = &paidAt
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: string(response.TradeStatus),
ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""},
DurationMS: time.Since(startedAt).Milliseconds(),
}); err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
return result, nil
}
func (a *AlipayPreCreateAdapter) client(config *model.WechatConfig) (*sdkalipay.Client, error) {
if !a.Available(config) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝扫码支付配置不可用")
}
return alipaypkg.NewClientFromConfig(config)
}
func (a *AlipayPreCreateAdapter) queryClient(config *model.WechatConfig) (*sdkalipay.Client, error) {
if !alipayConfigComplete(config, false) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝查单配置不可用")
}
return alipaypkg.NewClientFromConfig(config)
}
func alipayConfigComplete(config *model.WechatConfig, requireActive bool) bool {
return config != nil && (!requireActive || config.IsActive) && config.AliAppID != "" && config.AliPrivateKey != "" &&
config.AliPublicKey != "" && config.AliNotifyURL != ""
}
func (a *AlipayPreCreateAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) {
resourceID := paymentNo
return a.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderAlipay, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment,
ResourceID: &resourceID, ExternalID: &resourceID,
RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount},
})
}
func (a *AlipayPreCreateAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error {
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(),
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})
if err != nil {
return err
}
return apperrors.Wrap(apperrors.CodeTimeout, cause, "支付宝支付请求结果未知")
}
func mapAlipayTradeState(state sdkalipay.TradeStatus) string {
switch state {
case sdkalipay.TradeStatusSuccess, sdkalipay.TradeStatusFinished:
return agentrecharge.OnlinePaymentStatePaid
case sdkalipay.TradeStatusClosed:
return agentrecharge.OnlinePaymentStateClosed
case sdkalipay.TradeStatusWaitBuyerPay:
return agentrecharge.OnlinePaymentStatePending
default:
return agentrecharge.OnlinePaymentStateUnknown
}
}

View File

@@ -0,0 +1,171 @@
// Package payment 提供代理在线充值使用的支付渠道薄适配器。
package payment
import (
"context"
"strings"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
sdkpayment "github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
orderRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
"go.uber.org/zap"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// WechatNativeAdapter 使用现有 PowerWeChat 实现 Native 预下单与查单。
type WechatNativeAdapter struct {
cache kernel.CacheInterface
integration *integrationlog.Repository
logger *zap.Logger
}
// NewWechatNativeAdapter 创建微信 Native 支付适配器。
func NewWechatNativeAdapter(cache kernel.CacheInterface, integration *integrationlog.Repository, logger *zap.Logger) *WechatNativeAdapter {
return &WechatNativeAdapter{cache: cache, integration: integration, logger: logger}
}
// Available 判断配置是否完整支持微信 Native 预下单、验签与查单。
func (a *WechatNativeAdapter) Available(config *model.WechatConfig) bool {
return wechatConfigComplete(config, true)
}
// PreCreate 创建微信 Native 扫码支付单。
func (a *WechatNativeAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) {
app, err := a.paymentApp(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
startedAt := time.Now()
response, callErr := app.Order.TransactionNative(ctx, &orderRequest.RequestNativePrepay{
Description: request.Description,
OutTradeNo: request.PaymentNo,
TimeExpire: request.ExpireAt.Format(time.RFC3339),
Amount: &orderRequest.NativeAmount{Total: int(request.Amount), Currency: "CNY"},
})
if callErr != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
if response == nil || strings.TrimSpace(response.CodeURL) == "" {
err = apperrors.New(apperrors.CodeWechatPayFailed, "微信 Native 预下单未返回付款内容")
_, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: "empty_code_url", ProviderMessage: err.Error(),
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
})
if completeErr != nil {
return agentrecharge.OnlinePaymentResult{}, completeErr
}
return agentrecharge.OnlinePaymentResult{}, err
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true},
DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true,
}); err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
return agentrecharge.OnlinePaymentResult{QRContent: response.CodeURL}, nil
}
// Query 查询微信 Native 支付单状态。
func (a *WechatNativeAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) {
app, err := a.queryPaymentApp(config)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
startedAt := time.Now()
info, callErr := wechatpay.NewPaymentService(app, a.logger).QueryOrder(ctx, paymentNo)
if callErr != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
result := agentrecharge.OnlinePaymentQueryResult{
State: mapWechatTradeState(info.TradeState), ThirdPartyTradeNo: info.TransactionID, Amount: info.TotalAmount,
}
if paidAt, parseErr := time.Parse(time.RFC3339, info.SuccessTime); parseErr == nil {
result.PaidAt = &paidAt
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: info.TradeState,
ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""},
DurationMS: time.Since(startedAt).Milliseconds(),
}); err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
return result, nil
}
func (a *WechatNativeAdapter) paymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) {
if !a.Available(config) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信 Native 支付配置不可用")
}
app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 Native 支付配置不可用")
}
return app, nil
}
func (a *WechatNativeAdapter) queryPaymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) {
if !wechatConfigComplete(config, false) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信查单配置不可用")
}
app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信查单配置不可用")
}
return app, nil
}
func wechatConfigComplete(config *model.WechatConfig, requireActive bool) bool {
return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeWechat &&
config.OaAppID != "" && config.WxMchID != "" && config.WxAPIV3Key != "" &&
config.WxCertContent != "" && config.WxKeyContent != "" && config.WxSerialNo != "" && config.WxNotifyURL != ""
}
func (a *WechatNativeAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) {
resourceID := paymentNo
return a.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWechatPay, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment,
ResourceID: &resourceID, ExternalID: &resourceID,
RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount},
})
}
func (a *WechatNativeAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error {
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(),
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})
if err != nil {
return err
}
return apperrors.Wrap(apperrors.CodeTimeout, cause, "微信支付请求结果未知")
}
func mapWechatTradeState(state string) string {
switch state {
case "SUCCESS":
return agentrecharge.OnlinePaymentStatePaid
case "CLOSED", "REVOKED", "PAYERROR":
return agentrecharge.OnlinePaymentStateClosed
case "NOTPAY", "USERPAYING":
return agentrecharge.OnlinePaymentStatePending
default:
return agentrecharge.OnlinePaymentStateUnknown
}
}

View File

@@ -89,6 +89,8 @@ type AgentRechargeRecord struct {
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"`
RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"`
RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`

View File

@@ -2,13 +2,49 @@ package dto
// CreateAgentRechargeRequest 创建代理充值请求
type CreateAgentRechargeRequest struct {
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID代理只能填自己店铺"`
ShopID *uint `json:"shop_id,omitempty" description:"目标店铺ID仅平台线下代充可填;代理在线充值禁止传入"`
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额范围1分~100万元"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"`
RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个微信支付时忽略"`
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
}
// AgentRechargeOnlineResponse 代理在线扫码充值创建响应。
type AgentRechargeOnlineResponse struct {
RechargeID uint `json:"recharge_id" description:"充值记录ID"`
RechargeNo string `json:"recharge_no" description:"充值单号ARCH前缀"`
PaymentNo string `json:"payment_no" description:"支付单号PAY前缀"`
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信, alipay:支付宝)"`
RechargeSource string `json:"recharge_source" description:"充值来源 (agent_online:代理在线自充)"`
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
Amount int64 `json:"amount" description:"在线充值金额范围10000分~100000000分"`
QRContent string `json:"qr_content" description:"支付渠道原始扫码付款内容,由前端渲染二维码"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}
// AgentRechargePaymentMethodsResponse 代理在线充值可用支付方式响应。
type AgentRechargePaymentMethodsResponse struct {
Methods []string `json:"methods" description:"可用支付方式,固定顺序 (wechat:微信, alipay:支付宝)"`
MinAmount int64 `json:"min_amount" description:"在线充值最小金额固定为10000"`
MaxAmount int64 `json:"max_amount" description:"在线充值最大金额固定为100000000"`
}
// AgentRechargePaymentStatusResponse 代理充值本地支付与到账状态响应。
type AgentRechargePaymentStatusResponse struct {
RechargeID uint `json:"recharge_id" description:"充值记录ID"`
RechargeNo string `json:"recharge_no" description:"充值单号ARCH前缀"`
RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
Status int `json:"status" description:"充值状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StatusName string `json:"status_name" description:"充值状态名称(中文)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已失败, 3:已退款)"`
PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"`
PaidAt *string `json:"paid_at" description:"第三方支付确认时间"`
CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"`
}
// AgentOfflinePayRequest 代理线下充值确认请求
type AgentOfflinePayRequest struct {
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
@@ -28,7 +64,9 @@ type AgentRechargeResponse struct {
ShopName string `json:"shop_name" description:"店铺名称"`
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
Amount int64 `json:"amount" description:"充值金额(分)"`
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"`
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"`
RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID线下充值为null"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
@@ -62,12 +100,13 @@ type AgentRechargeRejectParams struct {
// AgentRechargeListRequest 代理充值记录列表请求
type AgentRechargeListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
RechargeSource string `json:"recharge_source" query:"recharge_source" validate:"omitempty,oneof=platform_offline agent_online" description:"按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
}
// AgentRechargeListResponse 代理充值记录列表响应

View File

@@ -3,6 +3,8 @@ package model
import (
"gorm.io/gorm"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type Payment struct {
@@ -11,11 +13,13 @@ type Payment struct {
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
QRContent string `gorm:"column:qr_content;type:text" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
@@ -28,15 +32,16 @@ func (Payment) TableName() string {
}
const (
PaymentRecordStatusPending = 0 // 待支付
PaymentRecordStatusPaid = 1 // 已支付
PaymentRecordStatusFailed = 2 // 已失败
PaymentRecordStatusRefunded = 3 // 已退款
PaymentRecordStatusPending = constants.PaymentRecordStatusPending // 待支付
PaymentRecordStatusPaid = constants.PaymentRecordStatusPaid // 已支付
PaymentRecordStatusFailed = constants.PaymentRecordStatusFailed // 已失败
PaymentRecordStatusRefunded = constants.PaymentRecordStatusRefunded // 已退款
)
const (
PaymentOrderTypePackage = "package_order"
PaymentOrderTypeRecharge = "recharge_order"
PaymentOrderTypePackage = "package_order" // 套餐订单
PaymentOrderTypeRecharge = "recharge_order" // 客户充值订单
PaymentOrderTypeAgentRecharge = "agent_recharge" // 代理充值订单
)
const (

View File

@@ -0,0 +1,62 @@
// Package agentrecharge 提供代理充值本地状态只读投影。
package agentrecharge
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PaymentStatusQuery 查询代理充值本地支付与到账事实。
type PaymentStatusQuery struct {
db *gorm.DB
}
// NewPaymentStatusQuery 创建代理充值支付状态 Query。
func NewPaymentStatusQuery(db *gorm.DB) *PaymentStatusQuery {
return &PaymentStatusQuery{db: db}
}
// Get 读取当前数据范围内的充值单和支付单,不调用第三方渠道。
func (q *PaymentStatusQuery) Get(ctx context.Context, rechargeID uint) (*dto.AgentRechargePaymentStatusResponse, error) {
if q == nil || q.db == nil || rechargeID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付状态查询参数无效")
}
var recharge model.AgentRechargeRecord
if err := q.db.WithContext(ctx).Where("id = ?", rechargeID).First(&recharge).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值状态失败")
}
var payment model.Payment
if err := q.db.WithContext(ctx).
Where("order_id = ? AND order_type = ?", recharge.ID, model.PaymentOrderTypeAgentRecharge).
First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值支付状态失败")
}
source, sourceName := constants.GetAgentRechargeSource(recharge.PaymentMethod)
result := &dto.AgentRechargePaymentStatusResponse{
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo,
RechargeSource: source, RechargeSourceName: sourceName,
Status: recharge.Status, StatusName: constants.GetRechargeStatusName(recharge.Status),
PaymentStatus: payment.Status, PaymentStatusName: constants.GetPaymentRecordStatusName(payment.Status),
}
if payment.PaidAt != nil {
paidAt := payment.PaidAt.Format("2006-01-02 15:04:05")
result.PaidAt = &paidAt
}
if recharge.CompletedAt != nil {
completedAt := recharge.CompletedAt.Format("2006-01-02 15:04:05")
result.CompletedAt = &completedAt
}
return result, nil
}

View File

@@ -25,7 +25,7 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
Summary: "创建代理充值订单",
Tags: []string{"代理预充值"},
Input: new(dto.CreateAgentRechargeRequest),
Output: new(dto.AgentRechargeResponse),
Output: new(dto.AgentRechargeOnlineResponse),
Auth: true,
})
@@ -37,6 +37,21 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
Auth: true,
})
Register(group, doc, groupPath, "GET", "/payment-methods", handler.PaymentMethods, RouteSpec{
Summary: "查询代理在线充值可用支付方式",
Tags: []string{"代理预充值"},
Output: new(dto.AgentRechargePaymentMethodsResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id/payment-status", handler.PaymentStatus, RouteSpec{
Summary: "查询代理充值本地支付与到账状态",
Tags: []string{"代理预充值"},
Input: new(dto.IDReq),
Output: new(dto.AgentRechargePaymentStatusResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "查询代理充值订单详情",
Tags: []string{"代理预充值"},

View File

@@ -95,20 +95,16 @@ func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCre
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
userShopID := middleware.GetShopIDFromContext(ctx)
// 代理只能充自己店铺
if userType == constants.UserTypeAgent && req.ShopID != userShopID {
return nil, errors.New(errors.CodeForbidden, "代理只能为自己的店铺充值")
if req.PaymentMethod != constants.RechargeMethodOffline {
return nil, errors.New(errors.CodeInvalidStatus, "在线充值必须通过代理在线创建用例处理")
}
// 线下充值仅平台可用
if req.PaymentMethod == constants.RechargeMethodOffline && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
if userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
// 线下充值必须上传支付凭证
if req.PaymentMethod == constants.RechargeMethodOffline && len(req.PaymentVoucherKey) == 0 {
if req.ShopID == nil || *req.ShopID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须指定目标店铺")
}
if len(req.PaymentVoucherKey) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
}
@@ -117,73 +113,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
}
rechargeNo := s.generateRechargeNo()
if req.PaymentMethod == constants.RechargeMethodOffline {
return s.createOffline(ctx, req, userID, userType, rechargeNo)
}
// 查找目标店铺的主钱包
wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺主钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主钱包失败")
}
// 查询店铺名称
shop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
}
// 在线支付需要查询生效的支付配置
var paymentConfigID *uint
var paymentChannel string
if req.PaymentMethod == "wechat" {
activeConfig, cfgErr := s.wechatConfigService.GetActiveConfig(ctx)
if cfgErr != nil || activeConfig == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前无可用的支付配置,请联系管理员")
}
paymentConfigID = &activeConfig.ID
paymentChannel = activeConfig.ProviderType
} else {
paymentChannel = "offline"
}
record := &model.AgentRechargeRecord{
UserID: userID,
AgentWalletID: wallet.ID,
ShopID: req.ShopID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: req.PaymentMethod,
PaymentChannel: &paymentChannel,
PaymentConfigID: paymentConfigID,
PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey),
Remark: req.Remark,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentRechargeStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
s.logger.Info("创建代理充值订单成功",
zap.Uint("recharge_id", record.ID),
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Uint("shop_id", req.ShopID),
zap.Uint("user_id", userID),
)
resp := toResponse(record, shop.ShopName)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID)
return resp, nil
return s.createOffline(ctx, req, userID, userType, rechargeNo)
}
func (s *Service) createOffline(
@@ -199,7 +129,7 @@ func (s *Service) createOffline(
result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{
SubmitterAccountID: userID,
SubmitterUserType: userType,
ShopID: req.ShopID,
ShopID: *req.ShopID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentVoucherKeys: req.PaymentVoucherKey,
@@ -522,6 +452,12 @@ func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) (
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
switch req.RechargeSource {
case constants.AgentRechargeSourcePlatformOffline:
query = query.Where("payment_method NOT IN ?", []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay})
case constants.AgentRechargeSourceAgentOnline:
query = query.Where("payment_method IN ?", []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay})
}
if req.StartDate != "" {
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
}
@@ -590,6 +526,7 @@ func (s *Service) generateRechargeNo() string {
// toResponse 将模型转换为响应 DTO
func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse {
rechargeSource, rechargeSourceName := constants.GetAgentRechargeSource(record.PaymentMethod)
resp := &dto.AgentRechargeResponse{
ID: record.ID,
RechargeNo: record.RechargeNo,
@@ -598,6 +535,8 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
RechargeSource: rechargeSource,
RechargeSourceName: rechargeSourceName,
PaymentVoucherKey: []string(record.PaymentVoucherKey),
Remark: record.Remark,
Status: record.Status,