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

12 KiB
Raw Blame History

PRD: 修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引

Task: 04-16-fix-notification-targets-cleanup Branch: worktree/fix-notification-targets-cleanup Status: planning


背景与动机

当前代码库存在四类独立但可并行修复的问题:

  1. 数据库重复索引llm_factory.namellms.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_usersuser_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_keyUniqueConstraint 自动生成的),drop ix_llm_factory_name
    • llms: 保留 llms_model_code_keydrop 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:支持 migrateinit-databootstrap 三个子命令
  • backend/src/core/runtime/cli.pymain() 已支持 sync-notifications 命令
  • bootstrap() 函数(cli.py:86)仅执行 migrate + init-data,不含 sync-notifications

实现方案

  1. 修改 dev-migrate.sh:添加 sync-notifications 子命令,透传所有参数给 CLI
  2. 修改 dev-migrate.shbootstrap 子命令:使其在 migrate + init-data 之后也执行 sync-notifications
  3. 修改 cli.pybootstrap() 函数:在 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__.pystatic_schema.pystatic_sync.py 三个文件
  • package 名 notification 和 YAML 目录 notifications 容易混淆

实现方案

  1. 重命名目录:将 backend/src/core/config/static/notification/notifications/ 移动到 backend/src/core/config/static/notifications/
  2. 更新 utils/paths.pyget_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 schemastatic_schema.py:32):

mode: Literal["all_users", "user_ids"]
user_ids: list[UUID] | None = None

当前 YAMLwelcome_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,则为重新注册用户
  • 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.pycreate_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_usersexist_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)机制
  • 通知模板引擎或多语言支持