feat: 静态通知同步 + 积分审计 JSONB 序列化修复

This commit is contained in:
qzl
2026-04-10 19:23:38 +08:00
parent 1cdaeb274e
commit 4b258bb4d0
13 changed files with 1196 additions and 14 deletions
+41 -11
View File
@@ -38,9 +38,7 @@
- 系统级离线推送
- 自动监听文件变化并实时同步
- 通过文件删除自动删库
- 复杂运营后台
- 严格对齐目标用户集合并自动删除既有投递记录
---
@@ -161,6 +159,7 @@ notification:
body: 你已获得注册奖励,可前往积分中心查看。
payload:
deleted: false
action: open_route
route: /points
entity_id: null
@@ -222,6 +221,8 @@ targets:
- 通知类型,当前默认 `system`
- `status`
- `draft/published/revoked`
- `deleted`
- 显式软删除主通知
- `published_at`
- 发布时间
- `title/body/payload`
@@ -239,6 +240,7 @@ targets:
- `source_key` 必填且全局唯一
- `version >= 1`
- `status` 只允许 `draft/published/revoked`
- `deleted` 为可选布尔值
- `payload` 必须符合现有通知 payload schema
- `targets.mode='all_users'` 时不允许传 `user_ids`
- `targets.mode='user_ids'``user_ids` 必填且不能为空
@@ -280,22 +282,39 @@ targets:
### 8.4 统一删除
本阶段不使用“文件消失自动删库”语义。
本阶段支持两种明确的下线方式:
1. 在 YAML 中显式写 `deleted: true`
2. 执行同步时使用 `--prune`,将文件中已不存在的静态通知软删除
- `deleted: true` 语义:
- 设置 `notifications.deleted_at`
- 不删除既有 `user_notifications`
- `--prune` 语义:
- 扫描范围内缺失的静态通知会被软删除
- 不会删除非 `source='static'` 的通知
默认情况下,不因为文件消失自动删库。
原因:
- 文件误删风险高
- 容易把版本控制操作误解释为业务删除
如果需要下线,显式通过配置状态控制
如果只是想临时停止用户可见,优先用
- `status: revoked`
如果未来确实需要静态配置触发软删除,再单独增加明确字段,不在本阶段默认启用。
如果想做统一下线并保留审计主记录,可用:
- `deleted: true`
### 8.5 目标用户变更
第一阶段采用保守策略:
默认采用保守策略:
- 新增目标用户时,补插入 `user_notifications`
- 被移出目标集合的用户,不自动删除既有 `user_notifications`
@@ -305,7 +324,9 @@ targets:
- 防止误操作删除已投递历史
- 与“通知一旦发出就保留用户侧记录”的语义更一致
如果未来需要严格对齐文件目标集合,再单独增加显式 `--reconcile-targets` 行为。
如果执行同步时显式加上 `--reconcile-targets`,则:
- 当前目标集合之外的既有 `user_notifications` 会被删除
---
@@ -377,8 +398,10 @@ PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications
- `--path`
- `--source-key`
- `--dry-run`
- `--prune`
- `--reconcile-targets`
第一阶段不默认提供危险的全量清理参数
危险行为必须显式开启,不默认启用
### 10.2 infra 脚本
@@ -399,6 +422,7 @@ infra/scripts/register-notifications.sh
./infra/scripts/register-notifications.sh
./infra/scripts/register-notifications.sh --dry-run
./infra/scripts/register-notifications.sh --source-key welcome_bonus
./infra/scripts/register-notifications.sh --prune --reconcile-targets
```
---
@@ -435,8 +459,9 @@ infra/scripts/register-notifications.sh
6. 为通知模块补充按 `source/source_key` 查询与更新能力
7.`core.runtime.cli` 中新增 `sync-notifications` 命令
8. 新增 `infra/scripts/register-notifications.sh`
9. 视需要补充 `notification_updated` Realtime 事件
10. 编写最小测试和 dry-run 校验
9. 支持 `--prune``--reconcile-targets`
10. 视需要补充 `notification_updated` Realtime 事件
11. 编写最小测试和 dry-run 校验
---
@@ -447,7 +472,10 @@ infra/scripts/register-notifications.sh
- [ ] 修改 `title/body/payload` 后,再同步可反映到数据库
- [ ] 用户侧已读状态在主通知内容更新后保持不变
- [ ]`status` 改为 `revoked` 后,再同步可使通知在用户列表中失效
- [ ]`deleted` 改为 `true` 后,再同步可使通知从用户列表和未读数中消失
- [ ] `--dry-run` 可输出计划变更而不写库
- [ ] `--prune` 可将文件中已不存在的静态通知软删除
- [ ] `--reconcile-targets` 可严格对齐目标用户集合
- [ ] YAML 结构不合法时同步失败,并给出明确错误
- [ ] 脚本可按全量或按 `source_key` 手动触发同步
@@ -461,10 +489,13 @@ infra/scripts/register-notifications.sh
- 新建通知同步
- 已有通知更新同步
- 撤销同步
- 显式软删除同步
- 相同 `source_key` 幂等 upsert
- 更新主通知时不重置 `user_notifications.is_read/read_at`
- 新增目标用户时补插入接收关系
- 被移出目标集合时不删除既有接收关系
- `--reconcile-targets` 下删除多余接收关系
- `--prune` 下软删除缺失静态通知
脚本至少验证:
@@ -479,6 +510,5 @@ infra/scripts/register-notifications.sh
只有在真实需求出现时,再考虑:
- 用删除文件触发软删除
- 严格对齐目标用户集合并清理历史接收关系
- 通过后台页面管理静态通知
- 将静态通知同步纳入更完整的发布工作流
@@ -0,0 +1,229 @@
# 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
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"
```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
./infra/scripts/register-notifications.sh
./infra/scripts/register-notifications.sh --dry-run
./infra/scripts/register-notifications.sh --source-key welcome_bonus
./infra/scripts/register-notifications.sh --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.