归档
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-09-20 10:46:34 +08:00
parent 1964c12ffa
commit 863607639f
9 changed files with 362 additions and 1 deletions

View File

@@ -74,6 +74,82 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
### 单槽单卡当前卡标识核对与受控修复
本节仅供生产维护者在已备份、已留存核对清单且具备回滚条件的维护窗口执行。Agent 只可通过 dbhub 做只读核对,不得执行下述写操作。
先执行只读核对,候选必须同时满足:设备未删除、最大槽位为 1、只有一条未删除的有效绑定、且没有任何有效绑定被标记为当前使用卡。
```sql
SELECT b.id AS binding_id, b.device_id, b.iot_card_id
FROM tb_device_sim_binding AS b
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.bind_status = 1
AND d.max_sim_slots = 1
AND NOT EXISTS (
SELECT 1
FROM tb_device_sim_binding AS current_binding
WHERE current_binding.device_id = b.device_id
AND current_binding.deleted_at IS NULL
AND current_binding.bind_status = 1
AND current_binding.is_current
)
AND (
SELECT count(*)
FROM tb_device_sim_binding AS active_binding
WHERE active_binding.device_id = b.device_id
AND active_binding.deleted_at IS NULL
AND active_binding.bind_status = 1
) = 1
ORDER BY b.device_id, b.id;
```
维护者审核并留存候选清单后,在事务中把批准的 `binding_id` 显式填入 `approved`。更新语句会再次核对全部条件;受影响行数与批准清单不一致时必须回滚,不得扩大条件或直接按设备全量更新。
```sql
BEGIN;
WITH approved(binding_id) AS (
VALUES
(CAST(<binding_id> AS bigint))
), eligible AS (
SELECT b.id
FROM tb_device_sim_binding AS b
JOIN approved AS a ON a.binding_id = b.id
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.bind_status = 1
AND d.max_sim_slots = 1
AND NOT EXISTS (
SELECT 1
FROM tb_device_sim_binding AS current_binding
WHERE current_binding.device_id = b.device_id
AND current_binding.deleted_at IS NULL
AND current_binding.bind_status = 1
AND current_binding.is_current
)
AND (
SELECT count(*)
FROM tb_device_sim_binding AS active_binding
WHERE active_binding.device_id = b.device_id
AND active_binding.deleted_at IS NULL
AND active_binding.bind_status = 1
) = 1
FOR UPDATE OF b
)
UPDATE tb_device_sim_binding AS target
SET is_current = TRUE, updated_at = NOW()
FROM eligible
WHERE target.id = eligible.id
RETURNING target.id AS binding_id, target.device_id, target.iot_card_id;
-- 核对返回行数、设备卡列表与资产信息;不一致时执行 ROLLBACK。
-- 全部一致后才执行 COMMIT。
```
修复后必须确认每个设备至多一条未删除的有效绑定满足 `is_current = TRUE`,并抽样核对设备卡列表和个人客户资产信息均按最新绑定事实返回。回滚时仅对本次留存清单中的绑定恢复 `is_current = FALSE`,不得影响运营商观测或显式切卡产生的其他当前卡事实。
### 商户池支付路由发布与回滚
本节是维护者操作清单不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。

View File

@@ -679,6 +679,7 @@
"capability": "asset-device",
"requirements": [
"asset-device::设备多卡关系",
"asset-device::单槽单卡设备绑定时初始化当前卡标识",
"identity-access::数据范围拒绝"
],
"classification": "behavior"
@@ -1405,7 +1406,8 @@
"capability": "personal-customer",
"requirements": [
"personal-customer::资产归属查询",
"personal-customer::资产信息展示当前可用或已用完主套餐"
"personal-customer::资产信息展示当前可用或已用完主套餐",
"personal-customer::缺少当前使用卡时的设备实名判定"
],
"classification": "behavior"
},
@@ -2023,6 +2025,7 @@
"capability": "asset-device",
"requirements": [
"asset-device::设备多卡关系",
"asset-device::单槽单卡设备绑定时初始化当前卡标识",
"identity-access::数据范围拒绝"
],
"classification": "behavior"
@@ -2053,6 +2056,7 @@
"capability": "asset-device",
"requirements": [
"asset-device::设备多卡关系",
"asset-device::单槽单卡设备绑定时初始化当前卡标识",
"identity-access::数据范围拒绝"
],
"classification": "behavior"
@@ -4644,4 +4648,14 @@
],
"classification": "behavior"
}
,
{
"entry_type": "cli",
"entry": "docs/deployment/production-runbook.md",
"capability": "asset-device",
"requirements": [
"asset-device::存量当前卡标识的核对与受控修复"
],
"classification": "behavior"
}
]

