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
@@ -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<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"
* (如 /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.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<>());