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

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

@@ -40,6 +40,8 @@ type ChangeAudit struct {
PersonalCustomer *model.PersonalCustomer
PersonalPhones []PersonalCustomerPhoneChange
PersonalOpenIDs []PersonalCustomerOpenIDChange
PersonalDevices []PersonalCustomerDeviceChange
PersonalICCIDs []PersonalCustomerICCIDChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
@@ -64,6 +66,24 @@ type PersonalCustomerOpenIDChange struct {
AfterData map[string]any
}
// PersonalCustomerDeviceChange 保存个人客户设备号绑定资源变化。
type PersonalCustomerDeviceChange struct {
Binding *model.PersonalCustomerDevice
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerICCIDChange 保存个人客户 ICCID 绑定资源变化。
type PersonalCustomerICCIDChange struct {
Binding *model.PersonalCustomerICCID
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
type DeviceChange struct {
Device *model.Device

View File

@@ -10,6 +10,7 @@ import (
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
)
@@ -18,21 +19,23 @@ import (
type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting}
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.posting == nil {
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -50,11 +53,11 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &record, event)
case constants.ApprovalDecisionRejected:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusRejected, "企业微信审批已拒绝")
case constants.ApprovalDecisionCancelled:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
default:
@@ -86,17 +89,23 @@ func (h *ApprovalDecisionHandler) applyApproved(
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
}
_, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
posting, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID,
Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID,
})
return err
if err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeCredited, "企业微信审批通过,代理充值已入账", constants.RechargeStatusCompleted, true)
}
func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error {
func (h *ApprovalDecisionHandler) closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, status int, reason string) error {
if record.Status == status {
return nil
}
@@ -113,7 +122,35 @@ func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentR
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
return nil
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeClosed, reason, status, false)
}
func (h *ApprovalDecisionHandler) appendTerminalAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, actionCode, summary string, status int, withWallet bool) error {
after := *record
after.Status = status
change := RechargeAudit{
ActionCode: actionCode, Summary: summary, Record: &after,
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
}
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, event.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
change.Approval = &approval
if withWallet {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
change.Wallet, change.Transaction = &wallet, &transaction
}
return h.audit.WriteAgentRecharge(ctx, tx, change)
}
var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil)

View File

@@ -64,16 +64,17 @@ type ConfirmOnlinePaymentResult struct {
type ConfirmOnlinePaymentService struct {
db *gorm.DB
eventWriter PaymentConfirmedEventWriter
auditWriter PaymentAuditWriter
}
// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter}
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter, auditWriter PaymentAuditWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter, auditWriter: auditWriter}
}
// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。
func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
if s == nil || s.db == nil || s.eventWriter == nil || s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置")
}
command.PaymentNo = strings.TrimSpace(command.PaymentNo)
@@ -133,7 +134,18 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
}
return nil
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusPaid
afterPayment.ThirdPartyTradeNo = command.ThirdPartyTradeNo
afterPayment.PaidAt = &paidAt
return s.auditWriter.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "确认代理充值支付成功",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, "paid_at": payment.PaidAt},
AfterData: map[string]any{"status": afterPayment.Status, "third_party_trade_no": afterPayment.ThirdPartyTradeNo, "paid_at": afterPayment.PaidAt},
RechargeBeforeData: map[string]any{"status": recharge.Status, "payment_transaction_id": recharge.PaymentTransactionID, "paid_at": recharge.PaidAt},
RechargeAfterData: map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt},
})
})
if err != nil {
return nil, err

View File

@@ -38,16 +38,17 @@ type CreateOfflineResult struct {
type OfflineCreationService struct {
db *gorm.DB
approval approvalapp.Port
audit RechargeAuditWriter
}
// NewOfflineCreationService 创建员工线下代充值申请用例。
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval}
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit RechargeAuditWriter) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
if err := validateCreateOfflineCommand(command); err != nil {
@@ -101,7 +102,14 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
}
record.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "创建员工线下代充值申请",
Record: record, Approval: &instance, Wallet: wallet, AfterData: map[string]any{"status": record.Status},
})
})
if err != nil {
return nil, err

View File

@@ -49,16 +49,17 @@ type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay}
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -230,7 +231,10 @@ func (s *OnlineCreationService) createLocalFacts(
if err := tx.Create(payment).Error; err != nil {
return err
}
return nil
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
@@ -301,7 +305,14 @@ func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result
if rechargeUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败")
}
return nil
afterPayment := *result.Payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付链接生成失败,关闭支付记录",
Payment: &afterPayment, Recharge: result.Recharge,
BeforeData: map[string]any{"status": result.Payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": result.Recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}

View File

@@ -5,8 +5,26 @@ import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// PaymentAudit 描述代理充值支付记录的实际生命周期变化。
type PaymentAudit struct {
ActionCode string
Summary string
Payment *model.Payment
Recharge *model.AgentRechargeRecord
BeforeData map[string]any
AfterData map[string]any
RechargeBeforeData map[string]any
RechargeAfterData map[string]any
}
// PaymentAuditWriter 在支付业务事务内追加统一 Audit Event。
type PaymentAuditWriter interface {
WriteAgentRechargePayment(ctx context.Context, tx *gorm.DB, change PaymentAudit) error
}
const (
// OnlinePaymentStatePending 表示渠道仍在等待付款。
OnlinePaymentStatePending = "pending"

View File

@@ -0,0 +1,27 @@
package agentrecharge
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// RechargeAudit 描述代理充值申请或资金终态的实际变化。
type RechargeAudit struct {
ActionCode string
Summary string
Record *model.AgentRechargeRecord
Payment *model.Payment
Approval *model.ApprovalInstance
Wallet *model.AgentWallet
Transaction *model.AgentWalletTransaction
BeforeData map[string]any
AfterData map[string]any
}
// RechargeAuditWriter 在代理充值业务事务内追加统一 Audit Event。
type RechargeAuditWriter interface {
WriteAgentRecharge(ctx context.Context, tx *gorm.DB, change RechargeAudit) error
}

View File

@@ -17,17 +17,18 @@ type RecoverOnlinePaymentService struct {
wechat OnlinePaymentPort
alipay OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -128,7 +129,7 @@ func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter
})
return err
case OnlinePaymentStateClosed:
return s.closePending(ctx, payment.ID, recharge.ID)
return s.closePending(ctx, payment, recharge)
default:
return nil
}
@@ -171,21 +172,31 @@ func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig
return configs[*payment.PaymentConfigID]
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error {
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending).
Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败")
}
return nil
if paymentUpdate.RowsAffected == 0 {
return nil
}
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付渠道确认订单已关闭",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}

View File

@@ -0,0 +1,40 @@
package approval
import (
"context"
"gorm.io/gorm"
)
// AuditChange 描述通用审批链路一次实际状态变化。
type AuditChange struct {
EventID string
ActionCode string
Summary string
InstanceID uint
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
Provider string
BeforeExternalRef string
AfterExternalRef string
CorrelationID string
ParentEventID string
BeforeStatus *int
AfterStatus *int
ActorKind string
ActorID string
ActorName string
Source string
Result string
ErrorSummary string
Decision string
IntegrationIDs []string
OutboxEventID string
}
// AuditWriter 在审批事实事务中追加统一 Audit Event。
type AuditWriter interface {
WriteApproval(ctx context.Context, tx *gorm.DB, change AuditChange) error
}

View File

@@ -18,6 +18,7 @@ type CreationService struct {
providers ProviderPort
repositories RepositoryProvider
eventWriter SubmissionEventWriter
audit AuditWriter
now func() time.Time
}
@@ -34,6 +35,11 @@ func NewCreationService(
return &CreationService{providers: providers, repositories: repositories, eventWriter: eventWriter, now: now}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *CreationService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// Prepare 在任何业务事实写入前确认 Adapter、场景和真实发起身份可用。
func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (Preparation, error) {
if s == nil || s.providers == nil || s.repositories == nil || s.eventWriter == nil {
@@ -62,7 +68,7 @@ func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (
// CreateInTx 使用调用方业务事务原子创建通用实例、渠道上下文和提交 Outbox。
func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error) {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil || s.audit == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批创建用例未完整配置")
}
now := s.now().UTC()
@@ -97,6 +103,18 @@ func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request C
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return Reference{}, err
}
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":audit:requested",
ActionCode: constants.AuditActionApprovalRequested, Summary: "提交通用审批申请",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, CorrelationID: instance.CorrelationID, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorAccount, ActorID: strconv.FormatUint(uint64(instance.SubmitterAccountID), 10),
Source: constants.AuditSourceAdminAPI, Result: constants.AuditResultSuccess, OutboxEventID: event.EventID,
}); err != nil {
return Reference{}, err
}
return Reference{InstanceID: instance.ID, Status: instance.Status}, nil
}

View File

@@ -46,6 +46,7 @@ type SyncDecisionCommand struct {
Decision string
DecisionSnapshot []byte
Source string
IntegrationIDs []string
}
// SyncDecisionResult 返回本次是否首次记录该标准终态。
@@ -60,6 +61,7 @@ type SyncDecisionService struct {
repositories RepositoryProvider
eventWriter TerminalEventWriter
deliveryWriter DecisionDeliveryWriter
audit AuditWriter
now func() time.Time
}
@@ -79,9 +81,14 @@ func NewSyncDecisionService(
}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *SyncDecisionService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// Execute 将回调或轮询取得的权威渠道状态原子转换为通用审批终态和可靠业务事件。
func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionCommand) (*SyncDecisionResult, error) {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批决策同步用例未完整配置")
}
if command.InstanceID == 0 || !isSupportedSyncSource(command.Source) {
@@ -98,6 +105,8 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
return err
}
expectedStatus, expectedVersion := instance.Status, instance.Version
beforeStatus := instance.Status
beforeExternalRef := instance.ExternalRef
changed, err := instance.ApplyDecision(command.Decision, command.DecisionSnapshot, s.now().UTC())
if err != nil {
return err
@@ -125,6 +134,20 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return err
}
actorKind, actorID, source := approvalSyncAuditOrigin(command.Source)
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: event.EventID + ":audit", ActionCode: constants.AuditActionApprovalDecisionSynced,
Summary: "同步审批权威终态", InstanceID: instance.ID,
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: beforeExternalRef, AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
Decision: command.Decision, IntegrationIDs: command.IntegrationIDs, OutboxEventID: event.EventID,
}); err != nil {
return err
}
result.FirstTerminal = true
return nil
})
@@ -134,6 +157,17 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
return result, nil
}
func approvalSyncAuditOrigin(source string) (string, string, string) {
switch source {
case constants.ApprovalSyncSourceCallback:
return constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback
case constants.ApprovalSyncSourcePolling:
return constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler
default:
return constants.AuditActorAccount, "", constants.AuditSourceAdminAPI
}
}
func terminalDecisionEventID(instanceID uint, decision string) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":" + decision
}

View File

@@ -10,6 +10,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"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"
"gorm.io/gorm"
@@ -42,11 +43,28 @@ type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
Summary string
Card *model.IotCard
IntegrationID string
BeforeData map[string]any
AfterData map[string]any
}
// StateAuditWriter 在卡状态事务中追加统一 Audit Event。
type StateAuditWriter interface {
WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input StateAudit) error
WriteCardStateFailure(ctx context.Context, input StateAudit, businessErr error)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
}
// NewService 创建卡实名观测应用服务。
@@ -54,6 +72,22 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer
}
// RecordCarrierCallbackFailure 在已解析卡资源后记录运营商回调处理失败。
func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.IotCard, integrationID string, businessErr error) {
if s == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr)
}
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
@@ -111,6 +145,58 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
}
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
summary := "人工更新 IoT 卡实名状态"
if !decision.StatusChanged {
summary = "人工确认 IoT 卡实名状态无需变化"
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameStatusUpdated,
Summary: summary,
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
"status_changed": decision.StatusChanged,
},
}); err != nil {
return err
}
} else if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && decision.StatusChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡实名状态",
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
@@ -122,6 +208,24 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return decision, nil
}
func firstRealnameAfter(before *time.Time, observedAt time.Time, firstVerified bool) *time.Time {
if firstVerified {
return &observedAt
}
return before
}
func manualRefreshAuditAction(ctx context.Context) (string, bool) {
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount:
return constants.AuditActionIotCardManualRefreshed, true
case constants.AuditActorPersonalCustomer:
return constants.AuditActionIotCardPersonalRefreshed, true
default:
return "", false
}
}
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed"))

View File

@@ -7,6 +7,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -74,17 +75,48 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if !decision.StatusChanged {
return nil
if decision.StatusChanged {
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
}
}
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling ||
decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡网络状态",
Card: &card,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
}
return nil
})

View File

@@ -10,6 +10,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -65,16 +66,41 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB <= 0 {
return nil
if decision.IncrementMB > 0 {
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return err
}
}
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
})
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
return domain.TrafficDecision{}, err

View File

@@ -8,7 +8,9 @@ import (
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -53,19 +55,24 @@ type deliveryRequest struct {
// DeliveryService 校验接收人并幂等生成站内通知。
type DeliveryService struct {
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
now func() time.Time
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
auditWriter *audit.Writer
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService {
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger, auditWriters ...*audit.Writer) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
service := &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
@@ -150,6 +157,9 @@ func validateDeliveryRequest(request deliveryRequest) error {
}
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
@@ -182,7 +192,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: expiresAt, CreatedAt: now,
}
created, err := s.repository.CreateIdempotent(ctx, notification)
created := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": false})},
})
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}

View File

