diff --git a/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java b/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java
index 5de73d0..3990227 100644
--- a/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java
+++ b/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java
@@ -1,10 +1,12 @@
package com.yunhai.svchall.controller.api;
import com.yunhai.platform.utils.model.BoaUser;
+import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
import com.yunhai.svchall.service.impl.ServiceApplyService;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -17,9 +19,10 @@ import javax.validation.Valid;
import java.util.Map;
/**
- * 用户面办件(/api/apply/**,CAS 登录):发起/重试/我的办件/详情。
- * 审批动作(撤回/催办/轨迹)由前端直调流程中心 /api/flow/*(同主域 CAS cookie),
- * 本控制器只管办事大厅侧的申请记录。
+ * 用户面办件(/api/apply/**,CAS 登录):发起/重试/重提/撤回/进度/流程图/我的办件/详情。
+ *
流程中心的用户面动作全部经本控制器中转(2026-09-16 治理):前端不再直调
+ * flow-center {@code /api/flow/**}——那需要跨应用 CAS 票据与 CORS,票据外溢且授权点落在前端。
+ * 后端经 FlowClient→SDK 门面出站(对端 appKey 验签,撤回另按 submitterId 复核身份)。
*/
@RestController
@RequestMapping("/api/apply")
@@ -40,6 +43,31 @@ public class ApplyUserController {
return applyService.retry(authUserWithDept, id);
}
+ /** 撤回(仅提交人本人 + 审批中 + 尚无任务被办理;后端经 SDK 调托管件) */
+ @PostMapping("/{id}/withdraw")
+ public void withdraw(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
+ applyService.withdraw(authUserWithDept, id);
+ }
+
+ /**
+ * 审批进度(结构化轨迹,时间线数据源):可见性=详情同口径三分支。
+ * 未进入流程的办件返回空 steps(前端按"待提交"渲染),不报错。
+ */
+ @GetMapping("/{id}/progress")
+ public SvcModels.FlowProgressVo progress(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
+ return applyService.progress(authUserWithDept, id);
+ }
+
+ /**
+ * 流程示意图(PNG 流,可见性=详情同口径):token 与字节都在服务端流转,浏览器只拿同源图片,
+ * 前端因此不需要知道 flow-center 地址,也不存在"img 带不了签名"的问题。
+ */
+ @GetMapping(value = "/{id}/diagram.png", produces = MediaType.IMAGE_PNG_VALUE)
+ public byte[] diagram(BoaUser authUserWithDept, @PathVariable("id") Integer id,
+ @RequestParam(value = "mode", required = false) String mode) {
+ return applyService.diagramImage(authUserWithDept, id, mode);
+ }
+
/** 一键重提(仅驳回/撤回态;复用同一 bizId,flow-center R4 重启推进,轨迹跨轮连续) */
@PostMapping("/{id}/resubmit")
public ApplyVo resubmit(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
diff --git a/src/main/java/com/yunhai/svchall/controller/api/HallController.java b/src/main/java/com/yunhai/svchall/controller/api/HallController.java
new file mode 100644
index 0000000..a54ac1f
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/controller/api/HallController.java
@@ -0,0 +1,63 @@
+package com.yunhai.svchall.controller.api;
+
+import com.yunhai.platform.utils.model.BoaUser;
+import com.yunhai.svchall.model.oapi.OapiModels;
+import com.yunhai.svchall.service.SiteDirectory;
+import com.yunhai.svchall.service.SiteGuard;
+import com.yunhai.svchall.service.impl.ServiceItemService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 公共办事大厅(用户面 /api/hall/**,CAS 登录):给学生/教师/访客申请用的"事项大厅"数据面,
+ * 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——出参直接复用
+ * {@link OapiModels.ListData} / {@link OapiModels.Filters},不另立 VO(形状单一事实源)。
+ *
+ * 与 oapi 的区别只有两点:①鉴权从"应用签名"换成"CAS + 本校成员",调用者是浏览器;
+ * ②只暴露已发布事项的展示字段,附件/明细等仍走 oapi(门户用)。
+ *
+ * 新建独立前端 {@code svc-hall-public}(学生/教师/访客办理入口)时,这就是它的列表/筛选数据源;
+ * 管理端(front)不使用本控制器。
+ */
+@RestController
+@RequestMapping("/api/hall")
+@RequiredArgsConstructor
+public class HallController {
+
+ private final ServiceItemService itemService;
+ private final SiteDirectory siteDirectory;
+ private final SiteGuard siteGuard;
+
+ /** 已发布事项分页(关键词/分类/服务对象/部门筛选;本校成员可见) */
+ @GetMapping("/items")
+ public OapiModels.ListData items(BoaUser authUserWithDept,
+ @RequestParam("siteCode") String siteCode,
+ @RequestParam(value = "objectKey", required = false) String objectKey,
+ @RequestParam(value = "categoryKey", required = false) String categoryKey,
+ @RequestParam(value = "deptGroupId", required = false) Integer deptGroupId,
+ @RequestParam(value = "keyword", required = false) String keyword,
+ @RequestParam(value = "page", required = false) Integer page,
+ @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+ SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode);
+ return itemService.oapiItems(site.getSchId(), site.getSiteCode(), objectKey, categoryKey,
+ deptGroupId, keyword, page, pageSize);
+ }
+
+ /** 筛选项(服务对象/分类/部门 + 已发布计数),供大厅左侧筛选区渲染 */
+ @GetMapping("/filters")
+ public OapiModels.Filters filters(BoaUser authUserWithDept,
+ @RequestParam("siteCode") String siteCode) {
+ SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode);
+ return itemService.oapiFilters(site.getSchId(), site.getSiteCode());
+ }
+
+ /** 本校成员 + 站点存在(与办件发起同一口径:只服务本校站点,不做跨校聚合) */
+ private SiteDirectory.SiteInfo requireMember(BoaUser user, String siteCode) {
+ SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
+ siteGuard.requireSchoolMember(user, site.getSchId());
+ return site;
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java
index fd1723b..610f0b5 100644
--- a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java
+++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java
@@ -6,6 +6,7 @@ import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -14,8 +15,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
- * 流程选择器(/api/svc/flow/**):事项编辑页"办理流程"数据源,代理流程中心
- * /oapi/flow/list。流程中心未接入(svchall.flow.enabled=false)时抛业务异常,
+ * 流程选择器与流程图(/api/svc/flow/**,管理面):事项编辑页"办理流程"数据源(代理流程中心
+ * /oapi/flow/list)+ 流程示意图出图。流程中心未接入(svchall.flow.enabled=false)时抛业务异常,
* 前端据此降级提示(事项只能配为线下/纯指南)。
*/
@RestController
@@ -33,4 +34,19 @@ public class SvcFlowController {
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
return flowClient.listFlows(site.getSchId());
}
+
+ /**
+ * 流程示意图(定义口径,PNG 流):事项编辑页"查看流程图"用当前选中的 flowGuid 直取。
+ * 原先该按钮 window.open 到 flow-center 前端 {@code /#/diagram?...}(跨应用页面 + 需要流程中心
+ * 会话);现改为后端换 token 取图、同源输出,前端只认 {@code
}。
+ */
+ @GetMapping(value = "/diagram.png", produces = MediaType.IMAGE_PNG_VALUE)
+ public byte[] diagram(BoaUser authUserWithDept,
+ @RequestParam("siteCode") String siteCode,
+ @RequestParam("flowGuid") String flowGuid,
+ @RequestParam(value = "mode", required = false) String mode) {
+ siteGuard.requireSiteManager(authUserWithDept, siteCode);
+ SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
+ return flowClient.diagramImage(site.getSchId(), null, flowGuid, mode);
+ }
}
diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
index 19331d4..ebb80a5 100644
--- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
+++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
@@ -436,6 +436,14 @@ public final class SvcModels {
private LocalDateTime submitTime;
private LocalDateTime finishTime;
private LocalDateTime createTime;
+ /**
+ * 流程中心是否已接入(响应期状态标志,不入库):只在 submit/retry/resubmit 的
+ * 出参上填。代发起是 AFTER_COMMIT 异步,故提交成功时 flowStatus 必然还是 0(待提交)——
+ * 单看 flowStatus 无法区分"已排队、秒级翻转"与"流程中心未接入/调用失败",
+ * 前端据此误报"流程中心暂不可用"(BP-R4-1)。有了本标志:
+ * enabled=false → 真的不会翻转,直接告知未接入;enabled=true 且 flowStatus=0 → 排队中,等待轮询。
+ */
+ private Boolean flowEnabled;
public static ApplyVo of(com.yunhai.svchall.entity.ServiceApply a) {
ApplyVo vo = new ApplyVo();
@@ -461,6 +469,11 @@ public final class SvcModels {
}
}
+ /**
+ * 用户面办理大厅的事项列表/筛选项形状,直接复用 oapi 出参 {@code OapiModels.ListData} /
+ * {@code Filters}(与 CMS 门户同源,不另立 VO)——见 {@code HallController}。
+ */
+
@Data
public static class ApplyPageQuery {
private String siteCode;
@@ -502,4 +515,22 @@ public final class SvcModels {
@lombok.EqualsAndHashCode(callSuper = true)
public static class CallbackRegVo extends com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult {
}
+
+ /**
+ * 办件进度(门面 {@code YschFlowOpenApi.progress} 的 R 绑定;字段单一事实源=SDK FlowProgress):
+ * steps 为"发起申请 + 各节点"时间线,前端直接渲染审批轨迹。
+ */
+ @Data
+ @lombok.EqualsAndHashCode(callSuper = true)
+ public static class FlowProgressVo extends com.yunhai.platform.openapi.flow.model.FlowProgress {
+ }
+
+ /**
+ * 流程示意图免签出图地址(门面 {@code YschFlowOpenApi.diagramUrl} 的 R 绑定)。
+ * 该出参只在服务端内部使用:token 由后端换取图片字节,不下发前端。
+ */
+ @Data
+ @lombok.EqualsAndHashCode(callSuper = true)
+ public static class FlowDiagramVo extends com.yunhai.platform.openapi.flow.model.FlowDiagramUrl {
+ }
}
diff --git a/src/main/java/com/yunhai/svchall/service/FlowClient.java b/src/main/java/com/yunhai/svchall/service/FlowClient.java
index 0650261..117fc7f 100644
--- a/src/main/java/com/yunhai/svchall/service/FlowClient.java
+++ b/src/main/java/com/yunhai/svchall/service/FlowClient.java
@@ -10,6 +10,10 @@ import java.util.Map;
* 默认实现经共享 IDHttp(出站自动签名,yh.app-auth 身份)调 flow-center /oapi/flow/**。
* enabled=false(本地未部署 flow-center)时所有方法抛业务异常,消费方按需降级
* (事项不绑流程=纯指南,不经过本接口)。
+ *
+ *
纪律(2026-09-16 治理):用户面动作一律由本接缝中转,前端不得直连
+ * flow-center——浏览器直调外部应用的 /api/flow/** 依赖跨应用 CAS 票据与 CORS,既是
+ * 票据外溢面,也让"谁能撤"落在前端。前端只认 /api/** 同源接口(CAS 保护)。
*/
public interface FlowClient {
@@ -37,6 +41,26 @@ public interface FlowClient {
/** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */
String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes);
+ /**
+ * 撤回(提交人本人;仅审批中且尚无任务被办理)。
+ * submitterId 由调用方按其办件归属传入,对端与办件提交人复核——应用签名不替代身份。
+ */
+ void withdraw(Long schId, Long instanceId, Integer submitterId);
+
+ /**
+ * 办件进度(结构化轨迹,时间线数据源):instanceId 优先,否则按 bizType+bizId 定位。
+ * 对端只校验 schId,调用方必须先做可见性守卫(本人/管理员/办理人)。
+ */
+ SvcModels.FlowProgressVo progress(Long schId, Long instanceId, String bizType, String bizId);
+
+ /**
+ * 流程示意图(PNG 字节):本接缝内完成"取 token → 取图"两步,浏览器只拿同源
+ * /api/** 图片流——免签 token 不落前端,前端也不需要知道 flow-center 地址。
+ *
+ * @param instanceId 办件口径(带运行时状态)与 flowGuid 定义口径二选一
+ */
+ byte[] diagramImage(Long schId, Long instanceId, String flowGuid, String mode);
+
/** 代发起结果 */
class ApplyResult {
private final Long instanceId;
diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
index 301a093..a738c21 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
@@ -186,6 +186,112 @@ public class FlowCenterClient implements FlowClient {
return String.valueOf(secret);
}
+ /**
+ * 撤回(托管件 POST /oapi/flow/instance/{id}/withdraw?schId=&submitterId=)。
+ * 对端按 submitterId 与办件提交人复核:应用签名只证明"哪个应用在调",不证明"谁在撤"。
+ */
+ @Override
+ public void withdraw(Long schId, Long instanceId, Integer submitterId) {
+ requireEnabled();
+ Map query = new HashMap<>();
+ query.put("schId", String.valueOf(schId));
+ query.put("submitterId", String.valueOf(submitterId));
+ if (directoryResolved) {
+ com.yunhai.platform.openapi.flow.YschFlowOpenApi.withdraw(schId, instanceId, submitterId);
+ return;
+ }
+ openCall.post(flowSpec, "/oapi/flow/instance/" + instanceId + "/withdraw", query, null, Void.class);
+ }
+
+ /** 办件进度(门面 progress / legacy GET;instanceId 优先,否则 bizType+bizId) */
+ @Override
+ public SvcModels.FlowProgressVo progress(Long schId, Long instanceId, String bizType, String bizId) {
+ requireEnabled();
+ if (directoryResolved) {
+ return com.yunhai.platform.openapi.flow.YschFlowOpenApi.progress(
+ instanceId, bizType, bizId, schId, SvcModels.FlowProgressVo.class);
+ }
+ Map query = new HashMap<>();
+ if (instanceId != null) {
+ query.put("instanceId", String.valueOf(instanceId));
+ } else {
+ query.put("bizType", bizType);
+ query.put("bizId", bizId);
+ }
+ query.put("schId", String.valueOf(schId));
+ return openCall.get(flowSpec, "/oapi/flow/progress", query, SvcModels.FlowProgressVo.class);
+ }
+
+ /**
+ * 流程示意图字节:先换免签 token(/oapi/flow/diagram/url,走应用签名),再取图
+ * (/open/flow/diagram/img,免签只认 token)。两步都在本方法内,token 不出后端。
+ *
+ * 取图走 {@code raw()}(预绑 flowcenter 的泛化视图)/ ad-hoc spec 的 download:目标基址解析
+ * 与签名由 SDK 统一承载,本类不拼 flow-center 地址——Caddy 没有 flow-center 的通用路由
+ * (Projman 只生成 {ctx}/api/* 与前端静态段),故浏览器侧不可达,必须由后端中转运图。
+ */
+ @Override
+ public byte[] diagramImage(Long schId, Long instanceId, String flowGuid, String mode) {
+ requireEnabled();
+ String imgUrl;
+ if (directoryResolved) {
+ com.yunhai.platform.openapi.flow.model.FlowDiagramUrl dto =
+ com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
+ instanceId, flowGuid, mode, schId, SvcModels.FlowDiagramVo.class);
+ imgUrl = dto == null ? null : dto.getImgUrl();
+ } else {
+ Map query = new HashMap<>();
+ if (instanceId != null) {
+ query.put("instanceId", String.valueOf(instanceId));
+ }
+ if (flowGuid != null && !flowGuid.isEmpty()) {
+ query.put("flowGuid", flowGuid);
+ }
+ if (mode != null && !mode.isEmpty()) {
+ query.put("mode", mode);
+ }
+ query.put("schId", String.valueOf(schId));
+ SvcModels.FlowDiagramVo dto =
+ openCall.get(flowSpec, "/oapi/flow/diagram/url", query, SvcModels.FlowDiagramVo.class);
+ imgUrl = dto == null ? null : dto.getImgUrl();
+ }
+ String token = tokenOf(imgUrl);
+ if (token == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
+ }
+ Map imgQuery = Collections.singletonMap("token", token);
+ byte[] bytes = directoryResolved
+ ? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
+ : openCall.download(flowSpec, DIAGRAM_IMG_PATH, imgQuery);
+ if (bytes == null || bytes.length == 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
+ }
+ return bytes;
+ }
+
+ /** 免签出图端点(flow-center OpenDiagramImageController,相对其 context-path) */
+ private static final String DIAGRAM_IMG_PATH = "/open/flow/diagram/img";
+
+ /**
+ * 从换图地址中取 token(形如 {@code /open/flow/diagram/img?token=xxx})。
+ * 包可见:契约测试冻结"token 只在后端流转、前端拿不到"的取用形态。
+ */
+ static String tokenOf(String imgUrl) {
+ if (imgUrl == null || imgUrl.isEmpty()) {
+ return null;
+ }
+ int q = imgUrl.indexOf("?token=");
+ if (q < 0) {
+ return null;
+ }
+ String token = imgUrl.substring(q + "?token=".length());
+ int amp = token.indexOf('&');
+ if (amp >= 0) {
+ token = token.substring(0, amp);
+ }
+ return token.isEmpty() ? null : token;
+ }
+
/**
* 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/callback/flow"
* (如 /svc-hall/callback/flow → /svc-hall;裸地址 /callback/flow → 空串)。
diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
index dfc7bdb..3da5c76 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
@@ -9,6 +9,7 @@ import com.yunhai.svchall.entity.ServiceForm;
import com.yunhai.svchall.entity.ServiceItem;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
+import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.model.svc.SvcModels.ApplyPageQuery;
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
@@ -126,7 +127,7 @@ public class ServiceApplyService {
apply = applyRepository.save(apply);
flowSubmitAfterCommit(apply.getId());
- return ApplyVo.of(apply);
+ return stampFlowEnabled(ApplyVo.of(apply));
}
/** 重试:PENDING 行复用同一 bizId 再次代发起(幂等);仅提交人本人可重试自己的办件(同步路径,无长事务) */
@@ -140,7 +141,7 @@ public class ServiceApplyService {
throw new BizException(ErrorCode.PARAM_ERROR, "该办件不在待提交状态,无需重试");
}
doFlowApply(applyId, false);
- return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply));
+ return stampFlowEnabled(applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply)));
}
/**
@@ -164,7 +165,84 @@ public class ServiceApplyService {
throw new BizException(ErrorCode.PARAM_ERROR, "该办件未绑定流程,不可重提");
}
doFlowApply(applyId, true);
- return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply));
+ return stampFlowEnabled(applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply)));
+ }
+
+ /**
+ * 撤回(用户面;2026-09-16 治理:原先前端直调 flow-center {@code /api/flow/instance/{id}/withdraw}
+ * ——跨应用 CAS 票据 + CORS 双重外溢,且"谁能撤"落在前端)。现由本服务中转:
+ * ①本应用内定位办件(同校 + 提交人本人 = 权威授权,也是列表自见规则);②状态须审批中(10) 且
+ * 已有流程实例;③经 FlowClient→SDK 门面调 flow-center 托管件(对端再按 submitterId 复核一次)。
+ * 撤回后的镜像状态由 flow.withdrawn 回调推进(40),本方法不改本地状态——单一写入口,
+ * 避免"本地先改 + 回调后到"的版本比较歧义。
+ */
+ public void withdraw(BoaUser user, Integer applyId) {
+ ServiceApply apply = requireOwnApply(user, applyId, "仅可撤回自己的办件");
+ if (apply.getFlowStatus() == null
+ || apply.getFlowStatus() != ApplyFlowStatusEnum.APPROVING.getCode()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回");
+ }
+ if (apply.getFlowInstanceId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "该办件尚未进入流程,无需撤回");
+ }
+ flowClient.withdraw(apply.getSchId(), apply.getFlowInstanceId(), apply.getSubmitterId());
+ }
+
+ /**
+ * 审批进度(结构化轨迹):可见性走与详情同一个守卫(本人/管理员/办理人三分支),
+ * 再按 instanceId 取流程中心进度——对端只校验 schId,故守卫必须在本地先做。
+ * 未进入流程的办件返回空轨迹(前端渲染"待提交"提示),不报错。
+ */
+ public SvcModels.FlowProgressVo progress(BoaUser user, Integer applyId) {
+ ServiceApply apply = requireVisibleApply(user, applyId);
+ if (apply.getFlowInstanceId() == null) {
+ SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo();
+ empty.setInstanceId(null);
+ empty.setBizType(FlowCallbackService.BIZ_TYPE);
+ empty.setBizId(String.valueOf(apply.getId()));
+ empty.setBizTitle(apply.getItemTitle());
+ empty.setStatus(apply.getFlowStatus());
+ empty.setSubmitterId(apply.getSubmitterId());
+ empty.setSubmitterName(apply.getSubmitterName());
+ empty.setSubmitTime(apply.getSubmitTime() == null ? null : apply.getSubmitTime().toString());
+ empty.setSteps(new ArrayList<>());
+ return empty;
+ }
+ return flowClient.progress(apply.getSchId(), apply.getFlowInstanceId(), null, null);
+ }
+
+ /**
+ * 流程示意图(PNG 流):同守卫;instanceId 口径(带运行时节点状态)。
+ * 出图两步(取免签 token → 取图字节)全部在本服务侧完成,浏览器只拿同源 {@code /api/**} 图片。
+ */
+ public byte[] diagramImage(BoaUser user, Integer applyId, String mode) {
+ ServiceApply apply = requireVisibleApply(user, applyId);
+ if (apply.getFlowInstanceId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "该办件尚未进入流程,无流程图");
+ }
+ return flowClient.diagramImage(apply.getSchId(), apply.getFlowInstanceId(), null, mode);
+ }
+
+ /** 本人办件(提交人校验;撤回/重提共用口径) */
+ private ServiceApply requireOwnApply(BoaUser user, Integer applyId, String forbiddenMsg) {
+ ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user))
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
+ if (!user.getId().equals(apply.getSubmitterId())) {
+ throw new BizException(ErrorCode.FORBIDDEN, forbiddenMsg);
+ }
+ return apply;
+ }
+
+ /** 可见办件(提交人本人 / 管理员 / 办理人;非超管) */
+ private ServiceApply requireVisibleApply(BoaUser user, Integer applyId) {
+ if (isSuperAdmin(user)) {
+ return applyRepository.findById(applyId)
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
+ }
+ ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user))
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
+ applyViewGuard.requireVisible(apply, user);
+ return apply;
}
/** 我的办件(提交人视角,flowStatus 可选筛选) */
@@ -378,6 +456,16 @@ public class ServiceApplyService {
// ===== 内部:快照与工具 =====
+ /**
+ * 提交类响应补"流程中心是否已接入"标志(BP-R4-1 误报根因,响应期字段不入库):
+ * 代发起是 AFTER_COMMIT 异步,提交成功那一刻 flowStatus 必然是 0;只有结合本标志,
+ * 前端才能区分"排队中(秒级翻转,等轮询)"与"压根没接入(不会翻转,需提示重试/联系人)"。
+ */
+ private ApplyVo stampFlowEnabled(ApplyVo vo) {
+ vo.setFlowEnabled(flowClient.enabled());
+ return vo;
+ }
+
/** 事项要点快照(= biz_snapshot 内容源;附件清单仅文件名——flow-center biz_snapshot 内容边界) */
private String itemSnapshotJson(ServiceItem item) {
return itemSnapshotJson(item, new ArrayList<>());
diff --git a/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java b/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java
index 8a5c715..13d27c7 100644
--- a/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java
+++ b/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -149,6 +150,100 @@ class FlowCenterClientFacadeTest {
org.junit.jupiter.api.Assertions.assertNotNull(client().getFlow(3L, "guid-1"));
}
+ @Test
+ @DisplayName("撤回:走门面 → /oapi/flow/instance/{id}/withdraw + schId/submitterId(对端复核身份)")
+ void withdraw_goesThroughFacade() {
+ client().withdraw(3L, 77L, 201);
+
+ ArgumentCaptor