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) {