修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m38s

This commit is contained in:
2026-08-12 10:42:35 +08:00
parent 619d0c5efe
commit fcfa347005
13 changed files with 371 additions and 14 deletions

View File

@@ -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 ""
}

View File

@@ -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 追加事件并返回已持久化的审计事件,幂等重放返回已有事件。

View 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)
}
}

View File

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

View 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)
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-12

View File

@@ -0,0 +1,29 @@
## Context
统一审计 Writer 当前被业务事务直接调用;其错误由调用方返回,导致事务回滚。审计规则要求内部可见资源不携带主体投影,但个人客户绑定关联资源违反了该规则。
## Goals / Non-Goals
**Goals:**
- 统一将审计写入失败降级为结构化诊断,不影响业务事务和接口结果。
- 修复个人客户资产绑定的无效审计资源。
**Non-Goals:**
- 不改变已成功写入审计与业务事实同事务提交的行为。
- 不新增队列、表、重试机制或迁移。
## Decisions
- 在统一 `Writer` 的业务审计入口吞掉写入错误并记录失败;所有既有 86 个调用者由此共享行为,无需逐一改造。备选的逐调用者处理会遗漏路径且重复。
- 失败诊断复用现有 `auditfailure.RecordSecondaryWriteFailure`,同时用 Zap 写结构化日志,保留动作、资源、请求与关联标识。
- 删除内部关联卡/设备资源上的主体摘要;主体投影只保留在主个人客户资源。
## Risks / Trade-offs
- [审计记录可能缺失] → 写入结构化日志与二次失败记录,供告警和补偿处理。
- [调用方继续假定写入失败可回滚业务] → 统一入口保证实际行为一致,并通过构建与静态调用点复核。
## Migration Plan
1. 发布代码后,首次 C 端登录及所有既有审计调用路径自动采用不阻断行为。
2. 回滚时恢复原 Writer 行为;不存在数据迁移。

View File

@@ -0,0 +1,26 @@
## Why
审计写入的校验或持久化失败会回滚业务事务,已导致 C 端首次登录的资产绑定失败。审计是记录能力,记录失败必须可排查但不得改变原业务接口的成功或失败结果。
## What Changes
- 审计写入失败时记录结构化错误日志与二次失败记录,不向业务事务返回该错误。
- 保留审计写入成功时与业务事实同事务提交的现有一致性。
- 修正个人客户资产绑定审计中内部关联资源携带主体摘要的无效数据。
## Capabilities
### New Capabilities
-
### Modified Capabilities
- `operations-audit`: 审计记录失败的可观测性与对业务事务的隔离行为。
- `personal-customer`: C 端有效登录材料的登录结果不受审计记录失败影响。
## Impact
- `internal/infrastructure/audit` 的写入边界与失败日志。
- 所有调用统一审计 Writer 的业务、任务和回调路径。
- `internal/service/customer_binding` 的资产绑定审计资源构造。

View File

@@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: 审计时间线
系统 SHALL 支持按事件、操作者、资源、请求、关联标识和资金维度查询已记录的审计事实。新建的 `tb_integration_log` 在已取得稳定审计事件时 SHALL 写入其内部 ID 作为 `audit_event_id`,并且该日志的非空 `integration_id` SHALL 出现在对应事件列表和事件详情的 `investigation_refs.integration_refs` 中;关联生成失败时系统 MUST 保留 Integration Log 并记录可排查告警,且 MUST NOT 按名称、时间或摘要推断关联。历史 Integration Log 不在本要求的回填范围内。审计事件的构造、校验或持久化失败 MUST 记录可关联的结构化错误日志和二次失败记录,且 MUST NOT 改变已通过业务校验的业务操作结果或接口响应。
#### Scenario: 审计时间线
- **GIVEN** 审计事实已存在
- **WHEN** 使用对应维度查询
- **THEN** 返回匹配的事实与稳定动作编码,不用访问日志替代
#### Scenario: 事件返回已关联外部交互引用
- **GIVEN** 一个在线审计事件有 `tb_integration_log.audit_event_id` 指向其内部 ID 的外部交互记录
- **WHEN** 查询全局审计事件列表或该事件详情
- **THEN** 该事件的 `investigation_refs.integration_refs` 返回该记录的 `integration_id`
#### Scenario: 事件没有关联外部交互引用
- **GIVEN** 一个在线审计事件没有稳定关联的外部交互记录
- **WHEN** 查询全局审计事件列表或该事件详情
- **THEN** `investigation_refs.integration_refs` 返回空数组
#### Scenario: 新外部交互日志具有稳定审计关联
- **GIVEN** 系统即将记录一次新的外部调用、入站回调或未发送裁决
- **WHEN** 写入对应 Integration Log
- **THEN** 该记录保存非空 `audit_event_id`,且其目标 Audit Event 已存在
#### Scenario: 缺少审计关联时保留外部交互日志
- **GIVEN** 一次新的外部交互日志没有稳定审计事件关联
- **WHEN** 系统尝试写入该 Integration Log
- **THEN** 系统持久化该 Integration Log、记录可排查告警并返回空 `integration_refs`
#### Scenario: 审计写入失败不阻断业务
- **GIVEN** 一个业务操作已通过自身输入、权限和状态校验
- **WHEN** 该操作的审计事件构造、校验或持久化失败
- **THEN** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: 个人客户身份
系统 SHALL 支持个人客户通过既有认证入口登录、退出并查询当前资料。有效登录材料对应的客户创建、资料同步或资产绑定 SHALL 不因审计事件构造、校验或持久化失败而失败;审计失败 SHALL 按运营审计要求记录。
#### Scenario: 个人客户身份
- **GIVEN** 个人客户提供有效登录材料
- **WHEN** 请求认证
- **THEN** 系统返回个人客户身份和访问凭证
#### Scenario: 登录审计失败
- **GIVEN** 个人客户提供有效登录材料且登录需要创建或绑定资产
- **WHEN** 对应审计事件写入失败
- **THEN** 系统仍完成客户和资产绑定并返回访问凭证