@@ -2,10 +2,15 @@ package notification
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -14,29 +19,24 @@ import (
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
db *gorm.DB
now func() time.Time
db *gorm.DB
auditWriter *audit.Writer
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB) *ReadService {
return &ReadService{db: db, now: time.Now}
func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService {
service := &ReadService{db: db, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
}
return nil
return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
}
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
@@ -44,18 +44,12 @@ func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request
if recipientID == 0 || !isReadAllCategory(request.Category) {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
db := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, now)
if request.Category != "" {
db = db.Where("category = ?", request.Category)
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
if err != nil {
return nil, err
}
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func isReadAllCategory(category string) bool {
@@ -70,17 +64,8 @@ func isReadAllCategory(category string) bool {
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
if customerID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("id = ? AND is_read = ?", notificationID, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
}
return nil
return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID,
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
@@ -88,14 +73,139 @@ func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint)
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("is_read = ?", false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notification model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("id = ? AND is_read = ?", notificationID, false).Take(&notification)
if stderrors.Is(query.Error, gorm.ErrRecordNotFound) {
return nil
}
if query.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, query.Error, "查询通知已读状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND is_read = ?", notification.ID, false).
Updates(map[string]any{"is_read": true, "read_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通知已读状态失败")
}
return s.appendReadAudit(ctx, tx, &notification, now, actorKind, source, "")
})
}
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
var updated int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notifications []*model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("is_read = ?", false)
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("id ASC").Find(&notifications).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
}
if len(notifications) == 0 {
return nil
}
ids := make([]uint, 0, len(notifications))
for _, notification := range notifications {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
if result.RowsAffected != int64(len(notifications)) {
return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化")
}
updated = result.RowsAffected
return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source)
})
return updated, err
}
func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error {
scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读",
Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)},
Source: source, ScopeType: scopeType, ScopeID: scopeID,
Result: constants.AuditResultSuccess, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error {
rootID := "evt_" + uuid.NewString()
actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)}
scopeType, scopeID := notificationScope(recipientKind, recipientID)
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"recipient_kind": recipientKind, "recipient_id": recipientID,
"category": category, "updated_count": len(notifications),
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func notificationScope(recipientKind string, recipientID uint) (string, string) {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10)
}
return constants.AuditScopePlatform, ""
}
func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return personalReadScope(db, recipientID, now)
}
return db.Model(&model.Notification{}).Where(
"recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
recipientKind, recipientID, now,
)
}
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {

View File

@@ -18,9 +18,23 @@ import (
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
type ApplicationAudit struct {
Refund *model.RefundRequest
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
}
// AuditWriter 接收退款申请事务内审计事实。
type AuditWriter interface {
WriteRefundApplication(ctx context.Context, tx *gorm.DB, audit ApplicationAudit) error
}
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
@@ -32,19 +46,20 @@ type CreateResult struct {
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewCreationService 创建退款审批申请用例。
func NewCreationService(db *gorm.DB, approval approvalapp.Port) *CreationService {
return &CreationService{db: db, approval: approval}
func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *CreationService {
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Refund.OrderID == 0 || command.SubmitterAccountID == 0 ||
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -100,7 +115,13 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
})
})
if err != nil {
return nil, err

View File

@@ -25,6 +25,9 @@ type ChangeAudit struct {
Description string
ConfigKey string
Module string
ResourceID *string
DisplayName string
Identity map[string]any
BeforeData map[string]any
AfterData map[string]any
RequestID string

View File

@@ -3,33 +3,55 @@ package wallet
import (
"context"
stderrors "errors"
"strconv"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"gorm.io/gorm"
)
// CreditChangeAudit 描述一次代理主钱包实际信用额度变化。
type CreditChangeAudit struct {
Wallet *model.AgentWallet
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
}
// CreditChangeAuditWriter 在信用额度业务事务内追加统一 Audit Event。
type CreditChangeAuditWriter interface {
WriteAgentWalletCreditChange(context.Context, *gorm.DB, CreditChangeAudit) error
}
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
db *gorm.DB
audit CreditChangeAuditWriter
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
func NewChangeCreditService(db *gorm.DB, audit CreditChangeAuditWriter) *ChangeCreditService {
return &ChangeCreditService{db: db, audit: audit}
}
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
var stored model.AgentWallet
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
beforeData = creditAuditData(&stored)
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
return err
@@ -51,8 +73,62 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: stored.Version + 1}
return nil
after := stored
after.CreditEnabled = enabled
after.CreditLimit = limit
after.Version++
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: after.Version}
if s.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包信用额度审计接缝未配置")
}
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: &after, BeforeData: beforeData, AfterData: creditAuditData(&after), Result: constants.AuditResultSuccess,
})
})
if err != nil && stored.ID != 0 {
s.recordCreditChangeFailure(ctx, &stored, beforeData, err)
}
return result, err
}
func creditAuditData(wallet *model.AgentWallet) map[string]any {
return map[string]any{
"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit, "version": wallet.Version,
}
}
func (s *ChangeCreditService) recordCreditChangeFailure(ctx context.Context, wallet *model.AgentWallet, beforeData map[string]any, originalErr error) {
result, code, summary := creditChangeError(originalErr)
if s.audit == nil || s.db == nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, errors.New(errors.CodeInvalidStatus, "代理主钱包信用额度审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: wallet, BeforeData: beforeData, Result: result, ErrorCode: code, ErrorSummary: summary,
})
}); err != nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, err)
}
}
func recordCreditChangeSecondaryFailure(ctx context.Context, walletID uint, errorCode string, err error) {
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
constants.AuditActionAgentWalletCreditChanged, strconv.FormatUint(uint64(walletID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, err,
)
}
func creditChangeError(err error) (string, string, string) {
var appErr *errors.AppError
if !stderrors.As(err, &appErr) {
return constants.AuditResultFailed, strconv.Itoa(errors.CodeInternalError), "更新代理主钱包信用额度失败"
}
result := constants.AuditResultDenied
if appErr.Code == errors.CodeDatabaseError || appErr.Code == errors.CodeInternalError {
result = constants.AuditResultFailed
}
return result, strconv.Itoa(appErr.Code), appErr.Message
}

View File

@@ -51,6 +51,7 @@ type CreditedEvent struct {
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
Remark string `json:"remark,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
@@ -145,6 +146,7 @@ func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command Post
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
Remark: remark,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
@@ -163,6 +165,9 @@ func validatePostingCommand(command PostingCommand) error {
if !validRecharge && !validAdjustment {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
}
if validAdjustment && strings.TrimSpace(command.Remark) == "" {
return errors.New(errors.CodeInvalidParam, "人工调整代理主钱包必须填写原因")
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -90,7 +91,7 @@ func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens Access
// Save 创建或更新企业微信应用配置。
func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil {
if s == nil || s.db == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -140,10 +141,12 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", existing.ID)
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置",
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave, Description: "保存企业微信应用安全配置",
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.Name, Identity: applicationAuditIdentity(existing),
}); err != nil {
return err
}
@@ -152,6 +155,14 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
return nil
})
if err != nil {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave,
Description: "保存企业微信应用配置失败", ConfigKey: fmt.Sprintf("wecom.application.%s.%d", request.CorpID, request.AgentID),
DisplayName: request.Name, Identity: map[string]any{
"corp_id": request.CorpID, "agent_id": request.AgentID, "name": request.Name, "status": request.Status,
"credentials_configured": request.Secret != "" && request.CallbackToken != "" && request.EncodingAESKey != "",
}, Result: constants.AuditResultFailed, ErrorCode: fmt.Sprintf("%d", errors.CodeDatabaseError), ErrorSummary: "企业微信应用配置事务已回滚",
})
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -240,7 +251,7 @@ func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error
// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.members == nil {
if s == nil || s.db == nil || s.repo == nil || s.members == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -256,6 +267,7 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
}
member, err := s.members.GetVisible(ctx, applicationID, request.UserID)
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "拒绝保存不可用的企业微信默认审批发起人", application, constants.AuditResultDenied, errors.CodeInvalidParam)
return nil, err
}
now := s.now().UTC()
@@ -273,15 +285,18 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人",
OperatorID: operatorID, OperationType: constants.AuditOperationWeComDefaultCreatorSave, Description: "保存企业微信默认审批发起人",
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
})
}
return nil
})
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "保存企业微信默认审批发起人失败", application, constants.AuditResultFailed, errors.CodeDatabaseError)
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -295,12 +310,52 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
func applicationAuditSnapshot(application *model.WeComApplication) map[string]any {
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status, "credentials_configured": true,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
}
}
func applicationAuditIdentity(application *model.WeComApplication) map[string]any {
if application == nil {
return nil
}
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
}
}
func recordApplicationFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, operation, description string, application *model.WeComApplication, result string, code int) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, db, audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: fmt.Sprintf("wecom.application.%d", application.ID), ResourceID: &resourceID,
DisplayName: application.Name, Identity: applicationAuditIdentity(application), BeforeData: applicationAuditSnapshot(application),
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func recordConfigFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, change systemconfigapp.ChangeAudit) {
if db == nil || audit == nil || change.OperatorID == 0 || change.ConfigKey == "" {
return
}
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
change.RequestID = *value
change.CorrelationID = *value
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return audit.WriteConfigChange(ctx, tx, change)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(change.OperationType, change.ConfigKey, change.RequestID, change.CorrelationID, change.ErrorCode, err)
}
}
func toApplicationResponse(application model.WeComApplication) dto.WeComApplicationResponse {
statusName := "禁用"
if application.Status == constants.StatusEnabled {

View File

@@ -2,10 +2,13 @@ package wecom
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -27,30 +30,32 @@ type DirectoryProvider interface {
// MemberRepository 定义可见成员快照同步和分页查询边界。
type MemberRepository interface {
ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error)
}
// DirectoryService 同步并分页查询企业微信应用可见成员。
type DirectoryService struct {
db *gorm.DB
applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}
provider DirectoryProvider
members MemberRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewDirectoryService 创建企业微信通讯录同步用例。
func NewDirectoryService(applications interface {
func NewDirectoryService(db *gorm.DB, applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}, provider DirectoryProvider, members MemberRepository) *DirectoryService {
return &DirectoryService{applications: applications, provider: provider, members: members, now: time.Now}
}, provider DirectoryProvider, members MemberRepository, audit systemconfigapp.AuditWriter) *DirectoryService {
return &DirectoryService{db: db, applications: applications, provider: provider, members: members, audit: audit, now: time.Now}
}
// Sync 拉取并替换指定应用当前可见成员快照。
func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) {
if s == nil || s.applications == nil || s.provider == nil || s.members == nil || applicationID == 0 {
if s == nil || s.db == nil || s.applications == nil || s.provider == nil || s.members == nil || s.audit == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
@@ -62,6 +67,7 @@ func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.W
}
remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID)
if err != nil {
s.recordFailure(ctx, application, "同步企业微信应用可见成员失败")
return nil, err
}
syncedAt := s.now().UTC()
@@ -77,12 +83,46 @@ func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.W
CreatedAt: syncedAt, UpdatedAt: syncedAt,
})
}
if err := s.members.ReplaceVisible(ctx, applicationID, members, syncedAt); err != nil {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.members.ReplaceVisible(ctx, tx, applicationID, members, syncedAt); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
after := applicationAuditSnapshot(application)
after["synced_count"] = len(members)
after["synced_at"] = syncedAt
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: "同步企业微信应用可见成员", ConfigKey: fmt.Sprintf("wecom.application.%d.members", applicationID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
AfterData: after, RequestID: requestID, CorrelationID: requestID,
})
})
if err != nil {
s.recordFailure(ctx, application, "保存企业微信应用可见成员快照失败")
return nil, err
}
return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil
}
func (s *DirectoryService) recordFailure(ctx context.Context, application *model.WeComApplication, description string) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: description, ConfigKey: fmt.Sprintf("wecom.application.%d.members", application.ID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
BeforeData: applicationAuditSnapshot(application), Result: constants.AuditResultFailed,
ErrorCode: fmt.Sprintf("%d", errors.CodeInternalError), ErrorSummary: description,
})
}
// List 分页返回本地最近一次同步的应用可见成员。
func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) {
if s == nil || s.applications == nil || s.members == nil || applicationID == 0 {

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
stdErrors "errors"
"fmt"
"strings"
"time"
@@ -108,7 +109,7 @@ func (s *SceneService) ListBusinessFields(ctx context.Context, businessType stri
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -124,9 +125,11 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt
}
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeInternalError, "校验企业微信审批模板失败")
return nil, err
}
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射")
return nil, err
}
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
@@ -177,23 +180,27 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := strings.TrimSpace(existing.BusinessType)
if existing.ID != 0 {
resourceID = fmt.Sprintf("%d", existing.ID)
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApprovalSceneSave, Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.TemplateName, Identity: sceneAuditIdentity(existing),
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeDatabaseError, "保存企业微信审批场景失败")
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -364,6 +371,28 @@ func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
}
}
func sceneAuditIdentity(scene *model.WeComApprovalScene) map[string]any {
if scene == nil {
return nil
}
return map[string]any{
"id": scene.ID, "business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "template_name": scene.TemplateName, "status": scene.Status,
}
}
func (s *SceneService) recordFailure(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest, result string, code int, description string) {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComApprovalSceneSave,
Description: description, ConfigKey: "wecom.approval_scene." + businessType,
DisplayName: businessType, Identity: map[string]any{
"business_type": businessType, "application_id": request.ApplicationID,
"template_id": strings.TrimSpace(request.TemplateID), "status": request.Status,
},
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
var mapping []dto.WeComControlMappingItem
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {

View File

@@ -74,6 +74,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.QueueClient,
deps.Logger,
)
rechargeOrderService.SetPaymentAudit(svc.AccessAudit)
clientOrderService := clientOrderSvc.New(
svc.Asset,
svc.PurchaseValidation,
@@ -109,6 +110,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
@@ -137,14 +139,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
wecomDirectory := wecomApp.NewDirectoryService(
deps.DB,
wecomRepository,
wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomMembers,
wecomMembers, systemConfigAudit,
)
wecomScenes := wecomApp.NewSceneService(
deps.DB,
wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit,
wecomInfra.NewSceneRepository(deps.DB), systemConfigAudit,
)
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
wecomRepository, integrationlog.NewRepository(deps.DB), deps.QueueClient, deps.Logger,
@@ -172,6 +175,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
}(),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
@@ -187,13 +191,14 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return handler
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
return handler
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
@@ -212,20 +217,21 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,

View File

@@ -5,7 +5,6 @@ import (
"go.uber.org/zap"
accessauditApp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
@@ -78,7 +77,7 @@ import (
)
type services struct {
AccessAudit accessauditApp.Writer
AccessAudit *auditInfra.Writer
Approval *approvalApp.CreationService
Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service
@@ -151,6 +150,7 @@ func initServices(s *stores, deps *Dependencies) *services {
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
customerBinding.SetAccessAudit(auditWriter)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
account.SetLifecycleAudit(deps.DB, auditWriter)
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
@@ -171,6 +171,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
assetAudit,
)
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
@@ -178,6 +179,7 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -205,7 +207,7 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc)
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, auditWriter, deps.Redis, deps.Logger)
// 创建支付配置动态加载器Order 和 Recharge 依赖)
paymentLoader := payment.NewPaymentConfigLoader(s.WechatConfig, deps.Redis, deps.Logger)
@@ -218,6 +220,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -231,6 +234,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -254,17 +258,25 @@ func initServices(s *stores, deps *Dependencies) *services {
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
device.SetAccessAudit(auditWriter)
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
shopCommission.SetAuditWriter(auditWriter)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
packageService.SetAccessAudit(deps.DB, auditWriter)
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
agentRechargeService := agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
@@ -276,17 +288,19 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox, auditWriter), nil)
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
deps.DB,
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
refundService := refundSvc.New(
deps.DB,
@@ -305,8 +319,10 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
@@ -333,11 +349,13 @@ func initServices(s *stores, deps *Dependencies) *services {
approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()),
nil,
)
approvalCreationService.SetAuditWriter(auditWriter)
agentRechargeService.SetOfflineCreationService(
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService),
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter),
)
agentRechargeService.SetRechargeAudit(auditWriter)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
)
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
@@ -345,6 +363,35 @@ func initServices(s *stores, deps *Dependencies) *services {
permissionService.SetAccessAudit(deps.DB, auditWriter)
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
commissionWithdrawal.SetAuditWriter(auditWriter)
commissionCalculation := commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
)
commissionCalculation.SetAuditWriter(auditWriter)
pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger)
pollingConfigService.SetAudit(deps.DB, auditWriter)
pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis)
pollingConcurrencyService.SetAudit(deps.DB, auditWriter)
pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger)
pollingAlertService.SetAudit(deps.DB, auditWriter)
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
return &services{
AccessAudit: auditWriter,
@@ -373,55 +420,38 @@ func initServices(s *stores, deps *Dependencies) *services {
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawal: commissionWithdrawal,
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
),
CommissionCalculation: commissionCalculation,
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account, auditWriter),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard, auditWriter),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit, auditWriter),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService, auditWriter),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit, auditWriter),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
Carrier: carrierSvc.New(s.Carrier, auditWriter),
PackageSeries: packageSeriesService,
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingConfig: pollingConfigService,
PollingConcurrency: pollingConcurrencyService,
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
@@ -436,8 +466,8 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,

View File

@@ -3,6 +3,7 @@ package bootstrap
import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
@@ -31,6 +32,7 @@ type workerServices struct {
func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices {
assetAudit := assetAuditSvc.NewService(stores.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats)
commissionCalculationService := commission_calculation.New(
@@ -51,6 +53,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
commissionStatsService,
deps.Logger,
)
commissionCalculationService.SetAuditWriter(auditWriter)
usageService := packagepkg.NewUsageService(
deps.DB,
@@ -76,6 +79,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PackageUsage,
deps.Logger,
)
activationService.SetLifecycleAudit(auditWriter)
usageService.SetLifecycleAudit(auditWriter)
resetService.SetLifecycleAudit(auditWriter)
alertService := pollingSvc.NewAlertService(
stores.PollingAlertRule,
@@ -131,9 +137,11 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PersonalCustomer,
stores.PersonalCustomerPhone,
)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
@@ -148,6 +156,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
assetAudit,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
@@ -160,6 +169,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
return &queue.WorkerServices{
PaymentAudit: auditWriter,
RechargeAudit: auditWriter,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,

View File

@@ -38,6 +38,22 @@ type Client struct {
maxRetries int
}
type attemptObserverKey struct{}
// AttemptObserver 记录一次 Gateway HTTP 尝试的开始和结果。
type AttemptObserver interface {
BeforeAttempt(ctx context.Context, attempt int) error
AfterAttempt(ctx context.Context, attempt int, callErr error) error
}
// WithAttemptObserver 为当前 Gateway 调用注入逐次 HTTP 尝试观察器。
func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context {
if observer == nil {
return ctx
}
return context.WithValue(ctx, attemptObserverKey{}, observer)
}
// requestWrapper 用于将请求参数包装为 Gateway 的 {"params": ...} 格式
type requestWrapper struct {
Params interface{} `json:"params"`
@@ -101,6 +117,7 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// 检查用户 Context 是否已取消
@@ -120,7 +137,18 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
time.Sleep(delay)
}
attemptNumber := attempt + 1
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
if observer != nil {
if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil {
return nil, observeErr
}
}
if err != nil {
lastErr = err
// 仅对网络级错误重试

View File

@@ -8,6 +8,8 @@ import (
"strings"
"time"
"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/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -46,6 +48,8 @@ type ClientWalletHandler struct {
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
paymentMethodPolicy ClientPaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -53,6 +57,12 @@ func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodP
h.paymentMethodPolicy = policy
}
// SetPaymentAudit 注入充值支付审计与外部交互日志接缝。
func (h *ClientWalletHandler) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
h.auditWriter = writer
h.paymentIntegration = integration
}
// NewClientWalletHandler 创建 C 端钱包处理器
func NewClientWalletHandler(
assetService *asset.Service,
@@ -352,6 +362,10 @@ func (h *ClientWalletHandler) createWechatRecharge(
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
}
payConfig, err := h.createClientRechargePayConfig(
resolved.SkipPermissionCtx,
config,
@@ -363,6 +377,12 @@ func (h *ClientWalletHandler) createWechatRecharge(
int(req.Amount),
)
if err != nil {
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return err
}
@@ -381,10 +401,6 @@ func (h *ClientWalletHandler) createWechatRecharge(
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
@@ -394,8 +410,17 @@ func (h *ClientWalletHandler) createWechatRecharge(
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
@@ -456,14 +481,17 @@ func (h *ClientWalletHandler) createAlipayRecharge(
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
if err != nil {
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
h.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),

View File

@@ -0,0 +1,100 @@
package app
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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func (h *ClientWalletHandler) appendRechargePaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, recharge *model.RechargeOrder) error {
if h.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "充值支付统一审计接缝未配置")
}
rechargeID := strconv.FormatUint(uint64(recharge.ID), 10)
resources := []audit.ResourceInput{
audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status}),
{
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "资产充值支付已创建",
},
}
references, err := audit.AssetRechargeReferences(ctx, tx, recharge)
if err != nil {
return err
}
resources = append(resources, references...)
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建资产充值支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: resources,
})
}
func (h *ClientWalletHandler) startRechargePaymentAttempt(ctx context.Context, config *model.WechatConfig, paymentNo, rechargeNo string, amount int64) (*model.IntegrationLog, time.Time, error) {
if h.paymentIntegration == nil {
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "充值支付 Integration Log 接缝未配置")
}
provider := constants.IntegrationProviderWechatPay
if config.ProviderType == model.ProviderTypeFuiou {
provider = constants.IntegrationProviderFuiou
}
series := "payment:" + paymentNo + ":" + constants.IntegrationOperationPaymentPreCreate
log, err := h.paymentIntegration.Start(ctx, integrationlog.Attempt{
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationPaymentPreCreate,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &paymentNo, ExternalID: &paymentNo,
TriggerSeries: &series, CorrelationID: &rechargeNo,
RequestSummary: map[string]any{"payment_config_id": config.ID, "amount": amount},
})
return log, time.Now(), err
}
func (h *ClientWalletHandler) completeRechargePaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
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 := h.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
return err
}
func (h *ClientWalletHandler) markRechargePaymentFailed(ctx context.Context, payment *model.Payment) error {
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭失败支付记录失败")
}
if result.RowsAffected == 0 {
return nil
}
after := *payment
after.Status = model.PaymentRecordStatusFailed
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付宝支付链接生成失败,关闭支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: []audit.ResourceInput{audit.PaymentResource(&after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
map[string]any{"status": payment.Status}, map[string]any{"status": after.Status})},
})
})
}

View File

@@ -7,11 +7,19 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
func carrierCallbackContext(ctx context.Context, provider string) context.Context {
return auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
ActorName: provider, Source: constants.AuditSourceCallback,
})
}
// SystemConfigReader 提供运营商回调运行时开关读取能力。
type SystemConfigReader interface {
Get(ctx context.Context, key string) (string, error)

View File

@@ -60,6 +60,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "移动实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCMCC)
translated, translateErr := h.translator.Translate(body)
idempotencyKey := cmccIdempotencyKey(body, translated)
integrationID := "cmcc-realname:" + shortHash(idempotencyKey)
@@ -108,6 +109,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {

View File

@@ -82,6 +82,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "电信实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCTCC)
translated, translateErr := h.translator.Translate(body)
idempotencyKey := ctccIdempotencyKey(body, translated)
integrationID := "ctcc-realname:" + shortHash(idempotencyKey)
@@ -134,6 +135,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err)
return h.failPending(ctx, log.IntegrationID, err)
}
if h.series != nil {

View File

@@ -61,6 +61,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "联通实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCUCC)
translated, translateErr := h.translator.Translate(body)
key := cuccRealnameIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname:" + shortHash(key)
@@ -107,6 +108,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, &card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {

View File

@@ -123,6 +123,12 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "微信 v2 回调验签失败")
}
if result.TradeState != "SUCCESS" {
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
}, result.TradeState); err != nil {
return err
}
return h.wechatV2SuccessResponse(c)
}
// TotalFee 为字符串格式的分,解析失败则降级为 0后续 handlePaymentCallback 会记录日志)
@@ -145,7 +151,10 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return nil
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
}, result.TradeState)
}
paidAt, _ := time.Parse(time.RFC3339, result.SuccessTime)
return h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
@@ -170,19 +179,35 @@ func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, call
if h.paymentStore != nil {
payment, err := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo)
if err == nil {
log, err := h.recordPaymentCallback(ctx, callback, payment)
if err != nil {
return true, err
}
var processErr error
switch payment.OrderType {
case model.PaymentOrderTypePackage:
return true, h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
processErr = h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
case model.PaymentOrderTypeRecharge:
if h.rechargeOrderService != nil {
return true, h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
} else {
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
return true, errors.New(errors.CodeInternalError, "充值订单服务未配置")
case model.PaymentOrderTypeAgentRecharge:
return true, h.confirmAgentRechargePayment(ctx, callback)
return true, h.confirmAgentRechargePayment(ctx, callback, log)
default:
return true, errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
processErr = errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
}
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": true}}
if processErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "支付回调业务确认失败"
completion.ResponseSummary = map[string]any{"confirmed": false}
} else if current, currentErr := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo); currentErr == nil {
completion.StateChanged = payment.Status != model.PaymentRecordStatusPaid && current.Status == model.PaymentRecordStatusPaid
}
h.completePaymentCallbackLog(ctx, log, completion)
return true, processErr
}
if err != gorm.ErrRecordNotFound {
return true, errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
@@ -198,31 +223,14 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, callback ve
return err
}
switch {
case strings.HasPrefix(callback.PaymentNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.Amount)
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
return h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentByWechat, callback.TransactionID)
}
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.TransactionID, callback.Amount)
}
return errors.New(errors.CodeInternalError, "代理充值服务未配置")
default:
return errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
}
return h.dispatchLegacyPaymentCallback(ctx, callback)
}
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback) error {
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
func (h *PaymentHandler) dispatchLegacyPaymentCallback(ctx context.Context, callback verifiedPaymentCallback) error {
if h.integration == nil {
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
@@ -230,13 +238,51 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceKey: &resourceKey,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil {
return err
}
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
return err
}
var processErr error
switch {
case strings.HasPrefix(callback.PaymentNo, "ORD"):
processErr = h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.Amount)
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
} else {
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
processErr = h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
} else {
processErr = errors.New(errors.CodeInternalError, "代理充值服务未配置")
}
default:
processErr = errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
}
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": processErr == nil}}
if processErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "旧支付回调业务确认失败"
}
h.completePaymentCallbackLog(ctx, log, completion)
return processErr
}
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback, log *model.IntegrationLog) error {
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
}
correlationID := callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
@@ -269,6 +315,69 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
return nil
}
func (h *PaymentHandler) recordPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, payment *model.Payment) (*model.IntegrationLog, error) {
if h.integration == nil || payment == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceID, resourceKey, correlationID := strconv.FormatUint(uint64(payment.ID), 10), payment.PaymentNo, payment.PaymentNo
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil {
return nil, err
}
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
return nil, err
}
return log, nil
}
func (h *PaymentHandler) preparePaymentCallbackRetry(ctx context.Context, log *model.IntegrationLog) error {
if log == nil || log.Result != constants.IntegrationResultFailed {
return nil
}
claimed, err := h.integration.ClaimFailedInbound(ctx, log.IntegrationID)
if err != nil {
return err
}
if claimed {
log.Result = constants.IntegrationResultPending
}
return nil
}
func (h *PaymentHandler) recordIgnoredPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, providerCode string) error {
if h.integration == nil {
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
idempotencyKey := callback.PaymentNo + ":" + providerCode
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil || log.Result != constants.IntegrationResultPending {
return err
}
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored, ProviderCode: providerCode,
ResponseSummary: map[string]any{"confirmed": false},
})
return err
}
func (h *PaymentHandler) completePaymentCallbackLog(ctx context.Context, log *model.IntegrationLog, completion integrationlog.Completion) {
if log == nil || log.Result != constants.IntegrationResultPending {
return
@@ -346,6 +455,12 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
zap.String("out_trade_no", outTradeNo),
zap.String("trade_status", tradeStatus),
)
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: outTradeNo, TransactionID: notification.TradeNo,
Provider: constants.IntegrationProviderAlipay, RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}, tradeStatus); err != nil {
return err
}
return c.SendString("success")
}
@@ -409,57 +524,13 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
}
// 新代理充值由统一确认事务原子写入交易号,旧业务保持原处理方式。
if payment.OrderType != model.PaymentOrderTypeAgentRecharge {
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
// 不中断存量业务,继续由原幂等业务层处理状态。
}
callback := verifiedPaymentCallback{
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
MerchantIdentity: notification.AppId, Provider: constants.IntegrationProviderAlipay,
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}
// 按支付单 order_type 分发业务
switch payment.OrderType {
case model.PaymentOrderTypePackage:
if err := h.orderService.HandlePaymentRecordCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo, payment.Amount); err != nil {
h.logger.Error("支付宝回调:推进套餐订单失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝支付回调失败")
}
h.logger.Info("支付宝回调:套餐订单支付成功",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.String("order_type", payment.OrderType),
)
case model.PaymentOrderTypeRecharge:
if h.rechargeOrderService == nil {
h.logger.Error("支付宝回调:充值订单服务未配置",
zap.String("out_trade_no", outTradeNo),
)
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo); err != nil {
h.logger.Error("支付宝回调:推进充值订单失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝充值回调失败")
}
h.logger.Info("支付宝回调:充值订单支付成功",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.String("order_type", payment.OrderType),
)
case model.PaymentOrderTypeAgentRecharge:
if payment.OrderType == model.PaymentOrderTypeAgentRecharge {
paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local)
if parseErr != nil {
h.logger.Error("支付宝回调:付款时间格式无效",
@@ -468,25 +539,13 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝付款时间格式错误")
}
if err := h.confirmAgentRechargePayment(ctx, verifiedPaymentCallback{
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
MerchantIdentity: notification.AppId, PaidAt: paidAt, Provider: constants.IntegrationProviderAlipay,
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}); err != nil {
h.logger.Error("支付宝回调:确认代理充值支付失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝代理充值回调失败")
}
default:
h.logger.Error("支付宝回调:未知支付记录类型",
zap.String("out_trade_no", outTradeNo),
zap.String("order_type", payment.OrderType),
)
return errors.New(errors.CodeInternalError, "未知支付记录类型")
callback.PaidAt = paidAt
}
if handled, dispatchErr := h.dispatchPaymentRecordCallback(ctx, callback); dispatchErr != nil {
h.logger.Error("支付宝回调:确认支付失败", zap.String("out_trade_no", outTradeNo), zap.Error(dispatchErr))
return errors.Wrap(errors.CodeInternalError, dispatchErr, "处理支付宝支付回调失败")
} else if !handled {
return errors.New(errors.CodeInternalError, "支付记录分发失败")
}
return c.SendString("success")
@@ -563,6 +622,12 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
h.logger.Warn("富友回调:非成功结果",
zap.String("result_code", notify.ResultCode),
zap.String("result_msg", notify.ResultMsg))
if recordErr := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: notify.MchntOrderNo, TransactionID: notify.TransactionId,
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
}, notify.ResultCode); recordErr != nil {
return c.Send(fuiou.BuildNotifyFailResponse("integration log failed"))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}
h.logger.Error("富友回调:验签或解析失败",
@@ -581,9 +646,10 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
orderNo := notify.MchntOrderNo
// OrderAmt 为字符串格式的分,解析失败则降级为 0
orderAmt, _ := strconv.ParseInt(notify.OrderAmt, 10, 64)
paidAt, _ := time.ParseInLocation("20060102150405", notify.TxnFinTs, time.Local)
if handled, err := h.dispatchPaymentRecordCallback(ctx, verifiedPaymentCallback{
PaymentNo: orderNo, PaymentMethod: "fuiou", TransactionID: notify.TransactionId, Amount: orderAmt,
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, Provider: model.ProviderTypeFuiou,
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, PaidAt: paidAt, Provider: constants.IntegrationProviderFuiou,
RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
@@ -591,27 +657,11 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifySuccessResponse())
}
switch {
case strings.HasPrefix(orderNo, "ORD"):
if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou", orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}
return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置"))
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}
default:
return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix"))
if err := h.dispatchLegacyPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: orderNo, PaymentMethod: model.ProviderTypeFuiou, TransactionID: notify.TransactionId, Amount: orderAmt,
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
return c.Send(fuiou.BuildNotifySuccessResponse())

View File

@@ -0,0 +1,189 @@
package audit
import (
"context"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteApproval 将通用审批状态变化及其 Integration/Outbox 引用写入统一 Audit Event。
func (w *Writer) WriteApproval(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) error {
if change.InstanceID == 0 || change.BusinessID == 0 || change.BusinessType == "" || change.SubmitterAccountID == 0 {
return errors.New(errors.CodeInvalidParam, "通用审批审计资源不完整")
}
resources, err := approvalResources(ctx, tx, change)
if err != nil {
return err
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
Actor: ActorInput{Kind: change.ActorKind, ID: change.ActorID, Name: change.ActorName}, Source: change.Source,
ScopeType: constants.AuditScopePlatform, Result: result, ErrorSummary: change.ErrorSummary,
CorrelationID: change.CorrelationID, ParentEventID: change.ParentEventID,
Metadata: map[string]any{"provider": change.Provider, "decision": change.Decision}, Resources: resources,
})
}
func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) ([]ResourceInput, error) {
instanceID := strconv.FormatUint(uint64(change.InstanceID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceApprovalInstance, ID: &instanceID, Key: instanceID, DisplayName: "审批实例 " + instanceID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget,
IdentitySnapshot: map[string]any{
"id": change.InstanceID, "business_type": change.BusinessType, "business_id": change.BusinessID,
"submitter_account_id": change.SubmitterAccountID, "provider": change.Provider,
"external_ref": change.AfterExternalRef, "correlation_id": change.CorrelationID,
"status": statusValue(change.AfterStatus),
},
BeforeData: approvalState(change.BeforeStatus, change.BeforeExternalRef),
AfterData: approvalState(change.AfterStatus, change.AfterExternalRef),
}}
business, err := approvalBusinessResource(ctx, tx, change.BusinessType, change.BusinessID, change.InstanceID)
if err != nil {
return nil, err
}
resources = append(resources, business)
resources = append(resources, approvalSubmitterResource(change))
seenIntegrationIDs := make(map[string]struct{}, len(change.IntegrationIDs))
for _, integrationID := range change.IntegrationIDs {
integrationID = strings.TrimSpace(integrationID)
if integrationID == "" {
continue
}
if _, exists := seenIntegrationIDs[integrationID]; exists {
continue
}
seenIntegrationIDs[integrationID] = struct{}{}
resource, err := approvalIntegrationResource(ctx, tx, integrationID)
if err != nil {
return nil, err
}
resources = append(resources, resource)
}
if strings.TrimSpace(change.OutboxEventID) != "" {
resource, err := approvalOutboxResource(ctx, tx, change.OutboxEventID)
if err != nil {
return nil, err
}
resources = append(resources, resource)
}
return resources, nil
}
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) (ResourceInput, error) {
id := strconv.FormatUint(uint64(businessID), 10)
switch businessType {
case constants.ApprovalBusinessTypeRefund:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联退款单失败")
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: &id, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID, "order_no": refund.OrderNo,
"order_type": refund.OrderType, "asset_identifier": refund.AssetIdentifier, "shop_id": refund.ShopID,
"requested_refund_amount": refund.RequestedRefundAmount, "actual_received_amount": refund.ActualReceivedAmount,
"approval_instance_id": instanceID, "status": refund.Status,
},
}, nil
case constants.ApprovalBusinessTypeOfflineRecharge:
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).First(&recharge, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
}
return ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: recharge.RechargeNo, DisplayName: recharge.RechargeNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_no": recharge.RechargeNo, "user_id": recharge.UserID,
"shop_id": recharge.ShopID, "agent_wallet_id": recharge.AgentWalletID, "amount": recharge.Amount,
"payment_method": recharge.PaymentMethod, "payment_channel": recharge.PaymentChannel,
"approval_instance_id": instanceID, "status": recharge.Status,
},
}, nil
default:
return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}
}
func approvalSubmitterResource(change approvalapp.AuditChange) ResourceInput {
accountID := strconv.FormatUint(uint64(change.SubmitterAccountID), 10)
identity := map[string]any{"id": change.SubmitterAccountID}
var snapshot map[string]any
if sonic.Unmarshal(change.SubmitterSnapshot, &snapshot) == nil {
identity["username"] = snapshot["account_name"]
identity["user_type"] = snapshot["user_type"]
}
displayName, _ := identity["username"].(string)
if displayName == "" {
displayName = "账号 " + accountID
}
return ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: displayName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalSubmitter,
IdentitySnapshot: identity,
}
}
func approvalIntegrationResource(ctx context.Context, tx *gorm.DB, integrationID string) (ResourceInput, error) {
var record model.IntegrationLog
if err := tx.WithContext(ctx).Where("integration_id = ?", integrationID).First(&record).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联 Integration Log 失败")
}
id := strconv.FormatUint(uint64(record.ID), 10)
return ResourceInput{
Type: constants.AuditResourceIntegrationLog, ID: &id, Key: record.IntegrationID, DisplayName: record.IntegrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalIntegration,
IdentitySnapshot: map[string]any{
"integration_id": record.IntegrationID, "provider": record.Provider, "direction": record.Direction,
"operation": record.Operation, "external_id": record.ExternalID,
"resource_type": record.ResourceType, "resource_id": record.ResourceID, "resource_key": record.ResourceKey,
"correlation_id": record.CorrelationID,
},
}, nil
}
func approvalOutboxResource(ctx context.Context, tx *gorm.DB, eventID string) (ResourceInput, error) {
var event model.OutboxEvent
if err := tx.WithContext(ctx).Where("event_id = ?", eventID).First(&event).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联 Outbox 事件失败")
}
id := strconv.FormatUint(uint64(event.ID), 10)
return ResourceInput{
Type: constants.AuditResourceOutboxEvent, ID: &id, Key: event.EventID, DisplayName: event.EventID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalOutbox,
IdentitySnapshot: map[string]any{
"event_id": event.EventID, "event_type": event.EventType, "aggregate_type": event.AggregateType,
"aggregate_id": event.AggregateID, "resource_type": event.ResourceType,
"resource_id": event.ResourceID, "business_key": event.BusinessKey,
},
}, nil
}
func approvalState(status *int, externalRef string) map[string]any {
if status == nil {
return nil
}
return map[string]any{"status": *status, "external_ref": externalRef}
}
func statusValue(status *int) any {
if status == nil {
return nil
}
return *status
}

View File

@@ -0,0 +1,46 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// CommissionWithdrawalResource 构造佣金提现单审计资源,不记录收款账户信息。
func CommissionWithdrawalResource(withdrawal *model.CommissionWithdrawalRequest, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(withdrawal.ID), 10)
key := withdrawal.WithdrawalNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceCommissionWithdrawal, ID: optionalResourceID(withdrawal.ID),
Key: key, DisplayName: "佣金提现单 " + key, Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": withdrawal.ID, "withdrawal_no": withdrawal.WithdrawalNo,
"shop_id": withdrawal.ShopID, "applicant_id": withdrawal.ApplicantID,
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "fee_rate": withdrawal.FeeRate,
"actual_amount": withdrawal.ActualAmount, "withdrawal_method": withdrawal.WithdrawalMethod,
"payment_type": withdrawal.PaymentType, "status": withdrawal.Status,
"processor_id": withdrawal.ProcessorID, "processed_at": withdrawal.ProcessedAt, "paid_at": withdrawal.PaidAt,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// AgentWalletResource 构造代理钱包审计资源及余额前后值。
func AgentWalletResource(wallet *model.AgentWallet, relation, role string, beforeData, afterData map[string]any) ResourceInput {
resource := agentWalletAuditResource(wallet, relation, role)
resource.BeforeData = beforeData
resource.AfterData = afterData
return resource
}
// AgentWalletTransactionResource 构造代理钱包流水审计资源。
func AgentWalletTransactionResource(transaction *model.AgentWalletTransaction, relation, role string) ResourceInput {
resource := agentWalletTransactionResource(transaction)
resource.Relation = relation
resource.Role = role
return resource
}

View File

@@ -0,0 +1,62 @@
package audit
import (
"context"
stderrors "errors"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func (w *Writer) RecordFailure(ctx context.Context, db *gorm.DB, input AppendInput, originalErr error) {
fillFailureInput(&input, originalErr)
if w == nil || db == nil {
recordFailureWriteError(ctx, input, pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一审计失败记录接缝未配置"))
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return w.Append(ctx, tx, input)
}); err != nil {
recordFailureWriteError(ctx, input, err)
}
}
func fillFailureInput(input *AppendInput, originalErr error) {
var appErr *pkgerrors.AppError
if stderrors.As(originalErr, &appErr) && appErr != nil {
if input.Result == "" {
input.Result = constants.AuditResultDenied
if appErr.Code == pkgerrors.CodeDatabaseError || appErr.Code == pkgerrors.CodeInternalError {
input.Result = constants.AuditResultFailed
}
}
input.ErrorCode = strconv.Itoa(appErr.Code)
input.ErrorSummary = appErr.Message
return
}
if input.Result == "" {
input.Result = constants.AuditResultFailed
}
input.ErrorCode = strconv.Itoa(pkgerrors.CodeInternalError)
input.ErrorSummary = "业务操作失败"
}
func recordFailureWriteError(ctx context.Context, input AppendInput, err error) {
linkage := auditcontext.From(ctx)
resourceKey := ""
for _, resource := range input.Resources {
if resource.Relation == constants.AuditResourceRelationPrimary {
resourceKey = resource.Key
break
}
}
auditfailure.RecordSecondaryWriteFailure(
input.ActionCode, resourceKey, linkage.RequestID, linkage.CorrelationID, input.ErrorCode, err,
)
}

View File

@@ -0,0 +1,24 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// NotificationResource 构造不包含通知正文的安全资源快照。
func NotificationResource(notification *model.Notification, relation, role string, before, after map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(notification.ID), 10)
return ResourceInput{
Type: constants.AuditResourceNotification, ID: &id, Key: notification.EventID + ":" + notification.RecipientKind + ":" + id,
DisplayName: notification.Type, Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": notification.ID, "event_id": notification.EventID,
"recipient_kind": notification.RecipientKind, "recipient_id": notification.RecipientID,
"category": notification.Category, "type": notification.Type, "severity": notification.Severity,
"ref_type": notification.RefType, "ref_id": notification.RefID, "ref_key": notification.RefKey,
},
BeforeData: before, AfterData: after, SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}

View File

@@ -0,0 +1,218 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PackageSeriesResource 构造套餐系列审计资源。
func PackageSeriesResource(series *model.PackageSeries, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(series.ID), 10)
var resourceID *string
if series.ID > 0 {
resourceID = &id
}
return ResourceInput{
Type: constants.AuditResourcePackageSeries, ID: resourceID, Key: series.SeriesCode, DisplayName: series.SeriesName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName,
"status": series.Status, "enable_one_time_commission": series.EnableOneTimeCommission,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// PackageResource 构造套餐商品审计资源。
func PackageResource(pkg *model.Package, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(pkg.ID), 10)
var resourceID *string
if pkg.ID > 0 {
resourceID = &id
}
return ResourceInput{
Type: constants.AuditResourcePackage, ID: resourceID, Key: pkg.PackageCode, DisplayName: pkg.PackageName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": pkg.ID, "package_code": pkg.PackageCode, "package_name": pkg.PackageName,
"series_id": pkg.SeriesID, "package_type": pkg.PackageType, "duration_months": pkg.DurationMonths,
"duration_days": pkg.DurationDays, "price_config_status": pkg.PriceConfigStatus,
"is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopResource 构造套餐配置关联的店铺审计资源。
func ShopResource(shop *model.Shop, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(shop.ID), 10)
key := shop.ShopCode
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: key, DisplayName: shop.ShopName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName,
"parent_id": shop.ParentID, "level": shop.Level,
},
}
}
// ShopSeriesAllocationResource 构造店铺系列授权审计资源。
func ShopSeriesAllocationResource(allocation *model.ShopSeriesAllocation, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(allocation.ID), 10)
var resourceID *string
if allocation.ID > 0 {
resourceID = &id
}
key := id
if allocation.ID == 0 {
key = "shop-series-" + strconv.FormatUint(uint64(allocation.ShopID), 10) + "-" + strconv.FormatUint(uint64(allocation.SeriesID), 10)
}
return ResourceInput{
Type: constants.AuditResourceShopSeriesAllocation, ID: resourceID, Key: key, DisplayName: "系列授权 " + key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": allocation.ID, "shop_id": allocation.ShopID, "series_id": allocation.SeriesID,
"allocator_shop_id": allocation.AllocatorShopID, "status": allocation.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopPackageAllocationResource 构造店铺套餐授权审计资源。
func ShopPackageAllocationResource(allocation *model.ShopPackageAllocation, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(allocation.ID), 10)
return ResourceInput{
Type: constants.AuditResourceShopPackageAllocation, ID: &id, Key: id, DisplayName: "套餐授权 " + id,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": allocation.ID, "shop_id": allocation.ShopID, "package_id": allocation.PackageID,
"allocator_shop_id": allocation.AllocatorShopID, "series_allocation_id": allocation.SeriesAllocationID,
"status": allocation.Status, "shelf_status": allocation.ShelfStatus,
"retail_price_config_status": allocation.RetailPriceConfigStatus,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopPackagePriceHistoryResource 构造套餐价格历史审计资源。
func ShopPackagePriceHistoryResource(history *model.ShopPackageAllocationPriceHistory) ResourceInput {
id := strconv.FormatUint(uint64(history.ID), 10)
return ResourceInput{
Type: constants.AuditResourceShopPackagePriceHistory, ID: &id, Key: id, DisplayName: "价格历史 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePackagePriceHistory,
IdentitySnapshot: map[string]any{
"id": history.ID, "allocation_id": history.AllocationID, "changed_by": history.ChangedBy,
"effective_from": history.EffectiveFrom,
},
AfterData: map[string]any{
"old_cost_price": history.OldCostPrice, "new_cost_price": history.NewCostPrice,
"change_reason": history.ChangeReason,
},
}
}
// PackageConfigBatchResource 构造套餐配置批次根资源。
func PackageConfigBatchResource(batchKey, operation string, shopID, seriesID uint) ResourceInput {
return ResourceInput{
Type: constants.AuditResourcePackageConfigBatch, Key: batchKey, DisplayName: batchKey,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageConfigBatch,
IdentitySnapshot: map[string]any{
"batch_key": batchKey, "operation": operation, "shop_id": shopID, "series_id": seriesID,
},
}
}
// PackageUsageResource 构造套餐权益生命周期审计资源。
func PackageUsageResource(usage *model.PackageUsage, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(usage.ID), 10)
return ResourceInput{
Type: constants.AuditResourcePackageUsage, ID: &id, Key: id, DisplayName: usage.PackageName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": usage.ID, "order_id": usage.OrderID, "order_no": usage.OrderNo,
"refund_id": usage.RefundID, "refund_no": usage.RefundNo,
"package_id": usage.PackageID, "package_name": usage.PackageName, "usage_type": usage.UsageType,
"iot_card_id": usage.IotCardID, "device_id": usage.DeviceID,
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, "status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
"last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt, "generation": usage.Generation,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// OrderResource 构造订单审计资源。
func OrderResource(order *model.Order, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(order.ID), 10)
var resourceID *string
if order.ID > 0 {
resourceID = &id
}
key := order.OrderNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceOrder, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": order.ID, "order_no": order.OrderNo, "order_type": order.OrderType,
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
"iot_card_id": order.IotCardID, "device_id": order.DeviceID,
"asset_identifier": order.AssetIdentifier, "total_amount": order.TotalAmount,
"actual_paid_amount": order.ActualPaidAmount, "payment_method": order.PaymentMethod,
"payment_status": order.PaymentStatus, "purchase_role": order.PurchaseRole,
"source": order.Source, "operator_account_id": order.OperatorAccountID,
"operator_account_type": order.OperatorAccountType, "operator_account_name": order.OperatorAccountName,
"seller_shop_id": order.SellerShopID, "expires_at": order.ExpiresAt,
},
}
}
// PaymentResource 构造支付记录审计资源。
func PaymentResource(payment *model.Payment, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(payment.ID), 10)
var resourceID *string
if payment.ID > 0 {
resourceID = &id
}
key := payment.PaymentNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourcePayment, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": payment.ID, "payment_no": payment.PaymentNo, "order_id": payment.OrderID,
"order_type": payment.OrderType, "payment_method": payment.PaymentMethod,
"amount": payment.Amount, "status": payment.Status,
"third_party_trade_no": payment.ThirdPartyTradeNo, "payment_config_id": payment.PaymentConfigID,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// RefundResource 构造退款单审计资源。
func RefundResource(refund *model.RefundRequest, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(refund.ID), 10)
var resourceID *string
if refund.ID > 0 {
resourceID = &id
}
key := refund.RefundNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID,
"order_no": refund.OrderNo, "order_type": refund.OrderType,
"package_usage_id": refund.PackageUsageID, "asset_identifier": refund.AssetIdentifier,
"shop_id": refund.ShopID, "requested_refund_amount": refund.RequestedRefundAmount,
"actual_received_amount": refund.ActualReceivedAmount, "refund_reason": refund.RefundReason,
"approved_refund_amount": refund.ApprovedRefundAmount, "approval_instance_id": refund.ApprovalInstanceID,
"status": refund.Status, "commission_deducted": refund.CommissionDeducted, "asset_reset": refund.AssetReset,
},
}
}

View File

@@ -0,0 +1,84 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentRechargePayment 将代理充值支付生命周期写入统一 Audit Event。
func (w *Writer) WriteAgentRechargePayment(ctx context.Context, tx *gorm.DB, change agentrecharge.PaymentAudit) error {
if change.Payment == nil || change.Payment.ID == 0 || change.Payment.PaymentNo == "" || change.Recharge == nil || change.Recharge.ID == 0 {
return errors.New(errors.CodeInvalidParam, "代理充值支付审计资源不完整")
}
payment := PaymentResource(change.Payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, change.BeforeData, change.AfterData)
rechargeID := strconv.FormatUint(uint64(change.Recharge.ID), 10)
rechargeRelation := constants.AuditResourceRelationReference
if len(change.RechargeBeforeData) > 0 || len(change.RechargeAfterData) > 0 {
rechargeRelation = constants.AuditResourceRelationAffected
}
rechargeStatus := any(change.Recharge.Status)
if status, ok := change.RechargeAfterData["status"]; ok {
rechargeStatus = status
}
recharge := ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &rechargeID,
Key: change.Recharge.RechargeNo, DisplayName: change.Recharge.RechargeNo,
Relation: rechargeRelation, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": change.Recharge.ID, "recharge_no": change.Recharge.RechargeNo,
"user_id": change.Recharge.UserID,
"shop_id": change.Recharge.ShopID, "agent_wallet_id": change.Recharge.AgentWalletID,
"amount": change.Recharge.Amount, "payment_method": change.Recharge.PaymentMethod,
"payment_channel": change.Recharge.PaymentChannel,
"approval_instance_id": change.Recharge.ApprovalInstanceID, "status": rechargeStatus,
},
BeforeData: change.RechargeBeforeData, AfterData: change.RechargeAfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
resources := []ResourceInput{payment, recharge}
if change.Recharge.UserID > 0 {
var account model.Account
if err := tx.WithContext(ctx).Unscoped().First(&account, change.Recharge.UserID).Error; err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值提交人审计快照失败")
}
if account.ID > 0 {
accountID := strconv.FormatUint(uint64(account.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: account.Username,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: accountIdentity(&account),
})
}
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, change.Recharge.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值店铺审计快照失败")
}
resources = append(resources, ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRechargeShop))
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, change.Recharge.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
return w.Append(ctx, tx, AppendInput{
ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.Payment.PaymentNo, Resources: resources,
})
}

View File

@@ -0,0 +1,73 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// PollingInput 描述轮询配置、规则或人工任务的审计事实。
type PollingInput struct {
EventID string
ActionCode string
Summary string
ResourceType string
ResourceID uint
ResourceKey string
DisplayName string
OperatorID uint
IdentitySnapshot map[string]any
BeforeData map[string]any
AfterData map[string]any
Metadata map[string]any
Cards []*model.IotCard
Result string
ErrorCode string
ErrorSummary string
}
// WritePolling 将轮询配置、规则或人工任务转换为统一 Audit Event。
func (w *Writer) WritePolling(ctx context.Context, tx *gorm.DB, input PollingInput) error {
if input.OperatorID == 0 || input.ResourceType == "" || input.ResourceKey == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "轮询审计资源或操作者不完整")
}
resourceID := optionalResourceID(input.ResourceID)
resources := []ResourceInput{{
Type: input.ResourceType, ID: resourceID, Key: input.ResourceKey, DisplayName: input.DisplayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePollingTarget,
IdentitySnapshot: input.IdentitySnapshot, BeforeData: input.BeforeData, AfterData: input.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}}
for index, card := range input.Cards {
if card == nil || card.ID == 0 {
continue
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceIotCard, ID: optionalResourceID(card.ID),
Key: iotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePollingCard,
IdentitySnapshot: iotCardIdentity(card), SubjectVisibility: constants.AuditSubjectInternalOnly,
SortOrder: index + 1,
})
}
result := input.Result
if result == "" {
result = constants.AuditResultSuccess
}
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: input.ActionCode, Summary: input.Summary,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(input.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary,
Metadata: input.Metadata, Resources: resources,
})
}

View File

@@ -0,0 +1,187 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentRecharge 将代理充值申请、终态和实际入账写入统一 Audit Event。
func (w *Writer) WriteAgentRecharge(ctx context.Context, tx *gorm.DB, change agentrecharge.RechargeAudit) error {
if change.Record == nil || change.Record.ID == 0 || change.Record.RechargeNo == "" {
return errors.New(errors.CodeInvalidParam, "代理充值审计资源不完整")
}
resources, err := agentRechargeResources(ctx, tx, change)
if err != nil {
return err
}
return w.Append(ctx, tx, AppendInput{
ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.Record.RechargeNo, Resources: resources,
})
}
func agentRechargeResources(ctx context.Context, tx *gorm.DB, change agentrecharge.RechargeAudit) ([]ResourceInput, error) {
record := change.Record
id := strconv.FormatUint(uint64(record.ID), 10)
primary := ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: record.RechargeNo, DisplayName: record.RechargeNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget,
IdentitySnapshot: map[string]any{
"id": record.ID, "recharge_no": record.RechargeNo, "user_id": record.UserID,
"shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID, "amount": record.Amount,
"payment_method": record.PaymentMethod, "payment_channel": record.PaymentChannel,
"payment_transaction_id": record.PaymentTransactionID, "approval_instance_id": record.ApprovalInstanceID,
"status": record.Status,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
resources := []ResourceInput{primary}
var account model.Account
if record.UserID > 0 {
if err := tx.WithContext(ctx).Unscoped().First(&account, record.UserID).Error; err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值提交人审计快照失败")
}
if account.ID > 0 {
accountID := strconv.FormatUint(uint64(account.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: account.Username,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: accountIdentity(&account),
})
}
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, record.ShopID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值店铺审计快照失败")
}
resources = append(resources, ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRechargeShop))
if change.Payment != nil {
resources = append(resources, PaymentResource(change.Payment, constants.AuditResourceRelationReference, constants.AuditResourceRolePaymentTarget, nil, nil))
}
if change.Approval != nil {
approvalID := strconv.FormatUint(uint64(change.Approval.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceApprovalInstance, ID: &approvalID, Key: approvalID, DisplayName: "审批实例 " + approvalID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeApproval,
IdentitySnapshot: map[string]any{
"id": change.Approval.ID, "business_type": change.Approval.BusinessType,
"business_id": change.Approval.BusinessID, "submitter_account_id": change.Approval.SubmitterAccountID,
"provider": change.Approval.Provider, "external_ref": change.Approval.ExternalRef,
"correlation_id": change.Approval.CorrelationID, "status": change.Approval.Status,
},
})
}
if change.Wallet != nil {
walletID := strconv.FormatUint(uint64(change.Wallet.ID), 10)
relation := constants.AuditResourceRelationReference
if change.Transaction != nil {
relation = constants.AuditResourceRelationAffected
}
wallet := ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
Relation: relation, Role: constants.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{
"id": change.Wallet.ID, "shop_id": change.Wallet.ShopID, "wallet_type": change.Wallet.WalletType,
"currency": change.Wallet.Currency, "status": change.Wallet.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
if change.Transaction != nil {
wallet.BeforeData = map[string]any{"balance": change.Transaction.BalanceBefore}
wallet.AfterData = map[string]any{"balance": change.Transaction.BalanceAfter}
}
resources = append(resources, wallet)
}
if change.Transaction != nil {
transactionID := strconv.FormatUint(uint64(change.Transaction.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAgentWalletTransaction, ID: &transactionID, Key: transactionID, DisplayName: "代理钱包流水 " + transactionID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRechargeWalletTransaction,
IdentitySnapshot: map[string]any{
"id": change.Transaction.ID, "agent_wallet_id": change.Transaction.AgentWalletID,
"shop_id": change.Transaction.ShopID, "transaction_type": change.Transaction.TransactionType,
"transaction_subtype": change.Transaction.TransactionSubtype,
"reference_type": change.Transaction.ReferenceType, "reference_id": change.Transaction.ReferenceID,
"status": change.Transaction.Status,
},
AfterData: map[string]any{
"amount": change.Transaction.Amount, "balance_before": change.Transaction.BalanceBefore,
"balance_after": change.Transaction.BalanceAfter,
},
})
}
return resources, nil
}
// AssetRechargeReferences 构造个人资产充值关联的提交人、钱包和资产资源。
func AssetRechargeReferences(ctx context.Context, tx *gorm.DB, recharge *model.RechargeOrder) ([]ResourceInput, error) {
if recharge == nil || recharge.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "资产充值审计资源不完整")
}
resources := make([]ResourceInput, 0, 3)
var customer model.PersonalCustomer
if err := tx.WithContext(ctx).Unscoped().First(&customer, recharge.UserID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值提交人审计快照失败")
}
customerID := strconv.FormatUint(uint64(customer.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomer, ID: &customerID, Key: customerID, DisplayName: customer.Nickname,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: map[string]any{
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
"wx_union_id": customer.WxUnionID, "status": customer.Status,
},
})
var wallet model.AssetWallet
if err := tx.WithContext(ctx).First(&wallet, recharge.AssetWalletID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值钱包审计快照失败")
}
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeWallet,
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,
},
})
switch recharge.ResourceType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Unscoped().First(&card, recharge.ResourceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值卡审计快照失败")
}
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: IotCardResourceKey(&card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
IdentitySnapshot: IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: "资产充值状态已更新",
})
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).Unscoped().First(&device, recharge.ResourceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值设备审计快照失败")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceDevice, ID: &deviceID, Key: DeviceResourceKey(&device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
IdentitySnapshot: DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: "资产充值状态已更新",
})
}
return resources, nil
}

View File

@@ -0,0 +1,128 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteRefundApplication 将退款申请、审批和关联业务资源写入同一事务。
func (w *Writer) WriteRefundApplication(ctx context.Context, tx *gorm.DB, input refundapprovalapp.ApplicationAudit) error {
if input.Refund == nil || input.Order == nil || input.Approval == nil || input.Submitter == nil {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "退款申请审计资源不完整")
}
primary := RefundResource(input.Refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
primary.AfterData = refundStateData(input.Refund)
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = "退款申请已提交"
resources := []ResourceInput{
primary,
OrderResource(input.Order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder),
ApprovalInstanceResource(input.Approval, constants.AuditResourceRelationAffected, constants.AuditResourceRoleRefundApproval, nil, map[string]any{"status": input.Approval.Status}),
AccountResource(input.Submitter, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundSubmitter),
}
for index := 1; index < len(resources); index++ {
resources[index].SubjectVisibility = constants.AuditSubjectInternalOnly
}
asset, err := RefundAssetResource(ctx, tx, input.Order, "退款申请已提交")
if err != nil {
return err
}
if asset != nil {
resources = append(resources, *asset)
}
return w.Append(ctx, tx, AppendInput{
EventID: "refund:" + strconv.FormatUint(uint64(input.Refund.ID), 10) + ":created",
ActionCode: constants.AuditActionRefundCreated, Summary: "提交退款申请",
Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(input.Submitter.ID), 10), Name: input.Submitter.Username},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: input.Refund.RefundNo,
Metadata: map[string]any{"requested_refund_amount": input.Refund.RequestedRefundAmount},
Resources: resources,
})
}
// ApprovalInstanceResource 构造审批实例审计资源。
func ApprovalInstanceResource(instance *model.ApprovalInstance, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(instance.ID), 10)
return ResourceInput{
Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": instance.ID, "business_type": instance.BusinessType, "business_id": instance.BusinessID,
"provider": instance.Provider, "external_ref": instance.ExternalRef, "status": instance.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// AccountResource 构造退款链路中的后台账号资源。
func AccountResource(account *model.Account, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(account.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAccount, ID: &id, Key: id, DisplayName: account.Username,
Relation: relation, Role: role, IdentitySnapshot: accountIdentity(account),
}
}
// RefundAssetResource 构造退款订单实际关联的卡或设备资源。
func RefundAssetResource(ctx context.Context, tx *gorm.DB, order *model.Order, subjectSummary string) (*ResourceInput, error) {
if order.IotCardID != nil {
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, *order.IotCardID).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询退款关联卡审计快照失败")
}
id := strconv.FormatUint(uint64(card.ID), 10)
return &ResourceInput{
Type: constants.AuditResourceIotCard, ID: &id, Key: IotCardResourceKey(&card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundAsset,
IdentitySnapshot: IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: subjectSummary,
}, nil
}
if order.DeviceID != nil {
var device model.Device
if err := tx.WithContext(ctx).First(&device, *order.DeviceID).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询退款关联设备审计快照失败")
}
id := strconv.FormatUint(uint64(device.ID), 10)
return &ResourceInput{
Type: constants.AuditResourceDevice, ID: &id, Key: DeviceResourceKey(&device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundAsset,
IdentitySnapshot: DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: subjectSummary,
}, nil
}
return nil, nil
}
// CommissionRecordResource 构造退款失效的佣金记录资源。
func CommissionRecordResource(record *model.CommissionRecord, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(record.ID), 10)
return ResourceInput{
Type: constants.AuditResourceCommissionRecord, ID: &id, Key: id, DisplayName: "佣金记录 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundCommission,
IdentitySnapshot: map[string]any{
"id": record.ID, "shop_id": record.ShopID, "order_id": record.OrderID,
"iot_card_id": record.IotCardID, "device_id": record.DeviceID,
"commission_source": record.CommissionSource, "amount": record.Amount,
"status": record.Status, "released_at": record.ReleasedAt,
},
BeforeData: beforeData, AfterData: afterData,
}
}
func refundStateData(refund *model.RefundRequest) map[string]any {
return map[string]any{
"status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount,
"approval_instance_id": refund.ApprovalInstanceID, "processor_id": refund.ProcessorID,
"processed_at": refund.ProcessedAt, "commission_deducted": refund.CommissionDeducted,
"asset_reset": refund.AssetReset, "reject_reason": refund.RejectReason, "remark": refund.Remark,
}
}

View File

@@ -17,6 +17,13 @@ type ActionDefinition struct {
AllowedVisibility []string
SubjectFields []string
SensitiveRead bool
AllowedOrigins []ActionOrigin
}
// ActionOrigin 定义动作允许的操作者与入口组合。
type ActionOrigin struct {
Actor string
Source string
}
// ResourceDefinition 是受控审计资源的快照契约。
@@ -70,6 +77,76 @@ func NewRegistry() *Registry {
personalPhoneBound := personalAction(constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", []string{"phone"})
personalPhoneChanged := personalAction(constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", []string{"phone"})
personalWechatIdentityUpdated := personalAction(constants.AuditActionPersonalCustomerWechatIdentityUpdated, "同步个人微信主体", []string{"app_id", "app_type"})
personalAssetBound := personalAction(constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", []string{"asset_type", "asset_id"})
personalAssetBound.Category = constants.AuditCategoryAsset
personalAssetUnbound := customerAssetAdminAction(constants.AuditActionPersonalCustomerAssetUnbound, "解除个人客户资产绑定")
personalAssetBindingMigrated := customerAssetAdminAction(constants.AuditActionPersonalCustomerAssetBindingMigrated, "迁移个人客户资产绑定")
iotCardCreated := iotCardAction(constants.AuditActionIotCardCreated, "创建 IoT 卡", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardDeleted := iotCardAction(constants.AuditActionIotCardDeleted, "删除 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardDeleted.Risk = constants.AuditRiskHigh
iotCardBatchDeleted := ActionDefinition{
Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡",
Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceIotCardBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
iotCardAllocationBatch := iotCardBatchAction(constants.AuditActionIotCardAllocationBatch, "批量分配 IoT 卡")
iotCardAllocated := iotCardAction(constants.AuditActionIotCardAllocated, "分配 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRecallBatch := iotCardBatchAction(constants.AuditActionIotCardRecallBatch, "批量回收 IoT 卡")
iotCardRecalled := iotCardAction(constants.AuditActionIotCardRecalled, "回收 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardSeriesBindingBatch := iotCardBatchAction(constants.AuditActionIotCardSeriesBindingBatch, "批量设置 IoT 卡系列绑定")
iotCardSeriesBound := iotCardAction(constants.AuditActionIotCardSeriesBound, "设置 IoT 卡系列绑定", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardSpeedTierSet := iotCardAction(constants.AuditActionIotCardSpeedTierSet, "设置 IoT 卡固定限速档位", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnamePolicyBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardRealnamePolicyBatchUpdated, "批量更新 IoT 卡实名策略")
iotCardRealnamePolicyUpdated := iotCardAction(constants.AuditActionIotCardRealnamePolicyUpdated, "更新 IoT 卡实名策略", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnameStatusUpdated := iotCardAction(constants.AuditActionIotCardRealnameStatusUpdated, "人工更新 IoT 卡实名状态", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnameCallbackSynced := iotCardAction(constants.AuditActionIotCardRealnameCallbackSynced, "运营商回调同步 IoT 卡实名状态", constants.AuditActorExternalSystem, constants.AuditSourceCallback)
iotCardManualRefreshed := iotCardAction(constants.AuditActionIotCardManualRefreshed, "人工刷新 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardPersonalRefreshed := iotCardAction(constants.AuditActionIotCardPersonalRefreshed, "个人客户刷新 IoT 卡", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
iotCardManualStopped := iotCardAction(constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardManualStarted := iotCardAction(constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardAutoStopped := iotCardAction(constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardAutoStarted := iotCardAction(constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardOpenAPIStarted := iotCardAction(constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络", constants.AuditActorOpenAPI, constants.AuditSourceOpenAPI)
iotCardAutoStopReasonUpdated := iotCardAction(constants.AuditActionIotCardAutoStopReasonUpdated, "自动更新 IoT 卡停机原因", constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceCreated := deviceAction(constants.AuditActionDeviceCreated, "导入创建设备", constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceDeleted := deviceAction(constants.AuditActionDeviceDeleted, "删除设备", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceDeleted.Risk = constants.AuditRiskHigh
deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备")
deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备")
deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备")
deviceRecalled := deviceMultiOriginAction(constants.AuditActionDeviceRecalled, "回收设备")
deviceSeriesBindingBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceSeriesBindingBatch, "批量设置设备系列绑定")
deviceSeriesBound := deviceMultiOriginAction(constants.AuditActionDeviceSeriesBound, "设置设备系列绑定")
deviceRealnamePolicyBatchUpdated := deviceAccountBatchAction(constants.AuditActionDeviceRealnamePolicyBatchUpdated, "批量更新设备实名策略")
deviceRealnamePolicyUpdated := deviceAction(constants.AuditActionDeviceRealnamePolicyUpdated, "更新设备实名策略", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceStopped := deviceAction(constants.AuditActionDeviceStopped, "停用设备绑定卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceStarted := deviceAction(constants.AuditActionDeviceStarted, "恢复设备绑定卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceWiFiSet := deviceExternalAction(constants.AuditActionDeviceWiFiSet, "设置设备 Wi-Fi", false)
deviceSwitchModeSet := deviceAction(constants.AuditActionDeviceSwitchModeSet, "设置设备切卡模式", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceRebooted := deviceExternalAction(constants.AuditActionDeviceRebooted, "重启设备", true)
deviceReset := deviceExternalAction(constants.AuditActionDeviceReset, "恢复设备出厂设置", true)
deviceCardBound := deviceAction(constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCardUnbound := deviceAction(constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCurrentCardSwitched := deviceExternalAction(constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", true)
cardExchangeCreated := cardExchangeAction(constants.AuditActionCardExchangeCreated, "创建卡换货单", constants.AuditRiskNormal, false)
cardExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息", constants.AuditRiskHigh, true)
cardExchangeShipped := cardExchangeAction(constants.AuditActionCardExchangeShipped, "卡换货发货", constants.AuditRiskNormal, false)
cardExchangeCompleted := cardExchangeAction(constants.AuditActionCardExchangeCompleted, "完成卡换货", constants.AuditRiskHigh, false)
cardExchangeCancelled := cardExchangeAction(constants.AuditActionCardExchangeCancelled, "取消卡换货", constants.AuditRiskNormal, false)
cardExchangeRenewed := cardExchangeAction(constants.AuditActionCardExchangeRenewed, "换出旧卡转新", constants.AuditRiskHigh, false)
cardExchangeRenewed.DefaultVisibility = constants.AuditSubjectInternalOnly
cardExchangeRenewed.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
deviceExchangeCreated := cardExchangeAction(constants.AuditActionDeviceExchangeCreated, "创建设备换货单", constants.AuditRiskNormal, false)
deviceExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionDeviceExchangeShippingInfoSubmitted, "提交设备换货收货信息", constants.AuditRiskHigh, true)
deviceExchangeShipped := cardExchangeAction(constants.AuditActionDeviceExchangeShipped, "设备换货发货", constants.AuditRiskNormal, false)
deviceExchangeCompleted := cardExchangeAction(constants.AuditActionDeviceExchangeCompleted, "完成设备换货", constants.AuditRiskHigh, false)
deviceExchangeCancelled := cardExchangeAction(constants.AuditActionDeviceExchangeCancelled, "取消设备换货", constants.AuditRiskNormal, false)
deviceExchangeRenewed := cardExchangeAction(constants.AuditActionDeviceExchangeRenewed, "换出旧设备转新", constants.AuditRiskHigh, false)
deviceExchangeRenewed.DefaultVisibility = constants.AuditSubjectInternalOnly
deviceExchangeRenewed.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
systemConfigUpdated := ActionDefinition{
Code: constants.AuditActionSystemConfigUpdated, Name: "更新受控系统配置",
Category: constants.AuditCategoryConfiguration, Risk: constants.AuditRiskHigh,
@@ -78,6 +155,19 @@ func NewRegistry() *Registry {
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
paymentConfigCreated := connectionConfigAction(constants.AuditActionPaymentConfigCreated, "创建支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigUpdated := connectionConfigAction(constants.AuditActionPaymentConfigUpdated, "更新支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigDeleted := connectionConfigAction(constants.AuditActionPaymentConfigDeleted, "删除支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigActivated := connectionConfigAction(constants.AuditActionPaymentConfigActivated, "激活支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigDeactivated := connectionConfigAction(constants.AuditActionPaymentConfigDeactivated, "停用支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
carrierCreated := connectionConfigAction(constants.AuditActionCarrierCreated, "创建运营商配置", constants.AuditResourceCarrier, constants.AuditRiskNormal)
carrierUpdated := connectionConfigAction(constants.AuditActionCarrierUpdated, "更新运营商配置", constants.AuditResourceCarrier, constants.AuditRiskNormal)
carrierDeleted := connectionConfigAction(constants.AuditActionCarrierDeleted, "删除运营商配置", constants.AuditResourceCarrier, constants.AuditRiskHigh)
carrierStatusUpdated := connectionConfigAction(constants.AuditActionCarrierStatusUpdated, "更新运营商配置状态", constants.AuditResourceCarrier, constants.AuditRiskHigh)
wecomApplicationSaved := connectionConfigAction(constants.AuditActionWeComApplicationSaved, "保存企业微信应用配置", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal)
wecomApprovalSceneSaved := connectionConfigAction(constants.AuditActionWeComApprovalSceneSaved, "保存企业微信审批场景配置", constants.AuditResourceWeComApprovalScene, constants.AuditRiskHigh)
outboxReplayed := outboxRecoveryAction(
constants.AuditActionOutboxReplayed,
"人工重放 Outbox 事件",
@@ -97,6 +187,37 @@ func NewRegistry() *Registry {
constants.AuditResourceDevice,
)
deviceBatchItem.AllowedVisibility = []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}
iotCardImportTaskCreated := taskAction(constants.AuditActionIotCardImportTaskCreated, "创建 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardImportTaskCompleted := taskAction(constants.AuditActionIotCardImportTaskCompleted, "完成 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceImportTaskCreated := taskAction(constants.AuditActionDeviceImportTaskCreated, "创建设备导入任务", constants.AuditResourceDeviceImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceImportTaskCompleted := taskAction(constants.AuditActionDeviceImportTaskCompleted, "完成设备导入任务", constants.AuditResourceDeviceImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
assetPackageBatchOrderTaskCreated := taskAction(constants.AuditActionAssetPackageBatchOrderTaskCreated, "创建资产套餐批量订购任务", constants.AuditResourceAssetPackageBatchOrderTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
assetPackageBatchOrderTaskCompleted := taskAction(constants.AuditActionAssetPackageBatchOrderTaskCompleted, "完成资产套餐批量订购任务", constants.AuditResourceAssetPackageBatchOrderTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
orderPackageInvalidateTaskCreated := taskAction(constants.AuditActionOrderPackageInvalidateTaskCreated, "创建订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
orderPackageInvalidateTaskCompleted := taskAction(constants.AuditActionOrderPackageInvalidateTaskCompleted, "完成订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
orderPackageInvalidateItem := taskAction(constants.AuditActionOrderPackageInvalidateItem, "失效订单套餐权益", constants.AuditResourceOrder, constants.AuditActorSystemTask, constants.AuditSourceWorker)
exportTaskCreated := taskAction(constants.AuditActionExportTaskCreated, "创建业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
exportTaskCancelled := taskAction(constants.AuditActionExportTaskCancelled, "取消业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationDelivered := notificationAction(constants.AuditActionNotificationDelivered, "生成站内通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationRead := notificationAction(constants.AuditActionNotificationRead, "标记通知已读", constants.AuditResourceNotification, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationRead.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationReadAll := notificationAction(constants.AuditActionNotificationReadAll, "批量标记通知已读", constants.AuditResourceNotificationReadBatch, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationReadAll.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationCleanup := notificationAction(constants.AuditActionNotificationCleanup, "清理过期通知", constants.AuditResourceNotificationCleanupBatch, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationCleanupItem := notificationAction(constants.AuditActionNotificationCleanupItem, "清理单条过期通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
pollingConfigCreated := pollingAction(constants.AuditActionPollingConfigCreated, "创建轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigStatusUpdated := pollingAction(constants.AuditActionPollingConfigStatusUpdated, "更新轮询配置状态", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConcurrencyUpdated := pollingAction(constants.AuditActionPollingConcurrencyUpdated, "更新轮询并发配置", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingConcurrencyReset := pollingAction(constants.AuditActionPollingConcurrencyReset, "重置轮询并发计数", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingAlertRuleCreated := pollingAction(constants.AuditActionPollingAlertRuleCreated, "创建轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingAlertRuleUpdated := pollingAction(constants.AuditActionPollingAlertRuleUpdated, "更新轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingAlertRuleDeleted := pollingAction(constants.AuditActionPollingAlertRuleDeleted, "删除轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingManualTriggerSingle := pollingAction(constants.AuditActionPollingManualTriggerSingle, "单卡手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualTriggerBatch := pollingAction(constants.AuditActionPollingManualTriggerBatch, "批量手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualTriggerByCondition := pollingAction(constants.AuditActionPollingManualTriggerByCondition, "条件筛选触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualCancelled := pollingAction(constants.AuditActionPollingManualCancelled, "取消手动触发任务", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
wecomCredentialsRead := ActionDefinition{
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
@@ -115,9 +236,121 @@ func NewRegistry() *Registry {
permissionCreated := accessAction(constants.AuditActionPermissionCreated, "创建权限", constants.AuditResourcePermission)
permissionUpdated := accessAction(constants.AuditActionPermissionUpdated, "更新权限", constants.AuditResourcePermission)
permissionDeleted := accessAction(constants.AuditActionPermissionDeleted, "删除权限", constants.AuditResourcePermission)
packageSeriesCreated := packageConfigAction(constants.AuditActionPackageSeriesCreated, "创建套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageSeriesUpdated := packageConfigAction(constants.AuditActionPackageSeriesUpdated, "更新套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageSeriesDeleted := packageConfigAction(constants.AuditActionPackageSeriesDeleted, "删除套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskHigh)
packageSeriesStatusUpdated := packageConfigAction(constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageCreated := packageConfigAction(constants.AuditActionPackageCreated, "创建套餐商品", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageUpdated := packageConfigAction(constants.AuditActionPackageUpdated, "更新套餐商品", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageDeleted := packageConfigAction(constants.AuditActionPackageDeleted, "删除套餐商品", constants.AuditResourcePackage, constants.AuditRiskHigh)
packageStatusUpdated := packageConfigAction(constants.AuditActionPackageStatusUpdated, "更新套餐商品状态", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageShelfStatusUpdated := packageConfigAction(constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态", constants.AuditResourcePackage, constants.AuditRiskNormal)
shopPackageShelfStatusUpdated := packageConfigAction(constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
packageRetailPriceUpdated := packageConfigAction(constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopSeriesGrantCreated := packageConfigAction(constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantUpdated := packageConfigAction(constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantPackagesManaged := packageConfigAction(constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantDeleted := packageConfigAction(constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskHigh)
shopPackageBatchAllocated := packageConfigAction(constants.AuditActionShopPackageBatchAllocated, "批量分配店铺套餐", constants.AuditResourcePackageConfigBatch, constants.AuditRiskNormal)
shopPackageAllocated := packageConfigAction(constants.AuditActionShopPackageAllocated, "分配店铺套餐", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopPackageExpiryBaseUpdated := packageConfigAction(constants.AuditActionShopPackageExpiryBaseUpdated, "更新店铺套餐生效条件", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopPackageBatchPricingUpdated := packageConfigAction(constants.AuditActionShopPackageBatchPricingUpdated, "批量更新店铺套餐成本价", constants.AuditResourcePackageConfigBatch, constants.AuditRiskNormal)
shopPackagePricingItemUpdated := packageConfigAction(constants.AuditActionShopPackagePricingItemUpdated, "更新店铺套餐成本价", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
packageUsageActivated := packageUsageAction(constants.AuditActionPackageUsageActivated, "激活套餐权益")
packageUsageExpired := packageUsageAction(constants.AuditActionPackageUsageExpired, "套餐权益到期")
packageUsageTrafficDeducted := packageUsageAction(constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量")
packageUsageTrafficReset := packageUsageAction(constants.AuditActionPackageUsageTrafficReset, "重置套餐权益流量")
packageUsageRefundInvalidated := packageUsageAction(constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益")
packageUsageAssetInvalidated := packageUsageAction(constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益")
orderCreated := orderAction(constants.AuditActionOrderCreated, "创建订单")
orderCancelled := orderAction(constants.AuditActionOrderCancelled, "取消订单")
orderWalletPaid := orderAction(constants.AuditActionOrderWalletPaid, "钱包支付订单")
orderExpiredClosed := orderAction(constants.AuditActionOrderExpiredClosed, "关闭过期订单")
orderOnlinePaid := orderAction(constants.AuditActionOrderOnlinePaid, "第三方支付订单")
orderOnlinePaid.AllowedOrigins = append(orderOnlinePaid.AllowedOrigins, ActionOrigin{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback})
agentWalletOrderDebited := agentWalletOrderAction(constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款")
agentWalletOrderReserved := agentWalletOrderAction(constants.AuditActionAgentWalletOrderReserved, "代理主钱包订单资金预占")
agentWalletOrderReleased := agentWalletOrderAction(constants.AuditActionAgentWalletOrderReleased, "释放代理主钱包订单预占")
agentWalletOrderCompleted := agentWalletOrderAction(constants.AuditActionAgentWalletOrderCompleted, "完成代理主钱包订单预占扣款")
agentWalletBalanceAdjusted := agentWalletAction(constants.AuditActionAgentWalletBalanceAdjusted, "人工调整代理主钱包余额")
agentWalletCreditChanged := agentWalletAction(constants.AuditActionAgentWalletCreditChanged, "调整代理主钱包信用额度")
paymentCreated := paymentAction(constants.AuditActionPaymentCreated, "创建支付记录", false)
paymentConfirmed := paymentAction(constants.AuditActionPaymentConfirmed, "确认支付成功", true)
paymentFailed := paymentAction(constants.AuditActionPaymentFailed, "关闭失败支付记录", false)
agentRechargeCreated := rechargeAction(constants.AuditActionAgentRechargeCreated, "创建代理充值申请", constants.AuditResourceAgentRecharge)
agentRechargeCredited := rechargeAction(constants.AuditActionAgentRechargeCredited, "代理充值资金入账", constants.AuditResourceAgentRecharge)
agentRechargeClosed := rechargeAction(constants.AuditActionAgentRechargeClosed, "关闭代理充值申请", constants.AuditResourceAgentRecharge)
assetRechargeAutoPurchased := rechargeAction(constants.AuditActionAssetRechargeAutoPurchased, "充值后自动购包", constants.AuditResourceRechargeOrder)
refundCreated := refundAction(constants.AuditActionRefundCreated, "提交退款申请", false)
refundApproved := refundAction(constants.AuditActionRefundApproved, "通过退款审批", true)
refundRejected := refundAction(constants.AuditActionRefundRejected, "拒绝退款审批", true)
refundReturned := refundAction(constants.AuditActionRefundReturned, "退回退款申请", false)
refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false)
refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金")
refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理")
approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
approvalSubmissionSynced := approvalAction(constants.AuditActionApprovalSubmissionSynced, "同步审批提交结果", []ActionOrigin{
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
})
approvalSubmissionRecovered := approvalAction(constants.AuditActionApprovalSubmissionRecovered, "恢复审批提交结果", []ActionOrigin{
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
})
approvalDecisionSynced := approvalAction(constants.AuditActionApprovalDecisionSynced, "同步审批权威终态", []ActionOrigin{
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
approvalDecisionSynced.Risk = constants.AuditRiskHigh
commissionCalculated := ActionDefinition{
Code: constants.AuditActionCommissionCalculated, Name: "计算订单佣金",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceOrder, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
commissionCredited := ActionDefinition{
Code: constants.AuditActionCommissionCredited, Name: "佣金入账",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionRecord, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
},
}
commissionInvalidated := ActionDefinition{
Code: constants.AuditActionCommissionInvalidated, Name: "失效待审佣金",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionRecord, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
withdrawalRequested := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRequested, "提交佣金提现申请")
withdrawalApproved := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请")
withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请")
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
constants.AuditOperationPaymentConfigCreate: paymentConfigCreated,
constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated,
constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted,
constants.AuditOperationPaymentConfigActivate: paymentConfigActivated,
constants.AuditOperationPaymentConfigDeactivate: paymentConfigDeactivated,
constants.AuditOperationCarrierCreate: carrierCreated,
constants.AuditOperationCarrierUpdate: carrierUpdated,
constants.AuditOperationCarrierDelete: carrierDeleted,
constants.AuditOperationCarrierStatusUpdate: carrierStatusUpdated,
constants.AuditOperationWeComApplicationSave: wecomApplicationSaved,
constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved,
constants.AuditOperationWeComMembersSync: wecomMembersSynced,
constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved,
constants.AuditOperationOutboxReplay: outboxReplayed,
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
},
@@ -155,11 +388,109 @@ func NewRegistry() *Registry {
constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound,
constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged,
constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated,
constants.AuditActionPersonalCustomerAssetBound: personalAssetBound,
constants.AuditActionPersonalCustomerAssetUnbound: personalAssetUnbound,
constants.AuditActionPersonalCustomerAssetBindingMigrated: personalAssetBindingMigrated,
constants.AuditActionIotCardCreated: iotCardCreated,
constants.AuditActionIotCardDeleted: iotCardDeleted,
constants.AuditActionIotCardBatchDeleted: iotCardBatchDeleted,
constants.AuditActionIotCardAllocationBatch: iotCardAllocationBatch,
constants.AuditActionIotCardAllocated: iotCardAllocated,
constants.AuditActionIotCardRecallBatch: iotCardRecallBatch,
constants.AuditActionIotCardRecalled: iotCardRecalled,
constants.AuditActionIotCardSeriesBindingBatch: iotCardSeriesBindingBatch,
constants.AuditActionIotCardSeriesBound: iotCardSeriesBound,
constants.AuditActionIotCardSpeedTierSet: iotCardSpeedTierSet,
constants.AuditActionIotCardRealnamePolicyBatchUpdated: iotCardRealnamePolicyBatchUpdated,
constants.AuditActionIotCardRealnamePolicyUpdated: iotCardRealnamePolicyUpdated,
constants.AuditActionIotCardRealnameStatusUpdated: iotCardRealnameStatusUpdated,
constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced,
constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed,
constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed,
constants.AuditActionIotCardManualStopped: iotCardManualStopped,
constants.AuditActionIotCardManualStarted: iotCardManualStarted,
constants.AuditActionIotCardAutoStopped: iotCardAutoStopped,
constants.AuditActionIotCardAutoStarted: iotCardAutoStarted,
constants.AuditActionIotCardOpenAPIStarted: iotCardOpenAPIStarted,
constants.AuditActionIotCardAutoStopReasonUpdated: iotCardAutoStopReasonUpdated,
constants.AuditActionDeviceCreated: deviceCreated,
constants.AuditActionDeviceDeleted: deviceDeleted,
constants.AuditActionDeviceAllocationBatch: deviceAllocationBatch,
constants.AuditActionDeviceAllocated: deviceAllocated,
constants.AuditActionDeviceRecallBatch: deviceRecallBatch,
constants.AuditActionDeviceRecalled: deviceRecalled,
constants.AuditActionDeviceSeriesBindingBatch: deviceSeriesBindingBatch,
constants.AuditActionDeviceSeriesBound: deviceSeriesBound,
constants.AuditActionDeviceRealnamePolicyBatchUpdated: deviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated: deviceRealnamePolicyUpdated,
constants.AuditActionDeviceStopped: deviceStopped,
constants.AuditActionDeviceStarted: deviceStarted,
constants.AuditActionDeviceWiFiSet: deviceWiFiSet,
constants.AuditActionDeviceSwitchModeSet: deviceSwitchModeSet,
constants.AuditActionDeviceRebooted: deviceRebooted,
constants.AuditActionDeviceReset: deviceReset,
constants.AuditActionDeviceCardBound: deviceCardBound,
constants.AuditActionDeviceCardUnbound: deviceCardUnbound,
constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched,
constants.AuditActionCardExchangeCreated: cardExchangeCreated,
constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted,
constants.AuditActionCardExchangeShipped: cardExchangeShipped,
constants.AuditActionCardExchangeCompleted: cardExchangeCompleted,
constants.AuditActionCardExchangeCancelled: cardExchangeCancelled,
constants.AuditActionCardExchangeRenewed: cardExchangeRenewed,
constants.AuditActionDeviceExchangeCreated: deviceExchangeCreated,
constants.AuditActionDeviceExchangeShippingInfoSubmitted: deviceExchangeShippingInfoSubmitted,
constants.AuditActionDeviceExchangeShipped: deviceExchangeShipped,
constants.AuditActionDeviceExchangeCompleted: deviceExchangeCompleted,
constants.AuditActionDeviceExchangeCancelled: deviceExchangeCancelled,
constants.AuditActionDeviceExchangeRenewed: deviceExchangeRenewed,
constants.AuditActionSystemConfigUpdated: systemConfigUpdated,
constants.AuditActionPaymentConfigCreated: paymentConfigCreated,
constants.AuditActionPaymentConfigUpdated: paymentConfigUpdated,
constants.AuditActionPaymentConfigDeleted: paymentConfigDeleted,
constants.AuditActionPaymentConfigActivated: paymentConfigActivated,
constants.AuditActionPaymentConfigDeactivated: paymentConfigDeactivated,
constants.AuditActionCarrierCreated: carrierCreated,
constants.AuditActionCarrierUpdated: carrierUpdated,
constants.AuditActionCarrierDeleted: carrierDeleted,
constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated,
constants.AuditActionWeComApplicationSaved: wecomApplicationSaved,
constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved,
constants.AuditActionWeComMembersSynced: wecomMembersSynced,
constants.AuditActionWeComApprovalSceneSaved: wecomApprovalSceneSaved,
constants.AuditActionOutboxReplayed: outboxReplayed,
constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased,
constants.AuditActionDeviceBatchAllocationCompleted: deviceBatchCompleted,
constants.AuditActionDeviceBatchAllocationItem: deviceBatchItem,
constants.AuditActionIotCardImportTaskCreated: iotCardImportTaskCreated,
constants.AuditActionIotCardImportTaskCompleted: iotCardImportTaskCompleted,
constants.AuditActionDeviceImportTaskCreated: deviceImportTaskCreated,
constants.AuditActionDeviceImportTaskCompleted: deviceImportTaskCompleted,
constants.AuditActionAssetPackageBatchOrderTaskCreated: assetPackageBatchOrderTaskCreated,
constants.AuditActionAssetPackageBatchOrderTaskCompleted: assetPackageBatchOrderTaskCompleted,
constants.AuditActionOrderPackageInvalidateTaskCreated: orderPackageInvalidateTaskCreated,
constants.AuditActionOrderPackageInvalidateTaskCompleted: orderPackageInvalidateTaskCompleted,
constants.AuditActionOrderPackageInvalidateItem: orderPackageInvalidateItem,
constants.AuditActionExportTaskCreated: exportTaskCreated,
constants.AuditActionExportTaskCancelled: exportTaskCancelled,
constants.AuditActionNotificationDelivered: notificationDelivered,
constants.AuditActionNotificationRead: notificationRead,
constants.AuditActionNotificationReadAll: notificationReadAll,
constants.AuditActionNotificationCleanup: notificationCleanup,
constants.AuditActionNotificationCleanupItem: notificationCleanupItem,
constants.AuditActionPollingConfigCreated: pollingConfigCreated,
constants.AuditActionPollingConfigUpdated: pollingConfigUpdated,
constants.AuditActionPollingConfigDeleted: pollingConfigDeleted,
constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated,
constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated,
constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset,
constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated,
constants.AuditActionPollingAlertRuleUpdated: pollingAlertRuleUpdated,
constants.AuditActionPollingAlertRuleDeleted: pollingAlertRuleDeleted,
constants.AuditActionPollingManualTriggerSingle: pollingManualTriggerSingle,
constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch,
constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition,
constants.AuditActionPollingManualCancelled: pollingManualCancelled,
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
constants.AuditActionRoleCreated: roleCreated,
constants.AuditActionRoleUpdated: roleUpdated,
@@ -172,6 +503,67 @@ func NewRegistry() *Registry {
constants.AuditActionPermissionCreated: permissionCreated,
constants.AuditActionPermissionUpdated: permissionUpdated,
constants.AuditActionPermissionDeleted: permissionDeleted,
constants.AuditActionPackageSeriesCreated: packageSeriesCreated,
constants.AuditActionPackageSeriesUpdated: packageSeriesUpdated,
constants.AuditActionPackageSeriesDeleted: packageSeriesDeleted,
constants.AuditActionPackageSeriesStatusUpdated: packageSeriesStatusUpdated,
constants.AuditActionPackageCreated: packageCreated,
constants.AuditActionPackageUpdated: packageUpdated,
constants.AuditActionPackageDeleted: packageDeleted,
constants.AuditActionPackageStatusUpdated: packageStatusUpdated,
constants.AuditActionPackageShelfStatusUpdated: packageShelfStatusUpdated,
constants.AuditActionShopPackageShelfStatusUpdated: shopPackageShelfStatusUpdated,
constants.AuditActionPackageRetailPriceUpdated: packageRetailPriceUpdated,
constants.AuditActionShopSeriesGrantCreated: shopSeriesGrantCreated,
constants.AuditActionShopSeriesGrantUpdated: shopSeriesGrantUpdated,
constants.AuditActionShopSeriesGrantPackagesManaged: shopSeriesGrantPackagesManaged,
constants.AuditActionShopSeriesGrantDeleted: shopSeriesGrantDeleted,
constants.AuditActionShopPackageBatchAllocated: shopPackageBatchAllocated,
constants.AuditActionShopPackageAllocated: shopPackageAllocated,
constants.AuditActionShopPackageExpiryBaseUpdated: shopPackageExpiryBaseUpdated,
constants.AuditActionShopPackageBatchPricingUpdated: shopPackageBatchPricingUpdated,
constants.AuditActionShopPackagePricingItemUpdated: shopPackagePricingItemUpdated,
constants.AuditActionPackageUsageActivated: packageUsageActivated,
constants.AuditActionPackageUsageExpired: packageUsageExpired,
constants.AuditActionPackageUsageTrafficDeducted: packageUsageTrafficDeducted,
constants.AuditActionPackageUsageTrafficReset: packageUsageTrafficReset,
constants.AuditActionPackageUsageRefundInvalidated: packageUsageRefundInvalidated,
constants.AuditActionPackageUsageAssetInvalidated: packageUsageAssetInvalidated,
constants.AuditActionOrderCreated: orderCreated,
constants.AuditActionOrderCancelled: orderCancelled,
constants.AuditActionOrderWalletPaid: orderWalletPaid,
constants.AuditActionOrderExpiredClosed: orderExpiredClosed,
constants.AuditActionOrderOnlinePaid: orderOnlinePaid,
constants.AuditActionAgentWalletOrderDebited: agentWalletOrderDebited,
constants.AuditActionAgentWalletOrderReserved: agentWalletOrderReserved,
constants.AuditActionAgentWalletOrderReleased: agentWalletOrderReleased,
constants.AuditActionAgentWalletOrderCompleted: agentWalletOrderCompleted,
constants.AuditActionAgentWalletBalanceAdjusted: agentWalletBalanceAdjusted,
constants.AuditActionAgentWalletCreditChanged: agentWalletCreditChanged,
constants.AuditActionPaymentCreated: paymentCreated,
constants.AuditActionPaymentConfirmed: paymentConfirmed,
constants.AuditActionPaymentFailed: paymentFailed,
constants.AuditActionAgentRechargeCreated: agentRechargeCreated,
constants.AuditActionAgentRechargeCredited: agentRechargeCredited,
constants.AuditActionAgentRechargeClosed: agentRechargeClosed,
constants.AuditActionAssetRechargeAutoPurchased: assetRechargeAutoPurchased,
constants.AuditActionRefundCreated: refundCreated,
constants.AuditActionRefundApproved: refundApproved,
constants.AuditActionRefundRejected: refundRejected,
constants.AuditActionRefundReturned: refundReturned,
constants.AuditActionRefundResubmitted: refundResubmitted,
constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated,
constants.AuditActionRefundAssetProcessed: refundAssetProcessed,
constants.AuditActionApprovalRequested: approvalRequested,
constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced,
constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered,
constants.AuditActionApprovalDecisionSynced: approvalDecisionSynced,
constants.AuditActionCommissionCalculated: commissionCalculated,
constants.AuditActionCommissionCredited: commissionCredited,
constants.AuditActionCommissionInvalidated: commissionInvalidated,
constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested,
constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved,
constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -190,6 +582,18 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceSystemConfig, Name: "受控系统配置",
IdentityFields: []string{"config_key", "module"},
},
constants.AuditResourcePaymentConfig: {
Type: constants.AuditResourcePaymentConfig, Name: "支付连接配置",
IdentityFields: []string{"id", "name", "provider_type", "is_active", "credentials_configured"},
},
constants.AuditResourceCarrier: {
Type: constants.AuditResourceCarrier, Name: "运营商配置",
IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"},
},
constants.AuditResourceWeComApprovalScene: {
Type: constants.AuditResourceWeComApprovalScene, Name: "企业微信审批场景配置",
IdentityFields: []string{"id", "business_type", "application_id", "template_id", "template_name", "status"},
},
constants.AuditResourceOutboxEvent: {
Type: constants.AuditResourceOutboxEvent, Name: "Outbox 事件",
IdentityFields: []string{
@@ -197,13 +601,68 @@ func NewRegistry() *Registry {
"resource_type", "resource_id", "business_key",
},
},
constants.AuditResourceIntegrationLog: {
Type: constants.AuditResourceIntegrationLog, Name: "外部集成日志",
IdentityFields: []string{
"integration_id", "provider", "direction", "operation", "result", "external_id",
"resource_type", "resource_id", "resource_key", "correlation_id",
},
},
constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"},
},
constants.AuditResourceIotCardImportTask: {
Type: constants.AuditResourceIotCardImportTask, Name: "IoT 卡导入任务",
IdentityFields: []string{"id", "task_no", "file_name", "carrier_id", "carrier_name", "batch_no", "card_category", "realname_policy"},
},
constants.AuditResourceDeviceImportTask: {
Type: constants.AuditResourceDeviceImportTask, Name: "设备导入任务",
IdentityFields: []string{"id", "task_no", "file_name", "operation_type", "target_id", "batch_no", "realname_policy"},
},
constants.AuditResourceAssetPackageBatchOrderTask: {
Type: constants.AuditResourceAssetPackageBatchOrderTask, Name: "资产套餐批量订购任务",
IdentityFields: []string{"id", "task_no", "file_name", "package_id", "package_code", "package_name", "payment_method"},
},
constants.AuditResourceOrderPackageInvalidateTask: {
Type: constants.AuditResourceOrderPackageInvalidateTask, Name: "订单套餐批量失效任务",
IdentityFields: []string{"id", "task_no", "file_name"},
},
constants.AuditResourceExportTask: {
Type: constants.AuditResourceExportTask, Name: "业务导出任务",
IdentityFields: []string{"id", "task_no", "scene", "format", "creator_user_id", "creator_user_type", "creator_shop_id", "creator_enterprise_id", "scope_shop_ids"},
},
constants.AuditResourceNotification: {
Type: constants.AuditResourceNotification, Name: "站内通知",
IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"},
},
constants.AuditResourceNotificationReadBatch: {
Type: constants.AuditResourceNotificationReadBatch, Name: "通知批量已读",
IdentityFields: []string{"recipient_kind", "recipient_id", "category", "updated_count"},
},
constants.AuditResourceNotificationCleanupBatch: {
Type: constants.AuditResourceNotificationCleanupBatch, Name: "通知清理批次",
IdentityFields: []string{"category", "cutoff", "deleted_count", "first_id", "last_id"},
},
constants.AuditResourcePollingConfig: {
Type: constants.AuditResourcePollingConfig, Name: "轮询配置",
IdentityFields: []string{"id", "config_name", "card_condition", "card_category", "carrier_id", "priority", "status"},
},
constants.AuditResourcePollingConcurrencyConfig: {
Type: constants.AuditResourcePollingConcurrencyConfig, Name: "轮询并发配置",
IdentityFields: []string{"id", "task_type", "max_concurrency"},
},
constants.AuditResourcePollingAlertRule: {
Type: constants.AuditResourcePollingAlertRule, Name: "轮询告警规则",
IdentityFields: []string{"id", "rule_name", "task_type", "metric_type", "operator", "threshold", "alert_level", "status"},
},
constants.AuditResourcePollingManualTrigger: {
Type: constants.AuditResourcePollingManualTrigger, Name: "手动轮询任务",
IdentityFields: []string{"id", "task_type", "trigger_type", "total_count", "status", "triggered_by"},
},
constants.AuditResourceDevice: {
Type: constants.AuditResourceDevice, Name: "设备",
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "generation"},
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "device_name", "device_model", "device_type", "manufacturer", "shop_id", "series_id", "generation"},
},
constants.AuditResourceIotCard: {
Type: constants.AuditResourceIotCard, Name: "IoT卡",
@@ -215,11 +674,11 @@ func NewRegistry() *Registry {
},
constants.AuditResourceOrder: {
Type: constants.AuditResourceOrder, Name: "订单",
IdentityFields: []string{"id", "order_no", "buyer_type", "buyer_id", "asset_identifier", "total_amount", "payment_method", "payment_status"},
IdentityFields: []string{"id", "order_no", "order_type", "buyer_type", "buyer_id", "iot_card_id", "device_id", "asset_identifier", "total_amount", "actual_paid_amount", "payment_method", "payment_status", "purchase_role", "source", "operator_account_id", "operator_account_type", "operator_account_name", "seller_shop_id", "expires_at"},
},
constants.AuditResourceRefund: {
Type: constants.AuditResourceRefund, Name: "退款单",
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "asset_identifier", "shop_id", "requested_refund_amount", "status"},
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",
@@ -233,21 +692,81 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceAssetAllocationRecord, Name: "资产分配记录",
IdentityFields: []string{"id", "allocation_no", "asset_type", "asset_id", "asset_identifier", "from_owner_type", "from_owner_id", "to_owner_type", "to_owner_id"},
},
constants.AuditResourcePackageSeries: {
Type: constants.AuditResourcePackageSeries, Name: "套餐系列",
IdentityFields: []string{"id", "series_code", "series_name", "status", "enable_one_time_commission"},
},
constants.AuditResourcePackage: {
Type: constants.AuditResourcePackage, Name: "套餐商品",
IdentityFields: []string{"id", "package_code", "package_name", "series_id", "package_type", "duration_months", "duration_days", "price_config_status", "is_gift", "status", "shelf_status"},
},
constants.AuditResourceShopSeriesAllocation: {
Type: constants.AuditResourceShopSeriesAllocation, Name: "店铺套餐系列授权",
IdentityFields: []string{"id", "shop_id", "series_id", "allocator_shop_id", "status"},
},
constants.AuditResourceShopPackageAllocation: {
Type: constants.AuditResourceShopPackageAllocation, Name: "店铺套餐授权",
IdentityFields: []string{"id", "shop_id", "package_id", "allocator_shop_id", "series_allocation_id", "status", "shelf_status", "retail_price_config_status"},
},
constants.AuditResourceShopPackagePriceHistory: {
Type: constants.AuditResourceShopPackagePriceHistory, Name: "店铺套餐价格历史",
IdentityFields: []string{"id", "allocation_id", "changed_by", "effective_from"},
},
constants.AuditResourcePackageConfigBatch: {
Type: constants.AuditResourcePackageConfigBatch, Name: "套餐配置批次",
IdentityFields: []string{"batch_key", "operation", "shop_id", "series_id"},
},
constants.AuditResourceExchangeOrder: {
Type: constants.AuditResourceExchangeOrder, Name: "换货单",
IdentityFields: []string{"id", "exchange_no", "old_asset_type", "old_asset_id", "new_asset_type", "new_asset_id", "shop_id", "status"},
IdentityFields: []string{"id", "exchange_no", "flow_type", "old_asset_type", "old_asset_id", "old_asset_identifier", "new_asset_type", "new_asset_id", "new_asset_identifier", "shop_id", "status"},
},
constants.AuditResourceAgentRecharge: {
Type: constants.AuditResourceAgentRecharge, Name: "代理充值单",
IdentityFields: []string{"id", "recharge_no", "shop_id", "agent_wallet_id", "approval_instance_id", "status"},
IdentityFields: []string{"id", "recharge_no", "user_id", "shop_id", "agent_wallet_id", "amount", "payment_method", "payment_channel", "payment_transaction_id", "approval_instance_id", "status"},
},
constants.AuditResourceRechargeOrder: {
Type: constants.AuditResourceRechargeOrder, Name: "资产充值单",
IdentityFields: []string{"id", "recharge_order_no", "user_id", "asset_wallet_id", "resource_type", "resource_id", "amount", "status"},
},
constants.AuditResourceAssetWallet: {
Type: constants.AuditResourceAssetWallet, Name: "资产钱包",
IdentityFields: []string{"id", "resource_type", "resource_id", "currency"},
IdentityFields: []string{"id", "resource_type", "resource_id", "currency", "shop_id_tag", "enterprise_id_tag"},
},
constants.AuditResourceAssetWalletTransaction: {
Type: constants.AuditResourceAssetWalletTransaction, Name: "资产钱包流水",
IdentityFields: []string{"id", "asset_wallet_id", "resource_type", "resource_id", "transaction_type", "reference_type", "reference_no", "status"},
},
constants.AuditResourceAgentWallet: {
Type: constants.AuditResourceAgentWallet, Name: "代理主钱包",
IdentityFields: []string{"id", "shop_id", "wallet_type", "currency", "status", "credit_enabled", "credit_limit"},
},
constants.AuditResourceAgentWalletTransaction: {
Type: constants.AuditResourceAgentWalletTransaction, Name: "代理主钱包流水",
IdentityFields: []string{"id", "agent_wallet_id", "shop_id", "transaction_type", "transaction_subtype", "reference_type", "reference_id", "status"},
},
constants.AuditResourceAgentWalletReservation: {
Type: constants.AuditResourceAgentWalletReservation, Name: "代理主钱包预占",
IdentityFields: []string{"id", "agent_wallet_id", "shop_id", "amount", "status", "reference_type", "reference_id"},
},
constants.AuditResourcePayment: {
Type: constants.AuditResourcePayment, Name: "支付记录",
IdentityFields: []string{"id", "payment_no", "order_id", "order_type", "payment_method", "amount", "status", "third_party_trade_no", "payment_config_id"},
},
constants.AuditResourcePackageUsage: {
Type: constants.AuditResourcePackageUsage, Name: "套餐权益",
IdentityFields: []string{"id", "order_id", "order_no", "refund_id", "refund_no", "package_id", "package_name", "usage_type", "iot_card_id", "device_id", "data_limit_mb", "data_usage_mb", "activated_at", "expires_at", "status", "pending_realname_activation", "last_reset_at", "next_reset_at", "generation"},
},
constants.AuditResourceApprovalInstance: {
Type: constants.AuditResourceApprovalInstance, Name: "审批实例",
IdentityFields: []string{"id", "business_type", "business_id", "provider", "external_ref", "status"},
IdentityFields: []string{"id", "business_type", "business_id", "submitter_account_id", "provider", "external_ref", "correlation_id", "status"},
},
constants.AuditResourceCommissionRecord: {
Type: constants.AuditResourceCommissionRecord, Name: "佣金记录",
IdentityFields: []string{"id", "shop_id", "order_id", "iot_card_id", "device_id", "commission_source", "amount", "status", "released_at"},
},
constants.AuditResourceCommissionWithdrawal: {
Type: constants.AuditResourceCommissionWithdrawal, Name: "佣金提现单",
IdentityFields: []string{"id", "withdrawal_no", "shop_id", "applicant_id", "amount", "fee", "fee_rate", "actual_amount", "withdrawal_method", "payment_type", "status", "processor_id", "processed_at", "paid_at"},
},
constants.AuditResourceWeComApplication: {
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
@@ -277,6 +796,22 @@ func NewRegistry() *Registry {
Type: constants.AuditResourcePersonalCustomerOpenID, Name: "个人客户微信主体",
IdentityFields: []string{"id", "customer_id", "app_id", "open_id", "union_id", "app_type"},
},
constants.AuditResourcePersonalCustomerDevice: {
Type: constants.AuditResourcePersonalCustomerDevice, Name: "个人客户设备号绑定",
IdentityFields: []string{"id", "customer_id", "virtual_no", "bind_at", "last_used_at", "status"},
},
constants.AuditResourcePersonalCustomerICCID: {
Type: constants.AuditResourcePersonalCustomerICCID, Name: "个人客户 ICCID 绑定",
IdentityFields: []string{"id", "customer_id", "iccid", "iccid_19", "bind_at", "last_used_at", "status"},
},
constants.AuditResourceIotCardBatch: {
Type: constants.AuditResourceIotCardBatch, Name: "IoT 卡批量操作",
IdentityFields: []string{"request_id", "card_count"},
},
constants.AuditResourceDeviceBatch: {
Type: constants.AuditResourceDeviceBatch, Name: "设备批量操作",
IdentityFields: []string{"request_id", "correlation_id", "device_count", "operation_type"},
},
},
}
}
@@ -352,6 +887,233 @@ func personalAction(code, name string, subjectFields []string) ActionDefinition
}
}
func customerAssetAdminAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePersonalCustomer, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func packageConfigAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func packageUsageAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePackageUsage, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func orderAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceOrder, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func agentWalletOrderAction(code, name string) ActionDefinition {
action := orderAction(code, name)
action.Risk = constants.AuditRiskHigh
return action
}
func agentWalletAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceAgentWallet, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func paymentAction(code, name string, confirmation bool) ActionDefinition {
origins := []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
}
if confirmation {
origins = append(origins, ActionOrigin{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback})
}
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePayment, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: origins,
}
}
func rechargeAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func refundAction(code, name string, allowWorker bool) ActionDefinition {
origins := []ActionOrigin{{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}}
if allowWorker {
origins = append(origins, ActionOrigin{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker})
}
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceRefund, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: origins,
}
}
func refundSystemAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceRefund, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func approvalAction(code, name string, origins []ActionOrigin) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceApprovalInstance, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, AllowedOrigins: origins,
}
}
func commissionWithdrawalAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionWithdrawal,
AllowedActor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
SubjectFields: []string{"amount", "fee", "actual_amount", "withdrawal_method", "payment_type", "status"},
}
}
func iotCardAction(code, name, actorKind, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceIotCard, AllowedActor: actorKind, Source: source,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func deviceAction(code, name, actorKind, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDevice, AllowedActor: actorKind, Source: source,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func deviceMultiOriginAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDevice, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
},
}
}
func deviceExternalAction(code, name string, allowOpenAPI bool) ActionDefinition {
action := deviceAction(code, name, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
action.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
if allowOpenAPI {
action.AllowedOrigins = append(action.AllowedOrigins, ActionOrigin{Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI})
}
return action
}
func cardExchangeAction(code, name, risk string, personal bool) ActionDefinition {
action := ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: constants.AuditResourceExchangeOrder, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
if personal {
action.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
}
return action
}
func deviceMultiOriginBatchAction(code, name string) ActionDefinition {
action := deviceMultiOriginAction(code, name)
action.PrimaryResource = constants.AuditResourceDeviceBatch
action.DefaultVisibility = constants.AuditSubjectInternalOnly
action.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
return action
}
func deviceAccountBatchAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDeviceBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func iotCardBatchAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceIotCardBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func accountLifecycleAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: risk,
@@ -372,6 +1134,34 @@ func deviceBatchAction(code, name, primaryResource string) ActionDefinition {
}
}
func taskAction(code, name, primaryResource, actor, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func notificationAction(code, name, primaryResource, actor, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskLow,
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func pollingAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func outboxRecoveryAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,
@@ -382,6 +1172,16 @@ func outboxRecoveryAction(code, name string) ActionDefinition {
}
}
func connectionConfigAction(code, name, resourceType, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,
PrimaryResource: resourceType, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
// Action 返回已注册动作定义。
func (r *Registry) Action(code string) (ActionDefinition, bool) {
if r == nil {

View File

@@ -0,0 +1,91 @@
package audit
import (
"context"
"crypto/sha256"
"fmt"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TaskInput 描述导入、批购、失效或导出任务的安全审计事实。
type TaskInput struct {
EventID string
ActionCode string
Summary string
TaskID uint
TaskNo string
DisplayName string
Actor ActorInput
Source string
ScopeType string
ScopeID string
ScopeName string
Result string
ErrorCode string
ErrorSummary string
CorrelationID string
ParentEventID string
BatchTotal int
SuccessCount int
FailCount int
IdentitySnapshot map[string]any
BeforeData map[string]any
AfterData map[string]any
Metadata map[string]any
}
// WriteTask 将任务状态与批量统计写入对应的注册任务资源。
func (w *Writer) WriteTask(ctx context.Context, tx *gorm.DB, input TaskInput) error {
if w == nil || w.registry == nil {
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "任务统一审计 Writer 未正确配置")
}
action, ok := w.registry.Action(input.ActionCode)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "任务审计动作未注册")
}
if input.TaskNo == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "任务审计缺少稳定任务编号")
}
var taskID *string
if input.TaskID != 0 {
value := strconv.FormatUint(uint64(input.TaskID), 10)
taskID = &value
}
displayName := input.DisplayName
if displayName == "" {
displayName = input.TaskNo
}
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: input.ActionCode, Summary: input.Summary,
Actor: input.Actor, Source: input.Source,
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: input.ScopeName,
Result: input.Result, ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary,
CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID,
BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount,
Metadata: input.Metadata,
Resources: []ResourceInput{{
Type: action.PrimaryResource, ID: taskID, Key: input.TaskNo, DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: input.IdentitySnapshot, BeforeData: input.BeforeData, AfterData: input.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// TaskEventID 返回可重试任务阶段的稳定审计事件 ID。
func TaskEventID(resourceType string, taskID uint, phase string) string {
if taskID == 0 || phase == "" {
return ""
}
value := fmt.Sprintf("task:%s:%d:%s", resourceType, taskID, phase)
if len(value) <= 64 {
return value
}
digest := sha256.Sum256([]byte(value))
return fmt.Sprintf("task:%x", digest[:16])
}

View File

@@ -0,0 +1,293 @@
package audit
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentWalletBalanceAdjustment 将人工余额调整写入统一 Audit Event。
func (w *Writer) WriteAgentWalletBalanceAdjustment(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
if event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeManualAdjustment ||
event.ReferenceID == 0 || event.TransactionType != constants.AgentTransactionTypeAdjustment ||
event.Amount <= 0 || strings.TrimSpace(event.Remark) == "" {
return errors.New(errors.CodeInvalidParam, "代理主钱包人工调整审计事实不完整")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询人工调整代理主钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Unscoped().Where(
"agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, event.TransactionType, constants.TransactionStatusSuccess,
).First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包人工调整流水失败")
}
walletResource := agentWalletAuditResource(&wallet, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWalletTarget)
walletResource.BeforeData = map[string]any{"balance": transaction.BalanceBefore, "frozen_balance": wallet.FrozenBalance}
walletResource.AfterData = map[string]any{"balance": transaction.BalanceAfter, "frozen_balance": wallet.FrozenBalance}
walletResource.SubjectVisibility = constants.AuditSubjectResult
walletResource.SubjectSummary = "代理主钱包余额已人工调整"
transactionResource := agentWalletTransactionResource(&transaction)
transactionResource.Role = constants.AuditResourceRoleWalletTransaction
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletBalanceAdjusted, Summary: "人工调整代理主钱包余额",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: event.CorrelationID,
Metadata: map[string]any{
"amount": event.Amount, "reason": strings.TrimSpace(event.Remark),
"reference_type": event.ReferenceType, "reference_id": event.ReferenceID,
},
Resources: []ResourceInput{walletResource, transactionResource},
})
}
// WriteAgentWalletCreditChange 将实际信用额度变化写入统一 Audit Event。
func (w *Writer) WriteAgentWalletCreditChange(ctx context.Context, tx *gorm.DB, change walletapp.CreditChangeAudit) error {
if change.Wallet == nil || change.Wallet.ID == 0 || change.Wallet.ShopID == 0 || change.Wallet.WalletType != constants.AgentWalletTypeMain {
return errors.New(errors.CodeInvalidParam, "代理主钱包信用额度审计事实不完整")
}
walletResource := agentWalletAuditResource(change.Wallet, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWalletTarget)
walletResource.BeforeData = change.BeforeData
walletResource.AfterData = change.AfterData
walletResource.SubjectVisibility = constants.AuditSubjectResult
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, change.Wallet.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询信用额度关联店铺审计快照失败")
}
shopResource := ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWalletShop)
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
walletResource.SubjectSummary = "代理主钱包信用额度已更新"
if result != constants.AuditResultSuccess {
walletResource.SubjectSummary = "代理主钱包信用额度更新未完成"
}
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletCreditChanged, Summary: "调整代理主钱包信用额度",
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
Resources: []ResourceInput{walletResource, shopResource},
})
}
// WriteAgentWalletDebit 将代理主钱包订单扣款写入统一 Audit Event。
func (w *Writer) WriteAgentWalletDebit(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
if event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeOrder || event.ReferenceID == 0 || event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包订单扣款审计事实不完整")
}
completed, err := completedReservationExists(ctx, tx, event.ReferenceID)
if err != nil || completed {
return err
}
order, wallet, transaction, err := loadAgentWalletDebitFacts(ctx, tx, event)
if err != nil {
return err
}
resources := []ResourceInput{
walletOrderResource(order, "代理主钱包订单扣款"),
agentWalletResource(wallet, transaction.BalanceBefore, wallet.FrozenBalance, transaction.BalanceAfter, wallet.FrozenBalance),
agentWalletTransactionResource(transaction),
}
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletOrderDebited, Summary: "代理主钱包完成订单扣款",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: event.CorrelationID, Metadata: map[string]any{"amount": event.Amount}, Resources: resources,
})
}
// WriteAgentWalletReservation 将代理主钱包订单预占状态变化写入统一 Audit Event。
func (w *Writer) WriteAgentWalletReservation(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
actionCode, summary, err := reservationAuditAction(event.Status)
if err != nil {
return err
}
if event.ReservationID == 0 || event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeOrder || event.ReferenceID == 0 || event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包订单预占审计事实不完整")
}
order, wallet, reservation, transaction, err := loadAgentWalletReservationFacts(ctx, tx, event)
if err != nil {
return err
}
beforeBalance, beforeFrozen, afterBalance, afterFrozen := reservationWalletState(wallet, transaction, event)
resources := []ResourceInput{
walletOrderResource(order, summary),
agentWalletResource(wallet, beforeBalance, beforeFrozen, afterBalance, afterFrozen),
agentWalletReservationResource(reservation, event.Status),
}
if transaction != nil {
resources = append(resources, agentWalletTransactionResource(transaction))
}
return w.Append(ctx, tx, AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: event.CorrelationID,
Metadata: map[string]any{"amount": event.Amount}, Resources: resources,
})
}
func completedReservationExists(ctx context.Context, tx *gorm.DB, orderID uint) (bool, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
Where("reference_type = ? AND reference_id = ? AND status = ?", constants.ReferenceTypeOrder, orderID, constants.AgentWalletReservationStatusCompleted).
Count(&count).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询订单钱包预占终态失败")
}
return count > 0, nil
}
func loadAgentWalletDebitFacts(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) (*model.Order, *model.AgentWallet, *model.AgentWalletTransaction, error) {
var order model.Order
if err := tx.WithContext(ctx).Unscoped().First(&order, event.ReferenceID).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包扣款订单审计快照失败")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包审计快照失败")
}
transaction, err := loadOrderDebitTransaction(ctx, tx, event.WalletID, event.ReferenceID)
if err != nil {
return nil, nil, nil, err
}
return &order, &wallet, transaction, nil
}
func loadAgentWalletReservationFacts(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) (*model.Order, *model.AgentWallet, *model.AgentWalletReservation, *model.AgentWalletTransaction, error) {
var order model.Order
if err := tx.WithContext(ctx).Unscoped().First(&order, event.ReferenceID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包预占订单审计快照失败")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包审计快照失败")
}
var reservation model.AgentWalletReservation
if err := tx.WithContext(ctx).First(&reservation, event.ReservationID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占审计快照失败")
}
var transaction *model.AgentWalletTransaction
if event.Status == constants.AgentWalletReservationStatusCompleted {
loaded, err := loadOrderDebitTransaction(ctx, tx, event.WalletID, event.ReferenceID)
if err != nil {
return nil, nil, nil, nil, err
}
transaction = loaded
}
return &order, &wallet, &reservation, transaction, nil
}
func loadOrderDebitTransaction(ctx context.Context, tx *gorm.DB, walletID, orderID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().Where(
"agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
walletID, constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess,
).First(&transaction).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包订单扣款流水失败")
}
return &transaction, nil
}
func walletOrderResource(order *model.Order, summary string) ResourceInput {
resource := OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
return resource
}
func agentWalletResource(wallet *model.AgentWallet, beforeBalance, beforeFrozen, afterBalance, afterFrozen int64) ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return 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": beforeBalance, "frozen_balance": beforeFrozen},
AfterData: map[string]any{"balance": afterBalance, "frozen_balance": afterFrozen},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "代理主钱包订单资金已更新",
}
}
func agentWalletAuditResource(wallet *model.AgentWallet, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理主钱包 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit,
},
}
}
func agentWalletReservationResource(reservation *model.AgentWalletReservation, status int) ResourceInput {
id := strconv.FormatUint(uint64(reservation.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWalletReservation, ID: &id, Key: reservation.ReferenceType + ":" + strconv.FormatUint(uint64(reservation.ReferenceID), 10),
DisplayName: "订单钱包预占 " + id, 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": status, "reference_type": reservation.ReferenceType, "reference_id": reservation.ReferenceID,
},
BeforeData: map[string]any{"status": reservationStatusBefore(status)}, AfterData: map[string]any{"status": status},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "订单钱包预占状态已更新",
}
}
func agentWalletTransactionResource(transaction *model.AgentWalletTransaction) ResourceInput {
id := strconv.FormatUint(uint64(transaction.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id,
Relation: constants.AuditResourceRelationAffected, 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},
}
}
func reservationAuditAction(status int) (string, string, error) {
switch status {
case constants.AgentWalletReservationStatusFrozen:
return constants.AuditActionAgentWalletOrderReserved, "代理主钱包已预占订单资金", nil
case constants.AgentWalletReservationStatusReleased:
return constants.AuditActionAgentWalletOrderReleased, "代理主钱包已释放订单预占", nil
case constants.AgentWalletReservationStatusCompleted:
return constants.AuditActionAgentWalletOrderCompleted, "代理主钱包已完成订单预占扣款", nil
default:
return "", "", errors.New(errors.CodeInvalidParam, "代理主钱包预占状态不受支持")
}
}
func reservationWalletState(wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, event walletapp.ReservationEvent) (int64, int64, int64, int64) {
afterBalance, afterFrozen := wallet.Balance, wallet.FrozenBalance
switch event.Status {
case constants.AgentWalletReservationStatusFrozen:
return afterBalance, afterFrozen - event.Amount, afterBalance, afterFrozen
case constants.AgentWalletReservationStatusReleased:
return afterBalance, afterFrozen + event.Amount, afterBalance, afterFrozen
default:
return transaction.BalanceBefore, afterFrozen + event.Amount, transaction.BalanceAfter, afterFrozen
}
}
func reservationStatusBefore(status int) any {
if status == constants.AgentWalletReservationStatusFrozen {
return nil
}
return constants.AgentWalletReservationStatusFrozen
}

View File

@@ -167,7 +167,7 @@ func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change acce
}
func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) {
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.Roles)+len(change.Permissions))
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.Roles)+len(change.Permissions))
switch primaryResource {
case constants.AuditResourceAccount:
if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") {
@@ -392,6 +392,46 @@ func accessResources(change accessauditapp.ChangeAudit, primaryResource string)
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalDevices {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户设备绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.VirtualNo,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerDeviceIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalICCIDs {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户 ICCID 绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.ICCID,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerICCIDIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
if primaryResource == constants.AuditResourceRole {
if change.Role == nil || (change.Role.ID == 0 && change.Role.RoleName == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "角色审计资源不完整")
@@ -482,6 +522,16 @@ func iotCardIdentity(card *model.IotCard) map[string]any {
}
}
// IotCardIdentitySnapshot 返回统一 Registry 允许的 IoT 卡身份快照。
func IotCardIdentitySnapshot(card *model.IotCard) map[string]any {
return iotCardIdentity(card)
}
// IotCardResourceKey 返回 IoT 卡审计使用的稳定资源 Key。
func IotCardResourceKey(card *model.IotCard) string {
return iotCardResourceKey(card)
}
func deviceResourceKey(device *model.Device) string {
if device.ID != 0 {
return strconv.FormatUint(uint64(device.ID), 10)
@@ -492,10 +542,22 @@ func deviceResourceKey(device *model.Device) string {
func deviceIdentity(device *model.Device) map[string]any {
return map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI,
"sn": device.SN, "generation": device.Generation,
"sn": device.SN, "device_name": device.DeviceName, "device_model": device.DeviceModel,
"device_type": device.DeviceType, "manufacturer": device.Manufacturer,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
}
}
// DeviceIdentitySnapshot 返回统一 Registry 允许的设备身份快照。
func DeviceIdentitySnapshot(device *model.Device) map[string]any {
return deviceIdentity(device)
}
// DeviceResourceKey 返回设备审计使用的稳定资源 Key。
func DeviceResourceKey(device *model.Device) string {
return deviceResourceKey(device)
}
func deviceSimBindingIdentity(binding *model.DeviceSimBinding) map[string]any {
return map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
@@ -541,6 +603,21 @@ func personalCustomerOpenIDIdentity(openID *model.PersonalCustomerOpenID) map[st
}
}
func personalCustomerDeviceIdentity(binding *model.PersonalCustomerDevice) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "virtual_no": binding.VirtualNo,
"bind_at": binding.BindAt, "last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func personalCustomerICCIDIdentity(binding *model.PersonalCustomerICCID) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "iccid": binding.ICCID,
"iccid_19": binding.ICCID19, "bind_at": binding.BindAt,
"last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func roleResource(role *model.Role, beforeData, afterData map[string]any) ResourceInput {
return ResourceInput{
Type: constants.AuditResourceRole, ID: optionalResourceID(role.ID), Key: roleResourceKey(role), DisplayName: role.RoleName,
@@ -706,6 +783,14 @@ func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change syst
if result == "" {
result = constants.AuditResultSuccess
}
displayName := change.DisplayName
if displayName == "" {
displayName = change.ConfigKey
}
identity := change.Identity
if identity == nil {
identity = map[string]any{"config_key": change.ConfigKey, "module": change.Module}
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: change.Description,
Actor: ActorInput{
@@ -719,9 +804,9 @@ func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change syst
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: action.PrimaryResource, Key: change.ConfigKey, DisplayName: change.ConfigKey,
Type: action.PrimaryResource, ID: change.ResourceID, Key: change.ConfigKey, DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleConfig,
IdentitySnapshot: map[string]any{"config_key": change.ConfigKey, "module": change.Module},
IdentitySnapshot: identity,
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: action.DefaultVisibility,
}},
@@ -795,7 +880,7 @@ func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) err
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if input.Actor.Kind != action.AllowedActor || input.Actor.ID == "" || input.Source != action.Source {
if !actionAllowsOrigin(action, input.Actor.Kind, input.Source) || input.Actor.ID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计操作者或入口不符合动作注册规则")
}
if !validResult(input.Result) || len(input.Resources) == 0 {
@@ -853,6 +938,18 @@ func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) err
return nil
}
func actionAllowsOrigin(action ActionDefinition, actor, source string) bool {
if action.AllowedActor == actor && action.Source == source {
return true
}
for _, origin := range action.AllowedOrigins {
if origin.Actor == actor && origin.Source == source {
return true
}
}
return false
}
func fillFromContext(ctx context.Context, input AppendInput) AppendInput {
value := auditcontext.From(ctx)
if input.Actor.Kind == "" {

View File

@@ -15,6 +15,7 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -180,7 +181,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
ctx = cardapp.SuppressSeriesTriggerContext(auditcontext.With(ctx, auditcontext.Context{ParentEventID: envelope.EventID}))
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
@@ -237,7 +238,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
ctx = cardapp.SuppressSeriesTriggerContext(auditcontext.With(ctx, auditcontext.Context{ParentEventID: envelope.EventID}))
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}

View File

@@ -2,28 +2,39 @@ package notification
import (
"context"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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"
)
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
type CleanupService struct {
db *gorm.DB
logger *zap.Logger
now func() time.Time
db *gorm.DB
logger *zap.Logger
auditWriter *audit.Writer
now func() time.Time
}
// NewCleanupService 创建通知保留清理服务。
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
func NewCleanupService(db *gorm.DB, logger *zap.Logger, auditWriters ...*audit.Writer) *CleanupService {
if logger == nil {
logger = zap.NewNop()
}
return &CleanupService{db: db, logger: logger, now: time.Now}
service := &CleanupService{db: db, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Run 按类别、创建时间和稳定主键执行有界分批清理。
@@ -49,24 +60,74 @@ func (s *CleanupService) Run(ctx context.Context) error {
}
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知清理统一审计接缝未配置")
}
var total int64
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
SELECT id FROM tb_notification
WHERE category = ? AND created_at < ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)
DELETE FROM tb_notification AS notification
USING candidates
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
if result.Error != nil {
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
var deleted []*model.Notification
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("category = ? AND created_at < ?", category, cutoff).
Order("created_at ASC, id ASC").Limit(constants.NotificationCleanupBatchSize).Find(&deleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待清理站内通知失败")
}
if len(deleted) == 0 {
return nil
}
ids := make([]uint, 0, len(deleted))
for _, notification := range deleted {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Notification{})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
}
if result.RowsAffected != int64(len(deleted)) {
return errors.New(errors.CodeInvalidStatus, "通知清理数量发生并发变化")
}
return s.appendCleanupAudit(ctx, tx, category, cutoff, deleted)
}); err != nil {
return total, err
}
total += result.RowsAffected
if result.RowsAffected < constants.NotificationCleanupBatchSize {
total += int64(len(deleted))
if len(deleted) < constants.NotificationCleanupBatchSize {
break
}
}
return total, nil
}
func (s *CleanupService) appendCleanupAudit(ctx context.Context, tx *gorm.DB, category string, cutoff time.Time, notifications []*model.Notification) error {
firstID, lastID := notifications[0].ID, notifications[len(notifications)-1].ID
key := fmt.Sprintf("%s:%s:%d:%d", category, cutoff.UTC().Format(time.RFC3339), firstID, lastID)
rootID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("notification-cleanup:"+key)).String()
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "cleanup"),
ActionCode: constants.AuditActionNotificationCleanupItem, Summary: "清理单条过期通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"exists": true}, map[string]any{"deleted": true})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationCleanup, Summary: "清理过期通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationCleanupBatch, Key: rootID, DisplayName: "通知清理批次",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"category": category, "cutoff": cutoff.UTC(), "deleted_count": len(notifications),
"first_id": firstID, "last_id": lastID,
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
Metadata: map[string]any{"first_id": strconv.FormatUint(uint64(firstID), 10), "last_id": strconv.FormatUint(uint64(lastID), 10)},
},
Children: children,
})
}

View File

@@ -20,6 +20,12 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// DB 返回通知 Repository 使用的数据库连接。
func (r *Repository) DB() *gorm.DB { return r.db }
// WithTx 返回绑定指定事务的通知 Repository。
func (r *Repository) WithTx(tx *gorm.DB) *Repository { return &Repository{db: tx} }
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{

View File

@@ -20,16 +20,17 @@ import (
type AgentRechargePaymentConsumer struct {
db *gorm.DB
posting *walletapp.PostingService
audit agentrecharge.RechargeAuditWriter
}
// NewAgentRechargePaymentConsumer 创建代理在线充值入账消费者。
func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService) *AgentRechargePaymentConsumer {
return &AgentRechargePaymentConsumer{db: db, posting: posting}
func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService, audit agentrecharge.RechargeAuditWriter) *AgentRechargePaymentConsumer {
return &AgentRechargePaymentConsumer{db: db, posting: posting, audit: audit}
}
// Consume 校验支付与充值权威事实后,在独立事务中完成唯一入账和充值终态。
func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.posting == nil {
if c == nil || c.db == nil || c.posting == nil || c.audit == nil {
return errors.New(errors.CodeInternalError, "代理在线充值入账消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentRechargePaymentConfirmed ||
@@ -45,23 +46,24 @@ func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope out
return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件载荷不完整")
}
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
recharge, _, err := lockCreditingFacts(ctx, tx, event)
recharge, payment, err := lockCreditingFacts(ctx, tx, event)
if err != nil {
return err
}
if recharge.Status != constants.RechargeStatusPaid && recharge.Status != constants.RechargeStatusCompleted {
return errors.New(errors.CodeInvalidStatus, "代理在线充值当前状态不可入账")
}
if _, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
posting, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, Amount: recharge.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: recharge.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: recharge.UserID, Creator: recharge.UserID, Remark: "代理在线扫码充值",
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID,
}); err != nil {
})
if err != nil {
return err
}
if recharge.Status == constants.RechargeStatusCompleted {
if posting.AlreadyApplied && recharge.Status == constants.RechargeStatusCompleted {
return nil
}
completedAt := time.Now().UTC()
@@ -74,7 +76,25 @@ func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope out
if update.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理在线充值状态已变化")
}
return nil
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, recharge.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, recharge.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
after := *recharge
after.Status = constants.RechargeStatusCompleted
after.CompletedAt = &completedAt
return c.audit.WriteAgentRecharge(ctx, tx, agentrecharge.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCredited, Summary: "代理在线充值已入账",
Record: &after, Payment: payment, Wallet: &wallet, Transaction: &transaction,
BeforeData: map[string]any{"status": recharge.Status},
AfterData: map[string]any{"status": after.Status, "completed_at": completedAt},
})
})
}

View File

@@ -14,19 +14,25 @@ import (
"gorm.io/gorm"
)
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox
// BalanceAdjustmentAuditWriter 在人工调整事务内追加统一 Audit Event
type BalanceAdjustmentAuditWriter interface {
WriteAgentWalletBalanceAdjustment(context.Context, *gorm.DB, walletapp.CreditedEvent) error
}
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox并审计人工调整。
type CreditEventWriter struct {
outbox *outbox.Repository
audit BalanceAdjustmentAuditWriter
}
// NewCreditEventWriter 创建代理主钱包入账 Outbox Writer。
func NewCreditEventWriter(repository *outbox.Repository) *CreditEventWriter {
return &CreditEventWriter{outbox: repository}
func NewCreditEventWriter(repository *outbox.Repository, auditWriter BalanceAdjustmentAuditWriter) *CreditEventWriter {
return &CreditEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方业务事务中追加代理主钱包入账事件。
// Append 在调用方业务事务中追加代理主钱包入账事件及必要审计
func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
if w == nil || w.outbox == nil {
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
@@ -36,9 +42,15 @@ func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walle
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
if err != nil || event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge {
if err != nil {
return err
}
if event.ReferenceType == constants.ReferenceTypeManualAdjustment && event.TransactionType == constants.AgentTransactionTypeAdjustment {
return w.audit.WriteAgentWalletBalanceAdjustment(ctx, tx, event)
}
if event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge {
return nil
}
rechargeID := strconv.FormatUint(uint64(event.ReferenceID), 10)
var shop model.Shop
if err := tx.WithContext(ctx).Select("shop_name").Where("id = ?", event.ShopID).Take(&shop).Error; err != nil {

View File

@@ -6,26 +6,28 @@ import (
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox 和统一审计
type DebitEventWriter struct {
outbox *outbox.Repository
audit *audit.Writer
}
// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。
func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter {
return &DebitEventWriter{outbox: repository}
// NewDebitEventWriter 创建代理主钱包扣款事件 Writer。
func NewDebitEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *DebitEventWriter {
return &DebitEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方业务事务中追加代理主钱包扣款事件。
// Append 在调用方业务事务中追加代理主钱包扣款 Outbox 与审计事件。
func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款 Outbox Writer 未配置")
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件 Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletDebited,
@@ -34,5 +36,8 @@ func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event wallet
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
if err != nil {
return err
}
return w.audit.WriteAgentWalletDebit(ctx, tx, event)
}

View File

@@ -5,26 +5,28 @@ import (
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox 和统一审计
type ReservationEventWriter struct {
outbox *outbox.Repository
audit *audit.Writer
}
// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。
func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository}
// NewReservationEventWriter 创建代理主钱包预占事件 Writer。
func NewReservationEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方事务中追加预占状态事件。
// Append 在调用方事务中追加预占状态 Outbox 与审计事件。
func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占 Outbox Writer 未配置")
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件 Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletReservationChanged,
@@ -33,5 +35,8 @@ func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
if err != nil {
return err
}
return w.audit.WriteAgentWalletReservation(ctx, tx, event)
}

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -35,13 +36,18 @@ type ApprovalRecoveryRecord struct {
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct {
db *gorm.DB
now func() time.Time
db *gorm.DB
audit approvalapp.AuditWriter
now func() time.Time
}
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository {
return &ApprovalContextRepository{db: db, now: time.Now}
func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter) *ApprovalContextRepository {
var audit approvalapp.AuditWriter
if len(audits) > 0 {
audit = audits[0]
}
return &ApprovalContextRepository{db: db, audit: audit, now: time.Now}
}
// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
@@ -68,7 +74,7 @@ func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanc
// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。
func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error {
if r == nil || r.db == nil || cutoff.IsZero() {
if r == nil || r.db == nil || r.audit == nil || cutoff.IsZero() {
return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效")
}
now := r.now().UTC()
@@ -97,13 +103,38 @@ func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Con
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败")
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步通用审批提交结果未知状态失败")
}
if instanceResult.RowsAffected != result.RowsAffected {
return errors.New(errors.CodeConflict, "通用审批提交结果未知状态已变化")
}
for _, channelContext := range stale {
instance, err := loadApprovalAuditInstance(ctx, tx, channelContext.ApprovalInstanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
afterStatus := constants.ApprovalStatusSubmissionUnknown
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instance.ID, afterStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "审批提交处理中断,结果转为未知",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: instance.ExternalRef, AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorScheduledJob, ActorID: constants.ApprovalAuditActorRecoveryJob,
Source: constants.AuditSourceScheduler, Result: constants.AuditResultUnknown,
ErrorSummary: "企业微信审批提交处理中断,已进入结果未知恢复",
}); err != nil {
return err
}
}
return nil
})
@@ -160,7 +191,7 @@ func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff
}
// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) {
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, string, error) {
resourceID := strconv.FormatUint(uint64(instanceID), 10)
var log model.IntegrationLog
err := r.db.WithContext(ctx).
@@ -169,18 +200,18 @@ func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Con
constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess).
Order("id DESC").First(&log).Error
if err == gorm.ErrRecordNotFound {
return "", nil
return "", "", nil
}
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
return "", "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
}
var summary struct {
SPNo string `json:"sp_no"`
}
if sonic.Unmarshal(log.ResponseSummary, &summary) != nil {
return "", nil
return "", "", nil
}
return strings.TrimSpace(summary.SPNo), nil
return strings.TrimSpace(summary.SPNo), log.IntegrationID, nil
}
// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。
@@ -231,13 +262,24 @@ func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint(
}
// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。
func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) {
func (r *ApprovalContextRepository) RecoverSubmitted(
ctx context.Context,
instanceID uint,
spNo string,
integrationIDs []string,
actorKind string,
actorID string,
source string,
) (bool, error) {
spNo = strings.TrimSpace(spNo)
if instanceID == 0 || spNo == "" {
return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效")
}
now := r.now().UTC()
recovered := false
if r.audit == nil {
return false, errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown).
@@ -263,6 +305,24 @@ func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instan
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批恢复状态已变化")
}
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmissionUnknown
afterStatus := constants.ApprovalStatusPending
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission_recovered",
ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "恢复结果未知的审批提交",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
IntegrationIDs: integrationIDs,
}); err != nil {
return err
}
recovered = true
return nil
})
@@ -335,7 +395,10 @@ func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanc
}
// MarkSubmitted 原子保存企微 sp_no并把通用审批实例置为审批中。
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error {
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string, integrationID string) error {
if r.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
@@ -359,37 +422,90 @@ func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceI
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交状态已变化")
}
return nil
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
afterStatus := constants.ApprovalStatusPending
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instanceID, afterStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "企业微信审批提交成功",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
Source: constants.AuditSourceWorker, Result: constants.AuditResultSuccess, IntegrationIDs: []string{integrationID},
})
})
}
// MarkFailed 将企微明确拒绝的提交记录为提交失败。
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message)
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string, integrationID string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message, constants.AuditResultFailed, integrationID)
}
// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message)
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string, integrationID string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message, constants.AuditResultUnknown, integrationID)
}
// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error {
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message, auditResult, integrationID string) error {
if r.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComApprovalContext{}).
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败")
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "更新企业微信审批提交结果失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
if contextResult.RowsAffected == 0 {
return nil
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": approvalStatus, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败")
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交结果失败")
}
return nil
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交结果状态已变化")
}
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "同步企业微信审批提交结果",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus,
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message,
IntegrationIDs: []string{integrationID},
})
})
}
func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) {
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批审计快照失败")
}
return &instance, nil
}
func approvalSubmissionAuditEventID(instanceID uint, status int) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission:" + strconv.Itoa(status)
}

View File

@@ -19,9 +19,10 @@ import (
// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。
type ApprovalDetail struct {
SPNo string
SPStatus int
Snapshot []byte
SPNo string
SPStatus int
Snapshot []byte
IntegrationID string
}
// ApprovalDetailClient 获取企业微信审批申请详情。
@@ -93,7 +94,7 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot, IntegrationID: attempt.IntegrationID}, err
}
// newRequest 组装按字符串审批单号查询权威详情的企微请求。

View File

@@ -71,6 +71,7 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
for _, decision := range decisions {
if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{
InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source,
IntegrationIDs: []string{payload.IntegrationID, detail.IntegrationID},
}); err != nil {
return err
}
@@ -100,7 +101,10 @@ func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context,
if err != nil || candidate == nil {
return nil, err
}
recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo)
recovered, err := h.contexts.RecoverSubmitted(
ctx, candidate.InstanceID, payload.SPNo,
[]string{payload.IntegrationID, detail.IntegrationID}, constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback,
)
if err != nil || !recovered {
return nil, err
}

View File

@@ -30,8 +30,9 @@ type ApprovalInfoQuery struct {
// ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。
type ApprovalInfoPage struct {
SPNos []string
NextCursor string
SPNos []string
NextCursor string
IntegrationID string
}
// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。
@@ -116,7 +117,7 @@ func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery)
},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor), IntegrationID: attempt.IntegrationID}, err
}
func validateApprovalInfoQuery(input ApprovalInfoQuery) error {

View File

@@ -67,20 +67,26 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
if !claimed {
continue
}
spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
if err != nil {
return err
}
integrationIDs := []string{submissionIntegrationID}
if spNo == "" {
spNo, err = h.findUniqueSPNo(ctx, record, now)
var recoveryIntegrationIDs []string
spNo, recoveryIntegrationIDs, err = h.findUniqueSPNo(ctx, record, now)
if err != nil {
return err
}
integrationIDs = append(integrationIDs, recoveryIntegrationIDs...)
}
if spNo == "" {
continue
}
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo)
recovered, err := h.contexts.RecoverSubmitted(
ctx, record.InstanceID, spNo, integrationIDs,
constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler,
)
if err != nil {
return err
}
@@ -94,19 +100,20 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
}
// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) {
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, []string, error) {
startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow)
endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow)
if endTime.After(now) {
endTime = now
}
if !startTime.Before(endTime) {
return "", nil
return "", nil, nil
}
cursor := ""
seenCursors := make(map[string]struct{})
seenCandidates := make(map[string]struct{})
unbound := make([]string, 0, 2)
integrationIDs := make([]string, 0, 2)
for {
page, err := h.infos.List(ctx, ApprovalInfoQuery{
ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime,
@@ -114,11 +121,12 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize,
})
if err != nil {
return "", err
return "", nil, err
}
integrationIDs = append(integrationIDs, page.IntegrationID)
existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos)
if err != nil {
return "", err
return "", nil, err
}
for _, candidate := range page.SPNos {
if _, seen := seenCandidates[candidate]; seen {
@@ -128,7 +136,7 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
if _, exists := existing[candidate]; !exists {
unbound = append(unbound, candidate)
if len(unbound) > 1 {
return "", nil
return "", integrationIDs, nil
}
}
}
@@ -137,15 +145,15 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
break
}
if _, exists := seenCursors[next]; exists {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
return "", nil, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
}
seenCursors[next] = struct{}{}
cursor = next
}
if len(unbound) != 1 {
return "", nil
return "", integrationIDs, nil
}
return unbound[0], nil
return unbound[0], integrationIDs, nil
}
func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error {

View File

@@ -35,10 +35,11 @@ type ApprovalSubmitRequest struct {
// ApprovalSubmitResult 描述企微是否明确创建审批单。
type ApprovalSubmitResult struct {
Outcome string
SPNo string
Message string
SafeToRetry bool
Outcome string
SPNo string
Message string
SafeToRetry bool
IntegrationID string
}
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
@@ -98,9 +99,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
@@ -113,9 +114,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
var result struct {
ErrCode int64 `json:"errcode"`
@@ -141,9 +142,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
@@ -151,7 +152,7 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
})
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo), IntegrationID: attempt.IntegrationID}, err
}
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。

View File

@@ -59,7 +59,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
}
return err
}
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil {
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error(), ""); markErr != nil {
return markErr
}
return nil
@@ -71,11 +71,11 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
})
switch result.Outcome {
case submissionOutcomeSuccess:
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil {
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo, result.IntegrationID); err != nil {
return err
}
case submissionOutcomeUnknown:
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil {
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
return err
}
case submissionOutcomeFailed:
@@ -88,7 +88,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
}
return errors.New(errors.CodeServiceUnavailable, result.Message)
}
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil {
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
return err
}
default:

View File

@@ -24,28 +24,25 @@ func NewMemberRepository(db *gorm.DB) *MemberRepository {
}
// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。
func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || r.db == nil {
func (r *MemberRepository) ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || tx == nil {
return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
return err
}
if len(members) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
DoUpdates: clause.Assignments(map[string]any{
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
}),
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error
})
if err != nil {
if err := tx.WithContext(ctx).Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
if len(members) == 0 {
return nil
}
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
DoUpdates: clause.Assignments(map[string]any{
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
}),
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
return nil

View File

@@ -37,6 +37,9 @@ type Order struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 wechat-微信 alipay-支付宝" json:"payment_method"`
PaymentStatus int `gorm:"column:payment_status;type:int;default:1;not null;index:idx_order_payment_status;comment:支付状态 1-待支付 2-已支付 3-已取消 4-已退款" json:"payment_status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
// AssetWalletReservationWalletID 和 AssetWalletReservedAmount 共同记录个人钱包订单的资金预占事实。
AssetWalletReservationWalletID *uint `gorm:"column:asset_wallet_reservation_wallet_id;comment:个人钱包订单预占的资产钱包ID无外键" json:"-"`
AssetWalletReservedAmount int64 `gorm:"column:asset_wallet_reserved_amount;type:bigint;not null;default:0;comment:个人钱包订单预占金额0表示历史未预占订单" json:"-"`
// 佣金信息
CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金流程状态 1-待计算 2-已完成 3-待人工处理" json:"commission_status"`

View File

@@ -13,10 +13,14 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"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"
)
// orphanPackageScanLimit 单轮最多恢复的真实孤儿载体数量。
const orphanPackageScanLimit = 100
// TrafficSyncer 套餐失效前流量同步接口
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
type TrafficSyncer interface {
@@ -156,17 +160,48 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont
}
// findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活
// 孤儿定义:存在 status=0 的主套餐,但不存在 status=1 的主套餐。
// 孤儿定义:存在待生效主套餐,但不存在生效中或已用完的占位主套餐。
func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Context) (int, error) {
// 查询孤儿待生效主套餐(无生效主套餐但有待生效主套餐)
var orphanUsages []*model.PackageUsage
err := h.db.WithContext(ctx).
Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL").
Where("deleted_at IS NULL").
Order("priority ASC, created_at ASC").
Limit(100).
Find(&orphanUsages).Error
err := h.db.WithContext(ctx).Raw(`
WITH pending_queue AS (
SELECT pending.id,
pending.priority,
pending.created_at,
ROW_NUMBER() OVER (
PARTITION BY
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END,
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END
ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC
) AS queue_position
FROM tb_package_usage AS pending
WHERE pending.status = ?
AND pending.master_usage_id IS NULL
AND pending.deleted_at IS NULL
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
AND NOT EXISTS (
SELECT 1
FROM tb_package_usage AS occupied
WHERE occupied.status IN (?, ?)
AND occupied.master_usage_id IS NULL
AND occupied.deleted_at IS NULL
AND (
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
)
)
)
SELECT usage.*
FROM pending_queue AS candidate
JOIN tb_package_usage AS usage ON usage.id = candidate.id
WHERE candidate.queue_position = 1
ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC
LIMIT ?`,
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
orphanPackageScanLimit,
).Scan(&orphanUsages).Error
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询孤儿套餐失败")
}
@@ -175,78 +210,32 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
return 0, nil
}
// 按载体分组,去重
type carrierKey struct {
carrierType string
carrierID uint
}
carrierMap := make(map[carrierKey]*model.PackageUsage) // 保留 priority 最低的套餐
for _, usage := range orphanUsages {
key := carrierKey{}
if usage.IotCardID > 0 {
key.carrierType = "iot_card"
key.carrierID = usage.IotCardID
} else if usage.DeviceID > 0 {
key.carrierType = "device"
key.carrierID = usage.DeviceID
} else {
continue
}
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐)
var activeCount int64
var countErr error
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
if key.carrierType == "iot_card" {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("iot_card_id = ?", key.carrierID).
Count(&activeCount).Error
} else {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("device_id = ?", key.carrierID).
Count(&activeCount).Error
}
if countErr != nil {
h.logger.Warn("检查载体生效套餐失败",
zap.String("carrier_type", key.carrierType),
zap.Uint("carrier_id", key.carrierID),
zap.Error(countErr))
continue
}
// 已有生效或已用完(占位)套餐,跳过
if activeCount > 0 {
continue
}
// 保留购买顺序最靠前的套餐,队首未满足实名条件时不跳过。
if existing, ok := carrierMap[key]; ok {
if usage.Priority < existing.Priority || (usage.Priority == existing.Priority && usage.CreatedAt.Before(existing.CreatedAt)) {
carrierMap[key] = usage
}
} else {
carrierMap[key] = usage
}
}
// 为每个孤儿载体提交激活任务
count := 0
for key, usage := range carrierMap {
if err := h.enqueueActivationTask(ctx, usage.ID, key.carrierType, key.carrierID, "orphan_recovery"); err != nil {
h.logger.Warn("提交孤儿套餐激活任务失败",
for _, usage := range orphanUsages {
carrierType, carrierID := h.getCarrierInfo(usage)
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("孤儿套餐同步激活失败",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", key.carrierType),
zap.Uint("carrier_id", key.carrierID),
zap.Error(err))
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"),
zap.Error(activationErr))
continue
}
if !activated {
h.logger.Info("孤儿套餐本轮未激活",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
continue
}
h.logger.Info("孤儿套餐同步激活成功",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
count++
}
@@ -269,7 +258,7 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
}
// processExpiredPackage 处理单个过期套餐
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个事务提交后触发停机
// 流程:先同步最新流量 → 事务内标记过期失效加油包 → 提交后同步接续并触发停机检查
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
carrierType, carrierID := h.getCarrierInfo(pkg)
@@ -278,11 +267,19 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
}
expired := false
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
return err
result := tx.Model(pkg).
Where("status IN ? AND expires_at <= ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, time.Now()).
Update("status", constants.PackageUsageStatusExpired)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
expired = true
expiresAt := time.Now()
if pkg.ExpiresAt != nil {
@@ -293,52 +290,85 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
zap.Time("expires_at", expiresAt))
// 任务 19.4: 加油包级联失效
if err := h.invalidateAddons(ctx, tx, pkg.ID); err != nil {
h.logger.Warn("加油包级联失效失败",
zap.Uint("master_usage_id", pkg.ID),
zap.Error(err))
addons, err := h.invalidateAddons(ctx, tx, pkg.ID)
if err != nil {
return err
}
// 任务 19.5: 查询并激活下一个待生效主套餐
if carrierType != "" && carrierID > 0 {
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
h.logger.Warn("激活下一个待生效套餐失败",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
if h.activationService == nil {
return errors.New(errors.CodeInternalError, "套餐激活服务未注入")
}
if err := h.activationService.AppendExpirationAudit(ctx, tx, pkg, addons); err != nil {
return err
}
return nil
})
if err != nil {
if h.activationService != nil {
h.activationService.RecordUsageFailure(ctx, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", pkg, err)
}
return err
}
if !expired {
return nil
}
// 事务提交后再触发异步停机,确保 CheckAndStopCard 读到最新的套餐状态
if carrierType != "" && carrierID > 0 {
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("过期后同步接续套餐失败",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"),
zap.Error(activationErr))
} else if activated {
h.logger.Info("过期后同步接续套餐成功",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
} else {
h.logger.Info("过期后本轮未接续套餐",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
}
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
return activationErr
}
return nil
}
// invalidateAddons 任务 19.4: 加油包级联失效
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]*model.PackageUsage, error) {
// 查询主套餐下的所有加油包status IN (0,1,2) 的加油包)
result := tx.Model(&model.PackageUsage{}).
var addons []*model.PackageUsage
if err := tx.WithContext(ctx).
Where("master_usage_id = ?", masterUsageID).
Where("status IN ?", []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}).
}).Find(&addons).Error; err != nil {
return nil, err
}
if len(addons) == 0 {
return nil, nil
}
ids := make([]uint, 0, len(addons))
for _, addon := range addons {
ids = append(ids, addon.ID)
}
result := tx.Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", ids, []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return result.Error
return nil, result.Error
}
if result.RowsAffected > 0 {
@@ -347,7 +377,7 @@ func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gor
zap.Int64("invalidated_count", result.RowsAffected))
}
return nil
return addons, nil
}
// getCarrierInfo 获取载体信息
@@ -361,34 +391,6 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri
return "", 0
}
// activateNextPackage 任务 19.5: 激活下一个待生效主套餐
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
// 查询下一个待生效主套餐
// WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1
var nextPkg model.PackageUsage
query := tx.Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL"). // 主套餐
Order("priority ASC").
Limit(1)
if carrierType == "iot_card" {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == "device" {
query = query.Where("device_id = ?", carrierID)
}
if err := query.First(&nextPkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 没有待生效套餐,正常情况
return nil
}
return err
}
// 提交 Asynq 任务进行激活(避免长事务)
return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue")
}
// triggerStopAfterExpiry 套餐过期后异步触发停机检查
// 仅在确认无后续生效套餐时有效CheckAndStopCard 内部有幂等保护,重复调用安全
func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) {
@@ -472,6 +474,10 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
h.logger.Error("解析套餐激活任务载荷失败", zap.Error(err))
return nil // 不重试
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageQueueActivation,
ActorName: "套餐排队激活任务", Source: constants.AuditSourceWorker,
})
h.logger.Info("开始执行套餐激活",
zap.Uint("package_usage_id", payload.PackageUsageID),
@@ -525,6 +531,10 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
return nil
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageFirstActivation,
ActorName: "套餐首次实名激活任务", Source: constants.AuditSourceWorker,
})
if payload.CarrierType == "" || payload.CarrierID == 0 {
h.logger.Error("首次实名激活任务 carrier 信息缺失",

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -224,6 +225,10 @@ func (s *Scheduler) processOneShard(ctx context.Context, shardID int) {
// processActivationTasks 套餐激活检查和流量重置调度(每 10 秒触发)
func (s *Scheduler) processActivationTasks(ctx context.Context) {
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: constants.AuditActorIDPackageLifecycleScheduler,
ActorName: "套餐权益生命周期计划任务", Source: constants.AuditSourceScheduler,
})
if s.packageActivationHandler != nil {
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
s.logger.Warn("套餐激活检查失败", zap.Error(err))

View File

@@ -53,6 +53,7 @@ type Service struct {
operationPasswordService OperationPasswordServiceInterface
redis *redis.Client
logger *zap.Logger
rechargeAudit agentrechargeapp.RechargeAuditWriter
}
// New 创建代理预充值服务实例
@@ -90,6 +91,11 @@ func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCre
s.offlineCreation = service
}
// SetRechargeAudit 注入代理充值统一审计 Writer。
func (s *Service) SetRechargeAudit(writer agentrechargeapp.RechargeAuditWriter) {
s.rechargeAudit = writer
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
@@ -215,33 +221,22 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID,
Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
})
if err != nil {
return err
}
return nil
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "线下充值确认已入账")
})
if err != nil {
return nil, err
}
// 异步记录审计日志
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
OperatorID: userID,
OperatorType: userType,
OperationType: "offline_recharge_confirm",
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s金额: %d分", record.RechargeNo, record.Amount),
RequestID: middleware.GetRequestIDFromContext(ctx),
IPAddress: middleware.GetIPFromContext(ctx),
UserAgent: middleware.GetUserAgentFromContext(ctx),
})
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
@@ -325,16 +320,16 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID,
Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
})
if err != nil {
return err
}
return nil
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "代理充值支付回调已入账")
})
if err != nil {
@@ -391,11 +386,30 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
return errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工驳回")
}
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
if err == gorm.ErrRecordNotFound {
if s.rechargeAudit == nil {
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{"status": constants.RechargeStatusRejected, "rejection_reason": strings.TrimSpace(rejectionReason)})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "驳回充值订单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
}
return errors.Wrap(errors.CodeDatabaseError, err, "驳回充值订单失败")
after := *record
after.Status = constants.RechargeStatusRejected
reason := strings.TrimSpace(rejectionReason)
after.RejectionReason = &reason
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeClosed, Summary: "驳回代理充值申请",
Record: &after, BeforeData: map[string]any{"status": record.Status},
AfterData: map[string]any{"status": after.Status, "rejection_reason": reason},
})
}); err != nil {
return err
}
s.logger.Info("代理充值订单驳回成功",
@@ -405,6 +419,29 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
return nil
}
func (s *Service) appendCreditedAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, payment *model.Payment, status int, summary string) error {
if s.rechargeAudit == nil {
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
after := *record
after.Status = status
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCredited, Summary: summary,
Record: &after, Payment: payment, Wallet: &wallet, Transaction: &transaction,
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
})
}
// GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {

