diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackAppSigner.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackAppSigner.java new file mode 100644 index 0000000..ad772e4 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackAppSigner.java @@ -0,0 +1,45 @@ +package com.yunhai.flowcenter.callback; + +import com.yunhai.platform.appauth.AppSignInterceptor; +import com.yunhai.platform.appauth.LocalAppIdentityProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +/** + * 回调推送的应用签名装配:把平台 {@link AppSignInterceptor} 挂到推送用 RestTemplate 上, + * 使推送请求同时携带 X-Flw-*(注册级 secret)与应用签名三件套 X-App-Key/X-Timestamp/X-Signature。 + *

+ * 接收方把回调端点收到 /oapi/* 下(Notice=/oapi/notice/flow/callback)后,由平台 + * AppAuthFilter + RequireAppSignFilter 强制应用验签——两套签名并存是刻意的: + * X-Flw-* 供只有注册级 secret 的消费方(svc-hall)使用,应用签名供 /oapi 强制验签的提供方使用。 + *

+ * base-path 取注册时对端申报的 context-path(如 /notice):从 URL path 剥离后再签, + * 与接收方 AppAuthFilter 用 {@code request.getContextPath()} 剥离后的 path 对齐; + * 未申报(空)表示对端无 context-path。规则与 AppSignInterceptor 既有 base-path 约定同一套实现。 + */ +@Slf4j +public final class CallbackAppSigner { + + private CallbackAppSigner() { + } + + /** 带应用签名拦截器的推送模板(identity 缺失时不签名:请求裸发,接收方 /oapi/* 会 401) */ + public static RestTemplate signedTemplate(LocalAppIdentityProvider identity, String basePath, + int connectTimeoutMs, int readTimeoutMs) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + RestTemplate template = new RestTemplate(factory); + if (identity == null || isBlank(identity.getAppKey()) || isBlank(identity.getAppSecret())) { + log.warn("[flow-center] 应用身份未装配(yh.app-auth.app-key/app-secret),回调推送不带应用签名头"); + return template; + } + template.getInterceptors().add(new AppSignInterceptor(identity, null, basePath)); + return template; + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java index b5f30e2..f0164be 100644 --- a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java @@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -47,6 +46,8 @@ public class CallbackPushService { private final FlwCallbackRegRepository regRepository; private final CallbackProperties properties; private final UserMsgClient userMsgClient; + private final org.springframework.beans.factory.ObjectProvider + appIdentityProvider; /** 推送指定日志行(幂等入口:成功/终态/在途跳过) */ public void pushAsync(Long logId) { @@ -86,7 +87,9 @@ public class CallbackPushService { headers.set("X-Flw-Timestamp", ts); headers.set("X-Flw-Nonce", nonce); headers.set("X-Flw-Signature", sign(reg.getSecret(), ts, nonce, body)); - RestTemplate template = rawTemplate(); + // 应用签名三件套(供 /oapi/* 强制验签的接收方);与 X-Flw-* 并存,互不影响 + RestTemplate template = CallbackAppSigner.signedTemplate( + appIdentityProvider.getIfAvailable(), reg.getBasePath(), 5000, 10000); ResponseEntity resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); int status = resp.getStatusCodeValue(); @@ -219,13 +222,6 @@ public class CallbackPushService { } } - private RestTemplate rawTemplate() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(5000); - factory.setReadTimeout(10000); - return new RestTemplate(factory); - } - private static String rootMessage(Throwable e) { Throwable cur = e; while (cur.getCause() != null && cur.getCause() != cur) { diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index c414625..71bfbe2 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -123,6 +123,21 @@ public class FlowAdminController { return mapAdminService.lookupUser(userId); } + /** + * 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(deptKey=UserBase 组 id)。 + * 导入前先展示给管理员确认,不落库。 + */ + @GetMapping("/map/groupPreview") + public List groupPreview(BoaUser user) { + return mapAdminService.previewGroupDepts(adminGuard.requireAdmin(user, null)); + } + + /** 一键拉取教师组·导入:批量落映射(已存在且启用中的 sch+dept+user 跳过),返回计数 */ + @PostMapping("/map/importGroups") + public com.yunhai.flowcenter.service.FlowMapAdminService.GroupImportResultVo importGroups(BoaUser user) { + return mapAdminService.importGroups(adminGuard.requireAdmin(user, null)); + } + // ==================== 回调注册/推送日志/死信 ==================== @GetMapping("/callback/regList") diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java index 76a18b0..5e04140 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java @@ -42,6 +42,14 @@ public class FlwCallbackReg extends BaseEntity { @Column(name = "event_types", length = 200) private String eventTypes; + /** + * 接收方 context-path / 网关前缀(注册时业务方申报,如 /notice;无则空)。 + * 仅用于推送应用签名:从 callback_url 的 path 中剥离后再签,与接收方 + * AppAuthFilter 剥 context-path 后的 path 对齐(AppSignInterceptor base-path 同一约定)。 + */ + @Column(name = "base_path", length = 200) + private String basePath; + /** 回调签名密钥(HmacSHA256,注册时生成下发) */ @Column(name = "secret", nullable = false, length = 64) private String secret; diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index c7c1dab..f93974c 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -74,6 +74,12 @@ public final class OapiModels { private String callbackUrl; /** 订阅事件逗号分隔,空=全部 */ private String eventTypes; + /** + * 接收方 context-path / 网关前缀(可选,形如 /notice;无则空)。 + * 推送时按 AppSignInterceptor 的既有约定从 URL path 中剥离后再签 X-Signature, + * 与接收方 AppAuthFilter 用 request.contextPath 剥离后的 path 对齐——配置错会 401。 + */ + private String basePath; } /** 回调注册响应(secret 注册时生成下发) */ diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java index c7376df..e416f87 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java @@ -5,23 +5,41 @@ import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.platform.openapi.client.OpenApiGroupClient; import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.utils.model.BoaGroup; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; /** * 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经 * UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。 + *

+ * 2026-09-16 新增「一键拉取教师组」:按 {@code dept-group-type}(默认 7=教师组,与 svc-hall + * {@code svchall.item.dept-group-type} 同口径)从 UserBase 学校组子树取科室容器,其子组即科室 + * (容器无子组时容器本身即科室),deptKey=UserBase 组 id;成员=组内用户(不含子组)。 + * 批量落 flw_assignee_map,替代管理端逐人录入。 */ +@Slf4j @Service @RequiredArgsConstructor public class FlowMapAdminService { private final FlwAssigneeMapRepository mapRepository; private final OpenApiUserClient userClient; + private final OpenApiGroupClient groupClient; + + /** 科室"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 svc-hall) */ + @Value("${flow-center.map.dept-group-type:7}") + private int deptGroupType = 7; public List listByDept(Long schId, String deptKey) { if (deptKey == null || deptKey.trim().isEmpty()) { @@ -74,6 +92,131 @@ public class FlowMapAdminService { } } + // ==================== 一键拉取教师组 ==================== + + /** 科室预览/导入项(deptKey=UserBase 组 id) */ + @Getter + @Setter + public static class GroupDeptVo { + private String deptKey; + private String deptName; + private List users = new ArrayList<>(); + } + + @Getter + @Setter + public static class MemberVo { + private Integer userId; + private String userName; + + public MemberVo() { + } + + public MemberVo(Integer userId, String userName) { + this.userId = userId; + this.userName = userName; + } + } + + /** 导入结果计数 */ + @Getter + @Setter + public static class GroupImportResultVo { + private int depts; + private int users; + private int skippedExisting; + } + + /** + * 预览:学校子树内教师组容器 → 科室列表(含组内成员)。 + * UserBase 不可达/学校组不存在时抛业务异常(预览即校验,导入前先给前端看结果)。 + */ + public List previewGroupDepts(Long schId) { + BoaGroup subtree; + try { + subtree = groupClient.getGroupById(schId.intValue(), true); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage()); + } + if (subtree == null || subtree.getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")"); + } + List depts = new ArrayList<>(); + collectDeptGroups(subtree, depts); + List out = new ArrayList<>(); + for (BoaGroup dept : depts) { + GroupDeptVo vo = new GroupDeptVo(); + vo.setDeptKey(String.valueOf(dept.getId())); + vo.setDeptName(dept.getName()); + try { + List members = groupClient.getGroupUsers(dept.getId(), false, null); + if (members != null) { + for (BoaUser u : members) { + if (u != null && u.getId() != null) { + vo.getUsers().add(new MemberVo(u.getId(), + u.getName() == null ? "" : u.getName())); + } + } + } + } catch (Exception e) { + log.warn("[flow-center] 组成员拉取失败(groupId={}, name={}):{}", dept.getId(), dept.getName(), e.getMessage()); + } + out.add(vo); + } + return out; + } + + /** + * 一键导入:按 {@link #previewGroupDepts} 的口径批量落映射;已存在(sch+dept+user 且启用中) + * 跳过不重复刷写。空成员的科室保留为无映射科室(流程编辑器可见提示,成员后续手工补)。 + */ + @Transactional + public GroupImportResultVo importGroups(Long schId) { + List depts = previewGroupDepts(schId); + GroupImportResultVo result = new GroupImportResultVo(); + for (GroupDeptVo dept : depts) { + for (MemberVo m : dept.getUsers()) { + FlwAssigneeMap existing = mapRepository.findBySchIdAndDeptKeyAndUserId( + schId, dept.getDeptKey(), m.getUserId()); + if (existing != null && existing.getStatus() == 0) { + result.setSkippedExisting(result.getSkippedExisting() + 1); + continue; + } + save(schId, dept.getDeptKey(), dept.getDeptName(), m.getUserId(), m.getUserName()); + result.setUsers(result.getUsers() + 1); + } + result.setDepts(result.getDepts() + 1); + } + return result; + } + + /** + * 收集科室组:子树内 type=容器组类型的节点,其**子组**为科室(容器无子组时容器自身即科室)。 + * 语义与 svc-hall ServiceItemService.buildDeptTree 逐字对齐(同一份学校组树两种消费)。 + */ + private void collectDeptGroups(BoaGroup node, List out) { + if (node == null) { + return; + } + if (node.getType() != null && node.getType() == deptGroupType) { + if (node.getChildren() == null || node.getChildren().isEmpty()) { + out.add(node); + } else { + for (BoaGroup child : node.getChildren()) { + if (child != null && child.getId() != null) { + out.add(child); + } + } + } + return; + } + if (node.getChildren() != null) { + for (BoaGroup child : node.getChildren()) { + collectDeptGroups(child, out); + } + } + } + private String fetchName(Integer userId) { try { BoaUser user = userClient.getUserById(String.valueOf(userId)); diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index f463c16..f6483d2 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -1,6 +1,7 @@ package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.callback.CallbackAppSigner; import com.yunhai.flowcenter.entity.FlwCallbackReg; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; @@ -23,7 +24,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; @@ -63,6 +63,10 @@ public class OapiFlowService { private final ObjectMapper objectMapper; private final OpenApiUserClient userClient; + /** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */ + private final org.springframework.beans.factory.ObjectProvider + appIdentityProvider; + /** 已发布流程列表(每条流程线取最高已发布版本) */ public List listFlows(Long schId) { List out = new ArrayList<>(); @@ -134,10 +138,11 @@ public class OapiFlowService { } reg.setCallbackUrl(body.getCallbackUrl()); reg.setEventTypes(body.getEventTypes()); + reg.setBasePath(body.getBasePath()); reg.setSecret(secret); reg.setStatus(0); regRepository.saveAndFlush(reg); - probeReachable(body.getCallbackUrl(), body.getFlowGuid(), secret); + probeReachable(body.getCallbackUrl(), body.getBasePath(), body.getFlowGuid(), secret); OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo(); vo.setSecret(secret); return vo; @@ -147,10 +152,14 @@ public class OapiFlowService { * 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定: * 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404) * 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。 + *

+ * 但探活请求必须带应用签名:回调端点挂在接收方 /oapi/* 下(强制应用验签)时, + * 裸 ping 会被 401 拦下,而 RestTemplate 对 4xx 抛异常 → 误判"不可达"拒绝注册。 */ - private void probeReachable(String callbackUrl, String flowGuid, String secret) { + private void probeReachable(String callbackUrl, String basePath, String flowGuid, String secret) { try { - RestTemplate template = rawTemplate(); + RestTemplate template = CallbackAppSigner.signedTemplate( + appIdentityProvider.getIfAvailable(), basePath, 3000, 3000); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Flw-EventId", uuid32()); @@ -170,13 +179,6 @@ public class OapiFlowService { } } - private RestTemplate rawTemplate() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(3000); - factory.setReadTimeout(3000); - return new RestTemplate(factory); - } - private static Throwable rootCause(Throwable e) { Throwable cur = e; while (cur.getCause() != null && cur.getCause() != cur) { diff --git a/src/main/resources/sql/20260915_add_flw_callback_reg_base_path.sql b/src/main/resources/sql/20260915_add_flw_callback_reg_base_path.sql new file mode 100644 index 0000000..09793ff --- /dev/null +++ b/src/main/resources/sql/20260915_add_flw_callback_reg_base_path.sql @@ -0,0 +1,7 @@ +-- flw_callback_reg 增加 base_path(接收方 context-path,可空) +-- 用途:回调推送补应用签名时,从 callback_url 的 path 中剥离该前缀再签, +-- 与接收方 AppAuthFilter 用 request.contextPath 剥离后的 path 对齐(AppSignInterceptor base-path 约定)。 +-- 业务方注册时申报(/oapi/flow/callback/reg 请求体 basePath);Notice 生产为 /notice。 +-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。 +ALTER TABLE `flw_callback_reg` + ADD COLUMN `base_path` VARCHAR(200) NULL COMMENT '接收方 context-path(签名前剥离;空=无前缀)' AFTER `event_types`; diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java index 2a8346f..141da16 100644 --- a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java @@ -67,6 +67,8 @@ class CallbackDispatchIT extends FlowTestSupport { private FlwCallbackRegRepository regRepository; @Autowired private ObjectMapper objectMapper; + @Autowired + private com.yunhai.platform.appauth.LocalAppIdentityProvider appIdentity; private HttpServer stubServer; private int stubPort; @@ -114,6 +116,10 @@ class CallbackDispatchIT extends FlowTestSupport { String timestamp; String nonce; String signature; + /** 应用签名三件套(供 /oapi/* 强制验签的接收方,与 X-Flw-* 并存) */ + String appKey; + String appTimestamp; + String appSignature; } @BeforeEach @@ -130,6 +136,9 @@ class CallbackDispatchIT extends FlowTestSupport { r.timestamp = exchange.getRequestHeaders().getFirst("X-Flw-Timestamp"); r.nonce = exchange.getRequestHeaders().getFirst("X-Flw-Nonce"); r.signature = exchange.getRequestHeaders().getFirst("X-Flw-Signature"); + r.appKey = exchange.getRequestHeaders().getFirst("X-App-Key"); + r.appTimestamp = exchange.getRequestHeaders().getFirst("X-Timestamp"); + r.appSignature = exchange.getRequestHeaders().getFirst("X-Signature"); received.add(r); byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, resp.length); @@ -191,15 +200,63 @@ class CallbackDispatchIT extends FlowTestSupport { String expected = hmac(reg.getSecret(), r.timestamp, r.nonce, r.body); assertEquals(expected, r.signature, "推送必须带合法 X-Flw-Signature"); assertEquals(r.eventId, payload.get("eventId").asText(), "头与体 eventId 一致"); + // 应用签名断言:接收方把端点收在 /oapi/* 下时由平台强制验签(未申报 basePath → 签整个 path) + assertAppSignature("/callback/flow", r); } assertTrue(hasSubmitted, "应有 flow.submitted 事件"); assertTrue(hasTaskCreated, "应有 flow.task.created 事件"); + // 注册探活 ping 也必须带应用签名(否则接收方 401,RestTemplate 对 4xx 抛异常会误判不可达) + Received ping = received.stream() + .filter(r -> r.body != null && r.body.contains("\"ping\"")).findFirst().orElse(null); + assertNotNull(ping, "注册探活应命中 stub"); + assertAppSignature("/callback/flow", ping); // 4. 日志表成功态(异步推送落定,轮询等待) assertTrue(logRepository.findAll().size() >= 2); waitUntil(() -> logRepository.findAll().stream() .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000); } + /** + * 应用签名 canonical:注册申报 basePath(接收方 context-path)时,先从 callback_url 的 path 剥离 + * 再签名——与接收方 AppAuthFilter 用 request.contextPath 剥离后的 path 对齐。 + * 生产 Notice 部署在 /notice 前缀下(deploy/notice.yaml context-path)即此场景。 + */ + @Test + void appSignature_canonicalTargetStripsRegisteredBasePath() { + CopyOnWriteArrayList hits = new CopyOnWriteArrayList<>(); + stubServer.createContext("/notice/oapi/notice/flow/callback", exchange -> { + Received r = new Received(); + r.body = readBody(exchange); + r.appKey = exchange.getRequestHeaders().getFirst("X-App-Key"); + r.appTimestamp = exchange.getRequestHeaders().getFirst("X-Timestamp"); + r.appSignature = exchange.getRequestHeaders().getFirst("X-Signature"); + hits.add(r); + byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + mapDept("ybb", 101, "甲"); + com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t25", "基路径签名", linearDef("ybb", "审核")); + OapiModels.CallbackRegBody rb = regBody(def.getFlowGuid(), + "http://localhost:" + stubPort + "/notice/oapi/notice/flow/callback", "flow.submitted"); + rb.setBasePath("/notice"); + oapiFlowService.registerCallback(SCH, APP_KEY, rb); + + OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET); + String body = "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"cb-5\",\"bizTitle\":\"基路径件\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + + waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 10_000); + Received push = hits.stream() + .filter(r -> r.body != null && r.body.contains("flow.submitted")) + .findFirst().orElseThrow(IllegalStateException::new); + assertAppSignature("/oapi/notice/flow/callback", push); + } + @Test void pushFailure_persistsRetryBeyondBackoffList() { mapDept("ybb", 101, "甲"); @@ -397,6 +454,17 @@ class CallbackDispatchIT extends FlowTestSupport { return body; } + /** 应用签名断言:canonical = METHOD + "\n" + 剥离 base-path 后的 path(+排序 query) + "\n" + ts */ + private void assertAppSignature(String expectedTarget, Received r) { + assertNotNull(r.appKey, "推送必须带 X-App-Key"); + assertEquals(appIdentity.getAppKey(), r.appKey, "签名身份应是本应用 appKey"); + assertNotNull(r.appTimestamp, "推送必须带 X-Timestamp"); + assertNotNull(r.appSignature, "推送必须带 X-Signature"); + assertTrue(com.yunhai.platform.utils.crypto.SignUtil.verifyRequest( + "POST", expectedTarget, r.appTimestamp, appIdentity.getAppSecret(), r.appSignature), + "应用签名应按 target=" + expectedTarget + " 可验(与接收方 AppAuthFilter 剥离 context-path 后对齐)"); + } + /** 轮询等待(替代 Awaitility,离线环境无该依赖) */ private static void waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs) { long deadline = System.currentTimeMillis() + timeoutMs; diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java new file mode 100644 index 0000000..02eeaaf --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java @@ -0,0 +1,165 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.entity.FlwAssigneeMap; +import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; +import com.yunhai.platform.openapi.client.OpenApiGroupClient; +import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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; + +/** + * 一键拉取教师组(办理人映射批量导入)单测:容器组语义(type=7 容器→子组为科室 / 无子组容器 + * 自身为科室,与 svc-hall buildDeptTree 同口径)、deptKey=组 id、成员批量落库、 + * 已存在且启用中的映射跳过、UserBase 不可达抛业务异常。 + */ +class FlowMapAdminServiceGroupImportTest { + + private FlwAssigneeMapRepository mapRepository; + private OpenApiUserClient userClient; + private OpenApiGroupClient groupClient; + private FlowMapAdminService service; + + private static BoaGroup group(int id, String name, Integer type, BoaGroup... children) { + BoaGroup g = new BoaGroup(); + g.setId(id); + g.setName(name); + g.setType(type); + g.setChildren(Arrays.asList(children)); + return g; + } + + private static BoaUser user(int id, String name) { + BoaUser u = new BoaUser(); + u.setId(id); + u.setName(name); + return u; + } + + @BeforeEach + void setUp() { + mapRepository = mock(FlwAssigneeMapRepository.class); + userClient = mock(OpenApiUserClient.class); + groupClient = mock(OpenApiGroupClient.class); + service = new FlowMapAdminService(mapRepository, userClient, groupClient); + ReflectionTestUtils.setField(service, "deptGroupType", 7); + } + + @Test + void preview_containerChildrenBecomeDepts() { + // 云海中学(3) → 教师组(4,type7 容器) → 校长室(6)/教务处(7);学生组(5,type8) 不参与 + BoaGroup school = group(3, "云海中学", 2, + group(4, "教师组", 7, group(6, "校长室", 1), group(7, "教务处", 1)), + group(5, "学生组", 8)); + when(groupClient.getGroupById(3, true)).thenReturn(school); + when(groupClient.getGroupUsers(eq(6), eq(false), any())).thenReturn(Arrays.asList(user(10006, "陈星1"))); + when(groupClient.getGroupUsers(eq(7), eq(false), any())).thenReturn(Arrays.asList(user(10045, "测试教师2"))); + + List depts = service.previewGroupDepts(3L); + + assertEquals(2, depts.size()); + assertEquals("6", depts.get(0).getDeptKey()); + assertEquals("校长室", depts.get(0).getDeptName()); + assertEquals(1, depts.get(0).getUsers().size()); + assertEquals(Integer.valueOf(10006), depts.get(0).getUsers().get(0).getUserId()); + assertEquals("7", depts.get(1).getDeptKey()); + } + + @Test + void preview_childlessContainerItselfIsDept() { + // 教师组容器无子组 → 容器自身即科室 + BoaGroup school = group(3, "云海中学", 2, group(9, "教研组", 7)); + when(groupClient.getGroupById(3, true)).thenReturn(school); + when(groupClient.getGroupUsers(eq(9), eq(false), any())).thenReturn(Arrays.asList(user(1, "张三"))); + + List depts = service.previewGroupDepts(3L); + assertEquals(1, depts.size()); + assertEquals("9", depts.get(0).getDeptKey()); + assertEquals("教研组", depts.get(0).getDeptName()); + } + + @Test + void importGroups_writesAllMembersAndSkipsExistingEnabled() { + BoaGroup school = group(3, "云海中学", 2, + group(4, "教师组", 7, group(6, "校长室", 1))); + when(groupClient.getGroupById(3, true)).thenReturn(school); + when(groupClient.getGroupUsers(eq(6), eq(false), any())) + .thenReturn(Arrays.asList(user(10006, "陈星1"), user(10008, "李晶"))); + // 10006 已有启用映射 → 跳过;10008 不存在 → 新增 + FlwAssigneeMap existing = new FlwAssigneeMap(); + existing.setStatus(0); + when(mapRepository.findBySchIdAndDeptKeyAndUserId(3L, "6", 10006)).thenReturn(existing); + when(mapRepository.findBySchIdAndDeptKeyAndUserId(3L, "6", 10008)).thenReturn(null); + + FlowMapAdminService.GroupImportResultVo result = service.importGroups(3L); + + assertEquals(1, result.getDepts()); + assertEquals(1, result.getUsers()); + assertEquals(1, result.getSkippedExisting()); + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwAssigneeMap.class); + verify(mapRepository, times(1)).save(captor.capture()); + assertEquals("6", captor.getValue().getDeptKey()); + assertEquals("校长室", captor.getValue().getDeptName()); + assertEquals(Integer.valueOf(10008), captor.getValue().getUserId()); + } + + @Test + void importGroups_disabledExistingRowIsRefreshedNotSkipped() { + BoaGroup school = group(3, "云海中学", 2, group(6, "校长室", 7)); + when(groupClient.getGroupById(3, true)).thenReturn(school); + when(groupClient.getGroupUsers(eq(6), eq(false), any())).thenReturn(Arrays.asList(user(1, "张三"))); + FlwAssigneeMap disabled = new FlwAssigneeMap(); + disabled.setStatus(1); // 停用中的历史行 → 重新启用而非跳过 + when(mapRepository.findBySchIdAndDeptKeyAndUserId(3L, "6", 1)).thenReturn(disabled); + + FlowMapAdminService.GroupImportResultVo result = service.importGroups(3L); + + assertEquals(1, result.getUsers()); + assertEquals(0, result.getSkippedExisting()); + verify(mapRepository).save(any(FlwAssigneeMap.class)); + } + + @Test + void preview_userBaseUnreachableThrowsBizException() { + when(groupClient.getGroupById(3, true)).thenThrow(new RuntimeException("connect refused")); + assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.previewGroupDepts(3L)); + verify(mapRepository, never()).save(any(FlwAssigneeMap.class)); + } + + @Test + void preview_schoolGroupMissingThrows() { + when(groupClient.getGroupById(99, true)).thenReturn(null); + assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.previewGroupDepts(99L)); + } + + @Test + void preview_memberFetchFailureDoesNotAbortOtherDepts() { + BoaGroup school = group(3, "云海中学", 2, + group(4, "教师组", 7, group(6, "校长室", 1), group(7, "教务处", 1))); + when(groupClient.getGroupById(3, true)).thenReturn(school); + when(groupClient.getGroupUsers(eq(6), eq(false), any())).thenThrow(new RuntimeException("boom")); + when(groupClient.getGroupUsers(eq(7), eq(false), any())).thenReturn(Arrays.asList(user(2, "李四"))); + + List depts = service.previewGroupDepts(3L); + assertEquals(2, depts.size()); + assertEquals(0, depts.get(0).getUsers().size()); + assertEquals(1, depts.get(1).getUsers().size()); + } +}