归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s

This commit is contained in:
2026-09-07 11:34:23 +08:00
parent 3a093ecd6b
commit c7c2b17d78
12 changed files with 90 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,45 @@
## Context
`internal/query/retention` 以聚合查询计算审计与外部交互日志的在线留存边界。PostgreSQL 在没有匹配行时会为 `MAX``MIN` 返回 `NULL`,而当前扫描目标不能接收空值,导致所有依赖该边界的审计调查接口失败。
## Goals / Non-Goals
**Goals:**
- 将空聚合结果识别为“尚无边界”,而非数据库错误。
- 保持已有已清理边界、最早在线记录和当月兜底的语义。
- 修复所有通过同一留存边界查询函数进入的审计接口。
**Non-Goals:**
- 不执行审计物理清理,不改变归档或保留策略。
- 不修改数据库结构、迁移记录或历史审计数据。
- 不修改 API 路由、权限或响应字段。
## Decisions
### 使用可空时间承接 SQL 聚合结果
留存边界查询使用标准库可空时间值承接 `MAX(range_end)``MIN(<time-column>)`。仅在值有效时转换为目标时区并作为边界返回。
拒绝将聚合结果用当前时间或固定时间 SQL `COALESCE`:这会把“尚未清理”误判为已归档,改变响应中的留存语义。
### 同时覆盖已清理和最早在线两个聚合路径
`MAX(range_end)` 是本次线上错误入口;`MIN` 在在线表为空时具有相同的空值扫描风险。两个路径共用相同的可空聚合边界,应一次修复。
## Risks / Trade-offs
- [风险] 空边界被误判为已清理,错误拒绝历史查询 → 仅在聚合值有效时设置已清理标记与归档边界。
- [风险] 修复遗漏其他审计接口 → 保持修改在所有审计查询共用的留存边界函数内。
## Migration Plan
1. 修改留存边界的空时间扫描逻辑。
2. 格式化并构建 API。
3. 仅替换 API 二进制并重启 API Unit。
4.`GET /api/admin/audit/events?page=1&page_size=20` 验证无已清理记录时接口不再返回 500。
### Rollback
无数据库迁移。若 API 启动或查询异常,覆盖回本次发布前 API 二进制并重启 API Unit。

View File

@@ -0,0 +1,26 @@
## Why
生产环境尚未执行审计物理清理时,留存边界查询中的 `MAX(range_end)` 返回 SQL `NULL`,当前实现无法扫描该值,导致 `GET /api/admin/audit/events` 等审计查询返回服务端错误。
## What Changes
- 将审计及外部交互日志留存边界的可空聚合结果作为可空时间处理。
- 没有完成物理清理记录时,审计查询继续返回在线数据,且 `archived_before` 保持为空。
- 没有在线审计或集成交互数据时,保留现有的当月起始时间兜底边界。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `operations-audit`: 审计调查在尚无已完成物理清理记录时仍可查询在线审计事实。
## Impact
- 代码:`internal/query/retention/retention.go`
- API修复全局审计事件、时间线、资源活动、风险和资金审计等依赖留存边界的既有查询。
- 数据库:无迁移、无数据修复。
- 部署:仅需重新构建和发布 API 二进制。

View File

@@ -0,0 +1,49 @@
## 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 改变已通过业务校验的业务操作结果或接口响应。尚无完成物理清理记录时,审计调查接口 MUST 继续查询在线审计事实,且返回的留存边界不得将数据标记为已归档。
#### 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** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败
#### Scenario: 尚无物理清理记录时查询在线审计
- **GIVEN** 审计或外部交互日志尚无完成物理清理的归档运行记录
- **WHEN** 调用任一依赖留存边界的审计调查接口
- **THEN** 系统返回当前在线数据或空列表
- **AND** 响应中的 `archived_before` 为空
- **AND** 系统不得因空留存边界返回服务端错误

View File

@@ -0,0 +1,10 @@
## 1. 留存边界空值处理
- [x] 1.1 将已清理边界与最早在线记录的聚合结果改为可空时间扫描,仅在结果有效时设置对应边界。
- [x] 1.2 保持无已清理记录时 `archived_before` 为空,以及无在线记录时按当月起始时间兜底的现有语义。
## 2. 验证与发布
- [x] 2.1 格式化修改文件并构建 API。
- [ ] 2.2 验证未执行物理清理时 `GET /api/admin/audit/events?page=1&page_size=20` 不再返回服务端错误。
- [ ] 2.3 仅发布并重启 API 二进制;不执行数据库迁移、不改 Worker 配置。

View File

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

View File