View File

@@ -0,0 +1,47 @@
package asset_package_batch_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/middleware"
)
func (s *Service) writeBatchOrderTaskAudit(ctx context.Context, tx *gorm.DB, task *model.AssetPackageBatchOrderTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.CreatorShopID != 0 {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(task.CreatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, task.ID, phase),
ActionCode: constants.AuditActionAssetPackageBatchOrderTaskCreated,
Summary: "创建资产套餐批量订购任务", TaskID: task.ID, TaskNo: task.TaskNo,
Actor: audit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
"package_id": task.PackageID, "package_code": task.PackageCode,
"package_name": task.PackageName, "payment_method": task.PaymentMethod,
},
BeforeData: before, AfterData: after,
})
}
func batchOrderTaskState(task *model.AssetPackageBatchOrderTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "total_count": task.TotalCount,
"success_count": task.SuccessCount, "fail_count": task.FailCount,
}
}

View File

@@ -4,17 +4,20 @@ package asset_package_batch_order
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"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/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -26,11 +29,16 @@ type Service struct {
taskStore *postgres.AssetPackageBatchOrderTaskStore
packageStore *postgres.PackageStore
queueClient *queue.Client
auditWriter *audit.Writer
}
// New 创建资产套餐批量订购任务服务。
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client) *Service {
return &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client, auditWriters ...*audit.Writer) *Service {
service := &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// TaskPayload 批量订购 Worker 结构化载荷。
@@ -69,7 +77,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
CreatorUserType: middleware.GetUserTypeFromContext(ctx), CreatorShopID: middleware.GetShopIDFromContext(ctx),
CreatorName: middleware.GetUsernameFromContext(ctx),
}
if err := s.taskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "资产套餐批量订购统一审计接缝未配置")
}
if err := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
return err
}
return s.writeBatchOrderTaskAudit(ctx, tx, task, nil, batchOrderTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建批量订购任务失败")
}
var enqueueErr error
@@ -82,10 +98,19 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
}
if enqueueErr != nil {
message := "批量订购任务入队失败"
_ = s.taskStore.MarkFailed(ctx, task.ID, message)
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
now := time.Now()
task.CompletedAt = &now
secondaryErr := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := batchOrderTaskState(task)
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, message); err != nil {
return err
}
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
now := time.Now()
task.CompletedAt = &now
return s.writeBatchOrderTaskAudit(ctx, tx, task, before, batchOrderTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), message)
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAssetPackageBatchOrderTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
}
}
return toResponse(task), nil
}

