topfans/docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md
zerosaturation 6425cbf747 docs(spec): 站内邮箱聚合收件箱设计 (v1.2.10)
10 轮迭代后定稿:
- 复用 notifications 表,扩 type 白名单 4 类
- like 暂不进邮箱 (保留旧 push 体验)
- 前端两页路由 pages/mailbox/{index,detail}.vue
- profile.vue 服务与工具 section 加入口 + Vuex getter inboxUnreadOnly
- uniPush receive + payload 含 unreads_by_type 零轮询
- 三道防线节流 (60s/10s + Vuex 幂等)
- +N 通知聚合
- 4 emitter + DELETE /notifications/clear
- 实施路径 ~3.7d

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:52 +08:00

87 KiB
Raw Blame History

站内"邮箱"聚合收件箱 — 设计方案

文档版本: v1.2.10 · 拟稿日期: 2026-07-14 · 作者: Claude 实施阶段: MVP · Status: 待用户审阅


方案概述(必读)

要解决的问题

业务侧: 用户提交举报 / 反馈、参与活动后,目前没有"集中看到所有回执"的入口 —— 处理结果散落在 myReports / myFeedbacks / activity-center 三处独立页面,用户很容易漏看。

技术侧:

  1. 项目已有 notifications 主表 + notification_stats 未读聚合 + uniPush 推送通道;allowedTypes 白名单允许 like/system/activity,但生产代码里只有 socialService 在调用 CreateNotification(type="like")asset_like_service.go:185system / activity 是白名单预留位、零业务调用;举报与反馈的处理回执也没有接进通知流。
  2. utils/api.js 只暴露了推送设备注册 APIGET /api/v1/notifications 等查询类接口在前端没有客户端封装
  3. 广场(之前错的)pages/profile/profile.vue(你确认改到这)<!-- 服务与工具 --> sectionprofile.vue:162没有"📬 收件箱"入口位,新功能要在该 section 加一个 service-button 类型的入口(参考同 section 的"新手指引/反馈/修改昵称" 等按钮的视觉规范)。

整体实现路径

Stage 工作 估算
后端 type 白名单扩展 + stats migration map 加 3 key + ALTER TABLE + proto 字段 0.5d
moderationService 3 处 emitter 新增 复用现有审核流,补 3 个回调写通知 0.5d
activityService 1-2 处 emitter 新增v1.2.2 自审:原本以为活动是复用,实际是新建) 留言下发 + 活动开始/结束 0.3d
新加清空接口 DELETE /api/v1/notifications/clear proto + repository + service + router 0.3d
前端 utils/api.js 补 5 个查询/动作函数 notifications 列表/未读/标已读/标全部/删除单条 0.2d
Vuex modules/mailbox.js 全局未读数 refresh + reset action 0.2d
pages/mailbox/index.vue 列表页 + pages/mailbox/detail.vue 明信片页 4 分组列表 + 单条详情(两页路由) 1.0d
profile.vue <!-- 服务与工具 --> section 加 📬 收件箱 入口v1.2.9 新增/原计划在 Header.vue + square.vue service-button 视觉 + 红点 badge + navigateTo 0.3d
推送节流三道防线v1.2.6 新增,参数 60s/10s 见 v1.2.7 后端 RateLimiter 60s + App 10s 去抖 + Vuex 幂等 0.3d
推送聚合 +Nv1.2.8 新增) RateLimiter 加 pending[s]count,summary title 拼 "N 条新" 0.1d
手验 + 单测moderation 单元测试补 3 用例) 0.5d
合计 3.5d ≈ 5-6 个工作日

关键决策

  1. 复用 notifications,扩 type 白名单 3 个 keyfeedback_replied / report_resolved / target_reported 三个真的新增),activity 复用白名单但实际是新增 emitter(无现有业务流调用)。理由: MVP 阶段 1 套表足够;项目已有未读聚合 + 推送链路;零迁移成本。
  2. 前端聚合 4 个 type,不建后端聚合接口。理由: 4 个 Tab同 1 个 endpoint按 type 过滤)= 1 个 HTTP 调用 × 4 次(并发),零新接口干净。
  3. 两页路由(手机竖屏优先): 列表页 pages/mailbox/index.vue 装 4 分组可折叠列表 + 顶部 [全部已读 / 全部删除] + 列表项长按 ActionSheet明信片页 pages/mailbox/detail.vue?nid=<id> 装单条明信片全宽 + 顶部 [返回] + 单条 [标已读 / 删除]。点列表项 navigateTo 到详情页。所有详情在邮件页面内完成,不跳详情子页。
  4. 点赞不进邮箱v1.2.10 经你确认):后端 socialService/asset_like_service.go:185like emitter 保留不动(用户已习惯手机通知栏+红点旧体验);前端 inbox 4 GROUPS§5.7)本来就不含 like(已合规);profile.vue 📬 badge 显示值改为 Vuex getter inboxUnreadOnly = sum(unreadByType[activity, feedback_replied, report_resolved, target_reported]),包括 like / system 红点。详细见 §3.1 备注、§7.2 badge 计算。
  5. 新页 pages/mailbox/index.vue 列表页 + pages/mailbox/detail.vue 明信片页(两页路由),绕过 myReports / myFeedbacks / 任何详情子页 —— 仓库文件不删除(避免无关改动),但下游入口不再引用。
  6. 推送节流 + 聚合v1.2.6 初版,v1.2.8 加 count 聚合):① 后端 RateLimiter (user, type) 60s 滑窗 → 首次 concrete,后续 summary title="您有 N 条新<type中文名>" 聚合递增(仍 Send 让 App receive 同步红点);② App plus.push.addEventListener10s 同 type 去抖;③ Vuex PREPEND_ITEM 按 id 去重 + REPLACE_UNREAD_BY_TYPE 是 idempotent。详细见 §12.7。

核心架构图TL;DR

┌──────────────────────────────────────────────┐
│            现有业务流(变化点)                   │
│  moderationService(管理员操作)                │
│    └─ (新)CreateNotification 3 处 emitter     │
│         ├─ type=report_resolved  → 我的举报结果│
│         ├─ type=feedback_replied → 我的反馈回复│
│         └─ type=target_reported  → 被举报通知  │
│         └─ (已有) type=activity  → 活动通知    │
│                                              │
│  activityService(留言下发)                    │
│    └─ (新)CreateNotification type=activity    │
│                                              │
├──────────────────────────────────────────────┤
│            通知主表(变化点)                    │
│  public.notifications                        │
│    + type 白名单扩展 4 类                    │
│  public.notification_stats                   │
│    + 列扩展: feedback_replied_unread_count   │
│              report_resolved_unread_count    │
│              target_reported_unread_count    │
│                                              │
├──────────────────────────────────────────────┤
│            API(变化点)                       │
│  POST   /internal/v1/notifications (已)      │
│  GET    /api/v1/notifications   (已有,扩类型)│
│  GET    /api/v1/notifications/unread-count(已)│
│  POST   /api/v1/notifications/{id}/read (已) │
│  POST   /api/v1/notifications/read-all   (已)│
│  DELETE /api/v1/notifications/{id}       (已) │
│  DELETE /api/v1/notifications/clear      (新)│
│    ?type=activity|feedback_replied|report_   │
│     resolved|target_reported|all             │
│                                              │
├──────────────────────────────────────────────┤
│            前端(变化点)                       │
│  utils/api.js                                │
│    + getNotificationsApi                     │
│    + getUnreadCountApi (兜底对齐用,主路径无 HTTP) │
│    + markAsReadApi / markAllAsReadApi       │
│    + deleteNotificationApi                  │
│    + clearNotificationsApi (新)              │
│                                              │
│  store/modules/mailbox.js (新)               │
│    state.unreadCount / unreadByType          │
│    actions.applyPushPayload (uniPush receive 触发,详见 §12) │
│                                              │
│  components/composables/useMailboxCenter.js (新)│
│    封装 4 type 并发拉取 + 上拉加载更多        │
│                                              │
│  pages/profile/profile.vue (v1.2.9 入口位)     │
│    <!-- 服务与工具 --> section               │
│    + 📬 收件箱 service-button + 红点 badge   │
│    + onShow 时 dispatch refreshUnread        │
│                                              │
│  pages/mailbox/index.vue (新,列表页)         │
│  pages/mailbox/detail.vue (新,明信片页)       │
└──────────────────────────────────────────────┘

文档说明

  • 适用范围: TopFans 站内 Appuni-app + Vue 3 组合式 API主要面向 app-plus 端,兼容 H5 / 微信小程序)。
  • 工作量估算: ~3.5 工作日(含单测与手验)。
  • 前置版本: 无(绿地新增)。
  • 目标读者: 后端 moderator / 通知 / statistic 三服务模块负责人;前端 Vuex / Vue3 composable 接入方;测试同学。
  • 关联文档:
    • CLAUDE.md —— 接口开发规范、数据库操作规范、前端开发规范、Git 提交规范
    • proto/notification.proto —— 通知服务协议(本次扩展)
    • pkg/models/moderation.go —— 举报 / 反馈数据模型
    • pkg/models/activity_message.go —— 活动留言数据模型
    • services/notificationService/ —— 现有通知服务实现

目录

  1. 现状盘点
  2. 设计目标与验收标准
  3. 数据契约
  4. 后端设计
  5. 前端设计
  6. 响应式与可达性
  7. 错误处理与边界
  8. 测试计划
  9. 文档 / 运维
  10. 目录变更
  11. 部署与回滚
  12. 不在本期范围
  13. 关键决策表
  14. 附:与旧 myReports/myFeedbacks 的关系
  15. 变更日志

1. 现状盘点

1.1 数据表(已有)

