# 站内"邮箱"聚合收件箱 — 设计方案 > 文档版本: 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(`