SpringBoot3+Vue3 流程抄送已读:打开详情消红点,待办与知会怎么分开
🌐 文档地址 :https://ruoyioffice.com
📦 源码1·GitHub :https://github.com/yuqing2026/ruoyi-office
📦 源码2·GitCode :https://gitcode.com/zhouzhongyan/ruoyi-office
📦 源码3·Gitee :https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(备注「RuoYi Office」)
抄送最容易做成两个极端:一种把被抄送人塞进 UserTask,领导不点同意,流程就卡住;另一种只推一条消息,三天后谁看过、看的是哪一关,没人说得清。RuoYi Office 把抄送定义为"有回执的知会":生成独立抄送记录,工作台只给未读记录计数,用户打开只读详情后写入已读时间,但整个过程不创建待办、不完成任务,也不改变流程状态。

▲ 抄送记录先以 readStatus=0 进入工作台,打开详情后按 copyId 校验归属并标记已读;待办负责推进,抄送只负责知情
引言:抄送不推进流程,但也不能没有结果
抄送是"把流程上下文交给需要知情的人",已读回执是"证明这条上下文被打开过"。它们都不是审批动作。
| 常见做法 | 表面效果 | 实际问题 |
|---|---|---|
| 把抄送人配置成审批人 | 对方一定能看到 | 不处理就卡流程,责任语义被污染 |
| 只发站内信或 IM | 开发量小 | 消息删除后没有正式抄送台账 |
| 进入列表就全部标读 | 红点消得快 | 用户没打开单据也被算"已阅" |
| 用流程实例 ID 批量标读 | 查询方便 | 同一实例多个抄送节点无法精确回执 |
| 标读失败就挡住详情 | 数据看似强一致 | 用户连单据都看不到,知会本末倒置 |
正确的产品口径是:列表代表"抄给过我",角标代表"我还没打开",详情代表"我正在阅读";三者共用抄送表,但不共用待办任务。
一、产品能力与特点
1.1 谁在什么地方使用
流程设计人员在 SIMPLE 流程里配置抄送节点,审批人也可以在办理时手动抄送。被抄送人从 工作台 → 抄送我的 或 流程中心 → 任务管理 → 抄送我的 查看。
| 能力 | 面向角色 | 和"只发一条通知"差在哪 |
|---|---|---|
| 自动抄送节点 | 流程管理员 | 记录节点 activityId,知道在哪一关知会 |
| 办理中手动抄送 | 审批人 | 带 taskId 和抄送意见,可追溯发起动作 |
| 独立抄送台账 | 被抄送人 | 历史记录一直保留,不随消息已读消失 |
| 未读筛选与角标 | 被抄送人 | 角标只统计 readStatus=0,不把历史总数当未读 |
| 打开详情标读 | 被抄送人 | 写 readTime,有明确阅知时间 |
1.2 四个盒子必须拆开
| 盒子 | 是否推进流程 | 是否需要回执 | 主要入口 |
|---|---|---|---|
| 待办任务 | 是 | 通过/拒绝等办理结果 | 我的待办 |
| 流程抄送 | 否 | 已读时间 | 抄送我的 |
| 流程评论 | 否 | 评论内容本身 | 单据详情评论 Tab |
| IM 会话 | 否 | 消息已读属于聊天语义 | 即时通讯 |
如果客户提出"抄送人必须点确认,否则流程不能结束",那已经不是轻量抄送,而是一个阅知 UserTask,应该单独建状态机,不能偷偷改变现有抄送语义。
二、业务流程怎么串
2.1 配置或办理时产生抄送
自动抄送节点复用流程候选人解析,办理中手动抄送则由当前任务带出流程实例和节点。两种入口最终写入同一张 bpm_process_instance_copy。

▲ 流程模型是自动抄送的配置入口;发布后,抄送节点跟随定义执行,但不会生成需要完成的 UserTask
新记录显式写 readStatus=0。同时冗余流程名、分类、定义 ID、节点名、发起人和抄送意见,使"抄送我的"不需要临时拼出所有上下文。
2.2 工作台展示历史,角标只数未读
抄送列表默认按主键倒序,展示单据类型、编号、摘要、发起人、公司、部门、节点、意见和已读状态。用户可以按未读/已读筛选。

