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

This commit is contained in:
2026-08-07 18:00:15 +08:00
parent 3421b5f106
commit 7aa03e91fb
12 changed files with 138 additions and 9 deletions

View File

@@ -3,8 +3,6 @@ package cardobservation
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
@@ -13,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -257,7 +256,5 @@ func workerObservationAudited(ctx context.Context) bool {
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed"))
// 使用稳定摘要保留幂等语义,同时严格服从公共 Outbox 的字段长度上限。
return prefix + hex.EncodeToString(digest[:])[:constants.OutboxEventIDMaxLength-len(prefix)]
return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed")
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -78,7 +79,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if decision.StatusChanged {
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
eventID := outboxid.Stable("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,

View File

@@ -12,6 +12,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
@@ -69,7 +70,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB > 0 {
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
eventID := outboxid.Stable("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,

View File

@@ -17,6 +17,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// Envelope 是业务 UseCase 在事务内追加的公共事件信封。
@@ -85,6 +86,9 @@ func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope,
if envelope.ParentEventID == "" {
envelope.ParentEventID = linkage.ParentEventID
}
if err := outboxid.Validate(envelope.EventID, envelope.ParentEventID); err != nil {
return nil, err
}
now := time.Now().UTC()
event := &model.OutboxEvent{
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,

View File

@@ -24,6 +24,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
type Service struct {
@@ -1721,7 +1722,7 @@ func (s *Service) updateCardAndAppendNetworkSeries(
requestID = *value
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
EventID: outboxid.Stable("card-observation:network-command:", operationID),
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),

View File

@@ -19,6 +19,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// StopResumeServiceInterface 停复机服务接口
@@ -893,7 +894,7 @@ func (s *StopResumeService) updateCardAndAppendNetworkSeries(
return nil
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
EventID: outboxid.Stable("card-observation:network-command:", operationID),
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),

View File

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

View File

@@ -0,0 +1,30 @@
## Context
公共 Outbox 的 `event_id``parent_event_id` 上限均为 64 字符。业务观测已有一个稳定 SHA-256 摘要实现,但停复机、网络和流量路径仍直接拼接 UUID 或 Integration IDRepository 也未提前执行长度校验。
## Goals / Non-Goals
**Goals:**
- 复用单一、稳定的事件 ID 压缩规则覆盖四条风险路径。
- 在公共持久化边界报告长度契约违规。
**Non-Goals:**
- 不扩大数据库字段,不修改既有事件,不改变 API 或异步载荷结构。
- 不重构其他 Outbox 生产者。
## Decisions
1. 在公共 Outbox 包提供最小稳定 ID 函数:原值未超限时原样返回,超限时保留短业务前缀并拼接 SHA-256 十六进制摘要至恰好不超过 64 字符。相比扩大 Schema此方案保持既有契约相比各调用点手写截断可避免碰撞风险和重复实现。
2. 四条已确认风险路径在构造业务事实时调用同一函数,使事件载荷内 `event_id` 与信封一致,保持幂等消费校验。
3. Repository 在 GORM Create 前校验 `EventID``ParentEventID` 长度。该防线仅返回明确错误,不自动改写未知生产者的标识语义。
## Risks / Trade-offs
- [摘要后的 ID 可读性降低] → 保留业务前缀,完整业务定位仍在聚合、资源和载荷字段中。
- [历史超长请求的重试 ID 发生变化] → 历史写入已整体回滚,不存在需兼容的 Outbox 事实。
## Migration Plan
部署代码后用 UUID 和 20 位主键验证四条构造路径及 Repository 边界,再执行现有构建与 OpenSpec 校验。回滚仅需恢复代码;无 Schema 与数据迁移。

View File

@@ -0,0 +1,23 @@
## Why
卡与设备停复机生成的业务观测 Outbox 事件 ID 超过数据库 64 字符上限,导致上游操作后本地事务回滚;同一观测链路的网络、流量事件也存在相同隐患。
## What Changes
- 为业务观测可靠事件生成不超过公共 Outbox 上限的稳定事件 ID并保持重试幂等。
- 在公共 Outbox 持久化边界提前校验事件 ID 与父事件 ID避免以 PostgreSQL 字段错误暴露契约违规。
- 覆盖卡停复机、设备停复机、网络状态变化和流量正增量四条已确认风险路径。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `asset-device`: 卡与设备控制及其后续业务观测应使用合法、稳定的可靠事件标识,不因标识超长回滚本地事务。
## Impact
影响卡与设备停复机、卡网络与流量观测、公共 Outbox Repository不改变 API、数据库 Schema 或第三方契约,不新增依赖。

View File

@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 卡业务观测可靠事件标识
系统 SHALL 为卡与设备控制及其后续网络、流量观测生成不超过公共可靠事件存储上限的稳定事件标识,相同业务事实重试时 SHALL 保持同一标识。
#### Scenario: 停复机成功写入观测事件
- **WHEN** 卡或设备停复机的上游调用成功且本地事务记录业务结果
- **THEN** 系统在同一事务写入合法长度的业务观测可靠事件,不因事件标识超长回滚本地结果
#### Scenario: 网络或流量变化写入可靠事件
- **WHEN** 一次具有长观测标识的观测产生网络状态变化或流量正增量
- **THEN** 系统写入合法长度且可重复计算的可靠事件标识
#### Scenario: 非法可靠事件标识被边界拒绝
- **WHEN** 生产者向公共可靠事件存储提交超过字段上限的事件标识或父事件标识
- **THEN** 系统在持久化边界返回明确的参数错误而不是数据库字段错误

View File

@@ -0,0 +1,13 @@
## 1. 稳定事件标识
- [x] 1.1 在公共 Outbox 包实现并检查稳定、限长的事件 ID 生成逻辑
- [x] 1.2 将卡停复机、设备停复机、网络变化和流量增量生产点接入统一逻辑
## 2. 持久化边界
- [x] 2.1 在 Repository 写入前校验事件 ID 与父事件 ID 的 64 字符上限并返回明确中文错误
## 3. 验证
- [x] 3.1 留下一个覆盖原值保留、长值稳定压缩、四条风险构造和 Repository 边界的最小可运行检查
- [x] 3.2 执行 gofmt、API/Worker 构建、OpenSpec 校验与上下文健康检查

36
pkg/outboxid/outboxid.go Normal file
View File

@@ -0,0 +1,36 @@
// Package outboxid 提供公共 Outbox 稳定事件标识生成能力。
package outboxid
import (
"crypto/sha256"
"encoding/hex"
"errors"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Stable 保留未超限标识;超限时保留业务前缀并追加稳定摘要。
func Stable(prefix, value string) string {
candidate := prefix + value
if utf8.RuneCountInString(candidate) <= constants.OutboxEventIDMaxLength {
return candidate
}
digest := sha256.Sum256([]byte(candidate))
encoded := hex.EncodeToString(digest[:])
if len(prefix) >= constants.OutboxEventIDMaxLength {
return encoded
}
return prefix + encoded[:constants.OutboxEventIDMaxLength-len(prefix)]
}
// Validate 校验公共 Outbox 事件及父事件标识的数据库长度契约。
func Validate(eventID, parentEventID string) error {
if utf8.RuneCountInString(eventID) > constants.OutboxEventIDMaxLength {
return errors.New("Outbox 事件ID超过64字符上限")
}
if utf8.RuneCountInString(parentEventID) > constants.OutboxEventIDMaxLength {
return errors.New("Outbox 父事件ID超过64字符上限")
}
return nil
}