收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
internal/application/agentrecharge/recharge_audit.go
Normal file
27
internal/application/agentrecharge/recharge_audit.go
Normal 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
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
40
internal/application/approval/audit.go
Normal file
40
internal/application/approval/audit.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "写入站内通知失败")
|
||||
}
|
||||
|
||||
@@ -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(¬ification)
|
||||
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, ¬ification, 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(¬ifications).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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// 仅对网络级错误重试
|
||||
|
||||
@@ -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),
|
||||
|
||||
100
internal/handler/app/client_wallet_payment_audit.go
Normal file
100
internal/handler/app/client_wallet_payment_audit.go
Normal 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})},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
189
internal/infrastructure/audit/approval.go
Normal file
189
internal/infrastructure/audit/approval.go
Normal 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
|
||||
}
|
||||
46
internal/infrastructure/audit/commission.go
Normal file
46
internal/infrastructure/audit/commission.go
Normal 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
|
||||
}
|
||||
62
internal/infrastructure/audit/failure.go
Normal file
62
internal/infrastructure/audit/failure.go
Normal 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,
|
||||
)
|
||||
}
|
||||
24
internal/infrastructure/audit/notification.go
Normal file
24
internal/infrastructure/audit/notification.go
Normal 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,
|
||||
}
|
||||
}
|
||||
218
internal/infrastructure/audit/package.go
Normal file
218
internal/infrastructure/audit/package.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
84
internal/infrastructure/audit/payment.go
Normal file
84
internal/infrastructure/audit/payment.go
Normal 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,
|
||||
})
|
||||
}
|
||||
73
internal/infrastructure/audit/polling.go
Normal file
73
internal/infrastructure/audit/polling.go
Normal 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,
|
||||
})
|
||||
}
|
||||
187
internal/infrastructure/audit/recharge.go
Normal file
187
internal/infrastructure/audit/recharge.go
Normal 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
|
||||
}
|
||||
128
internal/infrastructure/audit/refund.go
Normal file
128
internal/infrastructure/audit/refund.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
91
internal/infrastructure/audit/task.go
Normal file
91
internal/infrastructure/audit/task.go
Normal 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])
|
||||
}
|
||||
293
internal/infrastructure/audit/wallet.go
Normal file
293
internal/infrastructure/audit/wallet.go
Normal 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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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, "卡流量事件类型或版本不受支持")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 组装按字符串审批单号查询权威详情的企微请求。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 只组装本次企微审批请求,调用前不会产生外部副作用。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 信息缺失",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
47
internal/service/asset_package_batch_order/audit.go
Normal file
47
internal/service/asset_package_batch_order/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
internal/service/client_order/payment_audit.go
Normal file
103
internal/service/client_order/payment_audit.go
Normal 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})},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
148
internal/service/commission_calculation/audit.go
Normal file
148
internal/service/commission_calculation/audit.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
86
internal/service/commission_withdrawal/audit.go
Normal file
86
internal/service/commission_withdrawal/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
200
internal/service/customer_binding/audit.go
Normal file
200
internal/service/customer_binding/audit.go
Normal 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
|
||||
}
|
||||
@@ -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_iccid(Issue 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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
236
internal/service/device/binding_audit.go
Normal file
236
internal/service/device/binding_audit.go
Normal 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
|
||||
}
|
||||
294
internal/service/device/gateway_audit.go
Normal file
294
internal/service/device/gateway_audit.go
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
673
internal/service/device/unified_audit.go
Normal file
673
internal/service/device/unified_audit.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
494
internal/service/exchange/audit.go
Normal file
494
internal/service/exchange/audit.go
Normal 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
|
||||
}
|
||||
}
|
||||
393
internal/service/exchange/device_audit.go
Normal file
393
internal/service/exchange/device_audit.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
60
internal/service/export_task/audit.go
Normal file
60
internal/service/export_task/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
117
internal/service/iot_card/gateway_integration.go
Normal file
117
internal/service/iot_card/gateway_integration.go
Normal 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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
239
internal/service/iot_card/stop_resume_audit.go
Normal file
239
internal/service/iot_card/stop_resume_audit.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
751
internal/service/iot_card/unified_audit.go
Normal file
751
internal/service/iot_card/unified_audit.go
Normal 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
Reference in New Issue
Block a user