# PRD：UR#33 套餐临期查询、Dashboard 与站内提醒

Status: ready-for-agent

---

## Problem Statement

系统尚无统一临期资产列表、Dashboard 汇总和站内提醒。各页面如果直接使用当前套餐到期时间，会忽略排队主套餐并产生不同的临期数量。现有代码也没有七月迭代要求的通用站内通知基础设施，旧轮询告警模型不能承担面向业务用户的消息中心职责。

平台和不同层级代理看到的数据范围不同，临期列表、Dashboard 数量和通知接收人都必须严格复用现有店铺层级权限，不能建立绕过权限的全局统计。

## Solution

以 UR#46 的预计最终到期 Query 为唯一事实来源，按 `Asia/Shanghai` 自然日将剩余 0～15 天定义为临期。提供受权限约束的临期列表，并把临期卡数、设备数作为通用 Dashboard 的首个业务卡片。

每日任务只负责在 15/7/3 天节点产生站内通知和防重记录；页面、Dashboard 和导出始终实时查询，不读取每日任务快照。

## User Stories

1. 作为平台人员，我希望查看当前权限范围内全部临期卡和设备。
2. 作为不同层级代理，我希望 Dashboard 数量和列表只包含自己有权查看的店铺层级数据。
3. 作为运营人员，我希望 0～3 天资产在临期页优先展示，而普通资产列表只高亮不改排序。
4. 作为店铺主账号或业务员，我希望在 15、7、3 天节点收到一次站内提醒。
5. 作为 C 端客户，我希望资产临期时看到续费入口。
6. 作为维护人员，我希望任务漏跑后只补一个最近节点，重试不会重复发消息。
7. 作为运营人员，我希望临期列表能沿用统一导出任务下载结果。

## Implementation Decisions

### 临期规则

- 唯一到期来源是 UR#46 的 `estimated_final_expires_at` 和 `days_until_final_expiry`。
- 仅 `expiry_estimate_status=exact` 且剩余上海自然日为 0～15 的资产属于临期。
- 已过期（负数）、`waiting_activation`、`none` 和 `invalid_data` 均不进入临期列表、Dashboard 数量和提醒扫描。
- 展示等级固定：8～15 天 `pink=粉红色`，4～7 天 `purple=紫色`，0～3 天 `red=红色`；后端返回 `expiry_level` 和 `expiry_level_name`。
- 临期独立列表先按“0～3 天优先”排序，再按预计最终到期时间升序、资产类型和资产 ID 稳定排序。
- 普通卡/设备列表只使用 UR#46 字段高亮，保持原排序。

### 临期列表 API

- 新增 `GET /api/admin/expiring-assets`。
- 参数：`asset_type`（可选 `iot_card|device`）、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`expires_from`、`expires_to`、`page`、`size`。
- `keyword` 按现有资产标识解析能力匹配卡 ICCID/MSISDN/虚拟号或设备稳定标识；其他筛选与 keyword 按 AND 组合。
- `days_min/days_max` 必须在 0～15 且最小值不大于最大值；日期按上海自然日解析；分页默认 20、最大 100。
- 每项返回 `asset_type`、`asset_id`、`identifier`、`shop_id`、`shop_name`、`package_usage_id`、`package_name`、`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_level`、`expiry_level_name`、`can_renew`。
- `total` 和 items 使用完全相同的最终到期、筛选和权限条件；不得先取一页资产再在内存过滤临期。
- `can_renew` 只表示当前登录主体是否有合法续费入口，具体可售性继续由 UR#40 的统一套餐可售策略在下单时复核。

### 通用 Dashboard

- 新增通用 `GET /api/admin/dashboard/overview`，本需求交付首个 `expiring_assets` 卡片，不建立无业务内容的抽象插件框架。
- 响应首期结构：`expiring_assets.card_count`、`device_count`、`total_count`、`window_days=15`，并返回服务端计算时间。
- 平台、代理及不同层级代理调用同一接口。统计 Query 必须使用当前账号既有数据权限：平台按平台范围，各级代理按自己被授权的店铺及下级范围，因此不同主体看到不同数量。
- Dashboard 数量与不带额外条件的临期列表使用相同 Query 和权限范围；三个数量必须可由列表结果复核。
- 点击卡数、设备数或合计数进入临期页并携带对应 `asset_type`，不新增第二套详情接口。
- 后续需求可以在 `overview` 响应增加其他业务卡片，但本期不预测未来字段。
- Dashboard 不做跨用户共享缓存；如需缓存，key 必须包含稳定权限范围版本并确保权限变化立即失效。首期优先实时聚合。

### 权限与接收人

- 临期列表、Dashboard、资产列表、详情和导出分别复用其执行时或任务创建时的权限快照，不通过字段筛选扩大行范围。
- `shop_id` 只会缩小现有数据范围；越权店铺按“无权限或资源不存在”处理。
- 每个临期资产的站内接收人为所属店铺主账号及仍有效绑定的平台业务员；去重后逐账号生成通知。
- 不因上级代理可以在列表看到下级数据，就自动向所有上级代理逐级发送通知；接收人以店铺主账号和业务员规则为准。
- 没有可用接收人时记录可观测告警，不创建无接收人的消息。