View File

@@ -2,14 +2,17 @@ package carrier
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -17,10 +20,11 @@ import (
type Service struct {
carrierStore *postgres.CarrierStore
audit systemconfigapp.AuditWriter
}
func New(carrierStore *postgres.CarrierStore) *Service {
return &Service{carrierStore: carrierStore}
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
return &Service{carrierStore: carrierStore, audit: audit}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
@@ -31,8 +35,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
if existing != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
return nil, errors.New(errors.CodeCarrierCodeExists, "运营商编码已存在")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
carrier := &model.Carrier{
CarrierCode: req.CarrierCode,
@@ -53,7 +61,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
}
carrier.Creator = currentUserID
if err := s.carrierStore.Create(ctx, carrier); err != nil {
err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Create(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierCreate, "创建运营商配置", nil, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierCreate, "创建运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
}
@@ -68,6 +83,10 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
return s.toResponse(carrier), nil
}
@@ -101,11 +120,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商实名链接配置", &before, errors.CodeInvalidParam)
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierUpdate, "更新运营商配置", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierUpdate, "更新运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
}
@@ -113,15 +140,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.carrierStore.GetByID(ctx, id)
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
if err := s.carrierStore.Delete(ctx, id); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Delete(ctx, id); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierDelete, "删除运营商配置", carrier, nil)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierDelete, "删除运营商配置失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "删除运营商失败")
}
@@ -178,17 +215,100 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
carrier.Status = status
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "更新运营商状态失败")
}
return nil
}
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.Carrier) error {
carrier := after
if carrier == nil {
carrier = before
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier),
BeforeData: carrierAuditSnapshot(before), AfterData: carrierAuditSnapshot(after),
RequestID: requestID, CorrelationID: requestID,
})
}
func (s *Service) recordDenied(ctx context.Context, operation, description string, carrier *model.Carrier, code int) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultDenied, code)
}
func (s *Service) recordFailure(ctx context.Context, operation, description string, carrier *model.Carrier) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultFailed, errors.CodeDatabaseError)
}
func (s *Service) recordAuditResult(ctx context.Context, operation, description string, carrier *model.Carrier, result string, code int) {
if s.audit == nil || carrier == nil || s.carrierStore == nil || s.carrierStore.DB() == nil {
return
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
audit := systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier), BeforeData: carrierAuditSnapshot(carrier),
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
RequestID: requestID, CorrelationID: requestID,
}
if err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(operation, audit.ConfigKey, requestID, requestID, audit.ErrorCode, err)
}
}
func carrierIdentity(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "status": carrier.Status,
}
}
func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
"data_reset_day": carrier.DataResetDay,
}
}
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
return &dto.CarrierResponse{
ID: c.ID,

View File

@@ -0,0 +1,103 @@
package client_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"
)
func (s *Service) appendPaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, recharge *model.RechargeOrder) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置")
}
resources := []audit.ResourceInput{audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status})}
if order != nil {
resources = append(resources, audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRolePaymentBusinessOrder))
}
if recharge != nil {
id := strconv.FormatUint(uint64(recharge.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceRechargeOrder, ID: &id, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
})
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建第三方支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo, Resources: resources,
})
}
func (s *Service) startPaymentAttempt(ctx context.Context, payment *model.Payment, 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(payment.ID), 10)
resourceKey, series, correlationID := payment.PaymentNo, "payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, payment.PaymentNo
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.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene,
TriggerSeries: &series, CorrelationID: &correlationID,
RequestSummary: map[string]any{"payment_config_id": payment.PaymentConfigID, "amount": payment.Amount},
})
return log, time.Now(), err
}
func (s *Service) completePaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
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 paymentIntegrationProvider(config *model.WechatConfig) string {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return constants.IntegrationProviderFuiou
}
return constants.IntegrationProviderWechatPay
}
func (s *Service) markPaymentFailed(ctx context.Context, payment *model.Payment, summary string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新失败支付记录失败")
}
if result.RowsAffected == 0 {
return nil
}
after := *payment
after.Status = model.PaymentRecordStatusFailed
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentFailed, Summary: summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: []audit.ResourceInput{audit.PaymentResource(&after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
map[string]any{"status": payment.Status}, map[string]any{"status": after.Status})},
})
})
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"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"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -40,6 +42,8 @@ type WechatConfigServiceInterface interface {
// 用于将钱包扣款、套餐激活、佣金计算等核心逻辑委托给 B 端 order.Service 处理。
type OrderWalletPayServiceInterface interface {
WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error
CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error
RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error)
}
// PaymentMethodPolicy 提供按资产类型校验支付方式的能力。
@@ -76,6 +80,8 @@ type Service struct {
redis *redis.Client
logger *zap.Logger
paymentMethodPolicy PaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -83,6 +89,12 @@ func (s *Service) SetPaymentMethodPolicy(policy PaymentMethodPolicy) {
s.paymentMethodPolicy = policy
}
// SetPaymentAudit 注入支付审计与外部交互日志接缝。
func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
s.auditWriter = writer
s.paymentIntegration = integration
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
@@ -131,7 +143,7 @@ func New(
// CreateOrder 创建客户端订单。
// 普通套餐下单:仅创建待支付订单,不发起支付,需后续调用 POST /orders/:id/pay 支付。
// 强充场景:检测到需要强充时,直接创建充值单并发起微信支付(一步完成),此时 app_type 必传。
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (resp *dto.ClientCreateOrderResponse, err error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -144,6 +156,23 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
if err != nil {
return nil, err
}
auditOrder := &model.Order{
OrderNo: "create:client:" + strings.TrimSpace(req.Identifier), BuyerType: model.BuyerTypePersonal,
BuyerID: customerID, AssetIdentifier: strings.TrimSpace(req.Identifier), PaymentMethod: req.PaymentMethod,
}
if assetInfo.AssetType == "card" || assetInfo.AssetType == constants.AssetTypeIotCard {
auditOrder.OrderType = model.OrderTypeSingleCard
auditOrder.IotCardID = &assetInfo.AssetID
} else {
auditOrder.OrderType = model.OrderTypeDevice
auditOrder.DeviceID = &assetInfo.AssetID
}
orderFlow := true
defer func() {
if orderFlow && s.orderPaymentService != nil {
s.orderPaymentService.RecordCreateFailure(skipCtx, auditOrder, err)
}
}()
if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
} else if !owned {
@@ -249,6 +278,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
}()
if forceRecharge.NeedForceRecharge {
orderFlow = false
if s.paymentMethodPolicy == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
@@ -405,8 +435,11 @@ func (s *Service) createPackageOrder(
return nil, err
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
if s.orderPaymentService == nil {
return nil, errors.New(errors.CodeInternalError, "订单创建能力未配置")
}
if err := s.orderPaymentService.CreatePendingOrder(ctx, order, items); err != nil {
return nil, err
}
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
@@ -484,10 +517,6 @@ func (s *Service) createForceRechargeOrder(
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
}
if err := s.rechargeOrderStore.Create(ctx, rechargeOrder); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
@@ -499,12 +528,34 @@ func (s *Service) createForceRechargeOrder(
PaymentConfigID: &activeConfig.ID,
}
if err := s.paymentStore.Create(ctx, payment); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(ctx, payment, paymentIntegrationProvider(activeConfig), "client_force_recharge")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, paymentNo, "余额充值", openID, int(rechargeOrder.Amount))
if err != nil {
if completeErr := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(ctx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
return nil, updateErr
}
return nil, err
}
if err := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return nil, err
}
@@ -937,7 +988,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
} else {
// 过期或不存在,标记旧单 failed 并新建
if existing != nil {
if updateErr := s.paymentStore.UpdateStatus(ctx, existing.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil {
s.logger.Warn("标记过期支付宝支付单 failed 失败",
zap.Uint("payment_id", existing.ID),
zap.Error(updateErr),
@@ -959,8 +1010,13 @@ func (s *Service) getOrBuildAlipayPaymentLink(
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
}
if err := s.paymentStore.Create(ctx, newPayment); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil)
}); err != nil {
return nil, err
}
payment = newPayment
s.logger.Info("创建支付宝支付单",
@@ -977,7 +1033,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
if err != nil {
// 新建的 payment 生成链接失败,标记 failed
if existing == nil || payment.ID != existing.ID {
_ = s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed)
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
}
return nil, err
}
@@ -1074,14 +1130,17 @@ func (s *Service) createAlipayForceRechargeOrder(
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return s.paymentStore.CreateWithTx(ctx, tx, payment)
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return err
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
}); err != nil {
return nil, err
}
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, "余额充值")
if err != nil {
if updateErr := s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
@@ -1407,14 +1466,21 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return nil
return s.appendPaymentCreatedAudit(skipCtx, tx, payment, order, nil)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(skipCtx, payment, paymentIntegrationProvider(activeConfig), "client_order")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(skipCtx, paymentNo, "套餐购买", openID, int(order.TotalAmount))
if err != nil {
if updateErr := s.paymentStore.UpdateStatus(skipCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if completeErr := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(skipCtx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付记录失败状态失败",
zap.Uint("payment_id", payment.ID),
zap.String("payment_no", paymentNo),
@@ -1423,6 +1489,9 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
}
return nil, err
}
if err := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return nil, err
}
return &dto.ClientPayOrderResponse{
PaymentMethod: paymentMethod,
PayConfig: buildClientPayConfigFromResult(paymentResult),

View File

@@ -0,0 +1,148 @@
package commission_calculation
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"
)
func (s *Service) appendCommissionCalculationAudit(ctx context.Context, tx *gorm.DB, order *model.Order, status, result int) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
}
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
primary.AfterData = map[string]any{"commission_status": status, "commission_result": result}
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary}
var records []model.CommissionRecord
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单佣金审计快照失败")
}
shopIDs := make([]uint, 0, len(records))
seenShops := make(map[uint]struct{}, len(records))
for i := range records {
resource := audit.CommissionRecordResource(&records[i], nil, map[string]any{
"amount": records[i].Amount, "status": records[i].Status, "balance_after": records[i].BalanceAfter,
})
resource.Relation = constants.AuditResourceRelationAffected
resource.Role = constants.AuditResourceRoleCommissionRecord
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
if _, ok := seenShops[records[i].ShopID]; !ok {
seenShops[records[i].ShopID] = struct{}{}
shopIDs = append(shopIDs, records[i].ShopID)
}
}
if len(shopIDs) > 0 {
var shops []model.Shop
if err := tx.WithContext(ctx).Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
}
for i := range shops {
resource := audit.ShopResource(&shops[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
}
seriesResource, err := commissionSeriesResource(ctx, tx, order)
if err != nil {
return err
}
if seriesResource != nil {
resources = append(resources, *seriesResource)
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission:order:" + strconv.FormatUint(uint64(order.ID), 10) + ":calculated",
ActionCode: constants.AuditActionCommissionCalculated, Summary: "完成订单佣金计算",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo, Resources: resources,
Metadata: map[string]any{"commission_status": status, "commission_result": result, "record_count": len(records)},
})
}
func (s *Service) appendCommissionCreditAudit(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, balanceBefore int64) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
}
var saved model.CommissionRecord
if err := tx.WithContext(ctx).First(&saved, record.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金入账审计快照失败")
}
var order model.Order
if err := tx.WithContext(ctx).First(&order, saved.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联订单审计快照失败")
}
var shop model.Shop
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
}
primary := audit.CommissionRecordResource(&saved,
map[string]any{"amount": record.Amount, "status": record.Status, "balance_after": record.BalanceAfter},
map[string]any{"amount": saved.Amount, "status": saved.Status, "balance_after": saved.BalanceAfter, "released_at": saved.ReleasedAt})
primary.Relation = constants.AuditResourceRelationPrimary
primary.Role = constants.AuditResourceRoleCommissionRecord
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionWallet,
map[string]any{"balance": balanceBefore, "frozen_balance": wallet.FrozenBalance},
map[string]any{"balance": balanceBefore + saved.Amount, "frozen_balance": wallet.FrozenBalance})
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionTransaction)
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionOrder)
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary, walletResource, transactionResource, orderResource, shopResource}
seriesResource, err := commissionSeriesResource(ctx, tx, &order)
if err != nil {
return err
}
if seriesResource != nil {
resources = append(resources, *seriesResource)
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission:record:" + strconv.FormatUint(uint64(saved.ID), 10) + ":credited",
ActionCode: constants.AuditActionCommissionCredited, Summary: "佣金已入账",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo, Resources: resources,
Metadata: map[string]any{"amount": saved.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter},
})
}
func commissionSeriesResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
if order.SeriesID == nil {
return nil, nil
}
var series model.PackageSeries
if err := tx.WithContext(ctx).First(&series, *order.SeriesID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联套餐系列审计快照失败")
}
resource := audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionSeries, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
return &resource, nil
}
func (s *Service) recordCommissionCalculationFailure(ctx context.Context, order *model.Order, businessErr error) {
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
return
}
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: constants.AuditActionCommissionCalculated, Summary: "订单佣金计算失败",
ScopeType: constants.AuditScopePlatform, CorrelationID: order.OrderNo,
Resources: []audit.ResourceInput{primary},
}, businessErr)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -30,6 +31,7 @@ type Service struct {
packageStore *postgres.PackageStore
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
commissionStatsService *commission_stats.Service
auditWriter *audit.Writer
logger *zap.Logger
}
@@ -71,12 +73,19 @@ func New(
}
}
// SetAuditWriter 注入佣金计算与入账统一审计 Writer。
func (s *Service) SetAuditWriter(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
order, err := s.orderStore.GetByID(ctx, orderID)
var order *model.Order
err := s.db.Transaction(func(tx *gorm.DB) error {
loadedOrder, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败")
}
order = loadedOrder
if order.CommissionStatus == model.CommissionStatusCompleted || order.CommissionStatus == model.CommissionStatusPendingReview {
s.logger.Warn("订单佣金流程已结束,跳过",
@@ -120,8 +129,12 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金结果失败")
}
return nil
return s.appendCommissionCalculationAudit(ctx, tx, order, commissionStatus, commissionResult)
})
if err != nil {
s.recordCommissionCalculationFailure(ctx, order, err)
}
return err
}
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error) {
@@ -702,7 +715,7 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
return s.appendCommissionCreditAudit(ctx, tx, record, &wallet, transaction, balanceBefore)
}
func (s *Service) persistCommissionRecordsInTx(ctx context.Context, tx *gorm.DB, records []*model.CommissionRecord) error {

View File

@@ -0,0 +1,86 @@
package commission_withdrawal
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"
)
func (s *Service) appendWithdrawalDecisionAudit(ctx context.Context, tx *gorm.DB, before *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, actionCode, summary string, amount int64) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金提现统一审计接缝未配置")
}
var saved model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).First(&saved, before.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批审计快照失败")
}
expectedStatus := constants.WithdrawalStatusApproved
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
expectedStatus = constants.WithdrawalStatusRejected
}
if saved.Status != expectedStatus {
return errors.New(errors.CodeInvalidStatus, "提现申请终态更新未生效")
}
var shop model.Shop
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批店铺审计快照失败")
}
primary := audit.CommissionWithdrawalResource(&saved, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
withdrawalDecisionState(before), withdrawalDecisionState(&saved))
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = summary
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance - amount})
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
suffix := "approved"
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
suffix = "rejected"
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(saved.ID), 10) + ":" + suffix,
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: saved.WithdrawalNo,
Metadata: map[string]any{"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount, "status": saved.Status},
Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource},
})
}
func (s *Service) recordWithdrawalDecisionFailure(ctx context.Context, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, actionCode, summary string, businessErr error) {
if businessErr == nil || withdrawal == nil || withdrawal.WithdrawalNo == "" || s.auditWriter == nil || s.db == nil {
return
}
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
withdrawalDecisionState(withdrawal), nil)
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary}
if wallet != nil {
resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalWallet, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
CorrelationID: withdrawal.WithdrawalNo, Resources: resources,
}, businessErr)
}
func withdrawalDecisionState(withdrawal *model.CommissionWithdrawalRequest) map[string]any {
return map[string]any{
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "actual_amount": withdrawal.ActualAmount,
"withdrawal_method": withdrawal.WithdrawalMethod, "payment_type": withdrawal.PaymentType,
"status": withdrawal.Status, "processor_id": withdrawal.ProcessorID,
"processed_at": withdrawal.ProcessedAt, "paid_at": withdrawal.PaidAt,
"reject_reason": withdrawal.RejectReason, "remark": withdrawal.Remark,
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
"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"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -22,6 +23,12 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
auditWriter *audit.Writer
}
// SetAuditWriter 注入佣金提现审批统一审计 Writer。
func (s *Service) SetAuditWriter(writer *audit.Writer) {
s.auditWriter = writer
}
func New(
@@ -154,13 +161,17 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
}
if withdrawal.Status != constants.WithdrawalStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
// 获取店铺分佣钱包
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
amount := withdrawal.Amount
@@ -169,7 +180,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
}
if wallet.FrozenBalance < amount {
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
businessErr := errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
now := time.Now()
@@ -239,10 +252,11 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
}
return nil
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalApproved, "佣金提现申请已通过", amount)
})
if err != nil {
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", err)
return nil, err
}
@@ -267,12 +281,16 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
}
if withdrawal.Status != constants.WithdrawalStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
return nil, businessErr
}
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
return nil, businessErr
}
now := time.Now()
@@ -312,10 +330,11 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
}
return nil
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalRejected, "佣金提现申请已驳回", withdrawal.Amount)
})
if err != nil {
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", err)
return nil, err
}

