topfans/docs/superpowers/specs/2026-06-16-notification-system-design.md
2026-06-16 21:30:58 +08:00

586 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 通知系统实现方案
**日期**: 2026-06-16
**状态**: 已确认
**版本**: v1.0
**上游文档**: `docs/specs/2026-05-15-notification-system-design.md`
---
## 1. 概述
基于已批准的设计文档 `2026-05-15-notification-system-design.md` 实现通知系统。本文档聚焦**实现层细节**(项目结构、调用关系、错误处理、回归点),不重复架构设计。
支持的通知类型:
- **点赞通知** - 用户点赞藏品时由 social service 同步触发
- **系统通知** - admin 后端(运营)手动创建
- **活动通知** - admin 后端创建 activity 时**手动或自动**广播
**列表聚合策略**v1.1 新增):
- **写入层**:每条点赞仍独立写入 `notifications`
- **展示层(`GetNotifications`**
- `type=like` → 按 `target_id` 聚合返回 1 张卡片total_count + 前 3 个点赞人预览)
- `type=system` / `type=activity` → 不聚合,每条独立返回
- **跳转**:点击 like 聚合卡直接跳到 `/pages/asset/detail?id={target_id}`,不需要"展开全部"页面
---
## 2. 关键决策(与上游文档的差异/补充)
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 失败容忍 | 同步 RPC失败仅 ERROR 日志,不影响点赞主路径 | 设计文档 §3.3 未明确;但通知丢失比点赞失败代价小 |
| Notification Service 形态 | 独立 Dubbo 服务(端口 20010 | 设计文档 §3.2 明确 |
| admin 集成方式 | admin 后端FastAPI**共享数据库直接写表** | admin 已在 `TopFans-activity-admin/backend`,无 Dubbo 客户端 |
| 系统通知接收方 | 单用户 / 按 star 广播 / 全量广播 三种模式均支持 | 运营实际场景需要 |
| 活动通知触发 | ① 手动按钮(`POST /api/v1/admin/activities/{id}/broadcast`)② 创建 activity 时自动广播 | 用户要求两种都支持 |
| asset 数据获取 | **扩展 `GetAssetForRPCResponse` 增加 `name` 和 `cover_url` 字段** | 复用现有调用,不新增 RPC |
| 防重复唯一约束 | **本次不实现** | 设计文档 §8.2 标注"可选",依赖客户端去抖 |
| TTL 清理 | **本次不实现** | 设计文档未要求 |
| 列表聚合v1.1 新增) | **展示层按 target_id 聚合 like**system/activity 不聚合 | 防止热门藏品刷屏通知列表 |
---
## 3. 架构与调用关系
```
┌─────────────────────┐
│ Social Service │
│ LikeAsset 末尾 │──── 同步 RPC (失败仅日志) ────┐
└─────────────────────┘ ▼
┌─────────────────────────┐
│ Notification Service │ 独立 Dubbo 服务
│ port 20010 │
│ - 事务内 INSERT 通知 │
│ - UPSERT 统计 │
└─────────────────────────┘
┌───────────────────────────────────────┼───────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Gateway HTTP 路由 │ │ Asset Service │ │ Admin Backend │
│ /api/v1/notifs/ │ │ GetAssetForRPC │ │ (FastAPI, 共享DB)│
│ 前端调用 │ │ (扩展 name/cover) │ │ 直接写通知表 │
└──────────────────┘ └──────────────────────┘ └──────────────────┘
```
**事务边界**
- notification service 内部:`CreateNotification` 把 INSERT notifications + UPSERT notification_stats 包在一个事务
- admin 后端:创建 activity + 广播通知包在一个 DB 事务(用 SQLAlchemy `session.begin()`
---
## 4. 数据层
### 4.1 迁移文件
`backend/migrations/2026_06_16_001_create_notifications.sql`
```sql
CREATE TABLE IF NOT EXISTS public.notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
type VARCHAR(20) NOT NULL, -- like / system / activity
title VARCHAR(200) NOT NULL,
content VARCHAR(500),
data JSONB,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL, -- 毫秒时间戳
read_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_type_created
ON public.notifications (user_id, star_id, type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON public.notifications (user_id, star_id, is_read, created_at DESC)
WHERE is_deleted = FALSE;
CREATE TABLE IF NOT EXISTS public.notification_stats (
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
like_unread_count INT NOT NULL DEFAULT 0,
system_unread_count INT NOT NULL DEFAULT 0,
activity_unread_count INT NOT NULL DEFAULT 0,
total_unread_count INT NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, star_id)
);
-- 序列起始值预留 10000符合 CLAUDE.md 数据库规范)
ALTER SEQUENCE notifications_id_seq RESTART WITH 10000;
```
### 4.2 admin 后端 ORM 模型
`TopFans-activity-admin/backend/models/models.py` 新增:
```python
class Notification(Base):
__tablename__ = "notifications"
id = Column(BigInteger, primary_key=True)
user_id = Column(BigInteger, nullable=False, index=True)
star_id = Column(BigInteger, nullable=False)
type = Column(String(20), nullable=False)
title = Column(String(200), nullable=False)
content = Column(String(500))
data = Column(JSON)
is_read = Column(Boolean, default=False, nullable=False)
is_deleted = Column(Boolean, default=False, nullable=False)
created_at = Column(BigInteger, nullable=False)
read_at = Column(BigInteger)
class NotificationStats(Base):
__tablename__ = "notification_stats"
user_id = Column(BigInteger, primary_key=True)
star_id = Column(BigInteger, primary_key=True)
like_unread_count = Column(Integer, default=0, nullable=False)
system_unread_count = Column(Integer, default=0, nullable=False)
activity_unread_count = Column(Integer, default=0, nullable=False)
total_unread_count = Column(Integer, default=0, nullable=False)
updated_at = Column(BigInteger, nullable=False)
```
---
## 5. Proto 定义
### 5.1 新增 `backend/proto/notification.proto`
```protobuf
syntax = "proto3";
package topfans.notification;
option go_package = "github.com/topfans/backend/pkg/proto/notification;notification";
import "proto/common.proto";
import "google/api/annotations.proto";
import "google/protobuf/struct.proto";
service NotificationService {
rpc CreateNotification(CreateNotificationRequest) returns (CreateNotificationResponse) {
option (google.api.http) = {
post: "/internal/v1/notifications"
body: "*"
};
}
rpc GetNotifications(GetNotificationsRequest) returns (GetNotificationsResponse) {
option (google.api.http) = { get: "/api/v1/notifications" };
}
rpc GetUnreadCount(GetUnreadCountRequest) returns (GetUnreadCountResponse) {
option (google.api.http) = { get: "/api/v1/notifications/unread-count" };
}
rpc MarkAsRead(MarkAsReadRequest) returns (MarkAsReadResponse) {
option (google.api.http) = { post: "/api/v1/notifications/{id}/read" };
}
rpc MarkAsReadByTarget(MarkAsReadByTargetRequest) returns (MarkAsReadByTargetResponse) {
// 仅 type=like 使用:按 target_id 标记该 user+star+target 下所有未读 like
option (google.api.http) = { post: "/api/v1/notifications/targets/{target_id}/read" };
}
rpc MarkAllAsRead(MarkAllAsReadRequest) returns (MarkAllAsReadResponse) {
option (google.api.http) = { post: "/api/v1/notifications/read-all" };
}
rpc DeleteNotification(DeleteNotificationRequest) returns (DeleteNotificationResponse) {
option (google.api.http) = { delete: "/api/v1/notifications/{id}" };
}
rpc DeleteByTarget(DeleteByTargetRequest) returns (DeleteByTargetResponse) {
// 仅 type=like 使用:按 target_id 软删该 user+star+target 下所有通知
option (google.api.http) = { delete: "/api/v1/notifications/targets/{target_id}" };
}
}
message Notification {
int64 id = 1; int64 user_id = 2; int64 star_id = 3;
string type = 4; string title = 5; string content = 6;
google.protobuf.Struct data = 7;
bool is_read = 8; int64 created_at = 9; int64 read_at = 10;
// 仅 type=like 聚合时返回
bool aggregated = 11; // 是否聚合卡
int32 total_count = 12; // 聚合总数aggregated=true 时有值)
repeated ActorPreview actors = 13; // 前 3 个点赞人预览
int64 target_id = 14; // 聚合时用 target_id 替代单条 id
}
message ActorPreview {
int64 user_id = 1; string nickname = 2; string avatar = 3;
int64 liked_at = 4;
}
message CreateNotificationRequest {
int64 user_id = 1; int64 star_id = 2;
string type = 3; string title = 4; string content = 5;
google.protobuf.Struct data = 6;
}
message CreateNotificationResponse { topfans.common.BaseResponse base = 1; int64 id = 2; }
message GetNotificationsRequest {
string type = 1; // like / system / activity / 空=全部
string tab = 2; // today / history / 空=全部
int32 page = 3; int32 page_size = 4;
}
message GetNotificationsResponse {
topfans.common.BaseResponse base = 1;
repeated Notification items = 2; int64 total = 3;
int32 page = 4; int32 page_size = 5;
}
message GetUnreadCountRequest {}
message UnreadCount {
int32 like = 1; int32 system = 2; int32 activity = 3; int32 total = 4;
}
message GetUnreadCountResponse {
topfans.common.BaseResponse base = 1;
UnreadCount counts = 2;
}
message MarkAsReadRequest { int64 id = 1; }
message MarkAsReadResponse { topfans.common.BaseResponse base = 1; }
message MarkAllAsReadRequest { string type = 1; } // 空=全部类型
message MarkAllAsReadResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; }
message DeleteNotificationRequest { int64 id = 1; }
message DeleteNotificationResponse { topfans.common.BaseResponse base = 1; }
message MarkAsReadByTargetRequest { int64 target_id = 1; }
message MarkAsReadByTargetResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; }
message DeleteByTargetRequest { int64 target_id = 1; }
message DeleteByTargetResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; }
```
### 5.2 扩展 `backend/proto/asset.proto`
`GetAssetForRPCResponse` 增加字段:
```protobuf
message GetAssetForRPCResponse {
topfans.common.BaseResponse base = 1;
int64 asset_id = 2;
int64 owner_uid = 3;
int64 star_id = 4;
int32 status = 5;
bool is_active = 6;
string name = 7; // 新增:藏品名称(用于通知)
string cover_url = 8; // 新增:藏品封面(用于通知)
}
```
asset provider 实现里从 `asset_repository` 查出 `name`/`cover_url` 填入DB 已有字段,无表结构变更)。
---
## 6. 服务目录结构
### 6.1 Go 端:新建 `backend/services/notificationService/`
```
backend/services/notificationService/
├── main.go # Dubbo 启动、DB 初始化
├── configs/config.yaml # 端口 20010
├── provider/notification_provider.go # RPC 接口实现
├── service/notification_service.go # 业务逻辑 + 事务
├── repository/
│ ├── notification_repository.go # notifications CRUD
│ └── notification_stats_repository.go # 统计 UPSERT
├── model/notification.go # 数据模型
└── client/ # (本服务不调用其他服务,留空目录占位)
```
### 6.2 Go 端social service 新增客户端
`backend/services/socialService/client/notification_client.go` —— Dubbo RPC 客户端封装,参考 `asset_client.go` 模式。
### 6.3 Python 端admin 后端新增
```
TopFans-activity-admin/backend/
├── models/models.py # 新增 Notification + NotificationStats
├── crud/notification_crud.py # 创建系统通知 / 广播 / 单发
├── handlers/notification.py # 管理 API
├── handlers/activity.py # 改造:创建后自动 broadcast
└── router/__init__.py # 注册 notification 路由
```
---
## 7. 关键逻辑
### 7.1 CreateNotification 事务Go 端)
```go
func (s *NotificationService) CreateNotification(ctx, req) (int64, error) {
// 参数校验
if req.UserId <= 0 || req.StarId <= 0 { return 0, ErrInvalidArgument }
if len(req.Type) == 0 || len(req.Title) == 0 { return 0, ErrInvalidArgument }
if len(req.Title) > 200 || len(req.Content) > 500 { return 0, ErrInvalidArgument }
return s.db.Transaction(func(tx *sql.Tx) (int64, error) {
// 1. INSERT notifications
var id int64
err := tx.QueryRowContext(ctx,
`INSERT INTO notifications (user_id, star_id, type, title, content, data, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
req.UserId, req.StarId, req.Type, req.Title, req.Content, dataJSON, now,
).Scan(&id)
if err != nil { return 0, err }
// 2. UPSERT notification_stats按 type 加未读数)
var col string
switch req.Type {
case "like": col = "like_unread_count"
case "system": col = "system_unread_count"
case "activity": col = "activity_unread_count"
default: return 0, ErrInvalidArgument
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO notification_stats (user_id, star_id, %s, total_unread_count, updated_at)
VALUES ($1, $2, 1, 1, $3)
ON CONFLICT (user_id, star_id) DO UPDATE SET
%s = notification_stats.%s + 1,
total_unread_count = notification_stats.total_unread_count + 1,
updated_at = $3
`, col, col, col), req.UserId, req.StarId, now)
if err != nil { return 0, err }
return id, nil
})
}
```
### 7.2 点赞触发通知social service
`services/socialService/service/asset_like_service.go` `LikeAsset` 方法 line 132 之前插入:
```go
// 构造通知 JSON data
data := map[string]interface{}{
"target_type": "asset",
"target_id": assetID,
"actor_id": userID,
"asset_title": getAssetResp.Name, // 新增字段
"asset_cover": getAssetResp.CoverUrl, // 新增字段
"star_id": starID,
}
// 同步查 actor 信息actor 信息缺失时降级(仅保留 actor_id
actorName := strconv.FormatInt(userID, 10)
if actorInfo, err := s.userClient.GetUsersByIDs(ctx, []int64{userID}, starID); err == nil {
if info, exists := actorInfo[userID]; exists && info != nil {
data["actor_name"] = info.Nickname
data["actor_avatar"] = info.Avatar
actorName = info.Nickname
}
} else {
logger.Logger.Warn("failed to fetch actor info for notification, degrade to actor_id",
zap.Int64("user_id", userID),
zap.Error(err),
)
}
// 同步调 notification service失败仅日志
_, notifErr := s.notificationClient.CreateNotification(ctx, &notifPb.CreateNotificationRequest{
UserId: getAssetResp.OwnerUid, // 接收方 = asset owner
StarId: starID,
Type: "like",
Title: "新点赞",
Content: fmt.Sprintf("%s 点赞了你的藏品", actorName),
Data: dataStruct,
})
if notifErr != nil {
logger.Logger.Error("failed to create like notification (like itself succeeded)",
zap.Int64("asset_id", assetID),
zap.Int64("actor_id", userID),
zap.Int64("owner_uid", getAssetResp.OwnerUid),
zap.Error(notifErr),
)
}
// 不影响点赞主路径返回值
```
### 7.3 admin 创建系统通知Python 端)
`crud/notification_crud.py` 暴露:
```python
def create_system_notification(
db: Session,
*,
title: str,
content: str,
target_type: Literal["user", "star", "all"],
target_value: Optional[int], # user_id 或 star_id
data: Optional[dict],
) -> int: # 返回插入条数
"""事务内:解析目标 + INSERT N 条 notifications + UPSERT N 条 notification_stats"""
```
`handlers/notification.py`
- `POST /api/v1/admin/notifications` body: `{title, content, target_type, target_value, data}` → 调 `create_system_notification`
- 复用 admin JWT 鉴权中间件
### 7.4 GetNotifications 聚合查询v1.1 新增)
```go
// 列表查询按 type 分支处理
if req.Type == "like" {
// SQL: 按 target_id 分组聚合
SELECT
target_id,
COUNT(*) AS total_count,
MAX(created_at) AS latest_at,
BOOL_AND(is_read) AS is_read, // 仅当全部已读才算已读
json_agg(actor_preview ORDER BY created_at DESC LIMIT 3) AS actors
FROM notifications n
WHERE user_id = ? AND star_id = ?
AND type = 'like' AND is_deleted = FALSE
AND (tab today/history 时加时间条件)
GROUP BY target_id
ORDER BY latest_at DESC
LIMIT ? OFFSET ?
} else {
// system / activity 走原始 SQL
SELECT * FROM notifications WHERE user_id=? AND star_id=? AND type=? AND is_deleted=FALSE
ORDER BY created_at DESC LIMIT ? OFFSET ?
}
```
返回的 `Notification` 字段映射:
- `id` = 第一条原始通知的 id仅作为列表 key不用于跳转
- `target_id` = `data->>'target_id'`(前端用于跳转)
- `aggregated` = true`total_count` = N
- `actors` = 按时间倒序前 3 个 actor
- `title` 模板:`{actor_names} 等 N 人赞了你的《{asset_title}》`asset_title 从 `data` 取出)
- `is_read` = 全部已读时为 true
- `created_at` = 最新一条的时间
### 7.5 MarkAsReadByTarget / DeleteByTarget
```go
// 标已读:仅作用于 like 类型
func (s *NotificationService) MarkAsReadByTarget(ctx, userID, starID, targetID) (int32, error) {
affected, err := tx.Exec(`
UPDATE notifications
SET is_read = TRUE, read_at = ?
WHERE user_id=? AND star_id=? AND type='like'
AND data->>'target_id' = ? AND is_read = FALSE AND is_deleted = FALSE
`, now, userID, starID, targetID)
// 同步重置 stats: like_unread_count -= affected
tx.Exec(`
UPDATE notification_stats SET
like_unread_count = GREATEST(0, like_unread_count - ?),
total_unread_count = GREATEST(0, total_unread_count - ?),
updated_at = ?
WHERE user_id=? AND star_id=?
`, affected, affected, now, userID, starID)
return affected, nil
}
// 软删:按 target 软删所有 like
func (s *NotificationService) DeleteByTarget(ctx, userID, starID, targetID) (int32, error) {
// 先查有多少未读,删除后未读数 -N
// 然后 UPDATE is_deleted = TRUE
// 然后 stats -=
}
```
### 7.6 admin 活动广播
`handlers/activity.py` 创建 activity 时**支持**以下两种触发方式(由 admin 创建接口的 `auto_notify` 字段控制,默认 `true`
```python
# activity 创建接口 POST /api/v1/admin/minting-activities 入参增加 auto_notify: bool = True
def create_minting_activity(payload, db, current_admin):
activity = MintingActivity(**payload.dict(exclude={"auto_notify"}))
db.add(activity); db.flush() # 先拿 id
if payload.auto_notify:
# 同一事务内广播INSERT N 条 notifications + UPSERT N 条 stats
fans = resolve_recipient_users(db, target_type="all") # 按需可改为 star
notification_crud.create_activity_notification(
db, activity=activity, recipient_users=fans
)
db.commit() # activity + 通知一起提交
```
`POST /api/v1/admin/minting-activities/{id}/broadcast` 手动再触发一次(适用于创建时未广播的场景)。
---
## 8. 错误处理
| 场景 | 错误码 | 行为 |
|------|--------|------|
| 通知不存在 | `NotFound` | 404 |
| 通知不属于该 user | `PermissionDenied` | 403 |
| 参数缺失/超长 | `InvalidArgument` | 400 |
| DB 异常 | `Internal` | ERROR 日志(含 userID/starID/type500 |
| owner_uid=0 | 跳过 + WARN | 不写通知 |
| 点赞主路径调 notification 失败 | 仅 ERROR 日志 | **点赞仍返回成功** |
| 标已读时通知已删 | 静默成功 | 幂等 |
| 删除通知时通知未读 | 同步 `--unread_count` | 事务内 |
---
## 9. 测试计划
| 层 | 类型 | 关键用例 |
|----|------|----------|
| notification repository | 单测 | 列表分页、tab=today/history 时间边界、软删除 |
| notification service | 单测 | 事务回滚(故意触发第二条 SQL 失败)、未读数 +1、+0 边界 |
| social asset_like_service | 单测 | Mock notification client 验证 ① 调用参数正确 ② 客户端报错点赞仍成功 |
| admin notification_crud | 单测 | 三种 target_type 产生的 INSERT 行数正确;与 stats UPSERT 一致 |
| admin activity handler | 单测 | 创建 activity 后自动 broadcastbroadcast 失败 activity 创建仍提交 |
| HTTP | curl 手测 | 走通 list/unread/mark/delete |
---
## 10. 集成点回归CLAUDE.md "自审与回归检查"
| 改动点 | 回归检查 |
|--------|----------|
| `proto/asset.proto` 加字段 | `query_graph pattern=callers_of target=GetAssetForRPC` 确认所有调用方编译通过 |
| `socialService/service/asset_like_service.go` 改 LikeAsset | 跑既有 asset_like_service 单测;检查 asset_likers 缓存仍正确失效 |
| 新建 notificationService | gateway 路由配置新增 `/api/v1/notifications/*` → notification service |
| admin models 新增表 | admin 既有启动测试通过migration 不破坏现有表 |
---
## 11. 上线顺序
1. 应用迁移 `2026_06_16_001_create_notifications.sql`
2. 部署 notification service 二进制(暂时无调用方)
3. 部署新版 assetService带 name/cover_url 扩展)
4. 部署新版 socialService带点赞通知调用
5. 部署新版 admin 后端
6. gateway 路由配置:
- `backend/gateway/router/router.go` 新增 `notifications := v1.Group("/notifications")` 并注册全部 HTTP 路径
- `backend/gateway/config/config.yaml` 新增 notification service backend 配置Dubbo tri URL默认 `tri://localhost:20010`
7. 灰度验证:先点赞 → 再系统通知 → 再活动通知
每步独立发布,任何一步可单独回滚。
---
## 12. 验收清单
- [ ] 迁移文件已应用notifications 表存在
- [ ] notification service 进程启动正常
- [ ] 前端 GET /api/v1/notifications?type=like&tab=today 返回今日点赞
- [ ] 点赞触发后,被点赞方收到通知(验证 actor_name、asset_title 都正确)
- [ ] 未读数 GET /api/v1/notifications/unread-count 返回正确数字
- [ ] admin POST 创建单用户系统通知,前端能查到
- [ ] admin 按 star 广播指定 star 的所有粉丝收到
- [ ] admin 全量广播所有用户收到
- [ ] admin 创建 activity 后自动广播
- [ ] admin 手动 broadcast API 工作
- [ ] 软删除后列表不再返回
- [ ] 标已读后未读数对应类型 -1
- [ ] 删除未读通知后未读数 -1
- [ ] 通知写入失败时点赞主路径返回成功
- [ ] 同一 asset 收到 N 个赞 → 列表只显示 1 张聚合卡,含 total_count=N + 前 3 个点赞人预览
- [ ] 点击 like 聚合卡 → 跳到 `/pages/asset/detail?id={target_id}`
- [ ] MarkAsReadByTarget → 该 target 下所有未读 like 标已读、未读数对应 -N
- [ ] DeleteByTarget → 该 target 下所有 like 软删、未读数对应 -N
- [ ] system / activity 通知仍按单条 MarkAsRead(id) / DeleteNotification(id) 操作