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>
87 KiB
站内"邮箱"聚合收件箱 — 设计方案
文档版本: v1.2.10 · 拟稿日期: 2026-07-14 · 作者: Claude 实施阶段: MVP · Status: 待用户审阅
方案概述(必读)
要解决的问题
业务侧: 用户提交举报 / 反馈、参与活动后,目前没有"集中看到所有回执"的入口 —— 处理结果散落在 myReports / myFeedbacks / activity-center 三处独立页面,用户很容易漏看。
技术侧:
- 项目已有
notifications主表 +notification_stats未读聚合 + uniPush 推送通道;allowedTypes白名单允许like/system/activity,但生产代码里只有socialService在调用CreateNotification(type="like")(asset_like_service.go:185),system/activity是白名单预留位、零业务调用;举报与反馈的处理回执也没有接进通知流。 utils/api.js只暴露了推送设备注册 API,GET /api/v1/notifications等查询类接口在前端没有客户端封装。- 广场(之前错的)
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 个工作日 |
关键决策
- 复用
notifications表,扩type白名单 3 个 key(feedback_replied / report_resolved / target_reported三个真的新增),activity复用白名单但实际是新增 emitter(无现有业务流调用)。理由: MVP 阶段 1 套表足够;项目已有未读聚合 + 推送链路;零迁移成本。 - 前端聚合 4 个 type,不建后端聚合接口。理由: 4 个 Tab(同 1 个 endpoint,按 type 过滤)= 1 个 HTTP 调用 × 4 次(并发),零新接口干净。
- 两页路由(手机竖屏优先): 列表页
pages/mailbox/index.vue装 4 分组可折叠列表 + 顶部 [全部已读 / 全部删除] + 列表项长按 ActionSheet;明信片页pages/mailbox/detail.vue?nid=<id>装单条明信片全宽 + 顶部 [返回] + 单条 [标已读 / 删除]。点列表项navigateTo到详情页。所有详情在邮件页面内完成,不跳详情子页。 - 点赞不进邮箱(v1.2.10 经你确认):后端
socialService/asset_like_service.go:185的likeemitter 保留不动(用户已习惯手机通知栏+红点旧体验);前端 inbox 4 GROUPS(§5.7)本来就不含like(已合规);profile.vue 📬 badge 显示值改为 Vuex getterinboxUnreadOnly=sum(unreadByType[activity, feedback_replied, report_resolved, target_reported]),不包括like/system红点。详细见 §3.1 备注、§7.2 badge 计算。 - 新页
pages/mailbox/index.vue列表页 +pages/mailbox/detail.vue明信片页(两页路由),绕过myReports / myFeedbacks / 任何详情子页—— 仓库文件不删除(避免无关改动),但下游入口不再引用。 - 推送节流 + 聚合(v1.2.6 初版,v1.2.8 加 count 聚合):① 后端
RateLimiter(user, type) 60s 滑窗 → 首次 concrete,后续 summarytitle="您有 N 条新<type中文名>"聚合递增(仍 Send 让 App receive 同步红点);② Appplus.push.addEventListener内 10s 同 type 去抖;③ VuexPREPEND_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/—— 现有通知服务实现
目录
- 现状盘点
- 设计目标与验收标准
- 数据契约
- 后端设计
- 前端设计
- 响应式与可达性
- 错误处理与边界
- 测试计划
- 文档 / 运维
- 目录变更
- 部署与回滚
- 不在本期范围
- 关键决策表
- 附:与旧 myReports/myFeedbacks 的关系
- 变更日志
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.jscomposables/useHolographicPreview / useDashboardData / useLenticularStudioTilt—— 范例模式- 新增
composables/useMailboxCenter.js
2. 设计目标与验收标准
2.1 目标
- 用户在
pages/profile/profile.vue的<!-- 服务与工具 -->section 看到一个 📬 收件箱 service-button + 红点 badge - 点击进入"邮箱"页面,左侧 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跳详情页 + 该项标已读 + 红点未读 -1Acceptance 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.goallowedTypesmap 加 3 个新 key(feedback_replied / report_resolved / target_reported);activity已在 map 中无需动proto/notification.protoCreateNotificationRequest.type不动(已是 string)- 4 处业务 emitter 真实接线(
activityService1-2 处、moderationService3 处)—— 见 §3.3 socialService/asset_like_service.go保持原样(不改 like emitter,不加 inbox 路由分支)
3.2 notification_stats 表 migration(backend/migrations/2026XXXX_add_mailbox_unread_counts.sql)
-- 加 3 列 unread 计数 (default 0)
ALTER TABLE public.notification_stats
ADD COLUMN IF NOT EXISTS feedback_replied_unread_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS report_resolved_unread_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS target_reported_unread_count INT NOT NULL DEFAULT 0;
-- 加触发器:在 INSERT 通知后自动累加对应列
-- (略,本版本用 service 层事务内手动累加,触发器列为下期优化)
-- 加索引: 按 type 拉列表 (已有 is_read+user_id 索引可复用)
-- 现无新索引需求
-- PG 序列同步 (按 CLAUDE.md 强制)
-- 本 migration 无 INSERT 语句, 不涉及序列同步
3.3 emitter 字段契约(backend/services/moderationService/ 与 activityService/)
3.3.1 type=feedback_replied
| 字段 | 来源 | 写入 |
|---|---|---|
user_id |
feedbacks.user_id |
收件人 (反馈提交者) |
star_id |
feedbacks.star_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' |
触发点: moderationService/service/target_status_service.go 中"WarnCount > 阈值自动处置"或"管理员手动处置" 写入分支。**只有 action在'warn'/'takedown'/'ban' 时才发通知**(dismiss` 不发,避免打扰无辜作者)。
3.3.4 type=activity 字段契约(白名单已有,但零 emitter → 本期补建)
⚠️ 自审发现:allowedTypes 已有 activity,但全仓 grep CreateNotification(... Type: "activity" 在 production 代码中零命中。本节是补建 emitter,不是补强。
| 字段 | 写入 |
|---|---|
title |
活动标题(取自 activities.title) |
content |
活动摘要 / 留言内容 |
data |
{activity_id, message_id?} |
触发点(本期新建 emitter):
activityService/service/activity_message_service.go—— 别人在你的活动留言 → 给活动创建者发 (type=activity)activityService/service/activity_service.go—— 活动开始 / 结束 → 给"我参与了该活动(购买过 / published_item_count>0)"的用户发
验证(grep Type:\s*"activity"):
# 自审命令
grep -rn 'Type:\s*"activity"' backend/services/ --exclude="*_test*|*_mock*"
# 期: 命中 activity_message_service.go 和 activity_service.go 的 emitter 行
3.4 proto 调整
// proto/notification.proto
message UnreadCount {
int32 like = 1;
int32 system = 2;
int32 activity = 3;
int32 total = 4; // 总数(保留原字段号,不变)
int32 feedback_replied = 5; // 新增(从 5 起追加,避免重编号破坏 wire 兼容)
int32 report_resolved = 6;
int32 target_reported = 7;
}
// ClearByType RPC (新)
service NotificationService {
rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
option (google.api.http) = {
delete: "/api/v1/notifications/clear"
};
}
}
message ClearByTypeRequest {
// 枚举 string: activity / feedback_replied / report_resolved / target_reported / all
string type = 1;
}
message ClearByTypeResponse {
topfans.common.BaseResponse base = 1;
int32 affected = 2;
}
4. 后端设计
4.1 handler / service / repository 三层(按 CLAUDE.md 强制)
4.1.1 notificationService 改动清单
service/notification_service.go
- 修改
allowedTypesmap,新增 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-codelike/system/activity列名改为根据 type 字符串映射列名或用条件CASE WHENSQL。
controller/notification_controller.go (gateway)
- 注册
ClearByType→DELETE /api/v1/notifications/clear?type=...
4.1.2 moderationService 改动清单
service/feedback_service.go 中 "管理员回复反馈" 流程:
// 注入 notificationClient (gRPC) - 由 main.go 初始化
type FeedbackService struct {
db *gorm.DB
notifCli notification.NotificationServiceClient
}
在 RepliedBy/RepliedAt/ReplyContent/Status='replied' 写入后同步:
// 事务提交后(避免回滚时通知已发出)
notifResp, err := s.notifCli.CreateNotification(ctx, &pb.CreateNotificationRequest{
UserId: feedback.UserID,
StarId: starIDZeroDefault(feedback.StarID),
Type: "feedback_replied",
Title: "您的反馈已处理",
Content: *feedback.ReplyContent, // 全文
Data: structpb.NewStruct(map[string]interface{}{
"feedback_id": feedback.ID,
"category_code": feedback.CategoryCode,
"original_title": feedback.Title,
}),
})
同样在 report_service.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 loguser_id/star_id/type/title_len/data_len;失败 ERROR log(含 stacktrace)ClearByType: 入口 INFO log;事务失败 ERROR logMarkAllAsRead(事务内 stats): 失败 ERROR log(影响后续 unrea-count 准确性)
4.6 推送触发(沿用现有链路)
CreateNotification 成功后异步 goroutine → uni_push_client.go → 站内信 + 手机通知栏。不动,仅扩类型。
5. 前端设计
5.1 文件清单
| 路径 | 动作 |
|---|---|
pages.json |
注册 pages/mailbox/index + pages/mailbox/detail 两页 |
pages/mailbox/index.vue |
新建(列表页 / 4 分组可折叠) |
pages/mailbox/detail.vue |
新建(明信片页) |
pages/mailbox/components/MailboxGroup.vue |
新建(单分组列表组件) |
pages/mailbox/components/Postcard.vue |
新建(明信片组件) |
pages/mailbox/composables/useMailboxCenter.js |
新建(4 type 并发拉取 + 单条操作) |
store/modules/mailbox.js |
新建(Vuex itemsByType / unreadByType / mutations) |
utils/api.js |
加 6 个函数(getNotificationsApi / getUnreadCountApi / markAsReadApi / markAllAsReadApi / deleteNotificationApi / clearNotificationsApi) |
App.vue |
加 #ifdef APP-PLUS 块注册 push receive/click 监听器(详见 §12.3) |
5.2 路由注册(CLAUDE.md 强约束)
pages.json 改动:
{
"path": "pages/mailbox/index",
"style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
},
{
"path": "pages/mailbox/detail",
"style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
}
5.3 Vuex store/modules/mailbox.js
// 简化示意
const state = () => ({
unreadByType: {
activity: 0,
feedback_replied: 0,
report_resolved: 0,
target_reported: 0,
},
totalUnread: 0,
})
const actions = {
/**
* 收到 receive 推送时调用,本地同步未读数(payload 自带 unreads_by_type)
* 见 §12.4
*/
applyPushPayload({ commit, state, rootState }, payload) {
if (!payload || !payload.unreads_by_type) return
commit('REPLACE_UNREAD_BY_TYPE', payload.unreads_by_type)
commit('SET_TOTAL_UNREAD', payload.total_unread || 0)
// 若用户已在邮箱页,本地插入新条目(避免用户下次进邮箱才看到)
if (rootState.mailbox.isInMailboxPage && payload.nid) {
commit('PREPEND_ITEM', {
type: payload.type,
notification: {
id: payload.nid,
title: payload.title,
content: payload.content,
data: payload.data,
is_read: false,
created_at: Date.now()
}
})
}
},
/** 用户在列表页 pages/mailbox/index.vue 的 onShow / 下拉刷新时拉一页,顺便对齐红点(stats) */
async alignFromServerUnread({ commit }) {
const resp = await getUnreadCountApi()
commit('REPLACE_UNREAD_BY_TYPE', resp.data.counts)
},
/** 单条标已读时,本地减 1(已与后端 UPDATE 同步) */
decrementOne({ commit }, { type }) {
commit('DECREMENT_TYPE', type)
},
}
- 入口:
App.vue#onLaunch注册plus.push.addEventListener('receive', cb),cb 内dispatch('mailbox/applyPushPayload', payload),完全不走 HTTP 轮询。 - 兜底对齐: 列表页
pages/mailbox/index.vueonShow拉一页数据时并发触发alignFromServerUnread,仅此 1 次。 - 不强求对齐: 列表页
pages/mailbox/index.vueonShow时拉一页getNotificationsApi(拉数据本身)时,并发调一次alignFromServerUnread同步 stats,仅一次而非周期。
5.4 utils/api.js 新增(带注释模板)
注释风格与现有
registerDeviceApi一致(块状中文注释 + 分隔行)。request为项目内通用 uni.request 封装,封装函数中。
// ==================== 通知查询(spec §1.3) ====================
// 本节 6 个函数均为本次新增 wrapper。前端原本只在 utils/api.js 暴露了
// registerDeviceApi / unregisterDeviceApi 两个推送设备注册接口,
// 通知本身的 list/unread/markRead/markAllRead/delete/clear 全部需要在
// 收件箱功能落地时补齐(参见 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md)。
//
// 鉴权:由 gateway AuthMiddleware 强制 JWT,无 token 自动 401,前端无需再校验。
// 响应统一: { code, message, data, base? },data 字段才是真正的 payload。
// getNotificationsApi —— 拉一页通知(用于列表页 onShow 并发 4 个 type / 详情页单补拉)
//
// 参数:
// type :必填,通知 type 枚举字符串
// 可选值: 'activity' | 'feedback_replied' | 'report_resolved'
// | 'target_reported' | 'like' | 'system'
// (前 4 个是收件箱主要业务 type,后 2 个仅占位,本期产品不暴露)
// tab :可选,二级过滤器(如 like 类的"未读/全部")。收件箱功能本期不传。
// page :可选,1-based 页码;默认 1,后端兜底
// pageSize:可选,默认 20,后端兜底最多 100
//
// 返回:Promise<{ data: { items: Notification[], unreadByType?, total, page, pageSize } }>
//
// 业务流:
// ① 列表页 pages/mailbox/index.vue 的 onShow → Promise.all(4 type 各拉首页)
// ② 详情页 pages/mailbox/detail.vue 找不到 store 命中时 → 单补拉 50 条找 nid
//
// 注意:
// GET 参数必须走查询串(项目约定,见 utils/api.js 现有 GET 用法如
// /api/v1/social/friends?page=&page_size=);不允许 data:{} 写法。
export function getNotificationsApi(params = {}) {
const { type, tab = '', page = 1, pageSize = 20 } = params || {}
const qs = new URLSearchParams({ type, page: String(page), page_size: String(pageSize) })
if (tab) qs.append('tab', tab)
return request({ url: `/api/v1/notifications?${qs}`, method: 'GET' })
}
// getUnreadCountApi —— 拉全局未读计数(主路径不调用,仅作为 push 不可达兜底)
//
// 参数:无
//
// 返回:Promise<{ data: { like, system, activity, feedback_replied,
// report_resolved, target_reported, total } }>
//
// 业务流:
// 列表页 onShow 拉数据页时,并发调一次对齐 stats;平时零调用(红点靠 uniPush
// receive 事件 + payload.unreads_by_type 本地增量,见 §12)
export function getUnreadCountApi() {
return request({ url: '/api/v1/notifications/unread-count', method: 'GET' })
}
// markAsReadApi —— 单条标已读(列表项点击 / 明信片底部 [✓标已读])
//
// 参数:
// id:必填,notifications.id(整数)
//
// 返回:Promise<{ data: void }>;前端按 resp.code === 0 判断成败
//
// 业务流:
// ① 列表项点击 → 调用成功后再 navigateTo 详情页
// ② 明信片页 [✓标已读] → 调用成功后留在页(用户可能再看一眼)
//
// 注意:后端事务内 ① UPDATE notifications SET is_read=TRUE WHERE is_read=FALSE
// ② UPDATE notification_stats.<type>_unread_count -= 1
// 后端已幂等(同 id 多次点击不会重复 -1,因为 WHERE 已过滤 is_read=FALSE)
export function markAsReadApi(id) {
return request({ url: `/api/v1/notifications/${id}/read`, method: 'POST' })
}
// markAllAsReadApi —— 按 type 全标已读(列表页顶部 [+全部已读] 触发 4 次并发)
//
// 参数:
// type:必填,通知 type 字符串 (同 getNotificationsApi 的 type)
//
// 返回:Promise<{ data: { affected } }>
//
// 业务流:
// 列表页顶部 [+全部已读] → Promise.all(4 个 type 各 1 次) → 本地 store
// 全部 type unread 清 0 + 4 组 items.map(n => ({...n, is_read: true}))
//
// 注意:
// type 走 URL 查询串(由 backend gateway controller 用 g.Query("type") 提取,
// 见 notification_controller.go:404)。不要写 data: { type },否则 400。
export function markAllAsReadApi(type) {
return request({ url: `/api/v1/notifications/read-all?type=${encodeURIComponent(type)}`, method: 'POST' })
}
// deleteNotificationApi —— 单条软删(列表项长按 [删除] / 明信片页 [🗑删除])
//
// 参数:
// id:必填,notifications.id
//
// 返回:Promise<{ data: void }>
//
// 业务流:
// ① 列表项长按 ActionSheet 第二项 → 本地 store REMOVE_ITEM
// ② 明信片页 [🗑删除] → 本地 store REMOVE_ITEM + setTimeout 500ms 后
// uni.navigateBack() 回列表页(让用户看到"删除" toast 再跳)
//
// 注意:后端 is_deleted=TRUE 软删,不真删;统计/红点不受影响(只 decr 标已读数)
export function deleteNotificationApi(id) {
return request({ url: `/api/v1/notifications/${id}`, method: 'DELETE' })
}
// clearNotificationsApi —— 按 type 软删(本期不直接调用,但留给"按 type 清空"场景)
//
// 参数:
// type:必填,枚举字符串: activity | feedback_replied | report_resolved
// | target_reported | all
//
// 返回:Promise<{ data: { affected } }>
//
// 业务流(本期):
// 列表页 [全部删除] → 仅传 'all' 一键清 4 类 → 本地 store CLEAR_ALL
//
// 注意:本期实现是 1 次 type='all' 调用;若以后加"按 type 清"按钮,直接传对应字符串。
// 后端:DELETE /api/v1/notifications/clear?type=<x> (v1.1 新加接口,见 §4)
// type 走查询串(gateway controller g.Query("type") 提取,与 read-all 同款)
export function clearNotificationsApi(type = 'all') {
return request({ url: `/api/v1/notifications/clear?type=${encodeURIComponent(type)}`, method: 'DELETE' })
}
注释规范要点(沿用 utils/api.js 既有约定):
- 每组(功能类)函数用
// ==================== 功能名(spec 章节) ====================横线分隔 - 函数上方块状中文注释:
功能名 —— 用途 (触发场景) \n 参数: \n 返回: \n 业务流: \n 注意: - 不引入 JSDoc(项目未使用 JSDoc 格式)
- 函数体保持原
request({ url, method, data })三件套 - 复杂字段(如 type 枚举)就地在注释里列字符串列表,不外链到 .d.ts
5.5 pages/mailbox/index.vue 列表页(Vue 3 <script setup>)
<template>
<view class="mailbox-list">
<CustomHeader title="收件箱" :showBack="true" />
<view class="toolbar">
<button class="btn-mark-all" @click="onMarkAllRead">[+全部已读]</button>
<button class="btn-delete-all" @click="onClearAll">[+全部删除]</button>
</view>
<scroll-view scroll-y="true" class="list-scroll" @scrolltolower="onScrollLower">
<MailboxGroup
v-for="grp in GROUPS" :key="grp.type"
:groupKey="grp.type"
:label="grp.label"
:collapsed="isCollapsed(grp.type)"
:items="itemsByType[grp.type]"
:unreadCount="unreadByType[grp.type] || 0"
@toggle="toggleCollapsed(grp.type)"
@select="onSelectItem"
@long-press="onLongPress"
/>
<view v-if="!hasAny && !loading" class="empty">暂无通知</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-if="hasAny && !hasMore" class="no-more">没有更多了</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, onMounted, onShow } from 'vue'
import { useMailboxCenter } from '../composables/useMailboxCenter.js'
const {
GROUPS, itemsByType, unreadByType,
loading, hasAny, hasMore,
loadAll, onScrollLower,
onMarkAllRead, onClearAll,
onSelectItem, onLongPress,
isCollapsed, toggleCollapse,
} = useMailboxCenter()
onShow(() => { loadAll() })
</script>
5.6 pages/mailbox/detail.vue 明信片页(Vue 3 <script setup>)
<template>
<view class="mailbox-detail">
<CustomHeader title="明信片" :showBack="true" />
<view v-if="notification" class="postcard-wrap">
<Postcard
:notification="notification"
@mark-read="onMarkRead"
@delete="onDeleteOne"
/>
</view>
<view v-else class="empty-detail">明信片不存在或已删除</view>
<!-- 底部固定 toolbar: 单条 [✓标已读] + [🗑删除] -->
<view v-if="notification" class="postcard-toolbar">
<button
v-if="!notification.is_read"
class="btn-mark-read"
@click="onMarkRead"
>[✓ 标已读]</button>
<button v-else class="btn-mark-read-disabled" disabled>[已读]</button>
<button class="btn-delete" @click="onDeleteOne">[🗑 删除]</button>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { onLoad } from '@dcloudio/uni-app'
import { useMailboxCenter } from '../composables/useMailboxCenter.js'
import { getNotificationsApi } from '@/utils/api.js'
const store = useStore()
const { onMarkRead: mr, onDeleteOne: del } = useMailboxCenter()
const nid = ref(null)
const type = ref(null)
onLoad((options) => {
nid.value = Number(options.nid)
type.value = options.type
})
const notification = computed(() => {
if (!nid.value || !type.value) return null
// 优先从 store 取(已加载过的条目)
const item = store.state.mailbox.itemsByType[type.value]?.find(n => n.id === nid.value)
if (item) return item
// store 没有则单条拉一次(杀进程后退 push 进来场景)
loadOne()
return store.state.mailbox.detailCache || null
})
async function loadOne() {
const resp = await getNotificationsApi({ type: type.value, page: 1, page_size: 50 })
const item = (resp.items || []).find(n => n.id === nid.value)
if (item) store.commit('mailbox/SET_DETAIL_CACHE', item)
}
async function onMarkRead() {
await mr(nid.value, type.value)
uni.showToast({ title: '已标已读', icon: 'none' })
// 留在本页(用户可能想再看一眼),也可选择 setTimeout 后 navigateBack
}
async function onDeleteOne() {
await del(nid.value, type.value)
uni.showToast({ title: '已删除', icon: 'none' })
setTimeout(() => uni.navigateBack(), 500)
}
</script>
5.7 useMailboxCenter.js(组合式 API)
import { computed } from 'vue'
import { useStore } from 'vuex'
import {
getNotificationsApi, markAsReadApi, markAllAsReadApi,
deleteNotificationApi, clearNotificationsApi
} from '@/utils/api.js'
const GROUPS = [
{ type: 'activity', label: '活动通知' },
{ type: 'feedback_replied', label: '反馈回复' },
{ type: 'report_resolved', label: '举报结果' },
{ type: 'target_reported', label: '被举报' },
]
export function useMailboxCenter() {
const store = useStore()
const itemsByType = computed(() => store.state.mailbox.itemsByType)
const unreadByType = computed(() => store.state.mailbox.unreadByType)
const loading = computed(() => store.state.mailbox.loading)
const hasAny = computed(() => GROUPS.some(g => (itemsByType.value[g.type] || []).length > 0))
const hasMore = computed(() => Object.values(store.state.mailbox.noMoreByType).some(v => !v))
// 列表页 onShow 时并发 4 个 type 各拉首页
async function loadAll() {
store.commit('mailbox/SET_LOADING', true)
try {
await Promise.all(GROUPS.map(g =>
getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
.then((resp) => {
store.commit('mailbox/SET_ITEMS', { type: g.type, items: resp.items || [], sortUnreadFirst: true })
store.commit('mailbox/SET_NO_MORE', { type: g.type, value: (resp.items || []).length < 20 })
if (resp.unreadByType) store.commit('mailbox/REPLACE_UNREAD_BY_TYPE', resp.unreadByType)
})
))
} finally {
store.commit('mailbox/SET_LOADING', false)
}
}
function onScrollLower() {
const target = GROUPS.find(g => !store.state.mailbox.noMoreByType[g.type])
if (target) loadMore(target.type)
}
async function loadMore(type) {
const page = Math.floor((itemsByType.value[type].length / 20) + 1)
const resp = await getNotificationsApi({ type, page, page_size: 20 })
store.commit('mailbox/APPEND_ITEMS', { type, items: resp.items || [] })
store.commit('mailbox/SET_NO_MORE', { type, value: (resp.items || []).length < 20 })
}
async function onMarkRead(nid, type) {
await markAsReadApi(nid)
store.commit('mailbox/UPDATE_ITEM', { type, id: nid, patch: { is_read: true } })
store.commit('mailbox/DECREMENT_TYPE', type)
store.commit('mailbox/RESORT_ITEMS', type) // 已读排后
}
async function onMarkAllRead() {
await Promise.all(GROUPS.map(g => markAllAsReadApi(g.type)))
store.commit('mailbox/MARK_ALL_READ')
}
async function onDeleteOne(nid, type) {
await deleteNotificationApi(nid)
store.commit('mailbox/REMOVE_ITEM', { type, id: nid })
}
async function onClearAll() {
const ok = await new Promise((resolve) => {
uni.showModal({ title: '提示', content: '清空所有通知?', success: (r) => resolve(r.confirm) })
})
if (!ok) return
await clearNotificationsApi('all')
store.commit('mailbox/CLEAR_ALL')
}
function onSelectItem(nid, type) {
onMarkRead(nid, type)
uni.navigateTo({ url: `/pages/mailbox/detail?nid=${nid}&type=${type}` })
}
function onLongPress(nid, type) {
uni.showActionSheet({
itemList: ['标已读', '删除'],
success: (res) => {
if (res.tapIndex === 0) onMarkRead(nid, type)
else if (res.tapIndex === 1) onDeleteOne(nid, type)
}
})
}
function isCollapsed(type) {
const saved = uni.getStorageSync('mailbox_collapsed') || {}
return !!saved[type]
}
function toggleCollapse(type) {
const saved = uni.getStorageSync('mailbox_collapsed') || {}
saved[type] = !saved[type]
uni.setStorageSync('mailbox_collapsed', saved)
}
return {
GROUPS,
itemsByType, unreadByType, loading, hasAny, hasMore,
loadAll, onScrollLower, loadMore,
onMarkRead, onMarkAllRead, onDeleteOne, onClearAll,
onSelectItem, onLongPress,
isCollapsed, toggleCollapse,
}
}
5.8 Vuex mailbox module 关键 mutations (摘要)
SET_ITEMS (state, { type, items, sortUnreadFirst }) {
const sorted = sortUnreadFirst
? [...items].sort((a, b) => (a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1))
: items
state.itemsByType[type] = sorted
},
APPEND_ITEMS (state, { type, items }) { state.itemsByType[type].push(...items) },
UPDATE_ITEM (state, { type, id, patch }) {
const arr = state.itemsByType[type]
const i = arr.findIndex(n => n.id === id)
if (i >= 0) arr[i] = { ...arr[i], ...patch }
},
REMOVE_ITEM (state, { type, id }) {
state.itemsByType[type] = state.itemsByType[type].filter(n => n.id !== id)
},
RESORT_ITEMS (state, type) {
state.itemsByType[type].sort((a, b) => (a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1))
},
SET_NO_MORE (state, { type, value }) { state.noMoreByType[type] = value },
DECREMENT_TYPE (state, type) { state.unreadByType[type] = Math.max(0, (state.unreadByType[type] || 0) - 1) },
REPLACE_UNREAD_BY_TYPE (state, byType) { state.unreadByType = { ...state.unreadByType, ...byType } },
MARK_ALL_READ (state) {
state.unreadByType = { activity: 0, feedback_replied: 0, report_resolved: 0, target_reported: 0 }
Object.keys(state.itemsByType).forEach(t => {
state.itemsByType[t] = state.itemsByType[t].map(n => ({ ...n, is_read: true }))
})
},
CLEAR_ALL (state) {
GROUPS.forEach(g => state.itemsByType[g.type] = [])
state.unreadByType = {}
},
SET_DETAIL_CACHE (state, item) { state.detailCache = item }
5.9 样式(CSS / SCSS)
// pages/mailbox/index.vue 列表页
.mailbox-list {
display: flex; flex-direction: column; height: 100vh; background: #f7f8fa;
.toolbar {
display: flex; justify-content: space-between; padding: 16rpx 24rpx;
background: #fff; border-bottom: 1rpx solid #eee;
.btn-mark-all, .btn-delete-all {
font-size: 26rpx; padding: 12rpx 24rpx; border-radius: 32rpx;
background: #5C40FF; color: #fff;
}
.btn-delete-all { background: #f5222d; }
}
.list-scroll { flex: 1; padding: 24rpx; }
.empty, .loading, .no-more {
text-align: center; color: #999; padding: 48rpx 0; font-size: 26rpx;
}
}
// pages/mailbox/detail.vue 明信片页
.mailbox-detail {
display: flex; flex-direction: column; height: 100vh; background: #f7f8fa;
.postcard-wrap { flex: 1; padding: 24rpx; padding-bottom: 140rpx; }
.empty-detail { padding: 80rpx; text-align: center; color: #999; }
.postcard-toolbar {
position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx;
display: flex; gap: 16rpx; padding: 16rpx 24rpx;
background: #fff; border-top: 1rpx solid #eee;
.btn-mark-read { flex: 1; background: #5C40FF; color: #fff; border-radius: 16rpx; font-size: 28rpx; }
.btn-mark-read-disabled { flex: 1; background: #f0f0f0; color: #999; border-radius: 16rpx; font-size: 28rpx; }
.btn-delete { flex: 1; background: #f5222d; color: #fff; border-radius: 16rpx; font-size: 28rpx; }
}
}
5.10 CLAUDE.md 合规清单
- Vue 3 组合式 API(
<script setup>),不复用myReports/myFeedbacks的 Options API - 接口走
utils/api.js(不裸uni.request) - 跨页状态走 Vuex (
mailboxmodule) + 明信片页 onLoad 取nid/type从 store 取条目 - 新页已注册(
index/detail两页路由) profile.vue服务与工具 section 加 📬 收件箱 service-button(参考同 section "新手指引/反馈" 等的视觉规范)- 不动任何
setInterval / setTimeout轮询 - 不动
unpackage/dist/ - 移动端优先:list 页全宽 + tap push 到 detail 页全宽 + 列表页底部 [全部已读 / 全部删除] / 明信片页底部 [标已读 / 删除] 两套操作按钮
- 列表操作 = 明信片操作覆盖: 4 个操作任何页面都能完成
- 不长列表(明信片内容由后端控制长度)
6. 布局与可达性
6.1 布局策略(v1.2 修订:手机竖屏为主,放弃 master-detail)
经用户反馈"竖屏分栏两边都挤",本期改为两页路由:
| 场景 | 页面 | 布局 |
|---|---|---|
| 进入收件箱 | pages/mailbox/index.vue |
全宽列表(4 分组可折叠),底部 [全部已读 / 全部删除] |
| 点列表项 | pages/mailbox/detail.vue?nid=&type= |
全宽明信片,底部 [标已读 / 删除] |
为什么不做响应式分栏:手机竖屏 50/50 两边都很挤(iPhone 默认宽 750rpx 远小于 pad 1024+);iPad / 横屏以后真要做分栏,Stage 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:
// 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自动幂等clearAll4 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长度 4KBfeedback_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—— mocknotificationClient.CreateNotification收到 1 次调用,参数对得上(type=feedback_replied, content=reply_content 全文)TestReportResolved_TriggersNotification—— type=report_resolved, content=resolution_noteTestTargetReported_TriggersNotification—— 只在 action ∈ {warn/takedown/ban} 时发,dismiss 不发
backend/services/activityService/service/activity_emitter_test.go (新增):
TestActivityMessage_TriggersActivityNotification—— 别人在你活动下留言 → mock notificationClient.CreateNotification 收到 1 次 type=activityTestActivityStartEnd_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 文件 + 新增, 单 commitfeat: 邮箱聚合收件箱 - 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.vuefrontend/pages/profile/myFeedbacks.vue
理由: 避免无关改动,遵循 CLAUDE.md 的"一个 commit 只做一件事"原则;这些文件已无下游引用,保留不影响新功能。
11. 部署与回滚
11.1 部署顺序
- 数据库 migration: 先 PG alter
notification_stats加 3 列(幂等IF NOT EXISTS,重复执行无副作用) - 后端服务更新:
notificationService重启(迁移 + ClearByType RPC + 仓库改动)moderationService重启(3 处 emitter)activityService重启(1-2 处 emitter)gateway重启(路由注册 ClearByType)
- 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.DataJSON 反序列化后塞进datamap。 我们新增 keyunreads_by_type+total_unread进 data map,语义零变化。
backend/pkg/push/uni_push_client.go 推送时 payload 整体结构:
{
"cids": ["cid-1", "cid-2"],
"title": "您的反馈已处理", // = notifications.title
"content": "完整回复全文...", // = notifications.content
"request_id": "1700000000000_abcd",
"data": { // map[string]interface{}, App 端核心读取
"notification_id": 12345, // = notifications.id (现有字段)
"type": "feedback_replied", // = notifications.type (现有字段)
"star_id": 1, // = notifications.star_id (现有字段)
"feedback_id": 1, // = notifications.data.feedback_id (现有字段,来自业务 emitter)
"category_code": "login", // = notifications.data.category_code (现有字段)
"original_title": "登录失败", // = notifications.data.original_title (现有字段)
"unreads_by_type": { // 🆕 本期新增,APP 端本地 +1 不查 HTTP
"activity": 8,
"feedback_replied": 3,
"report_resolved": 5,
"target_reported": 2
},
"total_unread": 18 // 🆕 本期新增,等于 Σ unreads_by_type
}
}
后端组装时机(在 triggerPush 内、Send 前):
// notification_service.go:175 triggerPush 内追加
stats, err := s.statsRepo.Get(cctx, n.UserID, n.StarID) // stats 含 6 类 unread count
if err == nil {
data["unreads_by_type"] = map[string]int{
"like": stats.LikeUnreadCount,
"system": stats.SystemUnreadCount,
"activity": stats.ActivityUnreadCount,
"feedback_replied": stats.FeedbackRepliedUnreadCount,
"report_resolved": stats.ReportResolvedUnreadCount,
"target_reported": stats.TargetReportedUnreadCount,
}
data["total_unread"] = stats.TotalUnreadCount
}
为什么 user_id 不在 data 里:cids 字段决定了推送谁;data 只放 App 端跳转 / 红点 / 详情展示需要的字段。
12.3 App.vue 初始化(#ifdef APP-PLUS)
// #ifdef APP-PLUS
import { registerDeviceApi } from '@/utils/api.js'
import store from '@/store/index.js'
export default {
onLaunch() {
// 1. 拿 cid 上报
uni.getPushClientId({
success: (res) => {
const platform = plus.os.name === 'iOS' ? 'ios'
: plus.os.name === 'Android' ? 'android' : 'harmony'
registerDeviceApi({
cid: res.cid, platform,
appVersion: plus.runtime.version,
deviceModel: plus.device.model
})
}
})
// 2. 注册 receive 监听器(前台 + 后台都触发)
// msg.payload 是 uniCloud 推过来的整个 payload 字符串 (JSON),
// 真正的业务字段都在 envelope.data 嵌套对象里 (见 §12.2)。
// 注意:Vuex action 需要 title/content 用于 PREPEND_ITEM 显示新条目,
// 这些字段在 envelope 顶层,所以打包 { data, title, content } 一起传。
plus.push.addEventListener('receive', (msg) => {
try {
const envelope = JSON.parse(msg.payload || '{}')
const data = envelope.data || {}
store.dispatch('mailbox/applyPushPayload', {
data, // 内层业务字段(含 unreads_by_type / notification_id)
title: envelope.title || '', // 外层 title
content: envelope.content || '' // 外层 content
})
} catch (e) {
console.error('[mailbox] receive payload parse failed', e)
}
})
// 3. 注册 click 监听器(用户点通知栏条目时跳转邮箱)
plus.push.addEventListener('click', (msg) => {
try {
const envelope = JSON.parse(msg.payload || '{}')
const data = envelope.data || {}
uni.navigateTo({
url: `/pages/mailbox/index?focus=${data.type || ''}&nid=${data.notification_id || ''}`
})
} catch (e) {}
})
}
}
// #endif
关键自审点(v1.2.4):
data必须从外层 envelope 里解,不能直接拿 envelope 当 data 用。原本(v1.2.3 前)的"applyPushPayload(payload)"写法会让 action 收到的永远是{cids, title, content, data, ...}外层,内层unreads_by_type永远拿不到。
12.4 Vuex mailbox/applyPushPayload action
// store/modules/mailbox.js
//
// 收到 receive 推送时调用,本地同步未读数
//
// 参数 data = App.vue 解出的 envelope.data (见 §12.3),即:
// {
// notification_id, type, star_id,
// feedback_id/category_code/original_title ... (业务字段),
// unreads_by_type: { activity: 8, feedback_replied: 3, ... },
// total_unread: 18
// }
actions: {
applyPushPayload({ commit, rootState }, { data, title = '', content = '' }) {
if (!data || !data.unreads_by_type) return // 不是通知类推送(可能 cids/系统消息)直接忽略
// 1. 直接用 server 端投递的权威未读数覆盖本地(避免本地累计漂移)
commit('REPLACE_UNREAD_BY_TYPE', data.unreads_by_type)
commit('SET_TOTAL_UNREAD', data.total_unread || 0)
// 2. 若用户在邮箱页,本地插入新条目(否则下次进邮箱才能看到)
if (rootState.mailbox.isInMailboxPage && data.notification_id && data.type) {
commit('PREPEND_ITEM', {
type: data.type,
notification: {
id: data.notification_id,
title: title || '',
content: content || '',
data: JSON.stringify(data), // 业务字段 JSON 字符串
is_read: false,
created_at: Date.now()
}
})
}
}
}
✅ 参数从 envelope 改为
{ data, title, content }由 App.vue 解 envelope 后打包传入。
12.5 边界场景
| 场景 | 行为 |
|---|---|
| 用户禁用了所有 push 通道(iOS 设置 / Android 自启动管理) | App 不触发 receive;用户进入邮箱页时拉一页 GET /api/v1/notifications?type=... 自然对齐(顺带把红点刷新) |
| App 后台 时收到推送 | 系统通知栏显示;用户点通知栏 → click 监听器 → 跳 pages/mailbox/index?focus=type&nid=...,列表页 onLoad 命中焦点项 → 自动 navigateTo 明信片页 |
| App 前台 时收到推送 | 不弹系统通知栏(uniPush 默认),触发 receive 回调 → Vuex 更新 → 红点 +1 / 邮箱页若有此 type 在展示 → 实时插入 |
| App 杀进程 后被推送唤醒 | 启动时 App.vue#onLaunch 触发 → 注册 receive + 拉一次 cid → 新推送按 receive 处理 |
| 多设备登录 | 每设备独立 cid,各自 receive;本设计按设备独立维护未读,最后一端以最后一次 receive 为准(写在 §12 老反例撤回说明里) |
| payload 解析失败 | applyPushPayload 内已 catch, 不影响后续推送 |
12.6 不在本期范围
- ❌ 邮件发送 / 真实 SMTP("邮箱"语义是站内信)
- ❌ Tab 切换(已切到分组模式)
- ❌ 折叠/展开 明信片本身(明信片永远全开)
- ❌ 通知撤回 / 归档按钮
- ❌ 详情页跳转 (绕过 myReports/myFeedbacks/任何详情子页)
- ❌ WebSocket / SSE / 自建长连接 —— uniPush 已提供事件通道,本期不引新通道
- ❌ 推送通道替代方案调研(个推/HMS/miPush/oppoPush/vivoPush 都在 uniPush 后端,本设计透明)
- ❌ 定时器轮询 /
setInterval/setTimeout任何形式的"周期性请求" - ❌ 跨设备未读数强同步(先按设备独立,最终一致性即可)
12.7 推送节流三道防线(v1.2.6 新增)
问题场景:管理员一次性处理 5 条举报 → 5 条 CreateNotification → 5 个 user_device 推送 → 用户手机5 次系统通知栏弹出 + 5 次震动 + 5 次 App receive 回调。这是灾难级 UX。
目标:
- 手机通知栏最多 1 次系统提示(合并的多条用 summary 文案)
- 红点始终精确(5 条全 1 +5)
- 邮箱列表全(inbox 页 onShow 拉数据时仍是 5 条)
- 零额外 HTTP / 零定时器
第一道:后端推送合并(pkg/push 层)
文件: backend/pkg/push/uni_push_client.go 增 RateLimiter 结构(v1.2.8 加入 count 聚合)。
// RateLimiter 节流器,key = "user_id:star_id:type"。
// 三个状态字段:
// lastSent: key → lastSentUnixMilli (窗口判定)
// pending: key → 该 key 本窗口累计 (聚合计数)
// sentMode: key → 最近一次 Send 的模式 (concrete / summary)
//
// windowMs 默认 60000 (60s, 见 §12.7 调优日志)。
type RateLimiter struct {
mu sync.Mutex
lastSent map[string]int64
pending map[string]int
sentMode map[string]string
windowMs int64
}
// Allow 判定本次推送应走的模式:
// - key 首次出现 / 窗口已过 → mode=concrete, count=1, lastSent 重置
// - key 在窗口内 → mode=summary, pending[key]++, count=pending[key]
func (r *RateLimiter) Allow(key string) (mode string, count int) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UnixMilli()
last, ok := r.lastSent[key]
if !ok || now-last >= r.windowMs {
r.lastSent[key] = now
r.pending[key] = 1
r.sentMode[key] = "concrete"
return "concrete", 1
}
r.pending[key]++
r.sentMode[key] = "summary"
return "summary", r.pending[key]
}
// TypeChinese 把 notification.type 映射到中文标签,summary title 用。
func TypeChinese(t string) string {
switch t {
case "activity": return "活动通知"
case "feedback_replied": return "反馈回复"
case "report_resolved": return "举报结果"
case "target_reported": return "被举报"
case "like": return "点赞消息"
case "system": return "系统消息"
default: return "通知"
}
}
notification_service.go:175 triggerPush 内改造:
key := fmt.Sprintf("%d:%d:%s", n.UserID, n.StarID, n.Type)
mode, count := s.rateLimiter.Allow(key)
title, content := n.Title, n.Content
if mode == "summary" {
title = fmt.Sprintf("您有 %d 条新%s", count, push.TypeChinese(n.Type))
content = "点击查看收件箱"
}
// data map 内 unreads_by_type/total_unread 仍真塞(§12.2 既有逻辑),summary 也带。
// summary 不塞 notification_id,App 端识别不到 nid 不做 PREPEND_ITEM。
聚合展示示例(管理员 5s 内批处理 5 条 feedback_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 滑窗:
const recentByType = {} // { feedback_replied: 1700000000000, ... }
const DEBOUNCE_MS = 10000 // v1.2.6 调优:原 5s 偏短,改 10s 留足 buffer
plus.push.addEventListener('receive', (msg) => {
try {
const envelope = JSON.parse(msg.payload || '{}')
const data = envelope.data || {}
const t = data.type
const now = Date.now()
// 防抖:同 type 10s 内已 dispatch 跳过 (re-render 收敛)
if (t && recentByType[t] && now - recentByType[t] < DEBOUNCE_MS) {
return
}
if (t) recentByType[t] = now
store.dispatch('mailbox/applyPushPayload', {
data, title: envelope.title || '', content: envelope.content || ''
})
} catch (e) {}
})
summary push (没 notification_id 或没 type) 的 applyPushPayload 仍执行:更新 unreads_by_type + total_unread,不触发 PREPEND_ITEM (§12.4 action 内已判断 data.notification_id && data.type)。
第三道:Vuex 幂等性保证
// mutations
PREPEND_ITEM(state, { type, notification }) {
const list = state.itemsByType[type] || []
// 1) 防止同一 notification 被重复 prepend (uniPush 重发 / 网络重试)
if (list.some(n => n.id === notification.id)) return
// 2) 防止同 type 多条同时 prepend 时排序混乱:未读排前
list.unshift(notification)
list.sort((a, b) => (a.is_read === b.is_read) ? (b.created_at - a.created_at) : (a.is_read ? 1 : -1))
state.itemsByType[type] = list
}
REPLACE_UNREAD_BY_TYPE(state, byType) {
// server 端权威覆盖,不累加(防止 N 次 push 把一个 +1 算成 +N)
state.unreadByType = { ...state.unreadByType, ...byType }
}
节流参数可配化
UniPushClient 构造时通过外部 pkg/config 注入:
push:
rate_limit_window_ms: 60000 # (user, type) 节流窗口,默认 60s (v1.2.6 调优)
app_debounce_ms: 10000 # App 端 receive 去抖,默认 10s (v1.2.6 调优)
V1 用硬编码也可,MVP 不强制配置化。
边界场景(60s / 10s 配置)
| 场景 | 节流表现 |
|---|---|
| 管理员 1 条 1 条处理(每条相隔 > 60s) | 全部 concrete push,无节流 |
| 管理员 1 次批量 5 条(30s 内完成) | 第 1 条 concrete,第 2-5 条 summary title 递增:"您有 2 条新反馈回复" → 3 → 4 → 5;手机 5 次弹出,每次 title 增长,用户能直观看到量级 |
| 管理员 5 分钟内分批处理 10 条 (每批思考 30s) | 10 条全部独立 concrete,因为间隔 > 60s 始终开启窗口 |
| App 在前台时 App receive 第 6 条 summary (10s 内多次) | App 内 unreads_by_type 准确更新 (REPLACE 幂等),仅第 1 次 commit PREPEND_ITEM;后续 receive 因为 10s 去抖被拦掉 |
| App 在后台时 receive 6 条 summary | 手机通知栏弹 5 次(concrete 1 + summary 4 title 递增);App 进前台时 redDot 仍准确 |
| 用户 60s 后又来 1 条 | 上次 concrete 距今 > 60s,自动恢复 concrete 模式 (pending[key]=1,sentMode 重置) |
| 节流窗口内登出再登入 (换 cid) | 新 cid 不在 RateLimiter 历史里 → 视为新 key → 第一次 concrete (节流 key 是 user_id 所以换 cid 不影响) |
| 突增边界 case:admin 用了 60s 节流期间登录另一台设备 | RateLimiter key = user⭐type 是 user 维度,不依赖 cid,所以换设备不会"skip" → 安全 |
| 节流模式可用 toggle 按钮(Stage 2) | V1 不实现,用户粒度"勿扰模式"留给 Stage 2 |
"兜底一次性 HTTP" 说明:用户在列表页
pages/mailbox/index.vueonShow时拉 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 是否进邮箱"行 |