View File

@@ -0,0 +1,200 @@
package customer_binding
import (
"context"
"strconv"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SetAccessAudit 注入个人客户资产关系的统一审计接缝。
func (s *Service) SetAccessAudit(writer accessauditapp.Writer) {
s.accessAudit = writer
}
func (s *Service) writeBindingAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary string,
customerID uint,
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
cards []accessauditapp.IotCardChange,
devices []accessauditapp.DeviceChange,
) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
var customer model.PersonalCustomer
if err := tx.WithContext(ctx).First(&customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户审计快照失败")
}
value := auditcontext.From(ctx)
operatorID := customerID
actorKind := constants.AuditActorPersonalCustomer
actorName := customer.Nickname
source := constants.AuditSourcePersonalAPI
scopeType := constants.AuditScopePersonalCustomer
visibility := constants.AuditSubjectDetail
subjectData := map[string]any(nil)
if actionCode == constants.AuditActionPersonalCustomerAssetBound {
assetType, assetID := bindingAssetReference(cards, devices)
subjectData = map[string]any{"asset_type": assetType, "asset_id": assetID}
} else {
operatorID = middleware.GetUserIDFromContext(ctx)
if parsed, err := strconv.ParseUint(value.ActorID, 10, 64); err == nil && parsed > 0 {
operatorID = uint(parsed)
}
actorKind = value.ActorKind
actorName = value.ActorName
source = value.Source
scopeType = constants.AuditScopePlatform
visibility = constants.AuditSubjectResult
}
if operatorID == 0 {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计操作者不完整")
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: constants.AuditResultSuccess,
OperatorID: operatorID, ActorKind: actorKind, ActorName: actorName, Source: source, ScopeType: scopeType,
PersonalCustomer: &customer, PersonalDevices: personalDevices, PersonalICCIDs: personalICCIDs,
Cards: cards, Devices: devices,
SubjectVisibility: visibility, SubjectSummary: summary, SubjectData: subjectData,
})
}
func bindingAssetReference(cards []accessauditapp.IotCardChange, devices []accessauditapp.DeviceChange) (string, uint) {
if len(cards) > 0 && cards[0].Card != nil {
return constants.AuditResourceIotCard, cards[0].Card.ID
}
if len(devices) > 0 && devices[0].Device != nil {
return constants.AuditResourceDevice, devices[0].Device.ID
}
return "", 0
}
func cardAuditChange(card *model.IotCard, relation, role string, beforeData, afterData map[string]any) accessauditapp.IotCardChange {
return accessauditapp.IotCardChange{
Card: card, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
SubjectSummary: "个人客户资产关系已更新",
}
}
func deviceAuditChange(device *model.Device, relation, role string, beforeData, afterData map[string]any) accessauditapp.DeviceChange {
return accessauditapp.DeviceChange{
Device: device, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
SubjectSummary: "个人客户资产关系已更新",
}
}
func (s *Service) loadAuditAssets(ctx context.Context, tx *gorm.DB, oldType string, oldID uint, newType string, newID uint) ([]accessauditapp.IotCardChange, []accessauditapp.DeviceChange, error) {
cards := make([]accessauditapp.IotCardChange, 0, 2)
devices := make([]accessauditapp.DeviceChange, 0, 2)
appendAsset := func(assetType string, assetID uint, role string) error {
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, assetID)
if err != nil {
return err
}
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, role, nil, nil))
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
if err != nil {
return err
}
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, role, nil, nil))
default:
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
return nil
}
if err := appendAsset(oldType, oldID, constants.AuditResourceRolePersonalCustomerOldAsset); err != nil {
return nil, nil, err
}
if err := appendAsset(newType, newID, constants.AuditResourceRolePersonalCustomerNewAsset); err != nil {
return nil, nil, err
}
return cards, devices, nil
}
func (s *Service) writeMigrationAudit(
ctx context.Context,
tx *gorm.DB,
customerID uint,
oldType string,
oldID uint,
newType string,
newID uint,
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
) error {
cards, devices, err := s.loadAuditAssets(ctx, tx, oldType, oldID, newType, newID)
if err != nil {
return err
}
return s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetBindingMigrated, "换货迁移个人客户资产绑定",
customerID, personalDevices, personalICCIDs, cards, devices,
)
}
// UnbindByVirtualNo 按现有换货重置语义删除设备号绑定,并在同一事务记录实际删除关系。
func (s *Service) UnbindByVirtualNo(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, virtualNo string) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
records, err := s.makePCD(tx).GetByDeviceNo(ctx, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户资产绑定失败")
}
if err := tx.WithContext(ctx).Where("virtual_no = ?", virtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
for _, record := range records {
if record == nil {
continue
}
cards := []accessauditapp.IotCardChange(nil)
devices := []accessauditapp.DeviceChange(nil)
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, loadErr := s.readCard(ctx, tx, assetID)
if loadErr != nil {
return loadErr
}
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
case assetTypeDevice:
device, loadErr := s.readDevice(ctx, tx, assetID)
if loadErr != nil {
return loadErr
}
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
default:
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
if err := s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetUnbound, "解除个人客户资产绑定", record.CustomerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
AfterData: map[string]any{"deleted": true},
}}, nil, cards, devices,
); err != nil {
return err
}
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -67,8 +68,9 @@ type Service struct {
makePCI func(*gorm.DB) pciOps
markAsSold func(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error
// readCard/readDevice 用于 Bind/Migrate 内部,接收事务 db 以保持读写在同一事务内
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
accessAudit accessauditapp.Writer
}
// New 创建客户绑定服务实例
@@ -216,6 +218,9 @@ func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.Per
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccidIssue 02
func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
@@ -229,10 +234,10 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
return err
}
if card.VirtualNo != "" {
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID)
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID, card, nil)
}
// 无虚拟号路径Issue 02
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID)
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID, card)
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
@@ -243,14 +248,14 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
if key == "" {
key = device.IMEI
}
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID)
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID, nil, device)
}
return errors.New(errors.CodeInvalidParam)
}
// bindViaPCD 通过 tb_personal_customer_device 创建绑定
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint) error {
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint, card *model.IotCard, device *model.Device) error {
count, err := pcd.CountByVirtualNo(ctx, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定数量失败")
@@ -262,8 +267,9 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
}
var record *model.PersonalCustomerDevice
if !exists {
record := &model.PersonalCustomerDevice{
record = &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: virtualNo,
Status: 1,
@@ -274,14 +280,30 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
return nil
if record == nil {
return nil
}
cards := []accessauditapp.IotCardChange(nil)
devices := []accessauditapp.DeviceChange(nil)
if card != nil {
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
if device != nil {
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
}}, nil, cards, devices)
}
// bindViaPCI 通过 tb_personal_customer_iccid 创建绑定无虚拟号卡专用Issue 02
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint) error {
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint, card *model.IotCard) error {
count, err := pci.CountByICCID(ctx, iccid)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询 ICCID 绑定数量失败")
@@ -293,12 +315,13 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
return errors.Wrap(errors.CodeInternalError, err, "查询客户 ICCID 绑定关系失败")
}
var record *model.PersonalCustomerICCID
if !exists {
iccid19 := iccid
if len(iccid) == 20 {
iccid19 = iccid[:19]
}
record := &model.PersonalCustomerICCID{
record = &model.PersonalCustomerICCID{
CustomerID: customerID,
ICCID: iccid,
ICCID19: iccid19,
@@ -310,15 +333,28 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
return nil
if record == nil {
return nil
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
nil, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"iccid": record.ICCID, "status": record.Status},
}}, []accessauditapp.IotCardChange{
cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil),
}, nil)
}
// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用)
// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci
func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
@@ -332,9 +368,9 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
return err
}
if oldCard.VirtualNo != "" {
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, newAssetType, newAssetID)
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, oldAssetType, oldAssetID, newAssetType, newAssetID)
}
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, newAssetType, newAssetID)
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, oldAssetType, oldAssetID, newAssetType, newAssetID)
case assetTypeDevice:
oldDevice, err := s.readDevice(ctx, tx, oldAssetID)
@@ -345,14 +381,14 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
if key == "" {
key = oldDevice.IMEI
}
return s.migrateFromPCD(ctx, tx, pcd, pci, key, newAssetType, newAssetID)
return s.migrateFromPCD(ctx, tx, pcd, pci, key, oldAssetType, oldAssetID, newAssetType, newAssetID)
}
return errors.New(errors.CodeInvalidParam)
}
// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, newAssetType string, newAssetID uint) error {
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
records, err := pcd.GetByDeviceNo(ctx, oldKey)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧资产绑定记录失败")
@@ -381,6 +417,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newCard.VirtualNo); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移客户绑定关系失败")
}
after := *rec
after.VirtualNo = newCard.VirtualNo
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
}}, nil); err != nil {
return err
}
}
} else {
// 新卡无虚拟号:禁用旧 pcd + 创建新 pci
@@ -401,6 +447,17 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": rec.VirtualNo, "status": 0},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
}}); err != nil {
return err
}
}
}
@@ -417,6 +474,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newKey); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移设备客户绑定关系失败")
}
after := *rec
after.VirtualNo = newKey
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
}}, nil); err != nil {
return err
}
}
default:
@@ -427,7 +494,7 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
}
// migrateFromPCI 将 tb_personal_customer_iccid 中 oldICCID 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, newAssetType string, newAssetID uint) error {
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
records, err := pci.GetByICCID(ctx, oldICCID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 绑定记录失败")
@@ -463,6 +530,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
}}); err != nil {
return err
}
}
} else {
// 新卡也无虚拟号:禁用旧 pci + 创建新 pci新 ICCID
@@ -483,6 +561,20 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新 ICCID 绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
nil, []accessauditapp.PersonalCustomerICCIDChange{
{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
},
{
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
},
}); err != nil {
return err
}
}
}
@@ -507,6 +599,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
}}); err != nil {
return err
}
}
default:

