feat(approval): 待审核代理——Todo/Instances/Approve/Reject(operator=CAS 登录态)
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:
chenxing
2026-09-23 00:28:32 +08:00
parent 5db629cfdb
commit df9b9e0aa5
3 changed files with 353 additions and 0 deletions
@@ -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 &gt; services &gt; 枚举 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));
}
}