feat(p3.1): 应用内审批后端代理——SvcApprovalClient + /api/apply/approval/ 7 端点
build / build-push (push) Successful in 54s
build / build-push (push) Successful in 54s
- SvcBizTypes:bizType 终值/存量单一取值点(svc-hall=H-1 实核 appKey=100008 注册行 app_code; svc_item=存量,双值双查先新后旧——方案B,P3.2 落地前的读面兼容口径) - SvcApprovalClient(沿 NoticeApprovalClient 形态,YschOpenCall 服务目录命名目标): todo/byBiz 双值双查合并、approve/reject 写面、embed 记录页 instanceId 形态、taskPageUrl - SvcApprovalController:Todo/ByBiz/RecordUrl/TaskPageUrl/Approve/Reject/My · 定位链=requireOperator→findByIdAndSchId(会话校)→ApplyViewGuard→byBiz 归属预检(P1-01) · 身份三字段只取 CAS 会话,请求参数出现身份字段一律拒(P1-10 择一取严) · 写面参数形态与中心同构(taskId/applyId 查询参数,opinion/edgeName JSON body) · My 端点复用本地 myApplies + FlowPage 信封显式映射(total/list→total/content+页元数据) · 不代理 /oapi/flow/instances(全校视角);本地 flow_status 仍只由回调推进 - 测试 30 例全绿(client 10 + MockMvc standalone 20,显式装 BoaUserArgumentResolver/ GlobalExceptionHandler/ApiResponseAdvice;真实 CasSsoApiRedirectFilter 过滤链未登录=200+code302; 拒绝路径断言审批出站=0);全量回归 157/157 绿(Stage S2/P3.1)
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.ApplyViewGuard;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import com.yunhai.svchall.service.impl.SvcApprovalClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批代理(P3.1,/api/apply/approval/**,CAS 登录):hall 前端被验签墙挡住不能直调
|
||||
* flow-center oapi,读面(列表/卡片/换址)与写面(通过/驳回)全部经本控制器中转。
|
||||
* <p>
|
||||
* 安全口径(沿 NoticeApprovalClient 范本 + svc-hall 加严,R3.0 P1-01/P1-10 择一取严):
|
||||
* <ul>
|
||||
* <li>身份三字段(operatorId/operatorName/schId)只取 CAS 会话(BoaUser 解析器);
|
||||
* <b>请求参数出现身份字段一律拒绝</b>,防注入改变出站身份;</li>
|
||||
* <li>定位链:requireOperator → findByIdAndSchId(applyId, 会话 schId) → ApplyViewGuard。
|
||||
* 新代理不做超管跨校读(既有 /api/apply/{id} 的超管 findById 分支不带入本代理);
|
||||
* 超管可见(Guard 管理员分支放行)<b>≠可办</b>——写面 byBiz 归属预检对任何身份都不省略;</li>
|
||||
* <li>写面双闸:requireVisible 先行 + byBiz 确认 taskId ∈ 当前人<b>该办件</b>的待办;</li>
|
||||
* <li>参数形态与中心端点同构:taskId/applyId 放 URL 查询参数,opinion/edgeName 放 JSON body;</li>
|
||||
* <li>不代理 /oapi/flow/instances(全校视角,不暴露给用户面)。</li>
|
||||
* </ul>
|
||||
* 本地 flow_status 一律由既有回调链推进,本控制器不写本地状态。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/apply/approval/")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcApprovalController {
|
||||
|
||||
/** 请求参数里的身份字段黑名单(出现即拒——身份只能来自会话) */
|
||||
private static final List<String> IDENTITY_PARAMS =
|
||||
java.util.Arrays.asList("operatorId", "operatorName", "schId", "assigneeId");
|
||||
|
||||
private final SvcApprovalClient approvalClient;
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ApplyViewGuard applyViewGuard;
|
||||
private final ServiceApplyService applyService;
|
||||
|
||||
/** 待我审批(/todo 页"待办"页签数据源;双值双查在 client 内合并) */
|
||||
@GetMapping("Todo")
|
||||
public Map<String, Object> todo(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
return approvalClient.todoTasks(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在该办件上的待办(详情卡片数据源,0/1/N 均 200 空 tasks)。
|
||||
* 先按会话学校定位办件行再过 Guard——跨校行在此即"办件不存在"。
|
||||
*/
|
||||
@GetMapping("ByBiz")
|
||||
public Map<String, Object> byBiz(@RequestParam("applyId") Integer applyId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
return approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批记录抽屉地址(token 只读页,instanceId 形态):本地 flow_instance_id 有值才可能换址——
|
||||
* 未进流程(待提交/发起失败)直接业务错,不打中心。
|
||||
*/
|
||||
@GetMapping("RecordUrl")
|
||||
public Map<String, Object> recordUrl(@RequestParam("applyId") Integer applyId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
if (apply.getFlowInstanceId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件尚未进入流程,暂无审批记录");
|
||||
}
|
||||
return approvalClient.embedRecordUrl(op.schId, apply.getFlowInstanceId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整办理任务页地址(CAS 会话页,无 token):先打 byBiz 确认 taskId 确实在<b>当前人</b>
|
||||
* 该办件的待办里才签发——顺手签发他人 taskId 等于替他人挂办理入口;对端仍按 assigneeId 复核。
|
||||
*/
|
||||
@GetMapping("TaskPageUrl")
|
||||
public Map<String, Object> taskPageUrl(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
return approvalClient.taskPageUrl(op.schId, taskId);
|
||||
}
|
||||
|
||||
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
|
||||
@PostMapping("Approve")
|
||||
public void approve(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
approvalClient.approve(taskId, op.schId, op.userId, op.name,
|
||||
body == null ? null : body.get("opinion"),
|
||||
body == null ? null : body.get("edgeName"));
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填,前置校验给前端更快反馈;对端仍会强制) */
|
||||
@PostMapping("Reject")
|
||||
public void reject(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
if (opinion == null || opinion.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "驳回必须填写意见");
|
||||
}
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
approvalClient.reject(taskId, op.schId, op.userId, op.name, opinion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我提交的(/todo 页"我提交的"页签数据源):不代理中心——复用本地
|
||||
* {@link ServiceApplyService#myApplies}(数据带事项名/快照等业务字段,列表体验完整;
|
||||
* 中心 instances 是全校视角不可用)。信封显式映射 {@code {total,list}} → 组件
|
||||
* {@code FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext}},参数名
|
||||
* pageSize→size 由本端消化。
|
||||
*/
|
||||
@GetMapping("My")
|
||||
public Map<String, Object> my(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
int pageIndex = page == null || page < 1 ? 1 : page;
|
||||
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
|
||||
Map<String, Object> raw = applyService.myApplies(user, null, pageIndex, pageSize);
|
||||
long total = raw.get("total") instanceof Number ? ((Number) raw.get("total")).longValue() : 0L;
|
||||
Object content = raw.get("list");
|
||||
long totalPages = (total + pageSize - 1) / pageSize;
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("total", total);
|
||||
out.put("content", content);
|
||||
out.put("pageIndex", pageIndex);
|
||||
out.put("pageSize", pageSize);
|
||||
out.put("totalPages", totalPages);
|
||||
out.put("hasNext", pageIndex < totalPages);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 定位链中段:按会话学校取行(跨校=不存在)→ Guard 三分支(提交人/管理员/办理人) */
|
||||
private ServiceApply requireVisibleApply(Integer applyId, Operator op, BoaUser user) {
|
||||
ServiceApply apply = applyRepository.findByIdAndSchId(applyId, op.schId.longValue())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
applyViewGuard.requireVisible(apply, user);
|
||||
return apply;
|
||||
}
|
||||
|
||||
/** byBiz 出参里是否含该 taskId(tasks 元素为 {taskId,nodeId,nodeName,arriveTime,outlets}) */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void requireMyPendingTask(ServiceApply apply, Long taskId, Operator op) {
|
||||
Map<String, Object> data = approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId()));
|
||||
Object tasks = data == null ? null : data.get("tasks");
|
||||
boolean mine = false;
|
||||
if (tasks instanceof List) {
|
||||
for (Object row : (List<Object>) tasks) {
|
||||
if (row instanceof Map) {
|
||||
Object id = ((Map<String, Object>) row).get("taskId");
|
||||
if (id instanceof Number && taskId.equals(((Number) id).longValue())) {
|
||||
mine = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mine) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该任务不是你在本办件上的待办");
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求参数出现身份字段一律拒绝(身份只能来自 CAS 会话,不接收任何注入) */
|
||||
private static void rejectIdentityParams(Map<String, String> rawParams) {
|
||||
if (rawParams == null) {
|
||||
return;
|
||||
}
|
||||
for (String key : rawParams.keySet()) {
|
||||
if (IDENTITY_PARAMS.contains(key)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"不允许在请求参数中携带身份字段:" + key + "(身份取自登录会话)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录态提参(沿 NoticeApprovalController 口径):登录 → 学校归属 → 姓名兜底账号 */
|
||||
private static Operator requireOperator(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
|
||||
}
|
||||
String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount();
|
||||
return new Operator(user.getId(), user.getCurrentRole().getSchId(), name, user);
|
||||
}
|
||||
|
||||
private static final class Operator {
|
||||
final Integer userId;
|
||||
final Integer schId;
|
||||
final String name;
|
||||
final BoaUser user;
|
||||
|
||||
Operator(Integer userId, Integer schId, String name, BoaUser user) {
|
||||
this.userId = userId;
|
||||
this.schId = schId;
|
||||
this.name = name;
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* svc-hall 流程业务类型(bizType)终值与存量兼容口径(P3.2 方案B,用户裁决 2026-10-19)。
|
||||
* <p>
|
||||
* 终值以 S0-H1 实核为准:UserBase application_info appKey=100008(svc-hall 生产出站身份)
|
||||
* 注册行 app_code = {@code svc-hall}。存量值 {@code svc_item} 是 2026-10-19 前建件的出站值;
|
||||
* S0-H2 实测中心 flw_instance 已无 svc_item 行,读路径双查属防御性兼容(防未来存量事件重放/
|
||||
* 重提),收敛条件=另一类型不再可能产生事件(覆盖 0 态重试/30/40 重提/积压核清),本轮不收敛单值。
|
||||
*/
|
||||
public final class SvcBizTypes {
|
||||
|
||||
/** 终值(新件出站 + 读路径首查键) */
|
||||
public static final String CURRENT = "svc-hall";
|
||||
|
||||
/** 存量值(2026-10-19 前建件;读路径次查键,回调白名单双值之一) */
|
||||
public static final String LEGACY = "svc_item";
|
||||
|
||||
private SvcBizTypes() {
|
||||
}
|
||||
|
||||
/** 读路径双查序:先终值后存量,结果按序合并(方案B 双值双查补丁的统一取值点) */
|
||||
public static List<String> queryOrder() {
|
||||
return Arrays.asList(CURRENT, LEGACY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批出站客户端(P3.1,沿 NoticeApprovalClient 形态):YschOpenCall 泛化调用打
|
||||
* flow-center {@code /oapi/flow/tasks|task|embed/**},走服务目录命名目标 flow-center
|
||||
* (yh.call.targets > yh.open.services,与 {@link FlowCenterClient} 同一条解析链)。
|
||||
* <p>
|
||||
* 与 Notice 的差异(P3.1 加严):
|
||||
* <ul>
|
||||
* <li>读面 bizType 不固定单值——{@link SvcBizTypes#queryOrder()} 双值双查(先终值后存量,
|
||||
* P3.2 方案B,防存量事件/重提漏查);</li>
|
||||
* <li>embed 记录页走 <b>instanceId</b> 形态(本地 flow_instance_id 镜像有值才可能换址成功,
|
||||
* bizType+bizId 形态在 bizType 换值后对存量件必然"办件不存在")。</li>
|
||||
* </ul>
|
||||
* 身份口径:operatorId/operatorName/schId 由调用方(controller)从 CAS 会话取,本类不猜身份;
|
||||
* 对端按任务办理人逐字复核。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SvcApprovalClient {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOW_CENTER.key();
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final FlowClient flowClient;
|
||||
|
||||
public SvcApprovalClient(ObjectProvider<YschOpenCall> openCallProvider, FlowClient flowClient) {
|
||||
this.openCall = openCallProvider.getIfAvailable();
|
||||
this.flowClient = flowClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的待办分页:双值双查后合并(同一 assignee 在两个 bizType 下都可能有历史待办的过渡期口径;
|
||||
* S0-H2 实测存量=0,合并退化为单查结果)。Map 形态即 flow-center BasePageResult JSON。
|
||||
*/
|
||||
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
List<Map<String, Object>> pages = new ArrayList<>();
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("assigneeId", String.valueOf(assigneeId));
|
||||
query.put("bizType", bizType);
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
pages.add(openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class));
|
||||
}
|
||||
return mergePages(pages, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在该办件上的待办(卡片数据源,{@code /oapi/flow/tasks/by-biz})。
|
||||
* <p>双值双查:一个 apply 行只可能命中其中一个 bizType 的实例——按序返回首个
|
||||
* {@code instanceId != null} 的应答;两者皆空(无实例/非办理人/错学校同形)返回首查(终值)
|
||||
* 应答,保持对端"空 tasks 不探测"语义。调用方不得据 tasks 反推他人待办。
|
||||
*/
|
||||
public Map<String, Object> byBiz(Integer schId, Integer operatorId, String bizId) {
|
||||
requireEnabled();
|
||||
Map<String, Object> first = null;
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", bizType);
|
||||
query.put("bizId", bizId);
|
||||
query.put("operatorId", String.valueOf(operatorId));
|
||||
Map<String, Object> data = openCall.get(TARGET, "/oapi/flow/tasks/by-biz", query, Map.class);
|
||||
if (data != null && data.get("instanceId") != null) {
|
||||
return data;
|
||||
}
|
||||
if (first == null) {
|
||||
first = data;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** 同意(opinion 可空;多出口 edgeName 透传) */
|
||||
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
|
||||
String opinion, String edgeName) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (opinion != null && !opinion.trim().isEmpty()) {
|
||||
body.put("opinion", opinion.trim());
|
||||
}
|
||||
if (edgeName != null && !edgeName.trim().isEmpty()) {
|
||||
body.put("edgeName", edgeName.trim());
|
||||
}
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填由 controller 前置校验;本方法为薄出站) */
|
||||
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (opinion != null && !opinion.trim().isEmpty()) {
|
||||
body.put("opinion", opinion.trim());
|
||||
}
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批记录抽屉地址(token 只读页,{@code /oapi/flow/embed/url?mode=record&instanceId=})。
|
||||
* instanceId 形态:本地 flow_instance_id 镜像即中心实例 ID,天然免疫 bizType 换值。
|
||||
*/
|
||||
public Map<String, Object> embedRecordUrl(Integer schId, Long instanceId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("mode", "record");
|
||||
query.put("instanceId", String.valueOf(instanceId));
|
||||
return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整办理任务页地址(CAS 会话页,无 token,{@code mode=task&taskId=})。
|
||||
* 任务页不定位办件(仅拼 taskId,免签 CAS 页),办理权由该页按 assigneeId 自校;
|
||||
* 调用方(controller)仍须先做 byBiz 归属预检,不把他人 taskId 散播到前端。
|
||||
*/
|
||||
public Map<String, Object> taskPageUrl(Integer schId, Long taskId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("mode", "task");
|
||||
query.put("taskId", String.valueOf(taskId));
|
||||
return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class);
|
||||
}
|
||||
|
||||
/** 双查分页合并:total 求和、content 拼接、页元数据按首查重算(存量=0 时与单查逐字同形) */
|
||||
private static Map<String, Object> mergePages(List<Map<String, Object>> pages, Integer page, Integer size) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
long total = 0L;
|
||||
List<Object> content = new ArrayList<>();
|
||||
for (Map<String, Object> p : pages) {
|
||||
if (p == null) {
|
||||
continue;
|
||||
}
|
||||
if (p.get("total") instanceof Number) {
|
||||
total += ((Number) p.get("total")).longValue();
|
||||
}
|
||||
if (p.get("content") instanceof List) {
|
||||
content.addAll((List<?>) p.get("content"));
|
||||
}
|
||||
}
|
||||
merged.put("total", total);
|
||||
merged.put("content", content);
|
||||
int pageIndex = page == null ? 1 : page;
|
||||
int pageSize = size == null ? 10 : size;
|
||||
long totalPages = pageSize <= 0 ? 0 : (total + pageSize - 1) / pageSize;
|
||||
merged.put("pageIndex", pageIndex);
|
||||
merged.put("pageSize", pageSize);
|
||||
merged.put("totalPages", totalPages);
|
||||
merged.put("hasNext", pageIndex < totalPages);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static Map<String, String> operatorQuery(Integer schId, Integer operatorId, String operatorName) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("operatorId", String.valueOf(operatorId));
|
||||
query.put("operatorName", operatorName);
|
||||
return query;
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!flowClient.enabled() || openCall == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "流程中心未接入,审批功能不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.spring.GlobalExceptionHandler;
|
||||
import com.yunhai.platform.auth.BoaUserContext;
|
||||
import com.yunhai.platform.auth.spring.BoaUserArgumentResolver;
|
||||
import com.yunhai.platform.auth.spring.CasSsoApiRedirectFilter;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.ApplyViewGuard;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import com.yunhai.svchall.service.impl.SvcApprovalClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* P3.1 审批代理三层测试之 MVC 契约层:MockMvc standalone <b>显式装配</b>
|
||||
* {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与
|
||||
* {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测,
|
||||
* 证明不了解析器/advice 装配)。
|
||||
* <p>
|
||||
* 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN);
|
||||
* 无学校=业务码 2(PARAM);<b>拒绝路径审批出站必须为 0</b>(verifyNoInteractions)。
|
||||
* 另有一组挂真实 {@link CasSsoApiRedirectFilter} 的过滤链用例:API 头未登录=HTTP200+code302
|
||||
* (平台 CAS 口径,非 HTTP 403——R3.0 勘误)。
|
||||
*/
|
||||
@DisplayName("SvcApprovalController - MVC 契约(双闸/身份注入/信封映射)")
|
||||
class SvcApprovalControllerTest {
|
||||
|
||||
private SvcApprovalClient approvalClient;
|
||||
private ServiceApplyRepository applyRepository;
|
||||
private ApplyViewGuard applyViewGuard;
|
||||
private ServiceApplyService applyService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalClient = mock(SvcApprovalClient.class);
|
||||
applyRepository = mock(ServiceApplyRepository.class);
|
||||
FlowClient flowClient = mock(FlowClient.class);
|
||||
applyViewGuard = new ApplyViewGuard(flowClient);
|
||||
applyService = mock(ServiceApplyService.class);
|
||||
SvcApprovalController controller = new SvcApprovalController(
|
||||
approvalClient, applyRepository, applyViewGuard, applyService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
|
||||
.setControllerAdvice(new GlobalExceptionHandler(null),
|
||||
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
BoaUserContext.clear();
|
||||
}
|
||||
|
||||
/** 深桩用户(沿 ServiceApplyFlowSubmitTest 形态):roleIds 可指定以驱动 Guard 管理员分支 */
|
||||
private BoaUser user(int id, int schId, Integer... roleIds) {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getName()).thenReturn("张三");
|
||||
when(user.getAccount()).thenReturn("zhangsan");
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
when(user.getRoleIds()).thenReturn(roleIds == null || roleIds.length == 0 ? java.util.Collections.<Integer>emptyList() : java.util.Arrays.asList(roleIds));
|
||||
when(user.getType()).thenReturn(null);
|
||||
return user;
|
||||
}
|
||||
|
||||
private ServiceApply apply(int id, int schId, Integer submitterId, Long instanceId) {
|
||||
ServiceApply apply = new ServiceApply();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(apply, "id", id);
|
||||
apply.setSchId((long) schId);
|
||||
apply.setSubmitterId(submitterId);
|
||||
apply.setFlowInstanceId(instanceId);
|
||||
return apply;
|
||||
}
|
||||
|
||||
private static Map<String, Object> byBizTasks(Long instanceId, Map<String, Object>... tasks) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("instanceId", instanceId);
|
||||
m.put("tasks", java.util.Arrays.asList(tasks));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> task(int taskId) {
|
||||
Map<String, Object> t = new LinkedHashMap<>();
|
||||
t.put("taskId", taskId);
|
||||
t.put("nodeName", "部门负责人");
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(int id) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", id);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> pair(Object value) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("total", 0L);
|
||||
m.put("list", value == null ? new java.util.ArrayList<>() : Collections.singletonList(value));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** RecordUrl 换址应答 {pageUrl} */
|
||||
private static Map<String, Object> pageUrl(String url) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("pageUrl", url);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ===== 身份层 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("未登录(无 BoaUser):HTTP200 + 业务码 3,零出站")
|
||||
void todoWithoutLogin() throws Exception {
|
||||
mockMvc.perform(get("/api/apply/approval/Todo"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("会话无学校归属:HTTP200 + 业务码 2,零出站")
|
||||
void noSchoolIdentity() throws Exception {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(10006);
|
||||
when(user.getCurrentRole()).thenReturn(null);
|
||||
BoaUserContext.set(user);
|
||||
mockMvc.perform(get("/api/apply/approval/Todo"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("请求参数携带身份字段(operatorId/schId/…):一律拒绝,零出站")
|
||||
void identityParamsRejected() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/Todo").param("operatorId", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42").param("schId", "3"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442").param("operatorName", "李四")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient, applyRepository);
|
||||
}
|
||||
|
||||
// ===== Todo / My =====
|
||||
|
||||
@Test
|
||||
@DisplayName("Todo:会话身份出站(双值双查在 client),信封透传")
|
||||
void todoHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("total", 0);
|
||||
data.put("content", new java.util.ArrayList<Object>());
|
||||
when(approvalClient.todoTasks(3, 10006, 2, 10)).thenReturn(data);
|
||||
mockMvc.perform(get("/api/apply/approval/Todo").param("page", "2").param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射")
|
||||
void myEnvelopeMapping() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
Map<String, Object> local = new LinkedHashMap<>();
|
||||
local.put("total", 21L);
|
||||
local.put("list", java.util.Arrays.asList(row(42), row(43)));
|
||||
when(applyService.myApplies(any(), any(), eq(3), eq(10))).thenReturn(local);
|
||||
mockMvc.perform(get("/api/apply/approval/My").param("page", "3").param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(21))
|
||||
.andExpect(jsonPath("$.data.content.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.pageIndex").value(3))
|
||||
.andExpect(jsonPath("$.data.pageSize").value(10))
|
||||
.andExpect(jsonPath("$.data.totalPages").value(3))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("My 非法页码/页大小归一(page<1→1,size<1→10,size>100→100)")
|
||||
void myNormalizesPaging() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
when(applyService.myApplies(any(), any(), anyInt(), anyInt())).thenReturn(pair(0L));
|
||||
mockMvc.perform(get("/api/apply/approval/My").param("page", "0").param("size", "500"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.pageIndex").value(1))
|
||||
.andExpect(jsonPath("$.data.pageSize").value(100));
|
||||
}
|
||||
|
||||
// ===== ByBiz 双闸 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 跨校/不存在(findByIdAndSchId 空):业务码 2,零审批出站")
|
||||
void byBizCrossSchool() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.empty());
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 同校陌生人(Guard 拒):业务码 3,零审批出站")
|
||||
void byBizStrangerRejected() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
// flowClient.enabled()=false → Guard 办理人分支 fail-closed;非提交人/非管理员 → 拒
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 管理员(Guard 放行=只读可见):放行查询,出站=byBiz")
|
||||
void byBizAdminReadonly() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.instanceId").value(139));
|
||||
}
|
||||
|
||||
// ===== 写面双闸(Approve/Reject/TaskPageUrl) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("Approve:归属预检不过(管理员可见但非办理人)→ 业务码 3,approve 零出站")
|
||||
void approveGuardFailsForNonAssigneeAdmin() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); // tasks 空=非本人待办
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verify(approvalClient, never()).approve(any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Approve:预检命中本人待办 → approve 一次(opinion/edgeName 从 body)")
|
||||
void approveHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json")
|
||||
.content("{\"opinion\":\"同意\",\"edgeName\":\"通过\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
verify(approvalClient).approve(442L, 3, 10006, "张三", "同意", "通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:空白 opinion 前置拒(业务码 2),零出站")
|
||||
void rejectBlankOpinion() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{\"opinion\":\" \"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:空 body/缺 opinion → 业务码 2")
|
||||
void rejectMissingBody() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:预检+opinion 齐 → reject 一次")
|
||||
void rejectHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L); // 提交人=本人(Guard owner 短路)
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{\"opinion\":\"材料不全\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
verify(approvalClient).reject(442L, 3, 10006, "张三", "材料不全");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TaskPageUrl:非本人待办 → 业务码 3,taskPageUrl 零出站")
|
||||
void taskPageUrlNotMyTask() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(999)));
|
||||
mockMvc.perform(get("/api/apply/approval/TaskPageUrl")
|
||||
.param("applyId", "42").param("taskId", "442"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verify(approvalClient, never()).taskPageUrl(any(), any());
|
||||
}
|
||||
|
||||
// ===== RecordUrl =====
|
||||
|
||||
@Test
|
||||
@DisplayName("RecordUrl:未进流程(instanceId 空)→ 业务码 2,零出站")
|
||||
void recordUrlNoInstance() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, null);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RecordUrl:instanceId 形态换址(pageUrl 透传)")
|
||||
void recordUrlHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.embedRecordUrl(3, 139L)).thenReturn(pageUrl("/embed/instance?token=x"));
|
||||
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.pageUrl").value("/embed/instance?token=x"));
|
||||
verify(approvalClient).embedRecordUrl(3, 139L);
|
||||
}
|
||||
|
||||
// ===== 参数形态(与中心端点同构) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("缺参 applyId:非 0 业务码(GlobalExceptionHandler 兜底,code=6),零出站")
|
||||
void missingParam() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(6));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非法数字 applyId=abc:非 0 业务码(code=6),零出站")
|
||||
void illegalNumber() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "abc"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(6));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
// ===== 真实 Servlet 过滤链(平台 CAS 口径) =====
|
||||
|
||||
/**
|
||||
* 挂真实 {@link CasSsoApiRedirectFilter}(yh-auth starter 同一实现类):API 头(XHR)未登录
|
||||
* → HTTP 200 + code 302 + urlToRedirectTo(平台口径,<b>不是</b> HTTP 403——R3.0 P1-04 勘误)。
|
||||
* 无 API 头的请求过滤器直通(浏览器导航场景走 CAS 重定向链,另由整合测试覆盖)。
|
||||
*/
|
||||
@Test
|
||||
void realFilterChainUnauthenticatedApiGetsCode302() throws Exception {
|
||||
CasSsoApiRedirectFilter filter = new CasSsoApiRedirectFilter(
|
||||
"X-Requested-With", "XMLHttpRequest", "http://sso.yunhaixingtong.com/cas", "http://localhost");
|
||||
MockMvc withFilter = MockMvcBuilders.standaloneSetup(
|
||||
new SvcApprovalController(approvalClient, applyRepository, applyViewGuard, applyService))
|
||||
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
|
||||
.setControllerAdvice(new GlobalExceptionHandler(null),
|
||||
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
|
||||
.addFilter(filter, "/api/*")
|
||||
.build();
|
||||
withFilter.perform(get("/api/apply/approval/Todo").header("X-Requested-With", "XMLHttpRequest"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(302))
|
||||
.andExpect(jsonPath("$.urlToRedirectTo").exists());
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* P3.1 应用内审批出站契约测试:端点 URL、参数拼装与<b>双值双查</b>(P3.2 方案B)行为。
|
||||
* 读面 bizType 必须按 {@code [svc-hall, svc_item]} 先终值后存量各查一次(todo 合并、byBiz 择命中);
|
||||
* 写面/换址形态与中心端点同构(taskId 在路径、opinion/edgeName 在 body、embed 记录页 instanceId 形态)。
|
||||
*/
|
||||
@DisplayName("SvcApprovalClient - oapi 契约与双值双查")
|
||||
class SvcApprovalClientTest {
|
||||
|
||||
private static final String TARGET = "flow-center";
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private FlowClient flowClient;
|
||||
private SvcApprovalClient client;
|
||||
|
||||
/** 固定返回的 ObjectProvider 桩(构造注入用,沿 Notice FlowCenterClientTest 形态) */
|
||||
static class FixedProvider<T> implements org.springframework.beans.factory.ObjectProvider<T> {
|
||||
private final T value;
|
||||
|
||||
FixedProvider(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject(Object... args) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfUnique() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
when(flowClient.enabled()).thenReturn(true);
|
||||
client = new SvcApprovalClient(new FixedProvider<>(openCall), flowClient);
|
||||
}
|
||||
|
||||
|
||||
private static Map<String, Object> taskIdRow(int id) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("taskId", id);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> page(long total, Object... rows) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("total", total);
|
||||
m.put("content", Arrays.asList(rows));
|
||||
m.put("pageIndex", 1);
|
||||
m.put("pageSize", 10);
|
||||
m.put("totalPages", 1);
|
||||
m.put("hasNext", false);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> byBizData(Long instanceId, Object... tasks) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("instanceId", instanceId);
|
||||
m.put("bizType", "svc-hall");
|
||||
m.put("bizId", "42");
|
||||
m.put("status", 10);
|
||||
m.put("currentNodes", "n1");
|
||||
m.put("tasks", Arrays.asList(tasks));
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("todo:双值双查(先 svc-hall 后 svc_item)+ 分页合并")
|
||||
void todoQueriesBothBizTypesInOrderAndMerges() {
|
||||
Map<String, Object> cur = page(2, taskIdRow(1), taskIdRow(2));
|
||||
Map<String, Object> legacy = page(1, taskIdRow(3));
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(cur, legacy);
|
||||
|
||||
Map<String, Object> out = client.todoTasks(3, 10006, 1, 10);
|
||||
|
||||
ArgumentCaptor<Map<String, String>> q1 = ArgumentCaptor.forClass(Map.class);
|
||||
ArgumentCaptor<Map<String, String>> q2 = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/todo"),
|
||||
q1.capture(), eq(Map.class));
|
||||
verify(openCall, times(2)).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any());
|
||||
assertEquals("svc-hall", q1.getAllValues().get(0).get("bizType"));
|
||||
assertEquals("svc_item", q1.getAllValues().get(1).get("bizType"));
|
||||
assertEquals("3", q1.getAllValues().get(0).get("schId"));
|
||||
assertEquals("10006", q1.getAllValues().get(0).get("assigneeId"));
|
||||
assertEquals(3L, out.get("total"));
|
||||
assertEquals(3, ((List<?>) out.get("content")).size());
|
||||
assertEquals(1L, out.get("totalPages"));
|
||||
assertEquals(false, out.get("hasNext"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:终值命中即返回(不查存量则不消耗第二次出站——命中即短路)")
|
||||
void byBizReturnsCurrentHit() {
|
||||
Map<String, Object> cur = byBizData(139L, taskIdRow(442));
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(cur);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertEquals(139L, ((Number) out.get("instanceId")).longValue());
|
||||
verify(openCall, times(1)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:终值 miss(instanceId=null)时续查存量并返回命中行")
|
||||
void byBizFallsBackToLegacy() {
|
||||
Map<String, Object> curMiss = byBizData(null);
|
||||
Map<String, Object> legacyHit = byBizData(9011L);
|
||||
legacyHit.put("bizType", "svc_item");
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(curMiss, legacyHit);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertEquals(9011L, ((Number) out.get("instanceId")).longValue());
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), q.capture(), eq(Map.class));
|
||||
assertEquals("svc-hall", q.getAllValues().get(0).get("bizType"));
|
||||
assertEquals("svc_item", q.getAllValues().get(1).get("bizType"));
|
||||
assertEquals("42", q.getAllValues().get(0).get("bizId"));
|
||||
assertEquals("10006", q.getAllValues().get(0).get("operatorId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:双查皆空返回首查应答(保持空 tasks 不探测语义)")
|
||||
void byBizBothMissReturnsFirst() {
|
||||
Map<String, Object> curMiss = byBizData(null);
|
||||
Map<String, Object> legacyMiss = byBizData(null);
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(curMiss, legacyMiss);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertTrue(((List<?>) out.get("tasks")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("approve:taskId 入路径、身份入查询串、opinion/edgeName 入 body 且 trim")
|
||||
void approveAssembly() {
|
||||
client.approve(442L, 3, 10006, "张三", " 同意 ", " 退回 ");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"),
|
||||
q.capture(), body.capture(), eq(Void.class));
|
||||
assertEquals("3", q.getValue().get("schId"));
|
||||
assertEquals("10006", q.getValue().get("operatorId"));
|
||||
assertEquals("张三", q.getValue().get("operatorName"));
|
||||
assertEquals("同意", body.getValue().get("opinion"));
|
||||
assertEquals("退回", body.getValue().get("edgeName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("approve:空 opinion/edgeName 不入 body")
|
||||
void approveOmitsBlankFields() {
|
||||
client.approve(442L, 3, 10006, "张三", " ", null);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"),
|
||||
anyMap(), body.capture(), eq(Void.class));
|
||||
assertTrue(body.getValue().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reject:opinion 入 body;路径 /task/{id}/reject")
|
||||
void rejectAssembly() {
|
||||
client.reject(443L, 3, 10006, "张三", "不同意");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/443/reject"),
|
||||
anyMap(), body.capture(), eq(Void.class));
|
||||
assertEquals("不同意", body.getValue().get("opinion"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("embedRecordUrl:mode=record + instanceId 形态(非 bizType+bizId)")
|
||||
void embedRecordUrlUsesInstanceIdForm() {
|
||||
client.embedRecordUrl(3, 139L);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class));
|
||||
assertEquals("record", q.getValue().get("mode"));
|
||||
assertEquals("139", q.getValue().get("instanceId"));
|
||||
assertEquals("3", q.getValue().get("schId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("taskPageUrl:mode=task + taskId")
|
||||
void taskPageUrlAssembly() {
|
||||
client.taskPageUrl(3, 442L);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class));
|
||||
assertEquals("task", q.getValue().get("mode"));
|
||||
assertEquals("442", q.getValue().get("taskId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("流程中心未接入:一律 FORBIDDEN 且零出站")
|
||||
void disabledRefusesAll() {
|
||||
when(flowClient.enabled()).thenReturn(false);
|
||||
assertThrows(BizException.class, () -> client.todoTasks(3, 10006, 1, 10));
|
||||
assertThrows(BizException.class, () -> client.byBiz(3, 10006, "42"));
|
||||
assertThrows(BizException.class, () -> client.approve(1L, 3, 1, "n", null, null));
|
||||
assertThrows(BizException.class, () -> client.embedRecordUrl(3, 1L));
|
||||
verify(openCall, never()).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any());
|
||||
verify(openCall, never()).post(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any(), any());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user