View File

@@ -1,117 +0,0 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"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/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func (s *Service) appendCSVBatchAllocationAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
succeededIDs []uint,
failedItems []dto.AllocationDeviceFailedItem,
targetShopID uint,
) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
linkage.CorrelationID == "" {
return nil
}
devicesByID := make(map[uint]*model.Device, len(devices))
for _, device := range devices {
if device != nil {
devicesByID[device.ID] = device
}
}
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
for _, deviceID := range succeededIDs {
if device := devicesByID[deviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
}
}
for _, item := range failedItems {
if device := devicesByID[item.DeviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
}
}
result := constants.AuditResultSuccess
if len(succeededIDs) > 0 && len(failedItems) > 0 {
result = constants.AuditResultPartial
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
Summary: "设备CSV批量分配完成", Result: result,
CorrelationID: linkage.CorrelationID,
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func deviceBatchChild(
device *model.Device,
parentEventID string,
correlationID string,
targetShopID uint,
succeeded bool,
reason string,
) audit.AppendInput {
resourceID := strconv.FormatUint(uint64(device.ID), 10)
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
after := before
result := constants.AuditResultFailed
summary := "设备批量分配失败"
if succeeded {
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
result = constants.AuditResultSuccess
summary = "设备批量分配成功"
}
return audit.AppendInput{
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
IdentitySnapshot: map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
},
BeforeData: before, AfterData: after,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}},
}
}
func stableBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
}
func deviceAuditKey(device *model.Device) string {
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
if value != "" {
return value
}
}
return strconv.FormatUint(uint64(device.ID), 10)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -80,105 +81,29 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "设备不存在")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
return nil, appErr
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
return nil, err
}
metadata := map[string]any{"iot_card_id": req.IotCardID, "slot_position": req.SlotPosition}
if req.SlotPosition > device.MaxSimSlots {
appErr := errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition)
if err != nil && err != gorm.ErrRecordNotFound {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
if existingBinding != nil {
appErr := errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
@@ -186,88 +111,30 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeIotCardNotFound)
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, appErr)
return nil, appErr
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
item := deviceBindingAuditItem{
Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard,
CardBefore: map[string]any{"device_id": nil, "slot_position": nil},
CardAfter: map[string]any{"device_id": device.ID, "slot_position": req.SlotPosition},
}
activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil && err != gorm.ErrRecordNotFound {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, []deviceBindingAuditItem{item}, metadata, err)
return nil, err
}
if activeBinding != nil {
appErr := errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, []deviceBindingAuditItem{item}, metadata, appErr)
return nil, appErr
}
@@ -278,29 +145,26 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
BindStatus: 1,
}
if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewDeviceSimBindingStore(tx, nil).Create(ctx, binding); err != nil {
return err
}
item.Binding = binding
item.BindingRole = constants.AuditResourceRoleDeviceCreatedBinding
item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, false)
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditResultSuccess,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": nil},
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": card.ID},
[]deviceBindingAuditItem{item}, metadata, nil)
})
if err != nil {
result := constants.AuditResultFailed
if appErr, ok := err.(*errors.AppError); ok && (appErr.Code == errors.CodeConflict || appErr.Code == errors.CodeIotCardBoundToDevice) {
result = constants.AuditResultDenied
}
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", result,
device, nil, []deviceBindingAuditItem{{Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard}}, metadata, err)
return nil, err
}
@@ -313,32 +177,6 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
)
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡",
constants.AssetAuditResultSuccess,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"binding_id": binding.ID,
"slot_position": req.SlotPosition,
"iot_card_id": card.ID,
"iccid": card.ICCID,
},
1,
1,
0,
nil,
)
return &dto.BindCardToDeviceResponse{
BindingID: binding.ID,
Message: "绑定成功",
@@ -349,111 +187,54 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "设备不存在")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": cardID,
},
0,
0,
0,
appErr,
)
return nil, appErr
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": cardID,
},
0,
0,
0,
err,
)
return nil, err
}
metadata := map[string]any{"iot_card_id": cardID}
binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "该卡未绑定到此设备")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": cardID},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": cardID},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
var cardAudit map[string]any
if card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID); cardErr == nil {
cardAudit = map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID)
if cardErr != nil {
card = &model.IotCard{}
card.ID = binding.IotCardID
}
item := deviceBindingAuditItem{
Card: card, Binding: binding,
CardRole: constants.AuditResourceRoleDeviceBindingTargetCard, BindingRole: constants.AuditResourceRoleDeviceRemovedBinding,
CardBefore: map[string]any{"device_id": device.ID, "slot_position": binding.SlotPosition},
CardAfter: map[string]any{"device_id": nil, "slot_position": nil},
BindingBefore: bindingStateData(binding, constants.BindStatusBound, binding.IsCurrent),
BindingAfter: bindingStateData(binding, constants.BindStatusUnbound, false),
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewDeviceSimBindingStore(tx, nil).Unbind(ctx, binding.ID); err != nil {
return err
}
}
beforeAuditData := map[string]any{
"device": deviceSnapshot(device),
"binding_id": binding.ID,
"iot_card_id": binding.IotCardID,
}
if cardAudit != nil {
beforeAuditData["card"] = cardAudit
}
if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", binding.ID).Update("is_current", false).Error; err != nil {
return err
}
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditResultSuccess,
device,
beforeAuditData,
nil,
0,
0,
0,
err,
)
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID},
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": nil},
[]deviceBindingAuditItem{item}, metadata, nil)
})
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
device, nil, []deviceBindingAuditItem{item}, metadata, err)
return nil, err
}
@@ -466,28 +247,6 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
)
}
afterAuditData := map[string]any{
"iot_card_id": cardID,
"unbind": true,
}
if cardAudit != nil {
afterAuditData["card"] = cardAudit
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡",
constants.AssetAuditResultSuccess,
device,
beforeAuditData,
afterAuditData,
1,
1,
0,
nil,
)
return &dto.UnbindCardFromDeviceResponse{
Message: "解绑成功",
}, nil

View File

@@ -0,0 +1,236 @@
package device
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type deviceBindingAuditItem struct {
Card *model.IotCard
Binding *model.DeviceSimBinding
CardRole string
BindingRole string
CardBefore map[string]any
CardAfter map[string]any
BindingBefore map[string]any
BindingAfter map[string]any
}
type deviceBindingState struct {
bindings []*model.DeviceSimBinding
cards map[uint]*model.IotCard
target *model.DeviceSimBinding
current *model.DeviceSimBinding
}
func (s *Service) appendDeviceBindingAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
deviceBefore, deviceAfter map[string]any,
items []deviceBindingAuditItem,
metadata map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: deviceBefore, AfterData: deviceAfter,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
for index, item := range items {
if item.Card != nil && item.Card.ID > 0 {
cardID := strconv.FormatUint(uint64(item.Card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: item.CardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), BeforeData: item.CardBefore, AfterData: item.CardAfter,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, SortOrder: index*2 + 1,
})
}
if item.Binding != nil && item.Binding.ID > 0 {
bindingID := strconv.FormatUint(uint64(item.Binding.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: item.BindingRole,
IdentitySnapshot: deviceBindingIdentity(device, item.Card, item.Binding),
BeforeData: item.BindingBefore, AfterData: item.BindingAfter,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index*2 + 2,
})
}
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: metadata, Resources: resources,
})
}
func (s *Service) recordDeviceBindingAuditFailure(
ctx context.Context,
actionCode, summary, result string,
device *model.Device,
deviceBefore map[string]any,
items []deviceBindingAuditItem,
metadata map[string]any,
businessErr error,
) {
deviceID := uint(0)
if device != nil {
deviceID = device.ID
}
if s.db == nil || s.auditWriter == nil || deviceID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceBindingAudit(ctx, tx, actionCode, summary, result, device, deviceBefore, nil, items, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, err)
}
}
func deviceBindingIdentity(device *model.Device, card *model.IotCard, binding *model.DeviceSimBinding) map[string]any {
identity := map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
"iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent,
}
if device != nil {
identity["device_virtual_no"] = device.VirtualNo
}
if card != nil {
identity["iccid"] = card.ICCID
identity["virtual_no"] = card.VirtualNo
}
return identity
}
func bindingStateData(binding *model.DeviceSimBinding, bindStatus int, isCurrent bool) map[string]any {
return map[string]any{
"slot_position": binding.SlotPosition,
"bind_status": bindStatus,
"is_current": isCurrent,
}
}
func loadDeviceBindingState(ctx context.Context, db *gorm.DB, deviceID uint, targetICCID string, lock bool) (*deviceBindingState, error) {
query := db.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).Order("slot_position ASC")
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
state := &deviceBindingState{cards: make(map[uint]*model.IotCard)}
if err := query.Find(&state.bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡槽关系失败")
}
cardIDs := make([]uint, 0, len(state.bindings))
for _, binding := range state.bindings {
cardIDs = append(cardIDs, binding.IotCardID)
if binding.IsCurrent {
state.current = binding
}
}
if len(cardIDs) > 0 {
var cards []*model.IotCard
if err := db.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡失败")
}
for _, card := range cards {
state.cards[card.ID] = card
}
}
targetICCID = strings.TrimSpace(targetICCID)
for _, binding := range state.bindings {
if card := state.cards[binding.IotCardID]; card != nil && cardMatchesICCID(card, targetICCID) {
state.target = binding
break
}
}
return state, nil
}
func switchCardAuditItems(state *deviceBindingState) []deviceBindingAuditItem {
items := make([]deviceBindingAuditItem, 0, 2)
if state.current != nil {
oldCurrentAfter := false
if state.target != nil && state.current.ID == state.target.ID {
oldCurrentAfter = true
}
items = append(items, deviceBindingAuditItem{
Card: state.cards[state.current.IotCardID], Binding: state.current,
CardRole: constants.AuditResourceRoleDeviceOldCurrentCard, BindingRole: constants.AuditResourceRoleDeviceOldCurrentBinding,
CardBefore: map[string]any{"is_current": true}, CardAfter: map[string]any{"is_current": oldCurrentAfter},
BindingBefore: bindingStateData(state.current, constants.BindStatusBound, true),
BindingAfter: bindingStateData(state.current, constants.BindStatusBound, oldCurrentAfter),
})
}
if state.target != nil {
wasCurrent := state.target.IsCurrent
items = append(items, deviceBindingAuditItem{
Card: state.cards[state.target.IotCardID], Binding: state.target,
CardRole: constants.AuditResourceRoleDeviceNewCurrentCard, BindingRole: constants.AuditResourceRoleDeviceNewCurrentBinding,
CardBefore: map[string]any{"is_current": wasCurrent}, CardAfter: map[string]any{"is_current": true},
BindingBefore: bindingStateData(state.target, constants.BindStatusBound, wasCurrent),
BindingAfter: bindingStateData(state.target, constants.BindStatusBound, true),
})
}
return items
}
func currentCardID(state *deviceBindingState) uint {
if state == nil || state.current == nil {
return 0
}
return state.current.IotCardID
}
func loadDeviceUnbindAuditReferences(ctx context.Context, tx *gorm.DB, device *model.Device) ([]audit.ResourceInput, error) {
referencesByDevice, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return nil, err
}
references := referencesByDevice[device.ID]
for index := range references {
resource := &references[index]
resource.Relation = constants.AuditResourceRelationAffected
switch resource.Type {
case constants.AuditResourceIotCard:
resource.Role = constants.AuditResourceRoleDeviceBindingTargetCard
resource.BeforeData = map[string]any{"device_id": device.ID}
resource.AfterData = map[string]any{"device_id": nil}
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = "设备删除并解绑 IoT 卡"
case constants.AuditResourceDeviceSIMBinding:
resource.Role = constants.AuditResourceRoleDeviceRemovedBinding
resource.BeforeData = map[string]any{
"slot_position": resource.IdentitySnapshot["slot_position"],
"bind_status": constants.BindStatusBound,
"is_current": resource.IdentitySnapshot["is_current"],
}
resource.AfterData = map[string]any{
"slot_position": resource.IdentitySnapshot["slot_position"],
"bind_status": constants.BindStatusUnbound,
"is_current": false,
}
}
}
return references, nil
}

View File