@@ -0,0 +1,106 @@
## Context
线上只读数据得到旧孤儿扫描窗口 `100/100/0`:固定取出的 100 条待生效记录全部仍有 `status IN (1,2)` 占位主套餐,而真正无占位套餐的记录位于窗口之外。当前实现先 `LIMIT 100`,再逐条查询占位状态,因此同一批无效候选会永久挡住真实孤儿。
过期路径还在数据库事务内调用 `enqueueActivationTask`。Asynq 消费者可能早于事务提交读取旧主套餐,随后 `ActivateSpecificPackage` 判断“已有生效主套餐”并返回 `nil`Handler 继续记录“套餐激活成功”任务不再重试。Redis 锁冲突也返回 `nil`,存在另一条假成功路径。
本热修保持当前线上 `Polling Handler → GORM transaction → Asynq → Package Activation Service` 架构,不引入新的可靠投递设施。
## Goals / Non-Goals
**Goals:**
- 每轮 100 个恢复名额只用于真实孤儿载体,同一载体只选择队首套餐。
- 旧套餐过期事实提交后才投递 Asynq消除事务可见性竞态。
- Redis 锁冲突返回错误,由现有 Asynq 重试。
- 成功日志只对应实际激活或明确的已完成幂等事实。
**Non-Goals:**
- 不新增 Outbox、迁移、索引、依赖、队列或任务类型。
- 不重构套餐购买、实名激活、流量扣减、退款和停复机。
- 不修改 API、DTO、路由或前端。
- 不批量修复历史数据。
- 按用户要求,不新增、修改或运行自动化测试。
## Decisions
### 决策 1数据库先筛选真实孤儿队首再执行 LIMIT
使用 GORM `Raw` 执行 PostgreSQL CTE/窗口查询:
1. 从有效 `status=0` 主套餐按卡/设备载体分组。
2. 每组按 `priority ASC, created_at ASC, id ASC``ROW_NUMBER()=1`
3. 使用相关 `NOT EXISTS` 排除同载体 `status IN (1,2)` 主套餐。
4. 稳定排序后 `LIMIT 100`
删除现有逐条 `Count` 和 Go map 分组。查询仍返回完整 `PackageUsage`,沿用现有投递循环。
**拒绝:扩大 LIMIT。** 只会推迟复现并增加 N+1。
**拒绝:分页遍历所有 pending。** 需要游标状态,复杂度高于一次正确查询。
### 决策 2过期事务提交后再投递现有 Asynq
`processExpiredPackage` 事务只更新旧主套餐和关联加油包。提交成功后,使用普通数据库句柄调用现有 `activateNextPackage` 查询队首并入队。
提交后入队失败时返回错误并记录上下文;同一轮末尾及后续轮询的真实孤儿扫描会再次发现该载体,提供持久状态驱动的补偿。
**拒绝:任务增加固定延迟。** 固定延迟不能证明事务已提交。
**拒绝:引入 Outbox。** 当前线上没有该基础设施,热修不扩张架构。
### 决策 3锁冲突必须触发 Asynq 重试
`ActivateSpecificPackage` 未取得 `RedisPackageActivationLockKey` 时返回现有 `CodePackageActivationConflict`。Handler 原样返回错误,由任务已有 `MaxRetry(3)` 处理。
Redis Key 继续使用 `pkg/constants/redis.go` 的生成函数,不新增硬编码 Key。
### 决策 4显式返回本次是否激活
`ActivateSpecificPackage` 返回 `(bool, error)`
- `true,nil`:本次把待生效套餐推进为生效中;
- `false,nil`:记录已非待生效、存在占位套餐或条件暂不满足;
- `false,error`数据库、Redis 或锁冲突,应由任务重试或记录失败。
Handler 仅在 `true,nil` 时记录“套餐激活成功”。Handler 已在调用前识别 `status=1` 的重复任务并记录幂等跳过。
### 决策 5依赖注入和事务边界保持不变
`PackageActivationHandler` 继续通过结构体字段持有 `*gorm.DB``*redis.Client``*asynq.Client``*ActivationService` 和 Zap Logger不新增单实现接口或工厂。套餐激活仍由 Service 自己开启 GORM 事务Handler 不直接更新新套餐状态。
### 决策 6公共能力与验证
- Audit EventN/A系统自动生命周期推进。
- Domain LedgerN/A`tb_package_usage` 是权威事实。
- Integration LogN/A无新增外部调用。
- OutboxN/A保持当前 Asynq + 周期自愈。
按用户要求不写或运行自动化测试。验证使用 `gofmt``git diff --check``go build ./...`、只读 SQL、查询计划和日志检查。
## Risks / Trade-offs
- **[风险] CTE 扫描大量 pending** → 用 `EXPLAIN (ANALYZE, BUFFERS)` 验证;无证据不新增索引。
- **[风险] 提交后、入队前进程退出** → 下一轮真实孤儿扫描恢复,最长增加一个轮询周期。
- **[风险] 多实例重复入队** → 载体 Redis 锁和套餐状态幂等保证只实际激活一次。
- **[风险] 方法签名变化遗漏调用点** → 使用 `rg` 检查全部调用方并以全量构建证明编译契约。
- **[权衡] Asynq 最终失败后仍依赖轮询重新入队** → 这是当前线上架构的既有补偿边界,本热修不扩建基础设施。
- **[权衡] 不新增自动化测试** → 遵循用户边界以构建、SQL 和日志证据替代。
## Migration Plan
1. 实施真实孤儿查询、提交后入队、锁冲突重试和准确日志。
2. 执行格式化、静态检查、`go build ./...` 和只读 SQL语义检查。
3. 形成独立中文 Lore 热修提交。
4. 部署 Worker观察至少两个轮询周期内真实孤儿收敛和任务日志。
### 回滚
- 无数据库迁移revert 热修提交并重新部署 Worker。
- 已正确激活的套餐保持业务事实,不执行反向 SQL。
- 回滚后新增孤儿继续使用带状态保护的单卡 SQL逐条恢复。
## Open Questions
无。

