This commit is contained in:
@@ -5,12 +5,14 @@ import (
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
)
|
||||
|
||||
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
|
||||
@@ -48,15 +50,38 @@ func fillFailureInput(input *AppendInput, originalErr error) {
|
||||
}
|
||||
|
||||
func recordFailureWriteError(ctx context.Context, input AppendInput, err error) {
|
||||
recordSecondaryWriteFailure(ctx, input.ActionCode, primaryResourceKey(input), input.ErrorCode, err)
|
||||
}
|
||||
|
||||
func recordBusinessAppendFailure(ctx context.Context, input AppendInput, err error) {
|
||||
recordBusinessWriteFailure(ctx, input.ActionCode, primaryResourceKey(input), err)
|
||||
}
|
||||
|
||||
func recordBusinessWriteFailure(ctx context.Context, actionCode, resourceKey string, err error) {
|
||||
linkage := auditcontext.From(ctx)
|
||||
logger.GetAppLogger().Error(
|
||||
"业务审计写入失败,已降级",
|
||||
zap.String("action", actionCode),
|
||||
zap.String("resource_key", resourceKey),
|
||||
zap.String("request_id", linkage.RequestID),
|
||||
zap.String("correlation_id", linkage.CorrelationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
recordSecondaryWriteFailure(ctx, actionCode, resourceKey, "", err)
|
||||
}
|
||||
|
||||
func recordSecondaryWriteFailure(ctx context.Context, actionCode, resourceKey, originalErrorCode string, err error) {
|
||||
linkage := auditcontext.From(ctx)
|
||||
resourceKey := ""
|
||||
for _, resource := range input.Resources {
|
||||
if resource.Relation == constants.AuditResourceRelationPrimary {
|
||||
resourceKey = resource.Key
|
||||
break
|
||||
}
|
||||
}
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
input.ActionCode, resourceKey, linkage.RequestID, linkage.CorrelationID, input.ErrorCode, err,
|
||||
actionCode, resourceKey, linkage.RequestID, linkage.CorrelationID, originalErrorCode, err,
|
||||
)
|
||||
}
|
||||
|
||||
func primaryResourceKey(input AppendInput) string {
|
||||
for _, resource := range input.Resources {
|
||||
if resource.Relation == constants.AuditResourceRelationPrimary {
|
||||
return resource.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -127,14 +127,17 @@ func accountIdentity(account *model.Account) map[string]any {
|
||||
func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change accessauditapp.ChangeAudit) error {
|
||||
action, ok := w.registry.Action(change.ActionCode)
|
||||
if !ok {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计动作未注册")
|
||||
recordBusinessWriteFailure(ctx, change.ActionCode, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计动作未注册"))
|
||||
return nil
|
||||
}
|
||||
if change.OperatorID == 0 {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计操作者不完整")
|
||||
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计操作者不完整"))
|
||||
return nil
|
||||
}
|
||||
resources, err := accessResources(change, action.PrimaryResource)
|
||||
if err != nil {
|
||||
return err
|
||||
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), err)
|
||||
return nil
|
||||
}
|
||||
result := change.Result
|
||||
if result == "" {
|
||||
@@ -166,6 +169,25 @@ func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change acce
|
||||
})
|
||||
}
|
||||
|
||||
func accessChangeResourceKey(change accessauditapp.ChangeAudit) string {
|
||||
if change.PersonalCustomer != nil {
|
||||
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
|
||||
}
|
||||
if change.Account != nil {
|
||||
return accountResourceKey(change.Account)
|
||||
}
|
||||
if change.Shop != nil {
|
||||
return shopResourceKey(change.Shop)
|
||||
}
|
||||
if change.Enterprise != nil {
|
||||
return enterpriseResourceKey(change.Enterprise)
|
||||
}
|
||||
if change.Role != nil {
|
||||
return strconv.FormatUint(uint64(change.Role.ID), 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) {
|
||||
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.Roles)+len(change.Permissions))
|
||||
switch primaryResource {
|
||||
@@ -873,7 +895,10 @@ func (w *Writer) WriteRecovery(ctx context.Context, tx *gorm.DB, recovery outbox
|
||||
// Append 在调用方提供的 GORM 事务中顺序追加事件及资源。
|
||||
func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) error {
|
||||
_, err := w.AppendAndGet(ctx, tx, input)
|
||||
return err
|
||||
if err != nil {
|
||||
recordBusinessAppendFailure(ctx, input, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendAndGet 追加事件并返回已持久化的审计事件,幂等重放返回已有事件。
|
||||
|
||||
82
internal/infrastructure/audit/writer_check_test.go
Normal file
82
internal/infrastructure/audit/writer_check_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestAppendFailureDoesNotReturnToBusiness(t *testing.T) {
|
||||
writer := NewWriter(nil, nil)
|
||||
input := AppendInput{ActionCode: "missing_action"}
|
||||
before := auditfailure.SecondaryWriteFailureCount()
|
||||
if err := writer.Append(context.Background(), nil, input); err != nil {
|
||||
t.Fatalf("Append 返回审计失败: %v", err)
|
||||
}
|
||||
if err := writer.WriteAccessChange(context.Background(), nil, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPersonalCustomerAssetBound,
|
||||
OperatorID: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("资源构造失败返回业务: %v", err)
|
||||
}
|
||||
if got := auditfailure.SecondaryWriteFailureCount(); got != before+2 {
|
||||
t.Fatalf("二次失败记录次数 = %d, want %d", got, before+2)
|
||||
}
|
||||
if _, err := writer.AppendAndGet(context.Background(), nil, input); err == nil {
|
||||
t.Fatal("AppendAndGet 未保留错误语义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalCustomerAssetBoundProjectsOnlyPersonalResources(t *testing.T) {
|
||||
action, ok := NewRegistry().Action(constants.AuditActionPersonalCustomerAssetBound)
|
||||
if !ok {
|
||||
t.Fatal("未注册个人客户资产绑定审计动作")
|
||||
}
|
||||
resources, err := accessResources(accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPersonalCustomerAssetBound,
|
||||
PersonalCustomer: &model.PersonalCustomer{Model: gorm.Model{ID: 1}, Nickname: "客户"},
|
||||
PersonalDevices: []accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: &model.PersonalCustomerDevice{Model: gorm.Model{ID: 2}, CustomerID: 1, VirtualNo: "DEVICE-1"},
|
||||
}},
|
||||
PersonalICCIDs: []accessauditapp.PersonalCustomerICCIDChange{{
|
||||
Binding: &model.PersonalCustomerICCID{Model: gorm.Model{ID: 3}, CustomerID: 1, ICCID: "ICCID-1"},
|
||||
}},
|
||||
SubjectVisibility: constants.AuditSubjectDetail,
|
||||
SubjectSummary: "绑定个人客户资产",
|
||||
SubjectData: map[string]any{"asset_type": constants.AuditResourceIotCard, "asset_id": uint(9)},
|
||||
}, action.PrimaryResource)
|
||||
if err != nil {
|
||||
t.Fatalf("构造绑定审计资源失败: %v", err)
|
||||
}
|
||||
projected, err := NewWriter(nil, nil).buildResources(resources, action)
|
||||
if err != nil {
|
||||
t.Fatalf("构造绑定审计投影失败: %v", err)
|
||||
}
|
||||
want := map[string]bool{
|
||||
constants.AuditResourcePersonalCustomer: true,
|
||||
constants.AuditResourcePersonalCustomerDevice: true,
|
||||
constants.AuditResourcePersonalCustomerICCID: true,
|
||||
}
|
||||
for _, resource := range projected {
|
||||
if resource.ResourceType == constants.AuditResourceIotCard || resource.ResourceType == constants.AuditResourceDevice {
|
||||
t.Fatalf("绑定审计投影包含内部资源: %s", resource.ResourceType)
|
||||
}
|
||||
delete(want, resource.ResourceType)
|
||||
if resource.ResourceType == constants.AuditResourcePersonalCustomer {
|
||||
var subjectData map[string]any
|
||||
if err := json.Unmarshal(resource.SubjectData, &subjectData); err != nil || resource.SubjectVisibility != constants.AuditSubjectDetail || subjectData["asset_type"] != constants.AuditResourceIotCard || subjectData["asset_id"] != float64(9) {
|
||||
t.Fatalf("主个人客户主体投影不完整: %#v", resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceType := range want {
|
||||
t.Fatalf("绑定审计投影缺少合法资源: %s", resourceType)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ func (s *Service) writeBindingAudit(
|
||||
if actionCode == constants.AuditActionPersonalCustomerAssetBound {
|
||||
assetType, assetID := bindingAssetReference(cards, devices)
|
||||
subjectData = map[string]any{"asset_type": assetType, "asset_id": assetID}
|
||||
cards = nil
|
||||
devices = nil
|
||||
} else {
|
||||
operatorID = middleware.GetUserIDFromContext(ctx)
|
||||
if parsed, err := strconv.ParseUint(value.ActorID, 10, 64); err == nil && parsed > 0 {
|
||||
|
||||
83
internal/service/customer_binding/audit_test.go
Normal file
83
internal/service/customer_binding/audit_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package customer_binding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func init() { sql.Register("customer_binding_audit_test", customerAuditDriver{}) }
|
||||
|
||||
type customerAuditDriver struct{}
|
||||
|
||||
func (customerAuditDriver) Open(string) (driver.Conn, error) { return customerAuditConn{}, nil }
|
||||
|
||||
type customerAuditConn struct{}
|
||||
|
||||
func (customerAuditConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
||||
func (customerAuditConn) Close() error { return nil }
|
||||
func (customerAuditConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
||||
func (customerAuditConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
|
||||
return &customerAuditRows{}, nil
|
||||
}
|
||||
|
||||
type customerAuditRows struct{ sent bool }
|
||||
|
||||
func (*customerAuditRows) Columns() []string { return []string{"id", "nickname"} }
|
||||
func (r *customerAuditRows) Close() error { return nil }
|
||||
func (r *customerAuditRows) Next(dest []driver.Value) error {
|
||||
if r.sent {
|
||||
return io.EOF
|
||||
}
|
||||
r.sent = true
|
||||
dest[0], dest[1] = int64(7), "客户"
|
||||
return nil
|
||||
}
|
||||
|
||||
type captureAuditWriter struct{ change accessauditapp.ChangeAudit }
|
||||
|
||||
func (w *captureAuditWriter) WriteAccessChange(_ context.Context, _ *gorm.DB, change accessauditapp.ChangeAudit) error {
|
||||
w.change = change
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWriteBindingAuditOmitsInternalAssets(t *testing.T) {
|
||||
db, err := sql.Open("customer_binding_audit_test", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &captureAuditWriter{}
|
||||
service := &Service{accessAudit: writer}
|
||||
personalDevices := []accessauditapp.PersonalCustomerDeviceChange{{Binding: &model.PersonalCustomerDevice{Model: gorm.Model{ID: 2}, CustomerID: 7, VirtualNo: "DEVICE-1"}}}
|
||||
personalICCIDs := []accessauditapp.PersonalCustomerICCIDChange{{Binding: &model.PersonalCustomerICCID{Model: gorm.Model{ID: 3}, CustomerID: 7, ICCID: "ICCID-1"}}}
|
||||
cards := []accessauditapp.IotCardChange{{Card: &model.IotCard{Model: gorm.Model{ID: 9}}}}
|
||||
devices := []accessauditapp.DeviceChange{{Device: &model.Device{Model: gorm.Model{ID: 10}}}}
|
||||
|
||||
if err := service.writeBindingAudit(context.Background(), tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", 7, personalDevices, personalICCIDs, cards, devices); err != nil {
|
||||
t.Fatalf("写入绑定审计失败: %v", err)
|
||||
}
|
||||
change := writer.change
|
||||
if len(change.Cards) != 0 || len(change.Devices) != 0 {
|
||||
t.Fatalf("绑定审计泄露内部资源: Cards=%d Devices=%d", len(change.Cards), len(change.Devices))
|
||||
}
|
||||
if change.PersonalCustomer == nil || change.PersonalCustomer.ID != 7 || len(change.PersonalDevices) != 1 || len(change.PersonalICCIDs) != 1 {
|
||||
t.Fatalf("绑定审计未保留个人客户字段: %#v", change)
|
||||
}
|
||||
if change.SubjectData["asset_type"] != constants.AuditResourceIotCard || change.SubjectData["asset_id"] != uint(9) {
|
||||
t.Fatalf("绑定审计未保留主体摘要: %#v", change.SubjectData)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user