收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAssetWalletOrderReservationLifecycle 验证个人钱包订单冻结、超额拦截、支付核销和历史订单兼容。
|
||||
func TestAssetWalletOrderReservationLifecycle(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
resourceID := uint(time.Now().UnixNano() & testIDMask)
|
||||
wallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeIotCard,
|
||||
ResourceID: resourceID,
|
||||
Balance: 1000,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(wallet).Error; err != nil {
|
||||
t.Fatalf("创建测试资产钱包失败:%v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
order := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 700, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, order); err != nil {
|
||||
t.Fatalf("冻结订单金额失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
if order.AssetWalletReservationWalletID == nil || *order.AssetWalletReservationWalletID != wallet.ID || order.AssetWalletReservedAmount != 700 {
|
||||
t.Fatalf("订单预占快照错误:wallet_id=%v amount=%d", order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount)
|
||||
}
|
||||
|
||||
overdrawOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 400, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, overdrawOrder); err == nil {
|
||||
t.Fatal("可用余额不足时必须拒绝第二笔冻结")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, order, constants.AssetWalletResourceTypeIotCard, resourceID); err != nil {
|
||||
t.Fatalf("核销订单预占失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 0)
|
||||
|
||||
if err := tx.Model(&model.AssetWallet{}).Where("id = ?", wallet.ID).
|
||||
Updates(map[string]any{"balance": 300, "frozen_balance": 200, "version": 10}).Error; err != nil {
|
||||
t.Fatalf("准备历史订单场景失败:%v", err)
|
||||
}
|
||||
legacyOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 150, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.releaseAssetWalletReservation(context.Background(), tx, legacyOrder); err != nil {
|
||||
t.Fatalf("历史订单取消不应释放其他订单冻结额:%v", err)
|
||||
}
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, legacyOrder, constants.AssetWalletResourceTypeIotCard, resourceID); err == nil {
|
||||
t.Fatal("历史订单不得占用其他订单的冻结余额")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 200)
|
||||
}
|
||||
|
||||
func assertAssetWalletFunds(t *testing.T, tx *gorm.DB, walletID uint, balance, frozenBalance int64) {
|
||||
t.Helper()
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.First(&wallet, walletID).Error; err != nil {
|
||||
t.Fatalf("查询资产钱包失败:%v", err)
|
||||
}
|
||||
if wallet.Balance != balance || wallet.FrozenBalance != frozenBalance {
|
||||
t.Fatalf("资产钱包金额错误:balance=%d frozen=%d,期望 balance=%d frozen=%d", wallet.Balance, wallet.FrozenBalance, balance, frozenBalance)
|
||||
}
|
||||
}
|
||||
363
internal/service/order/audit.go
Normal file
363
internal/service/order/audit.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SetLifecycleAudit 注入订单生命周期统一审计 Writer。
|
||||
func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendOrderAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, order *model.Order, beforeData, afterData map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单统一审计接缝未配置")
|
||||
}
|
||||
resources, err := orderAuditResources(ctx, tx, order, beforeData, afterData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo,
|
||||
Metadata: map[string]any{
|
||||
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
|
||||
"purchase_role": order.PurchaseRole, "payment_method": order.PaymentMethod,
|
||||
},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, businessErr error) {
|
||||
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
resource := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
resource.BeforeData = orderStateData(order)
|
||||
correlationID := order.OrderNo
|
||||
if order.ID == 0 {
|
||||
correlationID = ""
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
CorrelationID: correlationID, Resources: []audit.ResourceInput{resource},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) recordAgentWalletOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, shopID uint, businessErr error) {
|
||||
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
orderSnapshot := *order
|
||||
if orderSnapshot.ID > 0 {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Unscoped().Model(&model.Order{}).Where("id = ?", orderSnapshot.ID).Count(&count).Error; err == nil && count == 0 {
|
||||
orderSnapshot.ID = 0
|
||||
}
|
||||
}
|
||||
primary := audit.OrderResource(&orderSnapshot, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
primary.BeforeData = orderStateData(order)
|
||||
resources := []audit.ResourceInput{primary}
|
||||
|
||||
var reservation model.AgentWalletReservation
|
||||
if order.ID > 0 {
|
||||
if err := s.db.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, order.ID).First(&reservation).Error; err == nil {
|
||||
reservationID := strconv.FormatUint(uint64(reservation.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWalletReservation, ID: &reservationID,
|
||||
Key: reservation.ReferenceType + ":" + strconv.FormatUint(uint64(reservation.ReferenceID), 10), DisplayName: "订单钱包预占 " + reservationID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletReservation,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": reservation.ID, "agent_wallet_id": reservation.AgentWalletID, "shop_id": reservation.ShopID,
|
||||
"amount": reservation.Amount, "status": reservation.Status,
|
||||
"reference_type": reservation.ReferenceType, "reference_id": reservation.ReferenceID,
|
||||
},
|
||||
BeforeData: map[string]any{"status": reservation.Status},
|
||||
})
|
||||
shopID = reservation.ShopID
|
||||
}
|
||||
}
|
||||
if shopID > 0 {
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err == nil {
|
||||
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderWallet,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
|
||||
"currency": wallet.Currency, "status": wallet.Status,
|
||||
},
|
||||
BeforeData: map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
})
|
||||
}
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
CorrelationID: order.OrderNo, Metadata: map[string]any{"amount": order.TotalAmount}, Resources: resources,
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
// RecordCreateFailure 在订单创建调用方已识别资产后记录失败或拒绝事实。
|
||||
func (s *Service) RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error) {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", order, businessErr)
|
||||
}
|
||||
|
||||
func orderAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order, beforeData, afterData map[string]any) ([]audit.ResourceInput, error) {
|
||||
if order == nil || order.ID == 0 || order.OrderNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "订单审计资源不完整")
|
||||
}
|
||||
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
primary.BeforeData, primary.AfterData = beforeData, afterData
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "订单状态已更新"
|
||||
resources := []audit.ResourceInput{primary}
|
||||
|
||||
buyer, err := orderBuyerAuditResource(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buyer != nil {
|
||||
resources = append(resources, *buyer)
|
||||
}
|
||||
asset, err := orderAssetAuditResource(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asset != nil {
|
||||
resources = append(resources, *asset)
|
||||
}
|
||||
|
||||
packages, err := orderPackageAuditResources(ctx, tx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, packages...)
|
||||
finance, err := orderFinanceAuditResources(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resources, finance...), nil
|
||||
}
|
||||
|
||||
func orderBuyerAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
|
||||
if order.BuyerID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
switch order.BuyerType {
|
||||
case model.BuyerTypeAgent:
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, order.BuyerID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家店铺审计快照失败")
|
||||
}
|
||||
resource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderBuyer)
|
||||
return &resource, nil
|
||||
case model.BuyerTypePersonal:
|
||||
var customer model.PersonalCustomer
|
||||
if err := tx.WithContext(ctx).First(&customer, order.BuyerID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(customer.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomer, ID: &id, Key: id, DisplayName: customer.Nickname,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderBuyer,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
|
||||
"wx_union_id": customer.WxUnionID, "status": customer.Status,
|
||||
},
|
||||
}
|
||||
return &resource, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func orderAssetAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
|
||||
if order.IotCardID != nil {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).First(&card, *order.IotCardID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联卡审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(card.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
|
||||
SubjectSummary: "关联订单状态已更新",
|
||||
}
|
||||
return &resource, nil
|
||||
}
|
||||
if order.DeviceID != nil {
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).First(&device, *order.DeviceID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联设备审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(device.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
|
||||
SubjectSummary: "关联订单状态已更新",
|
||||
}
|
||||
return &resource, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func orderPackageAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) {
|
||||
var packages []model.Package
|
||||
if err := tx.WithContext(ctx).Model(&model.Package{}).Distinct("tb_package.*").
|
||||
Joins("JOIN tb_order_item item ON item.package_id = tb_package.id AND item.deleted_at IS NULL").
|
||||
Where("item.order_id = ?", orderID).Order("tb_package.id ASC").Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联套餐审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(packages))
|
||||
for i := range packages {
|
||||
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPackage, nil, nil))
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func orderFinanceAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order) ([]audit.ResourceInput, error) {
|
||||
resources := make([]audit.ResourceInput, 0, 5)
|
||||
agentResources, err := agentWalletOrderAuditResources(ctx, tx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, agentResources...)
|
||||
assetResources, err := assetWalletOrderAuditResources(ctx, tx, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, assetResources...)
|
||||
|
||||
var payments []model.Payment
|
||||
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&payments).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录审计快照失败")
|
||||
}
|
||||
for i := range payments {
|
||||
id := strconv.FormatUint(uint64(payments[i].ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePayment, ID: &id, Key: payments[i].PaymentNo, DisplayName: payments[i].PaymentNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderPayment,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": payments[i].ID, "payment_no": payments[i].PaymentNo, "order_id": payments[i].OrderID,
|
||||
"order_type": payments[i].OrderType, "payment_method": payments[i].PaymentMethod,
|
||||
"amount": payments[i].Amount, "status": payments[i].Status,
|
||||
"third_party_trade_no": payments[i].ThirdPartyTradeNo, "payment_config_id": payments[i].PaymentConfigID,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func agentWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) {
|
||||
var transactions []model.AgentWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, orderID).
|
||||
Order("id ASC").Find(&transactions).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包流水审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(transactions)*2)
|
||||
wallets := make(map[uint]struct{}, len(transactions))
|
||||
for i := range transactions {
|
||||
transaction := &transactions[i]
|
||||
if _, ok := wallets[transaction.AgentWalletID]; !ok {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理主钱包 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
|
||||
"currency": wallet.Currency, "status": wallet.Status,
|
||||
},
|
||||
BeforeData: map[string]any{"balance": transaction.BalanceBefore},
|
||||
AfterData: map[string]any{"balance": transaction.BalanceAfter},
|
||||
})
|
||||
wallets[transaction.AgentWalletID] = struct{}{}
|
||||
}
|
||||
id := strconv.FormatUint(uint64(transaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderWalletTransaction,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": transaction.ID, "agent_wallet_id": transaction.AgentWalletID, "shop_id": transaction.ShopID,
|
||||
"transaction_type": transaction.TransactionType, "transaction_subtype": transaction.TransactionSubtype,
|
||||
"reference_type": transaction.ReferenceType, "reference_id": transaction.ReferenceID, "status": transaction.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func assetWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderNo string) ([]audit.ResourceInput, error) {
|
||||
var transactions []model.AssetWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_no = ?", constants.ReferenceTypeOrder, orderNo).
|
||||
Order("id ASC").Find(&transactions).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包流水审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(transactions)*2)
|
||||
wallets := make(map[uint]struct{}, len(transactions))
|
||||
for i := range transactions {
|
||||
transaction := &transactions[i]
|
||||
if _, ok := wallets[transaction.AssetWalletID]; !ok {
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
|
||||
"currency": wallet.Currency, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag,
|
||||
},
|
||||
BeforeData: map[string]any{"balance": transaction.BalanceBefore},
|
||||
AfterData: map[string]any{"balance": transaction.BalanceAfter},
|
||||
})
|
||||
wallets[transaction.AssetWalletID] = struct{}{}
|
||||
}
|
||||
id := strconv.FormatUint(uint64(transaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletTransaction,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID,
|
||||
"resource_type": transaction.ResourceType, "resource_id": transaction.ResourceID,
|
||||
"transaction_type": transaction.TransactionType, "reference_type": transaction.ReferenceType,
|
||||
"reference_no": transaction.ReferenceNo, "status": transaction.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func orderStateData(order *model.Order) map[string]any {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"payment_status": order.PaymentStatus, "payment_method": order.PaymentMethod,
|
||||
"total_amount": order.TotalAmount, "actual_paid_amount": order.ActualPaidAmount,
|
||||
"paid_at": order.PaidAt, "expires_at": order.ExpiresAt,
|
||||
"purchase_role": order.PurchaseRole,
|
||||
}
|
||||
}
|
||||
78
internal/service/order/payment_audit.go
Normal file
78
internal/service/order/payment_audit.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SetPaymentIntegrationLog 注入支付渠道 Integration Log。
|
||||
func (s *Service) SetPaymentIntegrationLog(repository *integrationlog.Repository) {
|
||||
s.paymentIntegration = repository
|
||||
}
|
||||
|
||||
func (s *Service) startOrderPaymentAttempt(ctx context.Context, order *model.Order, provider, scene string) (*model.IntegrationLog, time.Time, error) {
|
||||
if s.paymentIntegration == nil {
|
||||
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "支付 Integration Log 接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(order.ID), 10)
|
||||
resourceKey, series, correlationID := order.OrderNo, "order-payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, order.OrderNo
|
||||
triggerSource, triggerScene := auditcontext.From(ctx).Source, scene
|
||||
log, err := s.paymentIntegration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationPaymentPreCreate,
|
||||
ResourceType: constants.AuditResourceOrder, ResourceID: &resourceID, ResourceKey: &resourceKey,
|
||||
ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene,
|
||||
TriggerSeries: &series, CorrelationID: &correlationID,
|
||||
RequestSummary: map[string]any{"payment_config_id": order.PaymentConfigID, "amount": order.TotalAmount},
|
||||
})
|
||||
return log, time.Now(), err
|
||||
}
|
||||
|
||||
func (s *Service) completeOrderPaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
|
||||
if log == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付 Integration Log 尝试不存在")
|
||||
}
|
||||
completion := integrationlog.Completion{
|
||||
Result: result, ProviderCode: providerCode, SafeProviderMessage: safeMessage,
|
||||
ResponseSummary: map[string]any{"success": result == constants.IntegrationResultSuccess},
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
if result == constants.IntegrationResultUnknown {
|
||||
completion.RecoveryStrategy = "使用原业务单号向支付渠道查单,确认结果后再推进本地支付状态"
|
||||
}
|
||||
_, err := s.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) appendPaymentConfirmedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, beforePayment, afterPayment, beforeOrder, afterOrder map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置")
|
||||
}
|
||||
paymentResource := audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, beforePayment, afterPayment)
|
||||
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRolePaymentBusinessOrder)
|
||||
orderResource.BeforeData, orderResource.AfterData = beforeOrder, afterOrder
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectResult
|
||||
orderResource.SubjectSummary = "订单支付已确认"
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "第三方支付确认订单已支付",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: payment.PaymentNo, Resources: []audit.ResourceInput{paymentResource, orderResource},
|
||||
})
|
||||
}
|
||||
|
||||
func paymentStateData(payment *model.Payment) map[string]any {
|
||||
return map[string]any{
|
||||
"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo,
|
||||
"paid_at": payment.PaidAt,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -68,6 +71,8 @@ type Service struct {
|
||||
agentWalletDebit *walletapp.DebitService
|
||||
agentWalletReservation *walletapp.ReservationService
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
auditWriter *audit.Writer
|
||||
paymentIntegration *integrationlog.Repository
|
||||
}
|
||||
|
||||
// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。
|
||||
@@ -145,7 +150,7 @@ func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) {
|
||||
// CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活)
|
||||
// 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活
|
||||
// POST /api/admin/orders
|
||||
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) {
|
||||
resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
@@ -164,9 +169,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
deviceID = &resolvedDevice.ID
|
||||
resourceShopID = resolvedDevice.ShopID
|
||||
}
|
||||
auditOrder := &model.Order{
|
||||
OrderNo: "create:" + orderType + ":" + req.Identifier, OrderType: orderType,
|
||||
BuyerType: buyerType, BuyerID: buyerID, IotCardID: iotCardID, DeviceID: deviceID,
|
||||
AssetIdentifier: req.Identifier, PaymentMethod: req.PaymentMethod,
|
||||
}
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err)
|
||||
}()
|
||||
|
||||
var validationResult *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
operatorUserType := middleware.GetUserTypeFromContext(ctx)
|
||||
validationCtx := ctx
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && (operatorUserType == constants.UserTypeSuperAdmin || operatorUserType == constants.UserTypePlatform) {
|
||||
@@ -477,6 +489,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
PurchaseRole: purchaseRole,
|
||||
PaymentConfigID: paymentConfigID,
|
||||
}
|
||||
auditOrder = order
|
||||
|
||||
// 线下支付订单写入支付凭证 file_key 列表
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
@@ -549,9 +562,16 @@ func rewriteAdminAgentPackageOffShelfError(err error, resourceShopID *uint, shou
|
||||
// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态)
|
||||
// 保留原 Create() 方法的完整逻辑,H5 端行为不变
|
||||
// POST /api/h5/orders
|
||||
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) {
|
||||
var validationResult *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
auditOrder := &model.Order{
|
||||
OrderNo: "create:" + req.OrderType + ":" + strconv.FormatUint(uint64(buyerID), 10),
|
||||
OrderType: req.OrderType, BuyerType: buyerType, BuyerID: buyerID,
|
||||
IotCardID: req.IotCardID, DeviceID: req.DeviceID, PaymentMethod: req.PaymentMethod,
|
||||
}
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err)
|
||||
}()
|
||||
|
||||
if req.OrderType == model.OrderTypeSingleCard {
|
||||
if req.IotCardID == nil {
|
||||
@@ -776,6 +796,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
ExpiresAt: expiresAt,
|
||||
PaymentConfigID: h5PaymentConfigID,
|
||||
}
|
||||
auditOrder = order
|
||||
|
||||
items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap)
|
||||
|
||||
@@ -816,7 +837,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
// 待支付订单设置过期时间,超过 30 分钟未支付则自动取消
|
||||
expireTime := now.Add(constants.OrderExpireTimeout)
|
||||
order.ExpiresAt = &expireTime
|
||||
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||||
if err := s.CreatePendingOrder(ctx, order, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.markOrderCreated(ctx, idempotencyKey, order.ID)
|
||||
@@ -1134,6 +1155,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
}
|
||||
actualAmount := *order.ActualPaidAmount
|
||||
var existingOrderID uint
|
||||
walletDebitAttempted := false
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey)
|
||||
@@ -1159,6 +1181,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
|
||||
relatedShopID = &buyerShopID
|
||||
}
|
||||
walletDebitAttempted = true
|
||||
if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1168,11 +1191,17 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并使用钱包支付订单", order, nil, orderStateData(order)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if walletDebitAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, operatorShopID, err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if existingOrderID > 0 {
|
||||
@@ -1200,7 +1229,29 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并完成订单", order, nil, orderStateData(order))
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePendingOrder 在同一事务中创建待支付订单、冻结个人资产钱包、创建明细和审计事件。
|
||||
func (s *Service) CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.freezeAssetWalletForOrder(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
||||
}
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||||
}
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建待支付订单", order, nil, orderStateData(order))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1394,14 +1445,19 @@ func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID
|
||||
}
|
||||
|
||||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
err = errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if order.PaymentStatus != model.PaymentStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
|
||||
err = errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.cancelOrder(ctx, order)
|
||||
_, err = s.cancelOrder(ctx, order, constants.AuditActionOrderCancelled)
|
||||
return err
|
||||
}
|
||||
|
||||
// CancelExpiredOrders 批量取消已超时的待支付订单
|
||||
@@ -1420,7 +1476,8 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
|
||||
|
||||
cancelledCount := 0
|
||||
for _, order := range orders {
|
||||
if err := s.cancelOrder(ctx, order); err != nil {
|
||||
orderCtx := auditcontext.With(ctx, auditcontext.Context{CorrelationID: order.OrderNo})
|
||||
if _, err := s.cancelOrder(orderCtx, order, constants.AuditActionOrderExpiredClosed); err != nil {
|
||||
s.logger.Error("自动取消超时订单失败",
|
||||
zap.Uint("order_id", order.ID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -1443,8 +1500,10 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
|
||||
|
||||
// cancelOrder 内部取消订单逻辑(共用于手动取消和自动超时取消)
|
||||
// 在事务中执行:更新订单状态为已取消、清除过期时间、解冻钱包余额(如有)
|
||||
func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
func (s *Service) cancelOrder(ctx context.Context, order *model.Order, actionCode string) (bool, error) {
|
||||
changed := false
|
||||
walletReleaseAttempted := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 使用条件更新确保幂等性:只有待支付的订单才能取消
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
|
||||
@@ -1459,11 +1518,11 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
// 订单已被处理(幂等),直接返回
|
||||
return nil
|
||||
}
|
||||
changed = true
|
||||
|
||||
// 检查是否需要解冻钱包余额(混合支付场景)
|
||||
// 当前系统中钱包支付订单是立即支付的,不会进入待支付状态
|
||||
// 此处为预留逻辑,支持未来混合支付场景的钱包解冻
|
||||
// 待支付钱包订单取消时释放该订单创建时记录的资金预占。
|
||||
if order.PaymentMethod == model.PaymentMethodWallet {
|
||||
walletReleaseAttempted = order.BuyerType == model.BuyerTypeAgent
|
||||
if err := s.unfreezeWalletForCancel(ctx, tx, order); err != nil {
|
||||
s.logger.Error("取消订单时解冻钱包失败",
|
||||
zap.Uint("order_id", order.ID),
|
||||
@@ -1473,8 +1532,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusCancelled
|
||||
after.ExpiresAt = nil
|
||||
summary := "取消待支付订单"
|
||||
if actionCode == constants.AuditActionOrderExpiredClosed {
|
||||
summary = "关闭过期待支付订单"
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, actionCode, summary, &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
if err != nil {
|
||||
s.recordOrderFailure(ctx, actionCode, "订单关闭失败", order, err)
|
||||
if walletReleaseAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderReleased, "释放代理主钱包订单预占失败", order, 0, err)
|
||||
}
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
// unfreezeWalletForCancel 取消订单时解冻钱包余额。
|
||||
@@ -1494,39 +1567,83 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde
|
||||
RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金",
|
||||
})
|
||||
} else if order.BuyerType == model.BuyerTypePersonal {
|
||||
// 个人客户钱包(卡/设备钱包)
|
||||
var resourceType string
|
||||
var resourceID uint
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
resourceType = "iot_card"
|
||||
resourceID = *order.IotCardID
|
||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
resourceType = "device"
|
||||
resourceID = *order.DeviceID
|
||||
} else {
|
||||
return errors.New(errors.CodeInternalError, "无法确定钱包归属")
|
||||
}
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeWalletNotFound, err, "查询资产钱包失败")
|
||||
}
|
||||
// 资产钱包解冻:直接减少冻结余额
|
||||
result := tx.Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "冻结余额不足,无法解冻")
|
||||
}
|
||||
return nil
|
||||
return s.releaseAssetWalletReservation(ctx, tx, order)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeAssetWalletForOrder 为个人待支付钱包订单冻结精确金额,并把钱包与金额写入订单快照。
|
||||
func (s *Service) freezeAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.BuyerType != model.BuyerTypePersonal || order.PaymentMethod != model.PaymentMethodWallet || order.TotalAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
if order.TotalAmount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "订单金额无效")
|
||||
}
|
||||
resourceType, resourceID, err := resolveOrderAssetWalletReference(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND balance - frozen_balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance + ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结资产钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突")
|
||||
}
|
||||
order.AssetWalletReservationWalletID = &wallet.ID
|
||||
order.AssetWalletReservedAmount = order.TotalAmount
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAssetWalletReservation 释放订单快照指向的个人资产钱包预占。
|
||||
// 历史订单没有预占快照,取消时只更新订单状态,避免误释放其他订单的冻结资金。
|
||||
func (s *Service) releaseAssetWalletReservation(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
if order.AssetWalletReservationWalletID == nil && order.AssetWalletReservedAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount <= 0 {
|
||||
return errors.New(errors.CodeInternalError, "资产钱包预占快照不完整")
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND frozen_balance >= ?", *order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放资产钱包预占失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "资产钱包预占状态不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveOrderAssetWalletReference(order *model.Order) (string, uint, error) {
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil
|
||||
}
|
||||
if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil
|
||||
}
|
||||
return "", 0, errors.New(errors.CodeInternalError, "无法确定资产钱包归属")
|
||||
}
|
||||
|
||||
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error {
|
||||
paidAt := order.PaidAt
|
||||
if paidAt == nil {
|
||||
@@ -1548,7 +1665,7 @@ func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, pay
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
|
||||
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) {
|
||||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -1556,6 +1673,13 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
}
|
||||
return err
|
||||
}
|
||||
agentWalletDebitAttempted := false
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderWalletPaid, "钱包支付订单失败", order, err)
|
||||
if agentWalletDebitAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, buyerID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
@@ -1636,6 +1760,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
order.PaidAt = &now
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
agentWalletDebitAttempted = true
|
||||
if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1644,22 +1769,18 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return err
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = model.PaymentMethodWallet
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用代理钱包支付订单", &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
} else {
|
||||
// 资产钱包系统(iot_card 或 device)
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if wallet.Balance < order.TotalAmount {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足")
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||
@@ -1695,22 +1816,12 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
actualPaidAmountSnapshot := actualPaidAmount
|
||||
order.ActualPaidAmount = &actualPaidAmountSnapshot
|
||||
|
||||
// 扣款前记录余额快照,用于写入流水
|
||||
wallet, err := s.deductAssetWalletForOrder(ctx, tx, order, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
balanceBefore := wallet.Balance
|
||||
|
||||
walletResult := tx.Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if walletResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
|
||||
}
|
||||
if walletResult.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||
}
|
||||
|
||||
// 扣款成功后补写扣款流水,填补流水表中扣款记录缺失的问题
|
||||
deductTx := &model.AssetWalletTransaction{
|
||||
AssetWalletID: wallet.ID,
|
||||
@@ -1736,7 +1847,15 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return err
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = model.PaymentMethodWallet
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用资产钱包支付订单", &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1750,6 +1869,47 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// deductAssetWalletForOrder 完成个人钱包订单扣款;新订单同时核销冻结额,历史订单只使用可用余额。
|
||||
func (s *Service) deductAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order, resourceType string, resourceID uint) (*model.AssetWallet, error) {
|
||||
var wallet model.AssetWallet
|
||||
query := tx.WithContext(ctx)
|
||||
if order.AssetWalletReservationWalletID != nil || order.AssetWalletReservedAmount != 0 {
|
||||
if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount != order.TotalAmount || order.AssetWalletReservedAmount <= 0 {
|
||||
return nil, errors.New(errors.CodeConflict, "资产钱包预占快照与订单金额不一致")
|
||||
}
|
||||
query = query.Where("id = ? AND resource_type = ? AND resource_id = ?", *order.AssetWalletReservationWalletID, resourceType, resourceID)
|
||||
} else {
|
||||
query = query.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID)
|
||||
}
|
||||
if err := query.First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
updateQuery := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ? AND version = ?", wallet.ID, wallet.Version)
|
||||
if order.AssetWalletReservedAmount > 0 {
|
||||
updateQuery = updateQuery.Where("balance >= ? AND frozen_balance >= ?", order.TotalAmount, order.AssetWalletReservedAmount)
|
||||
updates["frozen_balance"] = gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount)
|
||||
} else {
|
||||
updateQuery = updateQuery.Where("balance - frozen_balance >= ?", order.TotalAmount)
|
||||
}
|
||||
result := updateQuery.Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减资产钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突")
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string, actualPaidAmount int64) error {
|
||||
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
@@ -1760,6 +1920,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
beforeOrder := *order
|
||||
shouldResumeAfterPayment := false
|
||||
shouldEnqueueCommission := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -1800,7 +1961,15 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
shouldResumeAfterPayment = true
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = paymentMethod
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderOnlinePaid, "第三方支付确认订单已支付", &after, orderStateData(&beforeOrder), orderStateData(&after))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -1838,6 +2007,8 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
beforePayment := *payment
|
||||
beforeOrder := *order
|
||||
shouldResumeAfterPayment := false
|
||||
shouldEnqueueCommission := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -1900,7 +2071,23 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
|
||||
shouldResumeAfterPayment = true
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
afterPayment := beforePayment
|
||||
afterPayment.Status = model.PaymentRecordStatusPaid
|
||||
afterPayment.PaidAt = &now
|
||||
if thirdPartyTradeNo != "" {
|
||||
afterPayment.ThirdPartyTradeNo = thirdPartyTradeNo
|
||||
}
|
||||
afterOrder := beforeOrder
|
||||
afterOrder.PaymentStatus = model.PaymentStatusPaid
|
||||
afterOrder.PaymentMethod = paymentMethod
|
||||
afterOrder.PaidAt = &now
|
||||
afterOrder.ExpiresAt = nil
|
||||
afterOrder.ActualPaidAmount = &actualPaidAmountSnapshot
|
||||
return s.appendPaymentConfirmedAudit(ctx, tx, &afterPayment, &afterOrder,
|
||||
paymentStateData(&beforePayment), paymentStateData(&afterPayment), orderStateData(&beforeOrder), orderStateData(&afterOrder))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -2765,8 +2952,15 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
|
||||
description = items[0].PackageName
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_jsapi")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := paymentSvc.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("创建 JSAPI 支付失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -2774,6 +2968,9 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("创建 JSAPI 支付成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
@@ -2831,8 +3028,15 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.
|
||||
H5Type: sceneInfo.H5Info.Type,
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_h5")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := paymentSvc.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo)
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("创建 H5 支付失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -2840,6 +3044,9 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("创建 H5 支付成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
@@ -3075,6 +3282,10 @@ func (s *Service) fuiouPreCreate(
|
||||
termIP = *ip
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderFuiou, "order_"+strings.ToLower(tradeType))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.WxPreCreate(
|
||||
order.OrderNo,
|
||||
strconv.FormatInt(order.TotalAmount, 10),
|
||||
@@ -3085,6 +3296,9 @@ func (s *Service) fuiouPreCreate(
|
||||
openID,
|
||||
)
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "富友支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("富友预下单失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -3093,6 +3307,9 @@ func (s *Service) fuiouPreCreate(
|
||||
)
|
||||
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "000000", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("富友预下单成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
|
||||
Reference in New Issue
Block a user