@@ -0,0 +1,294 @@
package device
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"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"
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 deviceGatewayIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
}
// SetGatewayIntegrationLog 注入设备外部命令的 Integration Log 接缝。
func (s *Service) SetGatewayIntegrationLog(integration deviceGatewayIntegrationLog) {
s.gatewayIntegration = integration
}
type deviceGatewayResource struct {
Type string
ID string
Key string
ExternalID string
RequestSummary map[string]any
}
type deviceGatewayAttempt struct {
log *model.IntegrationLog
startedAt time.Time
}
type deviceGatewayAttemptObserver struct {
service *Service
operation string
scene string
seriesKey string
resource deviceGatewayResource
current *deviceGatewayAttempt
successful *deviceGatewayAttempt
integration string
unknown bool
}
func (o *deviceGatewayAttemptObserver) BeforeAttempt(ctx context.Context, attempt int) error {
started, err := o.service.startDeviceGatewayAttempt(ctx, o.operation, o.scene, o.seriesKey, attempt, o.resource)
if err != nil {
return err
}
o.current = started
o.integration = started.log.IntegrationID
return nil
}
func (o *deviceGatewayAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
if isDeviceGatewayTimeout(callErr) {
o.unknown = true
}
if callErr == nil {
o.successful = o.current
o.current = nil
return nil
}
err := o.service.completeDeviceGatewayAttempt(ctx, o.current, callErr, false)
o.current = nil
return err
}
func (o *deviceGatewayAttemptObserver) completeSuccess(ctx context.Context, stateChanged bool) error {
return o.service.completeDeviceGatewayAttempt(ctx, o.successful, nil, stateChanged)
}
func (s *Service) startDeviceGatewayAttempt(
ctx context.Context,
operation, scene, seriesKey string,
attempt int,
resource deviceGatewayResource,
) (*deviceGatewayAttempt, error) {
if s == nil || s.gatewayIntegration == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备 Gateway Integration Log 接缝未配置")
}
linkage := auditcontext.From(ctx)
triggerSource := linkage.Source
if triggerSource == "" {
triggerSource = "service"
}
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-device-command:"+seriesKey+":"+operation+":"+resource.Type+":"+resource.ID)).String()
var requestID, correlationID *string
if linkage.RequestID != "" {
requestID = &linkage.RequestID
}
if linkage.CorrelationID != "" {
correlationID = &linkage.CorrelationID
} else {
correlationID = requestID
}
log, err := s.gatewayIntegration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ExternalID: &resource.ExternalID,
ResourceType: resource.Type, ResourceID: &resource.ID, ResourceKey: &resource.Key,
TriggerSource: &triggerSource, TriggerScene: &scene, TriggerSeries: &triggerSeries,
Attempt: attempt, RequestID: requestID, CorrelationID: correlationID,
RequestSummary: resource.RequestSummary,
})
if err != nil {
return nil, err
}
return &deviceGatewayAttempt{log: log, startedAt: time.Now()}, nil
}
func (s *Service) completeDeviceGatewayAttempt(ctx context.Context, attempt *deviceGatewayAttempt, callErr error, stateChanged bool) error {
if attempt == nil || attempt.log == nil {
return nil
}
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
}
if callErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "Gateway 设备命令失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isDeviceGatewayTimeout(callErr) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 设备命令结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewayDeviceCommandUnknownRecoveryStrategy
}
}
_, err := s.gatewayIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
return err
}
type deviceGatewayCommand struct {
ActionCode string
Summary string
Operation string
Scene string
RequestSummary map[string]any
Metadata map[string]any
TargetCard *model.IotCard
Call func(context.Context) error
}
func (s *Service) executeDeviceGatewayCommand(ctx context.Context, device *model.Device, command deviceGatewayCommand) error {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
seriesKey := deviceCommandSeriesKey(ctx)
observer := &deviceGatewayAttemptObserver{
service: s, operation: command.Operation, scene: command.Scene, seriesKey: seriesKey,
resource: deviceGatewayResource{
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device),
ExternalID: device.IMEI, RequestSummary: command.RequestSummary,
},
}
callErr := command.Call(gateway.WithAttemptObserver(ctx, observer))
metadata := cloneDeviceCommandMetadata(command.Metadata)
metadata["integration_id"] = observer.integration
if callErr != nil {
result := constants.AuditResultFailed
summary := command.Summary + "失败"
if observer.unknown {
result = constants.AuditResultUnknown
summary = command.Summary + "结果未知"
}
s.recordDeviceCommandAudit(ctx, command.ActionCode, summary, result, device, command.TargetCard, nil, nil, metadata, callErr)
return callErr
}
if err := observer.completeSuccess(ctx, false); err != nil {
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary+"结果未知", constants.AuditResultUnknown,
device, command.TargetCard, nil, nil, metadata, err)
return errors.Wrap(errors.CodeDatabaseError, err, "终结设备 Gateway Integration Log 失败")
}
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary, constants.AuditResultSuccess,
device, command.TargetCard, nil, nil, metadata, nil)
return nil
}
func cloneDeviceCommandMetadata(source map[string]any) map[string]any {
result := make(map[string]any, len(source)+1)
for key, value := range source {
result[key] = value
}
return result
}
func deviceCommandSeriesKey(ctx context.Context) string {
linkage := auditcontext.From(ctx)
if linkage.CorrelationID != "" {
return linkage.CorrelationID
}
if linkage.RequestID != "" {
return linkage.RequestID
}
return uuid.NewString()
}
func isDeviceGatewayTimeout(err error) bool {
var appErr *errors.AppError
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == errors.CodeGatewayTimeout
}
func (s *Service) appendDeviceCommandAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
targetCard *model.IotCard,
cardBefore, cardAfter, metadata map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整")
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return err
}
if targetCard != nil && targetCard.ID > 0 {
targetID := strconv.FormatUint(uint64(targetCard.ID), 10)
found := false
for i := range cardReferences[device.ID] {
resource := &cardReferences[device.ID][i]
if resource.Type == constants.AuditResourceIotCard && resource.ID != nil && *resource.ID == targetID {
found = true
if cardBefore != nil || cardAfter != nil {
resource.Relation = constants.AuditResourceRelationAffected
resource.BeforeData = cardBefore
resource.AfterData = cardAfter
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
} else {
resource.Role = constants.AuditResourceRoleDeviceCommandTargetCard
}
}
}
if !found {
cardReferences[device.ID] = append(cardReferences[device.ID], audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &targetID,
Key: audit.IotCardResourceKey(targetCard), DisplayName: targetCard.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCommandTargetCard,
IdentitySnapshot: audit.IotCardIdentitySnapshot(targetCard),
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, cardReferences[device.ID]...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: metadata, Resources: resources,
})
}
func (s *Service) recordDeviceCommandAudit(
ctx context.Context,
actionCode, summary, result string,
device *model.Device,
targetCard *model.IotCard,
cardBefore, cardAfter, metadata map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || device == nil || device.ID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceCommandAudit(ctx, tx, actionCode, summary, result, device, targetCard, cardBefore, cardAfter, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
}
}
var _ deviceGatewayIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"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"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -51,73 +52,31 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g
func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{
SSIDName: req.SSID,
SSIDPassword: req.Password,
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceWiFiSet,
Summary: "设置设备 Wi-Fi",
Operation: constants.IntegrationOperationGatewaySetWiFi,
Scene: constants.CardObservationSceneDeviceSetWiFi,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "ssid": req.SSID,
"enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
},
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"imei": imei,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
err,
)
Metadata: map[string]any{
"ssid": req.SSID, "enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
},
Call: func(callCtx context.Context) error {
return s.gatewayClient.SetWiFi(callCtx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{SSIDName: req.SSID, SSIDPassword: req.Password},
})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"imei": imei,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
return nil
}
@@ -126,58 +85,92 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req *dto.SwitchCardRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"target_iccid": req.TargetICCID,
},
0,
0,
0,
err,
)
return err
}
state, err := loadDeviceBindingState(ctx, s.db, device.ID, req.TargetICCID, false)
metadata := map[string]any{"target_iccid": req.TargetICCID}
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return err
}
if state.target == nil {
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡被拒绝", constants.AuditResultDenied,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
targetCard := state.cards[state.target.IotCardID]
if targetCard == nil {
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
metadata["target_iot_card_id"] = targetCard.ID
metadata["target_slot_position"] = state.target.SlotPosition
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
deviceID := strconv.FormatUint(uint64(device.ID), 10)
observer := &deviceGatewayAttemptObserver{
service: s, operation: constants.IntegrationOperationGatewaySwitchCard,
scene: constants.CardObservationSceneDeviceSwitchCard, seriesKey: deviceCommandSeriesKey(ctx),
resource: deviceGatewayResource{
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device), ExternalID: imei,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "target_iot_card_id": targetCard.ID,
"target_iccid": targetCard.ICCID, "target_slot_position": state.target.SlotPosition,
},
},
}
if err = s.gatewayClient.SwitchCard(gateway.WithAttemptObserver(ctx, observer), &gateway.SwitchCardReq{
CardNo: imei,
ICCID: req.TargetICCID,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"target_iccid": req.TargetICCID},
0,
0,
0,
err,
)
result, summary := constants.AuditResultFailed, "设备切卡失败"
if observer.unknown {
result, summary = constants.AuditResultUnknown, "设备切卡结果未知"
}
metadata["integration_id"] = observer.integration
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, summary, result,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
return err
}
metadata["integration_id"] = observer.integration
if err := observer.completeSuccess(ctx, false); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "终结设备切卡 Integration Log 失败")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedState, err := loadDeviceBindingState(ctx, tx, device.ID, targetCard.ICCID, true)
if err != nil {
return err
}
if lockedState.target == nil {
return errors.New(errors.CodeConflict, "切卡期间目标卡绑定关系已变化")
}
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("id = ? AND bind_status = ?", lockedState.target.ID, constants.BindStatusBound).
Update("is_current", true).Error; err != nil {
return err
}
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", constants.AuditResultSuccess,
device,
map[string]any{"current_iot_card_id": currentCardID(lockedState)},
map[string]any{"current_iot_card_id": lockedState.target.IotCardID},
switchCardAuditItems(lockedState), metadata, nil)
})
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"target_iccid": req.TargetICCID},
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
return nil
}
@@ -186,54 +179,21 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{"identifier": identifier},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceRebooted, Summary: "重启设备",
Operation: constants.IntegrationOperationGatewayReboot, Scene: constants.CardObservationSceneDeviceReboot,
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
Metadata: map[string]any{"requested_action": "reboot"},
Call: func(callCtx context.Context) error {
return s.gatewayClient.RebootDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
return nil
}
@@ -242,54 +202,21 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{"identifier": identifier},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceReset, Summary: "恢复设备出厂设置",
Operation: constants.IntegrationOperationGatewayReset, Scene: constants.CardObservationSceneDeviceReset,
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
Metadata: map[string]any{"requested_action": "factory_reset"},
Call: func(callCtx context.Context) error {
return s.gatewayClient.ResetDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
return nil
}
@@ -303,244 +230,77 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
},
0,
0,
0,
err,
)
return err
}
recordRejected := func(summary, result string, businessErr error, targetCard *model.IotCard) error {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceSwitchModeSet, summary, result,
device, targetCard, nil, nil,
map[string]any{"requested_switch_mode": switchMode, "iot_card_id": req.IotCardID}, businessErr)
return businessErr
}
if req.SwitchMode == nil {
appErr := errors.New(errors.CodeInvalidParam, "切卡模式不能为空")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
if switchMode != 0 && switchMode != 1 {
appErr := errors.New(errors.CodeInvalidParam, "切卡模式仅支持0或1")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
if req.IotCardID == 0 {
appErr := errors.New(errors.CodeInvalidParam, "目标卡资产ID不能为空")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
targetCard, err := s.iotCardStore.GetByID(ctx, req.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询目标卡资产失败")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, nil)
}
if _, err = s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, targetCard.ID); err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备,禁止设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡绑定关系失败")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, targetCard)
}
if targetCard.ICCID == "" {
appErr := errors.New(errors.CodeConflict, "目标卡资产缺少ICCID无法设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
if targetCard.NetworkStatus != constants.NetworkStatusOnline {
appErr := errors.New(errors.CodeForbidden, "目标卡状态异常,仅正常状态的卡允许设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
"iccid": targetCard.ICCID,
"network_status": targetCard.NetworkStatus,
"real_name_status": targetCard.RealNameStatus,
},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
if targetCard.RealNameStatus != constants.RealNameStatusVerified {
appErr := errors.New(errors.CodeForbidden, "目标卡未实名,禁止设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
"iccid": targetCard.ICCID,
"network_status": targetCard.NetworkStatus,
"real_name_status": targetCard.RealNameStatus,
},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
CardNo: imei,
SwitchMode: strconv.Itoa(switchMode),
ICCID: targetCard.ICCID,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceSwitchModeSet, Summary: "设置设备切卡模式",
Operation: constants.IntegrationOperationGatewaySwitchMode, Scene: constants.CardObservationSceneDeviceSwitchMode,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "switch_mode": switchMode,
"iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
},
Metadata: map[string]any{
"requested_switch_mode": switchMode, "iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
},
TargetCard: targetCard,
Call: func(callCtx context.Context) error {
return s.gatewayClient.SwitchMode(callCtx, &gateway.SwitchModeReq{
CardNo: imei, SwitchMode: strconv.Itoa(switchMode), ICCID: targetCard.ICCID,
})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
return nil
}

View File

@@ -21,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if err != nil {
return nil, err
}
var devices []*model.Device
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var devices []model.Device
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.Device{})).Clauses(clause.Locking{Strength: "UPDATE"})
if err := query.Where("id IN ?", ids).Find(&devices).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量设备资产失败")
@@ -30,31 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if len(devices) != len(ids) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result := tx.Model(&model.Device{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
changedIDs := make([]uint, 0, len(devices))
for _, device := range devices {
if device != nil && device.RealnamePolicy != req.RealnamePolicy {
changedIDs = append(changedIDs, device.ID)
}
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
if len(changedIDs) > 0 {
result := tx.Model(&model.Device{}).Where("id IN ?", changedIDs).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
}
if result.RowsAffected != int64(len(changedIDs)) {
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
}
}
return nil
return s.appendDeviceRealnamePolicyBatchAudit(ctx, tx, devices, req.RealnamePolicy)
})
if err != nil {
result := constants.AuditResultFailed
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden {
result = constants.AuditResultDenied
}
s.recordDeviceRealnamePolicyBatchFailure(ctx, devices, req.RealnamePolicy, result, err)
return nil, err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpAssetRealnamePolicy,
"批量更新设备实名认证策略",
constants.AssetAuditResultSuccess,
nil,
nil,
map[string]any{"asset_ids": ids, "realname_policy": req.RealnamePolicy},
len(ids),
len(ids),
0,
nil,
)
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"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/internal/model/dto"
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"
)
// SetAccessAudit 注入设备身份生命周期的统一审计 Writer。
func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) appendDeviceLifecycleAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
beforeData, afterData map[string]any,
references []audit.ResourceInput,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置或资源不完整")
}
resourceID := strconv.FormatUint(uint64(device.ID), 10)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, references...)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Resources: resources,
})
}
func (s *Service) recordDeviceLifecycleFailure(ctx context.Context, actionCode, summary, result string, device *model.Device, deviceID uint, businessErr error) {
if device == nil {
device = &model.Device{}
device.ID = deviceID
}
if s.db == nil || s.auditWriter == nil || device.ID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceLifecycleAudit(ctx, tx, actionCode, summary, result, device, nil, nil, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
}
}
func recordDeviceAuditSecondaryFailure(ctx context.Context, actionCode string, deviceID uint, businessErr, auditErr error) {
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
actionCode, strconv.FormatUint(uint64(deviceID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, auditErr,
)
}
type deviceAuditOutcome struct {
Result string
Summary string
}
type deviceBatchAuditItem struct {
Device *model.Device
PrimaryRole string
Result string
Summary string
ErrorSummary string
BeforeData map[string]any
AfterData map[string]any
References []audit.ResourceInput
}
type deviceCardAuditChange struct {
ShopID *uint
Status int
}
func deviceAuditOutcomes(devices []*model.Device, result, summary string) map[uint]deviceAuditOutcome {
outcomes := make(map[uint]deviceAuditOutcome, len(devices))
for _, device := range devices {
if device != nil && device.ID > 0 {
outcomes[device.ID] = deviceAuditOutcome{Result: result, Summary: summary}
}
}
return outcomes
}
func setDeviceAuditOutcomes(outcomes map[uint]deviceAuditOutcome, ids []uint, result, summary string) {
for _, id := range ids {
if _, ok := outcomes[id]; ok {
outcomes[id] = deviceAuditOutcome{Result: result, Summary: summary}
}
}
}
func setDeviceAuditFailedItems(outcomes map[uint]deviceAuditOutcome, items []dto.AllocationDeviceFailedItem) {
for _, item := range items {
if _, ok := outcomes[item.DeviceID]; ok {
outcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason}
}
}
}
func deviceModelsByIDs(devices []*model.Device, ids []uint) []*model.Device {
wanted := make(map[uint]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
result := make([]*model.Device, 0, len(ids))
for _, device := range devices {
if device != nil {
if _, ok := wanted[device.ID]; ok {
result = append(result, device)
}
}
}
return result
}
func (s *Service) appendDeviceTransferAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
records []*model.AssetAllocationRecord,
targetShopID *uint,
newStatus, batchTotal, successCount, failCount int,
cardReferences map[uint][]audit.ResourceInput,
businessErr error,
) error {
if cardReferences == nil {
var err error
cardReferences, _, err = loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
}
shops, err := loadDeviceTransferAuditShops(ctx, tx, devices, targetShopID)
if err != nil {
return err
}
recordByDeviceID := make(map[uint]*model.AssetAllocationRecord, len(records))
for _, record := range records {
if record != nil {
recordByDeviceID[record.AssetID] = record
}
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
outcome, ok := outcomes[device.ID]
if !ok {
continue
}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"shop_id": targetShopID, "status": newStatus}
}
references := deviceTransferAuditReferences(device, recordByDeviceID[device.ID], targetShopID, shops)
references = append(references, cardReferences[device.ID]...)
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTransferTarget,
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
BeforeData: map[string]any{"shop_id": device.ShopID, "status": device.Status}, AfterData: afterData,
References: references,
})
}
allocationNo := ""
if len(records) > 0 && records[0] != nil {
allocationNo = records[0].AllocationNo
}
return s.appendDeviceBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
batchTotal, successCount, failCount, items,
map[string]any{"allocation_no": allocationNo, "to_shop_id": targetShopID, "new_status": newStatus}, businessErr)
}
func loadDeviceTransferAuditShops(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetShopID *uint) (map[uint]*model.Shop, error) {
shopIDs := make(map[uint]struct{})
if targetShopID != nil && *targetShopID > 0 {
shopIDs[*targetShopID] = struct{}{}
}
for _, device := range devices {
if device != nil && device.ShopID != nil && *device.ShopID > 0 {
shopIDs[*device.ShopID] = struct{}{}
}
}
ids := make([]uint, 0, len(shopIDs))
for id := range shopIDs {
ids = append(ids, id)
}
var rows []*model.Shop
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
shops := make(map[uint]*model.Shop, len(rows))
for _, shop := range rows {
shops[shop.ID] = shop
}
return shops, nil
}
func deviceTransferAuditReferences(device *model.Device, record *model.AssetAllocationRecord, targetShopID *uint, shops map[uint]*model.Shop) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 3)
if record != nil && record.ID > 0 {
recordID := strconv.FormatUint(uint64(record.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetAllocationRecord, ID: &recordID,
Key: recordID, DisplayName: record.AllocationNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAllocationRecord,
IdentitySnapshot: map[string]any{
"id": record.ID, "allocation_no": record.AllocationNo, "asset_type": record.AssetType,
"asset_id": record.AssetID, "asset_identifier": record.AssetIdentifier,
"from_owner_type": record.FromOwnerType, "from_owner_id": record.FromOwnerID,
"to_owner_type": record.ToOwnerType, "to_owner_id": record.ToOwnerID,
},
AfterData: map[string]any{"created": true}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
if device.ShopID != nil && *device.ShopID > 0 {
resources = appendDeviceShopAuditReference(resources, shops[*device.ShopID], *device.ShopID, constants.AuditResourceRoleTransferSourceShop)
}
if targetShopID != nil && *targetShopID > 0 {
resources = appendDeviceShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop)
}
return resources
}
func appendDeviceShopAuditReference(resources []audit.ResourceInput, shop *model.Shop, shopID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(shopID), 10)
name := id
identity := map[string]any{"id": shopID}
if shop != nil {
name = shop.ShopName
identity = map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func (s *Service) appendDeviceBatchAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
batchTotal, successCount, failCount int,
items []deviceBatchAuditItem,
metadata map[string]any,
businessErr error,
) error {
linkage := auditcontext.From(ctx)
batchKey := linkage.RequestID
if batchKey == "" {
batchKey = linkage.CorrelationID
}
if s.auditWriter == nil || batchKey == "" {
return errors.New(errors.CodeInvalidStatus, "设备批量审计上下文不完整")
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
children := make([]audit.AppendInput, 0, len(items))
for _, item := range items {
if item.Device == nil || item.Device.ID == 0 {
continue
}
deviceID := strconv.FormatUint(uint64(item.Device.ID), 10)
primaryRole := item.PrimaryRole
if primaryRole == "" {
primaryRole = constants.AuditResourceRoleDeviceTarget
}
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: primaryRole,
IdentitySnapshot: audit.DeviceIdentitySnapshot(item.Device), BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: item.Summary,
}}
resources = append(resources, item.References...)
childErrorCode, childErrorSummary := "", ""
if item.Result == constants.AuditResultFailed || item.Result == constants.AuditResultDenied {
childErrorCode = errorCode
childErrorSummary = item.ErrorSummary
if childErrorSummary == "" {
childErrorSummary = errorSummary
}
}
children = append(children, audit.AppendInput{
EventID: stableDeviceBatchEventID(kind+"-"+item.Result+"-device", batchKey+":"+deviceID),
ActionCode: itemAction, Summary: item.Summary, ScopeType: constants.AuditScopePlatform, Result: item.Result,
ErrorCode: childErrorCode, ErrorSummary: childErrorSummary, Resources: resources,
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: stableDeviceBatchEventID(kind+"-"+result, batchKey),
ActionCode: rootAction, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: errorCode, ErrorSummary: errorSummary,
BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, Metadata: metadata,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatch, Key: batchKey, DisplayName: batchKey,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceBatch,
IdentitySnapshot: map[string]any{
"request_id": linkage.RequestID, "correlation_id": linkage.CorrelationID,
"device_count": len(items), "operation_type": kind,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func loadDeviceCardAuditReferences(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
change *deviceCardAuditChange,
) (map[uint][]audit.ResourceInput, []uint, error) {
deviceByID := make(map[uint]*model.Device, len(devices))
deviceIDs := make([]uint, 0, len(devices))
for _, device := range devices {
if device != nil && device.ID > 0 {
deviceByID[device.ID] = device
deviceIDs = append(deviceIDs, device.ID)
}
}
result := make(map[uint][]audit.ResourceInput)
if len(deviceIDs) == 0 {
return result, nil, nil
}
var bindings []*model.DeviceSimBinding
if err := tx.WithContext(ctx).Where("device_id IN ? AND bind_status = ?", deviceIDs, 1).Find(&bindings).Error; err != nil {
return nil, nil, err
}
cardIDs := make([]uint, 0, len(bindings))
seenCards := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if _, exists := seenCards[binding.IotCardID]; !exists {
seenCards[binding.IotCardID] = struct{}{}
cardIDs = append(cardIDs, binding.IotCardID)
}
}
var cards []*model.IotCard
if len(cardIDs) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, nil, err
}
}
cardByID := make(map[uint]*model.IotCard, len(cards))
for _, card := range cards {
cardByID[card.ID] = card
}
for _, binding := range bindings {
device := deviceByID[binding.DeviceID]
card := cardByID[binding.IotCardID]
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
cardID := strconv.FormatUint(uint64(binding.IotCardID), 10)
deviceVirtualNo, cardICCID, cardVirtualNo := "", "", ""
if device != nil {
deviceVirtualNo = device.VirtualNo
}
cardIdentity := map[string]any{"id": binding.IotCardID}
cardKey, cardName := cardID, cardID
if card != nil {
cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo
cardKey, cardName = audit.IotCardResourceKey(card), card.ICCID
cardIdentity = audit.IotCardIdentitySnapshot(card)
}
cardRelation := constants.AuditResourceRelationReference
var beforeData, afterData map[string]any
if change != nil {
cardRelation = constants.AuditResourceRelationAffected
if card != nil {
beforeData = map[string]any{"shop_id": card.ShopID, "status": card.Status}
}
afterData = map[string]any{"shop_id": change.ShopID, "status": change.Status}
}
result[binding.DeviceID] = append(result[binding.DeviceID],
audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: cardKey, DisplayName: cardName,
Relation: cardRelation, Role: constants.AuditResourceRoleDeviceBoundCard,
IdentitySnapshot: cardIdentity, BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
},
audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: deviceVirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCardBinding,
IdentitySnapshot: map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": deviceVirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
"iccid": cardICCID, "virtual_no": cardVirtualNo, "is_current": binding.IsCurrent,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
},
)
}
return result, cardIDs, nil
}
func (s *Service) recordDeviceTransferAuditFailure(
ctx context.Context,
rootAction, itemAction, kind, summary, result string,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
targetShopID *uint,
newStatus, batchTotal, successCount, failCount int,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备批量审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
devices, outcomes, nil, targetShopID, newStatus, batchTotal, successCount, failCount, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err)
}
}
func stableDeviceBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device:"+kind+":"+key)).String()
}
func (s *Service) appendDeviceSeriesBindingAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) error {
series, err := loadDeviceSeriesAuditResources(ctx, tx, devices, seriesID)
if err != nil {
return err
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
outcome, ok := outcomes[device.ID]
if !ok {
continue
}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"series_id": seriesID}
}
references := deviceSeriesAuditReferences(device.SeriesID, seriesID, series)
references = append(references, cardReferences[device.ID]...)
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceSeriesTarget,
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
BeforeData: map[string]any{"series_id": device.SeriesID}, AfterData: afterData,
References: references,
})
}
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceSeriesBindingBatch,
constants.AuditActionDeviceSeriesBound,
"series-binding", "批量设置设备系列绑定", result,
batchTotal, successCount, failCount, items, metadata, businessErr)
}
func loadDeviceSeriesAuditResources(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) {
seriesIDs := make(map[uint]struct{})
if targetSeriesID != nil && *targetSeriesID > 0 {
seriesIDs[*targetSeriesID] = struct{}{}
}
for _, device := range devices {
if device != nil && device.SeriesID != nil && *device.SeriesID > 0 {
seriesIDs[*device.SeriesID] = struct{}{}
}
}
ids := make([]uint, 0, len(seriesIDs))
for id := range seriesIDs {
ids = append(ids, id)
}
var rows []*model.PackageSeries
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
series := make(map[uint]*model.PackageSeries, len(rows))
for _, item := range rows {
series[item.ID] = item
}
return series, nil
}
func deviceSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 2)
if previousID != nil && *previousID > 0 {
resources = appendDevicePackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries)
}
if targetID != nil && *targetID > 0 {
resources = appendDevicePackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries)
}
return resources
}
func appendDevicePackageSeriesAuditReference(resources []audit.ResourceInput, series *model.PackageSeries, seriesID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(seriesID), 10)
name := id
identity := map[string]any{"id": seriesID}
if series != nil {
name = series.SeriesName
identity = map[string]any{"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName, "status": series.Status}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourcePackageSeries, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func (s *Service) recordDeviceSeriesBindingAuditFailure(
ctx context.Context,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备系列绑定审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceSeriesBindingAudit(ctx, tx, devices, outcomes, seriesID, result,
batchTotal, successCount, failCount, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, err)
}
}
func (s *Service) appendDeviceRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, devices []*model.Device, policy string) error {
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 || device.RealnamePolicy == policy {
continue
}
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
Result: constants.AuditResultSuccess, Summary: "更新设备实名策略",
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
AfterData: map[string]any{"realname_policy": policy},
References: cardReferences[device.ID],
})
}
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated,
"realname-policy", "批量更新设备实名策略", constants.AuditResultSuccess,
len(items), len(items), 0, items,
map[string]any{"realname_policy": policy, "requested_count": len(devices)}, nil)
}
func (s *Service) recordDeviceRealnamePolicyBatchFailure(ctx context.Context, devices []*model.Device, policy, result string, businessErr error) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略批量审计接缝未配置或资源不完整"))
return
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
Result: result, Summary: "更新设备实名策略未完成",
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
AfterData: map[string]any{"requested_realname_policy": policy},
})
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated,
"realname-policy", "批量更新设备实名策略未完成", result,
len(devices), 0, len(devices), items, map[string]any{"realname_policy": policy}, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, err)
}
}
func (s *Service) appendDeviceRealnamePolicyAudit(
ctx context.Context,
tx *gorm.DB,
summary, result string,
device *model.Device,
beforeData, afterData map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整")
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return err
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, cardReferences[device.ID]...)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionDeviceRealnamePolicyUpdated, Summary: summary,
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: errorCode, ErrorSummary: errorSummary, Resources: resources,
})
}
func (s *Service) recordDeviceRealnamePolicyFailure(ctx context.Context, device *model.Device, deviceID uint, businessErr error) {
if device == nil || device.ID == 0 || s.db == nil || s.auditWriter == nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceRealnamePolicyAudit(ctx, tx, "更新设备实名策略失败", constants.AuditResultFailed,
device, map[string]any{"realname_policy": device.RealnamePolicy}, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, err)
}
}

View File

@@ -2,11 +2,17 @@ package device_import
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
infraAudit "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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// AssetAuditService 资产审计服务接口。
@@ -14,62 +20,62 @@ type AssetAuditService interface {
LogOperation(ctx context.Context, log *model.AssetOperationLog)
}
func (s *Service) logDeviceImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
if s == nil || s.assetAudit == nil {
func (s *Service) writeDeviceImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.DeviceImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.OperatorShopID != nil {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.OperatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
EventID: infraAudit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, phase),
ActionCode: constants.AuditActionDeviceImportTaskCreated, Summary: "创建设备导入任务",
TaskID: task.ID, TaskNo: task.TaskNo,
Actor: infraAudit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.OperatorShopID,
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
"operation_type": task.OperationType, "target_id": task.TargetID,
"batch_no": task.BatchNo, "realname_policy": task.RealnamePolicy,
},
BeforeData: before, AfterData: after,
})
}
func (s *Service) recordDeviceImportTaskAudit(ctx context.Context, task *model.DeviceImportTask, before, after map[string]any, result, phase string, errorCode int, summary string) {
if s == nil || s.db == nil || s.auditWriter == nil || task == nil || task.TaskNo == "" {
return
}
if p.Operator.Type == "" {
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
}
if p.OperationType == "" {
p.OperationType = constants.AssetAuditOpDeviceImportTaskCreate
}
if p.AssetType == "" {
p.AssetType = constants.AssetTypeDevice
}
p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil)
s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
}
func newDeviceImportAuditParams(
taskID uint,
taskNo string,
req *dto.ImportDeviceRequest,
resultStatus string,
err error,
) assetAuditSvc.BuildLogParams {
afterData := map[string]any{}
if req != nil {
afterData["batch_no"] = req.BatchNo
afterData["file_key"] = req.FileKey
afterData["realname_policy"] = req.RealnamePolicy
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID,
AssetIdentifier: taskNo,
OperationDesc: "创建设备导入任务",
ResultStatus: resultStatus,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AfterData: afterData,
code := strconv.Itoa(errorCode)
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, after, result, phase, code, summary)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionDeviceImportTaskCreated, task.TaskNo, "", task.TaskNo, code, err)
}
}
func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.CreateDeviceBatchAllocationRequest, resultStatus string, err error) assetAuditSvc.BuildLogParams {
afterData := map[string]any{}
if req != nil {
afterData["file_key"] = req.FileKey
afterData["operation_type"] = req.OperationType
if req.OperationType != constants.DeviceImportOperationRecall {
afterData["target_id"] = req.TargetID
func (s *Service) failEnqueueWithAudit(ctx context.Context, task *model.DeviceImportTask, summary string) error {
before := deviceImportTaskState(task)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
if err := tx.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
"status": model.ImportTaskStatusFailed, "error_message": summary, "completed_at": now, "updated_at": now,
}).Error; err != nil {
return err
}
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, summary
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), summary)
})
}
func deviceImportTaskState(task *model.DeviceImportTask) map[string]any {
if task == nil {
return nil
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID, AssetIdentifier: taskNo,
OperationType: constants.AssetAuditOpDeviceBatchTaskCreate,
OperationDesc: "创建设备CSV批量操作任务", ResultStatus: resultStatus,
ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData,
return map[string]any{
"status": task.Status, "total_count": task.TotalCount, "success_count": task.SuccessCount,
"skip_count": task.SkipCount, "fail_count": task.FailCount, "warning_count": task.WarningCount,
}
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"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"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -23,6 +24,7 @@ type Service struct {
importTaskStore *postgres.DeviceImportTaskStore
queueClient *queue.Client
assetAudit AssetAuditService
auditWriter *audit.Writer
}
type DeviceImportPayload struct {
@@ -34,21 +36,24 @@ func New(
importTaskStore *postgres.DeviceImportTaskStore,
queueClient *queue.Client,
assetAudit AssetAuditService,
auditWriters ...*audit.Writer,
) *Service {
return &Service{
service := &Service{
db: db,
importTaskStore: importTaskStore,
queueClient: queueClient,
assetAudit: assetAudit,
}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
appErr := errors.New(errors.CodeUnauthorized, "未授权访问")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
@@ -67,9 +72,17 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
task.Creator = userID
task.Updater = userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备导入任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
return err
}
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备导入任务失败")
return nil, appErr
}
@@ -81,14 +94,13 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
)
if err != nil {
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备导入任务入队失败"); secondaryErr != nil {
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备导入任务入队失败")
}
appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.ImportDeviceResponse{
TaskID: task.ID,
TaskNo: taskNo,
@@ -101,9 +113,7 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
return nil, errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
}
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate {
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务参数不合法")
@@ -136,20 +146,28 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator, task.Updater = userID, userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备批量任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
return err
}
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备CSV批量任务失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备 CSV 批量任务失败")
return nil, appErr
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, DeviceImportPayload{TaskID: task.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil {
_ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败")
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备 CSV 批量任务入队失败"); secondaryErr != nil {
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备 CSV 批量任务入队失败")
}
appErr := errors.Wrap(errors.CodeInternalError, err, "设备CSV批量任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.CreateDeviceBatchAllocationResponse{
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备CSV批量任务已创建Worker 将异步处理CSV文件",
}, nil

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) {

View File

@@ -0,0 +1,60 @@
package export_task
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/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.CreatorShopID != nil {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.CreatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: audit.TaskEventID(constants.AuditResourceExportTask, task.ID, phase),
ActionCode: actionCode, Summary: summary, TaskID: task.ID, TaskNo: task.TaskNo,
Actor: audit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.CreatorShopID, EnterpriseID: task.CreatorEnterpriseID,
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "scene": task.Scene, "format": task.Format,
"creator_user_id": task.CreatorUserID, "creator_user_type": task.CreatorUserType,
"creator_shop_id": task.CreatorShopID, "creator_enterprise_id": task.CreatorEnterpriseID,
"scope_shop_ids": task.ScopeShopIDs,
},
BeforeData: before, AfterData: after,
})
}
func (s *Service) recordTaskAudit(ctx context.Context, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase string, errorCode int) {
if s == nil || s.auditWriter == nil || s.db == nil || task == nil || task.TaskNo == "" {
return
}
code := strconv.Itoa(errorCode)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.writeTaskAudit(ctx, tx, actionCode, summary, task, before, after, result, phase, code, summary)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, task.TaskNo, "", task.TaskNo, code, err)
}
}
func exportTaskState(task *model.ExportTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "cancel_requested": task.CancelRequested, "progress": task.Progress,
}
}

View File

@@ -2,6 +2,8 @@ package export_task
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/bytedance/sonic"
@@ -10,10 +12,12 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/exporter"
"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"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -28,6 +32,7 @@ type Service struct {
queueClient *queue.Client
storageSvc *storage.Service
sceneRegistry *exporter.Registry
auditWriter *audit.Writer
}
type dispatchPayload struct {
@@ -35,14 +40,18 @@ type dispatchPayload struct {
}
// New 创建导出任务服务。
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service) *Service {
return &Service{
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service, auditWriters ...*audit.Writer) *Service {
service := &Service{
db: db,
taskStore: taskStore,
queueClient: queueClient,
storageSvc: storageSvc,
sceneRegistry: exporter.NewDefaultRegistry(db),
}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// CreateTask 创建导出任务并入队 dispatch。
@@ -118,7 +127,16 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
task.Creator = userID
task.Updater = userID
if err := s.taskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
return err
}
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "创建业务导出任务", task, nil, exportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCreated, "创建业务导出任务失败", task, nil, exportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError)
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建导出任务失败")
}
@@ -130,7 +148,18 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
asynq.Timeout(constants.ExportDispatchTaskTimeout),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeExportDispatch)),
); err != nil {
_ = s.taskStore.MarkFailed(ctx, task.ID, userID, "导出任务入队失败")
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, userID, "导出任务入队失败"); err != nil {
return err
}
before := exportTaskState(task)
task.Status = constants.ExportTaskStatusFailed
task.ErrorMessage = "导出任务入队失败"
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "导出任务入队失败", task, before, exportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "导出任务入队失败")
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionExportTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
}
return nil, errors.Wrap(errors.CodeTaskQueueError, err, "导出任务入队失败")
}
@@ -233,45 +262,68 @@ func (s *Service) CancelTask(ctx context.Context, id uint) (*dto.CancelExportTas
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询导出任务失败")
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
}
message := "取消请求已提交"
switch task.Status {
case constants.ExportTaskStatusPending:
ok, err := s.taskStore.CancelPendingTask(ctx, id, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
}
if !ok {
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
message = "任务已取消"
case constants.ExportTaskStatusProcessing:
if !task.CancelRequested {
ok, err := s.taskStore.SetCancelRequested(ctx, id, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "提交取消请求失败")
before := exportTaskState(task)
changed := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txStore := s.taskStore.WithTx(tx)
switch task.Status {
case constants.ExportTaskStatusPending:
ok, updateErr := txStore.CancelPendingTask(ctx, id, userID)
if updateErr != nil {
return updateErr
}
if !ok {
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
} else {
message = "取消请求已提交,请稍后刷新状态"
task.Status, task.CancelRequested, task.Progress = constants.ExportTaskStatusCancelled, true, 100
message, changed = "任务已取消", true
case constants.ExportTaskStatusProcessing:
if task.CancelRequested {
message = "取消请求已提交,请稍后刷新状态"
return nil
}
ok, updateErr := txStore.SetCancelRequested(ctx, id, userID)
if updateErr != nil {
return updateErr
}
if !ok {
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
task.CancelRequested, changed = true, true
case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
default:
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled:
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
default:
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
latestTask, err := s.taskStore.GetByID(ctx, id)
if !changed {
return nil
}
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCancelled, message, task, before, exportTaskState(task), constants.AuditResultSuccess, "cancelled", "", "")
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询最新任务状态失败")
result := constants.AuditResultFailed
errorCode := errors.CodeDatabaseError
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidStatus {
result = constants.AuditResultDenied
errorCode = appErr.Code
}
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCancelled, "取消业务导出任务失败", task, before, exportTaskState(task), result, "", errorCode)
if appErr != nil {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
}
return &dto.CancelExportTaskResponse{
TaskID: latestTask.ID,
Status: latestTask.Status,
StatusName: constants.GetExportTaskStatusName(latestTask.Status),
CancelRequested: latestTask.CancelRequested,
TaskID: task.ID,
Status: task.Status,
StatusName: constants.GetExportTaskStatusName(task.Status),
CancelRequested: task.CancelRequested,
Message: message,
}, nil
}

View File

@@ -0,0 +1,117 @@
package iot_card
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"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"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
type gatewayAttempt struct {
log *model.IntegrationLog
startedAt time.Time
}
func (s *Service) startGatewayCardAttempt(ctx context.Context, card *model.IotCard, operation, scene, seriesKey string, attempt int) (*gatewayAttempt, error) {
if s == nil || s.speedTierIntegration == nil {
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Gateway Integration Log 接缝未配置")
}
resourceID := strconv.FormatUint(uint64(card.ID), 10)
triggerSource := auditcontext.From(ctx).Source
if triggerSource == "" {
triggerSource = "service"
}
triggerScene := scene
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card:"+seriesKey+":"+operation)).String()
requestID := requestIDFromContext(ctx)
var requestIDPtr *string
if requestID != "" {
requestIDPtr = &requestID
}
log, err := s.speedTierIntegration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ExternalID: &card.ICCID,
ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, ResourceKey: &card.ICCID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &triggerSeries,
Attempt: attempt, RequestID: requestIDPtr, CorrelationID: requestIDPtr,
RequestSummary: map[string]any{"iot_card_id": card.ID, "iccid": card.ICCID},
})
if err != nil {
return nil, err
}
return &gatewayAttempt{log: log, startedAt: time.Now()}, nil
}
func (s *Service) completeGatewayCardAttempt(ctx context.Context, attempt *gatewayAttempt, callErr error, stateChanged bool) error {
if attempt == nil || attempt.log == nil {
return nil
}
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
}
if callErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "Gateway 请求失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(callErr) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 请求结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewayQueryUnknownRecoveryStrategy
}
}
_, err := s.speedTierIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
return err
}
type gatewayCardAttemptObserver struct {
service *Service
card *model.IotCard
operation string
scene string
seriesKey string
nextAttempt int
current *gatewayAttempt
successful *gatewayAttempt
lastCallErr error
recordingErr error
unknown bool
}
func (o *gatewayCardAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
o.nextAttempt++
o.lastCallErr = nil
attempt, err := o.service.startGatewayCardAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
if err != nil {
o.recordingErr = err
return err
}
o.current = attempt
return nil
}
func (o *gatewayCardAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
o.lastCallErr = callErr
if isGatewayTimeout(callErr) {
o.unknown = true
}
if callErr == nil {
o.successful = o.current
o.current = nil
return nil
}
err := o.service.completeGatewayCardAttempt(ctx, o.current, callErr, false)
o.current = nil
if err != nil {
o.recordingErr = err
}
return err
}

View File

