收口审计治理与套餐任务进展

Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
2026-08-05 14:30:54 +08:00
parent b3499adfca
commit 5e552d99bc
178 changed files with 16797 additions and 5674 deletions

View File

@@ -0,0 +1,494 @@
package exchange
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type cardExchangeAuditBefore struct {
Wallets map[uint]model.AssetWallet
DeviceBindings []*model.PersonalCustomerDevice
ICCIDBindings []*model.PersonalCustomerICCID
}
// SetAccessAudit 注入卡与设备换货完整用例的统一审计 Writer。
func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) appendCardExchangeAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
order *model.ExchangeOrder,
oldCard, newCard *model.IotCard,
orderBefore, orderAfter map[string]any,
oldCardBefore, oldCardAfter map[string]any,
newCardBefore, newCardAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置")
}
internalOnly := actionCode == constants.AuditActionCardExchangeRenewed
resources := []audit.ResourceInput{cardExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
if oldCard != nil {
resources = append(resources, cardExchangeCardAuditResource(oldCard, constants.AuditResourceRoleCardExchangeOldCard, summary, internalOnly, oldCardBefore, oldCardAfter))
}
if newCard != nil {
resources = append(resources, cardExchangeCardAuditResource(newCard, constants.AuditResourceRoleCardExchangeNewCard, summary, internalOnly, newCardBefore, newCardAfter))
}
shopResource, err := loadCardExchangeShopAuditResource(ctx, tx, order.ShopID)
if err != nil {
return err
}
if shopResource != nil {
resources = append(resources, *shopResource)
}
resources = append(resources, extra...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
scopeType, scopeID := constants.AuditScopePlatform, ""
if actionCode == constants.AuditActionCardExchangeShippingInfoSubmitted {
scopeType = constants.AuditScopePersonalCustomer
scopeID = auditcontext.From(ctx).ActorID
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
Resources: resources,
})
}
func cardExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
key := order.ExchangeNo
if key == "" {
key = "iot_card:" + strconv.FormatUint(uint64(order.OldAssetID), 10) + ":exchange"
}
var id *string
if order.ID > 0 {
value := strconv.FormatUint(uint64(order.ID), 10)
id = &value
}
resource := audit.ResourceInput{
Type: constants.AuditResourceExchangeOrder, ID: id, Key: key, DisplayName: key,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
IdentitySnapshot: map[string]any{
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": effectiveExchangeFlowType(order.FlowType),
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID, "old_asset_identifier": order.OldAssetIdentifier,
"new_asset_type": order.NewAssetType, "new_asset_id": order.NewAssetID, "new_asset_identifier": order.NewAssetIdentifier,
"shop_id": order.ShopID, "status": order.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func cardExchangeCardAuditResource(card *model.IotCard, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
id := strconv.FormatUint(uint64(card.ID), 10)
relation := constants.AuditResourceRelationReference
if len(beforeData) > 0 || len(afterData) > 0 {
relation = constants.AuditResourceRelationAffected
}
resource := audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: relation, Role: role, IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func loadCardExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
if shopID == nil || *shopID == 0 {
return nil, nil
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", *shopID).First(&shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货所属店铺失败")
}
id := strconv.FormatUint(uint64(shop.ID), 10)
return &audit.ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: shop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeShop,
IdentitySnapshot: map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}, nil
}
func (s *Service) captureCardExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldCard, newCard *model.IotCard) (*cardExchangeAuditBefore, error) {
state := &cardExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
cardIDs := make([]uint, 0, 2)
if oldCard != nil {
cardIDs = append(cardIDs, oldCard.ID)
}
if newCard != nil {
cardIDs = append(cardIDs, newCard.ID)
}
if len(cardIDs) > 0 {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeIotCard, cardIDs).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货钱包审计快照失败")
}
for _, wallet := range wallets {
state.Wallets[wallet.ResourceID] = wallet
}
}
devices, iccids, err := loadCardExchangeBindings(ctx, tx, oldCard)
if err != nil {
return nil, err
}
state.DeviceBindings, state.ICCIDBindings = devices, iccids
return state, nil
}
func loadCardExchangeBindings(ctx context.Context, tx *gorm.DB, card *model.IotCard) ([]*model.PersonalCustomerDevice, []*model.PersonalCustomerICCID, error) {
if card == nil {
return nil, nil, nil
}
if card.VirtualNo != "" {
var rows []*model.PersonalCustomerDevice
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", card.VirtualNo, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货客户绑定失败")
}
return rows, nil, nil
}
var rows []*model.PersonalCustomerICCID
if err := tx.WithContext(ctx).Where("iccid = ? AND status = ?", card.ICCID, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货 ICCID 绑定失败")
}
return nil, rows, nil
}
func (s *Service) buildCardExchangeCompletionResources(
ctx context.Context,
tx *gorm.DB,
order *model.ExchangeOrder,
oldCard, newCard *model.IotCard,
before *cardExchangeAuditBefore,
migration *exchangeMigrationResult,
) ([]audit.ResourceInput, error) {
resources := cardExchangeOldBindingResources(before)
devices, iccids, err := loadCardExchangeBindings(ctx, tx, newCard)
if err != nil {
return nil, err
}
resources = append(resources, cardExchangeNewBindingResources(devices, iccids)...)
beforeWallets := map[uint]model.AssetWallet(nil)
if before != nil {
beforeWallets = before.Wallets
}
walletResources, err := loadCardExchangeWalletResources(ctx, tx, oldCard.ID, newCard.ID, beforeWallets)
if err != nil {
return nil, err
}
resources = append(resources, walletResources...)
if migration == nil {
return resources, nil
}
transactionResources, err := loadCardExchangeTransactionResources(ctx, tx, order.ExchangeNo)
if err != nil {
return nil, err
}
resources = append(resources, transactionResources...)
usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldCard.ID, newCard.ID)
if err != nil {
return nil, err
}
return append(resources, usageResources...), nil
}
func cardExchangeOldBindingResources(before *cardExchangeAuditBefore) []audit.ResourceInput {
if before == nil {
return nil
}
resources := make([]audit.ResourceInput, 0, len(before.DeviceBindings)+len(before.ICCIDBindings))
for _, row := range before.DeviceBindings {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
BeforeData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
for _, row := range before.ICCIDBindings {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
BeforeData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources
}
func cardExchangeNewBindingResources(devices []*model.PersonalCustomerDevice, iccids []*model.PersonalCustomerICCID) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, len(devices)+len(iccids))
for _, row := range devices {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
AfterData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
for _, row := range iccids {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
AfterData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources
}
func loadCardExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldCardID, newCardID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeIotCard, oldCardID, newCardID, before,
constants.AuditResourceRoleCardExchangeOldWallet, constants.AuditResourceRoleCardExchangeNewWallet, "卡")
}
func loadExchangeWalletResources(ctx context.Context, tx *gorm.DB, assetType string, oldAssetID, newAssetID uint, before map[uint]model.AssetWallet, oldRole, newRole, assetName string) ([]audit.ResourceInput, error) {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", assetType, []uint{oldAssetID, newAssetID}).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货迁移后钱包失败")
}
resources := make([]audit.ResourceInput, 0, len(wallets))
for _, wallet := range wallets {
id := strconv.FormatUint(uint64(wallet.ID), 10)
role := newRole
if wallet.ResourceID == oldAssetID {
role = oldRole
}
relation := constants.AuditResourceRelationAffected
beforeData, afterData := map[string]any{"exists": false}, cardExchangeWalletData(wallet)
if previous, ok := before[wallet.ResourceID]; ok {
if !cardExchangeWalletChanged(previous, wallet) {
relation, beforeData, afterData = constants.AuditResourceRelationReference, nil, nil
} else {
beforeData = cardExchangeWalletData(previous)
}
}
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
Relation: relation, Role: role,
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: beforeData, AfterData: afterData, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources, nil
}
func cardExchangeWalletData(wallet model.AssetWallet) map[string]any {
return map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance, "status": wallet.Status, "version": wallet.Version, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag}
}
func cardExchangeWalletChanged(before, after model.AssetWallet) bool {
return before.Balance != after.Balance || before.FrozenBalance != after.FrozenBalance || before.Status != after.Status ||
before.Version != after.Version || before.ShopIDTag != after.ShopIDTag || !sameOptionalUint(before.EnterpriseIDTag, after.EnterpriseIDTag)
}
func sameOptionalUint(left, right *uint) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
func loadCardExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, cardID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeIotCard, cardID, before, constants.AuditResourceRoleCardExchangeOldWallet, "卡")
}
func loadExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, before map[uint]model.AssetWallet, role, assetName string) (*audit.ResourceInput, error) {
var wallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", assetType, assetID).First(&wallet).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询旧"+assetName+"转新钱包失败")
}
id := strconv.FormatUint(uint64(wallet.ID), 10)
beforeData := map[string]any{"exists": false}
if previous, ok := before[assetID]; ok {
beforeData = cardExchangeWalletData(previous)
}
return &audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
Relation: constants.AuditResourceRelationAffected, Role: role,
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: beforeData, AfterData: cardExchangeWalletData(wallet), SubjectVisibility: constants.AuditSubjectInternalOnly,
}, nil
}
func loadCardExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleCardExchangeWalletTransaction, "卡")
}
func loadExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo, role, assetName string) ([]audit.ResourceInput, error) {
var rows []model.AssetWalletTransaction
if err := tx.WithContext(ctx).Where("transaction_type = ? AND reference_type = ? AND reference_no = ?", constants.AssetTransactionTypeExchange, constants.ReferenceTypeExchange, exchangeNo).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货钱包流水失败")
}
resources := make([]audit.ResourceInput, 0, len(rows))
for _, row := range rows {
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: exchangeNo,
Relation: constants.AuditResourceRelationAffected, Role: role,
IdentitySnapshot: map[string]any{"id": row.ID, "asset_wallet_id": row.AssetWalletID, "resource_type": row.ResourceType, "resource_id": row.ResourceID, "transaction_type": row.TransactionType, "reference_type": row.ReferenceType, "reference_no": row.ReferenceNo, "status": row.Status},
AfterData: map[string]any{"amount": row.Amount, "balance_before": row.BalanceBefore, "balance_after": row.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources, nil
}
func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldCardID, newCardID uint) ([]audit.ResourceInput, error) {
return loadExchangePackageUsageResources(ctx, tx, ids, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡")
}
func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) {
if len(ids) == 0 {
return nil, nil
}
var rows []model.PackageUsage
if err := tx.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益失败")
}
orderIDs := make(map[uint]struct{}, len(rows))
packageIDs := make(map[uint]struct{}, len(rows))
resources := make([]audit.ResourceInput, 0, len(rows)*3)
for _, row := range rows {
resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role,
map[string]any{assetIDField: oldAssetID}, map[string]any{assetIDField: newAssetID})
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
orderIDs[row.OrderID] = struct{}{}
packageIDs[row.PackageID] = struct{}{}
}
var orders []model.Order
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(orderIDs)).Order("id ASC").Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联订单失败")
}
for i := range orders {
resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder))
}
var packages []model.Package
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(packageIDs)).Order("id ASC").Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联套餐失败")
}
for i := range packages {
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil))
}
return resources, nil
}
func exchangeUintKeys(values map[uint]struct{}) []uint {
result := make([]uint, 0, len(values))
for value := range values {
if value > 0 {
result = append(result, value)
}
}
return result
}
func (s *Service) recordCardExchangeFailure(ctx context.Context, actionCode, summary, result string, order *model.ExchangeOrder, oldCard, newCard *model.IotCard, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return
}
if s.db == nil || s.auditWriter == nil {
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardExchangeAudit(ctx, tx, actionCode, summary, result, order, oldCard, newCard,
nil, nil, nil, nil, nil, nil, nil, businessErr)
}); err != nil {
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, err)
}
}
func recordCardExchangeAuditSecondaryFailure(ctx context.Context, actionCode, exchangeNo string, businessErr, auditErr error) {
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(actionCode, exchangeNo, linkage.RequestID, linkage.CorrelationID, errorCode, auditErr)
}
func (s *Service) recordCardExchangeOrderFailure(ctx context.Context, actionCode, summary string, order *model.ExchangeOrder, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return
}
oldCard, newCard := s.loadCardExchangeAuditCards(ctx, order)
s.recordCardExchangeFailure(ctx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldCard, newCard, businessErr)
}
func (s *Service) loadCardExchangeAuditCards(ctx context.Context, order *model.ExchangeOrder) (*model.IotCard, *model.IotCard) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return nil, nil
}
var oldCard *model.IotCard
if s.iotCardStore != nil {
oldCard, _ = s.iotCardStore.GetByID(ctx, order.OldAssetID)
}
if oldCard == nil {
oldCard = &model.IotCard{Model: gorm.Model{ID: order.OldAssetID}, ICCID: order.OldAssetIdentifier, ShopID: order.ShopID}
}
var newCard *model.IotCard
if order.NewAssetID != nil && *order.NewAssetID > 0 {
if s.iotCardStore != nil {
newCard, _ = s.iotCardStore.GetByID(ctx, *order.NewAssetID)
}
if newCard == nil {
newCard = &model.IotCard{Model: gorm.Model{ID: *order.NewAssetID}, ICCID: order.NewAssetIdentifier, ShopID: order.ShopID}
}
}
return oldCard, newCard
}
func cardExchangeFailureResult(err error) string {
appErr, ok := err.(*errors.AppError)
if !ok {
return constants.AuditResultFailed
}
switch appErr.Code {
case errors.CodeDatabaseError, errors.CodeInternalError, errors.CodeExchangeMigrationFailed:
return constants.AuditResultFailed
default:
return constants.AuditResultDenied
}
}

