From f0279ae12d1777b8f4f4c27d836a50397cd81949 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 17 Sep 2026 01:05:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(apply):=20=E7=94=A8=E6=88=B7=E9=9D=A2?= =?UTF-8?q?=E6=92=A4=E5=9B=9E/=E8=BF=9B=E5=BA=A6/=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=9B=BE=E5=90=8E=E7=AB=AF=E4=B8=AD=E8=BD=AC=20+=20=E5=A4=A7?= =?UTF-8?q?=E5=8E=85HallController=20+=20flowEnabled=20=E4=B8=89=E6=80=81?= =?UTF-8?q?=EF=BC=88SDK=20withdraw/progress/diagramUrl=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/ApplyUserController.java | 34 ++- .../controller/api/HallController.java | 63 +++++ .../controller/api/svc/SvcFlowController.java | 20 +- .../yunhai/svchall/model/svc/SvcModels.java | 31 +++ .../yunhai/svchall/service/FlowClient.java | 24 ++ .../service/impl/FlowCenterClient.java | 106 +++++++++ .../service/impl/ServiceApplyService.java | 94 +++++++- .../impl/FlowCenterClientFacadeTest.java | 95 ++++++++ .../impl/ServiceApplyUserActionTest.java | 216 ++++++++++++++++++ 9 files changed, 675 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/yunhai/svchall/controller/api/HallController.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/ServiceApplyUserActionTest.java 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> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).post(eq(TARGET), eq("/oapi/flow/instance/77/withdraw"), query.capture(), + isNull(), eq(Void.class)); + assertEquals("3", query.getValue().get("schId")); + assertEquals("201", query.getValue().get("submitterId")); + } + + @Test + @DisplayName("进度:走门面 → /oapi/flow/progress?instanceId=&schId=") + void progress_goesThroughFacade() { + SvcModels.FlowProgressVo stub = new SvcModels.FlowProgressVo(); + stub.setInstanceId(77L); + when(openCall.get(eq(TARGET), eq("/oapi/flow/progress"), anyMap(), + eq(SvcModels.FlowProgressVo.class))).thenReturn(stub); + + assertEquals(Long.valueOf(77L), client().progress(3L, 77L, null, null).getInstanceId()); + + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(eq(TARGET), eq("/oapi/flow/progress"), query.capture(), + eq(SvcModels.FlowProgressVo.class)); + assertEquals("77", query.getValue().get("instanceId")); + assertEquals("3", query.getValue().get("schId")); + } + + @Test + @DisplayName("流程图:换图 + 取图两步都在后端;token 不出后端(前端拿不到,也不需要 flow-center 地址)") + void diagramImage_tokenStaysInBackend() { + SvcModels.FlowDiagramVo url = new SvcModels.FlowDiagramVo(); + url.setImgUrl("/open/flow/diagram/img?token=tok-abc"); + url.setExpiresIn(300); + when(openCall.get(eq(TARGET), eq("/oapi/flow/diagram/url"), anyMap(), + eq(SvcModels.FlowDiagramVo.class))).thenReturn(url); + byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'}; + when(openCall.download(eq(TARGET), eq("/open/flow/diagram/img"), + eq(java.util.Collections.singletonMap("token", "tok-abc")))).thenReturn(png); + + assertEquals(png, client().diagramImage(3L, 77L, null, "dept")); + + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(eq(TARGET), eq("/oapi/flow/diagram/url"), query.capture(), + eq(SvcModels.FlowDiagramVo.class)); + assertEquals("77", query.getValue().get("instanceId")); + assertEquals("dept", query.getValue().get("mode")); + verify(openCall).download(eq(TARGET), eq("/open/flow/diagram/img"), + eq(java.util.Collections.singletonMap("token", "tok-abc"))); + } + + @Test + @DisplayName("tokenOf:只认 ?token= 段,容忍尾部其它参数;无 token 返回 null(不拼出脏地址)") + void tokenOf_parsesTokenOnly() { + assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc")); + assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc&x=1")); + assertEquals("abc", FlowCenterClient.tokenOf("http://h/flow-center/open/flow/diagram/img?token=abc")); + org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/open/flow/diagram/img")); + org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf(null)); + org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/x?token=")); + } + + @Test + @DisplayName("目录未配:撤回落 legacy base-url(ad-hoc TargetSpec POST,行为不变)") + void withdraw_fallsBackToLegacy() { + YschOpenCall legacyCall = mock(YschOpenCall.class); + when(legacyCall.hasTarget(TARGET)).thenReturn(false); + + new FlowCenterClient(legacyCall, objectMapper, true, LEGACY_BASE).withdraw(3L, 77L, 201); + + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(legacyCall).post(any(com.yunhai.platform.openapi.core.TargetSpec.class), + eq("/oapi/flow/instance/77/withdraw"), query.capture(), isNull(), eq(Void.class)); + assertEquals("201", query.getValue().get("submitterId")); + } + + @Test + @DisplayName("enabled=false:三个新方法一律抛「未接入」(不静默成功让前端误判)") + void disabledClient_rejectsNewActions() { + YschOpenCall noDirectory = mock(YschOpenCall.class); + when(noDirectory.hasTarget(TARGET)).thenReturn(false); + FlowCenterClient c = new FlowCenterClient(noDirectory, objectMapper, false, ""); + + com.yunhai.platform.apistd.BizException withdrawEx = + org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class, + () -> c.withdraw(3L, 77L, 201)); + org.junit.jupiter.api.Assertions.assertTrue(withdrawEx.getMessage().contains("流程中心未接入")); + org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class, + () -> c.progress(3L, 77L, null, null)); + org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class, + () -> c.diagramImage(3L, 77L, null, null)); + } + @Test @DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)") void fallsBackToLegacyWhenDirectoryMissing() { diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyUserActionTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyUserActionTest.java new file mode 100644 index 0000000..4f642c2 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyUserActionTest.java @@ -0,0 +1,216 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.ApplyViewGuard; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 用户面流程动作单测(BP-R4-1/BP-R4-2,2026-09-16): + * ①撤回改由后端经 SDK 中转(前端不再直调 flow-center),授权点在本地——提交人本人 + 审批中 + 有实例; + * ②进度/流程图与详情同守卫(三分支),未进流程不报错; + * ③提交类响应带 flowEnabled 标志,让前端能区分"排队中"与"流程中心没接入"。 + */ +class ServiceApplyUserActionTest { + + private ServiceApplyRepository applyRepository; + private FlowClient flowClient; + private ApplyViewGuard applyViewGuard; + private ServiceApplyService service; + private ServiceApply apply; + + @BeforeEach + void setUp() { + applyRepository = mock(ServiceApplyRepository.class); + ServiceItemRepository itemRepository = mock(ServiceItemRepository.class); + ServiceFormService formService = mock(ServiceFormService.class); + AttachmentService attachmentService = mock(AttachmentService.class); + flowClient = mock(FlowClient.class); + SiteGuard siteGuard = mock(SiteGuard.class); + SiteDirectory siteDirectory = mock(SiteDirectory.class); + applyViewGuard = mock(ApplyViewGuard.class); + service = new ServiceApplyService(applyRepository, itemRepository, formService, + attachmentService, flowClient, siteGuard, siteDirectory, applyViewGuard, + new ObjectMapper(), + new org.springframework.transaction.support.TransactionTemplate( + mock(org.springframework.transaction.PlatformTransactionManager.class))); + + apply = new ServiceApply(); + ReflectionTestUtils.setField(apply, "id", 27); + apply.setSchId(1L); + apply.setSiteCode("S1"); + apply.setItemTitle("教师入职"); + apply.setSubmitterId(9); + apply.setSubmitterName("刘文娟"); + apply.setFlowGuid("fg-1"); + apply.setFlowInstanceId(9011L); + apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); + when(applyRepository.findByIdAndSchId(27, 1L)).thenReturn(Optional.of(apply)); + when(applyRepository.findById(27)).thenReturn(Optional.of(apply)); + when(flowClient.enabled()).thenReturn(true); + } + + private BoaUser user(int id, int schId) { + BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(user.getId()).thenReturn(id); + when(user.getCurrentRole().getSchId()).thenReturn(schId); + return user; + } + + // ===== 撤回 ===== + + @Test + @DisplayName("撤回:提交人本人 + 审批中 → 经 FlowClient 出站(schId/instanceId/submitterId 全带)") + void withdraw_byOwner_goesThroughFlowClient() { + service.withdraw(user(9, 1), 27); + verify(flowClient).withdraw(eq(1L), eq(9011L), eq(9)); + } + + @Test + @DisplayName("撤回:非提交人 → FORBIDDEN,且不出站(授权点在后端,前端拿不到别人的办件 id 也没用)") + void withdraw_nonOwner_rejectedWithoutOutbound() { + BizException e = assertThrows(BizException.class, () -> service.withdraw(user(8, 1), 27)); + assertEquals("仅可撤回自己的办件", e.getMessage()); + verify(flowClient, never()).withdraw(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("撤回:非审批中(已撤回/已完成)→ 拒绝,不出站") + void withdraw_nonApproving_rejected() { + apply.setFlowStatus(ApplyFlowStatusEnum.WITHDRAWN.getCode()); + assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27)); + verify(flowClient, never()).withdraw(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("撤回:待提交(无实例)→ 拒绝并说明原因,不出站") + void withdraw_pendingWithoutInstance_rejected() { + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + apply.setFlowInstanceId(null); + BizException e = assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27)); + assertTrue(e.getMessage().contains("可撤回"), e.getMessage()); + verify(flowClient, never()).withdraw(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("撤回:流程中心拒绝(已有任务被办理)→ 业务异常原样透传给前端") + void withdraw_flowCenterRejection_propagates() { + // void 方法用 doThrow 桩(when(...) 对 void 不合法) + org.mockito.Mockito.doThrow(new BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回")) + .when(flowClient).withdraw(anyLong(), anyLong(), any()); + BizException e = assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27)); + assertEquals("已有任务被办理,不能撤回", e.getMessage()); + } + + // ===== 进度 ===== + + @Test + @DisplayName("进度:先过可见性守卫,再按 instanceId 取(对端只校验 schId,守卫必须在本地)") + void progress_guardsThenDelegates() { + SvcModels.FlowProgressVo vo = new SvcModels.FlowProgressVo(); + vo.setInstanceId(9011L); + when(flowClient.progress(eq(1L), eq(9011L), isNull(), isNull())).thenReturn(vo); + + assertEquals(vo, service.progress(user(9, 1), 27)); + verify(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class)); + verify(flowClient).progress(eq(1L), eq(9011L), isNull(), isNull()); + } + + @Test + @DisplayName("进度:守卫拒绝时不出站(他人办件查不到轨迹)") + void progress_guardRejection_blocksOutbound() { + org.mockito.Mockito.doThrow(new BizException( + com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "办件不存在或无权查看")) + .when(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class)); + assertThrows(BizException.class, () -> service.progress(user(8, 1), 27)); + verify(flowClient, never()).progress(anyLong(), any(), any(), any()); + } + + @Test + @DisplayName("进度:未进流程(待提交)→ 返回空 steps 不报错(前端渲染待提交)") + void progress_notSubmitted_returnsEmptySteps() { + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + apply.setFlowInstanceId(null); + SvcModels.FlowProgressVo vo = service.progress(user(9, 1), 27); + assertTrue(vo.getSteps().isEmpty()); + assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getStatus()); + assertEquals("27", vo.getBizId(), "bizId 口径 = 办件 id(与代发起同键)"); + assertNull(vo.getInstanceId()); + verify(flowClient, never()).progress(anyLong(), any(), any(), any()); + } + + // ===== 流程图 ===== + + @Test + @DisplayName("流程图:可见性通过后返回 PNG 字节(token 换图在 FlowClient 内部完成)") + void diagram_delegatesToFlowClient() { + byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'}; + when(flowClient.diagramImage(eq(1L), eq(9011L), isNull(), eq("dept"))).thenReturn(png); + assertEquals(png, service.diagramImage(user(9, 1), 27, "dept")); + verify(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class)); + } + + @Test + @DisplayName("流程图:未进流程 → 明确报错(不返回空图骗前端)") + void diagram_notSubmitted_rejected() { + apply.setFlowInstanceId(null); + assertThrows(BizException.class, () -> service.diagramImage(user(9, 1), 27, null)); + verify(flowClient, never()).diagramImage(anyLong(), any(), any(), any()); + } + + // ===== flowEnabled 契约(BP-R4-1 误报根因) ===== + + @Test + @DisplayName("retry:响应带 flowEnabled=true(已接入则 flowStatus=0 只是排队,不是失败)") + void retry_stampsFlowEnabledTrue() { + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + when(flowClient.apply(anyLong(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new FlowClient.ApplyResult(9011L, false)); + + SvcModels.ApplyVo vo = service.retry(user(9, 1), 27); + + assertTrue(vo.getFlowEnabled(), "前端据此判断'排队中'而非'流程中心不可用'"); + verify(applyRepository).markApproving(eq(27), eq(9011L), any()); + } + + @Test + @DisplayName("retry:enabled=false 时响应 flowEnabled=false(真的不会翻转,前端提示未接入)") + void retry_stampsFlowEnabledFalseWhenDisabled() { + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + when(flowClient.enabled()).thenReturn(false); + + SvcModels.ApplyVo vo = service.retry(user(9, 1), 27); + + assertFalse(vo.getFlowEnabled()); + verify(flowClient, never()).apply(anyLong(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } +}