From 6425cbf74799d2f877b2a52464185e85bcf1770c Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 14 Jul 2026 13:48:52 +0800 Subject: [PATCH] =?UTF-8?q?docs(spec):=20=E7=AB=99=E5=86=85=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E8=81=9A=E5=90=88=E6=94=B6=E4=BB=B6=E7=AE=B1=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20(v1.2.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../specs/2026-07-13-mailbox-inbox-design.md | 1630 +++++++++++++++++ 1 file changed, 1630 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md diff --git a/docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md b/docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md new file mode 100644 index 0000000..381701b --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md @@ -0,0 +1,1630 @@ +# 站内"邮箱"聚合收件箱 — 设计方案 + +> 文档版本: 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`(你确认改到这)`` section(profile.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 | +| 推送聚合 +N(v1.2.8 新增)| RateLimiter 加 pending[s]count,summary title 拼 "N 条新" | 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=` 装单条明信片全宽 + 顶部 [返回] + 单条 [标已读 / 删除]。点列表项 `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 条新"` 聚合递增(仍 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 站内 App(uni-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._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._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= (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 ` +``` + +### 5.6 `pages/mailbox/detail.vue` 明信片页(Vue 3 ` +``` + +### 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(`