▲ 抄送我的保留全部历史,顶部增加已读状态筛选;工作台角标另发一个 pageSize=1、readStatus=0 的请求,只取 total
这里有一个很重要的口径:列表总数不是红点数。 工作台加载抄送 Tab 时并行请求两次:
- 不带
readStatus,拿当前页历史记录; - 带
readStatus=0,只用返回的total作为角标。
这样看过的抄送仍能复盘,但不会让角标永远显示 99+。
2.3 点编号或详情,带 copyId 进入只读单据
列表跳转时不只带流程实例 ID,还带:
viewType=copy:详情页按抄送视角只读;copyId:精确定位这一条抄送记录;activityId:保留抄送发生的节点;copyReason:展示当时的抄送意见。

▲ 被抄送人看到完整业务单据,底部只有打印和关闭;打开后才触发标读,阅知不会误完成任务
详情先完成业务数据加载,再异步调用标读接口。即使标读网络失败,也只影响红点刷新,不阻断用户查看单据。
2.4 返回工作台,未读角标减少
详情写入 readStatus=1 和 readTime=当前时间。工作台被 KeepAlive 缓存后重新激活,会再次并行请求列表、待办数和抄送未读数,因此返回时角标按服务端事实刷新。
状态变化只有一条:
text
未读(0) --打开本人抄送详情--> 已读(1) + readTime
它没有"审批中/通过/驳回",也不会碰 Flowable Task。
三、设计怎么落地
3.1 设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 真相源 | 独立抄送表 | 不依赖站内信或 IM 是否送达 |
| 新记录状态 | 创建时显式 readStatus=0 | 历史数据和业务语义清楚 |
| 标读主键 | 优先 copyId | 同实例多节点、多次抄送可精确回执 |
| 兼容入口 | instanceId + 可选 activityId | 支持旧链接或缺少 copyId 的跳转 |
| 权限边界 | copy.userId 必须等于登录用户 | 不能拿别人的 copyId 替对方消红点 |
| 失败策略 | 标读失败不挡详情 | "能看见"优先于角标强一致 |
| 角标口径 | 查询未读 total | 不在前端用当前页过滤推算全量 |
3.2 表结构:抄送记录依赖流程实例,但不是流程任务
#mermaid-svg-nvZDs4kJ9M64YYIX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nvZDs4kJ9M64YYIX .error-icon{fill:#552222;}#mermaid-svg-nvZDs4kJ9M64YYIX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nvZDs4kJ9M64YYIX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nvZDs4kJ9M64YYIX .marker.cross{stroke:#333333;}#mermaid-svg-nvZDs4kJ9M64YYIX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nvZDs4kJ9M64YYIX p{margin:0;}#mermaid-svg-nvZDs4kJ9M64YYIX .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-nvZDs4kJ9M64YYIX .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-nvZDs4kJ9M64YYIX .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-nvZDs4kJ9M64YYIX .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-nvZDs4kJ9M64YYIX .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-nvZDs4kJ9M64YYIX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nvZDs4kJ9M64YYIX .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-nvZDs4kJ9M64YYIX .node rect,#mermaid-svg-nvZDs4kJ9M64YYIX .node circle,#mermaid-svg-nvZDs4kJ9M64YYIX .node ellipse,#mermaid-svg-nvZDs4kJ9M64YYIX .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nvZDs4kJ9M64YYIX .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-nvZDs4kJ9M64YYIX .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-nvZDs4kJ9M64YYIX .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nvZDs4kJ9M64YYIX .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-nvZDs4kJ9M64YYIX .edgeLabel .label text{fill:#333;}#mermaid-svg-nvZDs4kJ9M64YYIX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} produces
manual_copy
receives
ACT_HI_PROCINST
BPM_PROCESS_INSTANCE_COPY
bigint
id
PK
varchar
process_instance_id
varchar
task_id
varchar
activity_id
bigint
user_id
int
read_status
datetime
read_time
ACT_HI_TASKINST
SYSTEM_USERS
| 关键列 | 作用 |
|---|---|
process_instance_id |
打开哪一个流程实例 |
task_id |
手动抄送时关联办理任务;自动抄送允许为空 |
activity_id / activity_name |
知会发生在哪个节点 |
user_id |
唯一允许标读的被抄送人 |
reason |
手动抄送意见 |
read_status / read_time |
未读筛选、角标和回执 |
3.3 时序:展示详情与标读解耦
抄送表 CopyController 流程详情 Vue3 抄送列表 抄送表 CopyController 流程详情 Vue3 抄送列表 #mermaid-svg-lHy6ag7qhWiCsaOg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lHy6ag7qhWiCsaOg .error-icon{fill:#552222;}#mermaid-svg-lHy6ag7qhWiCsaOg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lHy6ag7qhWiCsaOg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lHy6ag7qhWiCsaOg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lHy6ag7qhWiCsaOg .marker.cross{stroke:#333333;}#mermaid-svg-lHy6ag7qhWiCsaOg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lHy6ag7qhWiCsaOg p{margin:0;}#mermaid-svg-lHy6ag7qhWiCsaOg .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lHy6ag7qhWiCsaOg text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lHy6ag7qhWiCsaOg .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lHy6ag7qhWiCsaOg .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lHy6ag7qhWiCsaOg #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lHy6ag7qhWiCsaOg .sequenceNumber{fill:white;}#mermaid-svg-lHy6ag7qhWiCsaOg #sequencenumber{fill:#333;}#mermaid-svg-lHy6ag7qhWiCsaOg #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lHy6ag7qhWiCsaOg .messageText{fill:#333;stroke:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lHy6ag7qhWiCsaOg .labelText,#mermaid-svg-lHy6ag7qhWiCsaOg .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .loopText,#mermaid-svg-lHy6ag7qhWiCsaOg .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lHy6ag7qhWiCsaOg .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lHy6ag7qhWiCsaOg .noteText,#mermaid-svg-lHy6ag7qhWiCsaOg .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lHy6ag7qhWiCsaOg .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lHy6ag7qhWiCsaOg .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lHy6ag7qhWiCsaOg .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lHy6ag7qhWiCsaOg .actorPopupMenu{position:absolute;}#mermaid-svg-lHy6ag7qhWiCsaOg .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-lHy6ag7qhWiCsaOg .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lHy6ag7qhWiCsaOg .actor-man circle,#mermaid-svg-lHy6ag7qhWiCsaOg line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lHy6ag7qhWiCsaOg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 被抄送人 打开抄送我的page + readStatus=0查询历史与未读 totalcopyId + viewType=copy先加载业务单据PUT /copy/view校验 userId 并写已读时间返回工作台重新查询未读 total 被抄送人
时序刻意把"加载详情"和"标读"分开:单据可读性不应被一个角标接口绑架。
3.4 创建抄送时就把未读事实写清
对应 2.1。无论自动还是手动入口,批量写表时显式初始化为未读。
java
List<BpmProcessInstanceCopyDO> copyList =
convertList(userIds, userId -> new BpmProcessInstanceCopyDO()
.setUserId(userId)
.setReason(reason)
.setStartUserId(Long.valueOf(processInstance.getStartUserId()))
.setProcessInstanceId(processInstanceId)
.setProcessInstanceName(processInstance.getName())
.setCategory(processDefinition.getCategory())
.setTaskId(taskId)
.setActivityId(activityId)
.setActivityName(activityName)
.setProcessDefinitionId(processInstance.getProcessDefinitionId())
.setReadStatus(0));
processInstanceCopyMapper.insertBatch(copyList);
这里不创建 Task,也没有 completionCondition;抄送人不操作,流程照常向后走。
3.5 标读先校验归属,再做幂等更新
对应 2.3。copyId 是首选,缺失时才按实例和节点查当前用户的未读记录。
java
public void viewProcessInstanceCopy(Long userId, Long copyId,
String processInstanceId, String activityId) {
if (copyId != null) {
BpmProcessInstanceCopyDO copy =
processInstanceCopyMapper.selectById(copyId);
if (copy == null || ObjectUtil.notEqual(copy.getUserId(), userId)) {
throw exception(PROCESS_INSTANCE_COPY_NOT_EXISTS);
}
markCopyRead(copy);
return;
}
if (StrUtil.isEmpty(processInstanceId)) {
return;
}
List<BpmProcessInstanceCopyDO> copies =
processInstanceCopyMapper.selectList(new LambdaQueryWrapperX<BpmProcessInstanceCopyDO>()
.eq(BpmProcessInstanceCopyDO::getUserId, userId)
.eq(BpmProcessInstanceCopyDO::getProcessInstanceId, processInstanceId)
.eqIfPresent(BpmProcessInstanceCopyDO::getActivityId, activityId)
.eq(BpmProcessInstanceCopyDO::getReadStatus, 0));
copies.forEach(this::markCopyRead);
}
已经是已读的记录再次打开,markCopyRead 直接返回,不反复改写 readTime。这个时间代表首次阅知,而不是最近访问。
3.6 前端先把详情交给用户,标读失败不遮页面
对应 2.3。详情加载完成后才发送回执,错误只交给下一次激活刷新修正。
typescript
onMounted(async () => {
await getDetail();
userOptions.value = await getSimpleUserList();
if (isCopy.value) {
const copyId = route.query.copyId as string | undefined;
viewProcessInstanceCopy({
...(copyId && { id: copyId }),
processInstanceId: routeProcessInstanceId.value,
activityId: routeActivityId.value,
}).catch(() => {
// 标已读失败不阻断详情查看
});
}
});
onActivated(() => {
loadAllData();
});
这是一个有意的最终一致选择:短暂断网可能让红点多保留一次,但不会让用户因"标读失败"无法知情。
四、边界与对照
| 场景 | 本方案怎么处理 | 不应该做什么 |
|---|---|---|
| 同一实例多次抄给同一人 | 每次一条记录,各自 copyId | 按实例把所有历史无差别改掉 |
| 旧链接没有 copyId | 当前用户 + 实例 + 可选节点匹配未读 | 不带 userId 全租户更新 |
| 用户只扫了一眼列表 | 仍未读 | 列表加载即全部已读 |
| 标读接口超时 | 详情继续展示 | 整页报错退出 |
| 流程已结束 | 抄送历史仍可读 | 跟随 Task 删除回执 |
| 抄送需要强制确认 | 另建阅知任务 | 把轻量抄送偷偷改成阻塞节点 |
目前的已读只证明"打开过详情",不证明读完、更不构成电子签收。如果合规场景需要确认责任,应增加明确的"确认阅知"动作、意见和审计证据,而不是夸大 readTime。
五、并发、兼容与数据治理
5.1 同一条抄送被两个页签同时打开
用户可能从工作台和流程中心各开一个页签,两边几乎同时调用标读。当前实现的结果是:
- 两个请求都先读到同一条 copy;
- 第一个请求把状态改成 1;
- 第二个请求手里的对象仍可能是 0,再执行一次 update;
- 最终状态仍是已读,但 readTime 可能取后一次请求的时间。
这不影响未读数和权限,但若产品把 readTime 定义为严格"首次阅读",更严谨的 SQL 应带条件:
sql
UPDATE bpm_process_instance_copy
SET read_status = 1,
read_time = CURRENT_TIMESTAMP
WHERE id = :id
AND user_id = :loginUserId
AND read_status = 0;
用受影响行数判断是否首次标读,可以避免并发页签覆盖首次时间。当前文章只描述现状,不把近似时间夸成不可抵赖证据。
5.2 为什么 fallback 只更新"当前用户的未读记录"
旧链接可能只有 processInstanceId,没有 copyId。兼容查询仍然加了三道约束:
| 约束 | 防止的问题 |
|---|---|
userId = loginUserId |
替别的抄送人标读 |
processInstanceId = 当前实例 |
一次请求清空所有抄送 |
readStatus = 0 |
重复改写历史已读时间 |
activityId 是可选的。传了就只处理该节点,没传则可能把当前用户在同一实例下的多条未读抄送一起标读。
因此新入口必须尽量带 copyId;fallback 是兼容通道,不是首选协议。
5.3 历史数据 readStatus 为 null 怎么办
旧数据可能早于已读字段上线,read_status 为 null。前端当前将 cellValue === 1 之外的值显示为"未读",这在视觉上合理;但后端 fallback 查询使用 readStatus=0,不会命中 null。
上线已读功能时,应至少确认:
| 检查项 | 推荐处理 |
|---|---|
| 列默认值 | 新数据默认 0 |
| 旧记录 null | 一次性回填 0 或按业务决定回填 1 |
| 索引 | 高频角标可评估 (user_id, read_status, id) |
| 删除策略 | 流程实例删除时同步删抄送记录 |
| 租户字段 | 继承 BaseDO,查询仍受租户边界保护 |
不要直接把所有历史 null 回填已读。它会让"上线前没读过"的记录永久失去未读提示;是否这样做应由产品和客户共同决定。
5.4 未读角标为什么不应该缓存太久
工作台角标的价值是反馈刚刚发生的动作。若把未读总数缓存 30 分钟,用户打开详情后返回仍看到红点,会以为标读失败。
适合的优化顺序是:
- 先用
(user_id, read_status)索引降低 count 成本; - 工作台列表与角标并行请求,减少瀑布等待;
- 返回页面时重新查询;
- 确实高并发再考虑短 TTL 或事件驱动失效。
不建议一开始就用 Redis 维护未读计数。新增、删除、回滚事务、历史补数据都会让缓存与数据库产生双写一致性问题,而抄送未读通常不是百万 QPS 场景。
5.5 标读与流程实例删除怎么保持一致
流程实例被管理员删除时,服务会按 processInstanceId 删除对应抄送记录。否则列表会留下一个无法打开的孤儿入口。
这也是为什么抄送真相源不能只放在消息中心:流程删除、实例权限和业务详情都需要按实例 ID 做生命周期治理。
六、验收矩阵:不要只测"红点消了"
6.1 功能验收
| 编号 | 场景 | 预期 |
|---|---|---|
| C01 | 自动抄送节点命中 1 人 | 生成一条 readStatus=0 记录 |
| C02 | 手动抄送 3 人 | 三人各有独立 copyId |
| C03 | 抄送人打开详情 | 本人记录变 1,并写 readTime |
| C04 | 打开同一条第二次 | 状态仍为 1,不新增抄送记录 |
| C05 | 只打开抄送列表 | 记录仍为未读 |
| C06 | 返回工作台 | 抄送角标按未读 total 刷新 |
| C07 | 流程继续审批 | 抄送人不处理也不阻塞 |
| C08 | 流程结束后打开 | 仍可看历史详情和轨迹 |
6.2 权限验收
| 编号 | 攻击或误用 | 预期 |
|---|---|---|
| P01 | 用户 A 提交用户 B 的 copyId | 返回记录不存在,不修改 B |
| P02 | 路人只有 instanceId | 不能借标读接口获得详情权限 |
| P03 | 跨租户 copyId | 租户与 userId 双边界拒绝 |
| P04 | 被抄送人看只读详情 | 无同意、拒绝、转办等按钮 |
| P05 | 无业务表单查看权 | 仍按详情权限策略处理,不因抄送越权 |
标读权限和详情权限是两道闸。标读接口只改抄送记录,业务详情仍要执行自己的数据权限与表单字段权限。
6.3 兼容验收
| 编号 | 链接形态 | 预期 |
|---|---|---|
| L01 | copyId + instanceId + activityId |
精确标记一条 |
| L02 | instanceId + activityId |
标记当前用户该节点未读 |
| L03 | 只有 instanceId |
兼容标记本人该实例未读 |
| L04 | 参数全空 | 安全返回,不全表更新 |
| L05 | copyId 已读 | 幂等成功 |
6.4 可观测性验收
生产排查至少要能回答四个问题:
- 抄送记录是否生成;
- 跳转链接是否带 copyId;
- 标读请求是否由正确用户发出;
- 工作台是否重新查询未读 total。
遇到"红点不消",应按这个顺序查,而不是先改前端 Badge:
text
抄送表 read_status
→ /bpm/process-instance/copy/view 请求
→ copyId 与登录 userId
→ 详情 onMounted 是否进入 isCopy
→ 工作台 onActivated 是否刷新
根因在哪一层,就修哪一层。数据没标读时不要在前端强行减 1;前端没刷新时也不要重写后端查询。
七、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
推荐按这条路径体验:
- 进入流程中心,打开一个 SIMPLE 流程模型;
- 配置抄送节点,或在待办办理时选择抄送人;
- 用被抄送账号进入工作台,看"抄送我的"未读角标;
- 打开抄送列表,按未读筛选;
- 点单据编号进入只读详情,确认没有通过/拒绝;
- 返回工作台,确认未读角标减少;
- 再次打开同一条记录,确认 readTime 不被反复覆盖。
源码地址:
- GitHub:https://github.com/yuqing2026/ruoyi-office
- GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
- Gitee:https://gitee.com/yqzy1688/ruoyi-office
常见问题(FAQ)
Flowable 抄送已读会推进流程吗?
不会。抄送记录不属于 UserTask,标记已读只更新业务抄送表的 readStatus 和 readTime。
为什么不能打开抄送列表就全部标为已读?
因为列表只证明用户到过入口,并不证明打开了某张业务单据。逐条详情标读更接近真实阅知行为。
为什么工作台要单独查询一次未读 total?
当前页可能只有 10 条,前端无法靠一页数据推算全量未读数。服务端按 userId + readStatus=0 查询 total,角标口径才稳定。
抄送、站内信和 IM 应该共用已读状态吗?
不应该。抄送代表流程知会,站内信代表通知送达,IM 代表会话消息;三者生命周期和审计含义不同。
readTime 能当作电子签收证据吗?
不能直接等同。它只能证明该账号打开过详情。强合规场景还需要确认动作、签名、意见或更完整的审计证据。
结语
抄送已读真正解决的不是"多一个红点",而是把知会的发生、入口、阅知和历史复盘串成闭环,同时守住"不推进流程"的边界。
你们的抄送更接近"看过即可",还是必须"确认阅知"?如果必须确认,是否应该直接建阅知任务?欢迎在评论区说说真实场景。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 技术咨询 :添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!