feat(apply): 用户面撤回/进度/流程图后端中转 + 大厅HallController + flowEnabled 三态(SDK withdraw/progress/diagramUrl)
build / build-push (push) Successful in 1m11s

This commit is contained in:
chenxing
2026-09-17 01:05:35 +08:00
parent e462b71bef
commit f0279ae12d
9 changed files with 675 additions and 8 deletions
@@ -1,10 +1,12 @@
package com.yunhai.svchall.controller.api; package com.yunhai.svchall.controller.api;
import com.yunhai.platform.utils.model.BoaUser; 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.ApplySubmitRequest;
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
import com.yunhai.svchall.service.impl.ServiceApplyService; import com.yunhai.svchall.service.impl.ServiceApplyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -17,9 +19,10 @@ import javax.validation.Valid;
import java.util.Map; import java.util.Map;
/** /**
* 用户面办件(/api/apply/**,CAS 登录):发起/重试/我的办件/详情。 * 用户面办件(/api/apply/**,CAS 登录):发起/重试/重提/撤回/进度/流程图/我的办件/详情。
* 审批动作(撤回/催办/轨迹)由前端直调流程中心 /api/flow/*(同主域 CAS cookie), * <p>流程中心的<b>用户面动作全部经本控制器中转</b>(2026-09-16 治理):前端不再直调
* 本控制器只管办事大厅侧的申请记录。 * flow-center {@code /api/flow/**}——那需要跨应用 CAS 票据与 CORS,票据外溢且授权点落在前端。
* 后端经 FlowClient→SDK 门面出站(对端 appKey 验签,撤回另按 submitterId 复核身份)。
*/ */
@RestController @RestController
@RequestMapping("/api/apply") @RequestMapping("/api/apply")
@@ -40,6 +43,31 @@ public class ApplyUserController {
return applyService.retry(authUserWithDept, id); 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 重启推进,轨迹跨轮连续) */ /** 一键重提(仅驳回/撤回态;复用同一 bizId,flow-center R4 重启推进,轨迹跨轮连续) */
@PostMapping("/{id}/resubmit") @PostMapping("/{id}/resubmit")
public ApplyVo resubmit(BoaUser authUserWithDept, @PathVariable("id") Integer id) { public ApplyVo resubmit(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
@@ -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(形状单一事实源)。
* <p>
* 与 oapi 的区别只有两点:①鉴权从"应用签名"换成"CAS + 本校成员",调用者是浏览器;
* ②只暴露已发布事项的展示字段,附件/明细等仍走 oapi(门户用)。
* <p>
* 新建独立前端 {@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;
}
}
@@ -6,6 +6,7 @@ import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard; import com.yunhai.svchall.service.SiteGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -14,8 +15,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** /**
* 流程选择器(/api/svc/flow/**):事项编辑页"办理流程"数据源,代理流程中心 * 流程选择器与流程图(/api/svc/flow/**,管理面):事项编辑页"办理流程"数据源(代理流程中心
* /oapi/flow/list。流程中心未接入(svchall.flow.enabled=false)时抛业务异常, * /oapi/flow/list)+ 流程示意图出图。流程中心未接入(svchall.flow.enabled=false)时抛业务异常,
* 前端据此降级提示(事项只能配为线下/纯指南)。 * 前端据此降级提示(事项只能配为线下/纯指南)。
*/ */
@RestController @RestController
@@ -33,4 +34,19 @@ public class SvcFlowController {
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
return flowClient.listFlows(site.getSchId()); return flowClient.listFlows(site.getSchId());
} }
/**
* 流程示意图(定义口径,PNG 流):事项编辑页"查看流程图"用当前选中的 flowGuid 直取。
* 原先该按钮 window.open 到 flow-center 前端 {@code /#/diagram?...}(跨应用页面 + 需要流程中心
* 会话);现改为后端换 token 取图、同源输出,前端只认 {@code <img src="/api/...">}。
*/
@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);
}
} }
@@ -436,6 +436,14 @@ public final class SvcModels {
private LocalDateTime submitTime; private LocalDateTime submitTime;
private LocalDateTime finishTime; private LocalDateTime finishTime;
private LocalDateTime createTime; private LocalDateTime createTime;
/**
* 流程中心是否已接入(<b>响应期状态标志,不入库</b>):只在 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) { public static ApplyVo of(com.yunhai.svchall.entity.ServiceApply a) {
ApplyVo vo = new ApplyVo(); ApplyVo vo = new ApplyVo();
@@ -461,6 +469,11 @@ public final class SvcModels {
} }
} }
/**
* 用户面办理大厅的事项列表/筛选项形状,直接复用 oapi 出参 {@code OapiModels.ListData} /
* {@code Filters}(与 CMS 门户同源,不另立 VO)——见 {@code HallController}。
*/
@Data @Data
public static class ApplyPageQuery { public static class ApplyPageQuery {
private String siteCode; private String siteCode;
@@ -502,4 +515,22 @@ public final class SvcModels {
@lombok.EqualsAndHashCode(callSuper = true) @lombok.EqualsAndHashCode(callSuper = true)
public static class CallbackRegVo extends com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult { 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 {
}
} }
@@ -10,6 +10,10 @@ import java.util.Map;
* 默认实现经共享 IDHttp(出站自动签名,yh.app-auth 身份)调 flow-center /oapi/flow/**。 * 默认实现经共享 IDHttp(出站自动签名,yh.app-auth 身份)调 flow-center /oapi/flow/**。
* enabled=false(本地未部署 flow-center)时所有方法抛业务异常,消费方按需降级 * enabled=false(本地未部署 flow-center)时所有方法抛业务异常,消费方按需降级
* (事项不绑流程=纯指南,不经过本接口)。 * (事项不绑流程=纯指南,不经过本接口)。
*
* <p>纪律(2026-09-16 治理):<b>用户面动作一律由本接缝中转</b>,前端不得直连
* flow-center——浏览器直调外部应用的 /api/flow/** 依赖跨应用 CAS 票据与 CORS,既是
* 票据外溢面,也让"谁能撤"落在前端。前端只认 /api/** 同源接口(CAS 保护)。
*/ */
public interface FlowClient { public interface FlowClient {
@@ -37,6 +41,26 @@ public interface FlowClient {
/** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */ /** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */
String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes); String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes);
/**
* 撤回(提交人本人;仅审批中且尚无任务被办理)。
* submitterId 由调用方按其办件归属传入,对端与办件提交人复核——应用签名不替代身份。
*/
void withdraw(Long schId, Long instanceId, Integer submitterId);
/**
* 办件进度(结构化轨迹,时间线数据源):instanceId 优先,否则按 bizType+bizId 定位。
* 对端只校验 schId,<b>调用方必须先做可见性守卫</b>(本人/管理员/办理人)。
*/
SvcModels.FlowProgressVo progress(Long schId, Long instanceId, String bizType, String bizId);
/**
* 流程示意图(PNG 字节):本接缝内完成"取 token → 取图"两步,浏览器只拿同源
* <code>/api/**</code> 图片流——免签 token 不落前端,前端也不需要知道 flow-center 地址。
*
* @param instanceId 办件口径(带运行时状态)与 flowGuid 定义口径二选一
*/
byte[] diagramImage(Long schId, Long instanceId, String flowGuid, String mode);
/** 代发起结果 */ /** 代发起结果 */
class ApplyResult { class ApplyResult {
private final Long instanceId; private final Long instanceId;
@@ -186,6 +186,112 @@ public class FlowCenterClient implements FlowClient {
return String.valueOf(secret); 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<String, String> 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<String, String> 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 不出后端。
* <p>
* 取图走 {@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<String, String> 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<String, String> 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" * 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/callback/flow"
* (如 /svc-hall/callback/flow → /svc-hall;裸地址 /callback/flow → 空串)。 * (如 /svc-hall/callback/flow → /svc-hall;裸地址 /callback/flow → 空串)。
@@ -9,6 +9,7 @@ import com.yunhai.svchall.entity.ServiceForm;
import com.yunhai.svchall.entity.ServiceItem; import com.yunhai.svchall.entity.ServiceItem;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum; import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.enums.ServiceItemStatusEnum; 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.ApplyPageQuery;
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest; import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
@@ -126,7 +127,7 @@ public class ServiceApplyService {
apply = applyRepository.save(apply); apply = applyRepository.save(apply);
flowSubmitAfterCommit(apply.getId()); flowSubmitAfterCommit(apply.getId());
return ApplyVo.of(apply); return stampFlowEnabled(ApplyVo.of(apply));
} }
/** 重试:PENDING 行复用同一 bizId 再次代发起(幂等);仅提交人本人可重试自己的办件(同步路径,无长事务) */ /** 重试:PENDING 行复用同一 bizId 再次代发起(幂等);仅提交人本人可重试自己的办件(同步路径,无长事务) */
@@ -140,7 +141,7 @@ public class ServiceApplyService {
throw new BizException(ErrorCode.PARAM_ERROR, "该办件不在待提交状态,无需重试"); throw new BizException(ErrorCode.PARAM_ERROR, "该办件不在待提交状态,无需重试");
} }
doFlowApply(applyId, false); 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, "该办件未绑定流程,不可重提"); throw new BizException(ErrorCode.PARAM_ERROR, "该办件未绑定流程,不可重提");
} }
doFlowApply(applyId, true); 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 可选筛选) */ /** 我的办件(提交人视角,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 内容边界) */ /** 事项要点快照(= biz_snapshot 内容源;附件清单仅文件名——flow-center biz_snapshot 内容边界) */
private String itemSnapshotJson(ServiceItem item) { private String itemSnapshotJson(ServiceItem item) {
return itemSnapshotJson(item, new ArrayList<>()); return itemSnapshotJson(item, new ArrayList<>());
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -149,6 +150,100 @@ class FlowCenterClientFacadeTest {
org.junit.jupiter.api.Assertions.assertNotNull(client().getFlow(3L, "guid-1")); 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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> 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 @Test
@DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)") @DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)")
void fallsBackToLegacyWhenDirectoryMissing() { void fallsBackToLegacyWhenDirectoryMissing() {
@@ -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());
}
}