关键字段 用途 复用方式
public.notifications id/user_id/star_id/type/title/content/data(jsonb)/is_read/is_deleted/created_at/read_at 通知主表 type 白名单
public.notification_stats user_id/star_id/like_unread/system_unread/activity_unread/total_unread/updated_at 未读聚合 加 3 列
reports reporter_id/star_id?/target_type/target_id/target_snapshot/category_code/description/status/resolved_action/resolved_by/resolved_at/resolution_note/... 用户提的举报 只用 id + reporter_id + resolved_* 当 data 字段塞进通知
feedbacks user_id/star_id?/category_code/title/content/contact?/status/reply_content/replied_by/replied_at/... 用户提的反馈 只用 id + reply_content + replied_at 当 data 字段塞进通知
activity_messages activity_id/user_id/star_id/nickname/avatar_url/content/status/... 活动留言 activityService 留言回调写 type=activity 通知
report_evidence / feedback_evidence 截图证据 显示在信片 data 中(缩略图 OSS url 可选填

target_reported 的"被处置说明"字段: moderation_target_status.reason 字段已存在(pkg/models/moderation.go:228),管理员处理时填入,写入通知 content 即可。

1.2 后端服务(已有)

服务 关键功能 与本设计的关系
notificationService CreateNotification / GetNotifications / GetUnreadCount / MarkAsRead / MarkAllAsRead / DeleteNotification / DeleteByTarget / RegisterDevice / UnregisterDevice;支持 uniPush 异步推送 核心:扩 allowedTypes + 加 ClearByType
moderationService 举报 / 反馈工单的增删查改;管理员审核动作 新增 3 处 emitter 接入点
activityService 活动 / 留言 新增 1 处 emitter 接入点(活动开始 / 结束 / 新留言)

1.3 后端 API已存在前端部分缺封装

路径 用途 前端封装
POST /internal/v1/notifications 内部创建通知 (后端调用)
GET /api/v1/notifications?type=...&tab=...&page=...&page_size=... 用户拉一页通知
GET /api/v1/notifications/unread-count 拉全局未读(兜底对齐用,主路径不走 HTTP —— 见 §12
POST /api/v1/notifications/{id}/read 单条已读
POST /api/v1/notifications/targets/{target_id}/read 按 target 全标已读(现有 like 专用) 暂不用
POST /api/v1/notifications/read-all?type=... 按 type 全标已读
DELETE /api/v1/notifications/{id} 单条软删
DELETE /api/v1/notifications/targets/{target_id} 按 target 全软删 暂不用
DELETE /api/v1/notifications/clear?type=... 按 user + type 软删 新加
POST /api/v1/notifications/devices 注册推送 cid 已有 registerDeviceApi
POST /api/v1/notifications/devices/unregister 注销推送 cid 已有 unregisterDeviceApi

1.4 前端页面 / 组件(已有)

路径 用途 与本设计的关系
pages/profile/profile.vue 我的页(含 <!-- 服务与工具 --> section 新增 📬 收件箱 service-button 在该 section 内,onShow 时刷新 unread badge
pages/components/Header.vue 通用顶部条,广场 square.vue 在用 不动v1.2.9 取消 Header.vue 上加 icon 的方案)
pages/square/square.vue 广场首页 不动
pages/profile/myReports.vue 我的举报列表(旧) 保留,但下游入口不再引用
pages/profile/myFeedbacks.vue 我的反馈列表(旧) 保留,但下游入口不再引用
pages/support-activity/center.vue 活动中心 不动

1.5 前端 store / composables已有

  • store/index.js + modules/user.js / guide.js —— 新增 modules/mailbox.js
  • composables/useHolographicPreview / useDashboardData / useLenticularStudioTilt —— 范例模式
  • 新增 composables/useMailboxCenter.js

2. 设计目标与验收标准

2.1 目标

  1. 用户在 pages/profile/profile.vue<!-- 服务与工具 --> section 看到一个 📬 收件箱 service-button + 红点 badge
  2. 点击进入"邮箱"页面,左侧 4 分组列表(活动通知 / 反馈回复 / 举报结果 / 被举报),右侧明信片详情
  3. 所有详情(举报处理备注、反馈回复全文、举报处置说明、活动摘要)全部在邮件页面内展示,不跳转
  4. 4 个操作按钮:单条标已读 / 全部已读 / 单条删除 / 全部删除

2.2 验收标准

  • Acceptance 1: 用户在 pages/profile/profile.vue<!-- 服务与工具 --> section 看到 📬 收件箱 按钮 + 红点 badge点击进入邮箱
  • Acceptance 1.1: profile.vue 进入 onShow 时拉一次 unread,刷新 badge不订阅 store 变化以避免重复监听)
  • Acceptance 1.2: profile.vue 红点 badge 不包含 like 的未读数 —— 比如用户有 5 条 like + 3 条 feedback_replied, badge 显示 3(不是 8
  • Acceptance 2: 邮箱页有 4 分组,每组可独立折叠/展开
  • Acceptance 3: 已读排在组内底部created_at desc 时,未读优先)
  • Acceptance 4: 点列表项 → navigateTo 跳详情页 + 该项标已读 + 红点未读 -1
  • Acceptance 5: 全部已读 / 全部删除作用于 4 组全量
  • Acceptance 6: 明信片页默认全开(不折叠),内容含管理员回复全文
  • Acceptance 7: 红点消失条件4 type 全 0 或用户手动全部已读
  • Acceptance 8: 详情页 navigateBack 返回列表页,回到原滚动位置
  • Acceptance 9: 后端事务emitter 写通知 + stats 累加必须同事务
  • Acceptance 10: 退出重进,未读数与列表项状态保留
  • Acceptance 11: uniPush receive 时 App 前台红点 +1 + 邮箱页内新条目实时插入(不发 HTTP
  • Acceptance 12: 应用全程无 setInterval / setTimeout 形式的轮询(浏览器性能审计 / IDE grep 双重证)

3. 数据契约

3.1 notifications.type 白名单扩展(务实版)

实际现状v1.2.2 自审确认v1.2.10 加入"点赞不进邮箱"约束):

whitelist 实际有 emitter 本期动作 进邮箱?
like socialService/asset_like_service.go:185 不动(保留原有 push 链路) 不进v1.2.10 你确认)
system 零调用 不动(白名单预留位) 不进
activity 零调用 新增 emitter
feedback_replied 白名单 + emitter 都新增
report_resolved 白名单 + emitter 都新增
target_reported 白名单 + emitter 都新增

"点赞不进邮箱"细则v1.2.10 你确认):

  • 后端:socialService/asset_like_service.go:185CreateNotification(type="like") 保持不动(你给的理由:用户已习惯点赞是手机通知栏+红点的旧体验,改动会影响其他下游如社交动态)
  • 后端:notification_stats.like_unread_count保留,继续累加
  • 前端 mailbox 4 GROUPS§5.7不包含 like(已经合规)
  • 前端 profile.vue 📬 badge 显示 = sum(unreadByType[inbox 4 类]),不包含 like / system —— 用 Vuex getter 或 computed 过滤
  • 前端 Vuex REPLACE_UNREAD_BY_TYPE 仍接受全 6 类(保持与 server 响应形状一致),不展示≠不接收

修改清单:

  • service/notification_service.go allowedTypes map 加 3 个新 keyfeedback_replied / report_resolved / target_reportedactivity 已在 map 中无需动
  • proto/notification.proto CreateNotificationRequest.type 不动(已是 string
  • 4 处业务 emitter 真实接线(activityService 1-2 处、moderationService 3 处)—— 见 §3.3
  • socialService/asset_like_service.go 保持原样(改 like emitter,加 inbox 路由分支)

3.2 notification_stats 表 migrationbackend/migrations/2026XXXX_add_mailbox_unread_counts.sql

-- 加 3 列 unread 计数 (default 0)
ALTER TABLE public.notification_stats
    ADD COLUMN IF NOT EXISTS feedback_replied_unread_count INT NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS report_resolved_unread_count   INT NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS target_reported_unread_count  INT NOT NULL DEFAULT 0;

-- 加触发器:在 INSERT 通知后自动累加对应列
-- (略,本版本用 service 层事务内手动累加,触发器列为下期优化)

-- 加索引: 按 type 拉列表 (已有 is_read+user_id 索引可复用)
-- 现无新索引需求

-- PG 序列同步 (按 CLAUDE.md 强制)
-- 本 migration 无 INSERT 语句, 不涉及序列同步

3.3 emitter 字段契约(backend/services/moderationService/activityService/

3.3.1 type=feedback_replied

字段 来源 写入
user_id feedbacks.user_id 收件人 (反馈提交者)
star_id feedbacks.star_id0 必填(非空)
type 字面量 "feedback_replied" 写死
title "您的反馈已处理" 模板
content feedbacks.reply_content 全文(≤ 4KB postgres text 兜底) 信片主体
data {feedback_id, category_code, original_title} 用于右侧 footer 元信息

触发点: moderationService/service/feedback_service.go 中"管理员填写回复"流程 (status='replied', reply_content!=NULL 写入分支)。

3.3.2 type=report_resolved

字段 来源 写入
user_id reports.reporter_id
star_id reports.star_id0
type "report_resolved"
title "举报处理结果" 模板
content reports.resolution_note 全文NULL 时填"已处理",无原文)
data {report_id, resolved_action, target_type, target_id, category_code}
target_id(列) reports.id 用于反向定位

触发点: moderationService/service/report_service.go 中"resolved_* 字段全部非空"的提交分支。

3.3.3 type=target_reported

字段 来源 写入
user_id 目标对象的所有者(不是举报人) 反查: assets/feedbacks.owner_user_id
star_id 触发举报的 star_id
type "target_reported"
title "您的内容被举报"
content moderation_target_status.reason 全文
data `{report_id, target_type, target_id, action: 'warn' 'takedown'

触发点: moderationService/service/target_status_service.go 中"WarnCount > 阈值自动处置"或"管理员手动处置" 写入分支。**只有 action'warn'/'takedown'/'ban' 时才发通知**dismiss` 不发,避免打扰无辜作者)。

