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
This commit is contained in:
qzl
2026-04-16 17:48:36 +08:00
parent d91064835b
commit c79c773d67
26 changed files with 1011 additions and 49 deletions
@@ -13,6 +13,7 @@ from backend.src.schemas.shared.notification import (
NotificationPayload,
NotificationPayloadNone,
)
from schemas.enums import NotificationTargetMode
class StaticNotificationDefinition(BaseModel):
@@ -32,14 +33,16 @@ class StaticNotificationDefinition(BaseModel):
class StaticNotificationTargets(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
mode: Literal["all_users", "user_ids"]
mode: NotificationTargetMode
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.mode != NotificationTargetMode.USER_IDS and self.user_ids is not None:
raise ValueError(
"targets.user_ids must be absent when mode is not user_ids"
)
if self.mode == NotificationTargetMode.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"
@@ -23,7 +23,9 @@ from core.config.notification.static_schema import (
)
from models.auth_user import AuthUser
from models.notification import Notification
from models.register_bonus_claims import RegisterBonusClaims
from models.user_notification import UserNotification
from schemas.enums import NotificationTargetMode
from utils.paths import get_notification_config_dir
logger = get_logger("core.config.notification.static_sync")
@@ -203,6 +205,7 @@ async def _sync_document(
body=definition.body,
payload=_payload_to_dict(definition.payload),
status=definition.status,
target_mode=document.config.targets.mode,
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),
@@ -219,6 +222,7 @@ async def _sync_document(
notification=notification,
config=definition,
content_hash=content_hash,
target_mode=document.config.targets.mode,
)
if changed:
updated = 1
@@ -373,7 +377,11 @@ def _resolve_deleted_at(
def _apply_notification_updates(
*, notification: Notification, config: object, content_hash: str
*,
notification: Notification,
config: object,
content_hash: str,
target_mode: NotificationTargetMode,
) -> bool:
next_values = {
"type": getattr(config, "type"),
@@ -383,6 +391,7 @@ def _apply_notification_updates(
"body": getattr(config, "body"),
"payload": _payload_to_dict(getattr(config, "payload")),
"status": getattr(config, "status"),
"target_mode": target_mode,
"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),
@@ -399,7 +408,7 @@ async def _resolve_target_user_ids(
*, session: AsyncSession, config: StaticNotificationFile
) -> list[UUID]:
targets = config.targets
if targets.mode == "user_ids":
if targets.mode == NotificationTargetMode.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))
@@ -416,6 +425,21 @@ async def _resolve_target_user_ids(
+ ", ".join(sorted(missing_user_ids))
)
return requested_user_ids
if targets.mode in (
NotificationTargetMode.NEW_USERS,
NotificationTargetMode.EXIST_USERS,
):
claimed_result = await session.execute(
select(RegisterBonusClaims.first_user_id_snapshot).where(
RegisterBonusClaims.first_user_id_snapshot.isnot(None)
)
)
claimed_ids = set(claimed_result.scalars().all())
all_users_result = await session.execute(select(AuthUser.id))
all_user_ids = all_users_result.scalars().all()
if targets.mode == NotificationTargetMode.NEW_USERS:
return [uid for uid in all_user_ids if uid not in claimed_ids]
return [uid for uid in all_user_ids if uid in claimed_ids]
result = await session.execute(select(AuthUser.id))
return list(result.scalars().all())
@@ -11,4 +11,4 @@ notification:
tab: balance
targets:
mode: all_users
mode: new_users
+5 -1
View File
@@ -84,7 +84,7 @@ async def run_init_data() -> bool:
async def bootstrap() -> bool:
logger.info("Starting bootstrap (migrate + init-data)")
logger.info("Starting bootstrap (migrate + init-data + sync-notifications)")
if not run_migrations():
logger.error("Bootstrap aborted: migrations failed")
@@ -94,6 +94,10 @@ async def bootstrap() -> bool:
logger.error("Bootstrap aborted: init-data failed")
return False
if not await run_sync_notifications():
logger.error("Bootstrap aborted: sync-notifications failed")
return False
logger.info("Bootstrap completed successfully")
return True
+8
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
from core.db.types import json_jsonb
from schemas.enums import NotificationTargetMode
class Notification(TimestampMixin, SoftDeleteMixin, Base):
@@ -18,6 +19,10 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base):
"status IN ('draft', 'published', 'revoked')",
name="ck_notifications_status",
),
CheckConstraint(
"target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')",
name="ck_notifications_target_mode",
),
CheckConstraint(
"jsonb_typeof(payload) = 'object'",
name="ck_notifications_payload_object",
@@ -63,6 +68,9 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base):
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default=text("'published'")
)
target_mode: Mapped[NotificationTargetMode] = mapped_column(
String(32), nullable=False, server_default=text("'all_users'")
)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+7
View File
@@ -151,3 +151,10 @@ class GroupMemberStatus(str, Enum):
ACTIVE = "active"
MUTED = "muted"
REMOVED = "removed"
class NotificationTargetMode(str, Enum):
NEW_USERS = "new_users"
EXIST_USERS = "exist_users"
ALL_USERS = "all_users"
USER_IDS = "user_ids"
+1 -1
View File
@@ -24,7 +24,7 @@ def get_database_config_dir() -> Path:
def get_notification_config_dir() -> Path:
return get_static_config_dir() / "notification/notifications"
return get_static_config_dir() / "notifications"
def get_divination_data_dir() -> Path:
+4 -3
View File
@@ -73,13 +73,14 @@ async def create_email_session(
)
result = await service.create_email_session(payload)
points_service = PointsService(repository=PointsRepository(session))
await points_service.grant_register_bonus_if_eligible(
bonus_result = await points_service.grant_register_bonus_if_eligible(
user_id=UUID(result.user.id),
user_email=result.user.email,
)
notification_service = NotificationService(NotificationRepository(session))
linked_count = await notification_service.link_published_notifications_to_user(
user_id=UUID(result.user.id)
linked_count = await notification_service.link_notifications_for_registered_user(
user_id=UUID(result.user.id),
is_first_registration=bonus_result.is_first_registration,
)
await session.commit()
logger.info(
+15 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.notification import Notification
from models.user_notification import UserNotification
from schemas.enums import NotificationTargetMode
class NotificationRepository:
@@ -116,13 +117,24 @@ class NotificationRepository:
async def commit(self) -> None:
await self._session.commit()
async def link_published_notifications_to_user(self, *, user_id: UUID) -> int:
async def link_notifications_for_registered_user(
self, *, user_id: UUID, is_first_registration: bool
) -> int:
target_modes: list[NotificationTargetMode]
if is_first_registration:
target_modes = [
NotificationTargetMode.NEW_USERS,
NotificationTargetMode.ALL_USERS,
]
else:
target_modes = [NotificationTargetMode.ALL_USERS]
notification_ids = list(
(
await self._session.execute(
select(Notification.id).where(
Notification.status == "published",
Notification.deleted_at.is_(None),
Notification.target_mode.in_(target_modes),
)
)
)
@@ -136,8 +148,8 @@ class NotificationRepository:
insert(UserNotification)
.values(
[
{"user_id": user_id, "notification_id": notification_id}
for notification_id in notification_ids
{"user_id": user_id, "notification_id": nid}
for nid in notification_ids
]
)
.on_conflict_do_nothing(index_elements=["user_id", "notification_id"])
+5 -3
View File
@@ -123,9 +123,11 @@ class NotificationService:
await self._repository.commit()
return updated_count
async def link_published_notifications_to_user(self, *, user_id: UUID) -> int:
return await self._repository.link_published_notifications_to_user(
user_id=user_id
async def link_notifications_for_registered_user(
self, *, user_id: UUID, is_first_registration: bool
) -> int:
return await self._repository.link_notifications_for_registered_user(
user_id=user_id, is_first_registration=is_first_registration
)
+9 -3
View File
@@ -61,6 +61,7 @@ class RegisterBonusResult:
amount: int
balance_after: int
event_id: str
is_first_registration: bool = False
@dataclass(frozen=True)
@@ -122,14 +123,17 @@ class PointsService:
account = await self._repository.get_or_create_user_points_for_update(
user_id=user_id
)
if claim is not None and claim.balance_snapshot is not None:
account.balance = max(int(claim.balance_snapshot), 0)
account.version = int(account.version) + 1
if claim is not None:
is_first_registration = claim.first_user_id_snapshot is None
if claim.balance_snapshot is not None:
account.balance = max(int(claim.balance_snapshot), 0)
account.version = int(account.version) + 1
return RegisterBonusResult(
granted=False,
amount=0,
balance_after=int(account.balance),
event_id=event_id,
is_first_registration=is_first_registration,
)
claimed = await self._repository.claim_register_bonus(
@@ -144,6 +148,7 @@ class PointsService:
amount=0,
balance_after=int(account.balance),
event_id=event_id,
is_first_registration=False,
)
balance = int(account.balance)
@@ -197,6 +202,7 @@ class PointsService:
amount=bonus_points,
balance_after=int(account.balance),
event_id=event_id,
is_first_registration=True,
)
async def ensure_run_points_available(