feat: 一键拉取教师组作为科室(办理人映射批量导入)+ 回调签名/编辑器存量
build / build-push (push) Successful in 46s

- FlowMapAdminService:previewGroupDepts/importGroups(学校子树 type=7 容器→子组为科室,
  deptKey=UserBase 组 id,成员=组内用户;已存在启用中映射跳过;语义与 svc-hall buildDeptTree 对齐)
- FlowAdminController:GET /map/groupPreview + POST /map/importGroups
- flow-center.map.dept-group-type(默认 7)
- 含 2026-09-13/14 存量:CallbackAppSigner 回调签名、流程编辑器 sql、示意图(F1-F4)
This commit is contained in:
chenxing
2026-09-16 01:20:59 +08:00
parent f116786846
commit 55e3a3f800
10 changed files with 475 additions and 20 deletions
@@ -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<Received> 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;
@@ -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<FlowMapAdminService.GroupDeptVo> 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<FlowMapAdminService.GroupDeptVo> 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<FlwAssigneeMap> 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<FlowMapAdminService.GroupDeptVo> depts = service.previewGroupDepts(3L);
assertEquals(2, depts.size());
assertEquals(0, depts.get(0).getUsers().size());
assertEquals(1, depts.get(1).getUsers().size());
}
}