# 通知系统实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现统一通知系统,支持点赞/系统/活动三种类型,列表层按 target_id 聚合 like 通知 **Architecture:** 独立 Dubbo Notification Service(事务内写通知+统计)+ social service 同步触发点赞通知 + admin 后端直写共享库触发系统/活动通知 + 列表层 GROUP BY 聚合 like **Tech Stack:** Go 1.21+ / Dubbo-go / GORM / PostgreSQL / FastAPI + SQLAlchemy **参考 spec:** `docs/superpowers/specs/2026-06-16-notification-system-design.md` --- ## 文件结构总览 ### 新建文件 | 路径 | 职责 | |------|------| | `backend/migrations/2026_06_16_001_create_notifications.sql` | DB schema(notifications + notification_stats) | | `backend/proto/notification.proto` | RPC 接口定义 | | `backend/services/notificationService/main.go` | Dubbo 启动入口 | | `backend/services/notificationService/configs/config.yaml` | 端口 20010、DB 配置 | | `backend/services/notificationService/provider/notification_provider.go` | RPC 实现 | | `backend/services/notificationService/service/notification_service.go` | 业务逻辑 + 事务 | | `backend/services/notificationService/repository/notification_repository.go` | notifications CRUD | | `backend/services/notificationService/repository/notification_stats_repository.go` | 统计 UPSERT | | `backend/services/notificationService/model/notification.go` | 数据模型 | | `backend/services/notificationService/repository/notification_repository_test.go` | 仓储单测 | | `backend/services/notificationService/service/notification_service_test.go` | 业务单测 | | `backend/services/socialService/client/notification_client.go` | Dubbo 客户端 | | `TopFans-activity-admin/backend/models/notification.py` | SQLAlchemy ORM | | `TopFans-activity-admin/backend/crud/notification_crud.py` | 业务函数 | | `TopFans-activity-admin/backend/crud/notification_crud_test.py` | crud 单测 | | `TopFans-activity-admin/backend/handlers/notification.py` | FastAPI handler | | `TopFans-activity-admin/backend/handlers/activity.py` | 改造:自动 broadcast | ### 修改文件 | 路径 | 改动 | |------|------| | `backend/proto/asset.proto` | `GetAssetForRPCResponse` 加 `name`/`cover_url` | | `backend/services/assetService/provider/asset_provider.go` | 填充新增字段 | | `backend/services/socialService/service/asset_like_service.go` | `LikeAsset` 末尾调 notification | | `backend/services/socialService/main.go` | 注入 `NotificationClient` | | `backend/services/socialService/service/asset_like_service_test.go` | 新增测试用例(mock notification 失败时点赞仍成功) | | `backend/gateway/router/router.go` | 注册 `/api/v1/notifications/*` | | `backend/gateway/config/config.yaml` | 加 notification service backend | | `TopFans-activity-admin/backend/router/__init__.py` | 注册 notification 路由 | | `TopFans-activity-admin/backend/main.py` | DB schema 同步声明(如使用 SQLAlchemy create_all) | --- ## 阶段 1:数据库 Schema ### Task 1: 创建 notifications 迁移文件 **Files:** - Create: `backend/migrations/2026_06_16_001_create_notifications.sql` - [ ] **Step 1: 写迁移文件** ```sql -- 通知系统主表 + 统计表 -- 创建时间: 2026-06-16 -- 关联: spec §4.1 -- 1. 通知主表 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 ); -- 2. 索引 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; -- 3. 通知统计表 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) ); -- 4. 序列起始值预留 10000(CLAUDE.md 数据库规范) ALTER SEQUENCE notifications_id_seq RESTART WITH 10000; ``` - [ ] **Step 2: 验证 SQL 语法**(如环境有 psql) ```bash PGPASSWORD=$DB_PASSWORD psql -h localhost -U $DB_USER -d $DB_NAME -f backend/migrations/2026_06_16_001_create_notifications.sql ``` Expected: 两条 CREATE TABLE 成功,无报错。 - [ ] **Step 3: 验证表结构** ```sql \d public.notifications \d public.notification_stats SELECT last_value FROM public.notifications_id_seq; ``` Expected: 字段、索引齐全;序列 `last_value = 10000`。 - [ ] **Step 4: 回归检查**(CLAUDE.md 规范) - 确认未影响其他表:`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename NOT IN ('notifications', 'notification_stats') ORDER BY tablename;` 应返回原有所有表 - 序列起始值 10000 不与现有 `assets`/`users` 等 BIGSERIAL 表冲突 - [ ] **Step 5: Commit** ```bash git add backend/migrations/2026_06_16_001_create_notifications.sql git commit -m "feat(notification): add notifications and notification_stats tables" ``` --- ## 阶段 2:Proto 定义 ### Task 2: 扩展 asset.proto 加 name/cover_url 字段 **Files:** - Modify: `backend/proto/asset.proto:326-333` - [ ] **Step 1: 修改 GetAssetForRPCResponse** 定位到 `backend/proto/asset.proto` line 326-333: ```protobuf // 获取资产信息响应(内部RPC) message GetAssetForRPCResponse { topfans.common.BaseResponse base = 1; int64 asset_id = 2; // 资产ID int64 owner_uid = 3; // 持有者ID int64 star_id = 4; // 明星ID int32 status = 5; // 状态:0=Pending, 1=Active bool is_active = 6; // 是否激活 string name = 7; // 藏品名称(用于通知) string cover_url = 8; // 藏品封面(用于通知) } ``` - [ ] **Step 2: 重新生成 proto Go 代码** ```bash cd backend ./gen-swagger.sh 2>/dev/null || true # 触发 proto 生成 # 或者直接调 protoc: protoc --go_out=. --go-grpc_out=. proto/asset.proto ``` Expected: `pkg/proto/asset/asset.pb.go` 重新生成,编译通过。 - [ ] **Step 3: 回归检查** 用 `mcp__code-review-graph__query_graph_tool` 查 `GetAssetForRPC` 的所有调用方: - `pattern=callers_of target=GetAssetForRPC` - 确认 social service 现有调用点不会因新字段破坏(向后兼容,新字段为零值即可) - [ ] **Step 4: 编译验证** ```bash cd backend go build ./... ``` Expected: 编译通过(asset service 端还没填新字段,但新字段为零值不影响运行)。 - [ ] **Step 5: Commit** ```bash git add backend/proto/asset.proto backend/pkg/proto/asset/ git commit -m "feat(proto): add name and cover_url to GetAssetForRPCResponse" ``` --- ### Task 3: asset provider 实现填充新字段 **Files:** - Modify: `backend/services/assetService/provider/asset_provider.go`(`GetAssetForRPC` 方法) - [ ] **Step 1: 定位 GetAssetForRPC 实现** ```bash grep -n "GetAssetForRPC" backend/services/assetService/provider/asset_provider.go ``` - [ ] **Step 2: 在 DB 查询中补充 name/cover_url** 定位到 `GetAssetForRPC` 的 SQL 查询处(一般在 `asset_repository.go` 调用处),补充 `name` 和 `cover_url` 字段到 SELECT。 参考实现位置(基于现有 `asset_repository.go` line 222 已有 `name`/`cover_url` 字段): ```go resp := &assetPb.GetAssetForRPCResponse{ Base: baseResp, AssetId: asset.ID, OwnerUid: asset.OwnerUID, StarId: asset.StarID, Status: int32(asset.Status), IsActive: asset.Status == 1, Name: asset.Name, // 新增 CoverUrl: asset.CoverURL, // 新增 } ``` - [ ] **Step 3: 编译验证** ```bash cd backend go build ./services/assetService/... ``` Expected: 编译通过。 - [ ] **Step 4: 单元测试(如有 GetAssetForRPC 测试用例)** ```bash cd backend go test ./services/assetService/repository/ -run TestGetAssetForRPC -v ``` Expected: 既有测试通过;如有测试可加 case 验证 `name`/`cover_url` 返回正确。 - [ ] **Step 5: 回归检查** `query_graph pattern=callers_of target=GetAssetForRPC` 再次确认;asset service 启动一次端到端验证: ```bash cd backend ./start.sh assetService # 或部署后健康检查 curl http://localhost:20003/health ``` - [ ] **Step 6: Commit** ```bash git add backend/services/assetService/ git commit -m "feat(assetService): populate name and cover_url in GetAssetForRPC" ``` --- ### Task 4: 创建 notification.proto **Files:** - Create: `backend/proto/notification.proto` - [ ] **Step 1: 写 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) { 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) { 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 且按 target_id 聚合时返回 bool aggregated = 11; int32 total_count = 12; repeated ActorPreview actors = 13; int64 target_id = 14; } 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 MarkAsReadByTargetRequest { int64 target_id = 1; } message MarkAsReadByTargetResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; } 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 DeleteByTargetRequest { int64 target_id = 1; } message DeleteByTargetResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; } ``` - [ ] **Step 2: 重新生成 Go 代码** ```bash cd backend protoc --go_out=. --go-grpc_out=. proto/notification.proto # 或 make proto ``` Expected: `pkg/proto/notification/notification.pb.go` 生成成功。 - [ ] **Step 3: 编译验证** ```bash cd backend go build ./pkg/proto/notification/... ``` Expected: 编译通过。 - [ ] **Step 4: Commit** ```bash git add backend/proto/notification.proto backend/pkg/proto/notification/ git commit -m "feat(proto): add notification service proto definition" ``` --- ## 阶段 3:Notification Service(Go 端) ### Task 5: 创建 notification service 配置文件 **Files:** - Create: `backend/services/notificationService/configs/config.yaml` - [ ] **Step 1: 复制 socialService 的 config.yaml 模板** ```bash cp backend/services/socialService/configs/config.yaml \ backend/services/notificationService/configs/config.yaml ``` - [ ] **Step 2: 修改服务名、端口、数据库名** 参考 `socialService/configs/config.yaml` 已有结构,修改: - `service.name: notification-service` - `service.port: 20010` - `database.dbname: top-fans`(共享数据库) - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/configs/ git commit -m "feat(notificationService): add service config" ``` --- ### Task 6: 创建 notification data model **Files:** - Create: `backend/services/notificationService/model/notification.go` - [ ] **Step 1: 写 model** ```go package model import "time" // Notification 通知数据模型 type Notification struct { ID int64 `json:"id" gorm:"primaryKey;column:id"` UserID int64 `json:"user_id" gorm:"column:user_id;not null;index"` StarID int64 `json:"star_id" gorm:"column:star_id;not null"` Type string `json:"type" gorm:"column:type;not null;size:20"` Title string `json:"title" gorm:"column:title;not null;size:200"` Content string `json:"content" gorm:"column:content;size:500"` Data string `json:"data" gorm:"column:data;type:jsonb"` // JSONB stored as string IsRead bool `json:"is_read" gorm:"column:is_read;not null;default:false"` IsDeleted bool `json:"is_deleted" gorm:"column:is_deleted;not null;default:false"` CreatedAt int64 `json:"created_at" gorm:"column:created_at;not null"` ReadAt int64 `json:"read_at" gorm:"column:read_at"` } // TableName 表名 func (Notification) TableName() string { return "public.notifications" } // NotificationStats 通知统计模型 type NotificationStats struct { UserID int64 `json:"user_id" gorm:"primaryKey;column:user_id"` StarID int64 `json:"star_id" gorm:"primaryKey;column:star_id"` LikeUnreadCount int `json:"like_unread_count" gorm:"column:like_unread_count;not null;default:0"` SystemUnreadCount int `json:"system_unread_count" gorm:"column:system_unread_count;not null;default:0"` ActivityUnreadCount int `json:"activity_unread_count" gorm:"column:activity_unread_count;not null;default:0"` TotalUnreadCount int `json:"total_unread_count" gorm:"column:total_unread_count;not null;default:0"` UpdatedAt int64 `json:"updated_at" gorm:"column:updated_at;not null"` } // TableName 表名 func (NotificationStats) TableName() string { return "public.notification_stats" } // ActorPreview 列表层聚合时的 actor 预览 type ActorPreview struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` LikedAt int64 `json:"liked_at"` } // AggregatedNotification 聚合查询结果(type=like 时返回) type AggregatedNotification struct { ID int64 `json:"id"` // 原始第一条 id UserID int64 `json:"user_id"` StarID int64 `json:"star_id"` Type string `json:"type"` Title string `json:"title"` Content string `json:"content"` TargetID int64 `json:"target_id"` IsRead bool `json:"is_read"` CreatedAt int64 `json:"created_at"` ReadAt int64 `json:"read_at"` TotalCount int32 `json:"total_count"` Actors []ActorPreview `json:"actors"` Data string `json:"data"` } ``` - [ ] **Step 2: Commit** ```bash git add backend/services/notificationService/model/ git commit -m "feat(notificationService): add data models" ``` --- ### Task 7: 创建 notification repository(仓储层) **Files:** - Create: `backend/services/notificationService/repository/notification_repository.go` - [ ] **Step 1: 写仓储实现** ```go package repository import ( "context" "database/sql" "fmt" "time" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/services/notificationService/model" "go.uber.org/zap" ) type NotificationRepository struct { db *database.DB } func NewNotificationRepository(db *database.DB) *NotificationRepository { return &NotificationRepository{db: db} } // Create 插入通知(事务内调用) func (r *NotificationRepository) Create(ctx context.Context, tx *sql.Tx, n *model.Notification) (int64, error) { var id int64 err := tx.QueryRowContext(ctx, ` INSERT INTO public.notifications (user_id, star_id, type, title, content, data, is_read, is_deleted, created_at, read_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id `, n.UserID, n.StarID, n.Type, n.Title, n.Content, n.Data, n.IsRead, n.IsDeleted, n.CreatedAt, n.ReadAt).Scan(&id) if err != nil { logger.Logger.Error("failed to insert notification", zap.Error(err)) return 0, fmt.Errorf("insert notification: %w", err) } return id, nil } // ListSystemActivity 列出 system / activity 通知(不聚合) func (r *NotificationRepository) ListSystemActivity(ctx context.Context, userID, starID int64, ntype, tab string, page, pageSize int) ([]*model.Notification, int64, error) { args := []interface{}{userID, starID, ntype} where := "user_id = $1 AND star_id = $2 AND type = $3 AND is_deleted = FALSE" if tab == "today" { startOfDay := startOfTodayMs() where += " AND created_at >= $4" args = append(args, startOfDay) } else if tab == "history" { startOfDay := startOfTodayMs() where += " AND created_at < $4" args = append(args, startOfDay) } // count var total int64 if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM public.notifications WHERE "+where, args...).Scan(&total); err != nil { return nil, 0, err } // list offset := (page - 1) * pageSize args = append(args, pageSize, offset) limitIdx := len(args) - 1 offsetIdx := len(args) query := fmt.Sprintf(` SELECT id, user_id, star_id, type, title, COALESCE(content,''), data, is_read, is_deleted, created_at, COALESCE(read_at, 0) FROM public.notifications WHERE %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d `, where, limitIdx, offsetIdx) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]*model.Notification, 0, pageSize) for rows.Next() { n := &model.Notification{} if err := rows.Scan(&n.ID, &n.UserID, &n.StarID, &n.Type, &n.Title, &n.Content, &n.Data, &n.IsRead, &n.IsDeleted, &n.CreatedAt, &n.ReadAt); err != nil { return nil, 0, err } items = append(items, n) } return items, total, nil } // ListLikesAggregated 列出 like 通知(按 target_id 聚合) func (r *NotificationRepository) ListLikesAggregated(ctx context.Context, userID, starID int64, tab string, page, pageSize int) ([]*model.AggregatedNotification, int64, error) { extra := "" args := []interface{}{userID, starID} if tab == "today" { extra = " AND MAX(created_at) >= $3" args = append(args, startOfTodayMs()) } else if tab == "history" { extra = " AND MAX(created_at) < $3" args = append(args, startOfTodayMs()) } // count: distinct target_id 数 var total int64 countQuery := fmt.Sprintf(` SELECT COUNT(*) FROM ( SELECT (data->>'target_id')::bigint AS target_id FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND is_deleted=FALSE GROUP BY target_id ) t `) if err := r.db.QueryRowContext(ctx, countQuery, userID, starID).Scan(&total); err != nil { return nil, 0, err } // 聚合查询:返回 target_id、total_count、最新时间、是否全部已读、actors 列表(前3) // 使用 json_agg + 子查询分两步 offset := (page - 1) * pageSize args = append(args, pageSize, offset) limitIdx, offsetIdx := len(args)-1, len(args) query := fmt.Sprintf(` WITH agg AS ( SELECT (data->>'target_id')::bigint AS target_id, COUNT(*) AS total_count, MAX(created_at) AS latest_at, BOOL_AND(is_read) AS all_read FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND is_deleted=FALSE GROUP BY (data->>'target_id') ), first_notif AS ( SELECT DISTINCT ON ((data->>'target_id')::bigint) (data->>'target_id')::bigint AS target_id, id, title, content, data, read_at FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND is_deleted=FALSE ORDER BY (data->>'target_id')::bigint, created_at DESC ), actors AS ( SELECT (data->>'target_id')::bigint AS target_id, json_agg(json_build_object( 'user_id', (data->>'actor_id')::bigint, 'nickname', COALESCE(data->>'actor_name', ''), 'avatar', COALESCE(data->>'actor_avatar', ''), 'liked_at', created_at ) ORDER BY created_at DESC) AS actor_previews FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND is_deleted=FALSE GROUP BY (data->>'target_id') ) SELECT a.target_id, a.total_count, a.latest_at, a.all_read, f.id, f.title, f.content, f.data, f.read_at, COALESCE(act.actor_previews, '[]'::json) AS actor_previews FROM agg a JOIN first_notif f ON f.target_id = a.target_id LEFT JOIN actors act ON act.target_id = a.target_id ORDER BY a.latest_at DESC LIMIT $%d OFFSET $%d `, limitIdx, offsetIdx) if extra != "" { // tab 时间条件插在 WHERE 之外(已经在 CTE 内过滤 MAX) } rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]*model.AggregatedNotification, 0, pageSize) for rows.Next() { var item model.AggregatedNotification var actorLikesJSON []byte if err := rows.Scan( &item.TargetID, &item.TotalCount, &item.CreatedAt, &item.IsRead, &item.ID, &item.Title, &item.Content, &item.Data, &item.ReadAt, &actorLikesJSON, ); err != nil { return nil, 0, err } // actor_likes 暂存于 Data 字段,由 service 层做 user 信息补全 item.UserID = userID item.StarID = starID item.Type = "like" item.Actors = parseActorLikes(actorLikesJSON) // 简易解析 items = append(items, &item) } return items, total, nil } // 注意:actor 昵称/头像已在 social service 写入通知时塞入 data 字段 // (data->>'actor_name' / data->>'actor_avatar'),聚合查询直接从 data 取 // 无需 notification service 再调 user service(避免重复 RPC)。 // MarkAsReadByID 标单条已读 func (r *NotificationRepository) MarkAsReadByID(ctx context.Context, tx *sql.Tx, userID, starID, id int64, now int64) (int32, error) { res, err := tx.ExecContext(ctx, ` UPDATE public.notifications SET is_read = TRUE, read_at = $4 WHERE id = $1 AND user_id = $2 AND star_id = $3 AND is_read = FALSE AND is_deleted = FALSE `, id, userID, starID, now) if err != nil { return 0, err } affected, _ := res.RowsAffected() return int32(affected), nil } // MarkAsReadByTarget 标某 target 下所有未读 like 已读 func (r *NotificationRepository) MarkAsReadByTarget(ctx context.Context, tx *sql.Tx, userID, starID, targetID, now int64) (int32, error) { res, err := tx.ExecContext(ctx, ` UPDATE public.notifications SET is_read = TRUE, read_at = $5 WHERE user_id=$1 AND star_id=$2 AND type='like' AND (data->>'target_id')::bigint = $3 AND is_read = FALSE AND is_deleted = FALSE `, userID, starID, targetID, now) if err != nil { return 0, err } affected, _ := res.RowsAffected() return int32(affected), nil } // MarkAllAsRead 标某 type 下所有未读已读 func (r *NotificationRepository) MarkAllAsRead(ctx context.Context, tx *sql.Tx, userID, starID int64, ntype, now int64) (int32, error) { if ntype == "" { // 全部类型 } res, err := tx.ExecContext(ctx, ` UPDATE public.notifications SET is_read = TRUE, read_at = $4 WHERE user_id=$1 AND star_id=$2 AND type=$3 AND is_read=FALSE AND is_deleted=FALSE `, userID, starID, ntype, now) if err != nil { return 0, err } affected, _ := res.RowsAffected() return int32(affected), nil } // SoftDeleteByID 软删单条 func (r *NotificationRepository) SoftDeleteByID(ctx context.Context, tx *sql.Tx, userID, starID, id int64) (int32, error) { res, err := tx.ExecContext(ctx, ` UPDATE public.notifications SET is_deleted = TRUE WHERE id = $1 AND user_id = $2 AND star_id = $3 AND is_deleted = FALSE `, id, userID, starID) if err != nil { return 0, err } affected, _ := res.RowsAffected() return int32(affected), nil } // SoftDeleteByTarget 软删某 target 下所有通知 func (r *NotificationRepository) SoftDeleteByTarget(ctx context.Context, tx *sql.Tx, userID, starID, targetID int64) (int32, error) { res, err := tx.ExecContext(ctx, ` UPDATE public.notifications SET is_deleted = TRUE WHERE user_id=$1 AND star_id=$2 AND type='like' AND (data->>'target_id')::bigint = $3 AND is_deleted = FALSE `, userID, starID, targetID) if err != nil { return 0, err } affected, _ := res.RowsAffected() return int32(affected), nil } // CountUnreadByID 数某条通知是否属于该 user 且未读 func (r *NotificationRepository) GetTypeByID(ctx context.Context, tx *sql.Tx, id, userID, starID int64) (string, bool, error) { var ntype string var isRead bool err := tx.QueryRowContext(ctx, ` SELECT type, is_read FROM public.notifications WHERE id=$1 AND user_id=$2 AND star_id=$3 AND is_deleted=FALSE `, id, userID, starID).Scan(&ntype, &isRead) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, err } return ntype, isRead, nil } // startOfTodayMs 今日 0 点毫秒时间戳 func startOfTodayMs() int64 { now := time.Now() return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli() } // parseActorLikes 解析 JSON 数组(user_id + nickname + avatar + liked_at) // 数据来源是 social service 写入时塞入 data 字段的 actor_name/actor_avatar func parseActorLikes(data []byte) []model.ActorPreview { type rawActor struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` LikedAt int64 `json:"liked_at"` } var raws []rawActor if err := json.Unmarshal(data, &raws); err != nil { return nil } out := make([]model.ActorPreview, 0, len(raws)) for _, r := range raws { out = append(out, model.ActorPreview{ UserID: r.UserID, Nickname: r.Nickname, Avatar: r.Avatar, LikedAt: r.LikedAt, }) } return out } ``` - [ ] **Step 2: 添加 import "encoding/json"** 在 `import` 块加 `"encoding/json"`。 - [ ] **Step 3: 编译验证** ```bash cd backend go build ./services/notificationService/... ``` Expected: 编译通过。 - [ ] **Step 4: Commit** ```bash git add backend/services/notificationService/repository/notification_repository.go git commit -m "feat(notificationService): add notification repository" ``` --- ### Task 8: 创建 notification stats repository **Files:** - Create: `backend/services/notificationService/repository/notification_stats_repository.go` - [ ] **Step 1: 写统计仓储** ```go package repository import ( "context" "database/sql" "fmt" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/services/notificationService/model" "go.uber.org/zap" ) type NotificationStatsRepository struct { db *database.DB } func NewNotificationStatsRepository(db *database.DB) *NotificationStatsRepository { return &NotificationStatsRepository{db: db} } // IncrementByType 在事务内对指定 type 未读数 +1,total +1 func (r *NotificationStatsRepository) IncrementByType(ctx context.Context, tx *sql.Tx, userID, starID int64, ntype string, now int64) error { var col string switch ntype { case "like": col = "like_unread_count" case "system": col = "system_unread_count" case "activity": col = "activity_unread_count" default: return fmt.Errorf("invalid notification type: %s", ntype) } query := fmt.Sprintf(` INSERT INTO public.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 = public.notification_stats.%s + 1, total_unread_count = public.notification_stats.total_unread_count + 1, updated_at = $3 `, col, col, col) _, err := tx.ExecContext(ctx, query, userID, starID, now) if err != nil { logger.Logger.Error("failed to increment notification stats", zap.Error(err)) return err } return nil } // DecrementByType 在事务内对指定 type 未读数 -N,total -N func (r *NotificationStatsRepository) DecrementByType(ctx context.Context, tx *sql.Tx, userID, starID int64, ntype string, delta int, now int64) error { if delta <= 0 { return nil } var col string switch ntype { case "like": col = "like_unread_count" case "system": col = "system_unread_count" case "activity": col = "activity_unread_count" default: return fmt.Errorf("invalid notification type: %s", ntype) } query := fmt.Sprintf(` UPDATE public.notification_stats SET %s = GREATEST(0, %s - $3), total_unread_count = GREATEST(0, total_unread_count - $3), updated_at = $4 WHERE user_id = $1 AND star_id = $2 `, col, col) _, err := tx.ExecContext(ctx, query, userID, starID, delta, now) if err != nil { logger.Logger.Error("failed to decrement notification stats", zap.Error(err)) return err } return nil } // ResetByType 把指定 type 未读数置 0 func (r *NotificationStatsRepository) ResetByType(ctx context.Context, tx *sql.Tx, userID, starID int64, ntype string, now int64) error { var col string switch ntype { case "like": col = "like_unread_count" case "system": col = "system_unread_count" case "activity": col = "activity_unread_count" case "": // 全部置 0 _, err := tx.ExecContext(ctx, ` UPDATE public.notification_stats SET like_unread_count = 0, system_unread_count = 0, activity_unread_count = 0, total_unread_count = 0, updated_at = $3 WHERE user_id = $1 AND star_id = $2 `, userID, starID, now) return err default: return fmt.Errorf("invalid notification type: %s", ntype) } query := fmt.Sprintf(` UPDATE public.notification_stats SET %s = 0, total_unread_count = GREATEST(0, total_unread_count - %s), updated_at = $3 WHERE user_id = $1 AND star_id = $2 `, col, col) _, err := tx.ExecContext(ctx, query, userID, starID, now) return err } // Get 取该 user+star 的统计 func (r *NotificationStatsRepository) Get(ctx context.Context, userID, starID int64) (*model.NotificationStats, error) { s := &model.NotificationStats{} err := r.db.QueryRowContext(ctx, ` SELECT user_id, star_id, like_unread_count, system_unread_count, activity_unread_count, total_unread_count, updated_at FROM public.notification_stats WHERE user_id = $1 AND star_id = $2 `, userID, starID).Scan(&s.UserID, &s.StarID, &s.LikeUnreadCount, &s.SystemUnreadCount, &s.ActivityUnreadCount, &s.TotalUnreadCount, &s.UpdatedAt) if err == sql.ErrNoRows { return &model.NotificationStats{UserID: userID, StarID: starID}, nil } if err != nil { return nil, err } return s, nil } ``` - [ ] **Step 2: 编译验证** ```bash cd backend go build ./services/notificationService/... ``` Expected: 编译通过。 - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/repository/notification_stats_repository.go git commit -m "feat(notificationService): add notification stats repository" ``` --- ### Task 9: 写 notification repository 单元测试 **Files:** - Create: `backend/services/notificationService/repository/notification_repository_test.go` - [ ] **Step 1: 写测试** ```go package repository_test import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/services/notificationService/model" "github.com/topfans/backend/services/notificationService/repository" ) // 依赖真实测试 DB;环境变量 TEST_DB_HOST 等 func setupTestDB(t *testing.T) *database.DB { t.Helper() db, err := database.NewFromEnv() if err != nil { t.Skipf("skipping: test DB not available: %v", err) } return db } func TestNotificationRepository_CreateAndList(t *testing.T) { db := setupTestDB(t) repo := repository.NewNotificationRepository(db) statsRepo := repository.NewNotificationStatsRepository(db) ctx := context.Background() userID, starID := int64(990001), int64(1) cleanup := func() { db.ExecContext(ctx, `DELETE FROM public.notifications WHERE user_id=$1 AND star_id=$2`, userID, starID) db.ExecContext(ctx, `DELETE FROM public.notification_stats WHERE user_id=$1 AND star_id=$2`, userID, starID) } cleanup() defer cleanup() now := time.Now().UnixMilli() tx, _ := db.BeginTx(ctx, nil) id, err := repo.Create(ctx, tx, &model.Notification{ UserID: userID, StarID: starID, Type: "like", Title: "新点赞", Content: "test", Data: `{}`, CreatedAt: now, }) assert.NoError(t, err) assert.Greater(t, id, int64(0)) err = statsRepo.IncrementByType(ctx, tx, userID, starID, "like", now) assert.NoError(t, err) tx.Commit() // list system/activity items, total, err := repo.ListSystemActivity(ctx, userID, starID, "like", "", 1, 20) assert.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, items, 1) assert.Equal(t, id, items[0].ID) } func TestNotificationRepository_LikeAggregation(t *testing.T) { db := setupTestDB(t) repo := repository.NewNotificationRepository(db) ctx := context.Background() userID, starID, targetID := int64(990002), int64(1), int64(8888) cleanup := func() { db.ExecContext(ctx, `DELETE FROM public.notifications WHERE user_id=$1 AND star_id=$2`, userID, starID) } cleanup() defer cleanup() now := time.Now().UnixMilli() for i := 0; i < 5; i++ { tx, _ := db.BeginTx(ctx, nil) _, err := repo.Create(ctx, tx, &model.Notification{ UserID: userID, StarID: starID, Type: "like", Title: "新点赞", Data: `{"target_id": 8888, "actor_id": 1000}`, CreatedAt: now + int64(i)*1000, }) assert.NoError(t, err) tx.Commit() } items, total, err := repo.ListLikesAggregated(ctx, userID, starID, "", 1, 20) assert.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, items, 1) assert.Equal(t, int32(5), items[0].TotalCount) assert.Equal(t, targetID, items[0].TargetID) } func TestNotificationRepository_MarkAsReadByTarget(t *testing.T) { db := setupTestDB(t) repo := repository.NewNotificationRepository(db) statsRepo := repository.NewNotificationStatsRepository(db) ctx := context.Background() userID, starID, targetID := int64(990003), int64(1), int64(7777) cleanup := func() { db.ExecContext(ctx, `DELETE FROM public.notifications WHERE user_id=$1 AND star_id=$2`, userID, starID) db.ExecContext(ctx, `DELETE FROM public.notification_stats WHERE user_id=$1 AND star_id=$2`, userID, starID) } cleanup() defer cleanup() now := time.Now().UnixMilli() for i := 0; i < 3; i++ { tx, _ := db.BeginTx(ctx, nil) _, _ = repo.Create(ctx, tx, &model.Notification{ UserID: userID, StarID: starID, Type: "like", Title: "x", Data: `{"target_id": 7777, "actor_id": 1}`, CreatedAt: now + int64(i)*100, }) _ = statsRepo.IncrementByType(ctx, tx, userID, starID, "like", now) tx.Commit() } tx, _ := db.BeginTx(ctx, nil) affected, err := repo.MarkAsReadByTarget(ctx, tx, userID, starID, targetID, now) _ = statsRepo.DecrementByType(ctx, tx, userID, starID, "like", int(affected), now) tx.Commit() assert.NoError(t, err) assert.Equal(t, int32(3), affected) } ``` - [ ] **Step 2: 运行测试** ```bash cd backend go test ./services/notificationService/repository/ -v -run TestNotificationRepository ``` Expected: 全部 PASS(前提是测试 DB 可用,否则 skip)。 - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/repository/notification_repository_test.go git commit -m "test(notificationService): add notification repository tests" ``` --- ### Task 10: 创建 notification service 业务层 **Files:** - Create: `backend/services/notificationService/service/notification_service.go` - [ ] **Step 1: 写业务层** ```go package service import ( "context" "encoding/json" "fmt" "time" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" notifPb "github.com/topfans/backend/pkg/proto/notification" pbCommon "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/services/notificationService/model" "github.com/topfans/backend/services/notificationService/repository" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/structpb" ) type NotificationService struct { db *database.DB notifRepo *repository.NotificationRepository statsRepo *repository.NotificationStatsRepository } func NewNotificationService(db *database.DB) *NotificationService { return &NotificationService{ db: db, notifRepo: repository.NewNotificationRepository(db), statsRepo: repository.NewNotificationStatsRepository(db), } } // CreateNotification 创建通知(事务:INSERT + UPSERT stats) func (s *NotificationService) CreateNotification(ctx context.Context, req *notifPb.CreateNotificationRequest) (*notifPb.CreateNotificationResponse, error) { // 参数校验 if req.UserId <= 0 || req.StarId <= 0 { return errResp(codes.InvalidArgument, "user_id and star_id required"), nil } if req.Type == "" || req.Title == "" { return errResp(codes.InvalidArgument, "type and title required"), nil } if len(req.Title) > 200 || len(req.Content) > 500 { return errResp(codes.InvalidArgument, "title <= 200, content <= 500"), nil } if req.Type != "like" && req.Type != "system" && req.Type != "activity" { return errResp(codes.InvalidArgument, "type must be like/system/activity"), nil } // 序列化 data dataStr := "{}" if req.Data != nil { b, err := json.Marshal(req.Data.AsMap()) if err != nil { return errResp(codes.InvalidArgument, "data marshal failed"), nil } dataStr = string(b) } now := time.Now().UnixMilli() n := &model.Notification{ UserID: req.UserId, StarID: req.StarId, Type: req.Type, Title: req.Title, Content: req.Content, Data: dataStr, CreatedAt: now, } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return errResp(codes.Internal, "begin tx failed"), nil } defer tx.Rollback() id, err := s.notifRepo.Create(ctx, tx, n) if err != nil { return errResp(codes.Internal, "create failed"), nil } if err := s.statsRepo.IncrementByType(ctx, tx, req.UserId, req.StarId, req.Type, now); err != nil { return errResp(codes.Internal, "stats update failed"), nil } if err := tx.Commit(); err != nil { return errResp(codes.Internal, "commit failed"), nil } logger.Logger.Info("notification created", zap.Int64("id", id), zap.Int64("user_id", req.UserId), zap.Int64("star_id", req.StarId), zap.String("type", req.Type)) return ¬ifPb.CreateNotificationResponse{ Base: okBase(), Id: id, }, nil } // GetNotifications 列表查询 func (s *NotificationService) GetNotifications(ctx context.Context, userID, starID int64, ntype, tab string, page, pageSize int32) (*notifPb.GetNotificationsResponse, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } var items []*notifPb.Notification var total int64 var err error if ntype == "like" { aggItems, aggTotal, e := s.notifRepo.ListLikesAggregated(ctx, userID, starID, tab, int(page), int(pageSize)) if e != nil { return errResp(codes.Internal, "list likes failed"), nil } total = aggTotal for _, a := range aggItems { // 生成聚合标题:"{actor_names} 等 N 人赞了你的《{asset_title}》" // asset_title 在 data 字段里(social service 写入时已塞入) assetTitle := extractAssetTitle(a.Data) a.Title = buildAggregatedLikeTitle(a.Actors, a.TotalCount, assetTitle) items = append(items, aggToProto(a)) } } else { // system / activity / 空 = 全部 if ntype == "" { // 一次查 system 和 activity(合并不优雅,先支持 type 非空场景;空 type 暂不实现,留待 V1.1) return errResp(codes.InvalidArgument, "type is required (like/system/activity)"), nil } rows, t, e := s.notifRepo.ListSystemActivity(ctx, userID, starID, ntype, tab, int(page), int(pageSize)) if e != nil { return errResp(codes.Internal, "list failed"), nil } total = t for _, r := range rows { items = append(items, rawToProto(r)) } } return ¬ifPb.GetNotificationsResponse{ Base: okBase(), Items: items, Total: total, Page: page, PageSize: pageSize, }, nil } // GetUnreadCount func (s *NotificationService) GetUnreadCount(ctx context.Context, userID, starID int64) (*notifPb.GetUnreadCountResponse, error) { s2, err := s.statsRepo.Get(ctx, userID, starID) if err != nil { return errResp(codes.Internal, "get stats failed"), nil } return ¬ifPb.GetUnreadCountResponse{ Base: okBase(), Counts: ¬ifPb.UnreadCount{ Like: int32(s2.LikeUnreadCount), System: int32(s2.SystemUnreadCount), Activity: int32(s2.ActivityUnreadCount), Total: int32(s2.TotalUnreadCount), }, }, nil } // MarkAsRead 标单条已读(自动判断 type,走对应 stats) func (s *NotificationService) MarkAsRead(ctx context.Context, userID, starID, id, now int64) (*notifPb.MarkAsReadResponse, error) { tx, _ := s.db.BeginTx(ctx, nil) defer tx.Rollback() ntype, isRead, err := s.notifRepo.GetTypeByID(ctx, tx, id, userID, starID) if err != nil { return errResp(codes.Internal, "lookup failed"), nil } if ntype == "" { return errResp(codes.NotFound, "notification not found"), nil } if isRead { return ¬ifPb.MarkAsReadResponse{Base: okBase()}, nil } affected, err := s.notifRepo.MarkAsReadByID(ctx, tx, userID, starID, id, now) if err != nil { return errResp(codes.Internal, "mark failed"), nil } if affected > 0 { _ = s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, int(affected), now) } tx.Commit() return ¬ifPb.MarkAsReadResponse{Base: okBase()}, nil } // MarkAsReadByTarget 标某 target 下所有未读 like 已读 func (s *NotificationService) MarkAsReadByTarget(ctx context.Context, userID, starID, targetID, now int64) (*notifPb.MarkAsReadByTargetResponse, error) { tx, _ := s.db.BeginTx(ctx, nil) defer tx.Rollback() affected, err := s.notifRepo.MarkAsReadByTarget(ctx, tx, userID, starID, targetID, now) if err != nil { return errResp(codes.Internal, "mark failed"), nil } if affected > 0 { _ = s.statsRepo.DecrementByType(ctx, tx, userID, starID, "like", int(affected), now) } tx.Commit() return ¬ifPb.MarkAsReadByTargetResponse{Base: okBase(), Affected: affected}, nil } // MarkAllAsRead func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID, starID int64, ntype string, now int64) (*notifPb.MarkAllAsReadResponse, error) { tx, _ := s.db.BeginTx(ctx, nil) defer tx.Rollback() affected, err := s.notifRepo.MarkAllAsRead(ctx, tx, userID, starID, ntype, now) if err != nil { return errResp(codes.Internal, "mark all failed"), nil } if affected > 0 && ntype != "" { _ = s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, int(affected), now) } else if ntype == "" { _ = s.statsRepo.ResetByType(ctx, tx, userID, starID, "", now) } tx.Commit() return ¬ifPb.MarkAllAsReadResponse{Base: okBase(), Affected: affected}, nil } // DeleteNotification 软删单条 func (s *NotificationService) DeleteNotification(ctx context.Context, userID, starID, id, now int64) (*notifPb.DeleteNotificationResponse, error) { tx, _ := s.db.BeginTx(ctx, nil) defer tx.Rollback() ntype, isRead, err := s.notifRepo.GetTypeByID(ctx, tx, id, userID, starID) if err != nil { return errResp(codes.Internal, "lookup failed"), nil } if ntype == "" { return errResp(codes.NotFound, "notification not found"), nil } affected, err := s.notifRepo.SoftDeleteByID(ctx, tx, userID, starID, id) if err != nil { return errResp(codes.Internal, "delete failed"), nil } if affected > 0 && !isRead { _ = s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, int(affected), now) } tx.Commit() return ¬ifPb.DeleteNotificationResponse{Base: okBase()}, nil } // DeleteByTarget 软删某 target 下所有 like func (s *NotificationService) DeleteByTarget(ctx context.Context, userID, starID, targetID, now int64) (*notifPb.DeleteByTargetResponse, error) { tx, _ := s.db.BeginTx(ctx, nil) defer tx.Rollback() // 先查未读数 var unreadCount int err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND (data->>'target_id')::bigint = $3 AND is_read=FALSE AND is_deleted=FALSE `, userID, starID, targetID).Scan(&unreadCount) if err != nil { return errResp(codes.Internal, "count failed"), nil } affected, err := s.notifRepo.SoftDeleteByTarget(ctx, tx, userID, starID, targetID) if err != nil { return errResp(codes.Internal, "delete failed"), nil } if unreadCount > 0 { _ = s.statsRepo.DecrementByType(ctx, tx, userID, starID, "like", unreadCount, now) } tx.Commit() return ¬ifPb.DeleteByTargetResponse{Base: okBase(), Affected: affected}, nil } // ===== helpers ===== func okBase() *pbCommon.BaseResponse { return &pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "success", Timestamp: time.Now().UnixMilli()} } func errResp(c codes.Code, msg string) *notifPb.CreateNotificationResponse { return ¬ifPb.CreateNotificationResponse{Base: &pbCommon.BaseResponse{Code: uint32(c), Message: msg, Timestamp: time.Now().UnixMilli()}} } func aggToProto(a *model.AggregatedNotification) *notifPb.Notification { // 把 Data JSON 字符串反序列化为 Struct dataStruct, _ := structpb.NewStruct(nil) if a.Data != "" { var m map[string]interface{} if err := json.Unmarshal([]byte(a.Data), &m); err == nil { dataStruct, _ = structpb.NewStruct(m) } } actors := make([]*notifPb.ActorPreview, 0, len(a.Actors)) for _, x := range a.Actors { actors = append(actors, ¬ifPb.ActorPreview{ UserId: x.UserID, Nickname: x.Nickname, Avatar: x.Avatar, LikedAt: x.LikedAt, }) } return ¬ifPb.Notification{ Id: a.ID, UserId: a.UserID, StarId: a.StarID, Type: a.Type, Title: a.Title, Content: a.Content, Data: dataStruct, IsRead: a.IsRead, CreatedAt: a.CreatedAt, ReadAt: a.ReadAt, Aggregated: true, TotalCount: a.TotalCount, Actors: actors, TargetId: a.TargetID, } } func rawToProto(n *model.Notification) *notifPb.Notification { dataStruct, _ := structpb.NewStruct(nil) if n.Data != "" { var m map[string]interface{} if err := json.Unmarshal([]byte(n.Data), &m); err == nil { dataStruct, _ = structpb.NewStruct(m) } } return ¬ifPb.Notification{ Id: n.ID, UserId: n.UserID, StarId: n.StarID, Type: n.Type, Title: n.Title, Content: n.Content, Data: dataStruct, IsRead: n.IsRead, CreatedAt: n.CreatedAt, ReadAt: n.ReadAt, } } // extractAssetTitle 从 data JSON 字符串中取 asset_title func extractAssetTitle(dataStr string) string { if dataStr == "" { return "" } var m map[string]interface{} if err := json.Unmarshal([]byte(dataStr), &m); err != nil { return "" } if v, ok := m["asset_title"].(string); ok { return v } return "" } // buildAggregatedLikeTitle 构造 like 聚合标题 // 1 个 actor: "{name} 赞了你的《{title}》" // 2 个 actor: "{name1}、{name2} 赞了你的《{title}》" // 3+ 个 actor: "{name1}、{name2} 等 N 人赞了你的《{title}》" func buildAggregatedLikeTitle(actors []model.ActorPreview, total int32, assetTitle string) string { names := make([]string, 0, 3) for i, a := range actors { if i >= 3 { break } name := a.Nickname if name == "" { name = fmt.Sprintf("用户%d", a.UserID) } names = append(names, name) } title := assetTitle if title == "" { title = "你的藏品" } switch len(names) { case 0: return fmt.Sprintf("有 %d 人赞了你的《%s》", total, title) case 1: return fmt.Sprintf("%s 赞了你的《%s》", names[0], title) case 2: return fmt.Sprintf("%s、%s 赞了你的《%s》", names[0], names[1], title) default: return fmt.Sprintf("%s、%s 等 %d 人赞了你的《%s》", names[0], names[1], total, title) } } ``` - [ ] **Step 2: 编译验证** ```bash cd backend go build ./services/notificationService/... ``` Expected: 编译通过(可能需要 import `"google.golang.org/protobuf/types/known/structpb"`)。 - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/service/notification_service.go git commit -m "feat(notificationService): add notification service business layer" ``` --- ### Task 11: 写 notification service 单元测试 **Files:** - Create: `backend/services/notificationService/service/notification_service_test.go` - [ ] **Step 1: 写测试** ```go package service_test import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/topfans/backend/pkg/database" notifPb "github.com/topfans/backend/pkg/proto/notification" "github.com/topfans/backend/services/notificationService/service" ) func setupTestService(t *testing.T) *service.NotificationService { t.Helper() db, err := database.NewFromEnv() if err != nil { t.Skipf("test DB not available: %v", err) } return service.NewNotificationService(db) } func TestCreateNotification_Validation(t *testing.T) { svc := setupTestService(t) ctx := context.Background() cases := []struct{ name string; req *notifPb.CreateNotificationRequest; expectCode uint32 }{ {"missing user", ¬ifPb.CreateNotificationRequest{StarId: 1, Type: "like", Title: "x"}, 3}, {"missing star", ¬ifPb.CreateNotificationRequest{UserId: 1, Type: "like", Title: "x"}, 3}, {"missing type", ¬ifPb.CreateNotificationRequest{UserId: 1, StarId: 1, Title: "x"}, 3}, {"bad type", ¬ifPb.CreateNotificationRequest{UserId: 1, StarId: 1, Type: "x", Title: "x"}, 3}, {"title too long", ¬ifPb.CreateNotificationRequest{UserId: 1, StarId: 1, Type: "like", Title: string(make([]byte, 201))}, 3}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { resp, _ := svc.CreateNotification(ctx, c.req) assert.Equal(t, c.expectCode, resp.Base.Code) }) } } func TestCreateNotification_TransactionRollback(t *testing.T) { svc := setupTestService(t) ctx := context.Background() userID, starID := int64(990100), int64(1) cleanup := func() { svc.DB().ExecContext(ctx, `DELETE FROM public.notifications WHERE user_id=$1`, userID) svc.DB().ExecContext(ctx, `DELETE FROM public.notification_stats WHERE user_id=$1`, userID) } cleanup() defer cleanup() // 正常创建 req := ¬ifPb.CreateNotificationRequest{ UserId: userID, StarId: starID, Type: "like", Title: "x", Content: "y", } resp, _ := svc.CreateNotification(ctx, req) assert.Equal(t, uint32(0), resp.Base.Code) assert.Greater(t, resp.Id, int64(0)) // 验证 stats 也写入 cntResp, _ := svc.GetUnreadCount(ctx, userID, starID) assert.Equal(t, int32(1), cntResp.Counts.Like) } ``` - [ ] **Step 2: 运行测试** ```bash cd backend go test ./services/notificationService/service/ -v ``` Expected: PASS(前提是测试 DB 可用)。 - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/service/notification_service_test.go git commit -m "test(notificationService): add service unit tests" ``` --- ### Task 12: 创建 notification provider(RPC 层) **Files:** - Create: `backend/services/notificationService/provider/notification_provider.go` - [ ] **Step 1: 写 provider** ```go package provider import ( "context" "fmt" "strconv" "time" commonPb "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/pkg/logger" notifPb "github.com/topfans/backend/pkg/proto/notification" "github.com/topfans/backend/services/notificationService/service" "go.uber.org/zap" "google.golang.org/grpc/metadata" ) type NotificationProvider struct { notifPb.UnimplementedNotificationServiceServer svc *service.NotificationService } func NewNotificationProvider(svc *service.NotificationService) *NotificationProvider { return &NotificationProvider{svc: svc} } func (p *NotificationProvider) CreateNotification(ctx context.Context, req *notifPb.CreateNotificationRequest) (*notifPb.CreateNotificationResponse, error) { return p.svc.CreateNotification(ctx, req) } func (p *NotificationProvider) GetNotifications(ctx context.Context, req *notifPb.GetNotificationsRequest) (*notifPb.GetNotificationsResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.GetNotificationsResponse{Base: errBase(err)}, nil } return p.svc.GetNotifications(ctx, userID, starID, req.Type, req.Tab, req.Page, req.PageSize) } func (p *NotificationProvider) GetUnreadCount(ctx context.Context, req *notifPb.GetUnreadCountRequest) (*notifPb.GetUnreadCountResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.GetUnreadCountResponse{Base: errBase(err)}, nil } return p.svc.GetUnreadCount(ctx, userID, starID) } func (p *NotificationProvider) MarkAsRead(ctx context.Context, req *notifPb.MarkAsReadRequest) (*notifPb.MarkAsReadResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.MarkAsReadResponse{Base: errBase(err)}, nil } now := time.Now().UnixMilli() return p.svc.MarkAsRead(ctx, userID, starID, req.Id, now) } func (p *NotificationProvider) MarkAsReadByTarget(ctx context.Context, req *notifPb.MarkAsReadByTargetRequest) (*notifPb.MarkAsReadByTargetResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.MarkAsReadByTargetResponse{Base: errBase(err)}, nil } now := time.Now().UnixMilli() return p.svc.MarkAsReadByTarget(ctx, userID, starID, req.TargetId, now) } func (p *NotificationProvider) MarkAllAsRead(ctx context.Context, req *notifPb.MarkAllAsReadRequest) (*notifPb.MarkAllAsReadResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.MarkAllAsReadResponse{Base: errBase(err)}, nil } now := time.Now().UnixMilli() return p.svc.MarkAllAsRead(ctx, userID, starID, req.Type, now) } func (p *NotificationProvider) DeleteNotification(ctx context.Context, req *notifPb.DeleteNotificationRequest) (*notifPb.DeleteNotificationResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.DeleteNotificationResponse{Base: errBase(err)}, nil } now := time.Now().UnixMilli() return p.svc.DeleteNotification(ctx, userID, starID, req.Id, now) } func (p *NotificationProvider) DeleteByTarget(ctx context.Context, req *notifPb.DeleteByTargetRequest) (*notifPb.DeleteByTargetResponse, error) { userID, starID, err := extractUserStarFromContext(ctx) if err != nil { return ¬ifPb.DeleteByTargetResponse{Base: errBase(err)}, nil } now := time.Now().UnixMilli() return p.svc.DeleteByTarget(ctx, userID, starID, req.TargetId, now) } // extractUserStarFromContext 从 gRPC metadata 提取 user_id / star_id // gateway 端通过 metadata.NewOutgoingContext 注入 x-user-id / x-star-id // 实际格式参考 social service provider 已有实现 func extractUserStarFromContext(ctx context.Context) (int64, int64, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return 0, 0, fmt.Errorf("no metadata in context") } uidStrs := md.Get("x-user-id") sidStrs := md.Get("x-star-id") if len(uidStrs) == 0 || len(sidStrs) == 0 { return 0, 0, fmt.Errorf("missing user_id or star_id in metadata") } uid, err := strconv.ParseInt(uidStrs[0], 10, 64) if err != nil { return 0, 0, fmt.Errorf("invalid user_id: %w", err) } sid, err := strconv.ParseInt(sidStrs[0], 10, 64) if err != nil { return 0, 0, fmt.Errorf("invalid star_id: %w", err) } return uid, sid, nil } func errBase(err error) *commonPb.BaseResponse { return &commonPb.BaseResponse{Code: 13, Message: err.Error(), Timestamp: time.Now().UnixMilli()} } ``` - [ ] **Step 2: 编译验证** ```bash cd backend go build ./services/notificationService/... ``` Expected: 编译通过。 - [ ] **Step 3: Commit** ```bash git add backend/services/notificationService/provider/ git commit -m "feat(notificationService): add RPC provider" ``` --- ### Task 13: 创建 notification service main.go **Files:** - Create: `backend/services/notificationService/main.go` - [ ] **Step 1: 复制 socialService/main.go 模板** ```bash cp backend/services/socialService/main.go backend/services/notificationService/main.go ``` - [ ] **Step 2: 修改包名、import、注册 service** 调整: - `package main` 保持不变 - import 改为 `notificationService "github.com/topfans/backend/services/notificationService"` - 端口 `20010` - DB 配置与 social 一致 - 服务注册:`srv, _ := server.NewServer(...)` + 注册 `notificationPb.RegisterNotificationServiceServer(srv, notificationProvider)` 具体改完后代码结构与 social 保持一致,注册逻辑参考 socialService main.go line 80-150。 - [ ] **Step 3: 编译验证** ```bash cd backend go build ./services/notificationService/ ``` Expected: 编译通过;生成 `notificationService` 可执行文件。 - [ ] **Step 4: 启动测试(可选,本地环境)** ```bash cd backend DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=xxx DB_NAME=top-fans \ ./bin/notificationService -port 20010 & sleep 2 curl http://localhost:20010/health ``` Expected: 进程启动,health 200。 - [ ] **Step 5: 回归检查** - `query_graph pattern=callers_of target=NotificationService` 确认未影响其他服务 - 既有 social/asset 等服务编译仍通过:`cd backend && go build ./...` - [ ] **Step 6: Commit** ```bash git add backend/services/notificationService/main.go git commit -m "feat(notificationService): add main entry with dubbo bootstrap" ``` --- ## 阶段 4:social service 集成 ### Task 14: 创建 notification client(social 端) **Files:** - Create: `backend/services/socialService/client/notification_client.go` - [ ] **Step 1: 写 client** ```go package client import ( "context" "fmt" "dubbo.apache.org/dubbo-go/v3/client" _ "dubbo.apache.org/dubbo-go/v3/imports" notifPb "github.com/topfans/backend/pkg/proto/notification" "go.uber.org/zap" ) type NotificationClient struct { client notifPb.NotificationService logger *zap.Logger } func NewNotificationClient(serviceURL string, logger *zap.Logger) (*NotificationClient, error) { cli, err := client.NewClient(client.WithClientURL(serviceURL)) if err != nil { return nil, fmt.Errorf("failed to create dubbo client: %w", err) } notifClient, err := notifPb.NewNotificationService(cli) if err != nil { return nil, fmt.Errorf("failed to create notification service client: %w", err) } return &NotificationClient{client: notifClient, logger: logger}, nil } func (c *NotificationClient) CreateNotification(ctx context.Context, req *notifPb.CreateNotificationRequest) (*notifPb.CreateNotificationResponse, error) { resp, err := c.client.CreateNotification(ctx, req) if err != nil { c.logger.Error("Failed to create notification", zap.Error(err)) return nil, err } return resp, nil } ``` - [ ] **Step 2: 编译验证** ```bash cd backend go build ./services/socialService/client/... ``` Expected: 编译通过。 - [ ] **Step 3: Commit** ```bash git add backend/services/socialService/client/notification_client.go git commit -m "feat(socialService): add notification dubbo client" ``` --- ### Task 15: 注入 NotificationClient 到 social service **Files:** - Modify: `backend/services/socialService/main.go` - Modify: `backend/services/socialService/service/asset_like_service.go` - [ ] **Step 1: 在 main.go 加 URL flag 与 client 初始化** 参考 `assetServiceURL` 添加: ```go notificationServiceURL = flag.String("notification-service-url", getEnv("NOTIFICATION_SERVICE_URL", "tri://localhost:20010"), "Notification service URL") ``` 在 main 函数 `NewAssetLikeService` 处加: ```go notifClient, err := client.NewNotificationClient(*notificationServiceURL, logger.Logger) if err != nil { logger.Sugar.Fatalf("Failed to create notification client: %v", err) } ``` 修改 `NewAssetLikeService` 调用,把 `notifClient` 注入: ```go assetLikeService := service.NewAssetLikeService(assetClient, socialRepo, notifClient) ``` - [ ] **Step 2: 修改 `AssetLikeService` 结构体** 在 `backend/services/socialService/service/asset_like_service.go` line 22-26: ```go type AssetLikeService struct { assetClient *client.AssetClient socialRepo repository.SocialRepository notificationClient *client.NotificationClient userClient UserServiceClient // 新增(如尚未注入) } func NewAssetLikeService(assetClient *client.AssetClient, socialRepo repository.SocialRepository, notificationClient *client.NotificationClient) *AssetLikeService { return &AssetLikeService{ assetClient: assetClient, socialRepo: socialRepo, notificationClient: notificationClient, } } ``` - [ ] **Step 3: 修改 LikeAsset 末尾加通知调用** 在 `LikeAsset` 方法 line 132 之前(return 之前)插入: ```go // 创建点赞通知(失败仅日志,不影响点赞主路径) s.createLikeNotification(ctx, getAssetResp, assetID, userID, starID) ``` 在文件末尾添加 `createLikeNotification` 私有方法: ```go func (s *AssetLikeService) createLikeNotification(ctx context.Context, asset *assetPb.GetAssetForRPCResponse, assetID, userID, starID int64) { // 跳过异常数据 if asset.OwnerUid <= 0 { logger.Logger.Warn("skip notification: invalid owner_uid", zap.Int64("asset_id", assetID), zap.Int64("owner_uid", asset.OwnerUid)) return } // 查 actor 信息 actorName := strconv.FormatInt(userID, 10) var actorAvatar string if s.userClient != nil { if actorInfo, err := s.userClient.GetUsersByIDs(ctx, []int64{userID}, starID); err == nil { if info, exists := actorInfo[userID]; exists && info != nil { actorName = info.Nickname actorAvatar = info.Avatar } } else { logger.Logger.Warn("failed to fetch actor info for notification", zap.Int64("user_id", userID), zap.Error(err)) } } // 构造 data data := map[string]interface{}{ "target_type": "asset", "target_id": assetID, "actor_id": userID, "actor_name": actorName, "actor_avatar": actorAvatar, "asset_title": asset.Name, "asset_cover": asset.CoverUrl, "star_id": starID, } dataStruct, err := structpb.NewStruct(data) if err != nil { logger.Logger.Error("failed to build notification data struct", zap.Int64("asset_id", assetID), zap.Error(err)) return } // 同步调 notification service _, notifErr := s.notificationClient.CreateNotification(ctx, ¬ifPb.CreateNotificationRequest{ UserId: asset.OwnerUid, 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", asset.OwnerUid), zap.Error(notifErr), ) return } logger.Logger.Info("like notification created", zap.Int64("asset_id", assetID), zap.Int64("owner_uid", asset.OwnerUid)) } ``` 并在 import 块加 `"google.golang.org/protobuf/types/known/structpb"`。 - [ ] **Step 4: 编译验证** ```bash cd backend go build ./services/socialService/... ``` Expected: 编译通过。 - [ ] **Step 5: 回归检查** - `query_graph pattern=callers_of target=LikeAsset` 确认调用方无破坏 - 跑 social service 既有测试:`go test ./services/socialService/ -v` - [ ] **Step 6: Commit** ```bash git add backend/services/socialService/ git commit -m "feat(socialService): trigger notification on like (degrade on failure)" ``` --- ### Task 16: 写点赞通知失败不影响主路径的测试 **Files:** - Create/Modify: `backend/services/socialService/service/asset_like_service_test.go` - Create: `backend/services/socialService/client/notification_client_mock.go`(mock 工具,与 user_rpc_client.go 已有 MockUserServiceClient 模式一致) - [ ] **Step 1: 先加 Mock 工具类** `backend/services/socialService/client/notification_client_mock.go`: ```go package client import ( "context" "sync" notifPb "github.com/topfans/backend/pkg/proto/notification" ) // MockNotificationClient 通知客户端 mock(用于测试) type MockNotificationClient struct { mu sync.Mutex CreateErr error CreateCallCount int32 LastRequest *notifPb.CreateNotificationRequest } func (m *MockNotificationClient) CreateNotification(ctx context.Context, req *notifPb.CreateNotificationRequest) (*notifPb.CreateNotificationResponse, error) { m.mu.Lock() defer m.mu.Unlock() m.CreateCallCount++ m.LastRequest = req if m.CreateErr != nil { return nil, m.CreateErr } return ¬ifPb.CreateNotificationResponse{Id: 1}, nil } ``` 为了让 `AssetLikeService` 接受 mock(而非具体类型 `*NotificationClient`),需要把 `notificationClient` 字段改为接口类型。改 `asset_like_service.go`: ```go type NotificationClientInterface interface { CreateNotification(ctx context.Context, req *notifPb.CreateNotificationRequest) (*notifPb.CreateNotificationResponse, error) } type AssetLikeService struct { assetClient *client.AssetClient socialRepo repository.SocialRepository notificationClient client.NotificationClientInterface // 改为接口 userClient UserServiceClient } ``` - [ ] **Step 2: 加测试用例** ```go func TestLikeAsset_NotificationFailureDoesNotFailLike(t *testing.T) { // 注入 mock notification client 返回 error // 期望 LikeAsset 仍返回成功 mockNotif := &client.MockNotificationClient{ CreateErr: fmt.Errorf("notification service down"), } svc := NewAssetLikeService(realAssetClient, realSocialRepo, mockNotif) // 准备 mock assetClient 返回 GetAssetForRPC OK newCount, err := svc.LikeAsset(ctx, assetID, userID, starID) assert.NoError(t, err) assert.Greater(t, newCount, int32(0)) } func TestLikeAsset_NotificationSuccess(t *testing.T) { mockNotif := &MockNotificationClient{} svc := NewAssetLikeService(realAssetClient, realSocialRepo, mockNotif) newCount, err := svc.LikeAsset(ctx, assetID, userID, starID) assert.NoError(t, err) assert.Equal(t, int32(1), mockNotif.CreateCallCount) } ``` (具体 mock 实现参考 social service 既有的 `user_rpc_client.go` 中 `MockUserServiceClient` 模式) - [ ] **Step 2: 运行测试** ```bash cd backend go test ./services/socialService/service/ -v -run TestLikeAsset ``` Expected: PASS。 - [ ] **Step 3: Commit** ```bash git add backend/services/socialService/service/asset_like_service_test.go git commit -m "test(socialService): verify notification failure doesn't fail like" ``` --- ## 阶段 5:Admin 后端集成 ### Task 17: 创建 admin 端 Notification ORM 模型 **Files:** - Create: `TopFans-activity-admin/backend/models/notification.py` - [ ] **Step 1: 写 model** ```python from sqlalchemy import Column, BigInteger, String, Boolean, Integer, JSON from database import Base class Notification(Base): __tablename__ = "notifications" id = Column(BigInteger, primary_key=True, index=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) ``` - [ ] **Step 2: 在 `models/__init__.py` 注册** `TopFans-activity-admin/backend/models/__init__.py` 追加: ```python from .notification import Notification, NotificationStats ``` - [ ] **Step 3: Commit** ```bash cd TopFans-activity-admin git add backend/models/ git commit -m "feat(admin): add Notification and NotificationStats ORM models" ``` --- ### Task 18: 创建 admin 端 notification CRUD **Files:** - Create: `TopFans-activity-admin/backend/crud/notification_crud.py` - [ ] **Step 1: 写 CRUD** ```python import time from typing import Optional, List, Literal from sqlalchemy.orm import Session from sqlalchemy import text from models.notification import Notification, NotificationStats from models.models import User # 假设有 User 模型;如无则用 raw SQL import logging logger = logging.getLogger(__name__) def _now_ms() -> int: return int(time.time() * 1000) def _resolve_recipients( db: Session, target_type: Literal["user", "star", "all"], target_value: Optional[int], ) -> List[int]: """根据 target_type 解析接收方 user_id 列表""" if target_type == "user": return [target_value] if target_value else [] if target_type == "star": # 查所有 star_id = target_value 的用户的 user_id rows = db.execute( text("SELECT user_id FROM user_fan_profiles WHERE star_id = :star_id"), {"star_id": target_value}, ).fetchall() return [r[0] for r in rows] if target_type == "all": rows = db.execute(text("SELECT id FROM users WHERE is_active = TRUE")).fetchall() return [r[0] for r in rows] return [] def _upsert_stats(db: Session, user_id: int, star_id: int, ntype: str, delta: int, now: int): col = {"like": "like_unread_count", "system": "system_unread_count", "activity": "activity_unread_count"}[ntype] sql = f""" INSERT INTO notification_stats (user_id, star_id, {col}, total_unread_count, updated_at) VALUES (:uid, :sid, :delta, :delta, :now) ON CONFLICT (user_id, star_id) DO UPDATE SET {col} = notification_stats.{col} + :delta, total_unread_count = notification_stats.total_unread_count + :delta, updated_at = :now """ db.execute(text(sql), {"uid": user_id, "sid": star_id, "delta": delta, "now": now}) def create_system_notification( db: Session, *, title: str, content: str, target_type: Literal["user", "star", "all"], target_value: Optional[int], data: Optional[dict] = None, ) -> int: """创建系统通知;返回插入条数""" recipients = _resolve_recipients(db, target_type, target_value) if not recipients: logger.warning("no recipients for system notification") return 0 now = _now_ms() for uid in recipients: n = Notification( user_id=uid, star_id=1, # 系统通知默认 star_id=1;如有多 star 需调整 type="system", title=title, content=content, data=data, created_at=now, ) db.add(n) _upsert_stats(db, uid, 1, "system", 1, now) db.commit() return len(recipients) def create_activity_notification( db: Session, *, activity_id: int, activity_title: str, activity_cover: str, recipients: List[int], data: Optional[dict] = None, ) -> int: """创建活动通知;返回插入条数""" if not recipients: return 0 now = _now_ms() payload = { "activity_id": activity_id, "activity_title": activity_title, "activity_cover": activity_cover, **(data or {}), } for uid in recipients: n = Notification( user_id=uid, star_id=1, type="activity", title=activity_title, content="新活动上线", data=payload, created_at=now, ) db.add(n) _upsert_stats(db, uid, 1, "activity", 1, now) db.commit() return len(recipients) ``` - [ ] **Step 2: 写测试** `TopFans-activity-admin/backend/crud/notification_crud_test.py`: ```python import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from database import Base import crud.notification_crud as nc import time @pytest.fixture def db(): engine = create_engine("postgresql://postgres:test@localhost:5432/top_fans_test") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() yield session session.close() def test_create_system_notification_single_user(db): count = nc.create_system_notification( db, title="维护通知", content="今晚 23:00 维护", target_type="user", target_value=12345, ) assert count == 1 # 验证 stats row = db.execute("SELECT system_unread_count FROM notification_stats WHERE user_id=12345").first() assert row[0] == 1 def test_create_system_notification_all(db): # 假设测试环境 users 表至少有 3 个 is_active=TRUE 用户 count = nc.create_system_notification( db, title="全量广播", content="重要通知", target_type="all", target_value=None, ) assert count >= 3 ``` - [ ] **Step 3: 运行测试** ```bash cd TopFans-activity-admin/backend source venv/bin/activate pytest crud/notification_crud_test.py -v ``` Expected: PASS。 - [ ] **Step 4: Commit** ```bash cd TopFans-activity-admin git add backend/crud/notification_crud.py backend/crud/notification_crud_test.py git commit -m "feat(admin): add notification CRUD with multi-target broadcast" ``` --- ### Task 19: 创建 admin 端 notification handler **Files:** - Create: `TopFans-activity-admin/backend/handlers/notification.py` - [ ] **Step 1: 写 handler** ```python from typing import Optional, Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from database import get_db from middleware.auth import require_admin # 复用现有 admin 鉴权 import crud.notification_crud as nc router = APIRouter(prefix="/api/v1/admin/notifications", tags=["admin-notifications"]) class CreateSystemNotificationReq(BaseModel): title: str content: str target_type: Literal["user", "star", "all"] target_value: Optional[int] = None data: Optional[dict] = None @router.post("") def create_system_notification( req: CreateSystemNotificationReq, db: Session = Depends(get_db), admin=Depends(require_admin), ): if not req.title or len(req.title) > 200: raise HTTPException(400, "title required, <= 200 chars") if req.content and len(req.content) > 500: raise HTTPException(400, "content <= 500 chars") if req.target_type in ("user", "star") and not req.target_value: raise HTTPException(400, "target_value required for user/star type") count = nc.create_system_notification( db, title=req.title, content=req.content, target_type=req.target_type, target_value=req.target_value, data=req.data, ) return {"affected": count} @router.post("/activities/{activity_id}/broadcast") def broadcast_activity( activity_id: int, db: Session = Depends(get_db), admin=Depends(require_admin), ): # 查 activity 信息 from models.models import MintingActivity activity = db.query(MintingActivity).filter(MintingActivity.id == activity_id).first() if not activity: raise HTTPException(404, "activity not found") # 解析接收方 recipients = nc._resolve_recipients(db, target_type="all", target_value=None) count = nc.create_activity_notification( db, activity_id=activity_id, activity_title=activity.title, activity_cover=activity.icon_url or "", recipients=recipients, ) return {"affected": count} ``` - [ ] **Step 2: 注册路由** 修改 `TopFans-activity-admin/backend/router/__init__.py`: ```python from handlers import auth, activity, ..., notification api_router.include_router(notification.router) ``` - [ ] **Step 3: 测试启动** ```bash cd TopFans-activity-admin/backend source venv/bin/activate uvicorn main:app --reload --port 8080 curl -X POST http://localhost:8080/api/v1/admin/notifications \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"test","content":"hi","target_type":"user","target_value":12345}' ``` Expected: 200 + `{"affected": 1}`。 - [ ] **Step 4: Commit** ```bash cd TopFans-activity-admin git add backend/handlers/notification.py backend/router/__init__.py git commit -m "feat(admin): add notification admin HTTP endpoints" ``` --- ### Task 20: admin activity handler 自动 broadcast **Files:** - Modify: `TopFans-activity-admin/backend/handlers/activity.py` - [ ] **Step 1: 定位 create activity 函数** ```bash grep -n "def create_minting_activity\|def.*create.*activity" \ TopFans-activity-admin/backend/handlers/activity.py ``` - [ ] **Step 2: 在创建成功后自动 broadcast** 参考 spec §7.6 写法,在 commit 之前插入 broadcast 调用: ```python # activity 创建成功后立即广播 if getattr(payload, "auto_notify", True): from crud import notification_crud from handlers.notification import _resolve_recipients # 或直接调用 crud 内函数 recipients = notification_crud._resolve_recipients(db, "all", None) notification_crud.create_activity_notification( db, activity_id=created.id, activity_title=created.title, activity_cover=created.icon_url or "", recipients=recipients, ) # 然后 db.commit() ``` - [ ] **Step 3: 测试** ```bash cd TopFans-activity-admin/backend source venv/bin/activate uvicorn main:app --reload --port 8080 # 通过 admin UI 或 curl 创建 activity # 验证 notification_stats 的 activity_unread_count +N ``` - [ ] **Step 4: 回归检查** - admin activity 既有测试仍通过 - 创建 activity 接口契约不变(auto_notify 字段可选,默认 True) - [ ] **Step 5: Commit** ```bash cd TopFans-activity-admin git add backend/handlers/activity.py git commit -m "feat(admin): auto-broadcast activity notification on creation" ``` --- ## 阶段 6:Gateway 路由配置 ### Task 21: gateway 路由注册 notification 路径 **Files:** - Modify: `backend/gateway/router/router.go` - [ ] **Step 1: 定位 social 路由分组** ```bash grep -n 'v1.Group("/social")\|v1.Group("/assets")' backend/gateway/router/router.go ``` - [ ] **Step 2: 在 social 路由后插入 notifications 路由组** ```go notifications := v1.Group("/notifications") { notifications.GET("", controller.NotificationController.GetNotifications) notifications.GET("/unread-count", controller.NotificationController.GetUnreadCount) notifications.POST("/:id/read", controller.NotificationController.MarkAsRead) notifications.POST("/targets/:target_id/read", controller.NotificationController.MarkAsReadByTarget) notifications.POST("/read-all", controller.NotificationController.MarkAllAsRead) notifications.DELETE("/:id", controller.NotificationController.DeleteNotification) notifications.DELETE("/targets/:target_id", controller.NotificationController.DeleteByTarget) } ``` - [ ] **Step 3: 实现 `NotificationController`** `backend/gateway/controller/notification_controller.go`: ```go package controller import ( "strconv" "github.com/gin-gonic/gin" notifPb "github.com/topfans/backend/pkg/proto/notification" "github.com/topfans/backend/gateway/pkg/response" "google.golang.org/grpc/metadata" ) type NotificationController struct { client notifPb.NotificationService } func NewNotificationController(client notifPb.NotificationService) *NotificationController { return &NotificationController{client: client} } // withUserCtx 把 user_id / star_id 注入到 gRPC metadata(与 social 端 controller 一致) func (c *NotificationController) withUserCtx(g *gin.Context) (userID, starID int64) { v1, _ := g.Get("user_id") v2, _ := g.Get("star_id") userID, _ = v1.(int64) starID, _ = v2.(int64) return } func parseInt(s string, def int) int { if s == "" { return def } n, err := strconv.Atoi(s) if err != nil { return def } return n } func (c *NotificationController) GetNotifications(g *gin.Context) { uid, sid := c.withUserCtx(g) ctx := metadata.AppendToOutgoingContext(g.Request.Context(), "x-user-id", strconv.FormatInt(uid, 10), "x-star-id", strconv.FormatInt(sid, 10), ) resp, err := c.client.GetNotifications(ctx, ¬ifPb.GetNotificationsRequest{ Type: g.Query("type"), Tab: g.Query("tab"), Page: int32(parseInt(g.Query("page"), 1)), PageSize: int32(parseInt(g.Query("pageSize"), 20)), }) if err != nil { response.Error(g, 500, "rpc failed: "+err.Error()) return } response.SuccessWithCode(g, int(resp.Base.Code), resp.Base.Message, gin.H{ "items": resp.Items, "total": resp.Total, "page": resp.Page, "page_size": resp.PageSize, }) } // GetUnreadCount / MarkAsRead / MarkAsReadByTarget / MarkAllAsRead / DeleteNotification / DeleteByTarget // 模式与 GetNotifications 相同:withUserCtx 注入 metadata + 调用 RPC + response 输出 // 此处省略重复代码(参考 social_controller.go 中的现有方法) ``` - [ ] **Step 4: 编译验证** ```bash cd backend go build ./gateway/... ``` Expected: 编译通过。 - [ ] **Step 5: Commit** ```bash git add backend/gateway/ git commit -m "feat(gateway): register notification HTTP routes" ``` --- ### Task 22: gateway config 加 notification backend **Files:** - Modify: `backend/gateway/config/config.yaml` - [ ] **Step 1: 加 notification service URL** ```yaml services: asset: url: tri://localhost:20003 social: url: tri://localhost:20002 user: url: tri://localhost:20000 notification: url: tri://localhost:20010 ``` - [ ] **Step 2: 启动 gateway 验证** ```bash cd backend ./start.sh gateway curl http://localhost:8080/api/v1/notifications/unread-count -H "Authorization: Bearer $JWT" ``` Expected: 200 + counts 响应(前提:notification service 也在运行)。 - [ ] **Step 3: 回归检查** - gateway 启动后其他路由仍正常(如 `/api/v1/social/...`) - `query_graph pattern=callers_of target=NotificationController` 确认新 controller 无循环依赖 - [ ] **Step 4: Commit** ```bash git add backend/gateway/config/ git commit -m "feat(gateway): add notification service backend config" ``` --- ## 阶段 7:整体回归与验收 ### Task 23: 整体回归检查(CLAUDE.md 强制) - [ ] **Step 1: 全量构建** ```bash cd backend go build ./... cd ../TopFans-activity-admin/backend source venv/bin/activate python -c "from main import app; print('admin import OK')" ``` Expected: 全部编译通过。 - [ ] **Step 2: 全量测试** ```bash cd backend go test ./services/notificationService/... -v go test ./services/socialService/... -v go test ./services/assetService/... -v ``` Expected: 全部 PASS(无 FAIL)。 ```bash cd TopFans-activity-admin/backend source venv/bin/activate pytest crud/notification_crud_test.py -v ``` Expected: PASS。 - [ ] **Step 3: graph 回归** ```bash # 通过 mcp__code-review-graph__query_graph_tool # pattern=callers_of target=GetAssetForRPC (确认 asset proto 扩展无破坏) # pattern=callers_of target=LikeAsset (确认 social 改动无破坏) # pattern=callers_of target=NotificationService (确认无循环依赖) ``` - [ ] **Step 4: 跨服务影响检查** - social service `LikeAsset` 是否在 notification 失败时**确实不影响主路径**(手动验证 + 自动化测试) - 修复 A 引入 B 排查: - A1: asset proto 加字段 → B1: 旧调用方零值兼容(应无影响) - A2: social service 加 notification 调用 → B2: notification service 不可用时点赞仍成功(已加测试) - A3: gateway 路由注册 → B3: 既有路由冲突(手工验证) - [ ] **Step 5: 端到端冒烟** 启动完整服务栈(asset / social / user / notification / gateway)后,手工验证: 1. 用户 A 点赞用户 B 的藏品 → 用户 B 收到通知 2. 用户 B 查看通知列表 → 看到 1 张聚合卡(如多个人都赞了同一个) 3. 用户 B 点未读数 → 数字减 1 4. 用户 B 标已读(MarkAsReadByTarget)→ 数字变 0 5. 用户 B 删除(DeleteByTarget)→ 列表中该 target 消失 - [ ] **Step 6: 验收清单交叉检查** 对照 `docs/superpowers/specs/2026-06-16-notification-system-design.md` §12 验收清单逐项打勾。 --- ## 任务依赖图 ``` T1 (迁移) ├→ T2 (asset proto 扩展) │ └→ T3 (asset provider 实现) ├→ T4 (notification proto) │ └→ T5-T6 (config + model) │ └→ T7-T8 (repositories) │ └→ T9 (repo tests) │ └→ T10 (service) │ └→ T11 (service tests) │ └→ T12 (provider) │ └→ T13 (main) └→ T14 (social client) └→ T15 (social 集成) └→ T16 (social 测试) └→ T17-T20 (admin) └→ T21-T22 (gateway) └→ T23 (整体回归) ``` --- ## 关键原则 - **TDD**:写测试 → 跑(红)→ 写实现 → 跑(绿)→ commit - **DRY**:common 错误处理函数 `okBase/errBase/extractUserStarFromContext` 等 - **YAGNI**:不实现 unique 约束、TTL、空 type=全部的查询 - **频繁 commit**:每个 Task 末尾都有 commit - **回归检查**:每个修改任务末尾都有 `query_graph` + `go build` 验证