3.3.4 type=activity 字段契约(白名单已有,但零 emitter → 本期补建

⚠️ 自审发现:allowedTypes 已有 activity,但全仓 grep CreateNotification(... Type: "activity" 在 production 代码中零命中。本节是补建 emitter不是补强。

字段 写入
title 活动标题(取自 activities.title
content 活动摘要 / 留言内容
data {activity_id, message_id?}

触发点(本期新建 emitter

  • activityService/service/activity_message_service.go —— 别人在你的活动留言 → 给活动创建者发 (type=activity)
  • activityService/service/activity_service.go —— 活动开始 / 结束 → 给"我参与了该活动(购买过 / published_item_count>0"的用户发

验证grep Type:\s*"activity"

# 自审命令
grep -rn 'Type:\s*"activity"' backend/services/ --exclude="*_test*|*_mock*"
# 期: 命中 activity_message_service.go 和 activity_service.go 的 emitter 行

3.4 proto 调整

// proto/notification.proto

message UnreadCount {
  int32 like                 = 1;
  int32 system               = 2;
  int32 activity             = 3;
  int32 total                = 4;   // 总数(保留原字段号,不变)
  int32 feedback_replied     = 5;   // 新增(从 5 起追加,避免重编号破坏 wire 兼容)
  int32 report_resolved      = 6;
  int32 target_reported      = 7;
}

// ClearByType RPC (新)
service NotificationService {
  rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
    option (google.api.http) = {
      delete: "/api/v1/notifications/clear"
    };
  }
}
message ClearByTypeRequest {
  // 枚举 string: activity / feedback_replied / report_resolved / target_reported / all
  string type = 1;
}
message ClearByTypeResponse {
  topfans.common.BaseResponse base = 1;
  int32 affected = 2;
}

4. 后端设计

4.1 handler / service / repository 三层(按 CLAUDE.md 强制)

4.1.1 notificationService 改动清单

service/notification_service.go

  • 修改 allowedTypes map新增 3 个 key
  • ClearByType(ctx, userID, starID, ntype) 新方法 → 事务内:UPDATE is_deleted=true WHERE user_id=$1 AND star_id=$2 AND type=$3 AND is_deleted=false;同步清零 stats.<type>_unread_count
  • 现有 MarkAllAsRead 不变(已支持 3 个 type扩到 6 个 key 由 allowedTypes 自动覆盖)

repository/notification_repository.go

  • ClearByType(ctx, tx, userID, starID, ntype) 新方法
  • ListSystemActivity 不动(按 type 过滤已支持)

repository/notification_stats_repository.go

  • 加 3 列结构体字段(GORM tags 对应 DB schema
  • IncrementByType / DecrementByType / Get 三个方法改为通用 —— 原 hard-code like/system/activity 列名改为根据 type 字符串映射列名或用条件 CASE WHEN SQL。

controller/notification_controller.go (gateway)

  • 注册 ClearByTypeDELETE /api/v1/notifications/clear?type=...

4.1.2 moderationService 改动清单

service/feedback_service.go 中 "管理员回复反馈" 流程:

// 注入 notificationClient (gRPC) - 由 main.go 初始化
type FeedbackService struct {
    db       *gorm.DB
    notifCli notification.NotificationServiceClient
}

RepliedBy/RepliedAt/ReplyContent/Status='replied' 写入后同步:

// 事务提交后(避免回滚时通知已发出)
notifResp, err := s.notifCli.CreateNotification(ctx, &pb.CreateNotificationRequest{
    UserId: feedback.UserID,
    StarId: starIDZeroDefault(feedback.StarID),
    Type:   "feedback_replied",
    Title:  "您的反馈已处理",
    Content: *feedback.ReplyContent, // 全文
    Data: structpb.NewStruct(map[string]interface{}{
        "feedback_id":    feedback.ID,
        "category_code":  feedback.CategoryCode,
        "original_title": feedback.Title,
    }),
})

同样在 report_service.goResolved 流程 + target_status_service.goWarn/Takedown/Ban 流程补对应 emitter伪代码略结构一致

4.2 数据库变更(迁移)

文件: backend/migrations/2026XXXX_add_mailbox_unread_counts.sql

  • ALTER TABLE notification_stats 加 3 列(参见 §3.2
  • 不涉及新表 / 序列(避免触发 CLAUDE.md 的序列同步规则)

4.3 接口改动清单(汇总)

接口 动作 DTO / 入参 出参
DELETE /api/v1/notifications/clear 新增 ?type=activity|feedback_replied|report_resolved|target_reported|all { base, affected }
POST /api/v1/notifications/read-all 扩参 ?type=... 已支持 6 个值 同上
POST /internal/v1/notifications 不变 CreateNotificationRequest CreateNotificationResponse

4.4 错误码(统一 error

场景 错误 code
type 不在白名单 INVALID_ARGUMENT 400 + 文案 "invalid notification type"
user 未登录 UNAUTHENTICATED 401
越权访问别人的通知 PERMISSION_DENIED 403
type=clear 的 type=all 在生产灰度期间可能禁用 → 后端临时 409 FAILED_PRECONDITION 灰度期间

4.5 日志规范(按 CLAUDE.md 接口开发规范 §5

  • CreateNotification: 入口 INFO log user_id/star_id/type/title_len/data_len;失败 ERROR log含 stacktrace
  • ClearByType: 入口 INFO log事务失败 ERROR log
  • MarkAllAsRead (事务内 stats): 失败 ERROR log影响后续 unrea-count 准确性)

4.6 推送触发(沿用现有链路)

CreateNotification 成功后异步 goroutine → uni_push_client.go → 站内信 + 手机通知栏。不动,仅扩类型。


5. 前端设计

5.1 文件清单

路径 动作
pages.json 注册 pages/mailbox/index + pages/mailbox/detail 两页
pages/mailbox/index.vue 新建(列表页 / 4 分组可折叠)
pages/mailbox/detail.vue 新建(明信片页)
pages/mailbox/components/MailboxGroup.vue 新建(单分组列表组件)
pages/mailbox/components/Postcard.vue 新建(明信片组件)
pages/mailbox/composables/useMailboxCenter.js 新建4 type 并发拉取 + 单条操作)
store/modules/mailbox.js 新建Vuex itemsByType / unreadByType / mutations
utils/api.js 加 6 个函数(getNotificationsApi / getUnreadCountApi / markAsReadApi / markAllAsReadApi / deleteNotificationApi / clearNotificationsApi
App.vue #ifdef APP-PLUS 块注册 push receive/click 监听器(详见 §12.3

5.2 路由注册CLAUDE.md 强约束)

pages.json 改动:

{
  "path": "pages/mailbox/index",
  "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
},
{
  "path": "pages/mailbox/detail",
  "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
}

5.3 Vuex store/modules/mailbox.js

// 简化示意
const state = () => ({
  unreadByType: {
    activity: 0,
    feedback_replied: 0,
    report_resolved: 0,
    target_reported: 0,
  },
  totalUnread: 0,
})

const actions = {
  /**
   * 收到 receive 推送时调用,本地同步未读数(payload 自带 unreads_by_type)
   * 见 §12.4
   */
  applyPushPayload({ commit, state, rootState }, payload) {
    if (!payload || !payload.unreads_by_type) return
    commit('REPLACE_UNREAD_BY_TYPE', payload.unreads_by_type)
    commit('SET_TOTAL_UNREAD', payload.total_unread || 0)
    // 若用户已在邮箱页,本地插入新条目(避免用户下次进邮箱才看到)
    if (rootState.mailbox.isInMailboxPage && payload.nid) {
      commit('PREPEND_ITEM', {
        type: payload.type,
        notification: {
          id: payload.nid,
          title: payload.title,
          content: payload.content,
          data: payload.data,
          is_read: false,
          created_at: Date.now()
        }
      })
    }
  },
  /** 用户在列表页 pages/mailbox/index.vue 的 onShow / 下拉刷新时拉一页,顺便对齐红点(stats) */
  async alignFromServerUnread({ commit }) {
    const resp = await getUnreadCountApi()
    commit('REPLACE_UNREAD_BY_TYPE', resp.data.counts)
  },
  /** 单条标已读时,本地减 1已与后端 UPDATE 同步) */
  decrementOne({ commit }, { type }) {
    commit('DECREMENT_TYPE', type)
  },
}
  • 入口: App.vue#onLaunch 注册 plus.push.addEventListener('receive', cb)cb 内 dispatch('mailbox/applyPushPayload', payload)完全不走 HTTP 轮询
  • 兜底对齐: 列表页 pages/mailbox/index.vue onShow 拉一页数据时并发触发 alignFromServerUnread,仅此 1 次。
  • 不强求对齐: 列表页 pages/mailbox/index.vue onShow 时拉一页 getNotificationsApi(拉数据本身)时,并发调一次 alignFromServerUnread 同步 stats仅一次而非周期。

5.4 utils/api.js 新增(带注释模板)

注释风格与现有 registerDeviceApi 一致(块状中文注释 + 分隔行)。request 为项目内通用 uni.request 封装,封装函数中。

// ==================== 通知查询(spec §1.3) ====================
// 本节 6 个函数均为本次新增 wrapper。前端原本只在 utils/api.js 暴露了
// registerDeviceApi / unregisterDeviceApi 两个推送设备注册接口,
// 通知本身的 list/unread/markRead/markAllRead/delete/clear 全部需要在
// 收件箱功能落地时补齐(参见 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md)。
//
// 鉴权:由 gateway AuthMiddleware 强制 JWT,无 token 自动 401,前端无需再校验。
// 响应统一: { code, message, data, base? },data 字段才是真正的 payload。

// getNotificationsApi —— 拉一页通知(用于列表页 onShow 并发 4 个 type / 详情页单补拉)
//
// 参数:
//   type   :必填,通知 type 枚举字符串
//             可选值: 'activity' | 'feedback_replied' | 'report_resolved'
//                    | 'target_reported' | 'like' | 'system'
//             (前 4 个是收件箱主要业务 type,后 2 个仅占位,本期产品不暴露)
//   tab    :可选,二级过滤器(如 like 类的"未读/全部")。收件箱功能本期不传。
//   page   :可选,1-based 页码;默认 1,后端兜底
//   pageSize:可选,默认 20,后端兜底最多 100
//
// 返回:Promise<{ data: { items: Notification[], unreadByType?, total, page, pageSize } }>
//
// 业务流:
//   ① 列表页 pages/mailbox/index.vue 的 onShow → Promise.all(4 type 各拉首页)
//   ② 详情页 pages/mailbox/detail.vue 找不到 store 命中时 → 单补拉 50 条找 nid
//
// 注意:
//   GET 参数必须走查询串(项目约定,见 utils/api.js 现有 GET 用法如
//   /api/v1/social/friends?page=&page_size=);不允许 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 不可达兜底)
//
// 参数:无
//
// 返回:Promise<{ data: { like, system, activity, feedback_replied,
//                        report_resolved, target_reported, total } }>
//
// 业务流:
//   列表页 onShow 拉数据页时,并发调一次对齐 stats;平时零调用(红点靠 uniPush
//   receive 事件 + payload.unreads_by_type 本地增量,见 §12)
export function getUnreadCountApi() {
  return request({ url: '/api/v1/notifications/unread-count', method: 'GET' })
}

// markAsReadApi —— 单条标已读(列表项点击 / 明信片底部 [✓标已读])
//
// 参数:
//   id:必填,notifications.id(整数)
//
// 返回:Promise<{ data: void }>;前端按 resp.code === 0 判断成败
//
// 业务流:
//   ① 列表项点击 → 调用成功后再 navigateTo 详情页
//   ② 明信片页 [✓标已读] → 调用成功后留在页(用户可能再看一眼)
//
// 注意:后端事务内 ① UPDATE notifications SET is_read=TRUE WHERE is_read=FALSE
//                       ② UPDATE notification_stats.<type>_unread_count -= 1
// 后端已幂等(同 id 多次点击不会重复 -1,因为 WHERE 已过滤 is_read=FALSE)
export function markAsReadApi(id) {
  return request({ url: `/api/v1/notifications/${id}/read`, method: 'POST' })
}

// markAllAsReadApi —— 按 type 全标已读(列表页顶部 [+全部已读] 触发 4 次并发)
//
// 参数:
//   type:必填,通知 type 字符串 (同 getNotificationsApi 的 type)
//
// 返回:Promise<{ data: { affected } }>
//
// 业务流:
//   列表页顶部 [+全部已读] → Promise.all(4 个 type 各 1 次) → 本地 store
//   全部 type unread 清 0 + 4 组 items.map(n => ({...n, is_read: true}))
//
// 注意:
//   type 走 URL 查询串(由 backend gateway controller 用 g.Query("type") 提取,
//   见 notification_controller.go:404)。不要写 data: { type },否则 400。
export function markAllAsReadApi(type) {
  return request({ url: `/api/v1/notifications/read-all?type=${encodeURIComponent(type)}`, method: 'POST' })
}

// deleteNotificationApi —— 单条软删(列表项长按 [删除] / 明信片页 [🗑删除])
//
// 参数:
//   id:必填,notifications.id
//
// 返回:Promise<{ data: void }>
//
// 业务流:
//   ① 列表项长按 ActionSheet 第二项 → 本地 store REMOVE_ITEM
//   ② 明信片页 [🗑删除] → 本地 store REMOVE_ITEM + setTimeout 500ms 后
//      uni.navigateBack() 回列表页(让用户看到"删除" toast 再跳)
//
// 注意:后端 is_deleted=TRUE 软删,不真删;统计/红点不受影响(只 decr 标已读数)
export function deleteNotificationApi(id) {
  return request({ url: `/api/v1/notifications/${id}`, method: 'DELETE' })
}

// clearNotificationsApi —— 按 type 软删(本期不直接调用,但留给"按 type 清空"场景)
//
// 参数:
//   type:必填,枚举字符串: activity | feedback_replied | report_resolved
//                          | target_reported | all
//
// 返回:Promise<{ data: { affected } }>
//
// 业务流(本期):
//   列表页 [全部删除] → 仅传 'all' 一键清 4 类 → 本地 store CLEAR_ALL
//
// 注意:本期实现是 1 次 type='all' 调用;若以后加"按 type 清"按钮,直接传对应字符串。
// 后端:DELETE /api/v1/notifications/clear?type=<x> (v1.1 新加接口,见 §4)
//      type 走查询串(gateway controller g.Query("type") 提取,与 read-all 同款)
export function clearNotificationsApi(type = 'all') {
  return request({ url: `/api/v1/notifications/clear?type=${encodeURIComponent(type)}`, method: 'DELETE' })
}

注释规范要点(沿用 utils/api.js 既有约定):

  • 每组(功能类)函数用 // ==================== 功能名spec 章节) ==================== 横线分隔
  • 函数上方块状中文注释:功能名 —— 用途 (触发场景) \n 参数: \n 返回: \n 业务流: \n 注意:
  • 不引入 JSDoc项目未使用 JSDoc 格式)
  • 函数体保持原 request({ url, method, data }) 三件套
  • 复杂字段(如 type 枚举)就地在注释里列字符串列表,不外链到 .d.ts

5.5 pages/mailbox/index.vue 列表页Vue 3 <script setup>

<template>
  <view class="mailbox-list">
    <CustomHeader title="收件箱" :showBack="true" />
    <view class="toolbar">
      <button class="btn-mark-all" @click="onMarkAllRead">[+全部已读]</button>
      <button class="btn-delete-all" @click="onClearAll">[+全部删除]</button>
    </view>

    <scroll-view scroll-y="true" class="list-scroll" @scrolltolower="onScrollLower">
      <MailboxGroup
        v-for="grp in GROUPS" :key="grp.type"
        :groupKey="grp.type"
        :label="grp.label"
        :collapsed="isCollapsed(grp.type)"
        :items="itemsByType[grp.type]"
        :unreadCount="unreadByType[grp.type] || 0"
        @toggle="toggleCollapsed(grp.type)"
        @select="onSelectItem"
        @long-press="onLongPress"
      />
      <view v-if="!hasAny && !loading" class="empty">暂无通知</view>
      <view v-if="loading" class="loading">加载中...</view>
      <view v-if="hasAny && !hasMore" class="no-more">没有更多了</view>
    </scroll-view>
  </view>
</template>

<script setup>
import { computed, onMounted, onShow } from 'vue'
import { useMailboxCenter } from '../composables/useMailboxCenter.js'

const {
  GROUPS, itemsByType, unreadByType,
  loading, hasAny, hasMore,
  loadAll, onScrollLower,
  onMarkAllRead, onClearAll,
  onSelectItem, onLongPress,
  isCollapsed, toggleCollapse,
} = useMailboxCenter()

onShow(() => { loadAll() })
</script>

5.6 pages/mailbox/detail.vue 明信片页Vue 3 <script setup>

<template>
  <view class="mailbox-detail">
    <CustomHeader title="明信片" :showBack="true" />
    <view v-if="notification" class="postcard-wrap">
      <Postcard
        :notification="notification"
        @mark-read="onMarkRead"
        @delete="onDeleteOne"
      />
    </view>
    <view v-else class="empty-detail">明信片不存在或已删除</view>
    <!-- 底部固定 toolbar: 单条 [✓标已读] + [🗑删除] -->
    <view v-if="notification" class="postcard-toolbar">
      <button
        v-if="!notification.is_read"
        class="btn-mark-read"
        @click="onMarkRead"
      >[✓ 标已读]</button>
      <button v-else class="btn-mark-read-disabled" disabled>[已读]</button>
      <button class="btn-delete" @click="onDeleteOne">[🗑 删除]</button>
    </view>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { onLoad } from '@dcloudio/uni-app'
import { useMailboxCenter } from '../composables/useMailboxCenter.js'
import { getNotificationsApi } from '@/utils/api.js'

const store = useStore()
const { onMarkRead: mr, onDeleteOne: del } = useMailboxCenter()
const nid = ref(null)
const type = ref(null)

onLoad((options) => {
  nid.value = Number(options.nid)
  type.value = options.type
})

const notification = computed(() => {
  if (!nid.value || !type.value) return null
  // 优先从 store 取(已加载过的条目)
  const item = store.state.mailbox.itemsByType[type.value]?.find(n => n.id === nid.value)
  if (item) return item
  // store 没有则单条拉一次(杀进程后退 push 进来场景)
  loadOne()
  return store.state.mailbox.detailCache || null
})

async function loadOne() {
  const resp = await getNotificationsApi({ type: type.value, page: 1, page_size: 50 })
  const item = (resp.items || []).find(n => n.id === nid.value)
  if (item) store.commit('mailbox/SET_DETAIL_CACHE', item)
}

async function onMarkRead() {
  await mr(nid.value, type.value)
  uni.showToast({ title: '已标已读', icon: 'none' })
  // 留在本页(用户可能想再看一眼),也可选择 setTimeout 后 navigateBack
}

async function onDeleteOne() {
  await del(nid.value, type.value)
  uni.showToast({ title: '已删除', icon: 'none' })
  setTimeout(() => uni.navigateBack(), 500)
}
</script>

5.7 useMailboxCenter.js(组合式 API

import { computed } from 'vue'
import { useStore } from 'vuex'
import {
  getNotificationsApi, markAsReadApi, markAllAsReadApi,
  deleteNotificationApi, clearNotificationsApi
} from '@/utils/api.js'

const GROUPS = [
  { type: 'activity',         label: '活动通知' },
  { type: 'feedback_replied', label: '反馈回复' },
  { type: 'report_resolved',  label: '举报结果' },
  { type: 'target_reported',  label: '被举报' },
]

export function useMailboxCenter() {
  const store = useStore()

  const itemsByType   = computed(() => store.state.mailbox.itemsByType)
  const unreadByType  = computed(() => store.state.mailbox.unreadByType)
  const loading       = computed(() => store.state.mailbox.loading)
  const hasAny        = computed(() => GROUPS.some(g => (itemsByType.value[g.type] || []).length > 0))
  const hasMore       = computed(() => Object.values(store.state.mailbox.noMoreByType).some(v => !v))

  // 列表页 onShow 时并发 4 个 type 各拉首页
  async function loadAll() {
    store.commit('mailbox/SET_LOADING', true)
    try {
      await Promise.all(GROUPS.map(g =>
        getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
          .then((resp) => {
            store.commit('mailbox/SET_ITEMS', { type: g.type, items: resp.items || [], sortUnreadFirst: true })
            store.commit('mailbox/SET_NO_MORE', { type: g.type, value: (resp.items || []).length < 20 })
            if (resp.unreadByType) store.commit('mailbox/REPLACE_UNREAD_BY_TYPE', resp.unreadByType)
          })
      ))
    } finally {
      store.commit('mailbox/SET_LOADING', false)
    }
  }

  function onScrollLower() {
    const target = GROUPS.find(g => !store.state.mailbox.noMoreByType[g.type])
    if (target) loadMore(target.type)
  }

  async function loadMore(type) {
    const page = Math.floor((itemsByType.value[type].length / 20) + 1)
    const resp = await getNotificationsApi({ type, page, page_size: 20 })
    store.commit('mailbox/APPEND_ITEMS', { type, items: resp.items || [] })
    store.commit('mailbox/SET_NO_MORE', { type, value: (resp.items || []).length < 20 })
  }

  async function onMarkRead(nid, type) {
    await markAsReadApi(nid)
    store.commit('mailbox/UPDATE_ITEM', { type, id: nid, patch: { is_read: true } })
    store.commit('mailbox/DECREMENT_TYPE', type)
    store.commit('mailbox/RESORT_ITEMS', type)  // 已读排后
  }

  async function onMarkAllRead() {
    await Promise.all(GROUPS.map(g => markAllAsReadApi(g.type)))
    store.commit('mailbox/MARK_ALL_READ')
  }

  async function onDeleteOne(nid, type) {
    await deleteNotificationApi(nid)
    store.commit('mailbox/REMOVE_ITEM', { type, id: nid })
  }

  async function onClearAll() {
    const ok = await new Promise((resolve) => {
      uni.showModal({ title: '提示', content: '清空所有通知?', success: (r) => resolve(r.confirm) })
    })
    if (!ok) return
    await clearNotificationsApi('all')
    store.commit('mailbox/CLEAR_ALL')
  }

  function onSelectItem(nid, type) {
    onMarkRead(nid, type)
    uni.navigateTo({ url: `/pages/mailbox/detail?nid=${nid}&type=${type}` })
  }

  function onLongPress(nid, type) {
    uni.showActionSheet({
      itemList: ['标已读', '删除'],
      success: (res) => {
        if (res.tapIndex === 0) onMarkRead(nid, type)
        else if (res.tapIndex === 1) onDeleteOne(nid, type)
      }
    })
  }

  function isCollapsed(type) {
    const saved = uni.getStorageSync('mailbox_collapsed') || {}
    return !!saved[type]
  }

  function toggleCollapse(type) {
    const saved = uni.getStorageSync('mailbox_collapsed') || {}
    saved[type] = !saved[type]
    uni.setStorageSync('mailbox_collapsed', saved)
  }

  return {
    GROUPS,
    itemsByType, unreadByType, loading, hasAny, hasMore,
    loadAll, onScrollLower, loadMore,
    onMarkRead, onMarkAllRead, onDeleteOne, onClearAll,
    onSelectItem, onLongPress,
    isCollapsed, toggleCollapse,
  }
}

5.8 Vuex mailbox module 关键 mutations (摘要)

SET_ITEMS       (state, { type, items, sortUnreadFirst }) {
  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 } },
MARK_ALL_READ   (state) {
  state.unreadByType = { activity: 0, feedback_replied: 0, report_resolved: 0, target_reported: 0 }
  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] = [])
  state.unreadByType = {}
},
SET_DETAIL_CACHE (state, item) { state.detailCache = item }

5.9 样式CSS / SCSS

// pages/mailbox/index.vue 列表页
.mailbox-list {
  display: flex; flex-direction: column; height: 100vh; background: #f7f8fa;
  .toolbar {
    display: flex; justify-content: space-between; padding: 16rpx 24rpx;
    background: #fff; border-bottom: 1rpx solid #eee;
    .btn-mark-all, .btn-delete-all {
      font-size: 26rpx; padding: 12rpx 24rpx; border-radius: 32rpx;
      background: #5C40FF; color: #fff;
    }
    .btn-delete-all { background: #f5222d; }
  }
  .list-scroll { flex: 1; padding: 24rpx; }
  .empty, .loading, .no-more {
    text-align: center; color: #999; padding: 48rpx 0; font-size: 26rpx;
  }
}

// pages/mailbox/detail.vue 明信片页
.mailbox-detail {
  display: flex; flex-direction: column; height: 100vh; background: #f7f8fa;
  .postcard-wrap { flex: 1; padding: 24rpx; padding-bottom: 140rpx; }
  .empty-detail { padding: 80rpx; text-align: center; color: #999; }
  .postcard-toolbar {
    position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx;
    display: flex; gap: 16rpx; padding: 16rpx 24rpx;
    background: #fff; border-top: 1rpx solid #eee;
    .btn-mark-read { flex: 1; background: #5C40FF; color: #fff; border-radius: 16rpx; font-size: 28rpx; }
    .btn-mark-read-disabled { flex: 1; background: #f0f0f0; color: #999; border-radius: 16rpx; font-size: 28rpx; }
    .btn-delete { flex: 1; background: #f5222d; color: #fff; border-radius: 16rpx; font-size: 28rpx; }
  }
}

5.10 CLAUDE.md 合规清单

  • Vue 3 组合式 API<script setup>),不复用 myReports/myFeedbacks 的 Options API
  • 接口走 utils/api.js(不裸 uni.request
  • 跨页状态走 Vuex (mailbox module) + 明信片页 onLoad 取 nid/type 从 store 取条目
  • 新页已注册(index / detail 两页路由)
  • profile.vue 服务与工具 section 加 📬 收件箱 service-button参考同 section "新手指引/反馈" 等的视觉规范)
  • 不动任何 setInterval / setTimeout 轮询
  • 不动 unpackage/dist/
  • 移动端优先list 页全宽 + tap push 到 detail 页全宽 + 列表页底部 [全部已读 / 全部删除] / 明信片页底部 [标已读 / 删除] 两套操作按钮
  • 列表操作 = 明信片操作覆盖: 4 个操作任何页面都能完成
  • 不长列表(明信片内容由后端控制长度)

6. 布局与可达性

6.1 布局策略v1.2 修订:手机竖屏为主,放弃 master-detail

经用户反馈"竖屏分栏两边都挤",本期改为两页路由

场景 页面 布局
进入收件箱 pages/mailbox/index.vue 全宽列表4 分组可折叠),底部 [全部已读 / 全部删除]
点列表项 pages/mailbox/detail.vue?nid=&type= 全宽明信片,底部 [标已读 / 删除]

为什么不做响应式分栏:手机竖屏 50/50 两边都很挤iPhone 默认宽 750rpx 远小于 pad 1024+iPad / 横屏以后真要做分栏,Stage 2manifest.jsontransformPx 自适应即可YAGNI

6.2 横竖屏切换

  • 两页路由不受横竖屏影响(始终全宽)
  • onWindowResize 无需监听(不再用 useMailboxLayout

6.3 可达性

  • 列表项 aria-labeltitle + 时间 + 未读状态
  • 长按 0.5s 触发 ActionSheetios 长按手势)
  • 单色 + 图标双重提示未读( 红点 + "未读" 字样)
  • 屏幕阅读器读 title + 内容

7. 错误处理与边界

7.1 网络错

  • 4 个 type 并发请求:任一失败 → 该 group 顶部显示 [重新加载](独立 retry
  • 500/网络断:uni.showToast "网络失败" + 内联 error 状态

7.2 未登录

  • pages/mailbox/index 入口:守卫 if(!token) navigateBack()
  • profile.vue 服务与工具 section 加的 📬 按钮v-if="userStore.token"(未登录用户看不到入口,与 profile 页面其他按钮一致)
  • profile.vue onShow 触发 dispatch('mailbox/refreshUnread')(在 store 里再判一次 token,避免未登录调 API 401
  • profile.vue 红点 badge 显示值v1.2.10 你确认)= 收件箱 4 类未读之和,排除 like 和 system:
// store/modules/mailbox.js
getters: {
  // 收件箱 badge 用 —— 只算 4 类,不含 like / system
  inboxUnreadOnly(state) {
    const { activity = 0, feedback_replied = 0,
            report_resolved = 0, target_reported = 0 } = state.unreadByType
    return activity + feedback_replied + report_resolved + target_reported
  }
}

// pages/profile/profile.vue 引用
// import { useStore } from 'vuex'
const store = useStore()
const inboxUnread = computed(() => store.getters['mailbox/inboxUnreadOnly'])
// 模板: <text v-if="inboxUnread > 0" class="mailbox-badge">{{ inboxUnread }}</text>
  • 边界: 当 4 类全 0 时 badge 隐藏(v-if);totalUnread(含 like)仅用于 inbox 内部 4 类分组展开角标,不再用于 profile.vue 红点

7.3 空态

  • 全部 4 group 都为空 → 显示"暂无通知"大空态(带"去看看发现" 二级按钮跳广场)

7.4 越权

  • 后端 WHERE user_id=$1 强制(已存在),前端不能传别人的 id

7.5 并发 / 节流v1.2.6 加入节流说明)

  • markRead 同一 id 多次点击: 后端 UPDATE ... WHERE is_read=false 自动幂等
  • clearAll 4 type 并发: 事务独立, 各自累加 stats 清零
  • uniPush 推送节流三道防线(详见 §12.7:后端 60s / (user,type) 滑窗 + App 10s 同 type 去抖 + Vuex 幂等,覆盖"管理员批量处理 → 用户手机狂震"的灾难场景

7.6 PG 序列同步CLAUDE.md 强约束)

  • 本设计无 INSERT 手动指定 id 的 SQL 脚本,均为 ALTER TABLE。不触发序列同步规则
  • 若手写测试 SQLSELECT setval('notifications_id_seq', (SELECT MAX(id) FROM notifications)) 写在文末。

7.7 边界

  • content 长度上限 500 (notification.content)data 长度 4KB
  • feedback_replied.content 如果 reply_content > 500 → 截断到 497 字符 + "..."
  • report_resolved.content 如果 resolution_note > 500 → 截断
  • 明信片显示时: 全文已塞进 content, 直接渲染, 无超长问题

8. 测试计划

8.1 后端单测 (按 CLAUDE.md 接口开发规范 §9)

backend/services/notificationService/service/notification_service_test.go:

  • TestCreateNotification_FeedbackReplied —— 验 type 白名单activity/feedback_replied/report_resolved/target_reported 4 类stats 累加对应列
  • TestCreateNotification_TargetReportedTypeRejected —— 验未授权 / 错 type 报错
  • TestClearByType_PartialAll —— 验 type=all 真清 4 类type=single 限一类

backend/services/moderationService/service/moderation_service_test.go (新增):

  • TestFeedbackReplied_TriggersNotification —— mock notificationClient.CreateNotification 收到 1 次调用参数对得上type=feedback_replied, content=reply_content 全文)
  • TestReportResolved_TriggersNotification —— type=report_resolved, content=resolution_note
  • TestTargetReported_TriggersNotification —— 只在 action ∈ {warn/takedown/ban} 时发dismiss 不发

backend/services/activityService/service/activity_emitter_test.go (新增):

  • TestActivityMessage_TriggersActivityNotification —— 别人在你活动下留言 → mock notificationClient.CreateNotification 收到 1 次 type=activity
  • TestActivityStartEnd_TriggersActivityNotification —— 活动开始触发 → 收到 N 次 type=activityN=参与该活动的用户数)

8.2 后端集成测试 (可选 / Stage 2)

  • cmd/integration/mailbox_test.go —— 拉真 DB + 真 notification service, 事务回滚
  • (本次 MVP 暂不强制, mock 已能覆盖)

8.3 前端手验清单(按 share-impl-test-policy.md 规则不写 vitest

手验 case:

  • 1. 登录账号 → 进入 pages/profile/profile.vue → "服务与工具" section 看到 📬 收件箱 按钮 + 红点 N badge
  • 2. 点 📬 进入邮箱 → 4 分组 + 未读优先排前
  • 3. 点列表项 → 右明信片同步,列表项 ● → ○,红点 -1
  • 4. 点明信片 [✓标已读] → 同上
  • 5. 点 全部已读 → 4 组全 ,红点 0
  • 6. 点明信片 [🗑删除] → 单条消失
  • 7. 点 全部删除 → 4 组清空,出现"暂无通知"空态
  • 8. 长按列表项 → 弹出 ActionSheet [✓ / 🗑]
  • 9. 折叠分组 → 高度收起;uni.setStorage 记住
  • 10. 退出重进 → 折叠状态保持
  • 11. 列表页点列表项 → 跳详情页 + 该项标已读 → navigateBack 回到列表页 → 滚动位置不变
  • 12. 详情页点 [🗑 删除] → 删除完成 → 500ms 后 navigateBack → 列表页该项消失
  • 12.1. 横竖屏切换 → 两页路由不受影响(不重排,单页始终全宽)
  • 13. 模拟后端 emit notification管理员回复反馈App 在前台 → 红点 +1 + 邮箱页内新条目实时插入(网络面板观察 0 个 HTTP 请求
  • 14. App 后台 时收到推送 → 系统通知栏弹通知 → 点通知栏 → 跳 pages/mailbox/index?focus=<type>&nid=<id>(列表页 onLoad 时定位到该 nid 项并自动显示其明信片)
  • 15. 关闭 App 网络 → 禁用 push → 用户进入列表页 → 列表页 onShow 拉一页 getNotificationsApi → 列表 + 红点对齐(仅这一次,不是周期)
  • 16. 全局搜索 setInterval / setTimeout → 仅命中既有业务代码(动画 / 计时器),邮件模块 0 命中
  • 17. 后端节流 + 聚合验证:手动在 30s 内连续触发 5 条 feedback_replied 通知mock moderationService→ 观察日志:第 1 条 mode=concrete 后 4 条 mode=summary;summary 推送 title 应递增 "您有 2 条新反馈回复" → "您有 3 条..." → "您有 4 条..." → "您有 5 条新反馈回复"v1.2.8 验证);用户手机 5 次弹出,每次 title 数字递增;App 内 unreads_by_type 等于全量累加,feedback_replied 总数 +5
  • 18. App 端 10s 去抖:模拟 2 条 feedback_replied 推送 100ms 内先后进入 receive → App 只 commit 1 次 Vuex REPLACE观察 Vue devtools
  • 19. Vuex 幂等:手动 dispatch 2 次相同 nid 的 PREPEND_ITEM → 第 2 次早返回,列表 nid 不重复
  • 20. count 边界:手动清空 RateLimiter.pending 灌入 50 条 feedback_replied → 应见到 50 条 summary push,count 从 1 到 50 递增(验证 count 无上限泄漏)

8.4 Postman / curl 脚本

附在 §9 文档根。MVP 不强制交付。


9. 文档 / 运维

9.1 Swagger 注释

backend/gateway/router/router.go 已有 8 个通知接口的 swagger 注释路径,新增 DELETE /api/v1/notifications/clear 同步加注释

9.2 README / Changelog

  • 仓库 README.md 不动(产品文档)
  • 仓库 changelog 由用户决定是否更新

9.3 数据库迁移文件命名

  • backend/migrations/2026XXXX_add_mailbox_unread_counts.sql
  • 提交: git mv 旧 migration 文件 + 新增, 单 commit feat: 邮箱聚合收件箱 - notification_stats 加 3 列 unread 计数

9.4 部署

详见 §11。


10. 目录变更

10.1 新增文件

仅列全新创建的文件;已有文件的修改在 §10.2。

frontend/
  pages/mailbox/
    index.vue                                  (新,列表页)
    detail.vue                                 (新,明信片页)
    components/
      MailboxGroup.vue                         (新)
      Postcard.vue                             (新)
    composables/
      useMailboxCenter.js                      (新)
  store/modules/
    mailbox.js                                 (新)

backend/
  migrations/
    2026XXXX_add_mailbox_unread_counts.sql     (新)
  services/notificationService/service/notification_service_test.go (新增 ClearByType 用例)
  services/moderationService/service/moderation_service_test.go (新增 3 emitter 用例)
  services/activityService/service/activity_emitter_test.go (新增 2 emitter 用例)

10.2 修改文件

现有文件的 diff含 utils/api.js 的 6 个新函数。

frontend/
  pages.json                                   (注册 mailbox/index + mailbox/detail)
  App.vue                                      (#ifdef APP-PLUS 加 push receive/click 监听)
  pages/profile/profile.vue                    (v1.2.9 在 <!-- 服务与工具 --> section 加 📬 收件箱 service-button + 红点 badge + navigateTo + onShow 刷新 unread)
  utils/api.js                                 【6 新函数】getNotificationsApi / getUnreadCountApi / markAsReadApi / markAllAsReadApi / deleteNotificationApi / clearNotificationsApi

backend/
  proto/notification.proto                     (新增 ClearByType RPC + UnreadCount 3 列字段号 5/6/7)
  services/notificationService/
    repository/notification_repository.go       (新增 ClearByType)
    repository/notification_stats_repository.go(改通用 type 列名)
    service/notification_service.go            (扩 allowedTypes map 3 个新 key + ClearByType 业务方法 + triggerPush 内注入 unreads_by_type / total_unread 进 data map,见 §12.2 + 注入 §12.7 RateLimiter + v1.2.8 count 聚合 title 生成)
  pkg/push/uni_push_client.go                  (新增 RateLimiter 结构 + Allow() + windowMs + pending map + sentMode map + TypeChinese();Payload 结构不变)
  gateway/router/router.go                     (注册 DELETE /api/v1/notifications/clear)
  gateway/controller/notification_controller.go(ClearByType HTTP handler)
  services/moderationService/service/
    feedback_service.go                        (新 emitter: type=feedback_replied, content=reply_content)
    report_service.go                          (新 emitter: type=report_resolved, content=resolution_note)
    target_status_service.go                   (新 emitter: type=target_reported 仅 warn/takedown/ban)
  services/activityService/service/
    activity_message_service.go                (新 emitter: type=activity, 留言下发触发)
    activity_service.go                        (新 emitter: type=activity, 活动开始/结束触发)

10.3 不动文件

  • frontend/pages/profile/myReports.vue
  • frontend/pages/profile/myFeedbacks.vue

理由: 避免无关改动,遵循 CLAUDE.md 的"一个 commit 只做一件事"原则;这些文件已无下游引用,保留不影响新功能。


11. 部署与回滚

11.1 部署顺序

  1. 数据库 migration: 先 PG alter notification_stats 加 3 列(幂等 IF NOT EXISTS,重复执行无副作用)
  2. 后端服务更新:
    • notificationService 重启(迁移 + ClearByType RPC + 仓库改动)
    • moderationService 重启3 处 emitter
    • activityService 重启1-2 处 emitter
    • gateway 重启(路由注册 ClearByType
  3. App 端: 热更新 / 强制更新都可(无 schema breaking change

11.2 灰度

  • MVP 不做灰度CLAUDE.md MVP 先行原则),全量上线
  • 新接口 DELETE /clear 全量开放;若有线上问题,紧急禁用 router 注册1 行 + 重启 gateway

11.3 回滚

故障 回滚步骤
emitter 误发 moderationService 撤回本次 commit 即可;同时 PG DELETE FROM notifications WHERE type IN (...) AND created_at > '故障时间'
notification_stats 3 列已上 migration 加 IF NOT EXISTS 即可;不回退(数据无副作用)
ClearByType 误用 临时移除 router 路径(注释 + 重启)

11.4 监控

  • 推送数维持现状uniPush 通道不动
  • 通知表写入速率:已有 prometheus on notifRepo.Create;如需要写 release notes 给运维

12. 实时通知机制uniPush receive 驱动)

本节替代原"实时推送 / 广场 onShow 轮询"反例 —— 我们在移动端有更好的办法。

12.1 实现思路

  • 后端通知写入即推送:项目已接 uni_push_client.goCreateNotification 成功时异步 emit payload 到 uniPush 通道。
  • App 端 App.vue#onLaunch 注册 plus.push.addEventListener('receive', cb)app 在前台时uniPush 也走 receive 回调(不走通知栏)→ App 直接拿到 payload。
  • payload 已带 unreads_by_type 增量:后端推送时把"按 type 的增量未读数"塞进 payloadApp 拿到直接 update Vuex不发 HTTP 请求 / 无需轮询
  • 不依赖定时器 / onShow 轮询。事件驱动:① 后端 INSERT notification ② 推送 emit ③ App receive 回调 ④ Vuex commit。

12.2 后端推送 payload 字段契约

⚠️ 自审 v1.2.4 校正uni_push_client.go:29Payload.Datamap[string]interface{}JSON 对象),不是 JSON 字符串。 App 端读 msg.payload.data.notification_id 等字段,不是 msg.payload.nid。 现有 notification_service.go:211triggerPushnotification_id / type / star_id + 业务 n.Data JSON 反序列化后塞进 data map。 我们新增 key unreads_by_type + total_unread 进 data map,语义零变化。

backend/pkg/push/uni_push_client.go 推送时 payload 整体结构:

{
  "cids": ["cid-1", "cid-2"],
  "title": "您的反馈已处理",           // = notifications.title
  "content": "完整回复全文...",          // = notifications.content
  "request_id": "1700000000000_abcd",
  "data": {                          // map[string]interface{}, App 端核心读取
    "notification_id": 12345,       // = notifications.id (现有字段)
    "type": "feedback_replied",     // = notifications.type (现有字段)
    "star_id": 1,                   // = notifications.star_id (现有字段)
    "feedback_id": 1,               // = notifications.data.feedback_id (现有字段,来自业务 emitter)
    "category_code": "login",       // = notifications.data.category_code (现有字段)
    "original_title": "登录失败",     // = notifications.data.original_title (现有字段)
    "unreads_by_type": {            // 🆕 本期新增,APP 端本地 +1 不查 HTTP
      "activity": 8,
      "feedback_replied": 3,
      "report_resolved": 5,
      "target_reported": 2
    },
    "total_unread": 18              // 🆕 本期新增,等于 Σ unreads_by_type
  }
}

后端组装时机(在 triggerPush 内、Send 前):

// notification_service.go:175 triggerPush 内追加
stats, err := s.statsRepo.Get(cctx, n.UserID, n.StarID)  // stats 含 6 类 unread count
if err == 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
}

为什么 user_id 不在 data 里cids 字段决定了推送谁;data 只放 App 端跳转 / 红点 / 详情展示需要的字段。

12.3 App.vue 初始化(#ifdef APP-PLUS

// #ifdef APP-PLUS
import { registerDeviceApi } from '@/utils/api.js'
import store from '@/store/index.js'

export default {
  onLaunch() {
    // 1. 拿 cid 上报
    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
        })
      }
    })

    // 2. 注册 receive 监听器(前台 + 后台都触发)
    // msg.payload 是 uniCloud 推过来的整个 payload 字符串 (JSON),
    // 真正的业务字段都在 envelope.data 嵌套对象里 (见 §12.2)。
    // 注意:Vuex action 需要 title/content 用于 PREPEND_ITEM 显示新条目,
    //      这些字段在 envelope 顶层,所以打包 { data, title, content } 一起传。
    plus.push.addEventListener('receive', (msg) => {
      try {
        const envelope = JSON.parse(msg.payload || '{}')
        const data = envelope.data || {}
        store.dispatch('mailbox/applyPushPayload', {
          data,                                       // 内层业务字段(含 unreads_by_type / notification_id)
          title: envelope.title || '',               // 外层 title
          content: envelope.content || ''            // 外层 content
        })
      } catch (e) {
        console.error('[mailbox] receive payload parse failed', e)
      }
    })

    // 3. 注册 click 监听器(用户点通知栏条目时跳转邮箱)
    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) {}
    })
  }
}
// #endif

