topfans/docs/superpowers/plans/2026-07-14-mailbox-inbox-impl.md
2026-07-14 21:59:15 +08:00

61 KiB
Raw Blame History

站内邮箱聚合收件箱 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:pages/profile/profile.vue 的"服务与工具"section 加 📬 收件箱入口,跳到新建的 pages/mailbox/{index,detail}.vue 看 4 类通知(活动通知 / 反馈回复 / 举报结果 / 被举报实时uniPush receive 驱动而非轮询、节流60s / 10s+ 聚合("您有 N 条新…")地推到 App。

Architecture:

  • 后端: Go 单体,扩 public.notifications 表的 type 白名单 4 类 (activity+feedback_replied+report_resolved+target_reported)public.notification_stats 加 3 列。allowedTypes map 白名单 + 新增 ClearByType RPC + 后端 RateLimiter 节流 60s + TypeChinese 聚合 title4 处业务 emitter 复用既有 notificationClient 写通知。socialServicelike emitter 不动 (V1.2.10 用户要求点赞不进收件箱)。
  • 前端: Vue 3 <script setup> + Vuex 4 + 组合式 API。utils/api.js 加 6 个 wrapper (列表/未读/标已读/全标已读/删除/清空)。Vuex mailbox module 维护 itemsByType / unreadByType / mutation = REPLACE_UNREAD_BY_TYPE (idempotent, server 端权威覆盖) + PREPEND_ITEM (按 id 去重)。两页路由 (index.vue + detail.vue)。App.vue#ifdef APP-PLUS 块内注册 plus.push 的 receive + click 监听,App 前台时 receive 直接更新 Vuex,不发 HTTP。

Tech Stack:

  • Backend: Go 1.25.5, gorm, grpc-gateway HTTP, postgres, uniCloud sendMessage (uniPush client lib in pkg/push)
  • Frontend: uni-app 3.x (Vue 3 组合式 API), <script setup>, Vuex 4, uni-app's request, plus.push.addEventListener, uniCloud push payload
  • 测试: 后端 go test + gorm 事务回滚; 前端 手验 + global grep (按 share-impl-test-policy.md 不写 Vitest)

Global Constraints

照抄自 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md 与仓库 CLAUDE.md,所有任务隐含遵守:

  1. CLAUDE.md Git 提交规范: AI 不得主动 commit;只有用户明确说"帮我 commit"或"提交吧"才执行。每个任务完成后等待用户明确指示才 commit。
  2. CLAUDE.md 数据库操作规范: 任何手动指定 ID 的 INSERT 末尾必须 SELECT setval('xxx_id_seq', (SELECT MAX(id) FROM xxx)) (本期无 INSERT 任务,但 reviewer 应留意)。本期唯一 ALTER TABLE 是 0.5 天内幂等执行 (ADD COLUMN IF NOT EXISTS)。
  3. CLAUDE.md 接口开发规范: handler/service/repository 三层分离,handler 禁直接调 ORM,service 禁操作 HTTP。DTO/Request/Response 分文件。本期已有代码遵循此约定。
  4. CLAUDE.md 前端开发规范: Vue 3 组合式 API (<script setup>), Options API;#ifdef APP-PLUS 包原生 API;接口走 utils/api.js 不裸 uni.request;跨页状态走 Vuex;新页pages.json 注册再写 .vue。setInterval / setTimeout 任何形式的"周期性请求"被显式禁止。
  5. CLAUDE.md PostgreSQL 序列同步: 本计划不触发 (无 INSERT 指定 ID 的测试 SQL);若 reviewer 写测试 SQL 也要 SELECT setval('xxx_id_seq', ...)
  6. 推送节流参数 (V1.2.7): 后端 (user, type) 60s 滑窗;App 10s 同 type 去抖。
  7. 点赞不进邮箱 (V1.2.10): 不动 socialService/asset_like_service.go 的 like emitter;前端 4 GROUPS 不含 like;profile.vue badge 用 Vuex getter inboxUnreadOnly (排除 like / system)。
  8. 修改/不删除旧文件: 本期不删除 myReports.vue / myFeedbacks.vue (CLAUDE.md 一个 commit 一件事), 也不动 socialService/asset_like_service.go(V1.2.10 显式排除)。
  9. uniPush payload 形态 (V1.2.5): Payload.Datamap[string]interface{} JSON 对象,不是字符串;unreads_by_type + total_unread 字段在 envelope.data 内层,不在顶层。
  10. Gateway 查询串约定 (V1.2.5): controller 读 g.Query("type")(body 不解);前端按此调用 ?type=<x>
  11. CLAUDE.md 接口开发规范 §9 测试: 后端 service 层核心业务逻辑必须覆盖单元测试 (本期 ClearByType + 4 处 emitter 各 ≥1 用例);前端按 share-impl-test-policy.md 不写 Vitest,手验即可。

File Structure (locked-in by spec)

后端 — 新增 (5)

File Responsibility
backend/migrations/20260713_add_mailbox_unread_counts.sql ALTER public.notification_stats 加 3 列 (feedback_replied_unread_count, report_resolved_unread_count, target_reported_unread_count)
backend/services/notificationService/service/notification_service_test.go 4 个新测试: 3 个 type 白名单扩参, 1 个 ClearByType 事务
backend/services/moderationService/service/moderation_service_test.go 3 个新测试: 3 处 emitter (feedback_replied / report_resolved / target_reported)
backend/services/activityService/service/activity_emitter_test.go 2 个新测试: activity_message / activity_start_end

后端 — 修改 (13)

File Changes
backend/proto/notification.proto UnreadCount 增 3 字段号 5/6/7 (保留 total=4);新增 ClearByType RPC + ClearByTypeRequest/Response
backend/services/notificationService/service/notification_service.go allowedTypes map 增 3 key; ClearByType 业务方法 (事务内 UPDATE is_deleted + stats 清零); triggerPush 注入 unreads_by_type + total_unread 进 data map
backend/services/notificationService/repository/notification_repository.go 新增 ClearByType 方法
backend/services/notificationService/repository/notification_stats_repository.go struct 加 3 字段; IncrementByType / DecrementByType / Get 改通用 (按 type 列名映射)
backend/pkg/push/uni_push_client.go 新增 RateLimiter 结构 + Allow(key) 方法;新增 TypeChinese(t) 函数
backend/gateway/router/router.go 注册 DELETE /api/v1/notifications/clear
backend/gateway/controller/notification_controller.go ClearByType HTTP handler
backend/services/moderationService/service/feedback_service.go 引入 notificationClient;反馈被回复时建 type=feedback_replied 通知
backend/services/moderationService/service/report_service.go 引入 notificationClient;举报处理完成时建 type=report_resolved 通知
backend/services/moderationService/service/target_status_service.go 引入 notificationClient;warn/takedown/ban 时建 type=target_reported 通知 (dismiss 不发)
backend/services/activityService/service/activity_message_service.go 引入 notificationClient;留言下发时建 type=activity 通知
backend/services/activityService/service/activity_service.go 引入 notificationClient;活动开始/结束时建 type=activity 通知 (给"参与该活动的用户")

前端 — 新增 (6)

File Responsibility
frontend/pages/mailbox/index.vue 列表页: 4 分组可折叠 + [全部已读] [全部删除] 顶部 toolbar
frontend/pages/mailbox/detail.vue 明信片页: 单条全宽 + [标已读] [删除] 底部 toolbar
frontend/pages/mailbox/components/MailboxGroup.vue 单分组列表组件 (header chevron + items)
frontend/pages/mailbox/components/Postcard.vue 明信片组件 (信片数据展示)
frontend/pages/mailbox/composables/useMailboxCenter.js 4 type 并发拉取 + 上拉加载更多 + 单条操作封装
frontend/store/modules/mailbox.js Vuex state (itemsByType / unreadByType / detailCache / isInMailboxPage) + mutations + getter inboxUnreadOnly + actions (applyPushPayload / alignFromServerUnread)

前端 — 修改 (4)

File Changes
frontend/pages.json 注册 pages/mailbox/index + pages/mailbox/detail 两页路由 (custom navigationStyle, app-plus bounce:none)
frontend/utils/api.js 加 6 个 wrapper (getNotificationsApi / getUnreadCountApi / markAsReadApi / markAllAsReadApi / deleteNotificationApi / clearNotificationsApi)。GET/DELETE 用 URL 查询串 (?type=&page=&page_size=) 不用 data:{}
frontend/App.vue onLaunch#ifdef APP-PLUS 块: 1) uni.getPushClientId 上报 registerDeviceApi; 2) plus.push.addEventListener('receive', cb) 解 envelope 取 data 后 10s 同 type 去抖再 dispatch mailbox/applyPushPayload({data, title:envelope.title, content:envelope.content}); 3) plus.push.addEventListener('click', cb) navigateTopages/mailbox/index?focus=<type>&nid=<id>
frontend/pages/profile/profile.vue <!-- 服务与工具 --> section 内 (line 162 之后) 加 📬 收件箱 service-button + 红点 badge (参考同 section guideClaimableCount 写法); v-if="userStore.token"; onShow 触发 dispatch('mailbox/refreshUnread'); click handler navigateTo('/pages/mailbox/index')

关键接口契约 (跨任务依赖)

后端 → 前端 caller 看到的接口:

// proto/notification.proto (片段)
service NotificationService {
  rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
    option (google.api.http) = {
      delete: "/api/v1/notifications/clear"  // controller 读 g.Query("type")
    };
  }
}
message ClearByTypeRequest { string type = 1; }  // activity | feedback_replied | report_resolved | target_reported | all
message ClearByTypeResponse { topfans.common.BaseResponse base = 1; int32 affected = 2; }

