@@ -0,0 +1,276 @@
# 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 migration, drop 冗余索引:
- `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/` — 新增 migration( notifications.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)机制
- 通知模板引擎或多语言支持