View File

@@ -5653,4 +5653,93 @@
"exit_status": 0
}
}
,
{
"capability": "asset-device",
"requirement": "单槽单卡设备绑定时初始化当前卡标识",
"spec": "openspec/specs/asset-device/spec.md",
"entries": [
"/api/admin/devices/{virtual_no}/cards",
"/api/admin/devices/import"
],
"handler_consumer_job": [
"internal/handler/admin/device.go",
"internal/task/device_import.go"
],
"application_service_query": [
"internal/service/device/binding.go",
"internal/task/device_import.go"
],
"domain_state_amount": [
"internal/model/device_sim_binding.go"
],
"store_migration_config": [
"internal/store/postgres/device_sim_binding_store.go"
],
"verification": {
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/service/device/binding.go\", \"if device.MaxSimSlots == 1\"), (\"internal/service/device/binding.go\", \"if activeCount == 1\"), (\"internal/service/device/binding.go\", \"Update(\\\"is_current\\\", true)\"), (\"internal/task/device_import.go\", \"initializeCurrentCard := device.MaxSimSlots == 1 && len(validCards) == 1\"), (\"internal/task/device_import.go\", \"IsCurrent: initializeCurrentCard\")]; missing=[f\"{p} :: {t}\" for p,t in checks if t not in Path(p).read_text()]; assert not missing, missing; print(\"OK: asset-device::单槽单卡设备绑定时初始化当前卡标识\")'",
"literal_output": [
"OK: asset-device::单槽单卡设备绑定时初始化当前卡标识"
],
"exit_status": 0
}
},
{
"capability": "asset-device",
"requirement": "存量当前卡标识的核对与受控修复",
"spec": "openspec/specs/asset-device/spec.md",
"entries": [
"docs/deployment/production-runbook.md"
],
"handler_consumer_job": [
"docs/deployment/production-runbook.md:单槽单卡当前卡标识核对与受控修复"
],
"application_service_query": [
"docs/deployment/production-runbook.md:只读候选核对与事务内条件复核"
],
"domain_state_amount": [
"internal/model/device_sim_binding.go:DeviceSimBinding.IsCurrent"
],
"store_migration_config": [
"tb_device_sim_binding",
"tb_device"
],
"verification": {
"command": "python3 -c 'from pathlib import Path; text=Path(\"docs/deployment/production-runbook.md\").read_text(); checks=[\"### 单槽单卡当前卡标识核对与受控修复\", \"d.max_sim_slots = 1\", \"current_binding.is_current\", \"count(*)\", \"WITH approved(binding_id) AS\", \"FOR UPDATE OF b\", \"RETURNING target.id AS binding_id\", \"受影响行数与批准清单不一致时必须回滚\"]; missing=[t for t in checks if t not in text]; assert not missing, missing; print(\"OK: asset-device::存量当前卡标识的核对与受控修复\")'",
"literal_output": [
"OK: asset-device::存量当前卡标识的核对与受控修复"
],
"exit_status": 0
}
},
{
"capability": "personal-customer",
"requirement": "缺少当前使用卡时的设备实名判定",
"spec": "openspec/specs/personal-customer/spec.md",
"entries": [
"/api/c/v1/asset/info"
],
"handler_consumer_job": [
"internal/routes/personal.go",
"internal/handler/app/client_asset.go"
],
"application_service_query": [
"internal/service/asset/service.go"
],
"domain_state_amount": [
"internal/model/device_sim_binding.go:DeviceSimBinding.IsCurrent",
"pkg/constants:RealNameStatusVerified"
],
"store_migration_config": [
"internal/store/postgres/device_sim_binding_store.go",
"internal/store/postgres/iot_card_store.go"
],
"verification": {
"command": "python3 -c 'from pathlib import Path; text=Path(\"internal/service/asset/service.go\").read_text(); checks=[\"hasCurrentCard := false\", \"if isCurrentMap[c.ID]\", \"if !hasCurrentCard\", \"if c.RealNameStatus == constants.RealNameStatusVerified\", \"resp.RealNameStatus = constants.RealNameStatusVerified\"]; missing=[t for t in checks if t not in text]; assert not missing, missing; print(\"OK: personal-customer::缺少当前使用卡时的设备实名判定\")'",
"literal_output": [
"OK: personal-customer::缺少当前使用卡时的设备实名判定"
],
"exit_status": 0
}
}
]

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-20