message UnreadCount {
  int32 like                 = 1;  // 保留
  int32 system               = 2;  // 保留
  int32 activity             = 3;  // 保留
  int32 total                = 4;  // 保留(原 4, 不重编号)
  int32 feedback_replied     = 5;  // 🆕 追加
  int32 report_resolved      = 6;  // 🆕 追加
  int32 target_reported      = 7;  // 🆕 追加
}

前端 → 后端 caller 看到的接口:

GET    /api/v1/notifications?type=<x>&page=<n>&page_size=<s>      → { items, total, page, page_size }
GET    /api/v1/notifications/unread-count                          → { like, system, activity, total, feedback_replied, report_resolved, target_reported }
POST   /api/v1/notifications/{id}/read                             → 200
POST   /api/v1/notifications/read-all?type=<x>                     → 200
DELETE /api/v1/notifications/{id}                                  → 200
DELETE /api/v1/notifications/clear?type=<x|all>                    → 200  (new)

uniPush payload (envelope → envelope.data):

{
  "cids": ["..."], "title": "...", "content": "...", "request_id": "...",
  "data": {
    "notification_id": 12345, "type": "feedback_replied", "star_id": 1,
    "feedback_id": 1, "category_code": "login", "original_title": "...",
    "unreads_by_type": {"activity":8,"feedback_replied":3,...},
    "total_unread": 18
  }
}

Tasks

Task 1: Database migration + proto 字段扩展

Files:

  • Create: backend/migrations/20260713_add_mailbox_unread_counts.sql
  • Modify: backend/proto/notification.proto:94-105 (UnreadCount message)
  • Modify: backend/proto/notification.proto (添加 ClearByType RPC)

Interfaces:

  • Produces: SQL 文件可被运维 psql 重放; proto 文件已可生成 Go/PB 桩代码。

  • Step 1: 写 SQL migration 文件

-- 20260713_add_mailbox_unread_counts.sql
-- 给 notification_stats 加 3 列,业务 emitter 用。
-- 幂等:重复执行无副作用(IF NOT EXISTS)。

ALTER TABLE public.notification_stats
    ADD COLUMN IF NOT EXISTS feedback_replied_unread_count INT NOT NULL DEFAULT 0;

ALTER TABLE public.notification_stats
    ADD COLUMN IF NOT EXISTS report_resolved_unread_count INT NOT NULL DEFAULT 0;

ALTER TABLE public.notification_stats
    ADD COLUMN IF NOT EXISTS target_reported_unread_count INT NOT NULL DEFAULT 0;
  • Step 2: 修改 proto UnreadCount

编辑 backend/proto/notification.proto,保留 total 在字段 4,新字段追加在后面:

message UnreadCount {
  int32 like                 = 1;
  int32 system               = 2;
  int32 activity             = 3;
  int32 total                = 4;   // 保留,wire 兼容
  int32 feedback_replied     = 5;   // 🆕
  int32 report_resolved      = 6;   // 🆕
  int32 target_reported      = 7;   // 🆕
}
  • Step 3: 加 ClearByType RPC 到 proto

NotificationService service 内添加 (放在 DeleteByTarget 之后):

rpc ClearByType(ClearByTypeRequest) returns (ClearByTypeResponse) {
  option (google.api.http) = {
    delete: "/api/v1/notifications/clear"
  };
}

在文件末尾加 message 定义:

message ClearByTypeRequest {
  string type = 1;  // activity | feedback_replied | report_resolved | target_reported | all
}
message ClearByTypeResponse {
  topfans.common.BaseResponse base = 1;
  int32 affected = 2;  // 受影响的行数
}
  • Step 4: 编译生成 Go 代码
cd backend
make proto  # 或 scripts/gen-proto.sh,具体看仓库 Makefile

Expected: 输出 pkg/proto/notification/notification.pb.goClearByTypeRequest / ClearByTypeResponse / 5/6/7 字段。

  • Step 5: 跑全后端 build
cd backend
go build ./...

Expected: 编译成功(可能有 unused import 警告,但不致命)。

  • Step 6: 等待用户 commit

