固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
223
internal/application/accessaudit/change.go
Normal file
223
internal/application/accessaudit/change.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package accessaudit 定义账号权限与组织简单写用例的统一审计接缝。
|
||||
package accessaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ChangeAudit 是账号权限与组织变更交给统一审计 Port 的事实。
|
||||
type ChangeAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
OperatorID uint
|
||||
ActorKind string
|
||||
ActorName string
|
||||
Source string
|
||||
ScopeType string
|
||||
Account *model.Account
|
||||
Accounts []AccountChange
|
||||
Shop *model.Shop
|
||||
ParentShop *model.Shop
|
||||
Enterprise *model.Enterprise
|
||||
Cards []IotCardChange
|
||||
CardAuthorizations []EnterpriseCardAuthorizationChange
|
||||
Devices []DeviceChange
|
||||
DeviceBindings []DeviceSimBindingChange
|
||||
DeviceAuthorizations []EnterpriseDeviceAuthorizationChange
|
||||
PersonalCustomer *model.PersonalCustomer
|
||||
PersonalPhones []PersonalCustomerPhoneChange
|
||||
PersonalOpenIDs []PersonalCustomerOpenIDChange
|
||||
Role *model.Role
|
||||
Roles []RoleChange
|
||||
Permissions []PermissionChange
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerPhoneChange 保存个人客户手机号资源变化。
|
||||
type PersonalCustomerPhoneChange struct {
|
||||
Phone *model.PersonalCustomerPhone
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
|
||||
type PersonalCustomerOpenIDChange struct {
|
||||
OpenID *model.PersonalCustomerOpenID
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
|
||||
type DeviceChange struct {
|
||||
Device *model.Device
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// DeviceSimBindingChange 保存企业设备授权涉及的卡槽绑定快照。
|
||||
type DeviceSimBindingChange struct {
|
||||
Binding *model.DeviceSimBinding
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// EnterpriseDeviceAuthorizationChange 保存企业设备授权记录的直接变化。
|
||||
type EnterpriseDeviceAuthorizationChange struct {
|
||||
Authorization *model.EnterpriseDeviceAuthorization
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// IotCardChange 保存组织操作关联卡的资源变化与主体安全投影。
|
||||
type IotCardChange struct {
|
||||
Card *model.IotCard
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// EnterpriseCardAuthorizationChange 保存企业卡授权记录的直接变化。
|
||||
type EnterpriseCardAuthorizationChange struct {
|
||||
Authorization *model.EnterpriseCardAuthorization
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// AccountChange 保存店铺操作关联账号的资源角色与直接变化。
|
||||
type AccountChange struct {
|
||||
Account *model.Account
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// RoleChange 保存主体授权中单个角色资源的前后变化。
|
||||
type RoleChange struct {
|
||||
Role *model.Role
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PermissionChange 保存单个权限资源的前后变化。
|
||||
type PermissionChange struct {
|
||||
Permission *model.Permission
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// Writer 接收账号权限与组织事务内审计事实。
|
||||
type Writer interface {
|
||||
WriteAccessChange(context.Context, *gorm.DB, ChangeAudit) error
|
||||
}
|
||||
|
||||
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
|
||||
func RecordFailure(ctx context.Context, db *gorm.DB, writer Writer, change ChangeAudit, originalErr error) {
|
||||
fillFailure(changeError(originalErr), &change)
|
||||
if db == nil || writer == nil {
|
||||
recordSecondaryFailure(ctx, change, apperrors.New(apperrors.CodeInvalidStatus, "统一组织审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return writer.WriteAccessChange(ctx, tx, change)
|
||||
}); err != nil {
|
||||
recordSecondaryFailure(ctx, change, err)
|
||||
}
|
||||
}
|
||||
|
||||
func changeError(err error) *apperrors.AppError {
|
||||
var appErr *apperrors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr
|
||||
}
|
||||
return apperrors.New(apperrors.CodeInternalError, "账号权限或组织操作失败")
|
||||
}
|
||||
|
||||
func fillFailure(appErr *apperrors.AppError, change *ChangeAudit) {
|
||||
if change.Result == "" {
|
||||
change.Result = constants.AuditResultFailed
|
||||
}
|
||||
if change.ErrorCode == "" {
|
||||
change.ErrorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
if change.ErrorSummary == "" {
|
||||
change.ErrorSummary = appErr.Message
|
||||
}
|
||||
}
|
||||
|
||||
func recordSecondaryFailure(ctx context.Context, change ChangeAudit, err error) {
|
||||
value := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
change.ActionCode, resourceKey(change), value.RequestID, value.CorrelationID, change.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
|
||||
func resourceKey(change ChangeAudit) string {
|
||||
if change.Account != nil {
|
||||
if change.Account.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Account.ID), 10)
|
||||
}
|
||||
return change.Account.Username
|
||||
}
|
||||
if change.Enterprise != nil {
|
||||
if change.Enterprise.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Enterprise.ID), 10)
|
||||
}
|
||||
return change.Enterprise.EnterpriseCode
|
||||
}
|
||||
if change.PersonalCustomer != nil {
|
||||
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
|
||||
}
|
||||
if change.Shop != nil {
|
||||
if change.Shop.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Shop.ID), 10)
|
||||
}
|
||||
return change.Shop.ShopCode
|
||||
}
|
||||
if change.Role != nil {
|
||||
if change.Role.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Role.ID), 10)
|
||||
}
|
||||
return change.Role.RoleName
|
||||
}
|
||||
for _, permission := range change.Permissions {
|
||||
if permission.Permission == nil {
|
||||
continue
|
||||
}
|
||||
if permission.Permission.ID != 0 {
|
||||
return strconv.FormatUint(uint64(permission.Permission.ID), 10)
|
||||
}
|
||||
return permission.Permission.PermCode
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
46
internal/application/accountaudit/lifecycle.go
Normal file
46
internal/application/accountaudit/lifecycle.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Package accountaudit 定义账号生命周期写入统一审计的应用边界。
|
||||
package accountaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LifecycleAudit 是账号生命周期用例提交给统一 Writer 的业务事实。
|
||||
type LifecycleAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
Account *model.Account
|
||||
Shop *model.Shop
|
||||
Enterprise *model.Enterprise
|
||||
Roles []*model.Role
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// SecurityAudit 是账号安全用例提交给统一 Writer 的无凭据业务事实。
|
||||
type SecurityAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
ActorID uint
|
||||
ActorName string
|
||||
Account *model.Account
|
||||
AuthenticationKey string
|
||||
Authentication map[string]any
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// Writer 在调用方提供的事务内追加账号生命周期事件。
|
||||
type Writer interface {
|
||||
WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit LifecycleAudit) error
|
||||
WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit SecurityAudit) error
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type ConfirmOnlinePaymentCommand struct {
|
||||
PaidAt time.Time
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
ParentEventID string
|
||||
}
|
||||
|
||||
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
|
||||
@@ -44,6 +45,7 @@ type PaymentConfirmedEvent struct {
|
||||
PaidAt time.Time `json:"paid_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ParentEventID string `json:"parent_event_id,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
|
||||
@@ -54,6 +56,7 @@ type PaymentConfirmedEventWriter interface {
|
||||
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
|
||||
type ConfirmOnlinePaymentResult struct {
|
||||
RechargeID uint
|
||||
PaymentID uint
|
||||
AlreadyConfirmed bool
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
return err
|
||||
}
|
||||
result.RechargeID = recharge.ID
|
||||
result.PaymentID = payment.ID
|
||||
if alreadyConfirmed {
|
||||
result.AlreadyConfirmed = true
|
||||
return nil
|
||||
@@ -124,6 +128,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
|
||||
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
ParentEventID: command.ParentEventID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
|
||||
|
||||
@@ -4,6 +4,7 @@ package outbox
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
)
|
||||
@@ -33,6 +35,30 @@ type RecoveryAudit struct {
|
||||
BatchID string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Events []RecoveryEventAudit
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// RecoveryEventAudit 是一次人工恢复中单个 Outbox 事件的身份和状态变化。
|
||||
type RecoveryEventAudit struct {
|
||||
ID uint
|
||||
EventID string
|
||||
EventType string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
BusinessKey string
|
||||
BeforeStatus int
|
||||
AfterStatus int
|
||||
BeforeNextAttempt time.Time
|
||||
AfterNextAttempt time.Time
|
||||
BeforeLeaseOwner *string
|
||||
BeforeLeaseExpires *time.Time
|
||||
AfterLeaseOwner *string
|
||||
AfterLeaseExpires *time.Time
|
||||
}
|
||||
|
||||
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
|
||||
@@ -65,23 +91,38 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
failureAudit := RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay,
|
||||
Description: "人工重放 Outbox 事件失败", Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
|
||||
if len(events) != len(ids) {
|
||||
failureAudit.Events = nil
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
allowed := event.Status == constants.OutboxStatusFailed ||
|
||||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
|
||||
if !allowed {
|
||||
failureAudit.Result = constants.AuditResultDenied
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
|
||||
failureAudit.ErrorSummary = "选择的事件不存在或状态不允许重放"
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
|
||||
}
|
||||
failureAudit.Result = constants.AuditResultFailed
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
|
||||
failureAudit.ErrorSummary = "Outbox 人工重放事务已回滚"
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
|
||||
@@ -90,11 +131,14 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_replay", Description: "人工重放 Outbox 事件",
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay, Description: "人工重放 Outbox 事件",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, failureAudit)
|
||||
}
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
@@ -105,21 +149,36 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
failureAudit := RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease,
|
||||
Description: "人工释放 Outbox 过期租约失败", Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
|
||||
if len(events) != len(ids) {
|
||||
failureAudit.Events = nil
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
|
||||
failureAudit.Result = constants.AuditResultDenied
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
|
||||
failureAudit.ErrorSummary = "选择的租约不存在或仍然有效"
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
|
||||
}
|
||||
failureAudit.Result = constants.AuditResultFailed
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
|
||||
failureAudit.ErrorSummary = "Outbox 过期租约释放事务已回滚"
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
@@ -129,14 +188,31 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_release_expired_lease", Description: "人工释放 Outbox 过期租约",
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease, Description: "人工释放 Outbox 过期租约",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, failureAudit)
|
||||
}
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
func (s *RecoveryService) recordFailure(ctx context.Context, audit RecoveryAudit) {
|
||||
if len(audit.Events) == 0 || audit.Result == "" {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteRecovery(ctx, tx, audit)
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
audit.OperationType, audit.Events[0].EventID, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCommand(operator Operator, ids []uint, reason string) error {
|
||||
if !operator.SuperAdmin {
|
||||
return pkgerrors.New(pkgerrors.CodeForbidden)
|
||||
@@ -152,3 +228,28 @@ func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error)
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func recoveryEventAudit(event model.OutboxEvent, nextAttempt time.Time) RecoveryEventAudit {
|
||||
return RecoveryEventAudit{
|
||||
ID: event.ID, EventID: event.EventID, EventType: event.EventType,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
BeforeStatus: event.Status, AfterStatus: constants.OutboxStatusPending,
|
||||
BeforeNextAttempt: event.NextAttemptAt, AfterNextAttempt: nextAttempt,
|
||||
BeforeLeaseOwner: event.LeaseOwner, BeforeLeaseExpires: event.LeaseExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func unchangedRecoveryAudit(events []model.OutboxEvent) ([]string, []RecoveryEventAudit) {
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvent := recoveryEventAudit(event, event.NextAttemptAt)
|
||||
auditEvent.AfterStatus = event.Status
|
||||
auditEvent.AfterLeaseOwner = event.LeaseOwner
|
||||
auditEvent.AfterLeaseExpires = event.LeaseExpiresAt
|
||||
auditEvents = append(auditEvents, auditEvent)
|
||||
}
|
||||
return eventIDs, auditEvents
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package role
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
|
||||
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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -20,11 +22,12 @@ type PermissionChecker interface {
|
||||
type DefaultCreditService struct {
|
||||
db *gorm.DB
|
||||
permissionChecker PermissionChecker
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewDefaultCreditService 创建角色默认信用模板服务。
|
||||
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
|
||||
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
|
||||
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker, accessAudit accessauditapp.Writer) *DefaultCreditService {
|
||||
return &DefaultCreditService{db: db, permissionChecker: permissionChecker, accessAudit: accessAudit}
|
||||
}
|
||||
|
||||
// Update 更新模板;该操作不扫描或修改任何既有钱包。
|
||||
@@ -39,8 +42,8 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
if err := validateDefaultCredit(enabled, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var role model.Role
|
||||
var beforeData map[string]any
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -48,6 +51,7 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
|
||||
}
|
||||
beforeData = defaultCreditAuditData(&role)
|
||||
if role.RoleType != constants.RoleTypeCustomer {
|
||||
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
|
||||
}
|
||||
@@ -68,14 +72,46 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
role.DefaultCreditEnabled = enabled
|
||||
role.DefaultCreditLimit = limit
|
||||
role.Updater = operatorID
|
||||
return nil
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "角色默认信用审计接缝未配置")
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Role: &role, BeforeData: beforeData, AfterData: defaultCreditAuditData(&role),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
if role.ID != 0 {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板失败",
|
||||
Result: defaultCreditAuditResult(err), OperatorID: operatorID, Role: &role, BeforeData: beforeData,
|
||||
}, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func defaultCreditAuditData(role *model.Role) map[string]any {
|
||||
return map[string]any{
|
||||
"role_name": role.RoleName, "role_type": role.RoleType,
|
||||
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCreditAuditResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if !stdErrors.As(err, &appErr) {
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
switch appErr.Code {
|
||||
case errors.CodeInternalError, errors.CodeDatabaseError, errors.CodeRedisError:
|
||||
return constants.AuditResultFailed
|
||||
default:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeSuperAdmin {
|
||||
|
||||
@@ -3,11 +3,13 @@ package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"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/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -17,12 +19,13 @@ import (
|
||||
|
||||
// CreateService 收口平台与代理创建店铺的完整事务。
|
||||
type CreateService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewCreateService 创建店铺创建事务脚本。
|
||||
func NewCreateService(db *gorm.DB) *CreateService {
|
||||
return &CreateService{db: db}
|
||||
func NewCreateService(db *gorm.DB, audit accessauditapp.Writer) *CreateService {
|
||||
return &CreateService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Create 按操作者类型执行平台显式归属或代理安全继承。
|
||||
@@ -37,26 +40,26 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
case constants.UserTypeAgent:
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限设置店铺业务员"))
|
||||
}
|
||||
if request.ParentID == nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
resolver = resolveInheritedBusinessOwner
|
||||
default:
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败"))
|
||||
}
|
||||
|
||||
var response *dto.ShopResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
|
||||
created, createErr := createShop(ctx, tx, request, operatorID, string(hashedPassword), resolver, s.audit)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
@@ -64,14 +67,14 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return s.fail(ctx, request, err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
|
||||
|
||||
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
|
||||
func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver, audit accessauditapp.Writer) (*dto.ShopResponse, error) {
|
||||
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
|
||||
} else if exists {
|
||||
@@ -157,9 +160,77 @@ func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, ha
|
||||
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺创建审计接缝未配置")
|
||||
}
|
||||
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺",
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent,
|
||||
AfterData: shopCreationData(shop),
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺创建审计失败")
|
||||
}
|
||||
if ownerID != nil {
|
||||
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "设置店铺业务员归属",
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent,
|
||||
Accounts: businessOwnerAuditAccounts(tx, nil, ownerID),
|
||||
AfterData: map[string]any{"business_owner_account_id": ownerID},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已设置",
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest, originalErr error) (*dto.ShopResponse, error) {
|
||||
shop := &model.Shop{ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID}
|
||||
var parent *model.Shop
|
||||
if request.ParentID != nil {
|
||||
parent = &model.Shop{}
|
||||
parent.ID = *request.ParentID
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺失败", Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, ParentShop: parent,
|
||||
}, originalErr)
|
||||
return nil, originalErr
|
||||
}
|
||||
|
||||
func shopCreationData(shop *model.Shop) map[string]any {
|
||||
data := shopProfileData(shop)
|
||||
data["shop_code"] = shop.ShopCode
|
||||
data["parent_id"] = shop.ParentID
|
||||
data["level"] = shop.Level
|
||||
return data
|
||||
}
|
||||
|
||||
func shopProfileData(shop *model.Shop) map[string]any {
|
||||
return map[string]any{
|
||||
"shop_name": shop.ShopName, "contact_name": shop.ContactName, "contact_phone": shop.ContactPhone,
|
||||
"province": shop.Province, "city": shop.City, "district": shop.District, "address": shop.Address,
|
||||
}
|
||||
}
|
||||
|
||||
func shopProfileChanged(before, after *model.Shop) bool {
|
||||
return before.ShopName != after.ShopName || before.ContactName != after.ContactName ||
|
||||
before.ContactPhone != after.ContactPhone || before.Province != after.Province || before.City != after.City ||
|
||||
before.District != after.District || before.Address != after.Address
|
||||
}
|
||||
|
||||
func shopAuditFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeInvalidParentID,
|
||||
errors.CodeShopLevelExceeded, errors.CodeShopCodeExists, errors.CodeUsernameExists, errors.CodePhoneExists:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
|
||||
if parentID == nil {
|
||||
return nil, 1, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
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/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -15,12 +16,13 @@ import (
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewUpdateService 创建店铺更新事务脚本。
|
||||
func NewUpdateService(db *gorm.DB) *UpdateService {
|
||||
return &UpdateService{db: db}
|
||||
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
|
||||
return &UpdateService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
|
||||
@@ -50,11 +52,16 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
var response *dto.ShopResponse
|
||||
var beforeShop *model.Shop
|
||||
var parentShop *model.Shop
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var shop model.Shop
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
before := shop
|
||||
beforeShop = &before
|
||||
parentShop = loadAuditParentShop(tx, shop.ParentID)
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
|
||||
if err != nil {
|
||||
@@ -88,14 +95,160 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
|
||||
return err
|
||||
}
|
||||
if shopProfileChanged(&before, &shop) {
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
|
||||
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
|
||||
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
|
||||
}
|
||||
}
|
||||
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
|
||||
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
|
||||
}, err)
|
||||
}
|
||||
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
|
||||
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
|
||||
}
|
||||
if before.Status != after.Status {
|
||||
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
|
||||
if after.Status == constants.StatusEnabled {
|
||||
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||||
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
|
||||
}
|
||||
}
|
||||
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
|
||||
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
|
||||
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
|
||||
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
|
||||
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
|
||||
}
|
||||
}
|
||||
if before.ClientLoginDisabled != after.ClientLoginDisabled {
|
||||
subject := "店铺 C 端登录限制已解除"
|
||||
if after.ClientLoginDisabled {
|
||||
subject = "店铺 C 端登录已限制"
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
|
||||
OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||||
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
|
||||
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
|
||||
if shop == nil {
|
||||
return
|
||||
}
|
||||
record := func(action, summary string) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, originalErr)
|
||||
}
|
||||
if shop.Status != request.Status {
|
||||
action := constants.AuditActionShopDisabled
|
||||
if request.Status == constants.StatusEnabled {
|
||||
action = constants.AuditActionShopEnabled
|
||||
}
|
||||
record(action, "更新店铺状态失败")
|
||||
}
|
||||
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
|
||||
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
|
||||
}
|
||||
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
|
||||
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
|
||||
}
|
||||
}
|
||||
|
||||
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
|
||||
changes := make([]accessauditapp.AccountChange, 0, 2)
|
||||
if account := loadAuditAccount(tx, beforeID); account != nil {
|
||||
changes = append(changes, accessauditapp.AccountChange{
|
||||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
|
||||
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
|
||||
})
|
||||
}
|
||||
if account := loadAuditAccount(tx, afterID); account != nil {
|
||||
changes = append(changes, accessauditapp.AccountChange{
|
||||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
|
||||
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
|
||||
if accountID == nil {
|
||||
return nil
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &account
|
||||
}
|
||||
|
||||
func sameOptionalUint(left, right *uint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
|
||||
return shop.ShopName != request.ShopName || shop.ContactName != request.ContactName ||
|
||||
shop.ContactPhone != request.ContactPhone || shop.Province != request.Province || shop.City != request.City ||
|
||||
shop.District != request.District || shop.Address != request.Address
|
||||
}
|
||||
|
||||
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
var parent model.Shop
|
||||
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &parent
|
||||
}
|
||||
|
||||
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
|
||||
if requestedID == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -3,8 +3,7 @@ package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/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"
|
||||
@@ -24,13 +24,17 @@ type ChangeAudit struct {
|
||||
OperationType string
|
||||
Description string
|
||||
ConfigKey string
|
||||
Module string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// AuditWriter 可选接收系统配置事务内审计事实。
|
||||
// AuditWriter 接收系统配置事务内审计事实。
|
||||
type AuditWriter interface {
|
||||
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
|
||||
}
|
||||
@@ -69,14 +73,29 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
if operatorID == 0 || key == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "系统配置审计接缝未配置")
|
||||
}
|
||||
definition, registered := s.registry.Get(key)
|
||||
if !registered {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
|
||||
}
|
||||
if definition.Readonly {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "拒绝更新只读系统配置", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidStatus),
|
||||
ErrorSummary: "系统配置为只读,更新请求已拒绝",
|
||||
})
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
|
||||
}
|
||||
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "拒绝非法系统配置值", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam),
|
||||
ErrorSummary: "系统配置值不符合注册规则",
|
||||
})
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
@@ -121,21 +140,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
|
||||
ConfigKey: key,
|
||||
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
|
||||
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate, Description: "更新受控系统配置",
|
||||
ConfigKey: key, Module: definition.Module,
|
||||
BeforeData: auditData(definition, beforeValue), AfterData: auditData(definition, request.Value),
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "系统配置更新失败", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(errors.CodeDatabaseError),
|
||||
ErrorSummary: "系统配置更新事务已回滚",
|
||||
})
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
|
||||
}
|
||||
s.registry.Remember(key, request.Value)
|
||||
@@ -159,10 +181,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
}, nil
|
||||
}
|
||||
|
||||
func auditValue(definition configinfra.Definition, value string) string {
|
||||
if !definition.Sensitive {
|
||||
return value
|
||||
func (s *UpdateService) recordFailure(ctx context.Context, audit ChangeAudit) {
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
audit.RequestID = *value
|
||||
audit.CorrelationID = *value
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteConfigChange(ctx, tx, audit)
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "[敏感值 sha256:" + hex.EncodeToString(sum[:8]) + "]"
|
||||
}
|
||||
|
||||
func auditData(definition configinfra.Definition, value string) map[string]any {
|
||||
if definition.Sensitive {
|
||||
return map[string]any{"credentials_configured": value != ""}
|
||||
}
|
||||
return map[string]any{"value": value}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
|
||||
return &ChangeCreditService{db: db}
|
||||
}
|
||||
|
||||
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
|
||||
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
|
||||
var result *dto.ShopCreditLimitResponse
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var stored model.AgentWallet
|
||||
@@ -35,7 +35,7 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
|
||||
return err
|
||||
}
|
||||
update := tx.Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, stored.Version, aggregate.EffectiveCredit()).
|
||||
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
|
||||
@@ -45,13 +45,13 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
|
||||
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(¤t).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
if current.Version != version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
|
||||
if current.Version != stored.Version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
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: version + 1}
|
||||
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
|
||||
})
|
||||
return result, err
|
||||
|
||||
@@ -38,14 +38,44 @@ type AccessTokenProvider interface {
|
||||
Invalidate(ctx context.Context, applicationID uint)
|
||||
}
|
||||
|
||||
// SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。
|
||||
type SensitiveReadAuditWriter interface {
|
||||
WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error
|
||||
}
|
||||
|
||||
// SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。
|
||||
type SensitiveReadAudit struct {
|
||||
OperatorID uint
|
||||
Applications []SensitiveReadResource
|
||||
FieldClasses []string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// SensitiveReadResource 是不含任何明文凭据的读取目标快照。
|
||||
type SensitiveReadResource struct {
|
||||
ID uint
|
||||
CorpID string
|
||||
AgentID int64
|
||||
Name string
|
||||
Status int
|
||||
CredentialsConfigured bool
|
||||
}
|
||||
|
||||
// ConnectionService 保存应用配置并测试企业微信连接。
|
||||
type ConnectionService struct {
|
||||
db *gorm.DB
|
||||
repo ApplicationRepository
|
||||
tokens AccessTokenProvider
|
||||
audit systemconfigapp.AuditWriter
|
||||
members DefaultCreatorMemberFinder
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
repo ApplicationRepository
|
||||
tokens AccessTokenProvider
|
||||
audit systemconfigapp.AuditWriter
|
||||
readAudit SensitiveReadAuditWriter
|
||||
members DefaultCreatorMemberFinder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。
|
||||
func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) {
|
||||
s.readAudit = writer
|
||||
}
|
||||
|
||||
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
|
||||
@@ -156,6 +186,33 @@ func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(applications) > 0 {
|
||||
if s.db == nil || s.readAudit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置")
|
||||
}
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
resources := make([]SensitiveReadResource, 0, len(applications))
|
||||
for _, application := range applications {
|
||||
resources = append(resources, SensitiveReadResource{
|
||||
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID,
|
||||
Name: application.Name, Status: application.Status,
|
||||
CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
|
||||
})
|
||||
}
|
||||
readAudit := SensitiveReadAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources,
|
||||
FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result := make([]dto.WeComApplicationResponse, 0, len(applications))
|
||||
for _, application := range applications {
|
||||
result = append(result, toApplicationResponse(application))
|
||||
|
||||
Reference in New Issue
Block a user