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

1631 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 站内"邮箱"聚合收件箱 — 设计方案
> 文档版本: 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:185``system` / `activity` 是白名单预留位、零业务调用;举报与反馈的处理回执**也没有**接进通知流。
2. `utils/api.js` 只暴露了推送设备注册 API`GET /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 条新<type>" | 0.1d |
| 手验 + 单测moderation 单元测试补 3 用例) | | 0.5d |
| **合计** | | **3.5d ≈ 5-6 个工作日** |
### 关键决策
1. **复用 `notifications` 表**,扩 `type` 白名单 3 个 key`feedback_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` 到详情页。所有详情在邮件页面内完成,不跳详情子页。
5. **点赞不进邮箱**v1.2.10 经你确认):后端 `socialService/asset_like_service.go:185``like` 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 计算。
4. **新页 `pages/mailbox/index.vue` 列表页 + `pages/mailbox/detail.vue` 明信片页(两页路由)**,绕过 `myReports / myFeedbacks / 任何详情子页` —— 仓库文件**不删除**(避免无关改动),但下游入口不再引用。
5. **推送节流 + 聚合**v1.2.6 初版,v1.2.8 加 count 聚合):① 后端 `RateLimiter` (user, type) **60s** 滑窗 → 首次 concrete,后续 summary `title="您有 N 条新<type中文名>"` 聚合递增(仍 Send 让 App receive 同步红点);② App `plus.push.addEventListener`**10s** 同 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. [现状盘点](#1-现状盘点)
2. [设计目标与验收标准](#2-设计目标与验收标准)
3. [数据契约](#3-数据契约)
4. [后端设计](#4-后端设计)
5. [前端设计](#5-前端设计)
6. [响应式与可达性](#6-响应式与可达性)
7. [错误处理与边界](#7-错误处理与边界)
8. [测试计划](#8-测试计划)
9. [文档 / 运维](#9-文档--运维)
10. [目录变更](#10-目录变更)
11. [部署与回滚](#11-部署与回滚)
12. [不在本期范围](#12-不在本期范围)
13. [关键决策表](#13-关键决策表)
14. [附:与旧 myReports/myFeedbacks 的关系](#14-附与旧-myreportsmyfeedbacks-的关系)
15. [变更日志](#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:185` 的 `CreateNotification(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 个新 key`feedback_replied / report_resolved / target_reported``activity` 已在 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` 表 migration`backend/migrations/2026XXXX_add_mailbox_unread_counts.sql`
```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_id``0` | 必填(非空) |
| `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_id``0` | |
| `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'|'ban', reason_summary}` | |
触发点: `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"`
```bash
# 自审命令
grep -rn 'Type:\s*"activity"' backend/services/ --exclude="*_test*|*_mock*"
# 期: 命中 activity_message_service.go 和 activity_service.go 的 emitter 行
```
### 3.4 proto 调整
```protobuf
// 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)
- 注册 `ClearByType``DELETE /api/v1/notifications/clear?type=...`
#### 4.1.2 `moderationService` 改动清单
`service/feedback_service.go` 中 "管理员回复反馈" 流程:
```go
// 注入 notificationClient (gRPC) - 由 main.go 初始化
type FeedbackService struct {
db *gorm.DB
notifCli notification.NotificationServiceClient
}
```
`RepliedBy/RepliedAt/ReplyContent/Status='replied'` 写入后同步:
```go
// 事务提交后(避免回滚时通知已发出)
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.go``Resolved` 流程 + `target_status_service.go``Warn/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` 改动:
```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`
```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 封装,封装函数中。
```js
// ==================== 通知查询(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>`
```vue
<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>`
```vue
<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
```js
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 (摘要)
```js
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
```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 合规清单
- [x] Vue 3 组合式 API`<script setup>`),不复用 `myReports/myFeedbacks` 的 Options API
- [x] 接口走 `utils/api.js`(不裸 `uni.request`
- [x] 跨页状态走 Vuex (`mailbox` module) + 明信片页 onLoad 取 `nid/type` 从 store 取条目
- [x] 新页已注册(`index` / `detail` 两页路由)
- [x] `profile.vue` 服务与工具 section 加 📬 收件箱 service-button参考同 section "新手指引/反馈" 等的视觉规范)
- [x] 不动任何 `setInterval / setTimeout` 轮询
- [x] 不动 `unpackage/dist/`
- [x] 移动端优先list 页全宽 + tap push 到 detail 页全宽 + 列表页底部 [全部已读 / 全部删除] / 明信片页底部 [标已读 / 删除] 两套操作按钮
- [x] 列表操作 = 明信片操作覆盖: 4 个操作任何页面都能完成
- [x] 不长列表(明信片内容由后端控制长度)
---
## 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 2** 走 `manifest.json``transformPx` 自适应即可YAGNI
### 6.2 横竖屏切换
- 两页路由不受横竖屏影响(始终全宽)
- `onWindowResize` 无需监听(不再用 `useMailboxLayout`
### 6.3 可达性
- 列表项 `aria-label`title + 时间 + 未读状态
- 长按 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**:
```js
// 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。**不触发序列同步规则**。
- 若手写测试 SQL`SELECT 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.go``CreateNotification` 成功时异步 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:29` 的 `Payload.Data` 是 `map[string]interface{}`JSON 对象),不是 JSON 字符串。
> App 端读 `msg.payload.data.notification_id` 等字段,不是 `msg.payload.nid`。
> 现有 `notification_service.go:211` 的 `triggerPush` 把 `notification_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 整体结构:
```json
{
"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` 前):
```go
// 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`
```js
// #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
```js
// 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.go``RateLimiter` 结构v1.2.8 加入 count 聚合)。
```go
// 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` 内改造:
```go
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 滑窗:
```js
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 幂等性保证
```js
// 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` 注入:
```yaml
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 = user:star:type 是 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.vue`、`pages/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=like**asset_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.Data` 是 `map[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 生成 title`fmt.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` 是否进邮箱"行 |