收口审计治理与套餐任务进展
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 {
|
||||
|
||||
Reference in New Issue
Block a user