SpringBoot3+Vue3 流程抄送已读:打开详情消红点,待办与知会怎么分开

SpringBoot3+Vue3 流程抄送已读:打开详情消红点,待办与知会怎么分开

🌐 文档地址https://ruoyioffice.com

📦 源码1·GitHubhttps://github.com/yuqing2026/ruoyi-office

📦 源码2·GitCodehttps://gitcode.com/zhouzhongyan/ruoyi-office

📦 源码3·Giteehttps://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 时并行请求两次:

  1. 不带 readStatus,拿当前页历史记录;
  2. readStatus=0,只用返回的 total 作为角标。

这样看过的抄送仍能复盘,但不会让角标永远显示 99+。

2.3 点编号或详情,带 copyId 进入只读单据

列表跳转时不只带流程实例 ID,还带:

  • viewType=copy:详情页按抄送视角只读;
  • copyId:精确定位这一条抄送记录;
  • activityId:保留抄送发生的节点;
  • copyReason:展示当时的抄送意见。

▲ 被抄送人看到完整业务单据,底部只有打印和关闭;打开后才触发标读,阅知不会误完成任务

详情先完成业务数据加载,再异步调用标读接口。即使标读网络失败,也只影响红点刷新,不阻断用户查看单据。

2.4 返回工作台,未读角标减少

详情写入 readStatus=1readTime=当前时间。工作台被 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 同一条抄送被两个页签同时打开

用户可能从工作台和流程中心各开一个页签,两边几乎同时调用标读。当前实现的结果是:

  1. 两个请求都先读到同一条 copy;
  2. 第一个请求把状态改成 1;
  3. 第二个请求手里的对象仍可能是 0,再执行一次 update;
  4. 最终状态仍是已读,但 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 分钟,用户打开详情后返回仍看到红点,会以为标读失败。

适合的优化顺序是:

  1. 先用 (user_id, read_status) 索引降低 count 成本;
  2. 工作台列表与角标并行请求,减少瀑布等待;
  3. 返回页面时重新查询;
  4. 确实高并发再考虑短 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 可观测性验收

生产排查至少要能回答四个问题:

  1. 抄送记录是否生成;
  2. 跳转链接是否带 copyId;
  3. 标读请求是否由正确用户发出;
  4. 工作台是否重新查询未读 total。

遇到"红点不消",应按这个顺序查,而不是先改前端 Badge:

text 复制代码
抄送表 read_status
  → /bpm/process-instance/copy/view 请求
  → copyId 与登录 userId
  → 详情 onMounted 是否进入 isCopy
  → 工作台 onActivated 是否刷新

根因在哪一层,就修哪一层。数据没标读时不要在前端强行减 1;前端没刷新时也不要重写后端查询。


七、快速体验

在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)

推荐按这条路径体验:

  1. 进入流程中心,打开一个 SIMPLE 流程模型;
  2. 配置抄送节点,或在待办办理时选择抄送人;
  3. 用被抄送账号进入工作台,看"抄送我的"未读角标;
  4. 打开抄送列表,按未读筛选;
  5. 点单据编号进入只读详情,确认没有通过/拒绝;
  6. 返回工作台,确认未读角标减少;
  7. 再次打开同一条记录,确认 readTime 不被反复覆盖。

源码地址:


常见问题(FAQ)

Flowable 抄送已读会推进流程吗?

不会。抄送记录不属于 UserTask,标记已读只更新业务抄送表的 readStatusreadTime

为什么不能打开抄送列表就全部标为已读?

因为列表只证明用户到过入口,并不证明打开了某张业务单据。逐条详情标读更接近真实阅知行为。

为什么工作台要单独查询一次未读 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 支持一下!

相关推荐
程序猿乐锅1 小时前
【黑马点评 | 第四篇】Redis缓存雪崩
java·数据库·spring boot·redis·缓存
张小凡vip2 小时前
Java Web 设置 Session 超时时间:如何让 Session 永不失效
java·spring boot
考虑考虑11 小时前
Springboot环境变量占位符语法
spring boot·后端·spring
边境悍匪14 小时前
蜗牛学苑 Java 智能体学习 Day42|项目周开发技术汇总 1 思维导图复盘
java·开发语言·spring boot·学习·spring
志尊宝15 小时前
Vue3 零基础每日笔记(038):亲手封装 useDebounceFn 与 useThrottleFn——高频事件的流量阀
前端·javascript·vue·html·vue3
xcl092515 小时前
本地电竞服务交易系统架构设计与实战:从同城匹配到订单履约
java·spring boot
白宇横流学长18 小时前
基于springboot的个人博客系统的设计与实现【源码+文档】
java·spring boot
雪芽蓝域zzs1 天前
第 2 章:MySQL8.0 安装与基础配置
spring boot
行百里er1 天前
Spring Insight 里如何把 Span 画成瀑布时间线
spring boot·后端·监控