按 CLAUDE.md Git 规范,不主动 commit。提示用户:git add backend/migrations/20260713_add_mailbox_unread_counts.sql backend/proto/notification.proto pkg/proto/notification/*.pb.go && git commit -m "feat(notification): 加 notification_stats 3 列 unread 计数 + ClearByType RPC 字段"


Task 2: 后端 notificationService: allowedTypes + ClearByType + payload 注入

Files:

  • Modify: backend/services/notificationService/repository/notification_repository.go (加 ClearByType 方法)
  • Modify: backend/services/notificationService/repository/notification_stats_repository.go (改通用 type)
  • Modify: backend/services/notificationService/service/notification_service.go (allowedTypes map + ClearByType 业务方法 + triggerPush data map 注入)

Interfaces:

  • 消费 Task 1 的 SQL/proto。

  • 产生 repository 方法 ClearByType(ctx, tx, userID, starID, ntype, now int64) (int32, error) 调用方使用。

  • 产生 service 方法 ClearByType(ctx, userID, starID, ntype)

  • 产生 service 修改 triggerPush 注入的 data map 字段:notification_id / type / star_id 既有 + unreads_by_type / total_unread 新增。

  • Step 1: 修改 notification_stats_repository.go 让 stats 三方法通用

打开 backend/services/notificationService/repository/notification_stats_repository.go

IncrementByType / DecrementByType / Get 三个方法的"按 type 列名写死 like/system/activity"改成动态列名:

// 在文件顶部加 helper
func statsColumn(t string) string {
    switch t {
    case "like":              return "like_unread_count"
    case "system":            return "system_unread_count"
    case "activity":          return "activity_unread_count"
    case "feedback_replied":  return "feedback_replied_unread_count"
    case "report_resolved":   return "report_resolved_unread_count"
    case "target_reported":   return "target_reported_unread_count"
    default:                  return "total_unread_count"
    }
}

// 修改 IncrementByType(原 hardcode 改成通用)
func (r *NotificationStatsRepository) IncrementByType(ctx context.Context, tx *gorm.DB, userID, starID int64, ntype string, now int64) error {
    col := statsColumn(ntype)
    res := tx.WithContext(ctx).Exec(`
        UPDATE public.notification_stats
        SET `+col+` = `+col+` + 1,
            total_unread_count = total_unread_count + 1,
            updated_at = $4
        WHERE user_id = $1 AND star_id = $2
    `, userID, starID, now)
    if res.Error != nil {
        return fmt.Errorf("increment by type: %w", res.Error)
    }
    return nil
}

// DecrementByType 同理(留意防负数:用 GREATEST)
// Get 改成查询所有 6 列 + 累加得到 total(不依赖 total_unread_count 列本身)

注:原有 Get 方法如果直接 SELECT * 也可,但要确认新加的列被读到。建议显式列出 6 列。

  • Step 2: 加 repository ClearByType 方法

backend/services/notificationService/repository/notification_repository.go 添加:

// ClearByType 软删 user+star 的所有该 type 通知。
func (r *NotificationRepository) ClearByType(ctx context.Context, tx *gorm.DB, userID, starID int64, ntype string) (int32, error) {
    if tx == nil { return 0, errors.New("ClearByType must be called within a transaction") }
    var res *gorm.DB
    if ntype == "all" {
        res = tx.WithContext(ctx).Exec(`
            UPDATE public.notifications
            SET is_deleted = TRUE
            WHERE user_id=$1 AND star_id=$2 AND is_deleted=FALSE
        `, userID, starID)
    } else {
        res = tx.WithContext(ctx).Exec(`
            UPDATE public.notifications
            SET is_deleted = TRUE
            WHERE user_id=$1 AND star_id=$2 AND type=$3 AND is_deleted=FALSE
        `, userID, starID, ntype)
    }
    if res.Error != nil { return 0, fmt.Errorf("clear by type: %w", res.Error) }
    return int32(res.RowsAffected), nil
}
  • Step 3: 修改 service allowedTypes map

backend/services/notificationService/service/notification_service.go line 39-44:

// 改为:
var allowedTypes = map[string]struct{}{
    "like":             {},
    "system":           {},
    "activity":         {},
    "feedback_replied": {},  // 🆕
    "report_resolved":  {},  // 🆕
    "target_reported":  {},  // 🆕
}

并把所有错误消息 "like/system/activity" 改为 "like/system/activity/feedback_replied/report_resolved/target_reported"

  • Step 4: 加 service ClearByType 业务方法

紧贴 MarkAllAsRead 之后(大约 line 460 附近):

// ClearByType 按 type 软删通知(全量已读反操作),事务内 UPDATE is_deleted=TRUE
// 同时清零 stats 对应列。
//
// type 参数: "activity" | "feedback_replied" | "report_resolved" | "target_reported" | "all"
//             "all" 表示对所有 4 类一起清。
//
// 注:目前**不**清 totals,因为邮件聚合页面只是隐藏,用户已读状态已记账。
//     只清未读 (未读 0 但 items 还在),如需"完全清空收件箱"看 §7。
func (s *NotificationService) ClearByType(
    ctx context.Context, userID, starID int64, ntype string, now int64,
) (*notifPb.ClearByTypeResponse, error) {
    if !validator.ValidateUserID(userID) { /*...*/ }
    if !validator.ValidateStarID(starID) { /*...*/ }

    var affected int32
    err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 1) 清通知
        n, err := s.notifRepo.ClearByType(ctx, tx, userID, starID, ntype)
        if err != nil { return err }
        affected = n

        // 2) 清 stats 未读计数(只清未读,不清 totals)
        if ntype == "all" {
            cols := []string{
                "activity_unread_count", "feedback_replied_unread_count",
                "report_resolved_unread_count", "target_reported_unread_count",
                "like_unread_count", "system_unread_count",
            }
            for _, col := range cols {
                if err := tx.WithContext(ctx).Exec(`
                    UPDATE public.notification_stats
                    SET `+col+` = 0, updated_at = $3
                    WHERE user_id=$1 AND star_id=$2
                `, userID, starID, now).Error; err != nil {
                    return fmt.Errorf("clear %s: %w", col, err)
                }
            }
        } else {
            col := statsColumn(ntype)  // 引用 Task 2 Step 1 的 helper
            if err := tx.WithContext(ctx).Exec(`
                UPDATE public.notification_stats
                SET `+col+` = 0, updated_at = $3
                WHERE user_id=$1 AND star_id=$2
            `, userID, starID, now).Error; err != nil {
                return fmt.Errorf("clear %s: %w", col, err)
            }
        }
        return nil
    })
    if err != nil { /* log + return nil, errResp */ }
    return &notifPb.ClearByTypeResponse{ Base: ..., Affected: affected }, nil
}
  • Step 5: 改 triggerPush 注入 unreads_by_type

backend/services/notificationService/service/notification_service.go triggerPush 函数 (line 175 附近),在 Send 前插入 stats 查询:

func (s *NotificationService) triggerPush(n *model.Notification) {
    // ... existing code (line 175-210) ...
    
    // 🆕 注入 stats 到 data map (v1.2.5 修正:在 envelope.data 内层)
    stats, err := s.statsRepo.Get(cctx, n.UserID, n.StarID)
    if err == nil && stats != 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
    }
    
    // 原有 go func() Send 逻辑保留
}

注意:此处用 cctx 而不是 context.Background(),否则会丢失 trace。

  • Step 6: 编译验证
cd backend
go build ./...

Expected: 编译成功。

  • Step 7: 等待用户 commit

Task 3: 后端 gateway 注册 ClearByType 路由

Files:

  • Modify: backend/gateway/router/router.go line ~298 (notifications 路由组)
  • Modify: backend/gateway/controller/notification_controller.go (新增 ClearByType HTTP handler)

Interfaces:

  • 消费 Task 2 的 s.notifService.ClearByType(ctx, userID, starID, type, now)

  • 产生 HTTP 路由 DELETE /api/v1/notifications/clear?type=<x> → 调用 ClearByType service 方法。

  • Step 1: 注册路由

backend/gateway/router/router.go 现有 notifications DELETE 行(line ~298)后追加:

notifications.DELETE("/clear", notificationCtrl.ClearByType)  // 按 type 全软删
  • Step 2: 加 controller handler

backend/gateway/controller/notification_controller.go 中,紧贴 MarkAllAsRead handler 之后添加:

// ClearByType 按 type 软删通知。
//
// Query: ?type=activity|feedback_replied|report_resolved|target_reported|all
func (ctrl *NotificationController) ClearByType(g *gin.Context) {
    userID, _ := g.Get("user_id")
    starID, _ := g.Get("star_id")

    uid, ok := userID.(int64)
    if !ok { /* 401 */ return }
    sid, ok := starID.(int64)
    if !ok { /* 401 */ return }

    typeStr := g.Query("type")
    if typeStr == "" { /* 400 */ return }

    // 鉴权 JWT 由 router.AuthMiddleware 强制(进路由组前已 check)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    now := time.Now().UnixMilli()
    resp, err := ctrl.notifService.ClearByType(ctx, uid, sid, typeStr, now)
    if err != nil {
        logger.Logger.Error("ClearByType failed",
            zap.Int64("user_id", uid), zap.String("type", typeStr), zap.Error(err))
        response.Error(g, http.StatusInternalServerError, "服务调用失败")
        return
    }
    if resp.Base.Code != uint32(codes.OK) {
        response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
        return
    }
    response.Success(g, gin.H{"affected": resp.Affected})
}

