Files
eryao/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/prd.md
T
qzl c79c773d67 feat(notification): add target_mode enum constraint and merge register-notifications script
- Add NotificationTargetMode enum (new_users/exist_users/all_users/user_ids)
- Create Alembic migrations: drop duplicate indexes, add target_mode column
- Merge register-notifications.sh into dev-migrate.sh sync-notifications subcommand
- Shorten notification config path: static/notification/notifications -> static/notifications
- Update registration flow to dispatch notifications by target_mode
- Add is_first_registration to RegisterBonusResult for first-time user detection
- Remove dead code: link_published_notifications_to_user
- Update welcome_points.yaml to target new_users only
- Add 44 unit tests + 1 integration test, all passing
2026-04-16 17:50:57 +08:00

277 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRD: 修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引
> Task: `04-16-fix-notification-targets-cleanup`
> Branch: `worktree/fix-notification-targets-cleanup`
> Status: planning
---
## 背景与动机
当前代码库存在四类独立但可并行修复的问题:
1. **数据库重复索引**`llm_factory.name``llms.model_code` 各有两个功能完全相同的 unique index,浪费存储并拖慢写入。
2. **脚本碎片化**`register-notifications.sh` 作为独立脚本存在,但其功能本质上是 `dev-migrate.sh` 的一个子命令(与 migrate / init-data 并列),且未被 bootstrap 覆盖。
3. **路径冗余**`backend/src/core/config/static/notification/notifications` 存在两层嵌套的 `notification`,可缩短为 `backend/src/core/config/static/notifications`
4. **targets 语义缺失** — 通知 YAML 中的 `targets.mode` 仅支持 `all_users``user_ids`,缺少对"新用户"和"已存在用户"的精确语义区分,且当前 `link_published_notifications_to_user` 在用户注册时无差别地关联所有 published 通知,无法满足"只推给新注册用户"的需求。
---
## 任务 1:清理数据库重复索引
### 现状分析
数据库中确认存在以下重复索引(通过 `pg_indexes` 查询确认):
| 表 | 冲突索引 | 列 | 类型 |
|---|---|---|---|
| `llm_factory` | `ix_llm_factory_name` vs `llm_factory_name_key` | `name` | UNIQUE BTREE |
| `llms` | `ix_llms_model_code` vs `llms_model_code_key` | `model_code` | UNIQUE BTREE |
根因:Alembic migration `20260411_0001` 同时定义了 `UniqueConstraint("name")`(自动生成 `*_key`)和 `op.create_index("ix_*", ..., unique=True)`,导致两个相同功能的索引。
### 实现方案
1. 创建新 Alembic migrationdrop 冗余索引:
- `llm_factory`: 保留 `llm_factory_name_key`UniqueConstraint 自动生成的),drop `ix_llm_factory_name`
- `llms`: 保留 `llms_model_code_key`drop `ix_llms_model_code`
2. 在 migration 的 `downgrade()` 中恢复被 drop 的索引。
3. 更新 `20260411_0001` migration 的 `downgrade()` 中对应的 `drop_index` 调用,改为 drop 保留的那个索引名(因为 `ix_*` 将不再存在)。
4. ORM model 中 `unique=True` 已在 `mapped_column` 上声明,不需要改动。
### 涉及文件
- `backend/alembic/versions/` — 新增 migration
- `backend/alembic/versions/20260411_0001_initial_llm_schema.py` — 修正 downgrade
---
## 任务 2:将 register-notifications.sh 合并到 dev-migrate.sh
### 现状分析
- `infra/scripts/register-notifications.sh`:加载 env 后调用 `python -m core.runtime.cli sync-notifications "$@"`
- `infra/scripts/dev-migrate.sh`:支持 `migrate``init-data``bootstrap` 三个子命令
- `backend/src/core/runtime/cli.py``main()` 已支持 `sync-notifications` 命令
- `bootstrap()` 函数(cli.py:86)仅执行 migrate + init-data,不含 sync-notifications
### 实现方案
1. **修改 `dev-migrate.sh`**:添加 `sync-notifications` 子命令,透传所有参数给 CLI
2. **修改 `dev-migrate.sh` 的 `bootstrap` 子命令**:使其在 migrate + init-data 之后也执行 sync-notifications
3. **修改 `cli.py` 的 `bootstrap()` 函数**:在 `run_init_data()` 之后调用 `run_sync_notifications()`
4. **删除 `infra/scripts/register-notifications.sh`**
5. 更新 usage 文本
### 涉及文件
- `infra/scripts/dev-migrate.sh` — 添加子命令,修改 bootstrap
- `backend/src/core/runtime/cli.py` — 修改 `bootstrap()``main()`
- `infra/scripts/register-notifications.sh` — 删除
---
## 任务 3:缩短 notification 配置路径
### 现状分析
当前路径:`backend/src/core/config/static/notification/notifications/welcome_points.yaml`
目录结构:
```
backend/src/core/config/static/notification/
├── __init__.py
├── static_schema.py
├── static_sync.py
└── notifications/
├── README.md
└── welcome_points.yaml
```
目标路径:`backend/src/core/config/static/notifications/welcome_points.yaml`
同时 `backend/src/core/config/notification/` 包(Python package)需要重新审视命名:
- 该 package 本身只有 `__init__.py``static_schema.py``static_sync.py` 三个文件
- package 名 `notification` 和 YAML 目录 `notifications` 容易混淆
### 实现方案
1. **重命名目录**:将 `backend/src/core/config/static/notification/notifications/` 移动到 `backend/src/core/config/static/notifications/`
2. **更新 `utils/paths.py`**`get_notification_config_dir()` 返回 `get_static_config_dir() / "notifications"`
3. **保留 `backend/src/core/config/notification/` package 不变**(这是 Python 代码 package,不是配置目录)
4. 删除空出来的 `backend/src/core/config/static/notification/` 目录(仅含 `notifications/` 子目录)
5. 更新 README.md 的路径引用(如果有)
### 涉及文件
- `backend/src/core/config/static/notification/notifications/``backend/src/core/config/static/notifications/` (git mv)
- `backend/src/utils/paths.py` — 修改 `get_notification_config_dir()`
- `backend/src/core/config/static/notification/` — 删除空目录
---
## 任务 4:通知 targets 语义约束与按用户类型分发
### 现状分析
**当前 `StaticNotificationTargets` schema**`static_schema.py:32`):
```python
mode: Literal["all_users", "user_ids"]
user_ids: list[UUID] | None = None
```
**当前 YAML**`welcome_points.yaml`):
```yaml
targets:
mode: all_users
```
**当前注册流程**`auth/router.py:80-83`):
```python
notification_service = NotificationService(NotificationRepository(session))
linked_count = await notification_service.link_published_notifications_to_user(
user_id=UUID(result.user.id)
)
```
`link_published_notifications_to_user` 无差别地将所有 `published` 状态的通知关联到新注册用户,不区分 target mode。
**`register_bonus_claims` 表**:通过 `email_hash` 记录首次注册奖励 claim。如果该邮箱已 claim 过,说明用户是"重新注册"的。
### 需求定义
targets mode 扩展为三种语义:
| mode | 含义 | 新用户注册时 | 已有用户(sync 时) |
|------|------|-------------|-------------------|
| `new_users` | 仅推送给首次注册用户 | 注入 | 不推送 |
| `exist_users` | 仅推送给已存在的用户 | 不注入 | 推送 |
| `all_users` | 所有用户 | 注入 | 推送 |
**关键判断逻辑**
- "新用户"vs"重新注册用户"通过查询 `register_bonus_claims` 表判断
- 如果该用户 email 在 `register_bonus_claims` 中已有记录且 `first_user_id_snapshot != 当前 user_id`,则为重新注册用户
- `new_users`:仅首次注册(register_bonus_claims 中无记录)的用户
- `exist_users`:已存在于数据库的用户(非注册时注入,而是通过 sync-notifications 或其他方式推送)
### 实现方案
#### 4.1 扩展 `StaticNotificationTargets` schema
```python
class StaticNotificationTargets(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
mode: Literal["new_users", "exist_users", "all_users", "user_ids"]
user_ids: list[UUID] | None = None
@model_validator(mode="after")
def validate_target_mode(self) -> StaticNotificationTargets:
if self.mode in ("new_users", "exist_users", "all_users") and self.user_ids is not None:
raise ValueError("targets.user_ids must be absent when mode is not user_ids")
if self.mode == "user_ids":
if self.user_ids is None or len(self.user_ids) == 0:
raise ValueError("targets.user_ids must be a non-empty list when mode=user_ids")
return self
```
#### 4.2 修改 `welcome_points.yaml`
```yaml
targets:
mode: new_users
```
#### 4.3 修改注册时通知分发逻辑
`auth/router.py``create_email_session` 中,替换当前的 `link_published_notifications_to_user` 调用:
- 查询所有 `published` 状态且未删除的通知
- 对每条通知,根据其 `targets.mode` 决定是否关联:
- `all_users`:始终关联
- `new_users`:仅在用户是首次注册时关联(通过 register_bonus_claims 判断)
- `exist_users`:注册时不关联(此 mode 仅供 sync-notifications 使用)
- `user_ids`:如果 user_id 在列表中则关联
**判断是否首次注册**:复用 PointsService 的逻辑,通过 `register_bonus_claims` 表的 `email_hash` 查询是否已有 claim 记录。但需要注意时序——注册 bonus claim 和通知关联在同一个事务中。需要:
-`grant_register_bonus_if_eligible` 之后判断该 claim 的结果
- 如果 `granted=True`,说明是首次注册 → 新用户
- 如果 `granted=False` 且 claim 已存在 → 重新注册用户
具体方案:让 `grant_register_bonus_if_eligible` 返回的结果已经包含判断信息(当前返回 `RegisterBonusResult`),可以用 `granted` 字段推断。但更精确的做法是在 `RegisterBonusResult` 中添加 `is_first_registration: bool` 字段。
或者更简洁:在 `NotificationRepository` 中新增方法 `link_notifications_for_new_user`,接收 `is_first_registration: bool` 参数,按 target mode 过滤通知后关联。
#### 4.4 修改 `static_sync.py` 的 `_resolve_target_user_ids`
当前 `all_users` mode 返回所有 `AuthUser.id`。需要根据新语义调整:
- `all_users`:返回所有 auth users
- `new_users`:返回在 `register_bonus_claims` 中**没有** claim 记录的用户的 `first_user_id_snapshot`(首次注册的用户)
- `exist_users`:返回所有 auth users 减去 new_users
- `user_ids`:保持不变
#### 4.5 通知与 target mode 的关联方式
通知的 target mode 存储在 YAML 中,但 sync 到数据库时 `Notification` 表本身没有 `target_mode` 字段。需要:
- 方案 A:在 `notifications` 表中添加 `target_mode` 列,sync 时写入
- 方案 B:每次分发时重新读取 YAML 配置
推荐 **方案 A**,因为:
1. 分发逻辑(注册、sync)需要高效查询,不应每次都读文件
2. target mode 是通知的固有属性,应持久化
### 实现步骤清单
1.`notifications` 表新增 `target_mode` 列(Alembic migration
2. 更新 `Notification` ORM model,添加 `target_mode` 字段
3. 更新 `StaticNotificationTargets.mode` 的 Literal 类型,加入 `new_users``exist_users`
4. 更新 `StaticNotificationDefinition` 或 sync 逻辑,将 target_mode 写入 Notification 记录
5. 修改 `welcome_points.yaml` 的 targets.mode 为 `new_users`
6.`NotificationRepository` 中新增 `link_notifications_for_registered_user` 方法
7. 修改 `auth/router.py` 注册流程,使用新的分发逻辑
8. 更新 `_resolve_target_user_ids` 以支持新的 mode 语义
### 涉及文件
- `backend/alembic/versions/` — 新增 migrationnotifications.target_mode
- `backend/src/models/notification.py` — 添加 target_mode 字段
- `backend/src/core/config/notification/static_schema.py` — 扩展 StaticNotificationTargets
- `backend/src/core/config/notification/static_sync.py` — sync 时写入 target_mode,修改 _resolve_target_user_ids
- `backend/src/core/config/static/notifications/welcome_points.yaml` — mode 改为 new_users
- `backend/src/v1/notifications/repository.py` — 新增按 target_mode 过滤的关联方法
- `backend/src/v1/notifications/service.py` — 新增服务方法
- `backend/src/v1/auth/router.py` — 修改注册流程调用
---
## 执行顺序
四个任务相互独立,按风险从低到高执行:
1. **任务 1**(重复索引清理)— 风险最低,纯 DDL
2. **任务 3**(路径缩短)— 低风险,文件移动 + 一行代码修改
3. **任务 2**(脚本合并)— 低风险,脚本层修改 + 删除文件
4. **任务 4**(targets 语义)— 中风险,涉及 schema 变更、DB migration、业务逻辑修改
---
## 验证策略
- 任务 1:迁移后查询 `pg_indexes` 确认冗余索引已删除
- 任务 2:运行 `./infra/scripts/dev-migrate.sh sync-notifications --dry-run` 验证子命令可用;运行 `./infra/scripts/dev-migrate.sh bootstrap` 验证包含通知同步
- 任务 3:运行 sync-notifications 验证 YAML 加载路径正确
- 任务 4
- 单元测试:`StaticNotificationTargets` 的验证逻辑(new_users/exist_users/all_users/user_ids
- 集成测试:新用户注册时只收到 `new_users` + `all_users` 通知
- 集成测试:重新注册用户不收到 `new_users` 通知
- sync-notifications 按 target_mode 正确分发
---
## 不在范围内
- 前端通知展示逻辑变更
- 通知的推送(push notification)机制
- 通知模板引擎或多语言支持