View File

@@ -0,0 +1,52 @@
## Context
参见 `proposal.md` 的 Why。当前运营弹窗配置读取链路由 Query 直接读取配置模型并映射为统一响应 DTO配置表只保存 `creator``updater` 账号 ID账号名称位于 `tb_account.username`。列表与详情共用响应 DTO但当前映射函数没有账号名称输入。
项目要求读取使用 Query/GORM关联通过 ID 显式查询,不新增 GORM 关联或数据库外键。账号采用软删除,历史配置需要能解析已删除账号。
## Goals / Non-Goals
**Goals:**
- 列表和详情使用同一账号名称语义与响应字段。
- 列表名称补充保持常数次查询,不产生 N+1。
- 名称查询错误沿既有稳定错误链返回。
**Non-Goals:**
- 不保存创建人或更新人的名称快照。
- 不修改配置写入、版本、启停、排序或权限语义。
- 不修改账号删除策略或数据库 Schema。
## Decisions
### 1. 在响应 DTO 增加 `creator_name` 与 `updater_name`
保留 `creator``updater`,新增同语义名称字段。这样现有调用方保持兼容,后台可直接展示名称。未采用替换 ID 或嵌套账号对象,因为都会扩大契约变更并增加前端迁移成本。
### 2. Query 批量读取当前账号名称
列表先读取当页配置,再收集非零且去重后的创建人、更新人 ID通过一次 `Unscoped` GORM 查询读取 `id, username`,生成名称映射后投影响应。详情复用同一批量加载函数,以单元素 ID 集合获得相同语义。
未采用 SQL JOIN当前 Query 已先执行总数与分页读取,独立批量查询更容易保持 Count、排序和配置模型扫描不变也符合项目显式关联约束。未采用逐条账号查询避免 N+1。
### 3. 已删除账号可见,缺失账号返回空名称
账号查询使用 `Unscoped`,因此软删除账号仍能提供历史用户名。映射中没有对应记录时保留字符串零值,表示账号事实已不存在。该行为不制造占位文案,避免把展示策略固化到 API。
### 4. 查询失败不得静默降级
账号名称查询失败包装为稳定数据库错误并终止列表或详情响应。静默返回空名称会把数据库故障与“账号记录不存在”混为同一可观察结果,违反契约。
## Risks / Trade-offs
- [账号用户名后续可修改,历史配置展示的是当前用户名而非操作时快照] → 本次明确只补充当前账号名称;若业务要求历史身份不可变,应另建名称快照变更。
- [列表增加一次账号查询] → 对当页 ID 去重并只选择 `id, username`,查询数量固定为一次。
- [历史脏数据引用不存在账号] → 保留账号 ID名称返回空字符串使调用方仍能识别原始关联值。
## Migration Plan
1. 更新响应 DTO 与 Query 投影。
2. 重新生成 OpenAPI 文档并构建 API、Worker。
3. 在维护者指定的隔离环境请求列表和详情,核对正常账号、软删除账号及不存在账号三种结果。
4. 回滚只需恢复应用代码与生成文档;无数据库迁移或数据回滚。

View File

@@ -0,0 +1,27 @@
## Why
H5 运营弹窗配置列表和详情当前只返回创建人、最近更新人的账号 ID后台页面无法直接识别实际操作账号。接口需要在保留 ID 的同时返回对应账号名称,并保证历史配置关联的已删除账号仍可识别。
## What Changes
- 在 H5 运营弹窗配置列表与详情响应中新增创建人账号名称和最近更新人账号名称。
- 保留现有 `creator``updater` 字段,不引入破坏性变更。
- 历史账号已软删除时仍返回其用户名;账号记录确实不存在时返回空名称。
- 列表按当页账号 ID 批量补充名称,避免逐条查询。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `h5-popup-notification`: 运营弹窗配置后台列表与详情响应补充创建人和最近更新人的账号名称。
## Impact
- API`GET /api/admin/h5-popup-configurations``GET /api/admin/h5-popup-configurations/{id}` 的响应 DTO 增加字段。
- 代码:`internal/model/dto/h5_popup_dto.go``internal/query/h5popup/query.go`
- 文档:生成的 OpenAPI 文档与 `h5-popup-notification` 行为契约。
- 数据库:无 Schema 变化;只读取现有 `tb_account.username`

View File

