Files
eryao/docs/protocols/notification/static-notification-sync-protocol.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

5.6 KiB

Static Notification Sync Protocol

This document defines the static notification file contract and database sync semantics.

Protocol verification status:

  • Sync plan source: docs/plans/static-notification-sync-plan.md
  • Static sync implementation source: backend/src/core/config/notification/static_sync.py
  • Static sync schema source: backend/src/core/config/notification/static_schema.py

Compatibility strategy

  • Additive evolution only.
  • Existing source_key values are stable identifiers and must not be repurposed.
  • Changing notification content must not reset user read state.
  • Removing a file has no effect by default; database pruning requires explicit CLI flag.

Static file location

Static notification files live under:

backend/src/core/config/static/notification/notifications/*.yaml

The sync command scans all *.yaml files in that directory unless a specific source_key is requested.

File schema

Each YAML file contains two top-level sections:

  • notification
  • targets

Example:

notification:
  source_key: welcome_bonus
  version: 1
  type: system
  status: published
  published_at: 2026-04-10T08:00:00Z
  title: 新用户欢迎通知
  body: 你已获得注册奖励,可前往积分中心查看。
  payload:
    action: open_route
    route: /points
    tab: balance

targets:
  mode: all_users

notification

  • source_key: required, string, max 128, unique among static notifications
  • version: required, integer, >= 1
  • type: required, string, currently system
  • status: required, one of draft, published, revoked
  • deleted: optional, boolean, default false, soft-delete this notification
  • published_at: optional ISO 8601 timestamp
  • title: required, non-empty string
  • body: required, non-empty string
  • payload: required, must follow the notification payload protocol

targets

  • mode: required, one of all_users, user_ids
  • user_ids: required only when mode = user_ids

Rules:

  • mode = all_users: user_ids must be absent
  • mode = user_ids: user_ids must be a non-empty UUID list

Payload contract

notification.payload reuses the inbox notification payload schema.

action = "none"

payload:
  action: none

action = "open_route"

payload:
  action: open_route
  route: /points
  entity_id: optional-id
  tab: balance

Rules:

  • route: required, max 200
  • entity_id: optional, max 64
  • tab: optional, max 32
  • url: must be absent

action = "open_url"

payload:
  action: open_url
  url: https://example.com/page

Rules:

  • url: required, max 500
  • route, entity_id, tab: must be absent

Database mapping

Static notifications map to notifications rows using:

  • source = 'static'
  • source_key = notification.source_key

Additional notification fields:

  • source_version = notification.version
  • content_hash = normalized content hash

Required uniqueness:

  • UNIQUE(source, source_key) where source_key IS NOT NULL

Sync semantics

Create

If (source='static', source_key=...) does not exist:

  1. Create notifications
  2. Create user_notifications for target users

Update

If (source='static', source_key=...) already exists:

  1. Update title, body, payload, status, published_at, source_version, content_hash
  2. Keep existing user_notifications
  3. Do not reset is_read or read_at

Revoke

If notification.status = revoked:

  1. Update notifications.status = 'revoked'
  2. Set revoked_at
  3. Keep existing user_notifications

Soft delete

If notification.deleted = true:

  1. Set notifications.deleted_at
  2. Keep existing user_notifications
  3. Exclude the notification from list/unread queries

Draft

If notification.status = draft:

  1. Keep or create the main notifications row
  2. Do not expose it to users through list/unread queries
  3. Do not create new user_notifications during sync

File deletion

Deleting a YAML file has no database effect by default.

Reason:

  • Prevent accidental revocation/deletion from filesystem changes

If the CLI is executed with --prune, then static notifications missing from the scanned files are soft-deleted by setting deleted_at.

Target changes

Default behavior:

  • Newly added targets receive new user_notifications
  • Removed targets do not delete existing user_notifications

This is intentionally non-destructive.

If the CLI is executed with --reconcile-targets, existing user_notifications that are no longer part of the computed target set are deleted.

CLI contract

Backend CLI command:

PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications

Supported options:

  • --path <dir-or-file>: override static notification source path
  • --source-key <key>: sync only one notification
  • --dry-run: validate and compute changes without writing to DB
  • --prune: soft-delete static notifications missing from the scanned files
  • --reconcile-targets: delete extra user_notifications not in the current target set

Infra wrapper:

./infra/scripts/dev-migrate.sh sync-notifications
./infra/scripts/dev-migrate.sh sync-notifications -- --dry-run
./infra/scripts/dev-migrate.sh sync-notifications -- --source-key welcome_bonus
./infra/scripts/dev-migrate.sh sync-notifications -- --prune --reconcile-targets

Failure behavior

  • Invalid YAML structure must fail the sync run
  • Duplicate source_key across files must fail the sync run
  • Invalid payload structure must fail the sync run
  • Missing target users are not auto-created
  • Database write failure must fail the sync run

No partial silent success is allowed.