feat: 实现站内通知系统

- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
  - GET /api/v1/notifications (列表+游标分页)
  - GET /api/v1/notifications/unread-count
  - PATCH /api/v1/notifications/{id}/read (幂等)
  - PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
qzl
2026-04-10 18:50:08 +08:00
parent 17ef460391
commit 3f3d613d99
28 changed files with 3481 additions and 651 deletions
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
from core.db.types import json_jsonb
class Notification(TimestampMixin, SoftDeleteMixin, Base):
__tablename__ = "notifications"
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'published', 'revoked')",
name="ck_notifications_status",
),
CheckConstraint(
"jsonb_typeof(payload) = 'object'",
name="ck_notifications_payload_object",
),
Index(
"ix_notifications_status_created_at",
"status",
"created_at",
),
Index(
"ix_notifications_published_at",
"published_at",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
type: Mapped[str] = mapped_column(
String(32), nullable=False, server_default=text("'system'")
)
title: Mapped[str] = mapped_column(Text, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[dict[str, object]] = mapped_column(
json_jsonb,
nullable=False,
server_default=text("'{}'::jsonb"),
default=dict,
)
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default=text("'published'")
)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)