- 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
12 KiB
PRD: 修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引
Task:
04-16-fix-notification-targets-cleanupBranch:worktree/fix-notification-targets-cleanupStatus: planning
背景与动机
当前代码库存在四类独立但可并行修复的问题:
- 数据库重复索引 —
llm_factory.name和llms.model_code各有两个功能完全相同的 unique index,浪费存储并拖慢写入。 - 脚本碎片化 —
register-notifications.sh作为独立脚本存在,但其功能本质上是dev-migrate.sh的一个子命令(与 migrate / init-data 并列),且未被 bootstrap 覆盖。 - 路径冗余 —
backend/src/core/config/static/notification/notifications存在两层嵌套的notification,可缩短为backend/src/core/config/static/notifications。 - 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),导致两个相同功能的索引。
实现方案
- 创建新 Alembic migration,drop 冗余索引:
llm_factory: 保留llm_factory_name_key(UniqueConstraint 自动生成的),dropix_llm_factory_namellms: 保留llms_model_code_key,dropix_llms_model_code
- 在 migration 的
downgrade()中恢复被 drop 的索引。 - 更新
20260411_0001migration 的downgrade()中对应的drop_index调用,改为 drop 保留的那个索引名(因为ix_*将不再存在)。 - ORM model 中
unique=True已在mapped_column上声明,不需要改动。
涉及文件
backend/alembic/versions/— 新增 migrationbackend/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
实现方案
- 修改
dev-migrate.sh:添加sync-notifications子命令,透传所有参数给 CLI - 修改
dev-migrate.sh的bootstrap子命令:使其在 migrate + init-data 之后也执行 sync-notifications - 修改
cli.py的bootstrap()函数:在run_init_data()之后调用run_sync_notifications() - 删除
infra/scripts/register-notifications.sh - 更新 usage 文本
涉及文件
infra/scripts/dev-migrate.sh— 添加子命令,修改 bootstrapbackend/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容易混淆
实现方案
- 重命名目录:将
backend/src/core/config/static/notification/notifications/移动到backend/src/core/config/static/notifications/ - 更新
utils/paths.py:get_notification_config_dir()返回get_static_config_dir() / "notifications" - 保留
backend/src/core/config/notification/package 不变(这是 Python 代码 package,不是配置目录) - 删除空出来的
backend/src/core/config/static/notification/目录(仅含notifications/子目录) - 更新 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):
mode: Literal["all_users", "user_ids"]
user_ids: list[UUID] | None = None
当前 YAML(welcome_points.yaml):
targets:
mode: all_users
当前注册流程(auth/router.py:80-83):
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,则为重新注册用户
- 如果该用户 email 在
new_users:仅首次注册(register_bonus_claims 中无记录)的用户exist_users:已存在于数据库的用户(非注册时注入,而是通过 sync-notifications 或其他方式推送)
实现方案
4.1 扩展 StaticNotificationTargets schema
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
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 usersnew_users:返回在register_bonus_claims中没有 claim 记录的用户的first_user_id_snapshot(首次注册的用户)exist_users:返回所有 auth users 减去 new_usersuser_ids:保持不变
4.5 通知与 target mode 的关联方式
通知的 target mode 存储在 YAML 中,但 sync 到数据库时 Notification 表本身没有 target_mode 字段。需要:
- 方案 A:在
notifications表中添加target_mode列,sync 时写入 - 方案 B:每次分发时重新读取 YAML 配置
推荐 方案 A,因为:
- 分发逻辑(注册、sync)需要高效查询,不应每次都读文件
- target mode 是通知的固有属性,应持久化
实现步骤清单
- 在
notifications表新增target_mode列(Alembic migration) - 更新
NotificationORM model,添加target_mode字段 - 更新
StaticNotificationTargets.mode的 Literal 类型,加入new_users和exist_users - 更新
StaticNotificationDefinition或 sync 逻辑,将 target_mode 写入 Notification 记录 - 修改
welcome_points.yaml的 targets.mode 为new_users - 在
NotificationRepository中新增link_notifications_for_registered_user方法 - 修改
auth/router.py注册流程,使用新的分发逻辑 - 更新
_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— 扩展 StaticNotificationTargetsbackend/src/core/config/notification/static_sync.py— sync 时写入 target_mode,修改 _resolve_target_user_idsbackend/src/core/config/static/notifications/welcome_points.yaml— mode 改为 new_usersbackend/src/v1/notifications/repository.py— 新增按 target_mode 过滤的关联方法backend/src/v1/notifications/service.py— 新增服务方法backend/src/v1/auth/router.py— 修改注册流程调用
执行顺序
四个任务相互独立,按风险从低到高执行:
- 任务 1(重复索引清理)— 风险最低,纯 DDL
- 任务 3(路径缩短)— 低风险,文件移动 + 一行代码修改
- 任务 2(脚本合并)— 低风险,脚本层修改 + 删除文件
- 任务 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)机制
- 通知模板引擎或多语言支持