@@ -5,7 +5,6 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -22,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if err != nil {
return nil, err
}
var cards []*model.IotCard
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var cards []model.IotCard
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.IotCard{})).Clauses(clause.Locking{Strength: "UPDATE"})
if err := query.Where("id IN ?", ids).Find(&cards).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量卡资产失败")
@@ -31,29 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if len(cards) != len(ids) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result := tx.Model(&model.IotCard{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败")
changedIDs := make([]uint, 0, len(cards))
for _, card := range cards {
if card != nil && card.RealnamePolicy != req.RealnamePolicy {
changedIDs = append(changedIDs, card.ID)
}
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试")
if len(changedIDs) > 0 {
result := tx.Model(&model.IotCard{}).Where("id IN ?", changedIDs).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败")
}
if result.RowsAffected != int64(len(changedIDs)) {
return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试")
}
}
return nil
return s.appendCardRealnamePolicyBatchAudit(ctx, tx, cards, req.RealnamePolicy)
})
if err != nil {
result := constants.AuditResultFailed
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden {
result = constants.AuditResultDenied
}
s.recordCardRealnamePolicyBatchFailure(ctx, cards, req.RealnamePolicy, result, err)
return nil, err
}
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardRealnamePolicy,
OperationDesc: "批量更新卡实名认证策略",
ResultStatus: constants.AssetAuditResultSuccess,
BatchTotal: len(ids),
SuccessCount: len(ids),
AfterData: map[string]any{
"asset_ids": ids,
"realname_policy": req.RealnamePolicy,
},
})
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,11 @@ import (
"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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
)
type speedTierIntegrationLog interface {
@@ -30,7 +30,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
if code == nil || !constants.IsGatewaySpeedTier(*code) {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "固定限速档位不合法")
}
if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil {
if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil || s.db == nil || s.auditWriter == nil {
return nil, pkgerrors.New(pkgerrors.CodeServiceUnavailable, "卡限速服务未完整配置")
}
@@ -58,11 +58,10 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
"iccid": card.ICCID,
"tier_code": *code,
"tier_name": tierName,
"operator_id": middleware.GetUserIDFromContext(ctx),
},
})
if err != nil {
s.logSpeedTierAudit(ctx, card, *code, "", constants.AssetAuditResultFailed, err)
s.recordSpeedTierAudit(ctx, card, *code, "", false, constants.AuditResultFailed, err)
return nil, err
}
@@ -87,18 +86,22 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
zap.Error(completeErr),
)
}
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, completeErr)
auditErr := gatewayErr
if auditErr == nil {
auditErr = completeErr
}
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, false, speedTierAuditResult(gatewayErr), auditErr)
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, completeErr, "终结卡限速外部交互记录失败")
}
if gatewayErr != nil {
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, gatewayErr)
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, speedTierAuditResult(gatewayErr), gatewayErr)
if isGatewayTimeout(gatewayErr) {
return nil, pkgerrors.New(pkgerrors.CodeGatewayTimeout, "Gateway 卡限速请求结果未知,请核对实际档位后再操作")
}
return nil, gatewayErr
}
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultSuccess, nil)
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, constants.AuditResultSuccess, nil)
return &dto.SetIotCardSpeedTierResponse{
IotCardID: card.ID, ICCID: card.ICCID, Code: *code,
SpeedTierName: tierName, IntegrationID: attempt.IntegrationID,
@@ -118,7 +121,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess,
DurationMS: duration.Milliseconds(),
StateChanged: true,
StateChanged: false,
ResponseSummary: map[string]any{
"result": "success",
},
@@ -132,6 +135,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(err) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 卡限速请求结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewaySpeedTierUnknownRecoveryStrategy
}
@@ -143,24 +147,34 @@ func isGatewayTimeout(err error) bool {
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == pkgerrors.CodeGatewayTimeout
}
func (s *Service) logSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID, result string, err error) {
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
OperationType: constants.AssetAuditOpCardSpeedTier,
OperationDesc: "设置 IoT 卡固定限速档位",
BeforeData: map[string]any{"card": cardSnapshot(card)},
AfterData: map[string]any{
"tier_code": code,
"tier_name": constants.GetGatewaySpeedTierName(code),
"integration_id": integrationID,
},
ResultStatus: result,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
})
func speedTierAuditResult(err error) string {
if err == nil {
return constants.AuditResultSuccess
}
if isGatewayTimeout(err) {
return constants.AuditResultUnknown
}
return constants.AuditResultFailed
}
func (s *Service) recordSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID string, integrationLogCompleted bool, result string, businessErr error) {
afterData := map[string]any{
"requested_tier_code": code,
"requested_tier_name": constants.GetGatewaySpeedTierName(code),
"integration_id": integrationID,
"integration_log_completed": integrationLogCompleted,
}
if s.db == nil || s.auditWriter == nil {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr,
pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardLifecycleAudit(ctx, tx, constants.AuditActionIotCardSpeedTierSet,
"设置 IoT 卡固定限速档位为"+constants.GetGatewaySpeedTierName(code), result, card, nil, afterData, businessErr)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr, err)
}
}
var _ speedTierIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -0,0 +1,239 @@
package iot_card
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"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"
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"
)
func (s *StopResumeService) appendCardCommandAudit(
ctx context.Context,
tx *gorm.DB,
card *model.IotCard,
actionCode, summary, result, integrationID string,
beforeData, afterData map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置或资源不完整")
}
resourcesByCard, err := loadCardDeviceAuditReferences(ctx, tx, []*model.IotCard{card})
if err != nil {
return err
}
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, resourcesByCard[card.ID]...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
input := audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: map[string]any{"integration_id": integrationID}, Resources: resources,
}
if actionCode == constants.AuditActionIotCardAutoStopped || actionCode == constants.AuditActionIotCardAutoStarted ||
actionCode == constants.AuditActionIotCardAutoStopReasonUpdated {
input.Actor = audit.ActorInput{Kind: constants.AuditActorSystemTask, ID: "iot-card-stop-resume", Name: "IoT 卡停复机服务"}
input.Source = constants.AuditSourceWorker
}
return s.auditWriter.Append(ctx, tx, input)
}
func (s *StopResumeService) recordCardCommandAudit(
ctx context.Context,
card *model.IotCard,
actionCode, summary, result, integrationID string,
beforeData, afterData map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
recordCardAuditSecondaryFailure(ctx, actionCode, cardID(card), businessErr,
errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, result, integrationID, beforeData, afterData, businessErr)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err)
}
}
func cardID(card *model.IotCard) uint {
if card == nil {
return 0
}
return card.ID
}
func stopAuditAction(ctx context.Context, stopReason string) (string, string) {
if stopReason == constants.StopReasonManual && auditcontext.From(ctx).ActorKind == constants.AuditActorAccount {
return constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络"
}
return constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络"
}
func cardCommandSeriesKey(ctx context.Context) string {
linkage := auditcontext.From(ctx)
if linkage.CorrelationID != "" {
return linkage.CorrelationID
}
if linkage.RequestID != "" {
return linkage.RequestID
}
return uuid.NewString()
}
func cardCommandAuditResult(err error) string {
if err == nil {
return constants.AuditResultSuccess
}
if isGatewayTimeout(err) {
return constants.AuditResultUnknown
}
return constants.AuditResultFailed
}
func (s *StopResumeService) updateCardStopReasonWithAudit(ctx context.Context, card *model.IotCard, stopReason string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("stop_reason", stopReason).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停机原因失败")
}
return s.appendCardCommandAudit(ctx, tx, card, constants.AuditActionIotCardAutoStopReasonUpdated,
"自动更新 IoT 卡停机原因", constants.AuditResultSuccess, "",
map[string]any{"stop_reason": card.StopReason}, map[string]any{"stop_reason": stopReason}, nil)
})
}
func (s *StopResumeService) startCardCommandAttempt(
ctx context.Context,
card *model.IotCard,
operation, scene, seriesKey string,
attempt int,
) (*gatewayAttempt, error) {
if s.integration == nil {
return nil, errors.New(errors.CodeInvalidStatus, "停复机 Integration Log 接缝未配置")
}
resourceID := strconv.FormatUint(uint64(card.ID), 10)
triggerSource := auditcontext.From(ctx).Source
if triggerSource == "" {
triggerSource = constants.AuditSourceWorker
}
triggerScene := scene
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card-command:"+seriesKey+":"+operation)).String()
requestID := requestIDFromContext(ctx)
correlationID := auditcontext.From(ctx).CorrelationID
if correlationID == "" {
correlationID = requestID
}
var requestIDPtr, correlationIDPtr *string
if requestID != "" {
requestIDPtr = &requestID
}
if correlationID != "" {
correlationIDPtr = &correlationID
}
log, err := s.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ExternalID: &card.ICCID,
ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, ResourceKey: &card.ICCID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &triggerSeries,
Attempt: attempt, RequestID: requestIDPtr, CorrelationID: correlationIDPtr,
RequestSummary: map[string]any{"iot_card_id": card.ID, "iccid": card.ICCID},
})
if err != nil {
return nil, err
}
return &gatewayAttempt{log: log, startedAt: time.Now()}, nil
}
func (s *StopResumeService) completeCardCommandAttempt(ctx context.Context, attempt *gatewayAttempt, callErr error, stateChanged bool) error {
if attempt == nil || attempt.log == nil {
return nil
}
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
}
if callErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "Gateway 停复机请求失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(callErr) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 停复机请求结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewayCardCommandUnknownRecoveryStrategy
}
}
_, err := s.integration.Complete(ctx, attempt.log.IntegrationID, completion)
return err
}
type cardCommandAttemptObserver struct {
service *StopResumeService
card *model.IotCard
operation string
scene string
seriesKey string
nextAttempt int
current *gatewayAttempt
successful *gatewayAttempt
lastIntegrationID string
lastCallErr error
recordingErr error
unknown bool
}
func (o *cardCommandAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
o.nextAttempt++
o.lastCallErr = nil
attempt, err := o.service.startCardCommandAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
if err != nil {
o.recordingErr = err
return err
}
o.current = attempt
o.lastIntegrationID = attempt.log.IntegrationID
return nil
}
func (o *cardCommandAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
o.lastCallErr = callErr
if isGatewayTimeout(callErr) {
o.unknown = true
}
if callErr == nil {
o.successful = o.current
o.current = nil
return nil
}
err := o.service.completeCardCommandAttempt(ctx, o.current, callErr, false)
o.current = nil
if err != nil {
o.recordingErr = err
}
return err
}
func (o *cardCommandAttemptObserver) auditResult(lastErr error) string {
if o.unknown {
return constants.AuditResultUnknown
}
return cardCommandAuditResult(lastErr)
}

View File

@@ -13,8 +13,9 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -25,6 +26,10 @@ import (
type StopResumeServiceInterface interface {
// EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
ForceStartCard(ctx context.Context, card *model.IotCard) error
}
// 编译时验证 StopResumeService 实现了 StopResumeServiceInterface
@@ -43,6 +48,8 @@ type StopResumeService struct {
assetAuditService AssetAuditService
pollingCallback PollingCallback
observationSeriesEvents cardObservationApp.SeriesEventWriter
auditWriter *audit.Writer
integration *integrationlog.Repository
maxRetries int
retryInterval time.Duration
@@ -54,6 +61,12 @@ func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer
s.observationSeriesEvents = writer
}
// SetUnifiedAudit 注入停复机统一审计和外部交互日志接缝。
func (s *StopResumeService) SetUnifiedAudit(writer *audit.Writer, integration *integrationlog.Repository) {
s.auditWriter = writer
s.integration = integration
}
// NewStopResumeService 创建停复机服务
func NewStopResumeService(
redis *redis.Client,
@@ -364,7 +377,7 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
var cardErrors []error
for _, card := range cards {
if !s.isRealnameOK(card) {
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
if updateErr := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); updateErr != nil {
cardErrors = append(cardErrors, updateErr)
s.logger.Warn("更新未实名卡停机原因失败",
zap.Uint("card_id", card.ID), zap.Error(updateErr))
@@ -408,21 +421,48 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
}
}
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error {
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
return s.stopCardWithRetry(ctx, card, stopReason)
}
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error {
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
return err
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
return err
}
s.reschedulePolling(ctx, card.ID)
if err := s.completeCardCommandAttempt(ctx, attempt, nil, true); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "终结保护期复机 Integration Log 失败")
}
return nil
}
// resumeSingleCard 对单张卡执行复机逻辑
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: assetAuditSvc.SystemOperator("系统任务"),
OperationType: constants.AssetAuditOpCardAutoStart,
OperationDesc: "自动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: cardID,
})
return err
}
@@ -462,112 +502,54 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
defer s.redis.Del(ctx, lockKey)
if err := s.resumeCardWithRetry(ctx, card); err != nil {
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
s.logger.Error("调用运营商复机接口失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Error(err))
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: assetAuditSvc.SystemOperator("系统任务"),
OperationType: constants.AssetAuditOpCardAutoStart,
OperationDesc: "自动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
})
return err
}
now := time.Now()
if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Error(err))
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: assetAuditSvc.SystemOperator("系统任务"),
OperationType: constants.AssetAuditOpCardAutoStart,
OperationDesc: "自动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": constants.NetworkStatusOffline,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
return err
}
s.reschedulePolling(ctx, card.ID)
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结复机 Integration Log 失败")
}
s.logger.Info("卡已自动复机",
zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID))
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: assetAuditSvc.SystemOperator("系统任务"),
OperationType: constants.AssetAuditOpCardAutoStart,
OperationDesc: "自动复机",
ResultStatus: constants.AssetAuditResultSuccess,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": constants.NetworkStatusOffline,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
},
})
return nil
}
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
operator := assetAuditSvc.SystemOperator("系统任务")
operationType := constants.AssetAuditOpCardAutoStop
operationDesc := "自动停卡"
if stopReason == constants.StopReasonManual {
operator = assetAuditSvc.OperatorFromContext(ctx)
operationType = constants.AssetAuditOpCardManualStop
operationDesc = "手动停卡"
}
actionCode, summary := stopAuditAction(ctx, stopReason)
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(failErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: operator,
OperationType: operationType,
OperationDesc: operationDesc + "执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "",
cardSnapshot(card), nil, failErr)
return failErr
}
@@ -576,7 +558,14 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
zap.String("iccid", card.ICCID),
zap.String("stop_reason", stopReason))
seriesKey := cardCommandSeriesKey(ctx)
attemptObserver := &cardCommandAttemptObserver{
service: s, card: card, operation: constants.IntegrationOperationGatewayStopCard,
scene: constants.CardObservationSceneBusinessStop, seriesKey: seriesKey,
}
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
if i > 0 {
s.logger.Debug("重试调用停机接口",
@@ -585,107 +574,83 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
time.Sleep(s.retryInterval)
}
err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
CardNo: card.ICCID,
})
if err == nil {
callErr := s.gatewayClient.StopCard(gatewayCtx, &gateway.CardOperationReq{CardNo: card.ICCID})
lastIntegrationID = attemptObserver.lastIntegrationID
if attemptObserver.recordingErr != nil {
s.logger.Error("记录停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))
lastErr = attemptObserver.lastCallErr
if lastErr == nil {
lastErr = attemptObserver.recordingErr
}
break
}
if callErr == nil {
attempt := attemptObserver.successful
s.logger.Info("网关停机成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
now := time.Now()
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": stopReason,
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil {
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString(), actionCode, summary, lastIntegrationID); updateErr != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(logErr))
}
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Error(updateErr))
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(updateErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: operator,
OperationType: operationType,
OperationDesc: operationDesc + "执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOffline,
"stop_reason": stopReason,
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
lastIntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, updateErr)
return updateErr
}
s.reschedulePolling(ctx, card.ID)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: operator,
OperationType: operationType,
OperationDesc: operationDesc,
ResultStatus: constants.AssetAuditResultSuccess,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOffline,
"stop_reason": stopReason,
},
})
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
}
return nil
}
lastErr = err
lastErr = callErr
s.logger.Warn("调用停机接口失败,准备重试",
zap.Int("attempt", i+1),
zap.String("iccid", card.ICCID),
zap.Error(err))
zap.Error(callErr))
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(lastErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
Operator: operator,
OperationType: operationType,
OperationDesc: operationDesc + "执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOffline,
"stop_reason": stopReason,
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr)
return lastErr
}
// resumeCardWithRetry 调用运营商复机接口(带重试机制)
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error {
// resumeCardWithRetry 调用运营商复机接口(带重试机制)
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, error) {
if s.gatewayClient == nil {
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
return nil, failErr
}
s.logger.Info("调用网关复机",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
seriesKey := cardCommandSeriesKey(ctx)
attemptObserver := &cardCommandAttemptObserver{
service: s, card: card, operation: constants.IntegrationOperationGatewayStartCard,
scene: constants.CardObservationSceneBusinessResume, seriesKey: seriesKey,
}
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
if i > 0 {
s.logger.Debug("重试调用复机接口",
@@ -698,22 +663,34 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendMachineSeparated {
req.Extend = constants.GatewayCardStartExtendMachineSeparated
}
err := s.gatewayClient.StartCard(ctx, req)
if err == nil {
callErr := s.gatewayClient.StartCard(gatewayCtx, req)
lastIntegrationID = attemptObserver.lastIntegrationID
if attemptObserver.recordingErr != nil {
s.logger.Error("记录复机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))
lastErr = attemptObserver.lastCallErr
if lastErr == nil {
lastErr = attemptObserver.recordingErr
}
break
}
if callErr == nil {
s.logger.Info("网关复机成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
return nil
return attemptObserver.successful, nil
}
lastErr = err
lastErr = callErr
s.logger.Warn("调用复机接口失败,准备重试",
zap.Int("attempt", i+1),
zap.String("iccid", card.ICCID),
zap.Error(err))
zap.Error(callErr))
}
return lastErr
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr)
return nil, lastErr
}
// StartMachineSeparatedCard 对机卡分离停机卡执行复机
@@ -722,8 +699,11 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
if card == nil {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络"
if s.gatewayClient == nil {
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
return failErr
}
gatewayExtend := strings.TrimSpace(card.GatewayExtend)
@@ -737,108 +717,43 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
denyMsg = "该卡已被运营商销户,不允许复机"
}
denyErr := errors.New(errors.CodeForbidden, denyMsg)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机被拒绝(风险状态)",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr
}
if gatewayExtend != constants.GatewayCardExtendMachineSeparated {
denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
AfterData: map[string]any{
"gateway_extend": gatewayExtend,
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{"gateway_extend": gatewayExtend}, denyErr)
return denyErr
}
if err := s.resumeCardWithRetry(ctx, card); err != nil {
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
AfterData: map[string]any{
"gateway_extend": gatewayExtend,
},
})
return wrapErr
}
now := time.Now()
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
"gateway_extend": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
"gateway_extend": gatewayExtend,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
"gateway_extend": "",
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr)
return wrapErr
}
s.reschedulePolling(ctx, card.ID)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机",
ResultStatus: constants.AssetAuditResultSuccess,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
"gateway_extend": gatewayExtend,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
"gateway_extend": "",
},
})
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结 OpenAPI 复机 Integration Log 失败")
}
return nil
}
@@ -846,32 +761,13 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
denyErr := errors.New(errors.CodeNotFound, "卡不存在")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStop,
OperationDesc: "手动停卡被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetIdentifier: iccid,
})
return denyErr
return errors.New(errors.CodeNotFound, "卡不存在")
}
actionCode, summary := stopAuditAction(ctx, constants.StopReasonManual)
if card.RealNameStatus != constants.RealNameStatusVerified {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStop,
OperationDesc: "手动停卡被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr
}
@@ -882,41 +778,19 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
if exists > 0 {
denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStop,
OperationDesc: "手动停卡被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
AfterData: map[string]any{
"device_id": binding.DeviceID,
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
return denyErr
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStop,
OperationDesc: "手动停卡执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
return wrapErr
}
}
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
return err
}
return nil
@@ -926,18 +800,9 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
denyErr := errors.New(errors.CodeNotFound, "卡不存在")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetIdentifier: iccid,
})
return denyErr
return errors.New(errors.CodeNotFound, "卡不存在")
}
actionCode, summary := constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络"
// 独立卡处于风险停机或已销户状态时,拒绝复机
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
@@ -948,33 +813,13 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
denyMsg = "该卡已被运营商销户,不允许复机"
}
denyErr := errors.New(errors.CodeForbidden, denyMsg)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr
}
if card.RealNameStatus != constants.RealNameStatusVerified {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr
}
@@ -985,106 +830,52 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
if exists > 0 {
denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
AfterData: map[string]any{
"device_id": binding.DeviceID,
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
return denyErr
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
return wrapErr
}
}
if err := s.resumeCardWithRetry(ctx, card); err != nil {
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
return wrapErr
}
now := time.Now()
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机执行失败",
ResultStatus: constants.AssetAuditResultFailed,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
},
})
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr)
return wrapErr
}
s.reschedulePolling(ctx, card.ID)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机",
ResultStatus: constants.AssetAuditResultSuccess,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
},
AfterData: map[string]any{
"network_status": constants.NetworkStatusOnline,
"stop_reason": "",
},
})
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结人工复机 Integration Log 失败")
}
return nil
}
func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error {
if s.db == nil || s.observationSeriesEvents == nil {
func (s *StopResumeService) updateCardAndAppendNetworkSeries(
ctx context.Context,
card *model.IotCard,
fields map[string]any,
scene, expected, operationID, actionCode, summary, integrationID string,
) error {
if s.db == nil || s.observationSeriesEvents == nil || card == nil || card.ID == 0 {
return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置")
}
requestID := requestIDFromContext(ctx)
@@ -1092,15 +883,21 @@ func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context
requestID = operationID
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败")
}
if err := s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, constants.AuditResultSuccess,
integrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
fields, nil); err != nil {
return err
}
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,

View File

@@ -0,0 +1,751 @@
package iot_card
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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"
)
// SetAccessAudit 注入 IoT 卡身份生命周期的统一审计 Writer。
func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// WriteCardStateAudit 将卡观测事务中的人工状态操作写入统一 Audit Event。
func (s *Service) WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input cardapp.StateAudit) error {
extraResources := make([]audit.ResourceInput, 0, 1)
if input.IntegrationID != "" {
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID))
}
return s.appendCardLifecycleAudit(ctx, tx, input.ActionCode, input.Summary, constants.AuditResultSuccess,
input.Card, input.BeforeData, input.AfterData, nil, extraResources...)
}
// WriteCardStateFailure 使用独立短事务记录已解析卡资源后的回调失败。
func (s *Service) WriteCardStateFailure(ctx context.Context, input cardapp.StateAudit, businessErr error) {
extraResources := make([]audit.ResourceInput, 0, 1)
if input.IntegrationID != "" {
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID))
}
s.recordCardLifecycleFailure(ctx, input.ActionCode, input.Summary, constants.AuditResultFailed,
input.Card, input.Card.ID, businessErr, extraResources...)
}
func callbackIntegrationAuditResource(ctx context.Context, integrationID string) audit.ResourceInput {
linkage := auditcontext.From(ctx)
correlationID := linkage.CorrelationID
if correlationID == "" {
correlationID = linkage.RequestID
}
return audit.ResourceInput{
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCallbackIntegration,
IdentitySnapshot: map[string]any{
"integration_id": integrationID, "provider": linkage.ActorID,
"direction": constants.IntegrationDirectionInbound, "correlation_id": correlationID,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
func cardRefreshAuditAction(ctx context.Context) (string, bool) {
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount:
return constants.AuditActionIotCardManualRefreshed, true
case constants.AuditActorPersonalCustomer:
return constants.AuditActionIotCardPersonalRefreshed, true
default:
return "", false
}
}
func (s *Service) updateCardRefreshCompletion(ctx context.Context, card *model.IotCard, syncTime time.Time, result, summary string) error {
actionCode, audited := cardRefreshAuditAction(ctx)
if !audited {
return s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{"last_sync_time": syncTime})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("last_sync_time", syncTime).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡刷新时间失败")
}
return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result,
card, map[string]any{"last_sync_time": card.LastSyncTime}, map[string]any{"last_sync_time": syncTime}, nil)
})
}
func (s *Service) recordCardRefreshFailure(ctx context.Context, card *model.IotCard, result string, businessErr error) {
actionCode, audited := cardRefreshAuditAction(ctx)
if !audited || card == nil {
return
}
s.recordCardLifecycleFailure(ctx, actionCode, "人工刷新 IoT 卡未完成", result, card, card.ID, businessErr)
}
func (s *Service) completeCardRefreshAttempt(
ctx context.Context,
card *model.IotCard,
attempt *gatewayAttempt,
callErr error,
stateChanged bool,
message string,
) error {
if err := s.completeGatewayCardAttempt(ctx, attempt, callErr, stateChanged); err != nil {
wrapped := errors.Wrap(errors.CodeDatabaseError, err, message)
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapped)
return wrapped
}
return nil
}
func (s *Service) appendCardLifecycleAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
card *model.IotCard,
beforeData, afterData map[string]any,
businessErr error,
extraResources ...audit.ResourceInput,
) error {
if s.auditWriter == nil || card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置或资源不完整")
}
resourceID := strconv.FormatUint(uint64(card.ID), 10)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceIotCard, ID: &resourceID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, extraResources...)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Resources: resources,
})
}
func (s *Service) recordCardLifecycleFailure(ctx context.Context, actionCode, summary, result string, card *model.IotCard, cardID uint, businessErr error, extraResources ...audit.ResourceInput) {
if card == nil {
card = &model.IotCard{}
card.ID = cardID
}
if s.db == nil || s.auditWriter == nil || card.ID == 0 {
recordCardAuditSecondaryFailure(ctx, actionCode, cardID, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result, card, nil, nil, businessErr, extraResources...)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err)
}
}
func (s *Service) appendBatchDeleteAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, batchTotal int) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.RequestID == "" {
return errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计上下文不完整")
}
rootEventID := stableCardBatchEventID("delete", linkage.RequestID)
result := constants.AuditResultSuccess
if len(cards) < batchTotal {
result = constants.AuditResultPartial
}
children := make([]audit.AppendInput, 0, len(cards))
for _, card := range cards {
if card == nil || card.ID == 0 {
continue
}
resourceID := strconv.FormatUint(uint64(card.ID), 10)
children = append(children, audit.AppendInput{
EventID: stableCardBatchEventID("delete-card", linkage.RequestID+":"+resourceID),
ActionCode: constants.AuditActionIotCardDeleted, Summary: "批量删除 IoT 卡", Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceIotCard, ID: &resourceID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: cardSnapshot(card),
AfterData: map[string]any{"deleted": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡已删除",
}},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootEventID, ActionCode: constants.AuditActionIotCardBatchDeleted,
Summary: "批量删除 IoT 卡", Result: result,
BatchTotal: batchTotal, SuccessCount: len(cards), FailCount: batchTotal - len(cards),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cards)},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func (s *Service) recordBatchDeleteFailure(ctx context.Context, cardIDs []uint, businessErr error) {
linkage := auditcontext.From(ctx)
if s.db == nil || s.auditWriter == nil || linkage.RequestID == "" {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计接缝未配置"))
return
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: stableCardBatchEventID("delete-failed", linkage.RequestID),
ActionCode: constants.AuditActionIotCardBatchDeleted, Summary: "批量删除 IoT 卡失败",
Result: constants.AuditResultFailed, ErrorCode: errorCode, ErrorSummary: errorSummary,
BatchTotal: len(cardIDs), FailCount: len(cardIDs),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cardIDs)},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
})
if err != nil {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, err)
}
}
type cardAuditOutcome struct {
Result string
Summary string
}
func cardAuditOutcomes(cards []*model.IotCard, result, summary string) map[uint]cardAuditOutcome {
outcomes := make(map[uint]cardAuditOutcome, len(cards))
for _, card := range cards {
if card != nil && card.ID > 0 {
outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary}
}
}
return outcomes
}
func setCardAuditOutcomes(outcomes map[uint]cardAuditOutcome, cardIDs []uint, result, summary string) {
for _, cardID := range cardIDs {
outcomes[cardID] = cardAuditOutcome{Result: result, Summary: summary}
}
}
func setCardAuditOutcomeByICCID(outcomes map[uint]cardAuditOutcome, cards []*model.IotCard, iccid, result, summary string) {
for _, card := range cards {
if card != nil && (card.ICCID == iccid || card.ICCID19 == iccid || card.ICCID20 != nil && *card.ICCID20 == iccid) {
outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary}
return
}
}
}
func (s *Service) appendCardTransferAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
cards []*model.IotCard,
outcomes map[uint]cardAuditOutcome,
records []*model.AssetAllocationRecord,
newShopID *uint,
newStatus, batchTotal, successCount, failCount int,
businessErr error,
) error {
shops, err := loadCardTransferAuditShops(ctx, tx, cards, records, newShopID)
if err != nil {
return err
}
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
if err != nil {
return err
}
recordByCardID := make(map[uint]*model.AssetAllocationRecord, len(records))
for _, record := range records {
if record != nil {
recordByCardID[record.AssetID] = record
}
}
items := make([]cardBatchAuditItem, 0, len(cards))
for _, card := range cards {
if card == nil || card.ID == 0 {
continue
}
outcome, ok := outcomes[card.ID]
if !ok {
continue
}
beforeData := map[string]any{"shop_id": card.ShopID, "status": card.Status}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"shop_id": newShopID, "status": newStatus}
}
references := cardTransferAuditReferences(card, recordByCardID[card.ID], newShopID, shops)
references = append(references, deviceReferences[card.ID]...)
items = append(items, cardBatchAuditItem{
Card: card, Result: outcome.Result, Summary: outcome.Summary,
BeforeData: beforeData, AfterData: afterData,
References: references,
})
}
allocationNo := ""
if len(records) > 0 && records[0] != nil {
allocationNo = records[0].AllocationNo
}
return s.appendCardBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
batchTotal, successCount, failCount, items,
map[string]any{"allocation_no": allocationNo, "to_shop_id": newShopID, "new_status": newStatus}, businessErr)
}
func loadCardTransferAuditShops(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, records []*model.AssetAllocationRecord, newShopID *uint) (map[uint]*model.Shop, error) {
shopIDs := make(map[uint]struct{})
if newShopID != nil && *newShopID > 0 {
shopIDs[*newShopID] = struct{}{}
}
for _, card := range cards {
if card != nil && card.ShopID != nil && *card.ShopID > 0 {
shopIDs[*card.ShopID] = struct{}{}
}
}
for _, record := range records {
if record == nil {
continue
}
if record.FromOwnerType == constants.OwnerTypeShop && record.FromOwnerID != nil {
shopIDs[*record.FromOwnerID] = struct{}{}
}
if record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 {
shopIDs[record.ToOwnerID] = struct{}{}
}
}
ids := make([]uint, 0, len(shopIDs))
for id := range shopIDs {
ids = append(ids, id)
}
var rows []*model.Shop
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
shops := make(map[uint]*model.Shop, len(rows))
for _, shop := range rows {
shops[shop.ID] = shop
}
return shops, nil
}
func cardTransferAuditReferences(card *model.IotCard, record *model.AssetAllocationRecord, targetShopID *uint, shops map[uint]*model.Shop) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 3)
if record != nil && record.ID > 0 {
recordID := strconv.FormatUint(uint64(record.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetAllocationRecord, ID: &recordID,
Key: recordID, DisplayName: record.AllocationNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAllocationRecord,
IdentitySnapshot: map[string]any{
"id": record.ID, "allocation_no": record.AllocationNo, "asset_type": record.AssetType,
"asset_id": record.AssetID, "asset_identifier": record.AssetIdentifier,
"from_owner_type": record.FromOwnerType, "from_owner_id": record.FromOwnerID,
"to_owner_type": record.ToOwnerType, "to_owner_id": record.ToOwnerID,
},
AfterData: map[string]any{"created": true}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
sourceShopID := card.ShopID
if record != nil && record.FromOwnerType == constants.OwnerTypeShop {
sourceShopID = record.FromOwnerID
}
if sourceShopID != nil && *sourceShopID > 0 {
resources = appendShopAuditReference(resources, shops[*sourceShopID], *sourceShopID, constants.AuditResourceRoleTransferSourceShop)
}
if record != nil && record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 {
resources = appendShopAuditReference(resources, shops[record.ToOwnerID], record.ToOwnerID, constants.AuditResourceRoleTransferTargetShop)
} else if targetShopID != nil && *targetShopID > 0 {
resources = appendShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop)
}
return resources
}
func appendShopAuditReference(resources []audit.ResourceInput, shop *model.Shop, shopID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(shopID), 10)
name := id
identity := map[string]any{"id": shopID}
if shop != nil {
name = shop.ShopName
identity = map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
type cardBatchAuditItem struct {
Card *model.IotCard
PrimaryRole string
Result string
Summary string
BeforeData map[string]any
AfterData map[string]any
References []audit.ResourceInput
}
func (s *Service) appendCardBatchAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
batchTotal, successCount, failCount int,
items []cardBatchAuditItem,
metadata map[string]any,
businessErr error,
) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.RequestID == "" {
return errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计上下文不完整")
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
children := make([]audit.AppendInput, 0, len(items))
for _, item := range items {
if item.Card == nil || item.Card.ID == 0 {
continue
}
cardID := strconv.FormatUint(uint64(item.Card.ID), 10)
primaryRole := item.PrimaryRole
if primaryRole == "" {
primaryRole = constants.AuditResourceRoleIotCardTransferTarget
}
resources := []audit.ResourceInput{{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
Relation: constants.AuditResourceRelationPrimary, Role: primaryRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: item.Summary,
}}
resources = append(resources, item.References...)
childErrorCode, childErrorSummary := "", ""
if item.Result == constants.AuditResultFailed || item.Result == constants.AuditResultDenied {
childErrorCode, childErrorSummary = errorCode, errorSummary
}
children = append(children, audit.AppendInput{
EventID: stableCardBatchEventID(kind+"-"+item.Result+"-card", linkage.RequestID+":"+cardID),
ActionCode: itemAction, Summary: item.Summary, ScopeType: constants.AuditScopePlatform, Result: item.Result,
ErrorCode: childErrorCode, ErrorSummary: childErrorSummary, Resources: resources,
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: stableCardBatchEventID(kind+"-"+result, linkage.RequestID),
ActionCode: rootAction, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: errorCode, ErrorSummary: errorSummary,
BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, Metadata: metadata,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(items)},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func (s *Service) recordCardTransferAuditFailure(
ctx context.Context,
rootAction, itemAction, kind, summary, result string,
cards []*model.IotCard,
outcomes map[uint]cardAuditOutcome,
newShopID *uint,
newStatus, batchTotal, successCount, failCount int,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
cards, outcomes, nil, newShopID, newStatus, batchTotal, successCount, failCount, businessErr)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err)
}
}
func (s *Service) appendCardSeriesBindingAudit(
ctx context.Context,
tx *gorm.DB,
cards []*model.IotCard,
outcomes map[uint]cardAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) error {
series, err := loadCardSeriesAuditResources(ctx, tx, cards, seriesID)
if err != nil {
return err
}
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
if err != nil {
return err
}
items := make([]cardBatchAuditItem, 0, len(cards))
for _, card := range cards {
if card == nil || card.ID == 0 {
continue
}
outcome, ok := outcomes[card.ID]
if !ok {
continue
}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"series_id": seriesID}
}
references := cardSeriesAuditReferences(card.SeriesID, seriesID, series)
references = append(references, deviceReferences[card.ID]...)
items = append(items, cardBatchAuditItem{
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardSeriesTarget,
Result: outcome.Result, Summary: outcome.Summary,
BeforeData: map[string]any{"series_id": card.SeriesID}, AfterData: afterData,
References: references,
})
}
return s.appendCardBatchAudit(ctx, tx,
constants.AuditActionIotCardSeriesBindingBatch,
constants.AuditActionIotCardSeriesBound,
"series-binding", "批量设置 IoT 卡系列绑定", result,
batchTotal, successCount, failCount, items, metadata, businessErr)
}
func (s *Service) appendCardRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, policy string) error {
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
if err != nil {
return err
}
items := make([]cardBatchAuditItem, 0, len(cards))
for _, card := range cards {
if card == nil || card.ID == 0 || card.RealnamePolicy == policy {
continue
}
items = append(items, cardBatchAuditItem{
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget,
Result: constants.AuditResultSuccess, Summary: "更新 IoT 卡实名策略",
BeforeData: map[string]any{"realname_policy": card.RealnamePolicy},
AfterData: map[string]any{"realname_policy": policy},
References: deviceReferences[card.ID],
})
}
return s.appendCardBatchAudit(ctx, tx,
constants.AuditActionIotCardRealnamePolicyBatchUpdated,
constants.AuditActionIotCardRealnamePolicyUpdated,
"realname-policy", "批量更新 IoT 卡实名策略", constants.AuditResultSuccess,
len(items), len(items), 0, items, map[string]any{"realname_policy": policy, "requested_count": len(cards)}, nil)
}
func (s *Service) recordCardRealnamePolicyBatchFailure(ctx context.Context, cards []*model.IotCard, policy, result string, businessErr error) {
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
return
}
items := make([]cardBatchAuditItem, 0, len(cards))
for _, card := range cards {
if card == nil || card.ID == 0 {
continue
}
items = append(items, cardBatchAuditItem{
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget,
Result: result, Summary: "更新 IoT 卡实名策略未完成",
BeforeData: map[string]any{"realname_policy": card.RealnamePolicy},
AfterData: map[string]any{"requested_realname_policy": policy},
})
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardBatchAudit(ctx, tx,
constants.AuditActionIotCardRealnamePolicyBatchUpdated,
constants.AuditActionIotCardRealnamePolicyUpdated,
"realname-policy", "批量更新 IoT 卡实名策略未完成", result,
len(cards), 0, len(cards), items, map[string]any{"realname_policy": policy}, businessErr)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardRealnamePolicyBatchUpdated, 0, businessErr, err)
}
}
func loadCardSeriesAuditResources(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) {
seriesIDs := make(map[uint]struct{})
if targetSeriesID != nil && *targetSeriesID > 0 {
seriesIDs[*targetSeriesID] = struct{}{}
}
for _, card := range cards {
if card != nil && card.SeriesID != nil && *card.SeriesID > 0 {
seriesIDs[*card.SeriesID] = struct{}{}
}
}
ids := make([]uint, 0, len(seriesIDs))
for id := range seriesIDs {
ids = append(ids, id)
}
var rows []*model.PackageSeries
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
series := make(map[uint]*model.PackageSeries, len(rows))
for _, item := range rows {
series[item.ID] = item
}
return series, nil
}
func cardSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 2)
if previousID != nil && *previousID > 0 {
resources = appendPackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries)
}
if targetID != nil && *targetID > 0 {
resources = appendPackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries)
}
return resources
}
func appendPackageSeriesAuditReference(resources []audit.ResourceInput, series *model.PackageSeries, seriesID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(seriesID), 10)
name := id
identity := map[string]any{"id": seriesID}
if series != nil {
name = series.SeriesName
identity = map[string]any{"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName, "status": series.Status}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourcePackageSeries, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func loadCardDeviceAuditReferences(ctx context.Context, tx *gorm.DB, cards []*model.IotCard) (map[uint][]audit.ResourceInput, error) {
cardByID := make(map[uint]*model.IotCard, len(cards))
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
if card != nil && card.ID > 0 {
cardByID[card.ID] = card
cardIDs = append(cardIDs, card.ID)
}
}
result := make(map[uint][]audit.ResourceInput)
if len(cardIDs) == 0 {
return result, nil
}
var bindings []*model.DeviceSimBinding
if err := tx.WithContext(ctx).Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1).Find(&bindings).Error; err != nil {
return nil, err
}
deviceIDs := make([]uint, 0, len(bindings))
for _, binding := range bindings {
deviceIDs = append(deviceIDs, binding.DeviceID)
}
var devices []*model.Device
if len(deviceIDs) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
return nil, err
}
}
deviceByID := make(map[uint]*model.Device, len(devices))
for _, device := range devices {
deviceByID[device.ID] = device
}
for _, binding := range bindings {
card := cardByID[binding.IotCardID]
device := deviceByID[binding.DeviceID]
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
deviceID := strconv.FormatUint(uint64(binding.DeviceID), 10)
deviceKey, deviceName := deviceID, deviceID
deviceIdentity := map[string]any{"id": binding.DeviceID}
deviceVirtualNo := ""
if device != nil {
deviceVirtualNo = device.VirtualNo
if device.VirtualNo != "" {
deviceKey = device.VirtualNo
}
deviceName = device.DeviceName
if deviceName == "" {
deviceName = device.VirtualNo
}
deviceIdentity = map[string]any{"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN, "generation": device.Generation}
}
cardICCID, cardVirtualNo := "", ""
if card != nil {
cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo
}
result[binding.IotCardID] = append(result[binding.IotCardID],
audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: deviceVirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardDeviceBinding,
IdentitySnapshot: map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": deviceVirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
"iccid": cardICCID, "virtual_no": cardVirtualNo, "is_current": binding.IsCurrent,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
},
audit.ResourceInput{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: deviceKey, DisplayName: deviceName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardRelatedDevice,
IdentitySnapshot: deviceIdentity, SubjectVisibility: constants.AuditSubjectInternalOnly,
},
)
}
return result, nil
}
func (s *Service) recordCardSeriesBindingAuditFailure(
ctx context.Context,
cards []*model.IotCard,
outcomes map[uint]cardAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡系列绑定审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardSeriesBindingAudit(ctx, tx, cards, outcomes, seriesID, result,
batchTotal, successCount, failCount, metadata, businessErr)
}); err != nil {
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, err)
}
}
func stableCardBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("iot-card:"+kind+":"+key)).String()
}
func recordCardAuditSecondaryFailure(ctx context.Context, actionCode string, cardID uint, businessErr, auditErr error) {
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
actionCode, strconv.FormatUint(uint64(cardID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, auditErr,
)
}

Some files were not shown because too many files have changed in this diff Show More