红点系统 PRD

1. 文档信息

  • 文档名称:红点系统 PRD
  • 所属项目:Pipipen
  • 文档路径:docs/pipipen/feature/red-dot-system-prd.md
  • 编写日期:2026-06-17
  • 相关代码仓库:
    • 后端:D:\codes\pipipen-api
    • 前端:D:\codes\pipipen-front
  • 相关模块:聊天、群聊、通知中心、用户中心、画师中心、管理后台、Work Task

2. 背景

当前系统里已经存在多处红点或未读提示,但它们是分散实现的:

  • 群聊、私聊在聊天浮窗内部已经能展示是否有新消息。
  • 通知中心能展示是否有未读通知。
  • Work Task 在用户中心、画师中心侧边栏已有部分数量提示。
  • 管理后台也有 job_count 类接口给侧边栏展示待处理数量。

当前问题是:用户必须点进具体入口后,才知道里面还有需要关注的内容。例如群聊列表里某个群有新消息,用户只有打开聊天浮窗、切到群聊列表后才看得到;Work Task 内部某个任务有待确认内容,用户也需要进入工作台或详情页后才能知道。

产品期望在更外层展示红点或红点数量,例如:

  • 最外层消息入口显示有未读或未处理内容。
  • 私聊、群聊、通知中心入口分别展示红点或数量。
  • 用户中心、画师中心、管理后台的父级菜单能显示子级汇总数量。
  • 列表页只展示当前页项目的红点数量,但父级总数仍然能正确覆盖所有分页数据。

这个需求的核心难点不是单个红点 UI,而是红点计数的统一抽象、分页列表下的性能、父级汇总、已读状态落库,以及实时更新的一致性。

3. 当前代码现状

3.1 前端现状

前端已存在以下红点相关实现:

  • components/chat/chat.vue

    • 私聊列表、群聊列表通过 item.has_new_message 显示红点。
    • 私聊 tab 通过 is_new_message_chat 判断是否展示红点。
    • 群聊 tab 通过 is_new_message_group 判断是否展示红点。
    • 通知 tab 通过 inform_has_unread 判断是否展示红点。
    • 最外层消息入口通过 is_new_message 判断是否展示红点。
    • 当前只偏向“是否有”,没有统一数量模型。
  • layouts/user-center.vue

    • 轮询 /api/profile/job_count
    • 更新 user_worktask_countartist_worktask_count
  • components/user_center/user_sider.vue

    • 用户 Work Task 菜单展示 user_worktask_count
    • 画师 Work Task 菜单展示 artist_worktask_count
  • layouts/admin-center.vue

    • 轮询 /api/admin_center/profile/job_count
    • 更新后台服务、项目、Work Task、画师资料相关数量。
  • components/admin_center/admin_sider.vue

    • 使用 Arco a-badge 展示后台待处理数量。
  • app.vue

    • 已有 red_point_count 全局 state 雏形,但当前没有成为正式红点数据源。
  • Work Task 改价相关页面与通知

    • components/chat/chat.vue 已监听 price_change.user_createprice_change.paidprice_change.canceledprice_change.rejectedprice_change.wait_payprice_change.artist_create 等通知事件。
    • components/chat/inform.vue 已根据 price change 场景跳转到对应业务页面。
    • Work Task 详情页已有改价创建、确认、拒绝、取消、支付等交互。
    • 当前改价更多是通知和详情页业务状态,还没有被纳入统一红点树。

3.2 后端现状

后端已存在红点雏形:

  • app/RedDot/RedDotNode.php
  • app/RedDot/RedDotTree.php
  • app/Services/RedDotService.php
  • app/Http/Controllers/Api/User/RedDotController.php
  • config/reddot.php
  • routes/api/userApi.php 下已有 /api/red_dots/treecountbatchreadread_dynamic

但当前红点模块还不能直接承担全局能力,原因包括:

  • 只覆盖 service request 一小部分业务。
  • config/reddot.php 中 path 使用 artist.center...,listener 中出现 home.artist_center...,命名不统一。
  • provider 契约还不完整,部分 provider 存在缺少 import 或不可达代码。
  • 当前实现以实时查询和 cache remember 为主,没有明确区分事实来源、物化计数、Redis 热缓存。
  • 还没有覆盖聊天、群聊、通知、Work Task、管理后台等主要红点源。

3.3 已读状态现状