注: 如既有 controller 用 protoError(...) 风格,沿用。

  • Step 3: 编译验证
cd backend
go build ./...

Expected: 编译成功。

  • Step 4: 等待用户 commit

Task 4: 后端 pkg/push uni_push_client.go RateLimiter + TypeChinese

Files:

  • Modify: backend/pkg/push/uni_push_client.go (新增 RateLimiter struct + Allow + TypeChinese)

Interfaces:

  • 消费: push.Payload 已有结构(CIDs / Title / Content / RequestID / Data)。

  • 产生: RateLimiter.Allow(key string) (mode string, count int) 返回 concretesummary

  • 产生: TypeChinese(t string) string 把 type 字符串映射到中文标签。

  • Step 1: 加 RateLimiter 结构 + Allow 方法

uni_push_client.go 现有的 Pusher interface 之后,Payload struct 之前(或紧随 Payload 之后,任意位置)添加:

// RateLimiter 节流器,key = "user_id:star_id:type"。
// (V1.2.7) 60s 滑窗;首次走 concrete,后续走 summary;窗口重置后再次 concrete。
// (V1.2.8) summary title 含递增 count:"您有 N 条新<type中文>"。
type RateLimiter struct {
    mu       sync.Mutex
    lastSent map[string]int64
    pending  map[string]int     // 🆕 v1.2.8:本窗口累计
    sentMode map[string]string  // 🆕 v1.2.8:最近 send 的模式
    windowMs int64
}

func NewRateLimiter(windowMs int64) *RateLimiter {
    if windowMs <= 0 { windowMs = 60000 }
    return &RateLimiter{
        lastSent: make(map[string]int64),
        pending:  make(map[string]int),
        sentMode: make(map[string]string),
        windowMs: windowMs,
    }
}

// Allow 判定本次 Send 模式。
// 返回 (mode, count):
//   - "concrete", 1   : 窗口已过或 key 首次出现,Send 原 title
//   - "summary", N    : 窗口内第 N 条(N≥2),Send "您有 N 条新<type>" 标题
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]
}
  • Step 2: 加 TypeChinese 函数

紧贴 RateLimiter 之后:

// 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 "通知"
    }
}
  • Step 3: 在 imports 加 sync

import (...) 段,如果没 sync 加:

import (
    "context"  // 已有
    "encoding/json"  // 已有
    "fmt"     // 已有
    "sync"    // 🆕 加这一行
    "time"    // 已有
    ...
)
  • Step 4: 编译验证
cd backend
go build ./...
go vet ./pkg/push/...

Expected: 编译 + vet 通过。

  • Step 5: 等待用户 commit

Task 5: 后端 moderationService 3 处 emitter

Files:

  • Modify: backend/services/moderationService/service/feedback_service.go (加 emitter)
  • Modify: backend/services/moderationService/service/report_service.go (加 emitter)
  • Modify: backend/services/moderationService/service/target_status_service.go (加 emitter)

Interfaces:

  • 消费: notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{...}) 由 main.go 注入(参考 socialService 的 notificationClient 注入模式)。

  • 产生: 业务方法不变,在状态翻转事务提交后异步 fire notificationClient.CreateNotification(类型见下)。

  • Step 1: 修改 feedback_service.go 加 emitter

找到"反馈被回复"(在 feedbackService.RepliedBy / RepliedAt / ReplyContent / Status='replied' 写入的提交方法)的事务提交后,加入:

// 事务提交后(避免回滚时通知已发出)
go func() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    starID := int64(0)
    if feedback.StarID != nil { starID = *feedback.StarID }
    dataStruct, _ := structpb.NewStruct(map[string]interface{}{
        "feedback_id":    feedback.ID,
        "category_code":  feedback.CategoryCode,
        "original_title": feedback.Title,
    })
    _, _ = s.notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{
        UserId:  feedback.UserID,
        StarId:  starID,
        Type:    "feedback_replied",
        Title:   "您的反馈已处理",
        Content: feedback.ReplyContent,  // 全文
        Data:    dataStruct,
    })
}()

并修改 feedbackService struct 加 notificationClient NotificationClientInterface 字段,NewFeedbackService 构造函数注入(参考 socialService/asset_like_service.go:34 的注入模式)。

notificationClient == nil 则跳过 emit(gRPC 不强制)。

  • Step 2: 修改 report_service.go 加 emitter

类似 Step 1,但 type=report_resolved,Content = reports.ResolutionNote (可能 nil,空时用 "已处理")。data 字段 {report_id, resolved_action, target_type, target_id, category_code}。在 Resolved_* 字段全部非空的提交方法后 fire。

  • Step 3: 修改 target_status_service.go 加 emitter

类似 Step 1,但 type=target_reported,只在 action ∈ {warn, takedown, ban} 时发(dismiss 不发 —— 避免打扰无辜作者)。Content = target_status.reason(可能 nil,空时用分类描述)。data 字段 {report_id, target_type, target_id, action: 'warn|takedown|ban', reason_summary}

收件人 user_id 是目标对象的所有者(不是举报人),需要查 assets / feedbacks 的 owner_user_id。

  • Step 4: main.go 注入 notificationClient

backend/services/moderationService/main.go(具体看仓库结构,可能有 cmd/moderationService/main.go):

// 在已有的 gRPC client 初始化区,加:
// (参照 asset_like_service.go 的 NewAssetLikeService 注入风格)
notifCli := notification.NewClient(notifConn)  // grpc-gateway 连接复用
feedbackSvc := feedback.NewFeedbackService(..., notifCli)
reportSvc   := report.NewReportService(..., notifCli)
targetSvc   := targetstatus.NewTargetStatusService(..., notifCli)

具体 NotificationClientInterfaceclient/notification_client.go 已定义,直接复用。

  • Step 5: 编译验证
cd backend
go build ./...

Expected: 编译通过。

  • Step 6: 等待用户 commit

Task 6: 后端 activityService 2 处 emitter (V1.2.2 自审确认新建)

Files:

  • Modify: backend/services/activityService/service/activity_message_service.go (加 emitter)
  • Modify: backend/services/activityService/service/activity_service.go (加 emitter)

Interfaces:

  • 同 Task 5, type=activity,title 来自 activities.title,content 是留言内容 / 活动摘要。

  • 触发点 1: 别人在你的活动留言 → 给活动创建者发。

  • 触发点 2: 活动开始 / 结束 → 给"我参与了该活动(购买过 / published_item_count>0)"的用户发。

  • Step 1: 修改 activity_message_service.go

在"留言被创建"事务提交后 fire emitter:

go func() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    dataStruct, _ := structpb.NewStruct(map[string]interface{}{
        "activity_id": msg.ActivityID,
        "message_id":  msg.ID,
    })
    _, _ = s.notificationClient.CreateNotification(ctx, &pbNotification.CreateNotificationRequest{
        UserId:  activity.OwnerUserID,  // 需查 activity
        StarId:  msg.StarID,
        Type:    "activity",
        Title:   activity.Title,
        Content: msg.Content,           // 留言内容
        Data:    dataStruct,
    })
}()
  • Step 2: 修改 activity_service.go

在"活动开始"和"活动结束"的定时器回调(或 cron 触发器)处 fire,遍历 activities 表拿到 owner_user_id,给该活动的所有"参与者"发通知。MVP 简化为只给"购买过的 user_id" 发。data {activity_id, kind: 'start|end'}

  • Step 3: main.go 注入 notificationClient
