- 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:
@@ -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。
|
||||||
|
* <p>
|
||||||
|
* 接收方把回调端点收到 /oapi/* 下(Notice=/oapi/notice/flow/callback)后,由平台
|
||||||
|
* AppAuthFilter + RequireAppSignFilter 强制应用验签——两套签名并存是刻意的:
|
||||||
|
* X-Flw-* 供只有注册级 secret 的消费方(svc-hall)使用,应用签名供 /oapi 强制验签的提供方使用。
|
||||||
|
* <p>
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
@@ -47,6 +46,8 @@ public class CallbackPushService {
|
|||||||
private final FlwCallbackRegRepository regRepository;
|
private final FlwCallbackRegRepository regRepository;
|
||||||
private final CallbackProperties properties;
|
private final CallbackProperties properties;
|
||||||
private final UserMsgClient userMsgClient;
|
private final UserMsgClient userMsgClient;
|
||||||
|
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||||
|
appIdentityProvider;
|
||||||
|
|
||||||
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
|
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
|
||||||
public void pushAsync(Long logId) {
|
public void pushAsync(Long logId) {
|
||||||
@@ -86,7 +87,9 @@ public class CallbackPushService {
|
|||||||
headers.set("X-Flw-Timestamp", ts);
|
headers.set("X-Flw-Timestamp", ts);
|
||||||
headers.set("X-Flw-Nonce", nonce);
|
headers.set("X-Flw-Nonce", nonce);
|
||||||
headers.set("X-Flw-Signature", sign(reg.getSecret(), ts, nonce, body));
|
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<String> resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST,
|
ResponseEntity<String> resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST,
|
||||||
new HttpEntity<>(body, headers), String.class);
|
new HttpEntity<>(body, headers), String.class);
|
||||||
int status = resp.getStatusCodeValue();
|
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) {
|
private static String rootMessage(Throwable e) {
|
||||||
Throwable cur = e;
|
Throwable cur = e;
|
||||||
while (cur.getCause() != null && cur.getCause() != cur) {
|
while (cur.getCause() != null && cur.getCause() != cur) {
|
||||||
|
|||||||
@@ -123,6 +123,21 @@ public class FlowAdminController {
|
|||||||
return mapAdminService.lookupUser(userId);
|
return mapAdminService.lookupUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(deptKey=UserBase 组 id)。
|
||||||
|
* 导入前先展示给管理员确认,不落库。
|
||||||
|
*/
|
||||||
|
@GetMapping("/map/groupPreview")
|
||||||
|
public List<com.yunhai.flowcenter.service.FlowMapAdminService.GroupDeptVo> 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")
|
@GetMapping("/callback/regList")
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ public class FlwCallbackReg extends BaseEntity {
|
|||||||
@Column(name = "event_types", length = 200)
|
@Column(name = "event_types", length = 200)
|
||||||
private String eventTypes;
|
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,注册时生成下发) */
|
/** 回调签名密钥(HmacSHA256,注册时生成下发) */
|
||||||
@Column(name = "secret", nullable = false, length = 64)
|
@Column(name = "secret", nullable = false, length = 64)
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ public final class OapiModels {
|
|||||||
private String callbackUrl;
|
private String callbackUrl;
|
||||||
/** 订阅事件逗号分隔,空=全部 */
|
/** 订阅事件逗号分隔,空=全部 */
|
||||||
private String eventTypes;
|
private String eventTypes;
|
||||||
|
/**
|
||||||
|
* 接收方 context-path / 网关前缀(可选,形如 /notice;无则空)。
|
||||||
|
* 推送时按 AppSignInterceptor 的既有约定从 URL path 中剥离后再签 X-Signature,
|
||||||
|
* 与接收方 AppAuthFilter 用 request.contextPath 剥离后的 path 对齐——配置错会 401。
|
||||||
|
*/
|
||||||
|
private String basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回调注册响应(secret 注册时生成下发) */
|
/** 回调注册响应(secret 注册时生成下发) */
|
||||||
|
|||||||
@@ -5,23 +5,41 @@ import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
|||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
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.openapi.client.OpenApiUserClient;
|
||||||
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经
|
* 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经
|
||||||
* UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。
|
* UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。
|
||||||
|
* <p>
|
||||||
|
* 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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class FlowMapAdminService {
|
public class FlowMapAdminService {
|
||||||
|
|
||||||
private final FlwAssigneeMapRepository mapRepository;
|
private final FlwAssigneeMapRepository mapRepository;
|
||||||
private final OpenApiUserClient userClient;
|
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<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
|
public List<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
|
||||||
if (deptKey == null || deptKey.trim().isEmpty()) {
|
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<MemberVo> 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<GroupDeptVo> 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<BoaGroup> depts = new ArrayList<>();
|
||||||
|
collectDeptGroups(subtree, depts);
|
||||||
|
List<GroupDeptVo> out = new ArrayList<>();
|
||||||
|
for (BoaGroup dept : depts) {
|
||||||
|
GroupDeptVo vo = new GroupDeptVo();
|
||||||
|
vo.setDeptKey(String.valueOf(dept.getId()));
|
||||||
|
vo.setDeptName(dept.getName());
|
||||||
|
try {
|
||||||
|
List<BoaUser> 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<GroupDeptVo> 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<BoaGroup> 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) {
|
private String fetchName(Integer userId) {
|
||||||
try {
|
try {
|
||||||
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yunhai.flowcenter.service;
|
package com.yunhai.flowcenter.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yunhai.flowcenter.callback.CallbackAppSigner;
|
||||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||||
import com.yunhai.flowcenter.entity.FlwDef;
|
import com.yunhai.flowcenter.entity.FlwDef;
|
||||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||||
@@ -23,7 +24,6 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -63,6 +63,10 @@ public class OapiFlowService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final OpenApiUserClient userClient;
|
private final OpenApiUserClient userClient;
|
||||||
|
|
||||||
|
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
|
||||||
|
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||||
|
appIdentityProvider;
|
||||||
|
|
||||||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||||||
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
||||||
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
|
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
|
||||||
@@ -134,10 +138,11 @@ public class OapiFlowService {
|
|||||||
}
|
}
|
||||||
reg.setCallbackUrl(body.getCallbackUrl());
|
reg.setCallbackUrl(body.getCallbackUrl());
|
||||||
reg.setEventTypes(body.getEventTypes());
|
reg.setEventTypes(body.getEventTypes());
|
||||||
|
reg.setBasePath(body.getBasePath());
|
||||||
reg.setSecret(secret);
|
reg.setSecret(secret);
|
||||||
reg.setStatus(0);
|
reg.setStatus(0);
|
||||||
regRepository.saveAndFlush(reg);
|
regRepository.saveAndFlush(reg);
|
||||||
probeReachable(body.getCallbackUrl(), body.getFlowGuid(), secret);
|
probeReachable(body.getCallbackUrl(), body.getBasePath(), body.getFlowGuid(), secret);
|
||||||
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
|
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
|
||||||
vo.setSecret(secret);
|
vo.setSecret(secret);
|
||||||
return vo;
|
return vo;
|
||||||
@@ -147,10 +152,14 @@ public class OapiFlowService {
|
|||||||
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
|
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
|
||||||
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
|
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
|
||||||
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
|
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
|
||||||
|
* <p>
|
||||||
|
* 但探活请求必须带应用签名:回调端点挂在接收方 /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 {
|
try {
|
||||||
RestTemplate template = rawTemplate();
|
RestTemplate template = CallbackAppSigner.signedTemplate(
|
||||||
|
appIdentityProvider.getIfAvailable(), basePath, 3000, 3000);
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
headers.set("X-Flw-EventId", uuid32());
|
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) {
|
private static Throwable rootCause(Throwable e) {
|
||||||
Throwable cur = e;
|
Throwable cur = e;
|
||||||
while (cur.getCause() != null && cur.getCause() != cur) {
|
while (cur.getCause() != null && cur.getCause() != cur) {
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -67,6 +67,8 @@ class CallbackDispatchIT extends FlowTestSupport {
|
|||||||
private FlwCallbackRegRepository regRepository;
|
private FlwCallbackRegRepository regRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private com.yunhai.platform.appauth.LocalAppIdentityProvider appIdentity;
|
||||||
|
|
||||||
private HttpServer stubServer;
|
private HttpServer stubServer;
|
||||||
private int stubPort;
|
private int stubPort;
|
||||||
@@ -114,6 +116,10 @@ class CallbackDispatchIT extends FlowTestSupport {
|
|||||||
String timestamp;
|
String timestamp;
|
||||||
String nonce;
|
String nonce;
|
||||||
String signature;
|
String signature;
|
||||||
|
/** 应用签名三件套(供 /oapi/* 强制验签的接收方,与 X-Flw-* 并存) */
|
||||||
|
String appKey;
|
||||||
|
String appTimestamp;
|
||||||
|
String appSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -130,6 +136,9 @@ class CallbackDispatchIT extends FlowTestSupport {
|
|||||||
r.timestamp = exchange.getRequestHeaders().getFirst("X-Flw-Timestamp");
|
r.timestamp = exchange.getRequestHeaders().getFirst("X-Flw-Timestamp");
|
||||||
r.nonce = exchange.getRequestHeaders().getFirst("X-Flw-Nonce");
|
r.nonce = exchange.getRequestHeaders().getFirst("X-Flw-Nonce");
|
||||||
r.signature = exchange.getRequestHeaders().getFirst("X-Flw-Signature");
|
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);
|
received.add(r);
|
||||||
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||||
exchange.sendResponseHeaders(200, resp.length);
|
exchange.sendResponseHeaders(200, resp.length);
|
||||||
@@ -191,15 +200,63 @@ class CallbackDispatchIT extends FlowTestSupport {
|
|||||||
String expected = hmac(reg.getSecret(), r.timestamp, r.nonce, r.body);
|
String expected = hmac(reg.getSecret(), r.timestamp, r.nonce, r.body);
|
||||||
assertEquals(expected, r.signature, "推送必须带合法 X-Flw-Signature");
|
assertEquals(expected, r.signature, "推送必须带合法 X-Flw-Signature");
|
||||||
assertEquals(r.eventId, payload.get("eventId").asText(), "头与体 eventId 一致");
|
assertEquals(r.eventId, payload.get("eventId").asText(), "头与体 eventId 一致");
|
||||||
|
// 应用签名断言:接收方把端点收在 /oapi/* 下时由平台强制验签(未申报 basePath → 签整个 path)
|
||||||
|
assertAppSignature("/callback/flow", r);
|
||||||
}
|
}
|
||||||
assertTrue(hasSubmitted, "应有 flow.submitted 事件");
|
assertTrue(hasSubmitted, "应有 flow.submitted 事件");
|
||||||
assertTrue(hasTaskCreated, "应有 flow.task.created 事件");
|
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. 日志表成功态(异步推送落定,轮询等待)
|
// 4. 日志表成功态(异步推送落定,轮询等待)
|
||||||
assertTrue(logRepository.findAll().size() >= 2);
|
assertTrue(logRepository.findAll().size() >= 2);
|
||||||
waitUntil(() -> logRepository.findAll().stream()
|
waitUntil(() -> logRepository.findAll().stream()
|
||||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000);
|
.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
|
@Test
|
||||||
void pushFailure_persistsRetryBeyondBackoffList() {
|
void pushFailure_persistsRetryBeyondBackoffList() {
|
||||||
mapDept("ybb", 101, "甲");
|
mapDept("ybb", 101, "甲");
|
||||||
@@ -397,6 +454,17 @@ class CallbackDispatchIT extends FlowTestSupport {
|
|||||||
return body;
|
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,离线环境无该依赖) */
|
/** 轮询等待(替代 Awaitility,离线环境无该依赖) */
|
||||||
private static void waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs) {
|
private static void waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs) {
|
||||||
long deadline = System.currentTimeMillis() + 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user