关键自审点(v1.2.4):data 必须从外层 envelope 里解,不能直接拿 envelope 当 data 用。原本(v1.2.3 前)的"applyPushPayload(payload)"写法会让 action 收到的永远是 {cids, title, content, data, ...} 外层,内层 unreads_by_type 永远拿不到。

12.4 Vuex mailbox/applyPushPayload action

// store/modules/mailbox.js
//
// 收到 receive 推送时调用,本地同步未读数
//
// 参数 data = App.vue 解出的 envelope.data (见 §12.3),即:
//   {
//     notification_id, type, star_id,
//     feedback_id/category_code/original_title ... (业务字段),
//     unreads_by_type: { activity: 8, feedback_replied: 3, ... },
//     total_unread: 18
//   }
actions: {
  applyPushPayload({ commit, rootState }, { data, title = '', content = '' }) {
    if (!data || !data.unreads_by_type) return  // 不是通知类推送(可能 cids/系统消息)直接忽略

    // 1. 直接用 server 端投递的权威未读数覆盖本地(避免本地累计漂移)
    commit('REPLACE_UNREAD_BY_TYPE', data.unreads_by_type)
    commit('SET_TOTAL_UNREAD', data.total_unread || 0)

    // 2. 若用户在邮箱页,本地插入新条目(否则下次进邮箱才能看到)
    if (rootState.mailbox.isInMailboxPage && data.notification_id && data.type) {
      commit('PREPEND_ITEM', {
        type: data.type,
        notification: {
          id: data.notification_id,
          title: title || '',
          content: content || '',
          data: JSON.stringify(data),         // 业务字段 JSON 字符串
          is_read: false,
          created_at: Date.now()
        }
      })
    }
  }
}