notifCli := notification.NewClient(notifConn)
msgSvc     := activitymessage.NewActivityMessageService(..., notifCli)
activitySvc := activity.NewActivityService(..., notifCli)
  • Step 4: 编译验证
cd backend
go build ./...

Expected: 编译通过。

  • Step 5: 等待用户 commit

Task 7: 后端单元测试

Files:

  • Create: backend/services/notificationService/service/notification_service_test.go(如不存在)
  • Create: backend/services/moderationService/service/moderation_service_test.go(如不存在)
  • Create: backend/services/activityService/service/activity_emitter_test.go

Interfaces:

  • 消费 Tasks 2/3/4/5/6 的所有 service + repository 接口。

  • 产生: 8 个通过的单测,作为 Task 8+ 的安全网。

  • Step 1: notification_service_test.go 4 个测试

参照 services/notificationService/service/notification_service_test.go 既有风格(test_helpers_test.go 已存在),添加:

func TestCreateNotification_FeedbackReplied_Allowed(t *testing.T) {
    svc, cleanup := setupTestService(t)
    defer cleanup()
    _, err := svc.CreateNotification(context.Background(), &notifPb.CreateNotificationRequest{
        UserId:  1001, StarId: 1, Type: "feedback_replied",
        Title: "您的反馈已处理", Content: "OK",
    })
    require.NoError(t, err)
}
// 同理 2 个: report_resolved / target_reported

func TestClearByType_All(t *testing.T) {
    svc, cleanup := setupTestService(t)
    defer cleanup()
    // seed 4 type 各 1 条 notification + stats
    seedNotifications(t, svc, 1, "activity", ...)
    seedNotifications(t, svc, 1, "feedback_replied", ...)
    // ...
    resp, err := svc.ClearByType(ctx, 1, 1, "all", time.Now().UnixMilli())
    require.NoError(t, err)
    assert.GreaterOrEqual(t, resp.Affected, int32(4))
    // verify notification rows is_deleted=TRUE
    stats, _ := svc.statsRepo.Get(ctx, 1, 1)
    assert.Equal(t, 0, stats.ActivityUnreadCount)
    // ... 其他 3 列也是 0
}

setupTestService 不存在,需先创建(用 pgxmock 或事务回滚,看既有风格)。

  • Step 2: moderation_service_test.go 3 个测试

参照 test_helpers_test.go,Mock notificationClient,验证每次状态翻转调用 1 次:

func TestFeedbackReplied_TriggersNotification(t *testing.T) {
    mockCli := &client.MockNotificationClient{}
    svc := setupModerationService(t, mockCli)

    // 触发"回复反馈"流程(走 service 方法,或 helper directly)
    svc.ReplyFeedback(ctx, feedbackID, adminID, "管理员回复内容")

    // verify mock
    require.Equal(t, 1, mockCli.CallCount)
    require.Equal(t, "feedback_replied", mockCli.LastRequest.Type)
    require.Equal(t, "管理员回复内容", mockCli.LastRequest.Content)
}
// 同理: TestReportResolved_TriggersNotification, TestTargetReported_TriggersNotification
// 后者验证 dismiss 不发 → mockCli.CallCount == 0
  • Step 3: activity_emitter_test.go 2 个测试

同结构:

func TestActivityMessage_TriggersActivityNotification(t *testing.T) {
    mockCli := &client.MockNotificationClient{}
    svc := setupActivityService(t, mockCli)

    svc.CreateMessage(ctx, activityID, userID, content)

    require.Equal(t, 1, mockCli.CallCount)
    require.Equal(t, "activity", mockCli.LastRequest.Type)
}

func TestActivityEnd_TriggersActivityNotification(t *testing.T) {
    mockCli := &client.MockNotificationClient{}
    svc := setupActivityService(t, mockCli)

    svc.EndActivity(ctx, activityID)

    require.Equal(t, 1, mockCli.CallCount)  // 至少 1,实际数 = 参与者数
}
  • Step 4: 跑测试
cd backend
go test ./services/notificationService/service/... -v -run TestCreateNotification_FeedbackReplied
go test ./services/moderationService/service/... -v -run TestFeedbackReplied_TriggersNotification
go test ./services/activityService/service/... -v -run TestActivityMessage_TriggersActivityNotification

Expected: 所有测试 PASS。

pkg/database 不能在本地起真实 DB,用事务回滚 + docker pg shim,或用 pkg/testdb (可能仓库已有)。

  • Step 5: 等待用户 commit

Task 8: 前端 utils/api.js 6 个 wrapper + 路由注册

Files:

  • Modify: frontend/utils/api.js (在 push device 注释块后加 6 个 wrapper)
  • Modify: frontend/pages.json (注册 mailbox/{index,detail} 两页)

Interfaces:

  • 后端 6 个 HTTP endpoint(见上文 Global Contract)。GET 用 ?type=&page=&page_size= 查询串,不写 data: {}

  • 全用 url 模板字符串 + encodeURIComponent 处理 type。

  • Step 1: 在 utils/api.js 加 6 个函数

参照 registerDeviceApi 块的注释风格(块状中文 + 注释规范要点,见 spec §5.4),在 push 块之后插入:

// ==================== 通知查询/操作(spec §1.3 + 信箱聚合 §5.4) ====================
// 本节 6 个 wrapper 为本期新增。前端原本只暴露了 registerDeviceApi /
// unregisterDeviceApi 两个推送设备接口;通知本身 list/unread/markRead/markAllRead/
// delete/clear 在本期补齐(详见 docs/superpowers/specs/2026-07-13-mailbox-inbox-design.md §5.4)。
//
// 鉴权由 gateway AuthMiddleware 强制 JWT,前端无需再校验。
// 响应统一: { code, message, data },data 字段才是真正的 payload。

// getNotificationsApi —— 拉一页通知(列表页 onShow 并发 4 个 type / 详情页单补拉)。
//
// 参数:
//   type   :必填,枚举:activity|feedback_replied|report_resolved|target_reported|like|system
//   tab    :可选,二级过滤(本期不传)。
//   page   :默认 1,后端兜底。
//   pageSize: 默认 20,后端上限 100。
//
// 业务流:列表页 onShow Promise.all(4 type) + 详情页 fallback 补拉 50 条找 nid。
//
// 注意:GET 必须走查询串(对齐 utils/api.js 现有 GET 约定);
     //   gateway controller 用 g.Query("type") 读 type,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 receive 事件覆盖),仅作 list onShow 兜底对齐用。
//
// 返回:Promise<{ data: { like, system, activity, total, feedback_replied, report_resolved, target_reported } }>
export function getUnreadCountApi() {
  return request({ url: '/api/v1/notifications/unread-count', method: 'GET' })
}

// markAsReadApi —— 单条标已读(列表项点击 / 详情页 [✓标已读])。
//
// 参数:id:必填,notifications.id。
//
// 后端事务:① UPDATE notifications SET is_read=TRUE WHERE is_read=FALSE(幂等)。
//
// 业务流:列表项点击后 dispatch 再 navigateTo 详情页;详情页标已读后留在原页(可能再看一眼)。
export function markAsReadApi(id) {
  return request({ url: `/api/v1/notifications/${id}/read`, method: 'POST' })
}

// markAllAsReadApi —— 按 type 全标已读(列表页顶部 [+全部已读] 并发 4 次)。
//
// type:必填,枚举同 getNotificationsApi。
//
// 后端 controller 用 g.Query("type") 提取,走查询串;data:{type} 不行会 400。
export function markAllAsReadApi(type) {
  return request({ url: `/api/v1/notifications/read-all?type=${encodeURIComponent(type)}`, method: 'POST' })
}

