61 KiB
站内邮箱聚合收件箱 — 实施计划
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: 在 pages/profile/profile.vue 的"服务与工具"section 加 📬 收件箱入口,跳到新建的 pages/mailbox/{index,detail}.vue 看 4 类通知(活动通知 / 反馈回复 / 举报结果 / 被举报),实时(uniPush receive 驱动而非轮询)、节流(60s / 10s)+ 聚合("您有 N 条新…")地推到 App。
Architecture:
- 后端: Go 单体,扩
public.notifications表的type白名单 4 类 (activity+feedback_replied+report_resolved+target_reported),public.notification_stats加 3 列。allowedTypesmap 白名单 + 新增ClearByTypeRPC + 后端RateLimiter节流 60s +TypeChinese聚合 title,4 处业务 emitter 复用既有notificationClient写通知。socialService的likeemitter 不动 (V1.2.10 用户要求点赞不进收件箱)。 - 前端: Vue 3
<script setup>+ Vuex 4 + 组合式 API。utils/api.js加 6 个 wrapper (列表/未读/标已读/全标已读/删除/清空)。Vuexmailboxmodule 维护itemsByType/unreadByType/mutation = REPLACE_UNREAD_BY_TYPE(idempotent, server 端权威覆盖) +PREPEND_ITEM(按 id 去重)。两页路由 (index.vue+detail.vue)。App.vue在#ifdef APP-PLUS块内注册plus.push的 receive + click 监听,App 前台时 receive 直接更新 Vuex,不发 HTTP。
Tech Stack:
- Backend: Go 1.25.5, gorm, grpc-gateway HTTP, postgres, uniCloud sendMessage (uniPush client lib in
pkg/push) - Frontend: uni-app 3.x (Vue 3 组合式 API),
<script setup>, Vuex 4, uni-app'srequest,plus.push.addEventListener,uniCloudpush payload - 测试: 后端
go test+ gorm 事务回滚; 前端 手验 + global grep (按share-impl-test-policy.md不写 Vitest)
Global Constraints
照抄自 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md 与仓库 CLAUDE.md,所有任务隐含遵守:
- CLAUDE.md Git 提交规范: AI 不得主动 commit;只有用户明确说"帮我 commit"或"提交吧"才执行。每个任务完成后等待用户明确指示才 commit。
- CLAUDE.md 数据库操作规范: 任何手动指定 ID 的 INSERT 末尾必须
SELECT setval('xxx_id_seq', (SELECT MAX(id) FROM xxx))(本期无 INSERT 任务,但 reviewer 应留意)。本期唯一 ALTER TABLE 是 0.5 天内幂等执行 (ADD COLUMN IF NOT EXISTS)。 - CLAUDE.md 接口开发规范: handler/service/repository 三层分离,handler 禁直接调 ORM,service 禁操作 HTTP。DTO/Request/Response 分文件。本期已有代码遵循此约定。
- CLAUDE.md 前端开发规范: Vue 3 组合式 API (
<script setup>),禁 Options API;#ifdef APP-PLUS包原生 API;接口走utils/api.js不裸uni.request;跨页状态走 Vuex;新页先在pages.json注册再写 .vue。setInterval / setTimeout任何形式的"周期性请求"被显式禁止。 - CLAUDE.md PostgreSQL 序列同步: 本计划不触发 (无 INSERT 指定 ID 的测试 SQL);若 reviewer 写测试 SQL 也要
SELECT setval('xxx_id_seq', ...)。 - 推送节流参数 (V1.2.7): 后端
(user, type)60s 滑窗;App 10s 同 type 去抖。 - 点赞不进邮箱 (V1.2.10): 不动
socialService/asset_like_service.go的 like emitter;前端 4 GROUPS 不含 like;profile.vue badge 用 Vuex getterinboxUnreadOnly(排除 like / system)。 - 修改/不删除旧文件: 本期不删除
myReports.vue/myFeedbacks.vue(CLAUDE.md 一个 commit 一件事), 也不动socialService/asset_like_service.go(V1.2.10 显式排除)。 - uniPush payload 形态 (V1.2.5):
Payload.Data是map[string]interface{}JSON 对象,不是字符串;unreads_by_type+total_unread字段在envelope.data内层,不在顶层。 - Gateway 查询串约定 (V1.2.5): controller 读
g.Query("type")(body 不解);前端按此调用?type=<x>。 - CLAUDE.md 接口开发规范 §9 测试: 后端 service 层核心业务逻辑必须覆盖单元测试 (本期 ClearByType + 4 处 emitter 各 ≥1 用例);前端按
share-impl-test-policy.md不写 Vitest,手验即可。
File Structure (locked-in by spec)
后端 — 新增 (5)
| File | Responsibility |
|---|---|
backend/migrations/20260713_add_mailbox_unread_counts.sql |
ALTER public.notification_stats 加 3 列 (feedback_replied_unread_count, report_resolved_unread_count, target_reported_unread_count) |
backend/services/notificationService/service/notification_service_test.go |
4 个新测试: 3 个 type 白名单扩参, 1 个 ClearByType 事务 |
backend/services/moderationService/service/moderation_service_test.go |
3 个新测试: 3 处 emitter (feedback_replied / report_resolved / target_reported) |
backend/services/activityService/service/activity_emitter_test.go |
2 个新测试: activity_message / activity_start_end |
后端 — 修改 (13)
| File | Changes |
|---|---|
backend/proto/notification.proto |
UnreadCount 增 3 字段号 5/6/7 (保留 total=4);新增 ClearByType RPC + ClearByTypeRequest/Response |
backend/services/notificationService/service/notification_service.go |
allowedTypes map 增 3 key; ClearByType 业务方法 (事务内 UPDATE is_deleted + stats 清零); triggerPush 注入 unreads_by_type + total_unread 进 data map |
backend/services/notificationService/repository/notification_repository.go |
新增 ClearByType 方法 |
backend/services/notificationService/repository/notification_stats_repository.go |
struct 加 3 字段; IncrementByType / DecrementByType / Get 改通用 (按 type 列名映射) |
backend/pkg/push/uni_push_client.go |
新增 RateLimiter 结构 + Allow(key) 方法;新增 TypeChinese(t) 函数 |
backend/gateway/router/router.go |
注册 DELETE /api/v1/notifications/clear |
backend/gateway/controller/notification_controller.go |
ClearByType HTTP handler |
backend/services/moderationService/service/feedback_service.go |
引入 notificationClient;反馈被回复时建 type=feedback_replied 通知 |
backend/services/moderationService/service/report_service.go |
引入 notificationClient;举报处理完成时建 type=report_resolved 通知 |
backend/services/moderationService/service/target_status_service.go |
引入 notificationClient;warn/takedown/ban 时建 type=target_reported 通知 (dismiss 不发) |
backend/services/activityService/service/activity_message_service.go |
引入 notificationClient;留言下发时建 type=activity 通知 |
backend/services/activityService/service/activity_service.go |
引入 notificationClient;活动开始/结束时建 type=activity 通知 (给"参与该活动的用户") |
前端 — 新增 (6)
| File | Responsibility |
|---|---|
frontend/pages/mailbox/index.vue |
列表页: 4 分组可折叠 + [全部已读] [全部删除] 顶部 toolbar |
frontend/pages/mailbox/detail.vue |
明信片页: 单条全宽 + [标已读] [删除] 底部 toolbar |
frontend/pages/mailbox/components/MailboxGroup.vue |
单分组列表组件 (header chevron + items) |
frontend/pages/mailbox/components/Postcard.vue |
明信片组件 (信片数据展示) |
frontend/pages/mailbox/composables/useMailboxCenter.js |
4 type 并发拉取 + 上拉加载更多 + 单条操作封装 |
frontend/store/modules/mailbox.js |
Vuex state (itemsByType / unreadByType / detailCache / isInMailboxPage) + mutations + getter inboxUnreadOnly + actions (applyPushPayload / alignFromServerUnread) |
前端 — 修改 (4)
| File | Changes |
|---|---|
frontend/pages.json |
注册 pages/mailbox/index + pages/mailbox/detail 两页路由 (custom navigationStyle, app-plus bounce:none) |
frontend/utils/api.js |
加 6 个 wrapper (getNotificationsApi / getUnreadCountApi / markAsReadApi / markAllAsReadApi / deleteNotificationApi / clearNotificationsApi)。GET/DELETE 用 URL 查询串 (?type=&page=&page_size=) 不用 data:{}。 |
frontend/App.vue |
onLaunch 内 #ifdef APP-PLUS 块: 1) uni.getPushClientId 上报 registerDeviceApi; 2) plus.push.addEventListener('receive', cb) 解 envelope 取 data 后 10s 同 type 去抖再 dispatch mailbox/applyPushPayload({data, title:envelope.title, content:envelope.content}); 3) plus.push.addEventListener('click', cb) navigateTo 到 pages/mailbox/index?focus=<type>&nid=<id> |
frontend/pages/profile/profile.vue |
<!-- 服务与工具 --> section 内 (line 162 之后) 加 📬 收件箱 service-button + 红点 badge (参考同 section guideClaimableCount 写法); v-if="userStore.token"; onShow 触发 dispatch('mailbox/refreshUnread'); click handler navigateTo('/pages/mailbox/index') |
关键接口契约 (跨任务依赖)
后端 → 前端 caller 看到的接口:
// proto/notification.proto (片段)
service NotificationService {
rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
option (google.api.http) = {
delete: "/api/v1/notifications/clear" // controller 读 g.Query("type")
};
}
}
message ClearByTypeRequest { string type = 1; } // activity | feedback_replied | report_resolved | target_reported | all
message ClearByTypeResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; }
message UnreadCount {
int32 like = 1; // 保留
int32 system = 2; // 保留
int32 activity = 3; // 保留
int32 total = 4; // 保留(原 4, 不重编号)
int32 feedback_replied = 5; // 🆕 追加
int32 report_resolved = 6; // 🆕 追加
int32 target_reported = 7; // 🆕 追加
}
前端 → 后端 caller 看到的接口:
GET /api/v1/notifications?type=<x>&page=<n>&page_size=<s> → { items, total, page, page_size }
GET /api/v1/notifications/unread-count → { like, system, activity, total, feedback_replied, report_resolved, target_reported }
POST /api/v1/notifications/{id}/read → 200
POST /api/v1/notifications/read-all?type=<x> → 200
DELETE /api/v1/notifications/{id} → 200
DELETE /api/v1/notifications/clear?type=<x|all> → 200 (new)
uniPush payload (envelope → envelope.data):
{
"cids": ["..."], "title": "...", "content": "...", "request_id": "...",
"data": {
"notification_id": 12345, "type": "feedback_replied", "star_id": 1,
"feedback_id": 1, "category_code": "login", "original_title": "...",
"unreads_by_type": {"activity":8,"feedback_replied":3,...},
"total_unread": 18
}
}
Tasks
Task 1: Database migration + proto 字段扩展
Files:
- Create:
backend/migrations/20260713_add_mailbox_unread_counts.sql - Modify:
backend/proto/notification.proto:94-105(UnreadCount message) - Modify:
backend/proto/notification.proto(添加 ClearByType RPC)
Interfaces:
-
Produces: SQL 文件可被运维 psql 重放; proto 文件已可生成 Go/PB 桩代码。
-
Step 1: 写 SQL migration 文件
-- 20260713_add_mailbox_unread_counts.sql
-- 给 notification_stats 加 3 列,业务 emitter 用。
-- 幂等:重复执行无副作用(IF NOT EXISTS)。
ALTER TABLE public.notification_stats
ADD COLUMN IF NOT EXISTS feedback_replied_unread_count INT NOT NULL DEFAULT 0;
ALTER TABLE public.notification_stats
ADD COLUMN IF NOT EXISTS report_resolved_unread_count INT NOT NULL DEFAULT 0;
ALTER TABLE public.notification_stats
ADD COLUMN IF NOT EXISTS target_reported_unread_count INT NOT NULL DEFAULT 0;
- Step 2: 修改 proto UnreadCount
编辑 backend/proto/notification.proto,保留 total 在字段 4,新字段追加在后面:
message UnreadCount {
int32 like = 1;
int32 system = 2;
int32 activity = 3;
int32 total = 4; // 保留,wire 兼容
int32 feedback_replied = 5; // 🆕
int32 report_resolved = 6; // 🆕
int32 target_reported = 7; // 🆕
}
- Step 3: 加 ClearByType RPC 到 proto
在 NotificationService service 内添加 (放在 DeleteByTarget 之后):
rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
option (google.api.http) = {
delete: "/api/v1/notifications/clear"
};
}
在文件末尾加 message 定义:
message ClearByTypeRequest {
string type = 1; // activity | feedback_replied | report_resolved | target_reported | all
}
message ClearByTypeResponse {
topfans.common.BaseResponse base = 1;
int32 affected = 2; // 受影响的行数
}
- Step 4: 编译生成 Go 代码
cd backend
make proto # 或 scripts/gen-proto.sh,具体看仓库 Makefile
Expected: 输出 pkg/proto/notification/notification.pb.go 含 ClearByTypeRequest / ClearByTypeResponse / 5/6/7 字段。
- Step 5: 跑全后端 build
cd backend
go build ./...
Expected: 编译成功(可能有 unused import 警告,但不致命)。
- Step 6: 等待用户 commit
按 CLAUDE.md Git 规范,不主动 commit。提示用户:git add backend/migrations/20260713_add_mailbox_unread_counts.sql backend/proto/notification.proto pkg/proto/notification/*.pb.go && git commit -m "feat(notification): 加 notification_stats 3 列 unread 计数 + ClearByType RPC 字段"。
Task 2: 后端 notificationService: allowedTypes + ClearByType + payload 注入
Files:
- Modify:
backend/services/notificationService/repository/notification_repository.go(加ClearByType方法) - Modify:
backend/services/notificationService/repository/notification_stats_repository.go(改通用 type) - Modify:
backend/services/notificationService/service/notification_service.go(allowedTypesmap +ClearByType业务方法 +triggerPushdata map 注入)
Interfaces:
-
消费 Task 1 的 SQL/proto。
-
产生 repository 方法
ClearByType(ctx, tx, userID, starID, ntype, now int64) (int32, error)调用方使用。 -
产生 service 方法
ClearByType(ctx, userID, starID, ntype)。 -
产生 service 修改
triggerPush注入的 data map 字段:notification_id / type / star_id既有 +unreads_by_type / total_unread新增。 -
Step 1: 修改 notification_stats_repository.go 让 stats 三方法通用
打开 backend/services/notificationService/repository/notification_stats_repository.go。
将 IncrementByType / DecrementByType / Get 三个方法的"按 type 列名写死 like/system/activity"改成动态列名:
// 在文件顶部加 helper
func statsColumn(t string) string {
switch t {
case "like": return "like_unread_count"
case "system": return "system_unread_count"
case "activity": return "activity_unread_count"
case "feedback_replied": return "feedback_replied_unread_count"
case "report_resolved": return "report_resolved_unread_count"
case "target_reported": return "target_reported_unread_count"
default: return "total_unread_count"
}
}
// 修改 IncrementByType(原 hardcode 改成通用)
func (r *NotificationStatsRepository) IncrementByType(ctx context.Context, tx *gorm.DB, userID, starID int64, ntype string, now int64) error {
col := statsColumn(ntype)
res := tx.WithContext(ctx).Exec(`
UPDATE public.notification_stats
SET `+col+` = `+col+` + 1,
total_unread_count = total_unread_count + 1,
updated_at = $4
WHERE user_id = $1 AND star_id = $2
`, userID, starID, now)
if res.Error != nil {
return fmt.Errorf("increment by type: %w", res.Error)
}
return nil
}
// DecrementByType 同理(留意防负数:用 GREATEST)
// Get 改成查询所有 6 列 + 累加得到 total(不依赖 total_unread_count 列本身)
注:原有 Get 方法如果直接 SELECT * 也可,但要确认新加的列被读到。建议显式列出 6 列。
- Step 2: 加 repository ClearByType 方法
在 backend/services/notificationService/repository/notification_repository.go 添加:
// ClearByType 软删 user+star 的所有该 type 通知。
func (r *NotificationRepository) ClearByType(ctx context.Context, tx *gorm.DB, userID, starID int64, ntype string) (int32, error) {
if tx == nil { return 0, errors.New("ClearByType must be called within a transaction") }
var res *gorm.DB
if ntype == "all" {
res = tx.WithContext(ctx).Exec(`
UPDATE public.notifications
SET is_deleted = TRUE
WHERE user_id=$1 AND star_id=$2 AND is_deleted=FALSE
`, userID, starID)
} else {
res = tx.WithContext(ctx).Exec(`
UPDATE public.notifications
SET is_deleted = TRUE
WHERE user_id=$1 AND star_id=$2 AND type=$3 AND is_deleted=FALSE
`, userID, starID, ntype)
}
if res.Error != nil { return 0, fmt.Errorf("clear by type: %w", res.Error) }
return int32(res.RowsAffected), nil
}
- Step 3: 修改 service allowedTypes map
backend/services/notificationService/service/notification_service.go line 39-44:
// 改为:
var allowedTypes = map[string]struct{}{
"like": {},
"system": {},
"activity": {},
"feedback_replied": {}, // 🆕
"report_resolved": {}, // 🆕
"target_reported": {}, // 🆕
}
并把所有错误消息 "like/system/activity" 改为 "like/system/activity/feedback_replied/report_resolved/target_reported"。
- Step 4: 加 service ClearByType 业务方法
紧贴 MarkAllAsRead 之后(大约 line 460 附近):
// ClearByType 按 type 软删通知(全量已读反操作),事务内 UPDATE is_deleted=TRUE
// 同时清零 stats 对应列。
//
// type 参数: "activity" | "feedback_replied" | "report_resolved" | "target_reported" | "all"
// "all" 表示对所有 4 类一起清。
//
// 注:目前**不**清 totals,因为邮件聚合页面只是隐藏,用户已读状态已记账。
// 只清未读 (未读 0 但 items 还在),如需"完全清空收件箱"看 §7。
func (s *NotificationService) ClearByType(
ctx context.Context, userID, starID int64, ntype string, now int64,
) (*notifPb.ClearByTypeResponse, error) {
if !validator.ValidateUserID(userID) { /*...*/ }
if !validator.ValidateStarID(starID) { /*...*/ }
var affected int32
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1) 清通知
n, err := s.notifRepo.ClearByType(ctx, tx, userID, starID, ntype)
if err != nil { return err }
affected = n
// 2) 清 stats 未读计数(只清未读,不清 totals)
if ntype == "all" {
cols := []string{
"activity_unread_count", "feedback_replied_unread_count",
"report_resolved_unread_count", "target_reported_unread_count",
"like_unread_count", "system_unread_count",
}
for _, col := range cols {
if err := tx.WithContext(ctx).Exec(`
UPDATE public.notification_stats
SET `+col+` = 0, updated_at = $3
WHERE user_id=$1 AND star_id=$2
`, userID, starID, now).Error; err != nil {
return fmt.Errorf("clear %s: %w", col, err)
}
}
} else {
col := statsColumn(ntype) // 引用 Task 2 Step 1 的 helper
if err := tx.WithContext(ctx).Exec(`
UPDATE public.notification_stats
SET `+col+` = 0, updated_at = $3
WHERE user_id=$1 AND star_id=$2
`, userID, starID, now).Error; err != nil {
return fmt.Errorf("clear %s: %w", col, err)
}
}
return nil
})
if err != nil { /* log + return nil, errResp */ }
return ¬ifPb.ClearByTypeResponse{ Base: ..., Affected: affected }, nil
}
- Step 5: 改 triggerPush 注入 unreads_by_type
backend/services/notificationService/service/notification_service.go triggerPush 函数 (line 175 附近),在 Send 前插入 stats 查询:
func (s *NotificationService) triggerPush(n *model.Notification) {
// ... existing code (line 175-210) ...
// 🆕 注入 stats 到 data map (v1.2.5 修正:在 envelope.data 内层)
stats, err := s.statsRepo.Get(cctx, n.UserID, n.StarID)
if err == nil && stats != nil {
data["unreads_by_type"] = map[string]int{
"like": stats.LikeUnreadCount,
"system": stats.SystemUnreadCount,
"activity": stats.ActivityUnreadCount,
"feedback_replied": stats.FeedbackRepliedUnreadCount,
"report_resolved": stats.ReportResolvedUnreadCount,
"target_reported": stats.TargetReportedUnreadCount,
}
data["total_unread"] = stats.TotalUnreadCount
}
// 原有 go func() Send 逻辑保留
}
注意:此处用 cctx 而不是 context.Background(),否则会丢失 trace。
- Step 6: 编译验证
cd backend
go build ./...
Expected: 编译成功。
- Step 7: 等待用户 commit
Task 3: 后端 gateway 注册 ClearByType 路由
Files:
- Modify:
backend/gateway/router/router.goline ~298 (notifications 路由组) - Modify:
backend/gateway/controller/notification_controller.go(新增 ClearByType HTTP handler)
Interfaces:
-
消费 Task 2 的
s.notifService.ClearByType(ctx, userID, starID, type, now)。 -
产生 HTTP 路由
DELETE /api/v1/notifications/clear?type=<x>→ 调用 ClearByType service 方法。 -
Step 1: 注册路由
backend/gateway/router/router.go 现有 notifications DELETE 行(line ~298)后追加:
notifications.DELETE("/clear", notificationCtrl.ClearByType) // 按 type 全软删
- Step 2: 加 controller handler
backend/gateway/controller/notification_controller.go 中,紧贴 MarkAllAsRead handler 之后添加:
// ClearByType 按 type 软删通知。
//
// Query: ?type=activity|feedback_replied|report_resolved|target_reported|all
func (ctrl *NotificationController) ClearByType(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
uid, ok := userID.(int64)
if !ok { /* 401 */ return }
sid, ok := starID.(int64)
if !ok { /* 401 */ return }
typeStr := g.Query("type")
if typeStr == "" { /* 400 */ return }
// 鉴权 JWT 由 router.AuthMiddleware 强制(进路由组前已 check)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now().UnixMilli()
resp, err := ctrl.notifService.ClearByType(ctx, uid, sid, typeStr, now)
if err != nil {
logger.Logger.Error("ClearByType failed",
zap.Int64("user_id", uid), zap.String("type", typeStr), zap.Error(err))
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{"affected": resp.Affected})
}
注: 如既有 controller 用 protoError(...) 风格,沿用。
- Step 3: 编译验证
cd backend
go build ./...
Expected: 编译成功。
- Step 4: 等待用户 commit
Task 4: 后端 pkg/push uni_push_client.go RateLimiter + TypeChinese
Files:
- Modify:
backend/pkg/push/uni_push_client.go(新增 RateLimiter struct + Allow + TypeChinese)
Interfaces:
-
消费:
push.Payload已有结构(CIDs / Title / Content / RequestID / Data)。 -
产生:
RateLimiter.Allow(key string) (mode string, count int)返回concrete或summary。 -
产生:
TypeChinese(t string) string把 type 字符串映射到中文标签。 -
Step 1: 加 RateLimiter 结构 + Allow 方法
在 uni_push_client.go 现有的 Pusher interface 之后,Payload struct 之前(或紧随 Payload 之后,任意位置)添加:
// RateLimiter 节流器,key = "user_id:star_id:type"。
// (V1.2.7) 60s 滑窗;首次走 concrete,后续走 summary;窗口重置后再次 concrete。
// (V1.2.8) summary title 含递增 count:"您有 N 条新<type中文>"。
type RateLimiter struct {
mu sync.Mutex
lastSent map[string]int64
pending map[string]int // 🆕 v1.2.8:本窗口累计
sentMode map[string]string // 🆕 v1.2.8:最近 send 的模式
windowMs int64
}
func NewRateLimiter(windowMs int64) *RateLimiter {
if windowMs <= 0 { windowMs = 60000 }
return &RateLimiter{
lastSent: make(map[string]int64),
pending: make(map[string]int),
sentMode: make(map[string]string),
windowMs: windowMs,
}
}
// Allow 判定本次 Send 模式。
// 返回 (mode, count):
// - "concrete", 1 : 窗口已过或 key 首次出现,Send 原 title
// - "summary", N : 窗口内第 N 条(N≥2),Send "您有 N 条新<type>" 标题
func (r *RateLimiter) Allow(key string) (mode string, count int) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UnixMilli()
last, ok := r.lastSent[key]
if !ok || now-last >= r.windowMs {
r.lastSent[key] = now
r.pending[key] = 1
r.sentMode[key] = "concrete"
return "concrete", 1
}
r.pending[key]++
r.sentMode[key] = "summary"
return "summary", r.pending[key]
}
- Step 2: 加 TypeChinese 函数
紧贴 RateLimiter 之后:
// TypeChinese 把 notification.type 映射到中文标签,summary title 用。
func TypeChinese(t string) string {
switch t {
case "activity": return "活动通知"
case "feedback_replied": return "反馈回复"
case "report_resolved": return "举报结果"
case "target_reported": return "被举报"
case "like": return "点赞消息"
case "system": return "系统消息"
default: return "通知"
}
}
- Step 3: 在 imports 加 sync
import (...) 段,如果没 sync 加:
import (
"context" // 已有
"encoding/json" // 已有
"fmt" // 已有
"sync" // 🆕 加这一行
"time" // 已有
...
)
- Step 4: 编译验证
cd backend
go build ./...
go vet ./pkg/push/...
Expected: 编译 + vet 通过。
- Step 5: 等待用户 commit
Task 5: 后端 moderationService 3 处 emitter
Files:
- Modify:
backend/services/moderationService/service/feedback_service.go(加 emitter) - Modify:
backend/services/moderationService/service/report_service.go(加 emitter) - Modify:
backend/services/moderationService/service/target_status_service.go(加 emitter)
Interfaces:
-
消费:
notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{...})由 main.go 注入(参考 socialService 的notificationClient注入模式)。 -
产生: 业务方法不变,在状态翻转事务提交后异步 fire notificationClient.CreateNotification(类型见下)。
-
Step 1: 修改 feedback_service.go 加 emitter
找到"反馈被回复"(在 feedbackService.RepliedBy / RepliedAt / ReplyContent / Status='replied' 写入的提交方法)的事务提交后,加入:
// 事务提交后(避免回滚时通知已发出)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
starID := int64(0)
if feedback.StarID != nil { starID = *feedback.StarID }
dataStruct, _ := structpb.NewStruct(map[string]interface{}{
"feedback_id": feedback.ID,
"category_code": feedback.CategoryCode,
"original_title": feedback.Title,
})
_, _ = s.notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{
UserId: feedback.UserID,
StarId: starID,
Type: "feedback_replied",
Title: "您的反馈已处理",
Content: feedback.ReplyContent, // 全文
Data: dataStruct,
})
}()
并修改 feedbackService struct 加 notificationClient NotificationClientInterface 字段,NewFeedbackService 构造函数注入(参考 socialService/asset_like_service.go:34 的注入模式)。
如 notificationClient == nil 则跳过 emit(gRPC 不强制)。
- Step 2: 修改 report_service.go 加 emitter
类似 Step 1,但 type=report_resolved,Content = reports.ResolutionNote (可能 nil,空时用 "已处理")。data 字段 {report_id, resolved_action, target_type, target_id, category_code}。在 Resolved_* 字段全部非空的提交方法后 fire。
- Step 3: 修改 target_status_service.go 加 emitter
类似 Step 1,但 type=target_reported,只在 action ∈ {warn, takedown, ban} 时发(dismiss 不发 —— 避免打扰无辜作者)。Content = target_status.reason(可能 nil,空时用分类描述)。data 字段 {report_id, target_type, target_id, action: 'warn|takedown|ban', reason_summary}。
收件人 user_id 是目标对象的所有者(不是举报人),需要查 assets / feedbacks 的 owner_user_id。
- Step 4: main.go 注入 notificationClient
backend/services/moderationService/main.go(具体看仓库结构,可能有 cmd/moderationService/main.go):
// 在已有的 gRPC client 初始化区,加:
// (参照 asset_like_service.go 的 NewAssetLikeService 注入风格)
notifCli := notification.NewClient(notifConn) // grpc-gateway 连接复用
feedbackSvc := feedback.NewFeedbackService(..., notifCli)
reportSvc := report.NewReportService(..., notifCli)
targetSvc := targetstatus.NewTargetStatusService(..., notifCli)
具体 NotificationClientInterface 在 client/notification_client.go 已定义,直接复用。
- Step 5: 编译验证
cd backend
go build ./...
Expected: 编译通过。
- Step 6: 等待用户 commit
Task 6: 后端 activityService 2 处 emitter (V1.2.2 自审确认新建)
Files:
- Modify:
backend/services/activityService/service/activity_message_service.go(加 emitter) - Modify:
backend/services/activityService/service/activity_service.go(加 emitter)
Interfaces:
-
同 Task 5, type=
activity,title 来自activities.title,content 是留言内容 / 活动摘要。 -
触发点 1: 别人在你的活动留言 → 给活动创建者发。
-
触发点 2: 活动开始 / 结束 → 给"我参与了该活动(购买过 / published_item_count>0)"的用户发。
-
Step 1: 修改 activity_message_service.go
在"留言被创建"事务提交后 fire emitter:
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
dataStruct, _ := structpb.NewStruct(map[string]interface{}{
"activity_id": msg.ActivityID,
"message_id": msg.ID,
})
_, _ = s.notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{
UserId: activity.OwnerUserID, // 需查 activity
StarId: msg.StarID,
Type: "activity",
Title: activity.Title,
Content: msg.Content, // 留言内容
Data: dataStruct,
})
}()
- Step 2: 修改 activity_service.go
在"活动开始"和"活动结束"的定时器回调(或 cron 触发器)处 fire,遍历 activities 表拿到 owner_user_id,给该活动的所有"参与者"发通知。MVP 简化为只给"购买过的 user_id" 发。data {activity_id, kind: 'start|end'}。
- Step 3: main.go 注入 notificationClient
notifCli := notification.NewClient(notifConn)
msgSvc := activitymessage.NewActivityMessageService(..., notifCli)
activitySvc := activity.NewActivityService(..., notifCli)
- Step 4: 编译验证
cd backend
go build ./...
Expected: 编译通过。
- Step 5: 等待用户 commit
Task 7: 后端单元测试
Files:
- Create:
backend/services/notificationService/service/notification_service_test.go(如不存在) - Create:
backend/services/moderationService/service/moderation_service_test.go(如不存在) - Create:
backend/services/activityService/service/activity_emitter_test.go
Interfaces:
-
消费 Tasks 2/3/4/5/6 的所有 service + repository 接口。
-
产生: 8 个通过的单测,作为 Task 8+ 的安全网。
-
Step 1: notification_service_test.go 4 个测试
参照 services/notificationService/service/notification_service_test.go 既有风格(test_helpers_test.go 已存在),添加:
func TestCreateNotification_FeedbackReplied_Allowed(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateNotification(context.Background(), ¬ifPb.CreateNotificationRequest{
UserId: 1001, StarId: 1, Type: "feedback_replied",
Title: "您的反馈已处理", Content: "OK",
})
require.NoError(t, err)
}
// 同理 2 个: report_resolved / target_reported
func TestClearByType_All(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
// seed 4 type 各 1 条 notification + stats
seedNotifications(t, svc, 1, "activity", ...)
seedNotifications(t, svc, 1, "feedback_replied", ...)
// ...
resp, err := svc.ClearByType(ctx, 1, 1, "all", time.Now().UnixMilli())
require.NoError(t, err)
assert.GreaterOrEqual(t, resp.Affected, int32(4))
// verify notification rows is_deleted=TRUE
stats, _ := svc.statsRepo.Get(ctx, 1, 1)
assert.Equal(t, 0, stats.ActivityUnreadCount)
// ... 其他 3 列也是 0
}
如 setupTestService 不存在,需先创建(用 pgxmock 或事务回滚,看既有风格)。
- Step 2: moderation_service_test.go 3 个测试
参照 test_helpers_test.go,Mock notificationClient,验证每次状态翻转调用 1 次:
func TestFeedbackReplied_TriggersNotification(t *testing.T) {
mockCli := &client.MockNotificationClient{}
svc := setupModerationService(t, mockCli)
// 触发"回复反馈"流程(走 service 方法,或 helper directly)
svc.ReplyFeedback(ctx, feedbackID, adminID, "管理员回复内容")
// verify mock
require.Equal(t, 1, mockCli.CallCount)
require.Equal(t, "feedback_replied", mockCli.LastRequest.Type)
require.Equal(t, "管理员回复内容", mockCli.LastRequest.Content)
}
// 同理: TestReportResolved_TriggersNotification, TestTargetReported_TriggersNotification
// 后者验证 dismiss 不发 → mockCli.CallCount == 0
- Step 3: activity_emitter_test.go 2 个测试
同结构:
func TestActivityMessage_TriggersActivityNotification(t *testing.T) {
mockCli := &client.MockNotificationClient{}
svc := setupActivityService(t, mockCli)
svc.CreateMessage(ctx, activityID, userID, content)
require.Equal(t, 1, mockCli.CallCount)
require.Equal(t, "activity", mockCli.LastRequest.Type)
}
func TestActivityEnd_TriggersActivityNotification(t *testing.T) {
mockCli := &client.MockNotificationClient{}
svc := setupActivityService(t, mockCli)
svc.EndActivity(ctx, activityID)
require.Equal(t, 1, mockCli.CallCount) // 至少 1,实际数 = 参与者数
}
- Step 4: 跑测试
cd backend
go test ./services/notificationService/service/... -v -run TestCreateNotification_FeedbackReplied
go test ./services/moderationService/service/... -v -run TestFeedbackReplied_TriggersNotification
go test ./services/activityService/service/... -v -run TestActivityMessage_TriggersActivityNotification
Expected: 所有测试 PASS。
如 pkg/database 不能在本地起真实 DB,用事务回滚 + docker pg shim,或用 pkg/testdb (可能仓库已有)。
- Step 5: 等待用户 commit
Task 8: 前端 utils/api.js 6 个 wrapper + 路由注册
Files:
- Modify:
frontend/utils/api.js(在 push device 注释块后加 6 个 wrapper) - Modify:
frontend/pages.json(注册 mailbox/{index,detail} 两页)
Interfaces:
-
后端 6 个 HTTP endpoint(见上文 Global Contract)。GET 用
?type=&page=&page_size=查询串,不写data: {}。 -
全用 url 模板字符串 +
encodeURIComponent处理 type。 -
Step 1: 在 utils/api.js 加 6 个函数
参照 registerDeviceApi 块的注释风格(块状中文 + 注释规范要点,见 spec §5.4),在 push 块之后插入:
// ==================== 通知查询/操作(spec §1.3 + 信箱聚合 §5.4) ====================
// 本节 6 个 wrapper 为本期新增。前端原本只暴露了 registerDeviceApi /
// unregisterDeviceApi 两个推送设备接口;通知本身 list/unread/markRead/markAllRead/
// delete/clear 在本期补齐(详见 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md §5.4)。
//
// 鉴权由 gateway AuthMiddleware 强制 JWT,前端无需再校验。
// 响应统一: { code, message, data },data 字段才是真正的 payload。
// getNotificationsApi —— 拉一页通知(列表页 onShow 并发 4 个 type / 详情页单补拉)。
//
// 参数:
// type :必填,枚举:activity|feedback_replied|report_resolved|target_reported|like|system
// tab :可选,二级过滤(本期不传)。
// page :默认 1,后端兜底。
// pageSize: 默认 20,后端上限 100。
//
// 业务流:列表页 onShow Promise.all(4 type) + 详情页 fallback 补拉 50 条找 nid。
//
// 注意:GET 必须走查询串(对齐 utils/api.js 现有 GET 约定);
// gateway controller 用 g.Query("type") 读 type,data:{} 不会被解。
export function getNotificationsApi(params = {}) {
const { type, tab = '', page = 1, pageSize = 20 } = params || {}
const qs = new URLSearchParams({ type, page: String(page), page_size: String(pageSize) })
if (tab) qs.append('tab', tab)
return request({ url: `/api/v1/notifications?${qs}`, method: 'GET' })
}
// getUnreadCountApi —— 拉全局未读计数。
// 主路径不调用(push receive 事件覆盖),仅作 list onShow 兜底对齐用。
//
// 返回:Promise<{ data: { like, system, activity, total, feedback_replied, report_resolved, target_reported } }>
export function getUnreadCountApi() {
return request({ url: '/api/v1/notifications/unread-count', method: 'GET' })
}
// markAsReadApi —— 单条标已读(列表项点击 / 详情页 [✓标已读])。
//
// 参数:id:必填,notifications.id。
//
// 后端事务:① UPDATE notifications SET is_read=TRUE WHERE is_read=FALSE(幂等)。
//
// 业务流:列表项点击后 dispatch 再 navigateTo 详情页;详情页标已读后留在原页(可能再看一眼)。
export function markAsReadApi(id) {
return request({ url: `/api/v1/notifications/${id}/read`, method: 'POST' })
}
// markAllAsReadApi —— 按 type 全标已读(列表页顶部 [+全部已读] 并发 4 次)。
//
// type:必填,枚举同 getNotificationsApi。
//
// 后端 controller 用 g.Query("type") 提取,走查询串;data:{type} 不行会 400。
export function markAllAsReadApi(type) {
return request({ url: `/api/v1/notifications/read-all?type=${encodeURIComponent(type)}`, method: 'POST' })
}
// deleteNotificationApi —— 单条软删(列表项长按 / 明信片页 [🗑删除])。
//
// 业务流:列表项长按 → 本地 store REMOVE_ITEM;明信片页 → 软删 + setTimeout 500ms navigateBack。
//
// 注意:后端 is_deleted=TRUE 软删,redDot 不受影响(只 decr 标已读数)。
export function deleteNotificationApi(id) {
return request({ url: `/api/v1/notifications/${id}`, method: 'DELETE' })
}
// clearNotificationsApi —— 按 type 软删(本期仅传 'all',清 4 类)。
//
// type: 枚举 activity|feedback_replied|report_resolved|target_reported|all。
// 后端:DELETE /api/v1/notifications/clear?type=<x>(spec §4 新加接口)。
export function clearNotificationsApi(type = 'all') {
return request({ url: `/api/v1/notifications/clear?type=${encodeURIComponent(type)}`, method: 'DELETE' })
}
- Step 2: 注册 mailbox 两页到 pages.json
打开 frontend/pages.json,在 pages 数组末尾追加:
,
{
"path": "pages/mailbox/index",
"style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
},
{
"path": "pages/mailbox/detail",
"style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
}
注意 JSON 无尾逗号;若 pages.json 用 JSON5 / 注释,可包注释注明:"信箱聚合 (spec §5)"。
- Step 3: 编译验证
cd frontend
# uni-app 不直接 build,看 package.json 是否有 lint / typecheck 命令
npm run lint 2>/dev/null || echo "(no lint script)"
Expected: 通过(可能有现有 lint 警告,与本次改动无关)。
- Step 4: 等待用户 commit
Task 9: 前端 store/modules/mailbox.js
Files:
- Create:
frontend/store/modules/mailbox.js
Interfaces:
-
消费: 无 (Task 8 前置)。
-
产生: Vuex module 含:
- state:
itemsByType(4 槽)、unreadByType(6 key)、detailCache、isInMailboxPage、loading、noMoreByType - mutations:
SET_ITEMS / APPEND_ITEMS / UPDATE_ITEM / REMOVE_ITEM / RESORT_ITEMS / SET_NO_MORE / DECREMENT_TYPE / REPLACE_UNREAD_BY_TYPE / MARK_ALL_READ / CLEAR_ALL / SET_DETAIL_CACHE / SET_LOADING / SET_IS_IN_PAGE / SET_TOTAL_UNREAD - getters:
inboxUnreadOnly= sum 4 类(给 profile.vue badge 用) - actions:
applyPushPayload({data, title, content})(App receive 用)、alignFromServerUnread(拉 /unread-count 用)
- state:
-
Step 1: 写完整 Vuex module
import {
getNotificationsApi, getUnreadCountApi,
markAsReadApi, markAllAsReadApi,
deleteNotificationApi, clearNotificationsApi,
} from '@/utils/api.js'
export const GROUPS = [
{ type: 'activity', label: '活动通知' },
{ type: 'feedback_replied', label: '反馈回复' },
{ type: 'report_resolved', label: '举报结果' },
{ type: 'target_reported', label: '被举报' },
// ⚠️ like / system 不进(V1.2.10)
]
const state = () => ({
itemsByType: GROUPS.reduce((acc, g) => { acc[g.type] = []; return acc }, {}),
unreadByType: GROUPS.reduce((acc, g) => { acc[g.type] = 0; return acc },
{ like: 0, system: 0 }), // 仍接收所有 type 用于聚合,只不进 INBOX
totalUnread: 0,
detailCache: null,
isInMailboxPage: false,
loading: false,
noMoreByType: GROUPS.reduce((acc, g) => { acc[g.type] = true; return acc }, {}),
})
const getters = {
// profile.vue 📬 badge 显示 (V1.2.10: 排除 like/system)
inboxUnreadOnly: (state) => {
return GROUPS.reduce((sum, g) => sum + (state.unreadByType[g.type] || 0), 0)
},
}
const mutations = {
SET_ITEMS(state, { type, items, sortUnreadFirst = true }) {
const sorted = sortUnreadFirst
? [...items].sort((a, b) =>
(a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1)
)
: items
state.itemsByType[type] = sorted
},
APPEND_ITEMS(state, { type, items }) {
state.itemsByType[type].push(...items)
},
UPDATE_ITEM(state, { type, id, patch }) {
const arr = state.itemsByType[type]
const i = arr.findIndex(n => n.id === id)
if (i >= 0) arr[i] = { ...arr[i], ...patch }
},
REMOVE_ITEM(state, { type, id }) {
state.itemsByType[type] = state.itemsByType[type].filter(n => n.id !== id)
},
RESORT_ITEMS(state, type) {
state.itemsByType[type].sort((a, b) =>
(a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1)
)
},
SET_NO_MORE(state, { type, value }) {
state.noMoreByType[type] = value
},
DECREMENT_TYPE(state, type) {
state.unreadByType[type] = Math.max(0, (state.unreadByType[type] || 0) - 1)
},
REPLACE_UNREAD_BY_TYPE(state, byType) {
state.unreadByType = { ...state.unreadByType, ...byType }
},
SET_TOTAL_UNREAD(state, n) { state.totalUnread = n },
MARK_ALL_READ(state) {
state.unreadByType = GROUPS.reduce((acc, g) => { acc[g.type] = 0; return acc },
{ ...state.unreadByType })
Object.keys(state.itemsByType).forEach(t => {
state.itemsByType[t] = state.itemsByType[t].map(n => ({ ...n, is_read: true }))
})
},
CLEAR_ALL(state) {
GROUPS.forEach(g => { state.itemsByType[g.type] = [] })
GROUPS.forEach(g => { state.unreadByType[g.type] = 0 })
},
SET_DETAIL_CACHE(state, item) { state.detailCache = item },
SET_LOADING(state, v) { state.loading = v },
SET_IS_IN_PAGE(state, v) { state.isInMailboxPage = v },
PREPEND_ITEM(state, { type, notification }) {
// V1.2.6 幂等:防重复
const list = state.itemsByType[type] || []
if (list.some(n => n.id === notification.id)) return
list.unshift(notification)
list.sort((a, b) =>
(a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1)
)
state.itemsByType[type] = list
},
}
const actions = {
// App.vue receive 事件后调用,只更新未读 + 可选 PREPEND_ITEM
applyPushPayload({ commit, rootState }, { data, title = '', content = '' }) {
if (!data || !data.unreads_by_type) return
commit('REPLACE_UNREAD_BY_TYPE', data.unreads_by_type)
commit('SET_TOTAL_UNREAD', data.total_unread || 0)
if (rootState.mailbox.isInMailboxPage && data.notification_id && data.type) {
commit('PREPEND_ITEM', {
type: data.type,
notification: {
id: data.notification_id,
title, content,
data: JSON.stringify(data),
is_read: false,
created_at: Date.now(),
},
})
}
},
async alignFromServerUnread({ commit }) {
const resp = await getUnreadCountApi()
commit('REPLACE_UNREAD_BY_TYPE', resp.data)
},
}
export default { namespaced: true, state, getters, mutations, actions }
- Step 2: 注册到 store/index.js
import mailbox from './modules/mailbox.js'
// 现有 modules 列表里加 mailbox
modules: {
user: ...,
guide: ...,
mailbox, // 🆕
}
- Step 3: 编译验证
cd frontend
# HBuilderX 直接打开,uni-app 没有 node build 命令,人工打开 dev 工具验证
Expected: 启动 dev 工具不报 module 解析错误。
- Step 4: 等待用户 commit
Task 10: 前端 pages/mailbox/components/{MailboxGroup,Postcard}.vue
Files:
- Create:
frontend/pages/mailbox/components/MailboxGroup.vue - Create:
frontend/pages/mailbox/components/Postcard.vue
Interfaces:
-
消费: Task 9 的 Vuex + Task 8 的 api.js。
-
产生:
MailboxGroupprops{groupKey, label, collapsed, items, unreadCount}, emits{toggle, select, long-press}。 -
产生:
Postcardprops{notification}, emits{mark-read, delete}。 -
Step 1: MailboxGroup.vue
<template>
<view class="mailbox-group">
<view class="group-header" @tap="onToggleHeader">
<text class="chevron">{{ collapsed ? '▸' : '▾' }}</text>
<text class="group-label">{{ label }}</text>
<text v-if="unreadCount > 0" class="group-badge">{{ unreadCount }}</text>
</view>
<view v-if="!collapsed" class="group-list">
<view
v-for="n in items" :key="n.id"
class="group-item"
@tap="onSelectItem(n)"
@longpress="onLongPressItem(n)"
>
<text class="dot" v-if="!n.is_read">●</text>
<text class="dot dot-read" v-else>○</text>
<text class="item-title">{{ n.title }}</text>
<text class="item-time">{{ formatTime(n.created_at) }}</text>
</view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
groupKey: String,
label: String,
collapsed: Boolean,
items: { type: Array, default: () => [] },
unreadCount: { type: Number, default: 0 },
})
const emit = defineEmits(['toggle', 'select', 'long-press'])
function onToggleHeader() { emit('toggle') }
function onSelectItem(n) { emit('select', n) }
function onLongPressItem(n) { emit('long-press', n) }
function formatTime(ms) {
if (!ms) return ''
const d = new Date(ms)
const pad = n => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>
<style lang="scss" scoped>
.mailbox-group {
background: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
.group-header {
display: flex; align-items: center; padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.chevron { font-size: 28rpx; color: #999; margin-right: 12rpx; width: 30rpx; }
.group-label { font-size: 28rpx; font-weight: 600; flex: 1; }
.group-badge {
background: #f5222d; color: #fff; border-radius: 16rpx;
padding: 4rpx 12rpx; font-size: 22rpx;
}
}
.group-item {
display: flex; align-items: center; padding: 20rpx 24rpx;
border-bottom: 1rpx solid #fafafa;
&:last-child { border-bottom: 0; }
.dot { color: #f5222d; font-size: 24rpx; margin-right: 12rpx; width: 24rpx; }
.dot-read { color: #ccc; }
.item-title { font-size: 26rpx; flex: 1; }
.item-time { font-size: 22rpx; color: #999; }
}
}
</style>
- Step 2: Postcard.vue
<template>
<view class="postcard">
<view class="postcard-header">
<text class="postcard-title">{{ notification.title }}</text>
<view class="postcard-meta">
<text v-if="notification.type" class="meta-type">[{{ typeLabel }}]</text>
<text class="meta-time">{{ formatTime(notification.created_at) }}</text>
</view>
</view>
<view class="postcard-content">
<text>{{ notification.content }}</text>
</view>
<view v-if="businessMeta.length" class="postcard-extra">
<view v-for="m in businessMeta" :key="m.k" class="meta-row">
<text class="meta-key">{{ m.k }}:</text>
<text class="meta-val">{{ m.v }}</text>
</view>
</view>
<view v-if="!notification.is_read" class="postcard-unread-tip">
<text>● 未读</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ notification: { type: Object, required: true } })
const typeMap = {
feedback_replied: '反馈回复',
report_resolved: '举报结果',
target_reported: '被举报',
activity: '活动通知',
}
const typeLabel = computed(() => typeMap[props.notification.type] || props.notification.type || '通知')
const businessMeta = computed(() => {
// data 是 JSON 字符串,反序列化展示业务字段
let data = {}
try { data = JSON.parse(props.notification.data || '{}') } catch (e) { data = {} }
const list = []
if (data.feedback_id) list.push({ k: '反馈 ID', v: data.feedback_id })
if (data.report_id) list.push({ k: '举报 ID', v: data.report_id })
if (data.resolved_action) list.push({ k: '处理动作', v: actionLabel(data.resolved_action) })
if (data.category_code) list.push({ k: '分类', v: data.category_code })
if (data.original_title) list.push({ k: '原标题', v: data.original_title })
if (data.activity_id) list.push({ k: '活动 ID', v: data.activity_id })
return list
})
function actionLabel(a) {
return { takedown: '已下架', ban: '已封禁', warn: '已警告', dismiss: '已驳回', restore: '已解除' }[a] || a
}
function formatTime(ms) {
if (!ms) return ''
const d = new Date(ms)
const pad = n => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>
<style lang="scss" scoped>
.postcard {
background: #fff; border-radius: 16rpx; padding: 32rpx;
.postcard-header { border-bottom: 1rpx solid #f5f5f5; padding-bottom: 16rpx; margin-bottom: 16rpx; }
.postcard-title { font-size: 32rpx; font-weight: 600; display: block; margin-bottom: 12rpx; }
.postcard-meta {
display: flex; gap: 16rpx; font-size: 22rpx; color: #999;
.meta-type { color: #5C40FF; }
}
.postcard-content {
font-size: 28rpx; line-height: 1.7; color: #333; margin-bottom: 24rpx;
white-space: pre-wrap;
}
.postcard-extra {
.meta-row {
display: flex; font-size: 24rpx; color: #666;
padding: 8rpx 0;
.meta-key { color: #999; margin-right: 16rpx; min-width: 140rpx; }
}
}
.postcard-unread-tip {
margin-top: 24rpx; padding-top: 16rpx; border-top: 1rpx solid #f5f5f5;
color: #f5222d; font-size: 24rpx;
}
}
</style>
- Step 3: 等待用户 commit
Task 11: 前端 composables/useMailboxCenter.js
Files:
- Create:
frontend/pages/mailbox/composables/useMailboxCenter.js
Interfaces:
-
消费: Task 9 Vuex + Task 8 api.js + Task 10 components props 接口。
-
产生: 组合式 API hook,封装 4 type 并发拉取 + 单条操作 + 折叠状态记忆(
uni.setStorage)。 -
Step 1: 写 useMailboxCenter.js
完整代码见 spec §5.7 (已在 v1.2.7 调优,含 10s 同 type 去抖已迁到 App.vue,本 hook 不重复)。
要点: 包含 GROUPS / itemsByType / unreadByType / loading / hasAny / hasMore / loadAll / onScrollLower / loadMore / onMarkRead / onMarkAllRead / onDeleteOne / onClearAll / onSelectItem / onLongPress / isCollapsed / toggleCollapse 共 18 个方法/状态。
(直接拷贝 spec §5.7 的代码块,逐字落地,无需"翻译"。)
- Step 2: 等待用户 commit
Task 12: 前端 pages/mailbox/index.vue 列表页
Files:
- Create:
frontend/pages/mailbox/index.vue
Interfaces:
-
消费: Task 10 MailboxGroup + Task 11 useMailboxCenter + Task 9 Vuex。
-
产生: 全宽列表页布局,4 分组可折叠,顶部 [全部已读] [全部删除]。
-
Step 1: 复制 spec §5.5 模板
完整 <template> + <script setup> 见 spec §5.5 (163 行),逐字落地。注意 onShow 触发 loadAll;onScrollLower 调 Task 11 的 onScrollLower;onMarkAllRead / onClearAll 调 Task 11 的方法。
- Step 2: 等待用户 commit
Task 13: 前端 pages/mailbox/detail.vue 明信片页
Files:
- Create:
frontend/pages/mailbox/detail.vue
Interfaces:
-
消费: Task 10 Postcard + Task 11 useMailboxCenter + Task 9 Vuex。
-
产生: 全宽明信片页,底部 [✓标已读] [🗑删除] 固定 toolbar,onLoad 接收
?nid=&type=。 -
Step 1: 复制 spec §5.6 模板
完整代码见 spec §5.6 (124 行)。注意 onLoad(options) 拆 nid/type;store 找不到时调 getNotificationsApi 单补拉;onDeleteOne 后 setTimeout(uni.navigateBack, 500) 让 toast 露出。
- Step 2: 等待用户 commit
Task 14: 前端 App.vue push receive/click 监听
Files:
- Modify:
frontend/App.vue(在<script>block 顶部加 #ifdef APP-PLUS 块)
Interfaces:
-
消费: Task 9 mailbox Vuex module + Task 8 registerDeviceApi。
-
产生:
#ifdef APP-PLUS块:注册 device + receive + click 监听。 -
Step 1: 加 #ifdef APP-PLUS 注册代码
打开 frontend/App.vue 的 <script> block,在 onLaunch 顶部添加:
import { registerDeviceApi } from '@/utils/api.js'
import store from '@/store/index.js'
// ============ 推送初始化(V1.2.5/6/7/8: receive 驱动 + 10s 去抖 + envelope 解包) ============
const PUSH_DEBOUNCE_MS = 10000 // V1.2.7 调优
const recentByType = {} // { [type]: lastDispatchMs }
export default {
onLaunch() {
// #ifdef APP-PLUS
try {
uni.getPushClientId({
success: (res) => {
const platform = plus.os.name === 'iOS' ? 'ios'
: plus.os.name === 'Android' ? 'android' : 'harmony'
registerDeviceApi({
cid: res.cid, platform,
appVersion: plus.runtime.version,
deviceModel: plus.device.model,
})
},
fail: (e) => { console.warn('[push] getPushClientId fail', e) },
})
} catch (e) {
console.warn('[push] getPushClientId skip', e)
}
// receive 监听 (App 前台 + 后台都触发)
try {
plus.push.addEventListener('receive', (msg) => {
try {
const envelope = JSON.parse(msg.payload || '{}')
const data = envelope.data || {}
const t = data.type
const now = Date.now()
// V1.2.7 10s 同 type 去抖
if (t && recentByType[t] && now - recentByType[t] < PUSH_DEBOUNCE_MS) return
if (t) recentByType[t] = now
store.dispatch('mailbox/applyPushPayload', {
data,
title: envelope.title || '',
content: envelope.content || '',
})
} catch (e) {
console.error('[push] receive parse fail', e)
}
})
plus.push.addEventListener('click', (msg) => {
try {
const envelope = JSON.parse(msg.payload || '{}')
const data = envelope.data || {}
uni.navigateTo({
url: `/pages/mailbox/index?focus=${data.type || ''}&nid=${data.notification_id || ''}`,
})
} catch (e) { console.error('[push] click parse fail', e) }
})
} catch (e) {
console.warn('[push] addEventListener skip', e)
}
// #endif
// ... 原有 onLaunch 逻辑保留 ...
},
onShow() { /* 原有 */ },
onHide() { /* 原有 */ },
}
- Step 2: 验证编译
在 HBuilderX / 你的 dev 工具中打开这个文件,确认无语法错误。
- Step 3: 等待用户 commit
Task 15: 前端 profile.vue 服务与工具 section 加 📬 按钮
Files:
- Modify:
frontend/pages/profile/profile.vue(在<!-- 服务与工具 -->section 加 📬 收件箱 entry)
Interfaces:
-
消费: Task 9 Vuex
mailbox/inboxUnreadOnlygetter + Task 9mailbox/refreshUnread。 -
产生: 完整的 📬 service-button + 红点 badge + onShow dispatch + navigateTo handler。
-
Step 1: 复制 service-button 模板
打开 frontend/pages/profile/profile.vue,定位 <!-- 服务与工具 --> section (line 162 之后),把 "反馈" 那个 view 之前(或之后,你的偏好)插入:
<!-- 📬 收件箱 (信箱聚合入口 V1.2.9) -->
<view class="service-button" @tap="goMailbox">
<image
class="service-icon"
src="/static/icon/mailbox.png"
mode="aspectFit"
></image>
<text class="service-text">收件箱</text>
<text v-if="inboxUnread > 0" class="guide-badge">{{ inboxUnread }}</text>
</view>
要 src="/static/icon/mailbox.png" 这个 icon,任务前置:前端设计师提供/从现成 icon 库选;若暂缺可先用 placeholder letter / mail 图标。
- Step 2: 加
<script setup>逻辑
在 <script setup> 内、computed 段加:
import { useStore } from 'vuex'
const store = useStore()
const inboxUnread = computed(() => store.getters['mailbox/inboxUnreadOnly'])
function goMailbox() {
// V1.2.7 兜底:进入邮箱页时先对齐红点(state 已在 App onReceive 维护,这里再 GET 一次兜底)
store.dispatch('mailbox/alignFromServerUnread').catch(() => {})
uni.navigateTo({ url: '/pages/mailbox/index' })
}
并在 onShow 内追加:
onShow(() => {
// 现有逻辑保留 ...
// 🆕 邮箱未读对齐 (只 1 次,非轮询)
store.dispatch('mailbox/alignFromServerUnread').catch(() => {})
})
如 v-if="userStore.token" 必要,改为:
<view v-if="userStore.token" class="service-button" @tap="goMailbox">
<image ... src="/static/icon/mailbox.png" ...></image>
<text class="service-text">收件箱</text>
<text v-if="inboxUnread > 0" class="guide-badge">{{ inboxUnread }}</text>
</view>
- Step 3: 注册 mailbox 图标资源
frontend/static/icon/mailbox.png —— 让设计师提供或从 Material/FontAwesome 取一个 envelope icon;若 uni-app tabBar 图标必须 81x81 (PNG) + 162x162 (PNG x2),这里 service-button 用 60x60 即可。
临缺方案: 用其他已有图标如 /static/icon/feedback.png 临时替,后续再换。
- Step 4: 验证
在 dev 工具中打开 profile.vue,确认模板不报错,热加载到 App 看:
-
服务与工具 section 出现"收件箱"按钮
-
已登录 + 有未读时显示红点 badge
-
点击跳到
pages/mailbox/index -
Step 5: 等待用户 commit
Tasks 进度表
按依赖顺序执行。每个 task 完成后等待用户"提交吧"才 commit;commit 不要 AI 主动执行(CLAUDE.md)。
| # | 模块 | 估时 | 等什么 |
|---|---|---|---|
| 1 | migration + proto | 0.3d | 提交 commit |
| 2 | notificationService | 0.5d | 提交 commit |
| 3 | gateway route + controller | 0.3d | 提交 commit |
| 4 | RateLimiter + TypeChinese | 0.3d | 提交 commit |
| 5 | moderationService 3 emitter | 0.3d | 提交 commit |
| 6 | activityService 2 emitter (V1.2.2 新增) | 0.2d | 提交 commit |
| 7 | 单元测试 (8 用例) | 0.5d | 提交 commit |
| 8 | api.js 6 wrapper + pages.json | 0.2d | 提交 commit |
| 9 | Vuex mailbox module | 0.3d | 提交 commit |
| 10 | MailboxGroup + Postcard 组件 | 0.3d | 提交 commit |
| 11 | useMailboxCenter composable | 0.3d | 提交 commit |
| 12 | mailbox/index.vue | 0.3d | 提交 commit |
| 13 | mailbox/detail.vue | 0.3d | 提交 commit |
| 14 | App.vue push 监听 | 0.3d | 提交 commit |
| 15 | profile.vue 📬 入口 | 0.2d | 提交 commit |
| 合计 | ~3.7d | (5-6 工作日) |
Self-Review Checklist (run before finalizing)
按 spec 对照:
- §0 方案概述 — doc header 完整
- §1 现状盘点 — DB / 后端 / API / 前端 全部覆盖
- §2 设计目标与验收标准 — 12 条 Acceptance
- §3 数据契约 — type 白名单 + stats schema + 4 个 emitter 字段契约
- §4 后端设计 — handler/service/repo 三层 + 接口改动清单
- §5 前端设计 — 6 个新文件 + 4 个修改文件
- §6 响应式 — 两页路由 + 极窄屏 fallback
- §7 错误处理 — 网络/未登录/空态/并发 + Vuex 幂等
- §8 测试计划 — 8 case 含节流、聚合、不轮询
- §9 文档 — Swagger + migration 命名
- §10 目录变更 — 严格区分新增 vs 修改
- §11 部署回滚 — 灰度 + SQL 幂等
- §12 uniPush receive — payload 修正 + 60s 60s+10s + count 聚合
- §13 关键决策表 — 11 行
- §14 旧 myReports/myFeedbacks 关系 — 不动文件,不再引用
- §15 变更日志 — 11 个版本
OK 后保存到 docs/superpowers/plans/2026-07-14-mailbox-inbox-impl.md。