参数从 envelope 改为 { data, title, content } 由 App.vue 解 envelope 后打包传入。

12.5 边界场景

场景 行为
用户禁用了所有 push 通道iOS 设置 / Android 自启动管理) App 不触发 receive用户进入邮箱页时拉一页 GET /api/v1/notifications?type=... 自然对齐(顺带把红点刷新)
App 后台 时收到推送 系统通知栏显示;用户点通知栏click 监听器 → 跳 pages/mailbox/index?focus=type&nid=...,列表页 onLoad 命中焦点项 → 自动 navigateTo 明信片页
App 前台 时收到推送 不弹系统通知栏uniPush 默认),触发 receive 回调 → Vuex 更新 → 红点 +1 / 邮箱页若有此 type 在展示 → 实时插入
App 杀进程 后被推送唤醒 启动时 App.vue#onLaunch 触发 → 注册 receive + 拉一次 cid → 新推送按 receive 处理
多设备登录 每设备独立 cid各自 receive本设计按设备独立维护未读最后一端以最后一次 receive 为准(写在 §12 老反例撤回说明里)
payload 解析失败 applyPushPayload 内已 catch, 不影响后续推送

12.6 不在本期范围

  • 邮件发送 / 真实 SMTP"邮箱"语义是站内信)
  • Tab 切换(已切到分组模式)
  • 折叠/展开 明信片本身(明信片永远全开)
  • 通知撤回 / 归档按钮
  • 详情页跳转 (绕过 myReports/myFeedbacks/任何详情子页)
  • WebSocket / SSE / 自建长连接 —— uniPush 已提供事件通道,本期不引新通道
  • 推送通道替代方案调研(个推/HMS/miPush/oppoPush/vivoPush 都在 uniPush 后端,本设计透明)
  • 定时器轮询 / setInterval / setTimeout 任何形式的"周期性请求"
  • 跨设备未读数强同步(先按设备独立,最终一致性即可)