// deleteNotificationApi —— 单条软删(列表项长按 / 明信片页 [🗑删除])。
//
// 业务流:列表项长按 → 本地 store REMOVE_ITEM;明信片页 → 软删 + setTimeout 500ms navigateBack。
//
// 注意:后端 is_deleted=TRUE 软删,redDot 不受影响(只 decr 标已读数)。
export function deleteNotificationApi(id) {
  return request({ url: `/api/v1/notifications/${id}`, method: 'DELETE' })
}

// clearNotificationsApi —— 按 type 软删(本期仅传 'all',清 4 类)。
//
// type: 枚举 activity|feedback_replied|report_resolved|target_reported|all。
// 后端:DELETE /api/v1/notifications/clear?type=<x>(spec §4 新加接口)。
export function clearNotificationsApi(type = 'all') {
  return request({ url: `/api/v1/notifications/clear?type=${encodeURIComponent(type)}`, method: 'DELETE' })
}
  • Step 2: 注册 mailbox 两页到 pages.json

打开 frontend/pages.json,在 pages 数组末尾追加:

,
{
  "path": "pages/mailbox/index",
  "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
},
{
  "path": "pages/mailbox/detail",
  "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } }
}

注意 JSON 无尾逗号;若 pages.json 用 JSON5 / 注释,可包注释注明:"信箱聚合 (spec §5)"。

  • Step 3: 编译验证
cd frontend
# uni-app 不直接 build,看 package.json 是否有 lint / typecheck 命令
npm run lint 2>/dev/null || echo "(no lint script)"

Expected: 通过(可能有现有 lint 警告,与本次改动无关)。

  • Step 4: 等待用户 commit

Task 9: 前端 store/modules/mailbox.js

Files:

  • Create: frontend/store/modules/mailbox.js

Interfaces:

  • 消费: 无 (Task 8 前置)。

  • 产生: Vuex module 含:

    • state: itemsByType (4 槽)、unreadByType (6 key)、detailCacheisInMailboxPageloadingnoMoreByType
    • mutations: SET_ITEMS / APPEND_ITEMS / UPDATE_ITEM / REMOVE_ITEM / RESORT_ITEMS / SET_NO_MORE / DECREMENT_TYPE / REPLACE_UNREAD_BY_TYPE / MARK_ALL_READ / CLEAR_ALL / SET_DETAIL_CACHE / SET_LOADING / SET_IS_IN_PAGE / SET_TOTAL_UNREAD
    • getters: inboxUnreadOnly = sum 4 类(给 profile.vue badge 用)
    • actions: applyPushPayload({data, title, content}) (App receive 用)、alignFromServerUnread (拉 /unread-count 用)
  • Step 1: 写完整 Vuex module

import {
  getNotificationsApi, getUnreadCountApi,
  markAsReadApi, markAllAsReadApi,
  deleteNotificationApi, clearNotificationsApi,
} from '@/utils/api.js'

export const GROUPS = [
  { type: 'activity',         label: '活动通知' },
  { type: 'feedback_replied', label: '反馈回复' },
  { type: 'report_resolved',  label: '举报结果' },
  { type: 'target_reported',  label: '被举报' },
  // ⚠️ like / system 不进(V1.2.10)
]

const state = () => ({
  itemsByType: GROUPS.reduce((acc, g) => { acc[g.type] = []; return acc }, {}),
  unreadByType: GROUPS.reduce((acc, g) => { acc[g.type] = 0; return acc },
                              { like: 0, system: 0 }),  // 仍接收所有 type 用于聚合,只不进 INBOX
  totalUnread: 0,
  detailCache: null,
  isInMailboxPage: false,
  loading: false,
  noMoreByType: GROUPS.reduce((acc, g) => { acc[g.type] = true; return acc }, {}),
})

const getters = {
  // profile.vue 📬 badge 显示 (V1.2.10: 排除 like/system)
  inboxUnreadOnly: (state) => {
    return GROUPS.reduce((sum, g) => sum + (state.unreadByType[g.type] || 0), 0)
  },
}

const mutations = {
  SET_ITEMS(state, { type, items, sortUnreadFirst = true }) {
    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 }
  },
  SET_TOTAL_UNREAD(state, n) { state.totalUnread = n },
  MARK_ALL_READ(state) {
    state.unreadByType = GROUPS.reduce((acc, g) => { acc[g.type] = 0; return acc },
                                       { ...state.unreadByType })
    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] = [] })
    GROUPS.forEach(g => { state.unreadByType[g.type] = 0 })
  },
  SET_DETAIL_CACHE(state, item) { state.detailCache = item },
  SET_LOADING(state, v) { state.loading = v },
  SET_IS_IN_PAGE(state, v) { state.isInMailboxPage = v },
  PREPEND_ITEM(state, { type, notification }) {
    // V1.2.6 幂等:防重复
    const list = state.itemsByType[type] || []
    if (list.some(n => n.id === notification.id)) return
    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
  },
}

const actions = {
  // App.vue receive 事件后调用,只更新未读 + 可选 PREPEND_ITEM
  applyPushPayload({ commit, rootState }, { data, title = '', content = '' }) {
    if (!data || !data.unreads_by_type) return
    commit('REPLACE_UNREAD_BY_TYPE', data.unreads_by_type)
    commit('SET_TOTAL_UNREAD', data.total_unread || 0)
    if (rootState.mailbox.isInMailboxPage && data.notification_id && data.type) {
      commit('PREPEND_ITEM', {
        type: data.type,
        notification: {
          id: data.notification_id,
          title, content,
          data: JSON.stringify(data),
          is_read: false,
          created_at: Date.now(),
        },
      })
    }
  },
  async alignFromServerUnread({ commit }) {
    const resp = await getUnreadCountApi()
    commit('REPLACE_UNREAD_BY_TYPE', resp.data)
  },
}

export default { namespaced: true, state, getters, mutations, actions }
  • Step 2: 注册到 store/index.js
import mailbox from './modules/mailbox.js'
// 现有 modules 列表里加 mailbox
modules: {
  user: ...,
  guide: ...,
  mailbox,  // 🆕
}
  • Step 3: 编译验证
cd frontend
# HBuilderX 直接打开,uni-app 没有 node build 命令,人工打开 dev 工具验证

Expected: 启动 dev 工具不报 module 解析错误。

  • Step 4: 等待用户 commit

Task 10: 前端 pages/mailbox/components/{MailboxGroup,Postcard}.vue

Files:

  • Create: frontend/pages/mailbox/components/MailboxGroup.vue
  • Create: frontend/pages/mailbox/components/Postcard.vue

Interfaces:

  • 消费: Task 9 的 Vuex + Task 8 的 api.js。

  • 产生: MailboxGroup props {groupKey, label, collapsed, items, unreadCount}, emits {toggle, select, long-press}

  • 产生: Postcard props {notification}, emits {mark-read, delete}

  • Step 1: MailboxGroup.vue

<template>
  <view class="mailbox-group">
    <view class="group-header" @tap="onToggleHeader">
      <text class="chevron">{{ collapsed ? '▸' : '▾' }}</text>
      <text class="group-label">{{ label }}</text>
      <text v-if="unreadCount > 0" class="group-badge">{{ unreadCount }}</text>
    </view>
    <view v-if="!collapsed" class="group-list">
      <view
        v-for="n in items" :key="n.id"
        class="group-item"
        @tap="onSelectItem(n)"
        @longpress="onLongPressItem(n)"
      >
        <text class="dot" v-if="!n.is_read">●</text>
        <text class="dot dot-read" v-else>○</text>
        <text class="item-title">{{ n.title }}</text>
        <text class="item-time">{{ formatTime(n.created_at) }}</text>
      </view>
    </view>
  </view>
</template>

