feat(approval): 待审核代理——Todo/Instances/Approve/Reject(operator=CAS 登录态)
build / build-push (push) Successful in 1m2s
build / build-push (push) Successful in 1m2s
- NoticeApprovalClient:YschOpenCall 泛化调 flow-center /oapi/flow/tasks|task/** (SDK 四方法未发版,泛化调用零发版依赖,同 resolveTarget 解析链经网关) - NoticeApprovalController:operatorId/Name 一律取 BoaUser 登录态,请求不收身份字段; reject 空 opinion 前置业务码拒绝;未接入流程中心降级 FORBIDDEN - 9 用例:URL/参数逐字断言+bizType 固定+未接入+异常透传(90 存量单测无回归)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package com.yunhai.notice.controller.api;
|
||||
|
||||
import com.yunhai.notice.config.Config;
|
||||
import com.yunhai.notice.service.flow.NoticeApprovalClient;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批(第三步 T-C4):"我的待审核"数据面。前端不直调 oapi——
|
||||
* operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段,
|
||||
* 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。
|
||||
* <ul>
|
||||
* <li>GET Approval/Todo:待办分页(bizType=notice 固定);</li>
|
||||
* <li>GET Approval/Instances:办件分页(status 可选);</li>
|
||||
* <li>POST Approval/Approve / Reject:办理(body 仅 opinion/edgeName)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/notice/approval/")
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeApprovalController {
|
||||
|
||||
private final NoticeApprovalClient approvalClient;
|
||||
|
||||
/** 我的待审核(列表页数据源) */
|
||||
@GetMapping("Todo")
|
||||
public Map<String, Object> todo(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Operator op = requireOperator(user);
|
||||
return approvalClient.todoTasks(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/** 办件视角(对账/历史;status 10 审批中/20 完成/30 拒绝/40 撤回) */
|
||||
@GetMapping("Instances")
|
||||
public Map<String, Object> instances(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Operator op = requireOperator(user);
|
||||
return approvalClient.instances(op.schId, status, page, size);
|
||||
}
|
||||
|
||||
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
|
||||
@PostMapping("Approve")
|
||||
public void approve(@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
Operator op = requireOperator(user);
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
String edgeName = body == null ? null : body.get("edgeName");
|
||||
approvalClient.approve(taskId, op.schId, op.userId, op.name, opinion, edgeName);
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填) */
|
||||
@PostMapping("Reject")
|
||||
public void reject(@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
Operator op = requireOperator(user);
|
||||
approvalClient.reject(taskId, op.schId, op.userId, op.name,
|
||||
body == null ? null : body.get("opinion"));
|
||||
}
|
||||
|
||||
/** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */
|
||||
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);
|
||||
}
|
||||
|
||||
private static final class Operator {
|
||||
final Integer userId;
|
||||
final Integer schId;
|
||||
final String name;
|
||||
|
||||
Operator(Integer userId, Integer schId, String name) {
|
||||
this.userId = userId;
|
||||
this.schId = schId;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批出站客户端(第三步 T-C4):待办/办件查询 + 通过/驳回,经网关打 flow-center
|
||||
* {@code /oapi/flow/tasks|task/**}(2026-09-23 已上线)。
|
||||
* <p>
|
||||
* <b>刻意用 {@link YschOpenCall} 泛化调用而非 YschFlowOpenApi 新门面方法</b>——四方法
|
||||
* (todoTasks/instances/approveTask/rejectTask)在 SDK main 已编码但**未发版**,notice 运行时
|
||||
* 依赖的是 nexus 已发布快照;泛化调用与 FlowCenterClient legacy 分支同一条
|
||||
* resolveTarget 解析链(targets > services > 枚举 base-host 兜底=网关),零发版依赖。
|
||||
* SDK 发版后可平替为门面方法(行为等价,测试锁 URL 不变)。
|
||||
* <p>
|
||||
* 身份口径:operatorId/operatorName 由调用方(controller)从 CAS 登录态取,本类不猜身份;
|
||||
* 对端按任务办理人逐字复核(非办理人→业务码拒绝),notice 不重复校验。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class NoticeApprovalClient {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOWCENTER.key();
|
||||
private static final String NOT_CONNECTED = "流程中心未接入,审批功能不可用";
|
||||
|
||||
private final NoticeFlowProperties properties;
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
public NoticeApprovalClient(NoticeFlowProperties properties,
|
||||
ObjectProvider<YschOpenCall> openCallProvider) {
|
||||
this.properties = properties;
|
||||
this.openCall = openCallProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
/** 我的待办(bizType 固定 notice):Map 形态即 flow-center BasePageResult JSON(total/content/…) */
|
||||
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("assigneeId", String.valueOf(assigneeId));
|
||||
query.put("bizType", FlowCenterClient.BIZ_TYPE);
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class);
|
||||
}
|
||||
|
||||
/** 办件分页(bizType 固定 notice;status 可空) */
|
||||
public Map<String, Object> instances(Integer schId, Integer status, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", FlowCenterClient.BIZ_TYPE);
|
||||
if (status != null) {
|
||||
query.put("status", String.valueOf(status));
|
||||
}
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class);
|
||||
}
|
||||
|
||||
/** 同意(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 必填由对端强制;此处前置校验给前端更快的反馈) */
|
||||
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
|
||||
requireEnabled();
|
||||
if (opinion == null || opinion.trim().isEmpty()) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见");
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", opinion.trim());
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
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 (!properties.isEnabled() || openCall == null) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.service.flow.NoticeApprovalClient;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
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.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批代理客户端(N06 TDD):URL/参数逐字断言(与 flow-center OapiFlowController 契约对齐)、
|
||||
* bizType=notice 固定、reject 空 opinion 前置拒绝、未接入业务异常。
|
||||
*/
|
||||
class NoticeApprovalClientTest {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOWCENTER.key();
|
||||
|
||||
private final NoticeFlowProperties properties = new NoticeFlowProperties();
|
||||
private YschOpenCall call;
|
||||
private NoticeApprovalClient client;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
call = mock(YschOpenCall.class);
|
||||
ObjectProvider<YschOpenCall> provider = Mockito.mock(ObjectProvider.class);
|
||||
when(provider.getIfAvailable()).thenReturn(call);
|
||||
client = new NoticeApprovalClient(properties, provider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void todoTasks_urlAndParams_charExact() {
|
||||
Map<String, Object> page = new HashMap<>();
|
||||
when(call.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class))).thenReturn(page);
|
||||
client.todoTasks(27, 10006, 2, 20);
|
||||
Map<String, String> expected = new LinkedHashMap<>();
|
||||
expected.put("schId", "27");
|
||||
expected.put("assigneeId", "10006");
|
||||
expected.put("bizType", "notice");
|
||||
expected.put("page", "2");
|
||||
expected.put("size", "20");
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), eq(expected), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void todoTasks_nullPaging_omitted() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
|
||||
client.todoTasks(27, 10006, null, null);
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), Mockito.argThat(q ->
|
||||
q.size() == 3 && "notice".equals(q.get("bizType"))
|
||||
&& !q.containsKey("page") && !q.containsKey("size")), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void instances_urlAndParams_charExact() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
|
||||
client.instances(27, 10, 1, 50);
|
||||
Map<String, String> expected = new LinkedHashMap<>();
|
||||
expected.put("schId", "27");
|
||||
expected.put("bizType", "notice");
|
||||
expected.put("status", "10");
|
||||
expected.put("page", "1");
|
||||
expected.put("size", "50");
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/instances"), eq(expected), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_urlParamsAndBody() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", "同意");
|
||||
body.put("edgeName", "通过");
|
||||
client.approve(106L, 27, 10006, "刘文娟", "同意", "通过");
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", "27");
|
||||
query.put("operatorId", "10006");
|
||||
query.put("operatorName", "刘文娟");
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), eq(query), eq(body), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_blankOpinionAndEdge_omittedFromBody() {
|
||||
client.approve(106L, 27, 10006, "甲", " ", null);
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), anyMap(),
|
||||
eq(new LinkedHashMap<String, Object>()), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_blankOpinion_rejectedLocally() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> client.reject(106L, 27, 10006, "甲", " "));
|
||||
assertTrue(e.getMessage().contains("意见"));
|
||||
verify(call, Mockito.never()).post(any(String.class), any(String.class), anyMap(), any(), any(Class.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_urlParamsAndBody() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", "材料不全");
|
||||
client.reject(107L, 27, 10006, "甲", "材料不全");
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", "27");
|
||||
query.put("operatorId", "10006");
|
||||
query.put("operatorName", "甲");
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/107/reject"), eq(query), eq(body), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledFlow_bizException() {
|
||||
properties.setEnabled(false);
|
||||
assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null));
|
||||
assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundError_propagatesAsIs() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new RuntimeException("connect timeout"));
|
||||
assertThrows(RuntimeException.class, () -> client.todoTasks(27, 10006, null, null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user