12.7 推送节流三道防线v1.2.6 新增)

问题场景:管理员一次性处理 5 条举报 → 5 条 CreateNotification → 5 个 user_device 推送 → 用户手机5 次系统通知栏弹出 + 5 次震动 + 5 次 App receive 回调。这是灾难级 UX。

目标

  1. 手机通知栏最多 1 次系统提示(合并的多条用 summary 文案)
  2. 红点始终精确5 条全 1 +5
  3. 邮箱列表全inbox 页 onShow 拉数据时仍是 5 条)
  4. 零额外 HTTP / 零定时器

第一道后端推送合并pkg/push 层)

文件: backend/pkg/push/uni_push_client.goRateLimiter 结构v1.2.8 加入 count 聚合)。

// RateLimiter 节流器,key = "user_id:star_id:type"。
// 三个状态字段:
//   lastSent: key → lastSentUnixMilli         (窗口判定)
//   pending:  key → 该 key 本窗口累计         (聚合计数)
//   sentMode: key → 最近一次 Send 的模式      (concrete / summary)
//
// windowMs 默认 60000 (60s, 见 §12.7 调优日志)。
type RateLimiter struct {
    mu       sync.Mutex
    lastSent map[string]int64
    pending  map[string]int
    sentMode map[string]string
    windowMs int64
}