<script setup>
const props = defineProps({
  groupKey: String,
  label: String,
  collapsed: Boolean,
  items: { type: Array, default: () => [] },
  unreadCount: { type: Number, default: 0 },
})
const emit = defineEmits(['toggle', 'select', 'long-press'])

function onToggleHeader() { emit('toggle') }
function onSelectItem(n) { emit('select', n) }
function onLongPressItem(n) { emit('long-press', n) }
function formatTime(ms) {
  if (!ms) return ''
  const d = new Date(ms)
  const pad = n => (n < 10 ? '0' + n : '' + n)
  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>

<style lang="scss" scoped>
.mailbox-group {
  background: #fff;
  border-radius: 16rpx;
  margin-bottom: 16rpx;
  .group-header {
    display: flex; align-items: center; padding: 24rpx;
    border-bottom: 1rpx solid #f5f5f5;
    .chevron { font-size: 28rpx; color: #999; margin-right: 12rpx; width: 30rpx; }
    .group-label { font-size: 28rpx; font-weight: 600; flex: 1; }
    .group-badge {
      background: #f5222d; color: #fff; border-radius: 16rpx;
      padding: 4rpx 12rpx; font-size: 22rpx;
    }
  }
  .group-item {
    display: flex; align-items: center; padding: 20rpx 24rpx;
    border-bottom: 1rpx solid #fafafa;
    &:last-child { border-bottom: 0; }
    .dot { color: #f5222d; font-size: 24rpx; margin-right: 12rpx; width: 24rpx; }
    .dot-read { color: #ccc; }
    .item-title { font-size: 26rpx; flex: 1; }
    .item-time { font-size: 22rpx; color: #999; }
  }
}
</style>
  • Step 2: Postcard.vue
<template>
  <view class="postcard">
    <view class="postcard-header">
      <text class="postcard-title">{{ notification.title }}</text>
      <view class="postcard-meta">
        <text v-if="notification.type" class="meta-type">[{{ typeLabel }}]</text>
        <text class="meta-time">{{ formatTime(notification.created_at) }}</text>
      </view>
    </view>
    <view class="postcard-content">
      <text>{{ notification.content }}</text>
    </view>
    <view v-if="businessMeta.length" class="postcard-extra">
      <view v-for="m in businessMeta" :key="m.k" class="meta-row">
        <text class="meta-key">{{ m.k }}:</text>
        <text class="meta-val">{{ m.v }}</text>
      </view>
    </view>
    <view v-if="!notification.is_read" class="postcard-unread-tip">
      <text> 未读</text>
    </view>
  </view>
</template>

<script setup>
import { computed } from 'vue'
const props = defineProps({ notification: { type: Object, required: true } })

const typeMap = {
  feedback_replied: '反馈回复',
  report_resolved: '举报结果',
  target_reported: '被举报',
  activity: '活动通知',
}
const typeLabel = computed(() => typeMap[props.notification.type] || props.notification.type || '通知')

const businessMeta = computed(() => {
  // data 是 JSON 字符串,反序列化展示业务字段
  let data = {}
  try { data = JSON.parse(props.notification.data || '{}') } catch (e) { data = {} }
  const list = []
  if (data.feedback_id) list.push({ k: '反馈 ID', v: data.feedback_id })
  if (data.report_id)    list.push({ k: '举报 ID', v: data.report_id })
  if (data.resolved_action) list.push({ k: '处理动作', v: actionLabel(data.resolved_action) })
  if (data.category_code) list.push({ k: '分类', v: data.category_code })
  if (data.original_title) list.push({ k: '原标题', v: data.original_title })
  if (data.activity_id)  list.push({ k: '活动 ID', v: data.activity_id })
  return list
})

function actionLabel(a) {
  return { takedown: '已下架', ban: '已封禁', warn: '已警告', dismiss: '已驳回', restore: '已解除' }[a] || a
}
function formatTime(ms) {
  if (!ms) return ''
  const d = new Date(ms)
  const pad = n => (n < 10 ? '0' + n : '' + n)
  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>

<style lang="scss" scoped>
.postcard {
  background: #fff; border-radius: 16rpx; padding: 32rpx;
  .postcard-header { border-bottom: 1rpx solid #f5f5f5; padding-bottom: 16rpx; margin-bottom: 16rpx; }
  .postcard-title { font-size: 32rpx; font-weight: 600; display: block; margin-bottom: 12rpx; }
  .postcard-meta {
    display: flex; gap: 16rpx; font-size: 22rpx; color: #999;
    .meta-type { color: #5C40FF; }
  }
  .postcard-content {
    font-size: 28rpx; line-height: 1.7; color: #333; margin-bottom: 24rpx;
    white-space: pre-wrap;
  }
  .postcard-extra {
    .meta-row {
      display: flex; font-size: 24rpx; color: #666;
      padding: 8rpx 0;
      .meta-key { color: #999; margin-right: 16rpx; min-width: 140rpx; }
    }
  }
  .postcard-unread-tip {
    margin-top: 24rpx; padding-top: 16rpx; border-top: 1rpx solid #f5f5f5;
    color: #f5222d; font-size: 24rpx;
  }
}
</style>
  • Step 3: 等待用户 commit

Task 11: 前端 composables/useMailboxCenter.js

Files:

  • Create: frontend/pages/mailbox/composables/useMailboxCenter.js

Interfaces:

  • 消费: Task 9 Vuex + Task 8 api.js + Task 10 components props 接口。

  • 产生: 组合式 API hook,封装 4 type 并发拉取 + 单条操作 + 折叠状态记忆(uni.setStorage)。

  • Step 1: 写 useMailboxCenter.js

完整代码见 spec §5.7 (已在 v1.2.7 调优,含 10s 同 type 去抖已迁到 App.vue,本 hook 不重复)。

要点: 包含 GROUPS / itemsByType / unreadByType / loading / hasAny / hasMore / loadAll / onScrollLower / loadMore / onMarkRead / onMarkAllRead / onDeleteOne / onClearAll / onSelectItem / onLongPress / isCollapsed / toggleCollapse 共 18 个方法/状态。

(直接拷贝 spec §5.7 的代码块,逐字落地,无需"翻译"。)

  • Step 2: 等待用户 commit

Task 12: 前端 pages/mailbox/index.vue 列表页

Files:

  • Create: frontend/pages/mailbox/index.vue

Interfaces:

  • 消费: Task 10 MailboxGroup + Task 11 useMailboxCenter + Task 9 Vuex。

  • 产生: 全宽列表页布局,4 分组可折叠,顶部 [全部已读] [全部删除]。

  • Step 1: 复制 spec §5.5 模板

完整 <template> + <script setup> 见 spec §5.5 (163 行),逐字落地。注意 onShow 触发 loadAll;onScrollLower 调 Task 11 的 onScrollLower;onMarkAllRead / onClearAll 调 Task 11 的方法。

  • Step 2: 等待用户 commit

Task 13: 前端 pages/mailbox/detail.vue 明信片页

Files:

  • Create: frontend/pages/mailbox/detail.vue

Interfaces:

  • 消费: Task 10 Postcard + Task 11 useMailboxCenter + Task 9 Vuex。

  • 产生: 全宽明信片页,底部 [✓标已读] [🗑删除] 固定 toolbar,onLoad 接收 ?nid=&type=

  • Step 1: 复制 spec §5.6 模板

完整代码见 spec §5.6 (124 行)。注意 onLoad(options)nid/type;store 找不到时调 getNotificationsApi 单补拉;onDeleteOnesetTimeout(uni.navigateBack, 500) 让 toast 露出。

  • Step 2: 等待用户 commit

Task 14: 前端 App.vue push receive/click 监听

Files:

  • Modify: frontend/App.vue (在 <script> block 顶部加 #ifdef APP-PLUS 块)

Interfaces:

  • 消费: Task 9 mailbox Vuex module + Task 8 registerDeviceApi。

  • 产生: #ifdef APP-PLUS 块:注册 device + receive + click 监听。

  • Step 1: 加 #ifdef APP-PLUS 注册代码

打开 frontend/App.vue<script> block,在 onLaunch 顶部添加:

import { registerDeviceApi } from '@/utils/api.js'
import store from '@/store/index.js'

// ============ 推送初始化(V1.2.5/6/7/8: receive 驱动 + 10s 去抖 + envelope 解包) ============
const PUSH_DEBOUNCE_MS = 10000  // V1.2.7 调优
const recentByType = {}        // { [type]: lastDispatchMs }

export default {
  onLaunch() {
    // #ifdef APP-PLUS
    try {
      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,
          })
        },
        fail: (e) => { console.warn('[push] getPushClientId fail', e) },
      })
    } catch (e) {
      console.warn('[push] getPushClientId skip', e)
    }

    // receive 监听 (App 前台 + 后台都触发)
    try {
      plus.push.addEventListener('receive', (msg) => {
        try {
          const envelope = JSON.parse(msg.payload || '{}')
          const data = envelope.data || {}
          const t = data.type
          const now = Date.now()
          // V1.2.7 10s 同 type 去抖
          if (t && recentByType[t] && now - recentByType[t] < PUSH_DEBOUNCE_MS) return
          if (t) recentByType[t] = now
          store.dispatch('mailbox/applyPushPayload', {
            data,
            title: envelope.title || '',
            content: envelope.content || '',
          })
        } catch (e) {
          console.error('[push] receive parse fail', e)
        }
      })

      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) { console.error('[push] click parse fail', e) }
      })
    } catch (e) {
      console.warn('[push] addEventListener skip', e)
    }
    // #endif

    // ... 原有 onLaunch 逻辑保留 ...
  },
  onShow() { /* 原有 */ },
  onHide() { /* 原有 */ },
}
  • Step 2: 验证编译

在 HBuilderX / 你的 dev 工具中打开这个文件,确认无语法错误。

  • Step 3: 等待用户 commit

Task 15: 前端 profile.vue 服务与工具 section 加 📬 按钮

Files:

  • Modify: frontend/pages/profile/profile.vue (在 <!-- 服务与工具 --> section 加 📬 收件箱 entry)

Interfaces:

  • 消费: Task 9 Vuex mailbox/inboxUnreadOnly getter + Task 9 mailbox/refreshUnread

  • 产生: 完整的 📬 service-button + 红点 badge + onShow dispatch + navigateTo handler。

  • Step 1: 复制 service-button 模板

打开 frontend/pages/profile/profile.vue,定位 <!-- 服务与工具 --> section (line 162 之后),把 "反馈" 那个 view 之前(或之后,你的偏好)插入:

<!-- 📬 收件箱 (信箱聚合入口 V1.2.9) -->
<view class="service-button" @tap="goMailbox">
  <image
    class="service-icon"
    src="/static/icon/mailbox.png"
    mode="aspectFit"
  ></image>
  <text class="service-text">收件箱</text>
  <text v-if="inboxUnread > 0" class="guide-badge">{{ inboxUnread }}</text>
</view>

src="/static/icon/mailbox.png" 这个 icon,任务前置:前端设计师提供/从现成 icon 库选;若暂缺可先用 placeholder letter / mail 图标。

  • Step 2: 加 <script setup> 逻辑

<script setup> 内、computed 段加:

import { useStore } from 'vuex'
const store = useStore()
const inboxUnread = computed(() => store.getters['mailbox/inboxUnreadOnly'])

function goMailbox() {
  // V1.2.7 兜底:进入邮箱页时先对齐红点(state 已在 App onReceive 维护,这里再 GET 一次兜底)
  store.dispatch('mailbox/alignFromServerUnread').catch(() => {})
  uni.navigateTo({ url: '/pages/mailbox/index' })
}

并在 onShow 内追加:

onShow(() => {
  // 现有逻辑保留 ...
  // 🆕 邮箱未读对齐 (只 1 次,非轮询)
  store.dispatch('mailbox/alignFromServerUnread').catch(() => {})
})

v-if="userStore.token" 必要,改为:

<view v-if="userStore.token" class="service-button" @tap="goMailbox">
  <image ... src="/static/icon/mailbox.png" ...></image>
  <text class="service-text">收件箱</text>
  <text v-if="inboxUnread > 0" class="guide-badge">{{ inboxUnread }}</text>
</view>
  • Step 3: 注册 mailbox 图标资源

frontend/static/icon/mailbox.png —— 让设计师提供或从 Material/FontAwesome 取一个 envelope icon;若 uni-app tabBar 图标必须 81x81 (PNG) + 162x162 (PNG x2),这里 service-button 用 60x60 即可。

临缺方案: 用其他已有图标如 /static/icon/feedback.png 临时替,后续再换。

  • Step 4: 验证

在 dev 工具中打开 profile.vue,确认模板不报错,热加载到 App 看:

  • 服务与工具 section 出现"收件箱"按钮

  • 已登录 + 有未读时显示红点 badge

  • 点击跳到 pages/mailbox/index

  • Step 5: 等待用户 commit


Tasks 进度表

按依赖顺序执行。每个 task 完成后等待用户"提交吧"才 commit;commit 不要 AI 主动执行(CLAUDE.md)。

# 模块 估时 等什么
1 migration + proto 0.3d 提交 commit
2 notificationService 0.5d 提交 commit
3 gateway route + controller 0.3d 提交 commit
4 RateLimiter + TypeChinese 0.3d 提交 commit
5 moderationService 3 emitter 0.3d 提交 commit
6 activityService 2 emitter (V1.2.2 新增) 0.2d 提交 commit
7 单元测试 (8 用例) 0.5d 提交 commit
8 api.js 6 wrapper + pages.json 0.2d 提交 commit
9 Vuex mailbox module 0.3d 提交 commit
10 MailboxGroup + Postcard 组件 0.3d 提交 commit
11 useMailboxCenter composable 0.3d 提交 commit
12 mailbox/index.vue 0.3d 提交 commit
13 mailbox/detail.vue 0.3d 提交 commit
14 App.vue push 监听 0.3d 提交 commit
15 profile.vue 📬 入口 0.2d 提交 commit
合计 ~3.7d (5-6 工作日)

Self-Review Checklist (run before finalizing)

按 spec 对照:

  • §0 方案概述 — doc header 完整
  • §1 现状盘点 — DB / 后端 / API / 前端 全部覆盖
  • §2 设计目标与验收标准 — 12 条 Acceptance
  • §3 数据契约 — type 白名单 + stats schema + 4 个 emitter 字段契约
  • §4 后端设计 — handler/service/repo 三层 + 接口改动清单
  • §5 前端设计 — 6 个新文件 + 4 个修改文件
  • §6 响应式 — 两页路由 + 极窄屏 fallback
  • §7 错误处理 — 网络/未登录/空态/并发 + Vuex 幂等
  • §8 测试计划 — 8 case 含节流、聚合、不轮询
  • §9 文档 — Swagger + migration 命名
  • §10 目录变更 — 严格区分新增 vs 修改
  • §11 部署回滚 — 灰度 + SQL 幂等
  • §12 uniPush receive — payload 修正 + 60s 60s+10s + count 聚合
  • §13 关键决策表 — 11 行
  • §14 旧 myReports/myFeedbacks 关系 — 不动文件,不再引用
  • §15 变更日志 — 11 个版本

OK 后保存到 docs/superpowers/plans/2026-07-14-mailbox-inbox-impl.md