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>
1631 lines
87 KiB
Markdown
1631 lines
87 KiB
Markdown
# 站内"邮箱"聚合收件箱 — 设计方案
|
||
|
||
> 文档版本: 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 条新<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 站内 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.<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 触发 ActionSheet(ios 长按手势)
|
||
- 单色 + 图标双重提示未读(`●` 红点 + "未读" 字样)
|
||
- 屏幕阅读器读 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=activity(N=参与该活动的用户数)
|
||
|
||
### 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 的增量未读数"塞进 payload;App 拿到直接 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_replied,60s 窗口内):
|
||
- 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:185),activity/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→60000;App DEBOUNCE_MS 5000→10000;yaml 配置、§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` 是否进邮箱"行 |
|