View File

@@ -0,0 +1,36 @@
## Why
功能 ID`hotfix-main-package-activation-recovery`
线上已第二次出现原主套餐成功过期、队首待生效套餐仍长期停留在 `status=0` 的故障。只读诊断确认当前孤儿扫描固定取出的 100 条记录全部仍有占位套餐,真实孤儿永远无法进入恢复窗口;同时,过期事务提交前投递 Asynq 会让消费者读到旧套餐仍为生效中并把跳过误判为成功。
## What Changes
- PostgreSQL 在 `LIMIT 100` 前完成每个载体队首选择和 `status IN (1,2)` 占位排除,删除逐条检查的 N+1 查询。
- 旧主套餐过期和加油包失效事务提交成功后,才查询队首套餐并投递现有 `package:queue:activation` Asynq 任务。
- Redis 激活锁冲突返回现有套餐激活冲突错误,让 Asynq 按既有策略重试,不再确认假成功。
- Asynq Handler 仅在实际推进套餐或确认已完成幂等事实时记录成功;占位阻塞、等待实名和锁冲突记录明确原因。
- 不新增 Outbox、数据库迁移、依赖或新任务类型保持线上现有纯 Asynq 架构。
- 按用户明确要求,不新增、修改或运行自动化测试;使用全量构建、只读 SQL、查询计划和日志核验。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `package-queue-activation`:补充纯 Asynq 过期接续的提交后投递、真实孤儿公平扫描、锁冲突重试和准确成功语义。
## Impact
- **适用范围**:仅当前 `main` 线上代码。
- **架构通道**:旧套餐轮询复杂写用例,沿用 `Polling Handler → GORM transaction → Asynq → Package Activation Service`;不迁移未触碰的套餐模块。
- **代码**`internal/polling/package_activation_handler.go``internal/service/package/activation_service.go`
- **数据库**:无迁移;仅调整 `tb_package_usage` 查询顺序与过滤。
- **API/前端**:无改动。
- **依赖**:继续使用 GORM、PostgreSQL、Redis、Asynq 和 Zap不新增依赖。
- **性能**:孤儿扫描由最多 101 次查询收敛为一次候选查询和有限任务投递;使用查询计划确认数据库耗时。
- **审计与可靠性**Audit Event、Domain Ledger、Integration Log、Outbox 均为 N/A`tb_package_usage` 是权威状态,现有 Asynq + 周期孤儿扫描负责最终恢复。
- **验证**:不写自动化测试;执行 `gofmt`、静态检查、`go build ./...`、只读 SQL及日志核验。

View File