### 站内通知与防重

- 本需求依赖七月公共站内通知能力：业务事务/任务通过 Outbox 发布通知事件，Notification Worker 按 `event_id + recipient_kind + recipient_id` 幂等写 `tb_notification`。
- 新增 `tb_expiry_push_record`，唯一键为 `package_usage_id + recipient_id + channel + push_node`；`channel` 本期固定为站内通知。
- `package_usage_id` 使用最终到期队列中最后一条主套餐使用记录，使新增续费套餐后能够形成新的 15/7/3 提醒周期。
- 每日任务按上海时区运行，扫描当前仍在 0～15 天内的资产。
- 节点为 15、7、3。命中或漏跑时，每次只选择“当前最近且尚未发送”的一个节点：剩 14 天补 15，剩 6 天补 7，剩 2 天补 3；不得一次补发多个旧节点。
- 防重记录与 Outbox 在同一数据库事务内写入；任务、Relay 和 Worker 至少一次重试不得重复通知。
- 通知使用受控 `ref_type/ref_id` 指向资产或临期列表，不保存任意 URL；前端点击后先标记已读，再通过受控路由跳转。
- 本期不发送企业微信、短信或邮件临期消息。

### 其他接口与前端

- 卡/设备列表、后台详情和 `GET /api/c/v1/asset/info` 使用 UR#46 的统一字段；C 端 `is_expiring=true && can_renew=true` 时展示续费入口。
- 代理首页调用通用 Dashboard，而不是临期专用 summary API。
- 通知中心使用公共通知 API，展示临期分类、15/7/3 节点文案和受控跳转。
- 前端颜色只根据 `expiry_level`，不重新计算天数阈值。
- 页面加载、空态、权限错误和重试遵循七月公共交互规范。

### 导出

- `POST /api/admin/export-tasks` 新增/注册 `scene=expiring_asset`，查询 filters 与临期列表参数保持同语义。
- 使用现有 DataSource 框架；场景只负责 Count、Headers、Fetch，不另建导出队列、文件或下载链路。
- Count 与 Fetch 必须应用同一临期条件和任务创建时保存的 `ScopeShopIDs` 权限快照。
- Fetch 使用稳定 offset/limit 排序，行字段与 Headers 一致；Worker 不读取实时登录上下文扩大权限。
- 同步更新 scene 常量、创建 DTO 校验、Registry 和支持场景判断。

### 架构、索引与发布

- 临期列表和 Dashboard 是 Query 层投影，直接使用 GORM/DTO，不经过聚合根；通知扫描复用同一查询核心。
- 不创建临期状态快照表。`tb_expiry_push_record` 只保存通知防重事实。
- 为最终到期批量计算、临期范围和接收人解析建立必要索引，使用代表性数据验证；禁止按资产或接收人 N+1。
- 实施顺序：UR#55 快照 → UR#46 Query → 公共站内通知 → 本需求列表/Dashboard/任务/导出/前端。

## Testing Decisions

- Query 测试覆盖 0、1、3、4、7、8、15、16 和负数天边界，以及所有不可预计状态。
- 验证临期页 0～3 天优先和稳定分页，普通资产列表排序不变。
- HTTP 集成测试使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App，覆盖全部筛选、AND、非法范围、分页和统一错误。
- 创建平台、不同层级代理及不同店铺范围数据，验证临期列表和 Dashboard 数量分别受权限约束，且 Dashboard 可由列表复核。
- 验证越权 `shop_id`、任务创建时权限快照和后续权限变化不会导致导出扩大数据范围。
- 通知测试覆盖精确命中、14/6/2 天漏跑补偿、重复任务、Outbox 重试、多接收人、无接收人、新续费使用记录和已过期跳过。
- 验证同一使用记录/接收人/节点只生成一条通知，不同接收人均能收到。
- DataSource 验证 Count/Fetch 行数及筛选一致、单一表头、CSV/XLSX、分片稳定排序和字段权限。
- 对 100 项列表、Dashboard 和每日扫描验证固定批量查询数量并执行 `EXPLAIN ANALYZE`。
- 前端验收临期颜色、置顶、Dashboard 跳转、C 端续费入口、通知已读与受控跳转。

## Out of Scope

- 不发送企业微信、短信或邮件临期提醒。
- 不提供可编辑临期阈值或颜色配置。
- 不维护临期状态或 Dashboard 数量快照。
- 不在本需求实现公共站内通知中心基础设施本身。
- 不提前设计 Dashboard 未来卡片或插件系统。
- 不改变套餐续费可售规则；由 UR#40 负责。

## Further Notes

- 当前仓库没有业务 Dashboard 路由；`GET /api/admin/dashboard/overview` 是通用 Dashboard 的首个正式契约。
- 旧轮询告警的通知字段不是业务通知中心，不得复用为 `tb_notification` 的替代品。
- 导出部分遵循项目 DataSource 体系：任务框架负责分片、文件、对象存储和下载，临期场景只负责数据语义。

