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
@@ -0,0 +1,55 @@
"""add notification static sync fields
Revision ID: 20260411_0005
Revises: 20260411_0004
Create Date: 2026-04-11 16:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260411_0005"
down_revision: Union[str, Sequence[str], None] = "20260411_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"notifications",
sa.Column(
"source",
sa.String(length=32),
server_default=sa.text("'manual'"),
nullable=False,
),
)
op.add_column(
"notifications",
sa.Column("source_key", sa.String(length=128), nullable=True),
)
op.add_column(
"notifications",
sa.Column("source_version", sa.Integer(), nullable=True),
)
op.add_column(
"notifications",
sa.Column("content_hash", sa.String(length=64), nullable=True),
)
op.create_index(
"uq_notifications_source_source_key",
"notifications",
["source", "source_key"],
unique=True,
postgresql_where=sa.text("source_key IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("uq_notifications_source_source_key", table_name="notifications")
op.drop_column("notifications", "content_hash")
op.drop_column("notifications", "source_version")
op.drop_column("notifications", "source_key")
op.drop_column("notifications", "source")
@@ -0,0 +1 @@
from __future__ import annotations
@@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from typing import Literal
from uuid import UUID
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from v1.notifications.schemas import (
NotificationPayload,
NotificationPayloadNone,
)
class StaticNotificationDefinition(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
source_key: str = Field(min_length=1, max_length=128)
version: int = Field(ge=1)
type: str = Field(default="system", min_length=1, max_length=32)
status: Literal["draft", "published", "revoked"]
deleted: bool = False
published_at: datetime | None = None
title: str = Field(min_length=1)
body: str = Field(min_length=1)
payload: NotificationPayload = NotificationPayloadNone(action="none")
class StaticNotificationTargets(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
mode: Literal["all_users", "user_ids"]
user_ids: list[UUID] | None = None
@model_validator(mode="after")
def validate_target_mode(self) -> StaticNotificationTargets:
if self.mode == "all_users" and self.user_ids is not None:
raise ValueError("targets.user_ids must be absent when mode=all_users")
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
class StaticNotificationFile(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
notification: StaticNotificationDefinition
targets: StaticNotificationTargets
class StaticNotificationDocument(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
path: Path
config: StaticNotificationFile
def load_static_notification_file(path: Path) -> StaticNotificationFile:
with path.open("r", encoding="utf-8") as file:
loaded: object = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid static notification format: {path}")
try:
return StaticNotificationFile.model_validate(loaded)
except ValidationError as exc:
raise ValueError(f"Invalid static notification data: {path}") from exc
@@ -0,0 +1,489 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import cast
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import delete
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.db.session import AsyncSessionLocal
from core.logging import get_logger
from core.config.notification.static_schema import (
StaticNotificationDocument,
StaticNotificationFile,
load_static_notification_file,
)
from models.auth_user import AuthUser
from models.notification import Notification
from models.user_notification import UserNotification
logger = get_logger("core.config.notification.static_sync")
@dataclass(frozen=True)
class StaticNotificationSyncResult:
processed: int
created: int
updated: int
revoked: int
deleted: int
target_links_added: int
target_links_removed: int
dry_run: bool
def default_static_notification_path() -> Path:
return (
Path(__file__).resolve().parents[1]
/ "static"
/ "notification"
/ "notifications"
)
def load_static_notification_documents(
*, path: Path | None = None, source_key: str | None = None
) -> list[StaticNotificationDocument]:
base_path = path or default_static_notification_path()
candidate_paths = list(_resolve_candidate_paths(base_path))
documents: list[StaticNotificationDocument] = []
seen_source_keys: dict[str, Path] = {}
for candidate in candidate_paths:
config = load_static_notification_file(candidate)
document = StaticNotificationDocument(path=candidate, config=config)
key = document.config.notification.source_key
previous = seen_source_keys.get(key)
if previous is not None:
raise ValueError(
f"Duplicate static notification source_key '{key}' in {previous} and {candidate}"
)
seen_source_keys[key] = candidate
if source_key is not None and key != source_key:
continue
documents.append(document)
if source_key is not None and not documents:
raise ValueError(f"Static notification source_key not found: {source_key}")
return documents
async def sync_static_notifications(
*,
path: Path | None = None,
source_key: str | None = None,
dry_run: bool = False,
prune: bool = False,
reconcile_targets: bool = False,
) -> StaticNotificationSyncResult:
if source_key is not None and prune:
raise ValueError("--prune cannot be used together with --source-key")
documents = load_static_notification_documents(path=path, source_key=source_key)
created = 0
updated = 0
revoked = 0
deleted_count = 0
target_links_added = 0
target_links_removed = 0
source_keys = {document.config.notification.source_key for document in documents}
async with AsyncSessionLocal() as session:
if dry_run:
for document in documents:
item_result = await _sync_document(
session=session,
document=document,
dry_run=True,
reconcile_targets=reconcile_targets,
)
created += item_result.created
updated += item_result.updated
revoked += item_result.revoked
deleted_count += item_result.deleted
target_links_added += item_result.target_links_added
target_links_removed += item_result.target_links_removed
if prune:
prune_result = await _prune_missing_static_notifications(
session=session,
keep_source_keys=source_keys,
dry_run=True,
)
deleted_count += prune_result.deleted
await session.rollback()
else:
async with session.begin():
for document in documents:
item_result = await _sync_document(
session=session,
document=document,
dry_run=False,
reconcile_targets=reconcile_targets,
)
created += item_result.created
updated += item_result.updated
revoked += item_result.revoked
deleted_count += item_result.deleted
target_links_added += item_result.target_links_added
target_links_removed += item_result.target_links_removed
if prune:
prune_result = await _prune_missing_static_notifications(
session=session,
keep_source_keys=source_keys,
dry_run=False,
)
deleted_count += prune_result.deleted
result = StaticNotificationSyncResult(
processed=len(documents),
created=created,
updated=updated,
revoked=revoked,
deleted=deleted_count,
target_links_added=target_links_added,
target_links_removed=target_links_removed,
dry_run=dry_run,
)
logger.info(
"Static notification sync completed",
processed=result.processed,
created=result.created,
updated=result.updated,
revoked=result.revoked,
deleted=result.deleted,
target_links_added=result.target_links_added,
target_links_removed=result.target_links_removed,
dry_run=result.dry_run,
)
return result
@dataclass(frozen=True)
class _ItemSyncResult:
created: int = 0
updated: int = 0
revoked: int = 0
deleted: int = 0
target_links_added: int = 0
target_links_removed: int = 0
async def _sync_document(
*,
session: AsyncSession,
document: StaticNotificationDocument,
dry_run: bool,
reconcile_targets: bool,
) -> _ItemSyncResult:
definition = document.config.notification
content_hash = build_static_notification_content_hash(document.config)
existing = await _get_notification_by_source_key(
session=session,
source="static",
source_key=definition.source_key,
)
previous_status = existing.status if existing is not None else None
created = 0
updated = 0
revoked = 0
deleted_count = 0
if existing is None:
notification = Notification(
type=definition.type,
source="static",
source_key=definition.source_key,
source_version=definition.version,
content_hash=content_hash,
title=definition.title,
body=definition.body,
payload=_payload_to_dict(definition.payload),
status=definition.status,
published_at=_resolve_published_at(existing=None, config=definition),
revoked_at=_resolve_revoked_at(existing=None, config=definition),
deleted_at=_resolve_deleted_at(existing=None, config=definition),
)
if not dry_run:
session.add(notification)
await session.flush()
created = 1
if definition.deleted:
deleted_count = 1
else:
notification = existing
changed = _apply_notification_updates(
notification=notification,
config=definition,
content_hash=content_hash,
)
if changed:
updated = 1
if definition.status == "revoked" and previous_status != "revoked":
revoked = 1
elif definition.status == "revoked" and changed:
revoked = 1
if definition.deleted:
deleted_count = 1
links_added = 0
links_removed = 0
if definition.status == "published" and not definition.deleted:
target_user_ids = await _resolve_target_user_ids(
session=session,
config=document.config,
)
existing_target_ids = set()
if notification.id is not None:
existing_target_ids = await _get_existing_target_user_ids(
session=session,
notification_id=notification.id,
)
if target_user_ids:
missing_target_ids = [
user_id
for user_id in target_user_ids
if user_id not in existing_target_ids
]
links_added = len(missing_target_ids)
if missing_target_ids and not dry_run:
await _insert_user_notifications(
session=session,
notification_id=notification.id,
user_ids=missing_target_ids,
)
if reconcile_targets and notification.id is not None:
extra_target_ids = [
user_id
for user_id in existing_target_ids
if user_id not in set(target_user_ids)
]
links_removed = len(extra_target_ids)
if extra_target_ids and not dry_run:
await _delete_user_notifications(
session=session,
notification_id=notification.id,
user_ids=extra_target_ids,
)
logger.info(
"Processed static notification",
source_key=definition.source_key,
path=str(document.path),
created=created,
updated=updated,
revoked=revoked,
deleted=deleted_count,
target_links_added=links_added,
target_links_removed=links_removed,
dry_run=dry_run,
reconcile_targets=reconcile_targets,
)
return _ItemSyncResult(
created=created,
updated=updated,
revoked=revoked,
deleted=deleted_count,
target_links_added=links_added,
target_links_removed=links_removed,
)
def build_static_notification_content_hash(config: StaticNotificationFile) -> str:
normalized = config.model_dump(mode="json", exclude_none=True)
if normalized.get("targets", {}).get("mode") == "user_ids":
raw_user_ids = normalized["targets"].get("user_ids", [])
normalized["targets"]["user_ids"] = sorted(raw_user_ids)
payload = json.dumps(normalized, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _resolve_candidate_paths(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix.lower() not in {".yaml", ".yml"}:
raise ValueError(f"Unsupported static notification file: {path}")
yield path
return
if not path.exists():
raise FileNotFoundError(f"Static notification path not found: {path}")
if not path.is_dir():
raise ValueError(
f"Static notification path must be a file or directory: {path}"
)
yield from sorted(
list(path.glob("*.yaml")) + list(path.glob("*.yml")),
key=lambda item: item.name,
)
async def _get_notification_by_source_key(
*, session: AsyncSession, source: str, source_key: str
) -> Notification | None:
stmt = select(Notification).where(
Notification.source == source,
Notification.source_key == source_key,
)
return (await session.execute(stmt)).scalar_one_or_none()
def _payload_to_dict(payload: object) -> dict[str, object]:
model_dump = getattr(payload, "model_dump", None)
if callable(model_dump):
return cast(dict[str, object], model_dump(mode="json", exclude_none=True))
raise TypeError("Notification payload must be a Pydantic model")
def _resolve_published_at(
*, existing: Notification | None, config: object
) -> datetime | None:
published_at = getattr(config, "published_at", None)
status = getattr(config, "status")
if published_at is not None:
return published_at
if status == "published":
if existing is not None and existing.published_at is not None:
return existing.published_at
return datetime.now(timezone.utc)
return None
def _resolve_revoked_at(
*, existing: Notification | None, config: object
) -> datetime | None:
status = getattr(config, "status")
if status == "revoked":
if existing is not None and existing.revoked_at is not None:
return existing.revoked_at
return datetime.now(timezone.utc)
return None
def _resolve_deleted_at(
*, existing: Notification | None, config: object
) -> datetime | None:
deleted = bool(getattr(config, "deleted", False))
if deleted:
if existing is not None and existing.deleted_at is not None:
return existing.deleted_at
return datetime.now(timezone.utc)
return None
def _apply_notification_updates(
*, notification: Notification, config: object, content_hash: str
) -> bool:
next_values = {
"type": getattr(config, "type"),
"source_version": getattr(config, "version"),
"content_hash": content_hash,
"title": getattr(config, "title"),
"body": getattr(config, "body"),
"payload": _payload_to_dict(getattr(config, "payload")),
"status": getattr(config, "status"),
"published_at": _resolve_published_at(existing=notification, config=config),
"revoked_at": _resolve_revoked_at(existing=notification, config=config),
"deleted_at": _resolve_deleted_at(existing=notification, config=config),
}
changed = False
for field_name, next_value in next_values.items():
if getattr(notification, field_name) != next_value:
setattr(notification, field_name, next_value)
changed = True
return changed
async def _resolve_target_user_ids(
*, session: AsyncSession, config: StaticNotificationFile
) -> list[UUID]:
targets = config.targets
if targets.mode == "user_ids":
requested_user_ids = list(dict.fromkeys(targets.user_ids or []))
result = await session.execute(
select(AuthUser.id).where(AuthUser.id.in_(requested_user_ids))
)
existing_user_ids = set(result.scalars().all())
missing_user_ids = [
str(user_id)
for user_id in requested_user_ids
if user_id not in existing_user_ids
]
if missing_user_ids:
raise ValueError(
"Static notification target users not found: "
+ ", ".join(sorted(missing_user_ids))
)
return requested_user_ids
result = await session.execute(select(AuthUser.id))
return list(result.scalars().all())
async def _get_existing_target_user_ids(
*, session: AsyncSession, notification_id: UUID
) -> set[UUID]:
result = await session.execute(
select(UserNotification.user_id).where(
UserNotification.notification_id == notification_id
)
)
return set(result.scalars().all())
async def _insert_user_notifications(
*, session: AsyncSession, notification_id: UUID, user_ids: list[UUID]
) -> None:
if not user_ids:
return
stmt = insert(UserNotification).values(
[
{"user_id": user_id, "notification_id": notification_id}
for user_id in user_ids
]
)
stmt = stmt.on_conflict_do_nothing(index_elements=["user_id", "notification_id"])
await session.execute(stmt)
await session.flush()
async def _delete_user_notifications(
*, session: AsyncSession, notification_id: UUID, user_ids: list[UUID]
) -> None:
if not user_ids:
return
stmt = delete(UserNotification).where(
UserNotification.notification_id == notification_id,
UserNotification.user_id.in_(user_ids),
)
await session.execute(stmt)
await session.flush()
async def _prune_missing_static_notifications(
*, session: AsyncSession, keep_source_keys: set[str], dry_run: bool
) -> _ItemSyncResult:
stmt = select(Notification).where(Notification.source == "static")
existing = list((await session.execute(stmt)).scalars().all())
to_delete = [
notification
for notification in existing
if notification.source_key is not None
and notification.source_key not in keep_source_keys
and notification.deleted_at is None
]
if to_delete and not dry_run:
now = datetime.now(timezone.utc)
for notification in to_delete:
notification.deleted_at = now
if to_delete:
logger.info(
"Pruned missing static notifications",
source_keys=[notification.source_key for notification in to_delete],
dry_run=dry_run,
)
return _ItemSyncResult(deleted=len(to_delete))
@@ -0,0 +1,5 @@
Place static notification YAML files in this directory.
Protocol source of truth:
- `docs/protocols/notification/static-notification-sync-protocol.md`
@@ -0,0 +1,14 @@
notification:
source_key: welcome_points
version: 1
type: system
status: published
title: 欢迎来到觅爻
body: 你已获得新用户奖励,点击前往积分页查看当前余额。
payload:
action: open_route
route: /points
tab: balance
targets:
mode: all_users
+76 -2
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import argparse
import asyncio
import subprocess
import sys
from pathlib import Path
from core.config.notification.static_sync import sync_static_notifications
from core.config.initial.init_data import initialize_data
from core.logging import get_logger
@@ -96,11 +98,70 @@ async def bootstrap() -> bool:
return True
async def run_sync_notifications(
*,
path: str | None = None,
source_key: str | None = None,
dry_run: bool = False,
prune: bool = False,
reconcile_targets: bool = False,
) -> bool:
logger.info(
"Running static notification sync",
path=path,
source_key=source_key,
dry_run=dry_run,
prune=prune,
reconcile_targets=reconcile_targets,
)
try:
result = await sync_static_notifications(
path=Path(path) if path is not None else None,
source_key=source_key,
dry_run=dry_run,
prune=prune,
reconcile_targets=reconcile_targets,
)
logger.info(
"Static notification sync finished",
processed=result.processed,
created=result.created,
updated=result.updated,
revoked=result.revoked,
deleted=result.deleted,
target_links_added=result.target_links_added,
target_links_removed=result.target_links_removed,
dry_run=result.dry_run,
)
return True
except Exception as e:
logger.error("Static notification sync failed", error=str(e))
return False
def _parse_sync_notifications_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="python -m core.runtime.cli sync-notifications"
)
parser.add_argument("--path", dest="path")
parser.add_argument("--source-key", dest="source_key")
parser.add_argument("--dry-run", dest="dry_run", action="store_true")
parser.add_argument("--prune", dest="prune", action="store_true")
parser.add_argument(
"--reconcile-targets",
dest="reconcile_targets",
action="store_true",
)
return parser.parse_args(argv)
def main() -> int:
if len(sys.argv) < 2:
logger.error("No command provided")
logger.info("Usage: python -m core.runtime.cli <command>")
logger.info("Available commands: migrate, init-data, bootstrap")
logger.info(
"Available commands: migrate, init-data, bootstrap, sync-notifications"
)
return 1
command = sys.argv[1]
@@ -111,9 +172,22 @@ def main() -> int:
success = asyncio.run(run_init_data())
elif command == "bootstrap":
success = asyncio.run(bootstrap())
elif command == "sync-notifications":
args = _parse_sync_notifications_args(sys.argv[2:])
success = asyncio.run(
run_sync_notifications(
path=args.path,
source_key=args.source_key,
dry_run=args.dry_run,
prune=args.prune,
reconcile_targets=args.reconcile_targets,
)
)
else:
logger.error("Unknown command", command=command)
logger.info("Available commands: migrate, init-data, bootstrap")
logger.info(
"Available commands: migrate, init-data, bootstrap, sync-notifications"
)
return 1
return 0 if success else 1
+13
View File
@@ -31,6 +31,13 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base):
"ix_notifications_published_at",
"published_at",
),
Index(
"uq_notifications_source_source_key",
"source",
"source_key",
unique=True,
postgresql_where=text("source_key IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -39,6 +46,12 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base):
type: Mapped[str] = mapped_column(
String(32), nullable=False, server_default=text("'system'")
)
source: Mapped[str] = mapped_column(
String(32), nullable=False, server_default=text("'manual'")
)
source_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_version: Mapped[int | None] = mapped_column(nullable=True)
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[dict[str, object]] = mapped_column(
+1 -1
View File
@@ -90,7 +90,7 @@ class PointsRepository:
input_tokens=command.input_tokens,
output_tokens=command.output_tokens,
cost=command.cost,
metadata_json=command.metadata,
metadata_json=command.metadata.model_dump(mode="json", exclude_none=True),
)
self._session.add(entry)
await self._session.flush()
@@ -0,0 +1,181 @@
from __future__ import annotations
from pathlib import Path
import textwrap
import pytest
from core.config.notification.static_schema import load_static_notification_file
from core.config.notification.static_sync import (
build_static_notification_content_hash,
load_static_notification_documents,
)
def _write_yaml(path: Path, content: str) -> None:
path.write_text(textwrap.dedent(content).strip() + "\n", encoding="utf-8")
def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None:
file_path = tmp_path / "welcome.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: welcome_bonus
version: 1
type: system
status: published
title: Welcome
body: Welcome to the app.
payload:
action: open_route
route: /points
tab: balance
targets:
mode: user_ids
user_ids:
- 11111111-1111-1111-1111-111111111111
""",
)
loaded = load_static_notification_file(file_path)
assert loaded.notification.source_key == "welcome_bonus"
assert loaded.notification.payload.action == "open_route"
assert loaded.targets.mode == "user_ids"
assert len(loaded.targets.user_ids or []) == 1
def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -> None:
file_path = tmp_path / "invalid.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: invalid_targets
version: 1
type: system
status: published
title: Invalid
body: Invalid targets.
payload:
action: none
targets:
mode: all_users
user_ids:
- 11111111-1111-1111-1111-111111111111
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_documents_rejects_duplicate_source_key(
tmp_path: Path,
) -> None:
_write_yaml(
tmp_path / "first.yaml",
"""
notification:
source_key: duplicate_key
version: 1
type: system
status: published
title: First
body: First body.
payload:
action: none
targets:
mode: all_users
""",
)
_write_yaml(
tmp_path / "second.yaml",
"""
notification:
source_key: duplicate_key
version: 1
type: system
status: published
title: Second
body: Second body.
payload:
action: none
targets:
mode: all_users
""",
)
with pytest.raises(ValueError, match="Duplicate static notification source_key"):
load_static_notification_documents(path=tmp_path)
def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None:
first_path = tmp_path / "first.yaml"
second_path = tmp_path / "second.yaml"
_write_yaml(
first_path,
"""
notification:
source_key: same_key
version: 1
type: system
status: published
title: Title A
body: Body A.
payload:
action: none
targets:
mode: all_users
""",
)
_write_yaml(
second_path,
"""
notification:
source_key: same_key
version: 1
type: system
status: published
title: Title B
body: Body A.
payload:
action: none
targets:
mode: all_users
""",
)
first = load_static_notification_file(first_path)
second = load_static_notification_file(second_path)
assert build_static_notification_content_hash(
first
) != build_static_notification_content_hash(second)
def test_load_static_notification_file_supports_deleted_flag(tmp_path: Path) -> None:
file_path = tmp_path / "deleted.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: deleted_notice
version: 1
type: system
status: revoked
deleted: true
title: Deleted
body: Deleted body.
payload:
action: none
targets:
mode: all_users
""",
)
loaded = load_static_notification_file(file_path)
assert loaded.notification.deleted is True
+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.
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: env file not found at $ENV_FILE" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$ENV_LOADER"
load_env_file "$ENV_FILE"
cd "$ROOT_DIR"
PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications "$@"