红点系统 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 前端现状
前端已存在以下红点相关实现:
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/tree、count、batch、read、read_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.status、groups.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
- 通知中心入口:可配置为
dot 或 count
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 的节点:
- 顶层入口,例如
root、message_center、user.center.total、artist.center.total。
- 多业务混合入口,数字含义容易混淆的节点。
- 需要跨大量动态实体实时精确计数的节点。
- 只需要提醒用户“有事要看”,不需要告诉用户“有多少”的节点。
适合 count 的节点:
- 数字含义清晰且对用户决策有帮助的节点。
- 能通过索引、摘要表或低成本 provider 精确返回的节点。
- 状态 tab 或列表型父级,例如
user.work_task.status.working、artist.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 不写
sidebar、tab、page、detail 等纯 UI 词。
- 现有
user.center.work_tasks、artist.center.work_tasks 可以作为兼容 alias,后续新增节点优先使用 user.work_task.total、artist.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_count、has_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 获取单个节点
保留现有能力:
请求:
{
"path": "user.center.work_tasks"
}
响应:
{
"code": 0,
"data": {
"path": "user.center.work_tasks",
"count": 6,
"has_dot": true,
"display": "count"
}
}
10.3 批量获取动态节点
保留并规范现有 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 标记已读
接口:
适用于静态叶子节点或 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。
- 很多节点可以通过已有索引低成本
exists 或 count。
- 过早维护所有父级 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_files、work_task_file_change_requests、WorktaskPageEventList、service_requests 等业务事实重建。
11.4 缓存策略收敛
第一阶段不强依赖 Redis。
推荐顺序:
- 先用 provider + 数据库索引保证正确性。
- 对热点 snapshot 节点加 15 到 60 秒短 TTL。
- 写路径稳定后,再补精确 cache forget。
- 读写规模明显增长后,再迁移到 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_id 和 chats.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_id 和 groups.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 保留。
- 标记后需要失效
notifications、chat、root 等相关缓存。
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 = false 和 user_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_join 或 whereHas('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: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。
推荐落地顺序:
- 第一阶段使用简单策略。
- 高频节点稳定后引入物化计数。
- 物化计数仍保留重算兜底。
15. 实时更新策略
15.1 HTTP 基线
页面初始化或登录后,前端调用:
POST /api/red_dots/snapshot
获取当前用户红点基线。
15.2 WebSocket 增量
现有前端已监听 user_events.{id} 通道,并处理:
ChatUpdated
ChatMessageCreated
GroupCreated
GroupUpdated
GroupMessageCreated
SystemNotificationEvent
推荐新增通用事件:
事件 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_change 或 artist.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:
- migration 给
chat_user_pivot、group_user_pivot 增加 nullable last_read_seq。
- 后台脚本根据
last_read_cursor 回填对应 message 的 seq_id。
- 回填失败时设置为 0。
- 新代码同时维护 cursor 和 seq。
- 稳定后计数使用 seq。
20.3 红点计数表迁移
如果引入 red_dot_counters:
- 新增表。
- 新增
reddot:rebuild 命令。
- 第一阶段只为高频节点写入。
- provider 读取优先级:Redis -> red_dot_counters -> 实时查询。
- 对账命令定期比较物化计数和事实来源。
21. 实施计划
21.1 第一阶段:长期骨架的最小闭环
目标:先交付入口引导能力,同时避免做成新的临时 job_count。
后端:
- 整理红点注册表,保留业务 path、
display、count_metric、provider、权限范围等配置。
- 新增
/api/red_dots/snapshot,支持一次返回多个静态或聚合节点。
- 第一阶段 provider 以实时查询为主,优先使用已有索引和
exists/count distinct。
- 接入高价值入口:
user.work_task.total、artist.work_task.total、artist.service.total、notifications.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。
- 每个节点显式声明
display 和 count_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 执行边界图