@@ -0,0 +1,77 @@
## MODIFIED Requirements
### Requirement: 当前主套餐过期后自动激活下一个
系统 SHALL 在生效中或已用完主套餐到期时,先提交旧主套餐及关联加油包的状态事务,再投递同一载体队首待生效套餐的现有 Asynq 激活任务;系统 MUST NOT 在旧套餐事务提交前投递任务。
#### Scenario: 事务提交后投递队首套餐
- **WHEN** 轮询处理一个到期的 `status=1``status=2` 主套餐,且存在队首待生效套餐
- **THEN** 系统先提交旧主套餐 `status=3` 和关联加油包 `status=4` 的事务
- **AND** 提交成功后查询稳定队首并投递 `package:queue:activation`
- **AND** 消费者读取时旧主套餐不再处于 `status IN (1,2)`
#### Scenario: 过期事务失败
- **WHEN** 更新旧主套餐或关联加油包失败
- **THEN** 事务回滚
- **AND** 系统不得投递下一套餐激活任务
- **AND** 下一轮过期扫描仍可重新处理
#### Scenario: 提交后入队失败
- **WHEN** 旧套餐事务已经提交,但 Asynq 入队失败或进程退出
- **THEN** 队首套餐保持 `status=0`
- **AND** 周期孤儿扫描重新发现并补投该套餐
#### Scenario: 无待生效套餐
- **WHEN** 旧套餐过期提交后不存在有效队首待生效套餐
- **THEN** 系统不投递激活任务
- **AND** 沿用既有无套餐停机检查
## ADDED Requirements
### Requirement: 孤儿待生效套餐必须公平恢复
系统 SHALL 在数据库中先选择每个卡或设备载体唯一的队首待生效主套餐,并排除仍有 `status IN (1,2)` 占位主套餐的载体,最后才执行单轮 100 个真实孤儿上限。队首顺序 MUST 为 `priority ASC, created_at ASC, id ASC`
#### Scenario: 固定窗口全部为非孤儿
- **WHEN** 排序靠前的 100 条待生效记录均有占位主套餐,窗口之后存在真实孤儿
- **THEN** 数据库先排除前 100 条非孤儿
- **AND** 窗口后的真实孤儿进入本轮候选并被投递
#### Scenario: 同一载体有多条 pending
- **WHEN** 一个真实孤儿载体存在多条待生效主套餐
- **THEN** 本轮只选择 priority 最小、created_at 最早、id 最小的一条
- **AND** 该载体只占一个恢复名额
#### Scenario: 生效中或已用完套餐占位
- **WHEN** pending 所属载体存在 `status=1``status=2` 主套餐
- **THEN** 该载体不得进入孤儿候选
### Requirement: Asynq 激活结果必须准确且可重试
系统 SHALL 仅在本次实际把套餐推进为 `status=1` 时记录新激活成功。Redis 激活锁冲突 MUST 返回错误,使 Asynq 按既有 `MaxRetry(3)` 重试,不得确认未执行任务成功。
#### Scenario: 锁冲突触发重试
- **WHEN** 消费者未取得载体级套餐激活锁
- **THEN** Service 返回套餐激活冲突错误
- **AND** Handler 将错误返回 Asynq
- **AND** 本次不记录激活成功
#### Scenario: 本次实际激活
- **WHEN** 套餐为待生效、载体无占位主套餐且满足激活条件
- **THEN** Service 返回 `activated=true`
- **AND** Handler 记录包含套餐使用记录和触发类型的成功日志
#### Scenario: 条件暂不满足
- **WHEN** Service 复检发现占位套餐或等待实名条件
- **THEN** Service 返回 `activated=false`且不修改套餐
- **AND** Handler 记录未激活原因,不记录成功

View File

@@ -0,0 +1,20 @@
## 1. Main 分支隔离与基线
- [x] 1.1 确认变更仅面向 `main` 的纯 Asynq 套餐接续链路,并保持 Outbox 与新任务基础设施为非目标。
- [x] 1.2 检索套餐激活方法的全部调用点及现有错误码、Redis 键和任务重试配置,锁定最小修改边界。
## 2. 过期接续与孤儿恢复
- [x] 2.1 将孤儿恢复改为数据库先按载体选择队首并排除 `status IN (1,2)` 占位套餐,最后限制 100 个真实孤儿,删除逐条占位查询。
- [x] 2.2 将旧主套餐过期后的下一套餐投递移到事务提交后,保留现有停机检查和周期孤儿补偿。
## 3. 激活结果与重试语义
- [x] 3.1 让指定套餐激活显式返回是否实际激活,并在 Redis 激活锁冲突时返回现有套餐激活冲突错误。
- [x] 3.2 调整 Asynq Handler 日志,仅在实际激活时记录成功,未激活时记录明确的跳过信息。
## 4. 文档、验证与提交
- [x] 4.1 更新功能总结和 README说明 `main` 纯 Asynq 热修边界、部署观察项及回滚方式。
- [x] 4.2 执行 `gofmt``git diff --check``go build ./...` 和 OpenSpec 严格校验;按用户要求不新增、修改或运行自动化测试。
- [x] 4.3 仅暂存本热修代码、文档和独立 OpenSpec创建符合 Lore 协议的中文提交。