2026-04-10 19:23:38 +08:00
|
|
|
# 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:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
notification:
|
|
|
|
|
source_key: welcome_bonus
|
|
|
|
|
version: 1
|
|
|
|
|
type: system
|
|
|
|
|
status: published
|
|
|
|
|
published_at: 2026-04-10T08:00:00Z
|
2026-04-28 17:20:17 +08:00
|
|
|
title:
|
|
|
|
|
zh: 新用户欢迎通知
|
|
|
|
|
zh_Hant: 新用戶歡迎通知
|
|
|
|
|
en: Welcome
|
|
|
|
|
body:
|
|
|
|
|
zh: 你已获得注册奖励,可前往积分中心查看。
|
|
|
|
|
zh_Hant: 你已獲得註冊獎勵,可前往積分中心查看。
|
|
|
|
|
en: You have received a registration reward. Check your points.
|
2026-04-10 19:23:38 +08:00
|
|
|
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
|
2026-04-28 17:20:17 +08:00
|
|
|
- `title`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`.
|
|
|
|
|
- `body`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`.
|
2026-04-10 19:23:38 +08:00
|
|
|
- `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"
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
payload:
|
|
|
|
|
action: none
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### action = "open_route"
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-04-16 17:48:36 +08:00
|
|
|
./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
|
2026-04-10 19:23:38 +08:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 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.
|