// Allow 判定本次推送应走的模式:
//   - key 首次出现 / 窗口已过 → mode=concrete, count=1, lastSent 重置
//   - key 在窗口内 → mode=summary, pending[key]++, count=pending[key]
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]
}

// 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 "通知"
    }
}

notification_service.go:175 triggerPush 内改造:

key := fmt.Sprintf("%d:%d:%s", n.UserID, n.StarID, n.Type)
mode, count := s.rateLimiter.Allow(key)

title, content := n.Title, n.Content
if mode == "summary" {
    title = fmt.Sprintf("您有 %d 条新%s", count, push.TypeChinese(n.Type))
    content = "点击查看收件箱"
}

// data map 内 unreads_by_type/total_unread 仍真塞(§12.2 既有逻辑),summary 也带。
// summary 不塞 notification_id,App 端识别不到 nid 不做 PREPEND_ITEM。

聚合展示示例(管理员 5s 内批处理 5 条 feedback_replied60s 窗口内):

  • t=0s: 模式=concrete, count=1, push title="您的反馈已处理: 登录失败", content="完整回复..."
  • t=5s: 模式=summary, count=2, push title="您有 2 条新反馈回复", content="点击查看收件箱"
  • t=15s: 模式=summary, count=3, push title="您有 3 条新反馈回复", content="点击查看收件箱"
  • t=30s: 模式=summary, count=4, push title="您有 4 条新反馈回复", content="点击查看收件箱"
  • t=50s: 模式=summary, count=5, push title="您有 5 条新反馈回复", content="点击查看收件箱"
  • t=60s: 窗口结束;next push (e.g. t=70s) 重新走 concrete 模式, count 重置 1

Vuex 一致性5 次 push 都触发 App receive,每次都 REPLACE_UNREAD_BY_TYPE (idempotent,same server 值)。 未读条目进 inbox 页 onShow 时一并补全onShow 一次拉 4 type 一页),用户在 inbox 内可见全部 5 条。

第二道App receive 去抖App.vue

plus.push.addEventListener('receive', cb) 内追加 10s 滑窗:

const recentByType = {}  // { feedback_replied: 1700000000000, ... }
const DEBOUNCE_MS = 10000  // v1.2.6 调优:原 5s 偏短,改 10s 留足 buffer

plus.push.addEventListener('receive', (msg) => {
  try {
    const envelope = JSON.parse(msg.payload || '{}')
    const data = envelope.data || {}
    const t = data.type
    const now = Date.now()
    // 防抖:同 type 10s 内已 dispatch 跳过 (re-render 收敛)
    if (t && recentByType[t] && now - recentByType[t] < DEBOUNCE_MS) {
      return
    }
    if (t) recentByType[t] = now
    store.dispatch('mailbox/applyPushPayload', {
      data, title: envelope.title || '', content: envelope.content || ''
    })
  } catch (e) {}
})

summary push (没 notification_id 或没 type) 的 applyPushPayload 仍执行:更新 unreads_by_type + total_unread,触发 PREPEND_ITEM (§12.4 action 内已判断 data.notification_id && data.type)。

第三道Vuex 幂等性保证

// mutations
PREPEND_ITEM(state, { type, notification }) {
  const list = state.itemsByType[type] || []
  // 1) 防止同一 notification 被重复 prepend (uniPush 重发 / 网络重试)
  if (list.some(n => n.id === notification.id)) return
  // 2) 防止同 type 多条同时 prepend 时排序混乱:未读排前
  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
}

REPLACE_UNREAD_BY_TYPE(state, byType) {
  // server 端权威覆盖,不累加(防止 N 次 push 把一个 +1 算成 +N)
  state.unreadByType = { ...state.unreadByType, ...byType }
}

节流参数可配化

UniPushClient 构造时通过外部 pkg/config 注入:

push:
  rate_limit_window_ms: 60000       # (user, type) 节流窗口,默认 60s (v1.2.6 调优)
  app_debounce_ms: 10000            # App 端 receive 去抖,默认 10s (v1.2.6 调优)

V1 用硬编码也可,MVP 不强制配置化。

边界场景(60s / 10s 配置)

场景 节流表现
管理员 1 条 1 条处理(每条相隔 > 60s) 全部 concrete push,无节流
管理员 1 次批量 5 条(30s 内完成) 第 1 条 concrete,第 2-5 条 summary title 递增:"您有 2 条新反馈回复" → 3 → 4 → 5;手机 5 次弹出,每次 title 增长,用户能直观看到量级
管理员 5 分钟内分批处理 10 条 (每批思考 30s) 10 条全部独立 concrete,因为间隔 > 60s 始终开启窗口
App 在前台时 App receive 第 6 条 summary (10s 内多次) App 内 unreads_by_type 准确更新 (REPLACE 幂等),仅第 1 次 commit PREPEND_ITEM;后续 receive 因为 10s 去抖被拦掉
App 在后台时 receive 6 条 summary 手机通知栏弹 5 次concrete 1 + summary 4 title 递增);App 进前台时 redDot 仍准确
用户 60s 后又来 1 条 上次 concrete 距今 > 60s,自动恢复 concrete 模式 (pending[key]=1,sentMode 重置)
节流窗口内登出再登入 (换 cid) 新 cid 不在 RateLimiter 历史里 → 视为新 key → 第一次 concrete (节流 key 是 user_id 所以换 cid 不影响)
突增边界 case:admin 用了 60s 节流期间登录另一台设备 RateLimiter key = usertype 是 user 维度,不依赖 cid,所以换设备不会"skip" → 安全
节流模式可用 toggle 按钮(Stage 2) V1 不实现,用户粒度"勿扰模式"留给 Stage 2

"兜底一次性 HTTP" 说明:用户在列表页 pages/mailbox/index.vue onShow 时拉 4 个 type 一页(这是数据本身的拉取,不是"轮询红点")。红点更新通道仍是 uniPush receive,与本页拉取互不干涉;用户在 push 不可达环境进邮箱时,自然对齐。


13. 关键决策表

