feat: 流程对接切 SDK 类型化门面 YschFlowOpenApi(服务目录 flowcenter 优先,legacy base-url 回落)
build / build-push (push) Failing after 26s

- FlowModels 全部改为 extends SDK FlowDto(字段单一事实源收敛到 SDK,消费方签名零改动)
- FlowCenterClient:目录显式配置 flowcenter(yh.call.targets/yh.open.services)走门面命名目标,
  未配置回落 notice.flow.base-url ad-hoc(迁移期兼容)
- 含 2026-09-13/14 存量:重提清旧驳回意见(F4)/消息中心/文件上传改造
This commit is contained in:
chenxing
2026-09-16 01:21:23 +08:00
parent 138b226ab0
commit bfb50a205d
23 changed files with 641 additions and 607 deletions
@@ -1,166 +0,0 @@
package com.yunhai.notice.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.NoticeFlowReg;
import com.yunhai.notice.repository.NoticeFlowRegRepository;
import com.yunhai.notice.utils.ReReadableRequestWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
/**
* 流程中心回调验签(方案 §5.3,svc-hall FlowCallbackFilterConfiguration 同构):
* 只拦 /callback/*(CAS url-pattern /api/*、/mobiapi/* 之外,独立于 oapi 的 appKey 签名体系)。
* 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/
* X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)),
* secret 按 payload.flowGuid 查 notice_flow_reg;常量时间比对。
* 注册探活 ping(事件=ping)直接 200——flow-center 探活只判连接级可达(签名是字面量 "ping" 无法验)。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class FlowCallbackFilterConfiguration {
/** ±5 分钟防重放窗口(对齐概览 §8.4) */
private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L;
private final NoticeFlowRegRepository regRepository;
private final ObjectMapper objectMapper;
@Bean
public FilterRegistrationBean<FlowCallbackVerifyFilter> flowCallbackVerifyFilter() {
FilterRegistrationBean<FlowCallbackVerifyFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new FlowCallbackVerifyFilter());
bean.addUrlPatterns("/callback/*");
// 晚于 AppAuthFilter(+10)/enforce(+11),独立回调签名体系
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
return bean;
}
class FlowCallbackVerifyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (!"POST".equalsIgnoreCase(req.getMethod())) {
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req);
String body = wrapper.bodyString();
// 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过)
if (isPing(body)) {
byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
res.setContentType("application/json");
res.setContentLength(ok.length);
res.setStatus(HttpServletResponse.SC_OK);
res.getOutputStream().write(ok);
return;
}
String eventId = req.getHeader("X-Flw-EventId");
String timestamp = req.getHeader("X-Flw-Timestamp");
String nonce = req.getHeader("X-Flw-Nonce");
String signature = req.getHeader("X-Flw-Signature");
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp)
|| !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers");
return;
}
try {
long ts = Long.parseLong(timestamp.trim());
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window");
return;
}
} catch (NumberFormatException e) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp");
return;
}
String[] reg = extractSchAndFlowGuid(body);
String secret = reg == null ? null : secretOf(reg[0], reg[1]);
if (secret == null) {
log.warn("[notice-flow] 回调验签失败:无注册或无 secret,flowGuid={}", reg == null ? null : reg[1]);
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration");
return;
}
String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body);
if (!constantTimeEquals(expected, signature.trim())) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature");
return;
}
chain.doFilter(wrapper, response);
}
private boolean isPing(String body) {
try {
JsonNode payload = objectMapper.readTree(body);
return payload != null && "ping".equals(payload.path("event").asText(null));
} catch (Exception e) {
return false;
}
}
/** 从推送体提取 [schId, flowGuid](验签必需的最小解析) */
private String[] extractSchAndFlowGuid(String body) {
try {
JsonNode payload = objectMapper.readTree(body);
if (!payload.has("flowGuid") || payload.get("flowGuid").isNull()
|| !payload.has("schId") || payload.get("schId").isNull()) {
return null;
}
return new String[]{payload.get("schId").asText(), payload.get("flowGuid").asText()};
} catch (Exception e) {
return null;
}
}
private String secretOf(String schId, String flowGuid) {
try {
NoticeFlowReg reg = regRepository
.findBySchIdAndFlowGuid(Integer.valueOf(schId), flowGuid).orElse(null);
if (reg != null && reg.getStatus() == 0 && StringUtils.hasText(reg.getSecret())) {
return reg.getSecret();
}
} catch (NumberFormatException ignored) {
// schId 非法按无注册处理 → 401
}
return null;
}
private String hmacSha256Base64(String secret, String content) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(raw);
} catch (Exception e) {
throw new IllegalStateException("HmacSHA256 计算失败", e);
}
}
private boolean constantTimeEquals(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
}
@@ -18,6 +18,8 @@ import java.util.Map;
* 通知-流程中心接缝接口(先审后发)。
* <ul>
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
* <li>GET Flow/Binding:应用设置 &gt; 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);</li>
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
* </ul>
@@ -36,6 +38,22 @@ public class NoticeFlowController {
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
}
/**
* 应用设置 &gt; 流程绑定:本校绑定视图(bound/flowGuid/flowName/flowEnabled)。
* 登录即可读——发通知页靠它决定「提交审核」还是「发送」。
*/
@GetMapping("Binding")
public Map<String, Object> binding(BoaUser user) {
return noticeFlowService.bindingView(currentSchId(user), user);
}
/** 保存/解除绑定(flowGuid 空=解绑);仅本校管理员可改 */
@PostMapping("Binding/Save")
public Map<String, Object> saveBinding(@RequestParam(value = "flowGuid", required = false) String flowGuid,
BoaUser user) {
return noticeFlowService.saveBinding(user, flowGuid);
}
/** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */
@PostMapping("TrySubmit")
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
@@ -1,4 +1,4 @@
package com.yunhai.notice.controller.callback;
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.service.flow.FlowCallbackService;
import lombok.RequiredArgsConstructor;
@@ -6,13 +6,21 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 流程中心回调接收端点(方案 §5.3;/callback/* 不在 CAS url-pattern 内,由
* FlowCallbackVerifyFilter 完成 X-Flw-* 四头验签后才到达这里)。
* 流程中心回调接收端点(挂在开放接口域 /oapi/notice/flow/callback)。
* <p>
* <b>验签(复用平台应用验签能力)</b>:不建独立回调验签过滤器——回调路径落在
* {@code yh.app-auth.server.enforce-patterns: [/oapi/*]} 内,由平台
* {@code AppAuthFilter}(X-App-Key/X-Timestamp/X-Signature,HmacSHA256(method\npath\n ts),
* 密钥取 trusted-apps 里 flow-center 的 appKey=100019)验签 + {@code RequireAppSignFilter}
* 拒绝匿名请求。推送方 flow-center 侧由 {@code CallbackAppSigner} 用同一套 canonical 规则签名
* (注册时申报的 base-path 即本服务 context-path,见 {@link com.yunhai.notice.service.flow.NoticeFlowProperties})。
* 原来的 /callback/* + X-Flw-* 自定义过滤器已下线;X-Flw-* 头仍由 flow-center 保留发送,本服务不再校验。
* <p>
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
@@ -21,23 +29,24 @@ import org.springframework.web.bind.annotation.RestController;
*/
@Slf4j
@RestController
@RequestMapping("/callback")
@RequestMapping("/oapi/notice/flow")
@RequiredArgsConstructor
public class FlowCallbackController {
public class OapiFlowCallbackController {
private final FlowCallbackService callbackService;
@PostMapping("/flow")
public ResponseEntity<String> flow(@RequestBody byte[] body) {
@PostMapping("/callback")
public ResponseEntity<String> callback(@RequestBody byte[] body,
@RequestAttribute(name = "yh.app.auth", required = false) String appKey) {
try {
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
return ResponseEntity.ok("{\"ok\":true}");
} catch (FlowCallbackService.TransientCallbackException e) {
log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e);
log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): 调用方={} {}", appKey, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
} catch (Exception e) {
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)", e);
log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)调用方={}", appKey, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
}
}
@@ -0,0 +1,39 @@
package com.yunhai.notice.entity;
import com.yunhai.notice.common.listener.NoticeAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 应用级审核流程绑定(应用设置 &gt; 流程绑定,按学校一条)。
* <p>
* 语义(先审后发规则单一事实源):「通知对象=全部用户」且本校已绑定流程时,发通知必须走审核
* (status=0 审核载体,回调 completed 才发布);「通知对象=指定人员」与未绑定一律直发。
* 行不存在 = 未绑定。通知落库时把 flow_guid 快照进 notice 行,故改绑/解绑不影响在途审批。
*/
@Entity
@Table(name = "notice_flow_binding",
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_binding_sch", columnNames = {"sch_id"}))
@Getter
@Setter
@EntityListeners(NoticeAuditListener.class)
public class NoticeFlowBinding extends BaseEntity {
/** 租户键(一校一条绑定) */
@Column(name = "sch_id")
private Integer schId;
/** 绑定的流程 GUID(flow-center flw_def.flow_guid,跨版本稳定) */
@Column(name = "flow_guid", length = 32)
private String flowGuid;
/** 流程名快照(流程中心不可达时设置页/提示条仍可显示) */
@Column(name = "flow_name", length = 128)
private String flowName;
}
@@ -1,13 +1,16 @@
package com.yunhai.notice.model.flow;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.yunhai.platform.openapi.dto.FlowDto;
import lombok.Data;
import java.util.List;
import lombok.EqualsAndHashCode;
/**
* 流程中心 /oapi/flow/** 契约模型(与 flow-center OapiModels 逐字对齐,
* 契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5——两侧已编码,改动须双向同步)。
* 流程中心 /oapi/flow/** 契约模型。<br>
* 2026-09-16 起全部改为 {@code extends} SDK 门面 DTO({@code yh-openapi-client FlowDto},
* 与 flow-center OapiModels 逐字对齐,契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5)
* ——字段单一事实源收敛到 SDK,本类仅保留 Notice 侧类型名(消费方签名零改动),
* 并可直接作为 {@code YschFlowOpenApi} 泛型出入参传递。
*/
public final class FlowModels {
@@ -16,68 +19,41 @@ public final class FlowModels {
/** 流程摘要(GET /oapi/flow/list 元素) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class FlowSummaryVo {
private String flowGuid;
private String name;
private Integer version;
private String nodeSummary;
public static class FlowSummaryVo extends FlowDto.FlowSummary {
}
/** 代发起请求体(POST /oapi/flow/apply) */
@Data
public static class ApplyBody {
private String flowGuid;
private String bizType;
private String bizId;
private String bizTitle;
private String bizUrl;
/** 业务摘要快照(对象透传,flow-center 统一落 JSON 串) */
private Object bizSnapshot;
private Integer submitterId;
private String submitterName;
@EqualsAndHashCode(callSuper = true)
public static class ApplyBody extends FlowDto.ApplyBody {
}
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApplyResultVo {
private Long instanceId;
private boolean duplicated;
public static class ApplyResultVo extends FlowDto.ApplyResult {
}
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class InstanceVo {
private Long instanceId;
private Integer status;
private Integer version;
private String nodeSummary;
/** 办理轨迹 JSON(含 assigneeId,办理人放行判定用) */
private String traceJson;
private List<Integer> assigneeIds;
private String currentNodes;
private String bizTitle;
private Integer submitterId;
private String submitterName;
private String submitTime;
private String finishTime;
public static class InstanceVo extends FlowDto.Instance {
}
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
@Data
public static class CallbackRegBody {
private String flowGuid;
private String callbackUrl;
/** 订阅事件逗号分隔,空=全部 */
private String eventTypes;
@EqualsAndHashCode(callSuper = true)
public static class CallbackRegBody extends FlowDto.CallbackRegBody {
}
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class CallbackRegResultVo {
private String secret;
public static class CallbackRegResultVo extends FlowDto.CallbackRegResult {
}
}
@@ -20,6 +20,13 @@ public class NoticeQuery extends BasePageQuery {
/** 状态过滤:null 不过滤;1 已发布 */
private Integer status;
/**
* 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status):
* 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
* <b>-1 = 无需审核</b>(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。
*/
private Integer auditStatus;
/** 类型过滤:1 普通 / 2 重要 */
private Integer type;
@@ -51,9 +51,6 @@ public class NoticeRequest {
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
private Integer sourceId;
/**
* 审核流程 GUID(可选,先审后发):非空=提交审核(status=0 待回调 completed 才发布);
* 空=原直发链路(一字不改)。编辑时可变更绑定(变更即走新一轮审核)。
*/
private String flowGuid;
// 审核流程不再由请求传入:绑定在「应用设置 > 流程绑定」(notice_flow_binding,按学校一条),
// 由 NoticeService 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。
}
@@ -0,0 +1,11 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeFlowBinding;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface NoticeFlowBindingRepository extends JpaRepository<NoticeFlowBinding, Integer> {
Optional<NoticeFlowBinding> findBySchId(Integer schId);
}
@@ -57,6 +57,11 @@ public class FlowCallbackService {
}
String eventId = text(payload, "eventId");
String event = text(payload, "event");
// 注册探活回声:ping 体只有 eventId/event/flowGuid,无 bizType/bizId,必须在必填校验之前返回
// (原实现靠回调验签过滤器短路;迁到 /oapi 后 ping 会真到 controller,由应用验签保证来源可信)
if ("ping".equals(event)) {
return;
}
String bizType = text(payload, "bizType");
String bizId = text(payload, "bizId");
Long instanceId = longVal(payload, "instanceId");
@@ -66,9 +71,6 @@ public class FlowCallbackService {
log.warn("[notice-flow] 回调缺必要字段(丢弃):event={} eventId={}", event, eventId);
return; // 协议毒消息 → 200
}
if ("ping".equals(event)) {
return; // 注册探活回声(本不应到这——过滤器已直接 200,防御性兜底)
}
if (!FlowCenterClient.BIZ_TYPE.equals(bizType)) {
log.warn("[notice-flow] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
return; // 不可重试事件 → 200
@@ -17,11 +17,11 @@ import java.util.List;
import java.util.Map;
/**
* FlowClient 默认实现:直接构建在 Tier 0 {@link YschOpenCall} 之上(两级模型纪律 1"先 Tier 0",
* 不手写 RestTemplate/签名代码)。目标地址单一事实源 = {@code notice.flow.base-url}(ad-hoc
* TargetSpec、base-path 空=裸地址直连,与 flow-center 无 context-path 的部署一致);
* 签名/信封解析/错误映射全部由 YschOpenCall 统一承载(401/403→DException、code!=0→DException、
* 其他传输错→OpenApiException)。
* FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key=
* {@code flowcenter}:yh.call.targets &gt; yh.open.services &gt; 枚举 base-host 兜底,
* 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键
* {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射
* 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
*/
@Slf4j
@@ -36,6 +36,8 @@ public class FlowCenterClient implements FlowClient {
private final NoticeFlowProperties properties;
private final YschOpenCall openCall;
private final boolean directoryResolved;
public FlowCenterClient(NoticeFlowProperties properties,
ObjectProvider<YschOpenCall> openCallProvider,
ObjectProvider<LocalAppIdentityProvider> identityProvider,
@@ -49,11 +51,20 @@ public class FlowCenterClient implements FlowClient {
(host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath));
}
this.openCall = call;
// 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc
this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.PlatformService.FLOWCENTER.key());
if (directoryResolved) {
com.yunhai.platform.openapi.client.YschFlowOpenApi.setCall(call);
}
}
@Override
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.client.YschFlowOpenApi.listFlows(
schId, FlowModels.FlowSummaryVo.class);
}
return openCall.getList(target(), "/oapi/flow/list", params("schId", schId),
FlowModels.FlowSummaryVo.class);
}
@@ -61,6 +72,10 @@ public class FlowCenterClient implements FlowClient {
@Override
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.client.YschFlowOpenApi.apply(
schId, body, FlowModels.ApplyResultVo.class);
}
return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body,
FlowModels.ApplyResultVo.class);
}
@@ -68,6 +83,10 @@ public class FlowCenterClient implements FlowClient {
@Override
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.client.YschFlowOpenApi.instance(
bizType, bizId, schId, FlowModels.InstanceVo.class);
}
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("bizType", bizType);
@@ -78,6 +97,10 @@ public class FlowCenterClient implements FlowClient {
@Override
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.client.YschFlowOpenApi.registerCallback(
schId, body, FlowModels.CallbackRegResultVo.class);
}
return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body,
FlowModels.CallbackRegResultVo.class);
}
@@ -71,7 +71,21 @@ public class NoticeFlowGuard {
throw notFound();
}
private boolean isAdmin(BoaUser user) {
/**
* 管理员校验(应用设置 &gt; 流程绑定保存用):绑定决定全校"发全部用户是否需要审核",
* 与"谁能看未发布通知"同一口径({@link #ADMIN_ROLE_IDS})。
*/
public void requireAdmin(BoaUser user) {
if (user == null || !isAdmin(user)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅本校管理员可修改审核流程绑定");
}
}
/** 是否本校管理员(口径同下方守卫;绑定页据此决定是否可编辑) */
public boolean isAdmin(BoaUser user) {
if (user == null) {
return false;
}
if (user.getType() != null && user.getType() == 1) {
return true;
}
@@ -24,8 +24,18 @@ public class NoticeFlowProperties {
*/
private String callbackBase = "http://localhost:8101";
/** 回调路径(挂在本服务上,FlowCallbackVerifyFilter 只拦 /callback/*) */
private String callbackPath = "/callback/flow";
/**
* 回调路径:挂在开放接口域下,由平台应用验签(/oapi/* 强制)保护,不再有独立回调过滤器。
*/
private String callbackPath = "/oapi/notice/flow/callback";
/**
* 本服务部署 context-path(可选,无则空):注册时随 basePath 一并申报给 flow-center,
* 推送方按此从回调 URL 的 path 中剥离后再签 X-Signature,与平台 AppAuthFilter 用
* request.contextPath 剥离后的 path 对齐。生产 Notice 部署在 /notice 前缀下,须配 /notice;
* 配错的表现是回调 401(推送方签的 target 与本服务验签 target 不一致)。
*/
private String callbackBasePath = "";
/** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */
private String frontBaseUrl = "http://localhost:8077";
@@ -3,10 +3,12 @@ package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeFlowBinding;
import com.yunhai.notice.entity.NoticeFlowReg;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
import com.yunhai.notice.repository.NoticeFlowRegRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository;
@@ -18,6 +20,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
@@ -39,6 +42,7 @@ public class NoticeFlowService {
private final NoticeRepository noticeRepository;
private final NoticeFlowRegRepository regRepository;
private final NoticeFlowBindingRepository bindingRepository;
private final UpFileRepository upFileRepository;
private final FlowClient flowClient;
private final NoticeFlowProperties properties;
@@ -136,6 +140,8 @@ public class NoticeFlowService {
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
body.setFlowGuid(flowGuid);
body.setCallbackUrl(callbackUrl());
// 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐)
body.setBasePath(properties.getCallbackBasePath());
FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body);
if (reg == null) {
reg = new NoticeFlowReg();
@@ -256,6 +262,84 @@ public class NoticeFlowService {
return currentBoaUser == null ? null : currentBoaUser.get();
}
// ==================== 应用级流程绑定(应用设置 > 流程绑定) ====================
/**
* 本校绑定视图(设置页展示 / 发通知页按钮显隐):bound / flowGuid / flowName / flowEnabled / canEdit。
* 无学校身份或未绑定 → bound=false。读取不需要管理员(发通知页人人都要据此判断是否走审核)。
*/
public Map<String, Object> bindingView(Integer schId, BoaUser user) {
Map<String, Object> view = new LinkedHashMap<>();
NoticeFlowBinding binding = schId == null ? null : bindingRepository.findBySchId(schId).orElse(null);
boolean bound = binding != null && StringUtils.hasText(binding.getFlowGuid());
view.put("bound", bound);
view.put("flowGuid", bound ? binding.getFlowGuid() : null);
view.put("flowName", bound ? binding.getFlowName() : null);
view.put("flowEnabled", properties.isEnabled());
view.put("canEdit", noticeFlowGuard.isAdmin(user));
return view;
}
/**
* 发通知分流唯一判定入口:本校已绑定且流程中心可用时返回绑定 GUID;否则 null(走直发)。
* 流程中心未接入(properties.enabled=false)时不进审核轨道——否则通知会永远停在未发布态。
*/
public String boundFlowGuid(Integer schId) {
if (schId == null || !properties.isEnabled()) {
return null;
}
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
if (binding == null || !StringUtils.hasText(binding.getFlowGuid())) {
return null;
}
return binding.getFlowGuid();
}
/**
* 保存绑定(空 flowGuid=解绑,直接删行)。绑定的流程必须是流程中心"已发布"流程——
* 值由 options() 同一数据源校验,避免手改请求把不存在的 GUID 落库(发通知时才发现)。
*/
@Transactional
public Map<String, Object> saveBinding(BoaUser user, String flowGuid) {
noticeFlowGuard.requireAdmin(user);
Integer schId = currentSchId(user);
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
if (!StringUtils.hasText(flowGuid)) {
if (binding != null) {
bindingRepository.delete(binding);
log.info("[notice-flow] 解除流程绑定:schId={} 原流程={}", schId, binding.getFlowGuid());
}
return bindingView(schId, user);
}
String name = requirePublishedFlowName(schId, flowGuid.trim());
if (binding == null) {
binding = new NoticeFlowBinding();
binding.setSchId(schId);
}
binding.setFlowGuid(flowGuid.trim());
binding.setFlowName(name);
bindingRepository.save(binding);
log.info("[notice-flow] 保存流程绑定:schId={} flowGuid={} name={}", schId, binding.getFlowGuid(), name);
return bindingView(schId, user);
}
/** 校验流程 GUID 属于本校已发布流程并返回流程名(流程中心不可达时抛业务异常,不落脏绑定) */
private String requirePublishedFlowName(Integer schId, String flowGuid) {
for (FlowModels.FlowSummaryVo flow : options(schId)) {
if (flowGuid.equals(flow.getFlowGuid())) {
return flow.getName();
}
}
throw new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + flowGuid);
}
private Integer currentSchId(BoaUser user) {
if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "无法确定当前学校身份,请重新登录后再试");
}
return user.getCurrentRole().getSchId();
}
private Notice requireNotice(Integer noticeId) {
return noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
@@ -131,29 +131,32 @@ public class NoticeService implements INoticeService {
entity.setId(null);
entity.setDeptName(resolveDeptName(request));
normalizeReceiveList(entity, request.getReceiveList());
// 发布分流(先审后发):绑 flowGuid → status=0 未发布(审核载体)+ audit=0 待提交,
// 提交动作 AFTER_COMMIT 出站(D9:apply 不进 DB 事务,失败保持 0 可幂等重推);
// 未绑 → 原直发链路一字不改(status=1 即发布)
boolean flowBound = StringUtils.hasText(request.getFlowGuid());
if (flowBound) {
entity.setStatus(UNPUBLISHED);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
entity.setFlowGuid(request.getFlowGuid().trim());
} else {
entity.setStatus(PUBLISHED);
}
entity.setPublisherAccount(currentAccount());
entity.setPublisherName(currentName());
entity.setSchId(currentSchId());
entity.setIsPulled(0);
entity.setReadCount(0);
// 发布分流(先审后发):应用设置绑定了流程 且 通知对象=全部用户 → status=0 审核载体 + audit=0
// 待提交,提交动作 AFTER_COMMIT 出站(D9:apply 不进 DB 事务,失败保持 0 可幂等重推);
// 指定人员 / 未绑定 → 原直发链路一字不改(status=1 即发布)——绑定值由后端判定,请求无法绕过
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList());
if (needAudit) {
entity.setStatus(UNPUBLISHED);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
entity.setFlowGuid(boundFlowGuid);
// 定时发送与审核互斥(审核通过前本就不发布;前端已隐藏定时入口,此处兜底清空)
entity.setTimingSendTime(null);
} else {
entity.setStatus(PUBLISHED);
}
noticeRepository.save(entity);
// 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害)
saveReceivers(entity);
// 绑定附件(前端上传后回传的 fileIds 逗号串)
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
if (flowBound) {
// 短信时机后移:绑流程的通知 create 时不发,回调 completed 才发(审核通过才发)
if (needAudit) {
// 短信时机后移:走审核的通知 create 时不发,回调 completed 才发(审核通过才发)
noticeFlowService.submitAfterCommit(entity.getId());
} else {
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
@@ -162,6 +165,11 @@ public class NoticeService implements INoticeService {
return entity.getId();
}
/** 通知对象是否全员(normalizeReceiveList 已把 all/UserAll/ALL 归一为 "ALL") */
private boolean isAllReceiver(String receiveList) {
return receiveList != null && "ALL".equalsIgnoreCase(receiveList.trim());
}
/**
* 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见,
* 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。
@@ -187,8 +195,6 @@ public class NoticeService implements INoticeService {
if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) {
throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回");
}
boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid());
boolean nowFlowBound = StringUtils.hasText(request.getFlowGuid());
entity.setTitle(request.getTitle());
entity.setContent(request.getContent());
entity.setType(request.getType());
@@ -213,24 +219,37 @@ public class NoticeService implements INoticeService {
}
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
}
// 审核绑定变更:新绑/换绑 → 回到 0 待提交(提交动作 AFTER_COMMIT 出站);解绑 → 直发即发布
if (nowFlowBound) {
entity.setFlowGuid(request.getFlowGuid().trim());
// 分流重算(每次保存都按当前绑定与通知对象判定,规则与 create 一致):
// 全体用户 + 已绑定 → 进/留审核轨道;指定人员 → 退出轨道直发
boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid());
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
boolean needAudit = isAllReceiver(entity.getReceiveList()) && boundFlowGuid != null;
if (needAudit) {
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173),
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
entity.setFlowResultJson(null);
} else if (wasFlowBound) {
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
entity.setTimingSendTime(null); // 定时发送与审核互斥
} else {
// 直发(指定人员 / 未绑定):清审核镜像字段并置发布
// (原实现在此漏置 status,会把解绑/改指定人员的通知永久留在未发布态)
entity.setFlowGuid(null);
entity.setAuditStatus(null);
entity.setFlowInstanceId(null);
entity.setLastInstanceVersion(null);
entity.setFlowResultJson(null);
entity.setStatus(PUBLISHED);
}
noticeRepository.save(entity);
if (nowFlowBound) {
if (needAudit) {
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
noticeFlowService.submitAfterCommit(entity.getId());
} else if (wasFlowBound) {
// 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发)
trySendSms(entity);
}
}
@@ -321,17 +340,31 @@ public class NoticeService implements INoticeService {
entity.setId(null);
entity.setDeptName(resolveDeptName(request));
normalizeReceiveList(entity, request.getReceiveList());
entity.setStatus(PUBLISHED);
entity.setPublisherAccount(currentAccount());
entity.setPublisherName(currentName());
entity.setParentId(sourceId);
entity.setIsPulled(0);
entity.setIsPulled(0);
entity.setReadCount(0);
entity.setParentId(sourceId);
entity.setSchId(currentSchId());
// 转发同样是"发通知":分流规则与 publish 一致(绑定 + 全员 → 审核;否则直发)。
// 不这样收口的话,全员通知可绕开审核——转发一条给全部用户即绕过先审后发。
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList());
if (needAudit) {
entity.setStatus(UNPUBLISHED);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
entity.setFlowGuid(boundFlowGuid);
entity.setTimingSendTime(null);
} else {
entity.setStatus(PUBLISHED);
}
noticeRepository.save(entity);
saveReceivers(entity);
trySendSms(entity);
if (needAudit) {
noticeFlowService.submitAfterCommit(entity.getId());
} else {
trySendSms(entity);
}
return entity.getId();
}
@@ -423,6 +456,14 @@ entity.setIsPulled(0);
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
// 审核状态筛选:-1 = 无需审核(audit_status is null,直发通知;审批态列显示 "—" 的那些行)
if (query.getAuditStatus() != null) {
if (query.getAuditStatus() < 0) {
predicates.add(cb.isNull(root.get("auditStatus")));
} else {
predicates.add(cb.equal(root.get("auditStatus"), query.getAuditStatus()));
}
}
appendCommonPredicates(query, root, cb, predicates);
// scope="user" → 只看自己发布的通知
if ("user".equalsIgnoreCase(query.getScope())) {
@@ -1,63 +0,0 @@
package com.yunhai.notice.utils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后,
* 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。
*/
public class ReReadableRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public ReReadableRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (InputStream in = request.getInputStream()) {
byte[] chunk = new byte[4096];
int read;
while ((read = in.read(chunk)) > 0) {
buffer.write(chunk, 0, read);
}
}
this.body = buffer.toByteArray();
}
public String bodyString() {
return new String(body, StandardCharsets.UTF_8);
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream source = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return source.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
throw new UnsupportedOperationException();
}
@Override
public int read() {
return source.read();
}
};
}
}
@@ -0,0 +1,16 @@
-- 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条)
-- 语义:绑定后「通知对象=全部用户」必须审核通过才发布;「指定人员」与未绑定一律直发。
-- 通知落库时把 flow_guid 快照进 notice 行,改绑/解绑不影响在途审批。
-- 本地 ddl-auto=update 会自动建表;生产执行本脚本。
CREATE TABLE IF NOT EXISTS `notice_flow_binding` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`sch_id` INT NULL COMMENT '学校ID(一校一条)',
`flow_guid` VARCHAR(32) NULL COMMENT 'flow-center 流程 GUID(flw_def.flow_guid,跨版本稳定)',
`flow_name` VARCHAR(128) NULL COMMENT '流程名快照(流程中心不可达时展示兜底)',
`create_by` VARCHAR(64) NULL COMMENT '创建人',
`create_time` DATETIME NULL COMMENT '创建时间',
`update_by` VARCHAR(64) NULL COMMENT '更新人',
`update_time` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_notice_flow_binding_sch` (`sch_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='应用级审核流程绑定';