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
@@ -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.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<com.yunhai.platform.appauth.LocalAppIdentityProvider>
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<String> 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) {
@@ -123,6 +123,21 @@ public class FlowAdminController {
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")
@@ -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;
@@ -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 注册时生成下发) */
@@ -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 模糊端点)。
* <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
@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<FlwAssigneeMap> 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<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) {
try {
BoaUser user = userClient.getUserById(String.valueOf(userId));
@@ -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<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> 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,鉴权语义不在此验证。
* <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 {
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) {