View File

@@ -0,0 +1,393 @@
package exchange
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type deviceExchangeAuditBefore struct {
Wallets map[uint]model.AssetWallet
CustomerBindings []*model.PersonalCustomerDevice
}
func (s *Service) appendExchangeAudit(
ctx context.Context,
tx *gorm.DB,
cardActionCode, summary, result string,
order *model.ExchangeOrder,
oldAsset, newAsset *resolvedExchangeAsset,
orderBefore, orderAfter map[string]any,
oldAssetBefore, oldAssetAfter map[string]any,
newAssetBefore, newAssetAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order != nil && order.OldAssetType == constants.ExchangeAssetTypeDevice {
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"), result,
order, resolvedDevice(oldAsset), resolvedDevice(newAsset), orderBefore, orderAfter,
oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
}
return s.appendCardExchangeAudit(ctx, tx, cardActionCode, summary, result, order, resolvedCard(oldAsset), resolvedCard(newAsset),
orderBefore, orderAfter, oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
}
func resolvedCard(asset *resolvedExchangeAsset) *model.IotCard {
if asset == nil {
return nil
}
return asset.Card
}
func resolvedDevice(asset *resolvedExchangeAsset) *model.Device {
if asset == nil {
return nil
}
return asset.Device
}
func deviceExchangeActionCode(cardActionCode string) string {
switch cardActionCode {
case constants.AuditActionCardExchangeCreated:
return constants.AuditActionDeviceExchangeCreated
case constants.AuditActionCardExchangeShippingInfoSubmitted:
return constants.AuditActionDeviceExchangeShippingInfoSubmitted
case constants.AuditActionCardExchangeShipped:
return constants.AuditActionDeviceExchangeShipped
case constants.AuditActionCardExchangeCompleted:
return constants.AuditActionDeviceExchangeCompleted
case constants.AuditActionCardExchangeCancelled:
return constants.AuditActionDeviceExchangeCancelled
case constants.AuditActionCardExchangeRenewed:
return constants.AuditActionDeviceExchangeRenewed
default:
return cardActionCode
}
}
func (s *Service) appendDeviceExchangeAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
order *model.ExchangeOrder,
oldDevice, newDevice *model.Device,
orderBefore, orderAfter map[string]any,
oldDeviceBefore, oldDeviceAfter map[string]any,
newDeviceBefore, newDeviceAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置")
}
internalOnly := actionCode == constants.AuditActionDeviceExchangeRenewed
resources := []audit.ResourceInput{deviceExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
if oldDevice != nil {
resources = append(resources, deviceExchangeDeviceAuditResource(oldDevice, constants.AuditResourceRoleDeviceExchangeOldDevice, summary, internalOnly, oldDeviceBefore, oldDeviceAfter))
}
if newDevice != nil {
resources = append(resources, deviceExchangeDeviceAuditResource(newDevice, constants.AuditResourceRoleDeviceExchangeNewDevice, summary, internalOnly, newDeviceBefore, newDeviceAfter))
}
shopResource, err := loadDeviceExchangeShopAuditResource(ctx, tx, order.ShopID)
if err != nil {
return err
}
if shopResource != nil {
resources = append(resources, *shopResource)
}
resources = append(resources, extra...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
scopeType, scopeID := constants.AuditScopePlatform, ""
if actionCode == constants.AuditActionDeviceExchangeShippingInfoSubmitted {
scopeType = constants.AuditScopePersonalCustomer
scopeID = auditcontext.From(ctx).ActorID
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
Resources: resources,
})
}
func deviceExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
resource := cardExchangeOrderAuditResource(order, summary, internalOnly, beforeData, afterData)
resource.Role = constants.AuditResourceRoleDeviceExchangeOrder
return resource
}
func deviceExchangeDeviceAuditResource(device *model.Device, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
id := strconv.FormatUint(uint64(device.ID), 10)
relation := constants.AuditResourceRelationReference
if len(beforeData) > 0 || len(afterData) > 0 {
relation = constants.AuditResourceRelationAffected
}
resource := audit.ResourceInput{
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(device), DisplayName: preferredDeviceIdentifier(device),
Relation: relation, Role: role, IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func loadDeviceExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
resource, err := loadCardExchangeShopAuditResource(ctx, tx, shopID)
if resource != nil {
resource.Role = constants.AuditResourceRoleDeviceExchangeShop
}
return resource, err
}
func (s *Service) captureDeviceExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) (*deviceExchangeAuditBefore, error) {
state := &deviceExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
deviceIDs := make([]uint, 0, 2)
if oldDevice != nil {
deviceIDs = append(deviceIDs, oldDevice.ID)
}
if newDevice != nil {
deviceIDs = append(deviceIDs, newDevice.ID)
}
if len(deviceIDs) > 0 {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeDevice, deviceIDs).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货钱包审计快照失败")
}
for _, wallet := range wallets {
state.Wallets[wallet.ResourceID] = wallet
}
}
rows, err := loadDeviceExchangeCustomerBindings(ctx, tx, oldDevice)
if err != nil {
return nil, err
}
state.CustomerBindings = rows
return state, nil
}
func loadDeviceExchangeCustomerBindings(ctx context.Context, tx *gorm.DB, device *model.Device) ([]*model.PersonalCustomerDevice, error) {
if device == nil {
return nil, nil
}
key := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: device, VirtualNo: device.VirtualNo})
if key == "" {
return nil, nil
}
var rows []*model.PersonalCustomerDevice
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", key, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货客户绑定失败")
}
return rows, nil
}
func (s *Service) buildDeviceExchangeCompletionResources(
ctx context.Context,
tx *gorm.DB,
order *model.ExchangeOrder,
oldDevice, newDevice *model.Device,
before *deviceExchangeAuditBefore,
migration *exchangeMigrationResult,
) ([]audit.ResourceInput, error) {
resources := deviceExchangeOldCustomerBindingResources(before)
newBindings, err := loadDeviceExchangeCustomerBindings(ctx, tx, newDevice)
if err != nil {
return nil, err
}
resources = append(resources, deviceExchangeNewCustomerBindingResources(newBindings)...)
simResources, err := loadDeviceExchangeSIMResources(ctx, tx, oldDevice, newDevice)
if err != nil {
return nil, err
}
resources = append(resources, simResources...)
beforeWallets := map[uint]model.AssetWallet(nil)
if before != nil {
beforeWallets = before.Wallets
}
walletResources, err := loadDeviceExchangeWalletResources(ctx, tx, oldDevice.ID, newDevice.ID, beforeWallets)
if err != nil {
return nil, err
}
resources = append(resources, walletResources...)
if migration == nil {
return resources, nil
}
transactions, err := loadDeviceExchangeTransactionResources(ctx, tx, order.ExchangeNo)
if err != nil {
return nil, err
}
resources = append(resources, transactions...)
usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldDevice.ID, newDevice.ID)
if err != nil {
return nil, err
}
return append(resources, usages...), nil
}
func deviceExchangeOldCustomerBindingResources(before *deviceExchangeAuditBefore) []audit.ResourceInput {
if before == nil {
return nil
}
return deviceExchangeCustomerBindingResources(before.CustomerBindings, constants.AuditResourceRoleDeviceExchangeOldCustomerBinding, true)
}
func deviceExchangeNewCustomerBindingResources(rows []*model.PersonalCustomerDevice) []audit.ResourceInput {
return deviceExchangeCustomerBindingResources(rows, constants.AuditResourceRoleDeviceExchangeNewCustomerBinding, false)
}
func deviceExchangeCustomerBindingResources(rows []*model.PersonalCustomerDevice, role string, before bool) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resource := audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: role,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
if before {
resource.BeforeData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
} else {
resource.AfterData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
}
resources = append(resources, resource)
}
return resources
}
func loadDeviceExchangeSIMResources(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) ([]audit.ResourceInput, error) {
resources := make([]audit.ResourceInput, 0)
for _, item := range []struct {
device *model.Device
cardRole string
bindingRole string
}{
{oldDevice, constants.AuditResourceRoleDeviceExchangeOldBoundCard, constants.AuditResourceRoleDeviceExchangeOldSIMBinding},
{newDevice, constants.AuditResourceRoleDeviceExchangeNewBoundCard, constants.AuditResourceRoleDeviceExchangeNewSIMBinding},
} {
if item.device == nil {
continue
}
var bindings []*model.DeviceSimBinding
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", item.device.ID, constants.BindStatusBound).
Order("slot_position ASC, id ASC").Find(&bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货卡槽绑定失败")
}
cardIDs := make([]uint, 0, len(bindings))
for _, binding := range bindings {
cardIDs = append(cardIDs, binding.IotCardID)
}
cards := make(map[uint]*model.IotCard, len(cardIDs))
if len(cardIDs) > 0 {
var rows []*model.IotCard
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货绑定卡失败")
}
for _, card := range rows {
cards[card.ID] = card
}
}
for _, binding := range bindings {
card := cards[binding.IotCardID]
if card == nil {
return nil, errors.New(errors.CodeAssetNotFound, "设备换货绑定卡不存在")
}
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: item.cardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), SubjectVisibility: constants.AuditSubjectInternalOnly,
})
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID, Key: bindingID, DisplayName: preferredDeviceIdentifier(item.device),
Relation: constants.AuditResourceRelationReference, Role: item.bindingRole,
IdentitySnapshot: map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": item.device.VirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
"iccid": card.ICCID, "virtual_no": card.VirtualNo, "is_current": binding.IsCurrent,
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
}
return resources, nil
}
func loadDeviceExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldDeviceID, newDeviceID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeDevice, oldDeviceID, newDeviceID, before,
constants.AuditResourceRoleDeviceExchangeOldWallet, constants.AuditResourceRoleDeviceExchangeNewWallet, "设备")
}
func loadDeviceExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, deviceID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeDevice, deviceID, before, constants.AuditResourceRoleDeviceExchangeOldWallet, "设备")
}
func loadDeviceExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleDeviceExchangeWalletTransaction, "设备")
}
func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) {
return loadExchangePackageUsageResources(ctx, tx, ids, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备")
}
func (s *Service) recordExchangeOrderFailure(ctx context.Context, cardActionCode, summary string, order *model.ExchangeOrder, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
s.recordCardExchangeOrderFailure(ctx, cardActionCode, summary, order, businessErr)
return
}
oldDevice, newDevice := s.loadDeviceExchangeAuditDevices(ctx, order)
if s.db == nil || s.auditWriter == nil {
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr,
errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"),
cardExchangeFailureResult(businessErr), order, oldDevice, newDevice,
nil, nil, nil, nil, nil, nil, nil, businessErr)
}); err != nil {
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr, err)
}
}
func (s *Service) loadDeviceExchangeAuditDevices(ctx context.Context, order *model.ExchangeOrder) (*model.Device, *model.Device) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
return nil, nil
}
var oldDevice *model.Device
if s.deviceStore != nil {
oldDevice, _ = s.deviceStore.GetByID(ctx, order.OldAssetID)
}
if oldDevice == nil {
oldDevice = &model.Device{Model: gorm.Model{ID: order.OldAssetID}, ShopID: order.ShopID}
}
var newDevice *model.Device
if order.NewAssetID != nil && *order.NewAssetID > 0 {
if s.deviceStore != nil {
newDevice, _ = s.deviceStore.GetByID(ctx, *order.NewAssetID)
}
if newDevice == nil {
newDevice = &model.Device{Model: gorm.Model{ID: *order.NewAssetID}, ShopID: order.ShopID}
}
}
return oldDevice, newDevice
}