现有业务表里已经有多类可作为红点事实来源的数据:

  • 私聊:chat_user_pivot.last_read_cursor
  • 群聊:group_user_pivot.last_read_cursor
  • 私聊总消息数:chats.message_count
  • 群聊总消息数:groups.message_count
  • Work Task 用户侧文件未读:work_task_files.user_is_read
  • Work Task 画师侧修改意见未读:work_task_file_change_requests.artist_is_read
  • 通知中心:notifications.read_at
  • Service Request 用户/画师侧:service_requests.user_is_read、service_requests.artist_is_readn- Work Task 改价待处理:work_task_price_changes的状态、发起方、确认方,以及WorktaskPageEventList` 中未关闭的改价页面事件
  • 管理后台待处理:translates.statusgroups.request_admin_join 等业务条件

这些字段应被视为红点事实来源。红点聚合表或 Redis 不应取代这些业务事实。

4. 目标

4.1 产品目标

  • 用户在最外层即可知道是否有需要关注的内容。
  • 父级菜单能够展示子级红点汇总。
  • 部分入口展示数量,部分入口只展示红点。
  • 列表页当前页项目能展示对应红点或数量。
  • 用户进入详情或完成相关处理后,红点应自动消失或数量减少。
  • 通知中心、聊天、Work Task 等高频入口的红点体验尽量实时。

4.2 技术目标

  • 建立统一红点模型,避免各页面重复造计数接口。
  • 支持静态节点、动态节点、父级聚合节点。
  • 支持分页列表的批量查询,避免一次性加载所有列表数据。
  • 支持按路径读取红点总览,适合全局导航、侧边栏、浮窗入口使用。
  • 支持按当前页实体 ID 批量读取动态红点,适合列表页使用。
  • 支持 HTTP 基线拉取 + WebSocket 增量刷新。
  • 支持 Redis 缓存,但数据库和业务事实可重建。
  • 保留现有前端接口的兼容过渡空间。

5. 非目标

  • 不要求一次性替换所有旧红点接口。
  • 不要求所有节点都展示数字;允许只展示 dot。
  • 不把业务通知、站内信、聊天消息合并成同一张“消息表”。
  • 不依赖 Redis 作为唯一事实来源。
  • 不为了红点系统重构聊天、通知、Work Task 的核心业务流程。
  • 不要求列表页一次性知道所有分页项目的逐项红点。

6. 名词定义

6.1 红点节点

一个可被前端展示或聚合的红点对象,使用稳定 path 标识。

示例:

root
message_center
chat
group
notifications
user.center.work_tasks
artist.center.work_tasks
admin.center.work_tasks

6.2 静态节点

path 固定的节点,通常用于导航、父级菜单、模块入口。

示例:

group
notifications
user.center.work_tasks

6.3 动态节点

path 中包含业务实体 ID 的节点,通常用于列表页单项。

示例:

chat.123
group.456
user.work_task.1001
artist.work_task.1001

6.4 聚合节点

计数来自子节点汇总或 provider 聚合查询的节点。

示例:

message_center = chat + group + notifications
user.center = user.center.work_tasks + user.center.applications

6.4.1 前端展示 slot

前端展示 slot 是 UI 位置标识,不等同于后端红点 path。

示例:

userCenter.entry
userCenter.menu.workTasks
userCenter.workTaskStatus.working
userCenter.workTaskList.item.{id}
userCenter.workTaskDetail.priceChange
layout.notificationBell

前端可以把一个 slot 绑定到一个或多个后端业务 path,但不能在前端重新计算业务红点事实。

6.4.2 待处理实体数

Work Task 等列表型业务的父级数量,默认表示“有待处理事项的业务实体数量”,不是子事件数量总和。

示例:

work_task.1001 同时有 5 个 price_change / files 子红点
user.work_task.status.working 的计数仍为 1

列表项或详情页可以展示该任务内部的子事件数量,例如 user.work_task.1001.price_change = 5

6.5 事实来源

业务表中真实表达“是否已读、是否待处理”的字段或条件。

示例:

notifications.read_at is null
work_task_files.user_is_read = false
group_user_pivot.last_read_cursor < groups.last_message_id

6.6 物化计数

为了提升读取性能,把事实来源计算结果写入专门的红点计数表。物化计数必须可以从事实来源重新计算。

7. 用户故事

7.1 最外层消息入口

作为登录用户,我希望在页面右下角聊天按钮上看到红点,这样不用打开聊天窗口也知道有新的聊天、群聊或通知。

验收:

  • 私聊有未读时,聊天按钮显示红点。
  • 群聊有未读时,聊天按钮显示红点。
  • 通知中心有未读时,聊天按钮显示红点。
  • 三者都清空后,聊天按钮红点消失。

7.2 聊天浮窗内部入口

作为登录用户,我希望打开聊天浮窗后,私聊、群聊、通知三个入口分别展示红点或数量,这样能直接知道应该点哪里。

验收:

  • 私聊有未读时,私聊 tab 展示红点或数量。
  • 群聊有未读时,群聊 tab 展示红点或数量。
  • 通知有未读时,通知 tab 展示红点或数量。
  • 当前实现可先展示 dot,后续支持数字。

7.3 群聊列表分页

作为用户,我可能加入很多群聊。系统不应为了展示群聊总红点而加载所有群聊列表。

验收:

  • 外层群聊总数由后端聚合接口返回。
  • 群聊列表只对当前页群聊展示逐项红点或数量。
  • 滚动加载下一页时,再批量请求下一页项目红点。
  • 不允许前端通过遍历所有分页数据来计算父级总数。

7.4 Work Task 外层菜单

作为用户或画师,我希望在用户中心或画师中心侧边栏看到 Work Task 待处理总数。

验收:

  • 用户侧 Work Task 菜单展示用户未读稿件数量。
  • 画师侧 Work Task 菜单展示画师未读修改意见数量。
  • 进入对应详情且系统标记已读后,菜单数量减少。

7.5 Work Task 列表项

作为用户或画师,我希望 Work Task 列表里的每个任务也能展示是否有待关注内容。

验收:

  • 用户 Work Task 列表当前页每个任务展示 unread_files_count
  • 画师 Work Task 列表当前页每个任务展示 unread_change_requests_count
  • 列表当前页红点数据可以由列表接口直接返回,也可以由 red dot batch 接口返回。

7.6 通知中心

作为用户,我希望通知中心入口显示未读通知状态,并在阅读后自动减少或消失。

验收:

  • 未读通知数大于 0 时,通知入口展示红点。
  • 通知列表中通知被标记已读后,未读数量减少。
  • WebSocket 新通知到达时,入口可即时出现红点。

7.7 Service 待申请链路

作为画师,我希望用户向我的 service 发起 service request 后,在画室中心、我的 service 列表、具体 service 的待申请列表中都能看到红点引导。

验收:

  • 有新的 service request 时,画室中心入口展示红点。
  • 进入画室中心后,自己的 service 或 service 管理入口展示红点或数量。
  • service 列表中,对应 service 行展示红点或数量。
  • 点击 service 后,待申请列表里的对应 service request 展示红点。
  • 父级数量统计有红点的直接子实体数量,不递归统计所有 service request 的子红点总和。

8. 红点展示规则

8.1 展示类型

每个节点支持以下展示类型:

dot      只展示红点
count    展示数字
none     不展示,仅参与父级聚合

示例:

  • 消息最外层入口:dot
  • 群聊 tab:可先 dot,后续升级 count
  • Work Task 菜单:count
  • 通知中心入口:可配置为 dotcount

8.2 数量上限

前端展示数量时默认使用上限 99。

规则:

  • count = 0:不展示。
  • 1 <= count <= 99:展示真实数字。
  • count > 99:展示 99+

接口返回真实 count,前端负责显示上限;也允许接口返回 display_count

8.3 父级聚合规则

父级节点不能默认递归累加所有子级 count。每个聚合节点都必须定义自己的 count_metric,前端只能按接口返回结果展示,不能自行沿 UI 树递归求和。

推荐 count_metric

dot_only             只返回 has_dot,不提供数量
direct_entity_count  统计有红点的直接业务实体数量
direct_event_count   统计当前实体下一层直接事件数量
explicit_sum         只对少量、稳定、明确允许求和的静态子节点求和
provider_count       由 provider 自定义业务口径

通用规则:

  • has_dot 表示当前节点或其直接业务范围内是否存在需要关注的事项。
  • count 只表达当前节点定义的 metric,不表达递归子树总和。
  • 高层入口可以只展示 dot,避免为了一个不关键的数字付出高成本。
  • 只有节点配置明确允许 explicit_sum 时,才可以把子节点数量相加。

示例:

user.work_task.1001.price_change = 3
user.work_task.1001.files = 2
user.work_task.1001 = 5                         // 当前任务下直接子事件数量
user.work_task.status.working = 1               // 有待处理的 working Work Task 数量
user.work_task.total = 1                        // 有待处理的 Work Task 数量

Service Request 示例:

artist.service_request.9001 = dot
artist.service_request.9002 = dot
artist.service.501.requests = 2                 // service 501 下有 2 个待关注 request
artist.service.total = 1                        // 有待关注 request 的 service 数量
artist.center.total = dot                       // 画室中心只提示有事项,不强制展示数量

这样用户看到“工作中 1”或“Service 1”时,含义是有 1 个直接业务实体需要关注;进入实体后,再看到该实体内部的具体子红点。

8.4 Dot-only 节点规则

考虑性能和体验,部分节点应限制为只展示红点,不展示数量。

适合 dot_only 的节点:

  • 顶层入口,例如 rootmessage_centeruser.center.totalartist.center.total
  • 多业务混合入口,数字含义容易混淆的节点。
  • 需要跨大量动态实体实时精确计数的节点。
  • 只需要提醒用户“有事要看”,不需要告诉用户“有多少”的节点。

适合 count 的节点:

  • 数字含义清晰且对用户决策有帮助的节点。
  • 能通过索引、摘要表或低成本 provider 精确返回的节点。
  • 状态 tab 或列表型父级,例如 user.work_task.status.workingartist.service.total

接口约定:

{
  "path": "artist.center.total",
  "display": "dot",
  "count_metric": "dot_only",
  "count": null,
  "has_dot": true
}

如果 display = dot,前端不得为了显示数字额外请求子级并自行求和。

9. 红点树设计

9.1 建议 path 命名规范

统一使用小写英文、点分层级、动态实体 ID 放在末尾。

root
message_center
chat
chat.*
group
group.*
notifications
user.center
user.center.work_tasks
user.work_task.*
artist.center
artist.center.service_requests
artist.center.work_tasks
artist.work_task.*
admin.center
admin.center.services
admin.center.projects
admin.center.work_tasks
admin.center.artist_info

不要混用以下风格:

home.artist_center.service.request.unread
artist.center.service.requests

需要在重构阶段统一命名,否则缓存失效和前端读取会出错。

9.2 推荐红点树配置示例

return [
    'cache_ttl' => 300,
    'cache_prefix' => 'reddot:',

    'tree' => [
        'root' => [
            'type' => 'aggregate',
            'children' => ['message_center', 'user.center', 'artist.center', 'admin.center'],
            'display' => 'dot',
        ],

        'message_center' => [
            'type' => 'aggregate',
            'children' => ['chat', 'group', 'notifications'],
            'display' => 'dot',
        ],

        'chat' => [
            'type' => 'aggregate_provider',
            'provider' => PrivateChatRedDotProvider::class,
            'display' => 'count',
        ],

        'chat.*' => [
            'type' => 'dynamic_provider',
            'provider' => PrivateChatItemRedDotProvider::class,
            'display' => 'count',
        ],

        'group' => [
            'type' => 'aggregate_provider',
            'provider' => GroupChatRedDotProvider::class,
            'display' => 'count',
        ],

        'group.*' => [
            'type' => 'dynamic_provider',
            'provider' => GroupChatItemRedDotProvider::class,
            'display' => 'count',
        ],

        'notifications' => [
            'type' => 'aggregate_provider',
            'provider' => NotificationRedDotProvider::class,
            'display' => 'dot',
        ],

        'user.center.work_tasks' => [
            'type' => 'aggregate_provider',
            'provider' => UserWorkTaskRedDotProvider::class,
            'display' => 'count',
        ],

        'user.work_task.*' => [
            'type' => 'dynamic_provider',
            'provider' => UserWorkTaskItemRedDotProvider::class,
            'display' => 'count',
        ],

        'artist.center.work_tasks' => [
            'type' => 'aggregate_provider',
            'provider' => ArtistWorkTaskRedDotProvider::class,
            'display' => 'count',
        ],

        'artist.work_task.*' => [
            'type' => 'dynamic_provider',
            'provider' => ArtistWorkTaskItemRedDotProvider::class,
            'display' => 'count',
        ],
    ],
];

9.3 后端 path 与前端 UI 解耦

后端 path 应表达稳定的业务语义,前端 UI slot 表达视觉位置。不要让后端 path 与当前菜单、侧边栏、详情页布局强绑定。

推荐分层:

后端业务 path:
user.work_task.total
user.work_task.status.working
user.work_task.1001
user.work_task.1001.price_change
notifications.unread
group.456

前端展示 slot:
userCenter.entry
userCenter.menu.workTasks
userCenter.workTaskStatus.working
userCenter.workTaskList.item.1001
userCenter.workTaskDetail.priceChange
layout.notificationBell

前端维护薄映射层:

const redDotBindings = {
  'userCenter.entry': ['user.center.total'],
  'userCenter.menu.workTasks': ['user.work_task.total'],
  'userCenter.workTaskStatus.working': ['user.work_task.status.working'],
  'userCenter.workTaskList.item': (id: number) => [`user.work_task.${id}`],
  'userCenter.workTaskDetail.priceChange': (id: number) => [`user.work_task.${id}.price_change`],
  'layout.notificationBell': ['notifications.unread'],
};

约束:

  • 前端可以做展示映射,但不重新维护一套业务红点树。
  • 后端 path 不写 sidebartabpagedetail 等纯 UI 词。
  • 现有 user.center.work_tasksartist.center.work_tasks 可以作为兼容 alias,后续新增节点优先使用 user.work_task.totalartist.work_task.total 这类业务 path。

9.4 动态节点命名边界

动态节点不携带当前用户 ID。用户身份来自登录态和数据库 owner_type / owner_id

不推荐:

user.233.work_task.1001
user.233.work_task.1001.price_change

推荐:

user.work_task.1001
user.work_task.1001.price_change
artist.work_task.1001
artist.work_task.1001.change_requests
group.456
chat.123

数据库中记录归属:

owner_type = user
owner_id = 233
path = user.work_task.1001.price_change

也不推荐把完整业务关系链塞进 path:

{role}.{module}.{entity_id}.{sub_type}.{module}.{entity_id}

如果需要精确到某条子业务记录,可以二选一:

user.work_task.1001.price_change      // 详情页只关心该任务是否有改价待处理
user.price_change.555                 // 需要精确到某一条改价记录

更复杂的归属关系放在数据库字段:

biz_type = work_task_price_change
biz_id = 555
related_type = work_task
related_id = 1001
parent_path = user.work_task.1001.price_change
root_path = user.work_task.total

9.5 Work Task 状态维度节点

Work Task 的“工作中 / 已完成 / 已取消”是业务筛选维度,不应放进具体动态节点路径中。

不推荐:

user.work_task.working.1001.price_change
user.work_task.completed.1001.price_change

推荐:

user.work_task.total
user.work_task.status.working
user.work_task.status.completed
user.work_task.status.canceled
user.work_task.1001
user.work_task.1001.price_change

原因:Work Task 状态会变化。如果状态写进实体 path,状态变更会导致红点 path 迁移,容易产生脏数据和缓存不一致。

9.6 总体架构图

红点系统按“展示位置”和“业务语义”解耦。前端只绑定 UI slot,后端负责业务 path 的计数口径、权限和数据来源。

9.7 前端 Slot 与后端 Path 映射图

10. 后端接口设计

10.1 获取红点快照

接口:

POST /api/red_dots/snapshot

用途:

  • 首页或全局布局获取多个静态节点。
  • 列表页同时获取当前页动态节点。
  • 替代多个分散的 job_counthas_unread 类接口。

请求:

{
  "paths": [
    "message_center",
    "chat",
    "group",
    "notifications",
    "user.center.work_tasks",
    "artist.center.work_tasks"
  ],
  "dynamic": [
    {
      "pattern": "group.*",
      "entity_ids": [12, 15, 18]
    },
    {
      "pattern": "user.work_task.*",
      "entity_ids": [101, 102]
    }
  ]
}

响应:

{
  "code": 0,
  "data": {
    "nodes": {
      "message_center": {
        "path": "message_center",
        "count": 8,
        "has_dot": true,
        "display": "dot",
        "updated_at": "2026-06-17T10:00:00+08:00"
      },
      "group": {
        "path": "group",
        "count": 5,
        "has_dot": true,
        "display": "count"
      },
      "notifications": {
        "path": "notifications",
        "count": 3,
        "has_dot": true,
        "display": "dot"
      }
    },
    "dynamic": {
      "group.12": {
        "path": "group.12",
        "entity_id": 12,
        "count": 2,
        "has_dot": true,
        "display": "count"
      },
      "group.15": {
        "path": "group.15",
        "entity_id": 15,
        "count": 0,
        "has_dot": false,
        "display": "count"
      }
    }
  }
}

10.2 获取单个节点

保留现有能力:

POST /api/red_dots/count

请求:

{
  "path": "user.center.work_tasks"
}

响应:

{
  "code": 0,
  "data": {
    "path": "user.center.work_tasks",
    "count": 6,
    "has_dot": true,
    "display": "count"
  }
}

10.3 批量获取动态节点

保留并规范现有 batch 能力:

POST /api/red_dots/batch

请求:

{
  "pattern": "artist.work_task.*",
  "entity_ids": [1001, 1002, 1003]
}

响应:

{
  "code": 0,
  "data": {
    "artist.work_task.1001": {
      "path": "artist.work_task.1001",
      "entity_id": 1001,
      "count": 3,
      "has_dot": true
    },
    "artist.work_task.1002": {
      "path": "artist.work_task.1002",
      "entity_id": 1002,
      "count": 0,
      "has_dot": false
    }
  }
}

约束:

  • entity_ids 单次最多 100 个。
  • provider 必须优先实现 getCountBatch()
  • 没有 batch provider 时可以降级逐个查,但需要日志告警。

10.4 标记已读

接口:

POST /api/red_dots/read

适用于静态叶子节点或 provider 能处理的聚合节点。

请求:

{
  "path": "notifications",
  "ids": ["uuid-1", "uuid-2"]
}

响应:

{
  "code": 0,
  "message": "success"
}

10.5 标记动态节点已读

接口:

POST /api/red_dots/read_dynamic

请求:

{
  "pattern": "group.*",
  "entity_id": 12,
  "cursor": 3456
}

说明:

  • 聊天类已读建议使用 cursor 或 seq,而不是 ids。
  • Work Task 类可以使用 entity ID 直接标记当前任务下的未读项。

10.6 业务列表按待处理筛选

列表型业务需要支持按红点/待处理筛选,避免用户在大量分页中寻找红点项。

示例:

GET /api/user/work_tasks?status=working&attention=1&page=1

响应建议:

{
  "code": 0,
  "data": {
    "total": 5,
    "data": [
      {
        "id": 1001,
        "status": "working",
        "has_red_dot": true,
        "red_dot_count": 5,
        "red_dot_types": ["price_change", "files"]
      }
    ]
  }
}

普通列表仍然支持当前页红点字段:

GET /api/user/work_tasks?status=working&page=1

约束:

  • attention=1 表示只看有待处理事项的业务实体。
  • total 表示满足筛选条件的实体数量,不是子红点总和。
  • 普通列表不需要强制把红点项排序到最前。
  • 不在分页页码上展示红点。

10.7 读取与筛选流程图

全局入口和父级菜单先读取 snapshot;列表页继续优先使用领域列表接口直返字段;跨分页定位通过 attention=1 筛选完成。

11. 数据库与缓存设计

11.1 收敛后的原则

红点系统不在第一阶段追求“所有节点都落库、所有父级都物化、所有变化都实时推送”。长期模型保持完整,但落地顺序要收敛。

推荐分层:

业务事实来源 -> RedDot provider -> 实体摘要索引(仅列表型高价值场景) -> 可选缓存

要求:

  • 业务表仍是最终事实来源。
  • 低成本节点直接由 provider 实时计算。
  • Work Task、Service 这类需要跨分页定位的列表型业务,才优先引入实体摘要索引。
  • 通用 red_dot_counters 和 Redis 热缓存属于后置优化,不是第一阶段必需项。
  • 所有物化数据必须能由业务事实来源重建。

11.2 第一阶段不做全量 red_dot_counters

不建议第一阶段就为所有 path 建 red_dot_counters

原因:

  • 很多节点只需要 dot,不需要 count。
  • 很多节点可以通过已有索引低成本 existscount
  • 过早维护所有父级 counter,会把复杂度转移到所有写路径的增减和失效上。
  • 业务口径还在收敛时,全量物化容易产生漂移和返工。

red_dot_counters 适合后续用于:

  • 读频极高、实时计算成本明确偏高的节点。
  • 业务口径稳定、写入事件清晰的节点。
  • 需要对账、重建、版本控制的高价值节点。

11.3 实体红点摘要表

为了支持 Work Task、Service 这类列表页的“待处理筛选”和状态维度聚合,第一阶段更应该优先考虑实体级摘要,而不是全量 path counter。

建议表名:red_dot_entity_summaries

字段:

id bigint primary key
owner_type string            // user / artist / admin
owner_id bigint
role string null             // user / artist,可选
module string                // work_task / service 等
entity_type string           // work_task / service
entity_id bigint
status string null           // working / completed / canceled / active 等业务筛选维度
has_attention boolean
attention_count int default 0 // 该实体内部子红点数量
attention_types json null     // price_change / files / requests 等
last_attention_at timestamp null
created_at timestamp
updated_at timestamp

唯一索引:

unique(owner_type, owner_id, module, entity_type, entity_id)

常用索引:

index(owner_type, owner_id, module, status, has_attention, last_attention_at)
index(owner_type, owner_id, module, has_attention, last_attention_at)
index(owner_type, owner_id, entity_type, entity_id)

使用方式:

-- 工作中 tab 的数字:有待处理的 Work Task 数量
where owner_id = ?
  and module = 'work_task'
  and status = 'working'
  and has_attention = true
count(*)

-- 工作中待处理列表
where owner_id = ?
  and module = 'work_task'
  and status = 'working'
  and has_attention = true
order by last_attention_at desc
paginate

-- 画师 service 入口:有待关注 request 的 service 数量
where owner_id = ?
  and module = 'service'
  and has_attention = true
count(*)

注意:

  • 父级和状态 tab 展示的是实体数量,即 count(*)
  • 列表项或详情页可以展示 attention_count,表示该实体内部有多少子红点。
  • 该表不是所有红点的事实来源,只是列表型业务的查询索引。
  • 该表必须可由 work_task_fileswork_task_file_change_requestsWorktaskPageEventListservice_requests 等业务事实重建。

11.4 缓存策略收敛

第一阶段不强依赖 Redis。

推荐顺序:

  1. 先用 provider + 数据库索引保证正确性。
  2. 对热点 snapshot 节点加 15 到 60 秒短 TTL。
  3. 写路径稳定后,再补精确 cache forget。
  4. 读写规模明显增长后,再迁移到 Redis 或引入 red_dot_counters

缓存 key 示例:

reddot:{owner_type}:{owner_id}:{path}

约束:

  • 缓存只是加速层,不作为事实来源。
  • 如果无法确保所有写路径都正确失效,则宁可使用短 TTL,不做长缓存。
  • dot-only 节点可以优先缓存 has_dot,不缓存 count。

11.5 通用 red_dot_counters 后置

如果后续需要通用物化计数表,可以新增 red_dot_counters

字段建议:

id bigint primary key
owner_type string
owner_id bigint
path string
entity_type string null
entity_id bigint null
count int default 0
version bigint default 0
calculated_at timestamp null
created_at timestamp
updated_at timestamp

唯一索引:

unique(owner_type, owner_id, path)

读取优先级:

Redis -> red_dot_counters -> provider 实时计算

但这属于第三阶段之后的性能优化,不作为第一阶段交付条件。

11.6 数据关系图

第一阶段重点不是把所有 path 都存成 counter,而是保留业务事实来源,并为 Work Task、Service 这类列表型业务维护实体摘要索引。

11.7 数据更新流转图

12. 各业务计数策略

12.1 私聊

当前事实来源:

chat_user_pivot.last_read_cursor
chat_messages.id
chats.message_count

当前问题:

  • last_read_cursor 是消息 ID。
  • 计算未读数量如果使用 chat_messages where id > cursor count(*),在大消息量下成本较高。
  • 现在前端多用 has_new_message,不直接显示未读数量。

推荐升级:

  • chat_user_pivot 增加 last_read_seq
  • 私聊消息已有 seq_idchats.message_count
  • 未读数量使用:
unread_count = chats.message_count - chat_user_pivot.last_read_seq

创建消息时:

  • chats.message_count += 1
  • 新消息 seq_id = chats.message_count
  • 发送者 last_read_seq = seq_id
  • 接收者不更新 last_read_seq

读消息时:

  • 进入聊天或滚动到底部后,更新当前用户 last_read_seq
  • 同步保留 last_read_cursor,避免旧逻辑断裂。

静态节点:

chat = 当前用户所有可见私聊 unread_count 汇总

动态节点:

chat.{chat_id} = 单个私聊 unread_count

12.2 群聊

当前事实来源:

group_user_pivot.last_read_cursor
group_messages.id
groups.message_count

推荐升级:

  • group_user_pivot 增加 last_read_seq
  • 群消息已有 seq_idgroups.message_count
  • 未读数量使用:
unread_count = groups.message_count - group_user_pivot.last_read_seq

静态节点:

group = 当前用户所有群聊 unread_count 汇总

动态节点:

group.{group_id} = 单个群聊 unread_count

列表页策略:

  • 群聊列表接口可以直接返回 unread_count
  • 或由 red dot batch 接口按当前页 group IDs 批量返回。
  • 父级 group 不能依赖前端已加载的 group_list 推导。

12.3 通知中心

事实来源:

notifications.notifiable_type
notifications.notifiable_id
notifications.read_at

计数:

  • 普通用户通知:notifiable_type = user + 当前 user id。
  • 画师通知:当前用户是 active artist 时,额外包含 notifiable_type = artist + artist id。
  • 未读条件:read_at is null

静态节点:

notifications = 当前用户未读通知总数

已读:

  • 现有 /api/notifications/mark_as_read 保留。
  • 标记后需要失效 notificationschatroot 等相关缓存。

12.4 用户侧 Work Task

事实来源:

work_task_files.user_id = 当前用户
work_task_files.user_is_read = false

静态节点:

user.center.work_tasks = 当前用户所有未读 work_task_files 数量

动态节点:

user.work_task.{work_task_id} = 当前 work_task 下 user_is_read=false 的 work_task_files 数量

当前代码:

  • app/Http/Controllers/Api/User/ProfileController.php 已有 userUnreadFiles()
  • app/Http/Controllers/Api/User/WorkTaskController.php 列表已返回 unread_files_count
  • info() 中会把该 work task 下 workTaskFiles.user_is_read 标记为 true。

要求:

  • 将这些现有查询封装为 red dot provider。
  • 标记已读后失效 user.center.work_tasks 和对应 user.work_task.{id}

12.5 画师侧 Work Task

事实来源:

work_task_file_change_requests.artist_is_read = false

静态节点:

artist.center.work_tasks = 当前画师所有未读 change requests 数量

动态节点:

artist.work_task.{work_task_id} = 当前 work_task 下未读 change requests 数量

当前代码:

  • app/Http/Controllers/Api/User/ProfileController.php 已有 unreadChangeRequestsCount()
  • app/Http/Controllers/Api/Artist/WorkTaskController.php 列表已返回 unread_change_requests_count
  • app/Http/Controllers/Api/Artist/WorkTaskFileChangeRequestController.php 列表会把当前 file 下的 change request 标记为已读。

要求:

  • 将现有统计封装为 red dot provider。
  • 标记已读后失效 artist.center.work_tasks 和对应 artist.work_task.{id}

12.6 Work Task 改价

改价应纳入 Work Task 红点体系,但不应作为独立顶层模块。

事实来源:

work_task_price_changes.status
work_task_price_changes.initiator_type
work_task_price_changes.approver_type
WorktaskPageEventList 中未关闭的 price_change 页面事件

推荐口径:

  • work_task_price_changes 表达改价业务状态。
  • WorktaskPageEventList 表达页面上是否仍有需要当前角色关注的事件。
  • 红点 provider 优先以未关闭的 WorktaskPageEventList 作为待处理事实来源,必要时结合 work_task_price_changes 校验状态。

节点:

user.work_task.1001.price_change
artist.work_task.1001.price_change

聚合:

user.work_task.1001 = files + price_change + other attention items
user.work_task.status.working = 有待处理事项的 working Work Task 数量
user.work_task.total = 有待处理事项的 Work Task 数量

清除规则:

  • 改价不是普通“已读”消息,不建议简单点击后清红点。
  • 红点应在业务动作完成后消失,例如确认、拒绝、取消、支付完成,或对应 page event 被 closePriceChange() 关闭。
  • 如果后续需要“已查看但未处理”的状态,应新增独立字段,不要复用业务关闭状态。

与通知中心的关系:

  • 通知中心未读表示用户是否读过通知。
  • Work Task 改价红点表示该任务是否还有待处理动作。
  • 两者可以同时存在,但父级计数不能重复相加。

12.6.1 Work Task 红点流转图

Work Task 的父级数量统计有待处理的任务数,详情内再展示文件、修改意见、改价等子红点。

12.7 Service Request

Service Request 红点需要覆盖“画室中心 -> 自己的 service -> 对应 service request”的引导链路。

事实来源:

service_requests.artist_is_read = false
service_requests.user_is_read = false
service_requests.status = pending / waiting / accepted / rejected 等业务状态

第一阶段可以沿用 artist_is_read = falseuser_is_read = false 作为“新申请/新反馈”的红点事实。后续如果产品希望“已查看但仍待处理”继续保留红点,需要增加 pending action 口径,不能只依赖 read 字段。

画师侧推荐节点:

artist.center.total
artist.service.total
artist.service.{service_id}.requests
artist.service_request.{service_request_id}

用户侧推荐节点:

user.center.service_requests
user.service_request.{service_request_id}

前端 UI 绑定:

artistCenter.entry                   -> artist.center.total
artistCenter.menu.services            -> artist.service.total
artistCenter.serviceList.item.501     -> artist.service.501.requests
artistCenter.serviceRequest.item.9001 -> artist.service_request.9001

计数口径:

  • artist.center.total:建议 dot-only,只提示画室中心有需要关注的事项。
  • artist.service.total:统计有待关注 service request 的 service 数量,即 count(distinct service_id)
  • artist.service.{service_id}.requests:统计该 service 下待关注的 service request 数量。
  • artist.service_request.{service_request_id}:具体 request 行展示 dot,通常不需要 count。

示例:

service 501 下有 2 个新 request
service 502 下有 3 个新 request

artist.service.total = 2
artist.service.501.requests = 2
artist.service.502.requests = 3
artist.center.total = dot

关键约束:

  • artist.service.total 不等于所有 service request 数量总和 5,而是有红点的 service 数量 2。
  • service 列表只对当前页 service 批量查询 artist.service.{id}.requests
  • 画室中心入口不需要为了展示数字扫描所有 service 和 service request。
  • 如果 service 列表分页很多,不在分页页码上展示红点,使用 attention=1 筛选直接展示有待关注 request 的 service。

查询策略:

-- 画师 service 入口数量:有待关注 request 的 service 数量
select count(distinct service_id)
from service_requests
where artist_id = :artist_id
  and artist_is_read = false

-- service 列表当前页 item 红点
select service_id, count(*) as attention_count
from service_requests
where artist_id = :artist_id
  and artist_is_read = false
  and service_id in (:service_ids)
group by service_id

当前已有 provider 雏形,需要修正:

  • path 命名统一。
  • UserServiceRequestLeafProvider 需要补 DB import。
  • 删除不可达代码。
  • notDeletedByUser() / notDeletedByArtist() 使用场景要校正。

12.7.1 Service Request 引导链路图

12.8 管理后台

当前事实来源:

  • 待人工翻译:translates.status = pending + translate_type = human,按 busable_type 分组。
  • 待加入群聊处理:groups.request_admin_joinwhereHas('requestAdminJoin')

静态节点:

admin.center.services
admin.center.projects
admin.center.artist_info
admin.center.work_tasks

说明:

  • 管理后台第一阶段可以继续由 /api/admin_center/profile/job_count 支撑。
  • 后续可以纳入 red_dots/snapshot,减少前端分散轮询。

13. Provider 契约

13.1 静态聚合 provider

interface RedDotAggregateProvider
{
    public function getCount(User $viewer, array $context = []): int;

    public function markAsRead(User $viewer, ?array $ids = null, array $context = []): bool;
}

13.2 动态 provider

interface RedDotDynamicProvider
{
    public function getCount(User $viewer, int|string $entityId, array $context = []): int;

    public function getCountBatch(User $viewer, array $entityIds, array $context = []): array;

    public function markAsRead(User $viewer, int|string $entityId, ?array $ids = null, array $context = []): bool;
}

getCountBatch() 返回格式:

[
    1001 => 3,
    1002 => 0,
]

要求:

  • 动态 provider 必须实现 batch 查询。
  • batch 查询必须控制权限,只返回当前 viewer 可访问的实体。
  • 未授权实体返回 0 或直接省略,不能泄露存在性。

14. 缓存策略

14.1 Redis key

reddot:{user_id}:{path}

示例:

reddot:23:message_center
reddot:23:chat
reddot:23:group
reddot:23:group.456
reddot:23:user.center.work_tasks

14.2 TTL

建议:

  • 高频节点:60 到 300 秒。
  • 低频节点:300 到 900 秒。
  • 第一阶段可统一 300 秒。

14.3 失效规则

当叶子节点变化时,需要失效:

  • 当前节点。
  • 父级节点。
  • 父级的父级,直到 root。

例如 group.456 新增未读:

forget group.456
forget group
forget message_center
forget root

Work Task 用户侧 user.work_task.1001 已读:

forget user.work_task.1001
forget user.center.work_tasks
forget user.center
forget root

14.4 增量更新与强制重算

两种策略:

  • 简单策略:事件发生后只失效缓存,下次读取实时重算。
  • 性能策略:事件发生后同步更新 red_dot_counters,并失效 Redis。

推荐落地顺序:

  1. 第一阶段使用简单策略。
  2. 高频节点稳定后引入物化计数。
  3. 物化计数仍保留重算兜底。

15. 实时更新策略

15.1 HTTP 基线

页面初始化或登录后,前端调用:

POST /api/red_dots/snapshot

获取当前用户红点基线。

15.2 WebSocket 增量

现有前端已监听 user_events.{id} 通道,并处理:

  • ChatUpdated
  • ChatMessageCreated
  • GroupCreated
  • GroupUpdated
  • GroupMessageCreated
  • SystemNotificationEvent

推荐新增通用事件:

RedDotUpdated

事件 payload:

{
  "paths": ["message_center", "group", "group.456"],
  "reason": "group_message.created",
  "mode": "refresh"
}

第一阶段可以不直接推 count,只通知前端刷新指定 path。

第二阶段可以推 count:

{
  "nodes": {
    "group": { "count": 5, "has_dot": true },
    "message_center": { "count": 8, "has_dot": true }
  }
}

15.3 断线恢复

当前前端 websocket 断线后会恢复轮询或重新 init。红点系统需要:

  • WebSocket 连接成功后,重新拉一次 snapshot。
  • 页面从后台切回前台时,可重新拉 snapshot。
  • 付款成功跳转、登录状态变化后,重新拉 snapshot。

16. 前端设计

16.1 全局 store

建议新增 composable:

composables/useRedDots.ts

或 Pinia store,如果项目已有 store 规范则使用现有规范。

状态结构:

type RedDotItem = {
  path: string;
  count: number;
  has_dot: boolean;
  display: 'dot' | 'count' | 'none';
  updated_at?: string;
};

type RedDotState = {
  nodes: Record<string, RedDotItem>;
  dynamic: Record<string, RedDotItem>;
};

核心方法:

fetchSnapshot(paths, dynamicGroups?)
get(path)
has(path)
count(path)
merge(payload)
clear(path)
markAsRead(path, ids?)
markDynamicAsRead(pattern, entityId, payload?)

16.2 替换现有分散轮询

逐步替换:

  • layouts/user-center.vue

    • 从轮询 /api/profile/job_count 改为读取 red dot store。
    • 第一阶段可保留旧接口作为 fallback。
  • layouts/admin-center.vue

    • 从轮询 /api/admin_center/profile/job_count 改为读取 red dot store。
    • 管理后台可以后置迁移。
  • components/chat/chat.vue

    • 最外层入口读取 message_center
    • 私聊 tab 读取 chat
    • 群聊 tab 读取 group
    • 通知 tab 读取 notifications
    • 列表项优先使用列表接口返回的 unread_count;没有则用 dynamic red dots。

16.3 Badge 组件

建议封装统一组件:

components/red_dot/RedDotBadge.vue

props:

path: string
mode?: "auto" | "dot" | "count"
maxCount?: number

行为:

  • mode=auto 时使用后端返回的 display
  • dot 只显示红点。
  • count 显示数字。
  • count 为 0 不渲染。

16.4 列表页接入

列表页有两种接入方式:

方式 A:列表接口直接返回红点字段。

适用:Work Task、chat/group list。

示例:

{
  "id": 1001,
  "title": "...",
  "red_dot": {
    "count": 3,
    "has_dot": true
  }
}

方式 B:列表接口返回数据后,前端调用 batch。

适用:暂时不方便改列表接口的页面。

流程:

请求列表 -> 取当前页 ids -> POST /api/red_dots/batch -> 合并显示

约束:

  • 不能为了父级总数请求所有分页。
  • batch 只处理当前页。
  • 父级总数由静态 provider 返回。

16.5 前端展示绑定层

前端需要一层薄的展示绑定层,把 UI slot 映射到后端业务 path。

示例:

const redDotBindings = {
  'userCenter.entry': ['user.center.total'],
  'userCenter.menu.workTasks': ['user.work_task.total'],
  'userCenter.workTaskStatus.working': ['user.work_task.status.working'],
  'userCenter.workTaskStatus.completed': ['user.work_task.status.completed'],
  'userCenter.workTaskList.item': (id: number) => [`user.work_task.${id}`],
  'userCenter.workTaskDetail.priceChange': (id: number) => [`user.work_task.${id}.price_change`],
};

约束:

  • 前端展示绑定层只负责读取和展示,不负责重算业务红点。
  • UI 改版只改 slot 到 path 的映射,不要求后端重建红点树。
  • 一个 UI slot 可以绑定多个 path,但需要明确展示口径是求和、任一有红点还是取最大值。

16.6 Work Task 状态筛选与待处理筛选

当前 Work Task 已经有“工作中 / 已完成 / 已取消”等状态筛选。新增待处理能力时,不再增加一层导航,而是在当前状态列表内增加一个正交筛选。

推荐 UI:

状态 Tabs: 工作中 3 | 已完成 2 | 已取消 0
筛选: 全部 | 待处理 3
列表: Work Task rows

路由参数:

/user/work_tasks?status=working
/user/work_tasks?status=working&attention=1
/user/work_tasks?attention=1

点击行为:

  • 点击“工作中”文字:进入工作中全部列表。
  • 点击“工作中”的红点或数字:进入 status=working&attention=1
  • 点击“约稿”侧边栏红点或数字:进入 attention=1,可默认落到当前产品定义的优先状态。

16.7 分页页码不展示红点

不在分页器页码上展示红点。

原因:

  • 页码不会完整展示,移动端尤其明显。
  • 每页条数、排序、筛选、搜索变化后,红点所在页会变化。
  • 计算每一页是否有红点需要额外分页聚合,性能收益比很低。
  • 用户真正需要的是快速看到待处理项,而不是知道它在第几页。

替代方案:

  • 父级和状态 tab 展示有待处理的实体数量。
  • 当前页列表项展示逐项红点。
  • 提供 attention=1 待处理筛选,直接展示有红点的实体。
  • 普通列表保持原有排序;待处理列表按 last_attention_at desc 或业务优先级排序。

17. 已读行为设计

17.1 聊天已读

触发条件:

  • 用户打开对应私聊,并加载到最新消息。
  • 用户收到当前打开会话的新消息,并且视图在底部或系统认为已读。
  • 用户点击“有新消息”并加载到最新。

更新:

chat_user_pivot.last_read_cursor
chat_user_pivot.last_read_seq

17.2 群聊已读

触发条件同私聊。

更新:

group_user_pivot.last_read_cursor
group_user_pivot.last_read_seq

17.3 通知已读

触发条件:

  • 通知列表项进入视口超过阈值。
  • 用户点击通知。
  • 用户点击全部已读。

更新:

notifications.read_at
notifications.read_at_ts

17.4 Work Task 用户侧已读

触发条件:

  • 用户进入 Work Task 详情页。
  • 或用户打开稿件文件列表。

当前代码已在 User\WorkTaskController::info() 中标记该任务下 workTaskFiles.user_is_read = true

需要补充:

  • 标记后触发 red dot 缓存失效。
  • 后续如果使用物化计数,需同步减少 counter 或异步重算。

17.5 Work Task 画师侧已读

触发条件:

  • 画师进入修改意见列表。

当前代码已在 Artist\WorkTaskFileChangeRequestController::list() 中标记 artist_is_read = true

需要补充:

  • 标记后触发 red dot 缓存失效。
  • 物化计数阶段同步更新 counter。

17.6 Work Task 改价待处理关闭

改价类红点属于 action item,不等同于普通已读。

触发条件:

  • 用户或画师确认改价。
  • 用户或画师拒绝改价。
  • 发起方取消改价。
  • 等待付款的改价完成支付。
  • 后端调用 closePriceChange() 关闭对应页面事件。

要求:

  • 关闭后失效 user.work_task.{id}.price_changeartist.work_task.{id}.price_change
  • 同步重算 user.work_task.{id}user.work_task.status.{status}user.work_task.total
  • 不通过“打开详情页”直接清除仍需处理的改价红点。

18. 权限与安全

18.1 用户隔离

所有红点查询必须基于当前登录用户。

禁止前端传入任意 user_id 查询他人红点。

18.2 动态节点权限

动态节点批量查询必须校验实体归属。

示例:

  • chat.*:当前用户必须在 chat_user_pivot 中。
  • group.*:当前用户必须在 group_user_pivot 中。
  • user.work_task.*work_tasks.user_id = 当前用户 id
  • artist.work_task.*:当前用户必须是该 work task 的 artist。

18.3 信息泄露

如果传入无权限 entity_id:

  • 不返回该实体,或返回 count 0。
  • 不返回 404 暴露实体存在性。
  • 服务端记录 debug 日志即可。

19. 性能要求

19.1 接口性能目标

  • 全局 snapshot:P95 < 200ms。
  • 动态 batch 100 个实体:P95 < 300ms。
  • 单节点 count:P95 < 100ms。

以上是应用层目标,具体取决于数据库规模和索引情况。

19.2 SQL 要求

  • 动态 batch 必须使用 whereIn + groupBy 或 join 聚合。
  • 禁止循环 entity ID 执行 N 次 SQL 作为常态。
  • 高频节点需要合适索引。

19.3 建议索引

通知:

notifications(notifiable_type, notifiable_id, read_at)
notifications(notifiable_type, notifiable_id, created_at_ts)

聊天 pivot:

chat_user_pivot(user_id, chat_visable_status)
chat_user_pivot(user_id, chat_id)

群聊 pivot:

group_user_pivot(user_id, group_id)

Work Task 文件:

work_task_files(user_id, user_is_read)
work_task_files(work_task_id, user_is_read)

Work Task 修改意见:

work_task_file_change_requests(work_task_id, artist_is_read)
work_task_file_change_requests(work_task_file_id, artist_is_read)

Service Request:

service_requests(artist_id, artist_is_read)
service_requests(user_id, user_is_read)
service_requests(service_id, artist_id, artist_is_read)

19.4 分页与待处理筛选性能

分页列表不计算每一页是否有红点,也不在页码上展示红点。

性能策略:

  • 普通列表只返回当前页 item 的红点状态。
  • 父级和状态 tab 使用实体摘要表或 provider 聚合查询。
  • 待处理筛选走 has_attention + status + last_attention_at 索引。
  • 不通过加载全部分页数据来计算父级数量。

待处理列表排序:

where owner_id = ?
  and module = 'work_task'
  and status = ?
  and has_attention = true
order by last_attention_at desc

只要索引设计正确,这不是对普通列表做昂贵动态排序,而是查询一张待处理索引列表。

20. 兼容与迁移

20.1 兼容旧接口

第一阶段不删除:

  • /api/profile/job_count
  • /api/admin_center/profile/job_count
  • /api/notifications/has_unread
  • /api/notifications/has_new
  • 聊天列表中的 has_new_message

新增 red dot 接口后,前端逐步迁移。

20.2 聊天 seq 迁移

如果新增 last_read_seq

  1. migration 给 chat_user_pivotgroup_user_pivot 增加 nullable last_read_seq
  2. 后台脚本根据 last_read_cursor 回填对应 message 的 seq_id
  3. 回填失败时设置为 0。
  4. 新代码同时维护 cursor 和 seq。
  5. 稳定后计数使用 seq。

20.3 红点计数表迁移

如果引入 red_dot_counters

  1. 新增表。
  2. 新增 reddot:rebuild 命令。
  3. 第一阶段只为高频节点写入。
  4. provider 读取优先级:Redis -> red_dot_counters -> 实时查询。
  5. 对账命令定期比较物化计数和事实来源。

21. 实施计划

21.1 第一阶段:长期骨架的最小闭环

目标:先交付入口引导能力,同时避免做成新的临时 job_count

后端:

  • 整理红点注册表,保留业务 path、displaycount_metric、provider、权限范围等配置。
  • 新增 /api/red_dots/snapshot,支持一次返回多个静态或聚合节点。
  • 第一阶段 provider 以实时查询为主,优先使用已有索引和 exists/count distinct
  • 接入高价值入口:user.work_task.totalartist.work_task.totalartist.service.totalnotifications.unread
  • 接入 Work Task 状态节点:user.work_task.status.working 等。
  • 接入 Service Request 链路:画室中心、service 入口、service 行、request 行。
  • 不强制引入 red_dot_counters,不强制 Redis,不新增 WebSocket 事件。
  • /api/profile/job_count/api/admin_center/profile/job_count 保留为兼容接口。

前端:

  • 新增 useRedDots store,统一保存 snapshot 结果。
  • 建立 UI slot 到业务 path 的映射,不把 UI 层级写进后端 path。
  • 用户中心、画师中心侧边栏接入 red dot store,旧 job_count 作为 fallback。
  • topnav 静态 unread-dot 改为读取真实业务 path。
  • 列表项红点继续优先使用领域列表接口直返字段,不强制改成独立 batch 请求。
  • 点击父级红点或数字时,进入对应 attention=1 筛选列表。

21.2 第二阶段:跨分页定位和实体摘要

目标:解决“父级有红点,但用户不知道在哪一页”的问题。

后端:

  • Work Task 列表支持 attention=1 和状态筛选组合。
  • Service 列表支持 attention=1,直接筛出有待关注 request 的 service。
  • 对 Work Task、Service 引入 red_dot_entity_summaries,支撑状态 tab 数量和待处理列表。
  • 摘要表只覆盖列表型高价值业务,不覆盖所有 path。
  • 新增摘要重建命令,例如 php artisan reddot:rebuild-entities --module=work_task

前端:

  • 状态 tab 的数字使用有待处理的实体数量。
  • 分页器不展示红点。
  • 当前页 item 继续展示红点或数量。
  • “全部 / 待处理”作为列表内筛选,不新增更深导航层级。

21.3 第三阶段:聊天、通知和实时刷新

目标:把高频消息类红点纳入统一读取口径,但不阻塞第一阶段交付。

后端:

  • 私聊、群聊评估新增 last_read_seq,用 message_count - last_read_seq 计算未读。
  • 通知中心接入 notifications.unread
  • 新增轻量 RedDotUpdated 事件,只推需要刷新的 path,不在 WS 里承载复杂业务数据。
  • 页面重新聚焦、WebSocket 重连后,前端重新拉 snapshot。

前端:

  • 消息最外层入口读取 chat 或相关业务 path。
  • 私聊、群聊、通知 tab 逐步迁移到 red dot store。
  • 保留现有聊天组件内实时逻辑,避免一次性重构。

21.4 第四阶段:后台和通用物化优化

目标:在业务口径稳定后,再做性能优化和后台统一。

后端:

  • 管理后台 job_count 迁入 red dot providers。
  • 对读频高、实时计算成本高、写路径清晰的节点引入 red_dot_counters
  • 增加对账和重建命令。
  • 确认无调用后,再删除废弃的旧 RedDot 路由或 provider。

前端:

  • 管理后台 layout 改用 red dot store。
  • 移除已经被 snapshot 覆盖的旧轮询。
  • 对热点页面按需接入局部刷新。

22. 验收标准

22.1 功能验收

  • 私聊收到新消息,最外层消息入口出现红点。
  • 群聊收到新消息,最外层消息入口和群聊 tab 出现红点。
  • 通知中心收到新通知,最外层消息入口和通知 tab 出现红点。
  • 用户 Work Task 有新稿件,用户中心 Work Task 菜单显示数量。
  • 画师收到修改意见,画师中心 Work Task 菜单显示数量。
  • 进入详情并触发已读后,对应红点消失或数量减少。
  • 列表分页只展示当前页项目红点,不影响父级总数准确性。
  • Work Task 改价产生待处理时,对应任务、状态 tab、约稿入口出现红点或数量。
  • 改价被确认、拒绝、取消或支付完成后,对应红点消失或数量减少。
  • Work Task 状态 tab 的数字表示有待处理事项的任务数,不是子红点总数。
  • 点击状态 tab 的红点或数字,可进入当前状态下的待处理筛选列表。
  • 分页器页码不展示红点,用户通过待处理筛选定位跨页红点项。
  • 新 service request 产生后,画室中心、service 管理入口、对应 service 行、对应 request 行能逐级展示红点。
  • artist.service.total 统计有待关注 request 的 service 数量,不递归相加所有 request 子红点。
  • dot-only 节点不返回或不展示 count,前端不额外请求子级自行拼数字。

22.2 性能验收

  • 全局 snapshot 不依赖加载所有聊天、群聊、Work Task 列表。
  • 动态 batch 100 个 ID 不出现 N+1 查询。
  • 聊天群聊父级总数不扫描消息表明细。
  • 页面不再出现多个 layout 各自高频轮询同类 count 的情况。

22.3 一致性验收

  • Redis 清空后,红点能通过 provider 或 DB 重新计算。
  • WebSocket 断开重连后,snapshot 能恢复正确状态。
  • 已读操作后,父级和祖先节点同步失效。
  • reddot:rebuild 能重建关键红点计数。

23. 风险与应对

23.1 旧接口和新接口并存导致不一致

风险:迁移期同一页面可能同时读旧 job_count 和新 red dot。

应对:

  • 明确每个页面的数据来源。
  • 前端 store 统一封装 fallback,不在组件里混用。
  • 完成迁移后删除旧轮询。

23.2 红点计数漂移

风险:物化计数写入失败或重复扣减导致不一致。

应对:

  • 业务事实来源保留。
  • 所有物化计数可重建。
  • 增加对账命令。
  • 关键事件使用幂等更新。

23.3 聊天未读数量计算成本高

风险:使用消息表 count(*) where id > cursor 会在大群或长会话下变慢。

应对:

  • 引入 last_read_seq
  • 使用 message_count - last_read_seq
  • 保留 cursor 用于定位消息。

23.4 权限泄露

风险:动态 batch 可被传入任意 ID 探测数据存在性。

应对:

  • provider 必须基于当前用户关系表过滤。
  • 无权限实体返回 0 或不返回。
  • 不直接返回权限错误细节。

24. 待确认问题

  • 聊天、群聊 tab 是否第一阶段就展示数字,还是只展示 dot。
  • 通知中心入口是否展示真实未读数,还是只展示 dot。
  • Work Task 改价已纳入待处理红点范围;延期变更、阶段确认等其他 WorktaskPageEventList 事件是否纳入同一 attention 筛选仍需按业务优先级确认。
  • Service Request 红点第一阶段是否按“未查看”清除,还是按“待处理动作完成”清除,需要产品确认。
  • 管理后台红点是否需要按 admin 权限拆分,不同 admin 看到不同节点。
  • 是否需要“全部已读”能力覆盖聊天、通知、Work Task,还是只在各业务内部分别处理。

25. 推荐结论

红点系统采用混合方案:

业务表保存已读事实
RedDot provider 统一计算口径
Redis 缓存热点结果
必要时 DB 物化高频计数
前端统一 RedDot store 展示
WebSocket 推动局部刷新

不推荐只用 Redis 保存一个总数,也不推荐简单地给每一级无脑建行。正确落地方式是:

  • 静态父级节点由 provider 或物化计数直接返回全量总数。
  • 动态列表节点只对当前页批量查询。
  • 已读事实留在业务表。
  • 物化计数可重建。
  • 前端不再用已加载列表推导父级总数。
  • 后端 path 表达业务语义,前端通过 UI slot 映射展示位置,避免 UI 改版导致后端红点树重建。
  • Work Task 父级和状态 tab 的数量使用“有待处理的任务数”,任务详情内再展示子红点数量。
  • 跨分页红点不做页码提示,通过 attention=1 待处理筛选和实体摘要索引解决。
  • 改价红点归入 Work Task 子事件,优先使用未关闭的 WorktaskPageEventList 作为待处理事实来源。
  • 节点必须显式声明 count_metric,父级默认不递归累加子级 count。
  • 高层入口和混合业务入口优先 dot-only,只有数字含义清晰且查询成本可控时才展示 count。
  • Service Request 链路按直接实体逐级提示:画室中心 dot、service 入口统计有待关注的 service 数、service 行统计 request 数、request 行展示 dot。

26. 收敛后的推荐执行口径

最终采用“长期模型、分阶段轻量落地”的方案。

保留的长期能力:

  • 后端 path 使用业务语义,不绑定前端视觉结构。
  • 前端通过 UI slot 映射业务 path。
  • 每个节点显式声明 displaycount_metric
  • 父级 count 默认不递归累加子级 count。
  • 高层入口优先 dot-only。
  • 列表型业务通过 attention=1 解决跨分页定位。
  • Work Task、Service 等高价值列表型业务可使用实体摘要索引。

第一阶段明确不做:

  • 不做所有 path 的全量 DB counter。
  • 不为红点单独强制引入 Redis。
  • 不把 WebSocket 作为交付依赖。
  • 不把所有列表项红点改成独立 batch 接口。
  • 不删除旧接口和旧 RedDot 代码,先兼容迁移。

第一阶段必须交付:

  • /api/red_dots/snapshot
  • useRedDots store。
  • 用户中心、画师中心、topnav 的真实红点接入。
  • Work Task 状态维度聚合。
  • Service Request 从画室中心到 service 再到 request 的逐级引导。
  • 父级红点点击进入 attention=1 待处理筛选。

这套方案避免变成新的 job_count,同时也避免一开始就投入过重的 Redis、全量物化计数和实时推送体系。

26.1 执行边界图

ON THIS PAGE