@@ -0,0 +1,65 @@
## MODIFIED Requirements
### Requirement: 运营弹窗实时匹配
系统 SHALL 允许超级管理员和平台用户管理全局运营弹窗的标题、内容、**弹窗类型**、有效期、启停、优先级、店铺/设备类型/卡类型范围、四种页面(首页、资产详情、套餐购买、资产钱包充值)、频率和一个可选受控操作。弹窗类型 MUST 为必填单选,取值范围为 `套餐政策推广``通用公告`;创建 MUST 提供该字段,更新按既有部分更新语义(未传时保持原值),一经传入 MUST 按同一取值域校验。类型变更而请求未显式传优先级时,该配置的显式优先级 MUST 保持不变(类型缺省优先级只在创建路径生效,类别序仍保证 `套餐政策推广` 优先于 `通用公告`。范围同一维度多选为任一匹配未配置范围即全量已配置范围而当前资产在该维度没有可判定值时该配置不命中。H5 请求必须携带当前页面资产标识。操作仅可为套餐购买或资产钱包充值,不得配置任意 URL。
候选排序 SHALL 先按有效优先级类别再按显式优先级:风险换卡提醒高于全部运营弹窗;运营弹窗内 `套餐政策推广` MUST 高于 `通用公告`;同一类别内按显式优先级降序、相同优先级取最近更新时间最新。类别顺序 MUST 表达为显式的类别排序MUST NOT 依赖类型取值的字典序(否则「通用公告」会排在「套餐政策推广」之前,与本条相反)。显式优先级缺省时 MUST 取该类型的默认值。既有配置在本次上线时 MUST 被赋予一个确定类型(默认 `通用公告`),其显式优先级取值与同类别内相对顺序 MUST 保持不变。
运营弹窗仅在客户请求候选时实时匹配并创建或复用通知;每客户每配置支持仅一次或每天一次。候选只返回优先级最高一条,同优先级取最近更新时间最新,启停操作同样更新最近更新时间。配置修改形成新版本,既有通知保留原快照且不被改写;修改后的仅一次配置可向原命中客户重新投放。配置到期或停用停止新投放,历史通知在通知中心展示 90 天。
后台运营弹窗配置列表与详情响应 SHALL 在保留创建人、最近更新人账号 ID 的同时,返回对应账号名称。账号已软删除时 MUST 继续返回该账号的用户名;账号记录确实不存在时名称 MUST 为空字符串。名称补充失败时接口 MUST 返回稳定错误,不得以空名称伪装查询成功。
#### Scenario: 风险与运营候选同时命中
- **WHEN** 当前资产同时满足风险换卡和多个运营弹窗条件
- **THEN** 系统仅返回风险换卡候选,并保持通知未读
#### Scenario: 优先级与最近更新排序
- **WHEN** 多条同类型运营配置同时命中且优先级相同
- **THEN** 系统只返回最近更新时间最新的一条
#### Scenario: 推广优先于公告
- **GIVEN** 一条 `套餐政策推广` 配置与一条 `通用公告` 配置同时命中,且公告配置的显式优先级更高
- **WHEN** 客户请求候选
- **THEN** 系统返回 `套餐政策推广` 配置,类型类别优先于显式优先级
#### Scenario: 既有配置类型补齐
- **GIVEN** 一条本次上线前创建的运营配置
- **WHEN** 上线迁移完成
- **THEN** 该配置具有确定类型(默认 `通用公告`)且其显式优先级与同类别内顺序不变
#### Scenario: 独立卡的设备类型维度
- **WHEN** 命中的运营配置配置了设备类型范围,但当前资产是未绑定设备的独立卡
- **THEN** 系统不命中该配置
#### Scenario: 配置修改后重新投放
- **WHEN** 已按仅一次频率向客户投放过的配置被修改
- **THEN** 配置版本递增,既有通知的内容与快照保持不变,该客户可再次命中一次
#### Scenario: 到期或停用
- **WHEN** 配置已到期或被停用
- **THEN** 系统停止新投放,既有通知在展示期内仍可见
#### Scenario: 配置列表返回账号名称
- **WHEN** 平台管理账号查询运营弹窗配置列表
- **THEN** 每条配置同时返回创建人账号 ID 与名称、最近更新人账号 ID 与名称
#### Scenario: 配置详情返回已删除账号名称
- **GIVEN** 配置的创建人或最近更新人账号已被软删除
- **WHEN** 平台管理账号查询该运营弹窗配置详情
- **THEN** 响应保留账号 ID并返回该已删除账号的用户名
#### Scenario: 关联账号记录不存在
- **GIVEN** 配置关联的创建人或最近更新人账号记录确实不存在
- **WHEN** 平台管理账号查询运营弹窗配置列表或详情
- **THEN** 响应保留账号 ID对应账号名称为空字符串

View File

@@ -0,0 +1,17 @@
## 1. 响应契约
- [x] 1.1 在运营弹窗配置响应 DTO 增加创建人和最近更新人账号名称字段及中文 OpenAPI 描述
## 2. 查询投影
- [x] 2.1 在 H5 弹窗 Query 中实现账号 ID 去重与 `Unscoped` 批量名称查询,查询失败返回稳定数据库错误
- [x] 2.2 让列表与详情投影同时返回账号 ID 和名称,并保持缺失账号名称为空字符串
## 3. 验证与收尾
- [x] 3.1 执行 gofmt生成 OpenAPI并构建 API 与 Worker
- [x] 3.2 按维护者指定的隔离环境 smoke 列表与详情,核对正常、软删除和不存在账号的响应语义;若当前未提供可用环境,记录该项阻塞证据
- 阻塞记录,未执行 smoke2026-09-20当前变更仅存在于 `Iteration/8-11` 本地未提交工作区;测试部署工作流 `.gitea/workflows/deploy.yaml` 仅由该分支的 `push` 触发,本任务明确禁止提交、推送或触发外部部署,因此无法让本次代码进入指定测试部署。
- 当前会话未提供测试部署 API 基址,也未设置 `JUNHONG_ADMIN_BASE_URL``TEST_BASE_URL``CMP_TEST_BASE_URL`;未提供后台认证 Token也未设置 `JUNHONG_ADMIN_TOKEN``ADMIN_TOKEN`;未设置 `GITEA_TOKEN``GITEA_ACCESS_TOKEN`。SSH 配置存在 `cmp-test` 主机别名及身份文件路径,但当前 agent 中没有已加载身份,且 SSH 按 ENG-TEST-001 仅可用于日志、容器状态与受控 Smoke不能替代部署入口。未输出任何凭据值。
- [x] 3.3 运行 `openspec validate --all` 与上下文健康检查,确认变更工件和项目导航有效
- 验证2026-09-20补齐仓库既有三条 Requirement 的证据行与入口矩阵关联后,`openspec validate --all` 为 36 passed/0 failed`./scripts/context-health.sh` 输出「Context 健康检查通过」。

View File

@@ -45,6 +45,8 @@
运营弹窗仅在客户请求候选时实时匹配并创建或复用通知;每客户每配置支持仅一次或每天一次。候选只返回优先级最高一条,同优先级取最近更新时间最新,启停操作同样更新最近更新时间。配置修改形成新版本,既有通知保留原快照且不被改写;修改后的仅一次配置可向原命中客户重新投放。配置到期或停用停止新投放,历史通知在通知中心展示 90 天。
后台运营弹窗配置列表与详情响应 SHALL 在保留创建人、最近更新人账号 ID 的同时,返回对应账号名称。账号已软删除时 MUST 继续返回该账号的用户名;账号记录确实不存在时名称 MUST 为空字符串。名称补充失败时接口 MUST 返回稳定错误,不得以空名称伪装查询成功。
#### Scenario: 风险与运营候选同时命中
- **WHEN** 当前资产同时满足风险换卡和多个运营弹窗条件
@@ -82,6 +84,23 @@
- **WHEN** 配置已到期或被停用
- **THEN** 系统停止新投放,既有通知在展示期内仍可见
#### Scenario: 配置列表返回账号名称
- **WHEN** 平台管理账号查询运营弹窗配置列表
- **THEN** 每条配置同时返回创建人账号 ID 与名称、最近更新人账号 ID 与名称
#### Scenario: 配置详情返回已删除账号名称
- **GIVEN** 配置的创建人或最近更新人账号已被软删除
- **WHEN** 平台管理账号查询该运营弹窗配置详情
- **THEN** 响应保留账号 ID并返回该已删除账号的用户名
#### Scenario: 关联账号记录不存在
- **GIVEN** 配置关联的创建人或最近更新人账号记录确实不存在
- **WHEN** 平台管理账号查询运营弹窗配置列表或详情
- **THEN** 响应保留账号 ID对应账号名称为空字符串
### Requirement: 通知留存与已读
弹窗投放 SHALL 复用个人客户站内通知,保存投放时内容、受控操作与旧资产关联;运营弹窗通知额外保存配置标识与配置版本快照。投放后通知同时出现在通知列表与未读数中,并自投放时间起展示 90 天。