View File

@@ -12,21 +12,27 @@ import (
"gorm.io/gorm/clause"
)
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
type exchangeMigrationResult struct {
Balance int64
PackageUsageIDs []uint
}
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (*exchangeMigrationResult, error) {
migrationBalance, err := s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
}
if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
usageIDs, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
if err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
}
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
}
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
}
return migrationBalance, nil
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil
}
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
@@ -95,7 +101,7 @@ func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB,
return migrationBalance, nil
}
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, error) {
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
@@ -105,11 +111,11 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
var usageIDs []uint
if err := query.Pluck("id", &usageIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
if len(usageIDs) == 0 {
return nil
return nil, nil
}
updates := map[string]any{"updated_at": time.Now()}
@@ -120,14 +126,14 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
}
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
}
return nil
return usageIDs, nil
}
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {

View File

@@ -6,6 +6,7 @@ import (
"time"
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
@@ -31,6 +32,7 @@ type Service struct {
resourceTagStore *postgres.ResourceTagStore
customerBinding *customerBindingSvc.Service
shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier
auditWriter *audit.Writer
logger *zap.Logger
}
@@ -81,31 +83,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
if err != nil {
return nil, err
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
return nil, oldAssetStatusError(asset.AssetStatus)
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
}
if hasUnfinishedRefund {
return nil, errors.New(errors.CodeExchangeActiveRefund)
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
return nil, errors.New(errors.CodeExchangeInProgress)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
if flowType == constants.ExchangeFlowTypeDirect {
return s.createDirectExchange(ctx, req, asset)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeShipping,
FlowType: flowType,
OldAssetType: asset.AssetType,
OldAssetID: asset.AssetID,
OldAssetIdentifier: asset.Identifier,
@@ -114,15 +99,57 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: false,
MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if asset.ShopID != nil {
order.ShopID = asset.ShopID
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
err = oldAssetStatusError(asset.AssetStatus)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if hasUnfinishedRefund {
err = errors.New(errors.CodeExchangeActiveRefund)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
err = errors.New(errors.CodeExchangeInProgress)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
} else if err != gorm.ErrRecordNotFound {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if flowType == constants.ExchangeFlowTypeDirect {
orderID, directErr := s.createDirectExchange(ctx, req, asset, order)
if directErr != nil {
order.ID = 0
order.Status = constants.ExchangeStatusPendingInfo
order.MigrationCompleted = false
order.MigrationBalance = 0
order.CompletedAt = nil
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡直接换货失败", order, directErr)
return nil, directErr
}
return s.Get(ctx, orderID)
}
if s.shippingCreatedNotifier == nil {
return nil, errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
err = errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
@@ -145,9 +172,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
return notifyErr
}
}
return nil
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCreated, "已创建卡换货单", constants.AuditResultSuccess,
order, asset, nil,
map[string]any{"exists": false}, map[string]any{"status": constants.ExchangeStatusPendingInfo},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.ID = 0
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
@@ -178,13 +210,18 @@ func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipReques
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingShip {
return nil, errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if !isShippingExchangeFlow(order.FlowType) {
return nil, errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if err = s.shipWithTx(ctx, order, req); err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货失败", order, err)
return nil, err
}
@@ -200,13 +237,17 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id)
if lockErr != nil {
return lockErr
@@ -219,6 +260,10 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
}
return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
}
return err
}
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
@@ -230,10 +275,14 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
updates := map[string]any{
@@ -243,13 +292,36 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
if req != nil {
updates["remark"] = req.Remark
}
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
if err == gorm.ErrRecordNotFound {
oldAsset, resolveErr := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if resolveErr != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, resolveErr)
return resolveErr
}
fromStatus := order.Status
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusCancelled
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).Where("id = ? AND status = ?", id, fromStatus).Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "取消换货失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
order.Status = constants.ExchangeStatusCancelled
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCancelled, "已取消卡换货单", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": fromStatus}, map[string]any{"status": constants.ExchangeStatusCancelled},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = fromStatus
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, err)
}
return nil
return err
}
func (s *Service) Renew(ctx context.Context, id uint) error {
@@ -261,52 +333,84 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusCompleted {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新被拒绝", order, err)
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if queryErr := tx.WithContext(ctx).Where("id = ?", order.OldAssetID).First(&card).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡失败")
}
if card.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newCard *model.IotCard
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新卡失败")
}
newCard = &value
}
auditBefore, auditErr := s.captureCardExchangeAuditBefore(ctx, tx, &card, newCard)
if auditErr != nil {
return auditErr
}
cardBefore := map[string]any{"generation": card.Generation, "asset_status": card.AssetStatus}
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
if updateErr := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
}).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败")
}
cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo})
if cardKey != "" {
if err = tx.Where("virtual_no = ?", cardKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
if unbindErr := s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, cardKey); unbindErr != nil {
return unbindErr
}
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
if deleteErr := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "清理旧钱包失败")
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
if createErr := tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建新钱包失败")
}
return nil
var renewedCard model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", card.ID).First(&renewedCard).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡转新结果失败")
}
walletResource, resourceErr := loadCardExchangeRenewWalletResource(ctx, tx, card.ID, auditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := cardExchangeOldBindingResources(auditBefore)
extra = append(extra, *walletResource)
return s.appendCardExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &renewedCard, newCard,
nil, nil,
cardBefore, map[string]any{"generation": renewedCard.Generation, "asset_status": renewedCard.AssetStatus},
nil, nil, extra, nil)
}
var device model.Device
@@ -319,6 +423,22 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
if device.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newDevice *model.Device
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新设备失败")
}
newDevice = &value
}
deviceAuditBefore, auditErr := s.captureDeviceExchangeAuditBefore(ctx, tx, &device, newDevice)
if auditErr != nil {
return auditErr
}
deviceBefore := map[string]any{"generation": device.Generation, "asset_status": device.AssetStatus}
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
@@ -333,8 +453,8 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo})
if deviceKey != "" {
if err = tx.Where("virtual_no = ?", deviceKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
if err = s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, deviceKey); err != nil {
return err
}
}
@@ -349,8 +469,31 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
var renewedDevice model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", device.ID).First(&renewedDevice).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧设备转新结果失败")
}
walletResource, resourceErr := loadDeviceExchangeRenewWalletResource(ctx, tx, device.ID, deviceAuditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := deviceExchangeOldCustomerBindingResources(deviceAuditBefore)
simResources, resourceErr := loadDeviceExchangeSIMResources(ctx, tx, &renewedDevice, newDevice)
if resourceErr != nil {
return resourceErr
}
extra = append(extra, simResources...)
extra = append(extra, *walletResource)
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &renewedDevice}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: newDevice},
nil, nil,
deviceBefore, map[string]any{"generation": renewedDevice.Generation, "asset_status": renewedDevice.AssetStatus},
nil, nil, extra, nil)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新失败", order, err)
}
return err
}
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
@@ -392,17 +535,24 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
if order.Status != constants.ExchangeStatusPendingInfo {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
return err
}
if !s.customerOwnsAsset(ctx, oldAsset) {
return errors.New(errors.CodeExchangeOrderNotFound)
err = errors.New(errors.CodeExchangeOrderNotFound)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
updates := map[string]any{
@@ -411,13 +561,32 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
"recipient_address": req.RecipientAddress,
"updated_at": time.Now(),
}
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
if err == gorm.ErrRecordNotFound {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusPendingShip
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", id, constants.ExchangeStatusPendingInfo).
Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "提交收货信息失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
order.Status = constants.ExchangeStatusPendingShip
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShippingInfoSubmitted, "已提交卡换货收货信息", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": constants.ExchangeStatusPendingInfo}, map[string]any{"status": constants.ExchangeStatusPendingShip},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = constants.ExchangeStatusPendingInfo
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
}
return nil
return err
}
type resolvedExchangeAsset struct {
@@ -497,12 +666,8 @@ func isShippingExchangeFlow(flowType string) bool {
return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping
}
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset) (*dto.ExchangeOrderResponse, error) {
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset, order *model.ExchangeOrder) (uint, error) {
var orderID uint
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID)
@@ -524,27 +689,13 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeDirect,
OldAssetType: lockedOldAsset.AssetType,
OldAssetID: lockedOldAsset.AssetID,
OldAssetIdentifier: lockedOldAsset.Identifier,
NewAssetType: newAsset.AssetType,
NewAssetID: &newAsset.AssetID,
NewAssetIdentifier: newAsset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if lockedOldAsset.ShopID != nil {
order.ShopID = lockedOldAsset.ShopID
}
order.OldAssetType = lockedOldAsset.AssetType
order.OldAssetID = lockedOldAsset.AssetID
order.OldAssetIdentifier = lockedOldAsset.Identifier
order.NewAssetType = newAsset.AssetType
order.NewAssetID = &newAsset.AssetID
order.NewAssetIdentifier = newAsset.Identifier
order.ShopID = cloneShopID(lockedOldAsset.ShopID)
if err = tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败")
}
@@ -555,9 +706,9 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
return nil
})
if err != nil {
return nil, err
return 0, err
}
return s.Get(ctx, orderID)
return orderID, nil
}
func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error {
@@ -609,7 +760,16 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
lockedOrder.NewAssetType = newAsset.AssetType
lockedOrder.NewAssetID = &newAsset.AssetID
lockedOrder.NewAssetIdentifier = newAsset.Identifier
lockedOrder.MigrateData = req.MigrateData
lockedOrder.ShippedAt = &now
lockedOrder.Status = constants.ExchangeStatusShipped
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess,
lockedOrder, oldAsset, newAsset,
map[string]any{"status": constants.ExchangeStatusPendingShip}, map[string]any{"status": constants.ExchangeStatusShipped},
nil, nil, nil, nil, nil, nil)
})
}
@@ -629,6 +789,19 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil {
return err
}
var auditBefore *cardExchangeAuditBefore
var deviceAuditBefore *deviceExchangeAuditBefore
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
auditBefore, err = s.captureCardExchangeAuditBefore(ctx, tx, oldAsset.Card, newAsset.Card)
if err != nil {
return err
}
} else {
deviceAuditBefore, err = s.captureDeviceExchangeAuditBefore(ctx, tx, oldAsset.Device, newAsset.Device)
if err != nil {
return err
}
}
if err = s.syncNewAssetOwnershipWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
@@ -639,9 +812,9 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
return err
}
var migrationBalance int64
var migration *exchangeMigrationResult
if order.MigrateData {
migrationBalance, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
migration, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return err
}
@@ -656,7 +829,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
}
if order.MigrateData {
updates["migration_completed"] = true
updates["migration_balance"] = migrationBalance
updates["migration_balance"] = migration.Balance
}
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", order.ID, fromStatus).
@@ -667,7 +840,49 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}
order.Status = constants.ExchangeStatusCompleted
order.CompletedAt = &now
if migration != nil {
order.MigrationCompleted = true
order.MigrationBalance = migration.Balance
}
if order.OldAssetType == constants.ExchangeAssetTypeDevice {
var oldDeviceAfter, newDeviceAfter model.Device
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货旧设备结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货新设备结果失败")
}
extra, resourceErr := s.buildDeviceExchangeCompletionResources(ctx, tx, order, &oldDeviceAfter, &newDeviceAfter, deviceAuditBefore, migration)
if resourceErr != nil {
return resourceErr
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID},
map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID},
extra, nil)
}
var oldCardAfter, newCardAfter model.IotCard
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货旧卡结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货新卡结果失败")
}
extra, err := s.buildCardExchangeCompletionResources(ctx, tx, order, &oldCardAfter, &newCardAfter, auditBefore, migration)
if err != nil {
return err
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID},
map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID},
extra, nil)
}
func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) {