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

24 KiB
Raw Blame History

通知系统实现方案

日期: 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 增加 namecover_url 字段 复用现有调用,不新增 RPC
防重复唯一约束 本次不实现 设计文档 §8.2 标注"可选",依赖客户端去抖
TTL 清理 本次不实现 设计文档未要求
列表聚合v1.1 新增) 展示层按 target_id 聚合 likesystem/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

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 新增:

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

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 增加字段:

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 端)

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 之前插入:

// 构造通知 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 暴露:

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 新增)

// 列表查询按 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 = truetotal_count = N
  • actors = 按时间倒序前 3 个 actor
  • title 模板:{actor_names} 等 N 人赞了你的《{asset_title}》asset_title 从 data 取出)
  • is_read = 全部已读时为 true
  • created_at = 最新一条的时间

7.5 MarkAsReadByTarget / DeleteByTarget

// 标已读:仅作用于 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

# 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) 操作