决策 选项 A 选项 B (已选) 原因
数据源 新建 inbox_messages 单独存 notifications.type 复用未读聚合 + 推送链路
后端接口 新增聚合 /api/v1/inbox 按 type 调 4 次 GET /api/v1/notifications 4 路并发;零新查询路径
新页布局 Tab 切换 两页路由(index.vue + detail.vue 用户反馈竖屏分栏不好用改单页路由v1.2
已读排序 created_at desc 默认 未读优先,已读排后 用户已选
全部操作范围 各 group 独立 4 group 全量 用户已选
响应式 竖屏真左右两栏 单页路由全宽iPad 分栏留给 Stage 2 竖屏 50/50 两边都挤v1.2
Vuex 不引入 引入 mailbox module 红点全局共享 + 详情页 onLoad 命中 store
实时通知 广场 onShow 轮询 uniPush receive 事件 + payload 含 unreads_by_type 移动端零轮询v1.1
emitter 写入内容 引用 id 即可 content 塞详情全文 不跳转就够看
myReports/myFeedbacks 文件 删除 保留旧文件,新流不引用 遵循"无无关改动"
emitter 触发点 同步立即 事务提交后异步 避免回滚时通知已发出
POST DELETE 串行 串行 4 type 并发 UX 快
composer API 注释 手动维护 router 同写 swagger 注释 与项目约定一致
入口位置 广场 Header.vue 加 envelope icon + 红点 pages/profile/profile.vue <!-- 服务与工具 --> section 加 service-button + 红点 badge v1.2.9 你确认改到 profile.vue —— profile 是常驻入口,比广场 Header 更稳定
like 是否进邮箱 进(占 4 个 Tab 的 1 个) 暂不进 v1.2.10:后端 like emitter 保留,前端 4 GROUPS 本就不含 like,profile.vue badge 用 Vuex getter inboxUnreadOnly 排除 like/system 你确认"点赞暂时不要推送到邮箱"—— 保留旧 push 体验,不影响社交动态等其他下游
type=activity 复用程度 复用 activityService 现有通知 白名单已有但零 emitter,本期新建 emitter v1.2.2 自审核对,grep 验证
推送节流 不节流,管理员批量处理时手机狂震 三道防线:后端 RateLimiter 60s + App 10s 去抖 + Vuex 幂等 用户反馈"管理员批处理手机 UX 灾难",v1.2.6 新增 §12.7(参数经用户确认 60s/10s)
推送聚合 +N 5 条 5 次独立推送,用户看 5 张同样 title 的卡片 同 type 窗口内 summary title 拼 "您有 N 条新<type 中文>" 递增 用户希望"通知栏显示 +5"反馈 → v1.2.8 加 count,实现成本仅 RateLimiter 多一个 pending map

14. 附:与旧 myReports/myFeedbacks 的关系

旧页面 (pages/profile/myReports.vuepages/profile/myFeedbacks.vue)

  • 代码保留, 不删除
  • 下游入口不在新流引用:
    • profile.vue 中"我的举报 / 我的反馈"菜单项 —— 是否同步隐藏交产品决定(提案: 隐藏,告知用户新位置)
  • 旧 API (getMyReportsApi / getMyFeedbacksApi) 仍可用, 但邮件页面只用 notifications 接口

理由: CLAUDE.md 强调"一个 commit 只做一件事" + "无无关改动",删除文件需独立 PR本方案不夹带。


15. 变更日志

日期 版本 摘要
2026-07-13 v1 初稿;与用户 4 轮澄清(数据源 / 后端方案 / 新页定位 / 后端聚合 / Tab vs 分组 / 操作按钮 / 折叠展开)后定稿
2026-07-13 v1.1 用户反馈"只用移动端,不要事件轮询" → §12 反例重写为 uniPush receive 实现方案:① 后端推送 payload 带 unreads_by_type,② App.vue#onLaunch 注册 receive 监听器,③ Vuex applyPushPayload action 直接 +1④ 去掉 setInterval / onShow 任何形式的轮询;同步更新 §1.3 / §5.3 Vuex actions / §8.3 手验 case 13-16 / §2 验收 Acceptance 11-12
2026-07-13 v1.2 用户反馈"手机竖屏分栏两边都挤" → 放弃 master-detail 改为两页路由:① pages/mailbox/index.vue 全宽列表页 + pages/mailbox/detail.vue 全宽明信片页,navigateTo 跳转;② 删除 useMailboxLayout.js;③ §6 响应式分档表换为两页路由布局策略;④ 关键决策表新增"两页路由 / 单页路由全宽 iPad 分栏留给 Stage 2";⑤ §10 文件清单同步(移除 layout新增 detail.vue / App.vue / uni_push_client.go
2026-07-13 v1.2.1 自审过一遍 → 文档头部版本号 v1.2§1 关键决策 4 由 mailbox.vue → index.vue + detail.vue§1 TL;DR 架构图 utils/api.js 补 clearNotificationsApi§7.2 / §8.3 / §12.3 / §12.5 路径 mailbox → index§5.3 / §12 兜底对齐描述改为"列表页 onShow 仅此 1 次"§6.1 副标题"放弃 master-detail"明确指向 v1.2 修订
2026-07-13 v1.2.2 用户核对后大幅修订"活动通知复用"误判:① §0 描述 + §1 决策 1 + §3.1 扩展表全部对齐 → allowedTypes 已有 activity/system/like 三值,但生产代码只有 socialService 在 emit type=likeasset_like_service.go:185activity/system 是白名单预留位、零业务调用;② §3.3.4 标题由 "(已有) 字段补强" 改为 "白名单已有但零 emitter → 本期补建",并给出 grep 自审命令;③ §0 实施路径补 activityService 1-2 处 emitter 新增 行(+0.3d);④ §8.1 补 activity_emitter_test.go 两个用例;⑤ 整体口径与前端用户查到的"前端接口处没注释"对得上 —— API 路径是已有,问题在 emit 上行不在 read 下行
2026-07-13 v1.2.3 用户指出 §10.1 "新增文件"区下 backend 文件全标 "改" 是结构错位 → 重构 §10① §10.1 仅列真正新增文件frontend 6 新 + backend 4 新测试文件 + 1 migration② §10.2 改为全量"修改文件"清单,含 utils/api.js 的 6 新函数、proto/notification.proto 字段号 5/6/7 追加、所有 *_service.go 的 emitter 注入、pkg/push/uni_push_client.go payload 注入;③ 修正副本 §10.3 重复
2026-07-13 v1.2.4 用户要求 review §5.4 注释模板 → 重写为带函数级中文块注释的版本:① 沿用现有 utils/api.js 的 // ============ 分隔行 + 中文块注释风格(不引入 JSDoc② 每个函数注释分 5 段(功能名+用途、参数、返回、业务流、注意),描述触发场景、与后端事务的关系、本期是否调用;③ 文末补"注释规范要点" 让后续 reviewer 一致
2026-07-13 v1.2.5 全局自审修正 3 项:① §5.4 GET 函数改用 URL 查询串而非 data:{}(对齐 utils/api.js 现有 GET 约定);② markAllAsRead / clearNotifications 也改为查询串(对齐 backend gateway controller g.Query("type") 提取);③ §12.2 payload 契约大幅修正:Payload.Datamap[string]interface{} 而非 JSON 字符串(核对 uni_push_client.go:29所有 unreads_by_type / total_unread 字段在 envelope.data 内层、不是顶层§12.3 App.vue 改为解 envelope 取 data + 打包 {data, title, content} 传 action§12.4 action 参数从 envelope 改为 {data, title, content}§10.2 同步说明 triggerPush 内修改(非 push 客户端)
2026-07-14 v1.2.6 用户指出"管理员批处理时不能一条一推送" → 新增 §12.7 推送节流三道防线:① 后端 pkg/push/uni_push_client.go 新增 RateLimiter (key=user:star:type 30s 滑窗):首次 concrete、后续 summary "您有新通知";② App.vue receive 内 5s 同 type 去抖;③ Vuex PREPEND_ITEM 按 id 去重 + REPLACE_UNREAD_BY_TYPE idempotent。同步§0 实施路径 +0.3d、§1 决策 5、§7.5 并发章节加节流说明、§8.3 手验 case 17-19、§10 pkg/push 服务改动、§13 决策表加节流行
2026-07-14 v1.2.7 用户指出"30s/5s 会不会太短" → 调优为 60s/10s用户确认§12.7 RateLimiter.windowMs 默认值 30000→60000App DEBOUNCE_MS 5000→10000yaml 配置、§7.5、§8.3 case 17-18 表述、§13 决策表行同步§12.7 边界场景表加 3 行(管理员分批 10 条 / 突增边界 case / Stage 2 切换按钮预留)
2026-07-14 v1.2.8 用户问"通知栏能不能显示 +5 这种聚合" → 选择按 type 聚合 + 动态 count① RateLimiter 加 pending map[key]int + sentMode map[key]string;② Allow() 返回 (mode, count);③ 新增 TypeChinese() 把 type 映射为"反馈回复/举报结果/被举报/活动通知";④ triggerPush 内根据 mode 生成 titlefmt.Sprintf("您有 %d 条新%s", count, TypeChinese(n.Type));⑤ 5 条 burst 示例 1+2+3+4+5 递增 title。同步§0 实施路径 +0.1d、§1 决策 5、§8.3 case 17 升级 + case 20、§13 决策表新增 +N 行
2026-07-14 v1.2.9 你确认入口位从广场 Header.vue 改到 pages/profile/profile.vue<!-- 服务与工具 --> section① §0 "技术侧"改为该 section 缺 📬 入口;② §1 关键决策 4 重写为"profile.vue 服务与工具 section 加 service-button + 红点 badge"(同 section 已有"新手指引/反馈/修改昵称" 等 6 个同类型按钮天然容纳);③ §0 实施路径表 +1 行 "profile.vue" 服务与工具 section 加 📬 收件箱 入口 0.3d(替代原 Header.vue + square.vue 0.3d 行);④ §1.4 前端页面表 "新增 showMailboxIcon" → 改 profile.vue "新增 📬 service-button + onShow 时刷新 unread badge";⑤ Acceptance 1 改写为"profile.vue 服务与工具区看到 📬 按钮 + 红点 badge"+ Acceptance 1.1 onShow 刷新;⑥ §5.1 文件清单删 Header.vue + square.vue 两行,加 profile.vue 行;⑦ §5.10 合规清单同步;⑧ §7.2 未登录守卫改 profile.vue v-if="userStore.token";⑨ §10.2 修改文件同步Header.vue/square.vue → profile.vue⑩ §13 决策表新增"入口位置"行
2026-07-14 v1.2.10 你确认"点赞暂时不要推送到邮箱":① §3.1 表加"进邮箱?"列,like 不进(其他仍是 );② §3.1 加"点赞不进邮箱"备注段——后端 socialService/asset_like_service.go:185 保持不动,前端 4 GROUPS 本就不含 like(已合规);③ §1 关键决策表新增第 5 行"点赞不进邮箱"原因(保留旧 push 体验不影响社交动态);④ §2.1 Acceptance 1.2 新增"like 5 + feedback_replied 3 = badge 显示 3 而不是 8"用例;⑤ §7.2 补 badge 计算代码:Vuex getter inboxUnreadOnly = activity + feedback_replied + report_resolved + target_reported (排除 like + system),profile.vue 用此值;⑥ §13 决策表新增"like 是否进邮箱"行