View File

@@ -0,0 +1,13 @@
## 1. 审计写入隔离
- [x] 1.1 将统一审计 Writer 的业务写入失败降级为结构化日志和二次失败记录,且不向调用业务返回错误。
- [x] 1.2 在统一入口保留审计成功时的原事务写入行为,并核对全部既有直接调用者共享该入口。
## 2. C 端登录修复
- [x] 2.1 删除个人客户资产绑定中内部关联卡、设备资源的主体摘要,保留主个人客户资源的合法投影。
## 3. 验证
- [x] 3.1 对审计写入失败不阻断业务及资产绑定内部资源无投影执行最小可运行验证。
- [x] 3.2 运行 gofmt、Go 构建和 OpenSpec 校验。

View File

@@ -8,7 +8,7 @@
### Requirement: 审计时间线
系统 SHALL 支持按事件、操作者、资源、请求、关联标识和资金维度查询已记录的审计事实。新建的 `tb_integration_log` 在已取得稳定审计事件时 SHALL 写入其内部 ID 作为 `audit_event_id`,并且该日志的非空 `integration_id` SHALL 出现在对应事件列表和事件详情的 `investigation_refs.integration_refs` 中;关联生成失败时系统 MUST 保留 Integration Log 并记录可排查告警,且 MUST NOT 按名称、时间或摘要推断关联。历史 Integration Log 不在本要求的回填范围内。
系统 SHALL 支持按事件、操作者、资源、请求、关联标识和资金维度查询已记录的审计事实。新建的 `tb_integration_log` 在已取得稳定审计事件时 SHALL 写入其内部 ID 作为 `audit_event_id`,并且该日志的非空 `integration_id` SHALL 出现在对应事件列表和事件详情的 `investigation_refs.integration_refs` 中;关联生成失败时系统 MUST 保留 Integration Log 并记录可排查告警,且 MUST NOT 按名称、时间或摘要推断关联。历史 Integration Log 不在本要求的回填范围内。审计事件的构造、校验或持久化失败 MUST 记录可关联的结构化错误日志和二次失败记录,且 MUST NOT 改变已通过业务校验的业务操作结果或接口响应。
#### Scenario: 审计时间线
@@ -40,6 +40,12 @@
- **WHEN** 系统尝试写入该 Integration Log
- **THEN** 系统持久化该 Integration Log、记录可排查告警并返回空 `integration_refs`
#### Scenario: 审计写入失败不阻断业务
- **GIVEN** 一个业务操作已通过自身输入、权限和状态校验
- **WHEN** 该操作的审计事件构造、校验或持久化失败
- **THEN** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -8,7 +8,7 @@
### Requirement: 个人客户身份
系统 SHALL 支持个人客户通过既有认证入口登录、退出并查询当前资料。
系统 SHALL 支持个人客户通过既有认证入口登录、退出并查询当前资料。有效登录材料对应的客户创建、资料同步或资产绑定 SHALL 不因审计事件构造、校验或持久化失败而失败;审计失败 SHALL 按运营审计要求记录。
#### Scenario: 个人客户身份
@@ -16,6 +16,12 @@
- **WHEN** 请求认证
- **THEN** 系统返回个人客户身份和访问凭证
#### Scenario: 登录审计失败
- **GIVEN** 个人客户提供有效登录材料且登录需要创建或绑定资产
- **WHEN** 对应审计事件写入失败
- **THEN** 系统仍完成客户和资产绑定并返回访问凭证
### Requirement: 资产归属查询
系统 SHALL 只向个人客户返回当前绑定或可识别的卡与设备资产。