diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java new file mode 100644 index 0000000..d74294f --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java @@ -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 再按任务办理人复核(双层)。 + * + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/notice/approval/") +@RequiredArgsConstructor +public class NoticeApprovalController { + + private final NoticeApprovalClient approvalClient; + + /** 我的待审核(列表页数据源) */ + @GetMapping("Todo") + public Map 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 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 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 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)); + } +}