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;
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java b/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java
new file mode 100644
index 0000000..85036d2
--- /dev/null
+++ b/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java
@@ -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 已上线)。
+ *
+ * 刻意用 {@link YschOpenCall} 泛化调用而非 YschFlowOpenApi 新门面方法——四方法
+ * (todoTasks/instances/approveTask/rejectTask)在 SDK main 已编码但**未发版**,notice 运行时
+ * 依赖的是 nexus 已发布快照;泛化调用与 FlowCenterClient legacy 分支同一条
+ * resolveTarget 解析链(targets > services > 枚举 base-host 兜底=网关),零发版依赖。
+ * SDK 发版后可平替为门面方法(行为等价,测试锁 URL 不变)。
+ *
+ * 身份口径: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 openCallProvider) {
+ this.properties = properties;
+ this.openCall = openCallProvider.getIfAvailable();
+ }
+
+ /** 我的待办(bizType 固定 notice):Map 形态即 flow-center BasePageResult JSON(total/content/…) */
+ public Map todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
+ requireEnabled();
+ Map 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 instances(Integer schId, Integer status, Integer page, Integer size) {
+ requireEnabled();
+ Map 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 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 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 operatorQuery(Integer schId, Integer operatorId, String operatorName) {
+ Map 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);
+ }
+ }
+}
diff --git a/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java b/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java
new file mode 100644
index 0000000..52e6b23
--- /dev/null
+++ b/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java
@@ -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 provider = Mockito.mock(ObjectProvider.class);
+ when(provider.getIfAvailable()).thenReturn(call);
+ client = new NoticeApprovalClient(properties, provider);
+ }
+
+ @Test
+ void todoTasks_urlAndParams_charExact() {
+ Map 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 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 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 body = new LinkedHashMap<>();
+ body.put("opinion", "同意");
+ body.put("edgeName", "通过");
+ client.approve(106L, 27, 10006, "刘文娟", "同意", "通过");
+ Map 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()), 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 body = new LinkedHashMap<>();
+ body.put("opinion", "材料不全");
+ client.reject(107L, 27, 10006, "甲", "材料不全");
+ Map 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));
+ }
+}