@@ -3,10 +3,12 @@ package com.yunhai.notice;
|
||||
import com.alibaba.fastjson.parser.ParserConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.yunhai.notice")
|
||||
@EnableTransactionManagement
|
||||
@EnableScheduling
|
||||
public class NoticeApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yunhai.notice.controller.api;
|
||||
|
||||
import com.yunhai.notice.config.Config;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowService;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接缝接口(先审后发)。
|
||||
* <ul>
|
||||
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
|
||||
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
||||
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/notice/flow/")
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowController {
|
||||
|
||||
private final NoticeFlowService noticeFlowService;
|
||||
|
||||
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
|
||||
@GetMapping("Options")
|
||||
public List<FlowModels.FlowSummaryVo> options(BoaUser user,
|
||||
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
|
||||
}
|
||||
|
||||
/** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */
|
||||
@PostMapping("TrySubmit")
|
||||
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
noticeFlowService.trySubmitFlow(serid, user);
|
||||
}
|
||||
|
||||
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false 的未接入语义;未发布仅三类人可见) */
|
||||
@GetMapping("Trace")
|
||||
public Map<String, Object> trace(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
return noticeFlowService.buildTrace(serid, user);
|
||||
}
|
||||
|
||||
private Integer currentSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null) {
|
||||
return null;
|
||||
}
|
||||
return user.getCurrentRole().getSchId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.notice.controller.callback;
|
||||
|
||||
import com.yunhai.notice.service.flow.FlowCallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 流程中心回调接收端点(方案 §5.3;/callback/* 不在 CAS url-pattern 内,由
|
||||
* FlowCallbackVerifyFilter 完成 X-Flw-* 四头验签后才到达这里)。
|
||||
* <p>
|
||||
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
||||
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
||||
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
|
||||
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/callback")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackController {
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
|
||||
@PostMapping("/flow")
|
||||
public ResponseEntity<String> flow(@RequestBody byte[] body) {
|
||||
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);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
} catch (Exception e) {
|
||||
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
|
||||
log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.notice.controller.oapi;
|
||||
|
||||
import com.yunhai.notice.service.IDictService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 开放接口 demo(两级模型试点 P2.1:Notice 作为提供方开放 /oapi/**)。
|
||||
* 强制验签由 starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}):
|
||||
* 浏览器/匿名直访 401,注册应用带 X-App-* 签名三件套直调通过(trusted-apps 收调用方密钥)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/notice")
|
||||
@RequiredArgsConstructor
|
||||
public class OapiDictController {
|
||||
|
||||
private final IDictService dictService;
|
||||
|
||||
/** 部门字典(demo:type=1 部门;调用方签名鉴权由 enforce-patterns 强制) */
|
||||
@GetMapping("/getDict")
|
||||
public List<Map<String, Object>> getDict(@RequestParam(value = "type", defaultValue = "1") Integer type) {
|
||||
return dictService.getSysDic(type);
|
||||
}
|
||||
}
|
||||
@@ -145,4 +145,34 @@ public class Notice extends BaseEntity {
|
||||
*/
|
||||
@Column(name = "sms_flag")
|
||||
private Integer smsFlag = 0;
|
||||
|
||||
/**
|
||||
* 短信实际发送时间(防重复发送的幂等标记):NULL=未发。
|
||||
* 直发/审批通过/到点补发三条链路共用(SmsSendService 原子认领);本地随 ddl-auto=update
|
||||
* 自动加列,生产执行 deploy/sql/20260913_add_sms_sent_at.sql。
|
||||
*/
|
||||
@Column(name = "sms_sent_at")
|
||||
private LocalDateTime smsSentAt;
|
||||
|
||||
// ===== 先审后发(流程中心托管):全部可空,NULL=未绑流程(原直发链路,存量零迁移) =====
|
||||
|
||||
/** 绑定的流程 GUID;NULL=直发(原链路一字不改) */
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程中心办件 id(apply 成功回填) */
|
||||
@Column(name = "flow_instance_id")
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 审核镜像状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(FlowAuditStatusEnum) */
|
||||
@Column(name = "audit_status")
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 已消费的最大回调 instanceVersion(乱序丢弃闸门) */
|
||||
@Column(name = "last_instance_version")
|
||||
private Integer lastInstanceVersion;
|
||||
|
||||
/** 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示) */
|
||||
@Column(name = "flow_result_json", columnDefinition = "LONGTEXT")
|
||||
private String flowResultJson;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.yunhai.notice.common.listener.NoticeAuditListener;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 流程中心回调事件去重表(照 svc-hall ServiceFlowEvent / flow-center 事件 uk 同构):
|
||||
* eventId 唯一约束兜底并发;重复投递(flow-center 持续重试语义下必然发生)命中唯一键即跳过。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notice_flow_event",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_event", columnNames = "event_id"),
|
||||
indexes = @Index(name = "idx_notice_flow_event_inst", columnList = "instance_id"))
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(NoticeAuditListener.class)
|
||||
public class NoticeFlowEvent extends BaseEntity {
|
||||
|
||||
/** 事件唯一键(32 位 uuid,flow-center 重试不变) */
|
||||
@Column(name = "event_id", length = 40, nullable = false)
|
||||
private String eventId;
|
||||
|
||||
/** 事件码:flow.submitted / task.created / task.handled / completed / rejected / withdrawn / ping */
|
||||
@Column(name = "event", length = 32)
|
||||
private String event;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
@Column(name = "instance_id")
|
||||
private Long instanceId;
|
||||
|
||||
@Column(name = "receive_time")
|
||||
private java.time.LocalDateTime receiveTime;
|
||||
|
||||
@Column(name = "payload_json", columnDefinition = "LONGTEXT")
|
||||
private String payloadJson;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
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;
|
||||
import com.yunhai.notice.common.listener.NoticeAuditListener;
|
||||
|
||||
/**
|
||||
* 流程中心回调注册(照 svc-hall ServiceFlowReg / flow-center FlwCallbackReg 同构):
|
||||
* sch+appKey+flowGuid 唯一;secret 为 flow-center reg 接口下发时落库的回调验签密钥。
|
||||
* 注意:flow-center 重复注册会<b>轮换 secret</b>,故仅在缺失时注册(ensureRegistered),
|
||||
* 绝不重复调用——失配恢复手段=管理端重新保存绑定。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notice_flow_reg",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_reg", columnNames = {"sch_id", "app_key", "flow_guid"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(NoticeAuditListener.class)
|
||||
public class NoticeFlowReg extends BaseEntity {
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 本应用在流程中心侧的 appKey(yh.app-auth.app-key,出站签名同一身份) */
|
||||
@Column(name = "app_key", length = 64)
|
||||
private String appKey;
|
||||
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
@Column(name = "callback_url", length = 500)
|
||||
private String callbackUrl;
|
||||
|
||||
/** 订阅事件逗号分隔,空=全部 */
|
||||
@Column(name = "event_types", length = 200)
|
||||
private String eventTypes;
|
||||
|
||||
/** flow-center reg 接口下发的回调验签密钥 */
|
||||
@Column(name = "secret", length = 64)
|
||||
private String secret;
|
||||
|
||||
/** 0 启用 / 1 停用(预留) */
|
||||
@Column(name = "status")
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.notice.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 通知审核状态(audit_status 列,对齐 flow-center FlwEnums.InstanceStatus 与 svc-hall ApplyFlowStatusEnum;
|
||||
* 未提交 0 为 Notice 特有——apply 尚未成功的暂态)。仅在绑定了审核流程(flow_guid 非空)时有意义。
|
||||
*/
|
||||
@Getter
|
||||
public enum FlowAuditStatusEnum {
|
||||
|
||||
PENDING_SUBMIT(0, "待提交"),
|
||||
APPROVING(10, "审批中"),
|
||||
APPROVED(20, "已通过"),
|
||||
REJECTED(30, "已驳回"),
|
||||
WITHDRAWN(40, "已撤回");
|
||||
|
||||
private final int code;
|
||||
private final String desc;
|
||||
|
||||
FlowAuditStatusEnum(int code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public static FlowAuditStatusEnum of(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
for (FlowAuditStatusEnum e : values()) {
|
||||
if (e.code == code) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String descOf(Integer code) {
|
||||
FlowAuditStatusEnum e = of(code);
|
||||
return e == null ? null : e.getDesc();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@ package com.yunhai.notice.enums;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 通知状态。无草稿态:发通知一律置为「已发布」,
|
||||
* 通知状态。status=1 已发布仍是用户端唯一可见态;
|
||||
* 先审后发引入 status=0 未发布(审核载体:绑流程通知提交后置 0,回调 completed 才置 1,
|
||||
* 审核进度独立成列 audit_status)。未绑流程的通知仍无草稿态(直发即 1),
|
||||
* 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。
|
||||
* 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。
|
||||
*/
|
||||
@Getter
|
||||
public enum NoticeStatusEnum {
|
||||
|
||||
UNPUBLISHED(0, "未发布"),
|
||||
PUBLISHED(1, "已发布");
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.notice.job;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSendService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 短信到点补发调度(§3.2 / BP-R5-2):扫"已发布 + 要发短信 + 从未发过 + 已到定时点"的行补发。
|
||||
* 覆盖场景:定时发送的通知在定时点之前审批通过——发布链路按设计跳过短信(可见性即时生效),
|
||||
* 原实现无任何补发导致短信永久丢失。幂等由 SmsSendService 的 sms_sent_at 原子认领保证
|
||||
* (并发/重复调度不双发);单条失败不阻断批次(归还认领,下轮重试)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SmsCatchupJob {
|
||||
|
||||
/** 单批上限(防御性:避免超大存量发布时一次性拉满) */
|
||||
private static final int BATCH = 200;
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final SmsSendService smsSendService;
|
||||
|
||||
@Value("${notice.sms.catchup-enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
/** fixedDelay=5min(上一轮跑完起算,单实例内天然不重叠;跨实例由原子认领闸) */
|
||||
@Scheduled(fixedDelayString = "${notice.sms.catchup-fixed-delay-ms:300000}", initialDelay = 30_000)
|
||||
public void catchupDueSms() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
List<Notice> due = noticeRepository.findSmsDue(LocalDateTime.now(), PageRequest.of(0, BATCH));
|
||||
if (due.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("[notice-sms] 到点补发扫描:{} 条待发", due.size());
|
||||
int sent = 0;
|
||||
for (Notice notice : due) {
|
||||
try {
|
||||
if (smsSendService.sendOnce(notice)) {
|
||||
sent++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-sms] 补发单条失败(下轮重试)notice={}:{}", notice.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
log.info("[notice-sms] 到点补发完成:本轮发送 {} 条", sent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.notice.model.flow;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程中心 /oapi/flow/** 契约模型(与 flow-center OapiModels 逐字对齐,
|
||||
* 契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5——两侧已编码,改动须双向同步)。
|
||||
*/
|
||||
public final class FlowModels {
|
||||
|
||||
private FlowModels() {
|
||||
}
|
||||
|
||||
/** 流程摘要(GET /oapi/flow/list 元素) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class FlowSummaryVo {
|
||||
private String flowGuid;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
}
|
||||
|
||||
/** 代发起请求体(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;
|
||||
}
|
||||
|
||||
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ApplyResultVo {
|
||||
private Long instanceId;
|
||||
private boolean duplicated;
|
||||
}
|
||||
|
||||
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
|
||||
@Data
|
||||
@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;
|
||||
}
|
||||
|
||||
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
|
||||
@Data
|
||||
public static class CallbackRegBody {
|
||||
private String flowGuid;
|
||||
private String callbackUrl;
|
||||
/** 订阅事件逗号分隔,空=全部 */
|
||||
private String eventTypes;
|
||||
}
|
||||
|
||||
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class CallbackRegResultVo {
|
||||
private String secret;
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,23 @@ public class NoticeDetailVo {
|
||||
@JsonProperty("tlist")
|
||||
private Object tlist;
|
||||
|
||||
// ===== 先审后发(编辑页驳回/撤回态展示) =====
|
||||
|
||||
/** 绑定的审核流程 GUID(null=直发未接流程) */
|
||||
private String flowGuid;
|
||||
|
||||
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑) */
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 审核状态文案 */
|
||||
private String auditStatusDesc;
|
||||
|
||||
/** 流程中心办件 id(撤回/催办直调 flow-center 用) */
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 终态明细(result/opinion/rejectNode;null=无) */
|
||||
private Object flowResult;
|
||||
|
||||
/**
|
||||
* 附件项(旧 ChaoSend 数组元素)。
|
||||
*/
|
||||
|
||||
@@ -50,4 +50,10 @@ public class NoticeRequest {
|
||||
|
||||
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
|
||||
private Integer sourceId;
|
||||
|
||||
/**
|
||||
* 审核流程 GUID(可选,先审后发):非空=提交审核(status=0 待回调 completed 才发布);
|
||||
* 空=原直发链路(一字不改)。编辑时可变更绑定(变更即走新一轮审核)。
|
||||
*/
|
||||
private String flowGuid;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ public class NoticeVo {
|
||||
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
|
||||
private String UpFile;
|
||||
|
||||
// ===== 先审后发(流程中心托管) =====
|
||||
|
||||
/** 绑定的审核流程 GUID(null=直发,未接流程中心) */
|
||||
private String flowGuid;
|
||||
|
||||
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑流程) */
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 审核状态文案(待提交/审批中/已通过/已驳回/已撤回;null=未绑流程) */
|
||||
private String auditStatusDesc;
|
||||
|
||||
/** 流程中心办件 id(提交成功后非空;详情页撤回/催办直调 flow-center 用) */
|
||||
private Long flowInstanceId;
|
||||
|
||||
private String createBy;
|
||||
private LocalDateTime createTime;
|
||||
private String updateBy;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeFlowEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface NoticeFlowEventRepository extends JpaRepository<NoticeFlowEvent, Integer> {
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NoticeFlowRegRepository extends JpaRepository<NoticeFlowReg, Integer> {
|
||||
|
||||
Optional<NoticeFlowReg> findBySchIdAndAppKeyAndFlowGuid(Integer schId, String appKey, String flowGuid);
|
||||
|
||||
Optional<NoticeFlowReg> findBySchIdAndFlowGuid(Integer schId, String flowGuid);
|
||||
|
||||
List<NoticeFlowReg> findBySchIdAndStatusOrderByUpdateTimeDesc(Integer schId, Integer status);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -20,6 +22,50 @@ public interface NoticeRepository extends JpaRepository<Notice, Integer>,
|
||||
*/
|
||||
long countByStatus(Integer status);
|
||||
|
||||
/** 回调事件处理专用:行级悲观锁读(并发推送串行化,防整实体保存互相覆盖丢更新) */
|
||||
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||
@org.springframework.data.jpa.repository.Query("select n from Notice n where n.id = :id")
|
||||
Optional<Notice> findByIdForUpdate(@org.springframework.data.repository.query.Param("id") Integer id);
|
||||
|
||||
/**
|
||||
* 提交审核后的原子回填(窄列更新,防旧快照整实体覆盖并发回调的推进):
|
||||
* 置 flow_instance_id + audit=10;若回调已并发先行落定 audit=20(审核通过)则保持 20 不回退。
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.flowInstanceId = :instanceId, "
|
||||
+ "n.auditStatus = (case when n.auditStatus = 20 then n.auditStatus else 10 end) "
|
||||
+ "where n.id = :id")
|
||||
int markApproving(@org.springframework.data.repository.query.Param("id") Integer id,
|
||||
@org.springframework.data.repository.query.Param("instanceId") Long instanceId);
|
||||
|
||||
/**
|
||||
* 短信发送原子认领(§3.2 防重复核心):置 sms_sent_at,仅当当前为 NULL(未发)时生效。
|
||||
* 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.smsSentAt = :sentAt where n.id = :id and n.smsSentAt is null")
|
||||
int markSmsSent(@Param("id") Integer id, @Param("sentAt") LocalDateTime sentAt);
|
||||
|
||||
/** 发送失败归还认领(下轮补发调度可重试) */
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.smsSentAt = null where n.id = :id and n.smsSentAt is not null")
|
||||
int clearSmsSent(@Param("id") Integer id);
|
||||
|
||||
/**
|
||||
* 到点待补发短信清单(§3.2 SmsCatchupJob 扫描):已发布 + 要发短信 + 从未发过 + 已到定时点
|
||||
* (无定时点即发;覆盖"审批通过早于定时点被跳过"的行——BP-R5-2 短信永久丢失缺口)。
|
||||
*/
|
||||
@Query("select n from Notice n where n.status = 1 and n.smsFlag = 1 and n.smsSentAt is null"
|
||||
+ " and (n.timingSendTime is null or n.timingSendTime <= :now) order by n.id asc")
|
||||
List<Notice> findSmsDue(@Param("now") LocalDateTime now,
|
||||
org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
/**
|
||||
* 已发布且已到点(定时发送未到点不计入)的通知数,用于用户端未读数统计。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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.NoticeFlowEvent;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.enums.NoticeStatusEnum;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* 流程中心回调事件处理(方案 §5.3,svc-hall FlowCallbackService 平移 + 5xx 语义修订 D2)。<br>
|
||||
* 返回语义(免对账核心,契约冻结):<b>瞬时失败抛 {@link TransientCallbackException} → HTTP 5xx</b>
|
||||
* (flow-center 持续重试追平);毒 JSON/缺字段/去重命中/乱序丢弃/办件不属本应用 → 正常返回(HTTP 200)。
|
||||
* 事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 + trySendSms 后置);
|
||||
* rejected→30;withdrawn→40。乱序闸门:instanceVersion 严格小于已消费版本即丢弃
|
||||
* (等于不丢——submitted 与 task.created 天然同版本,且不同事件同版本幂等安全)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackService {
|
||||
|
||||
/** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */
|
||||
public static class TransientCallbackException extends RuntimeException {
|
||||
public TransientCallbackException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final NoticeFlowEventRepository eventRepository;
|
||||
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** 处理一条回调推送体(已过验签过滤器)。 */
|
||||
@Transactional
|
||||
public void handle(String body) {
|
||||
JsonNode payload;
|
||||
try {
|
||||
payload = objectMapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
// 协议毒消息:不可重试,回 200 防无限重放
|
||||
log.warn("[notice-flow] 回调体非 JSON(丢弃):{}",
|
||||
body != null && body.length() > 200 ? body.substring(0, 200) + "..." : body);
|
||||
return;
|
||||
}
|
||||
String eventId = text(payload, "eventId");
|
||||
String event = text(payload, "event");
|
||||
String bizType = text(payload, "bizType");
|
||||
String bizId = text(payload, "bizId");
|
||||
Long instanceId = longVal(payload, "instanceId");
|
||||
Integer instanceVersion = intVal(payload, "instanceVersion");
|
||||
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(event)
|
||||
|| !StringUtils.hasText(bizType) || !StringUtils.hasText(bizId)) {
|
||||
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
|
||||
}
|
||||
// eventId 去重(uk 兜底并发):重复投递在持续重试语义下必然发生,幂等跳过
|
||||
if (eventRepository.existsByEventId(eventId)) {
|
||||
log.debug("[notice-flow] 重复投递跳过:eventId={}", eventId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NoticeFlowEvent row = new NoticeFlowEvent();
|
||||
row.setEventId(eventId);
|
||||
row.setEvent(event);
|
||||
row.setSchId(intVal(payload, "schId"));
|
||||
row.setInstanceId(instanceId);
|
||||
row.setReceiveTime(LocalDateTime.now());
|
||||
row.setPayloadJson(body);
|
||||
eventRepository.saveAndFlush(row);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.debug("[notice-flow] 并发重复投递(uk 命中)跳过:eventId={}", eventId);
|
||||
return; // 事务回滚无副作用(尚未写 notice),重复投递 → 200
|
||||
}
|
||||
|
||||
Integer noticeId;
|
||||
try {
|
||||
noticeId = Integer.valueOf(bizId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", bizId);
|
||||
return; // 协议毒消息 → 200
|
||||
}
|
||||
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
|
||||
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
|
||||
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
|
||||
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", bizId);
|
||||
return; // 不可重试 → 200
|
||||
}
|
||||
// 乱序闸门:早于已消费版本的迟到事件直接丢弃(重复eventId已被上一道闸拦下)
|
||||
if (instanceVersion != null && notice.getLastInstanceVersion() != null
|
||||
&& instanceVersion < notice.getLastInstanceVersion()) {
|
||||
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
|
||||
noticeId, instanceVersion, notice.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
advance(notice, event, instanceVersion, payload);
|
||||
} catch (TransientCallbackException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 重试追平(免对账核心语义)
|
||||
throw new TransientCallbackException("回调状态推进失败:notice=" + noticeId + " event=" + event, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
private void advance(Notice notice, String event, Integer instanceVersion, JsonNode payload) {
|
||||
Integer target;
|
||||
switch (event) {
|
||||
case "flow.completed":
|
||||
target = FlowAuditStatusEnum.APPROVED.getCode();
|
||||
break;
|
||||
case "flow.rejected":
|
||||
target = FlowAuditStatusEnum.REJECTED.getCode();
|
||||
break;
|
||||
case "flow.withdrawn":
|
||||
target = FlowAuditStatusEnum.WITHDRAWN.getCode();
|
||||
break;
|
||||
case "flow.submitted":
|
||||
case "flow.task.created":
|
||||
case "flow.task.handled":
|
||||
target = FlowAuditStatusEnum.APPROVING.getCode();
|
||||
break;
|
||||
default:
|
||||
log.warn("[notice-flow] 未知事件(丢弃):{}", event);
|
||||
return; // 未知事件码:不可重试 → 200
|
||||
}
|
||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
|
||||
// 命中守卫记 log 后正常返回(200,不触发 flow-center 重试——重试也改变不了完成顺序)。
|
||||
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
|
||||
if (isTerminal(notice.getAuditStatus())) {
|
||||
if (!isTerminal(target)) {
|
||||
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
|
||||
notice.getId(), notice.getAuditStatus(), event, instanceVersion);
|
||||
return;
|
||||
}
|
||||
if (StringUtils.hasText(notice.getFlowResultJson())) {
|
||||
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
|
||||
notice.getId(), notice.getAuditStatus(), event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notice.setAuditStatus(target);
|
||||
if (instanceVersion != null) {
|
||||
notice.setLastInstanceVersion(instanceVersion);
|
||||
}
|
||||
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
|
||||
if (target == FlowAuditStatusEnum.APPROVED.getCode()
|
||||
|| target == FlowAuditStatusEnum.REJECTED.getCode()
|
||||
|| target == FlowAuditStatusEnum.WITHDRAWN.getCode()) {
|
||||
JsonNode data = payload.get("data");
|
||||
String snapshot = data == null || data.isNull() ? null : data.toString();
|
||||
notice.setFlowResultJson(snapshot);
|
||||
}
|
||||
boolean justPublished = false;
|
||||
if (target == FlowAuditStatusEnum.APPROVED.getCode()) {
|
||||
// 审核通过 = 正式发布:置 status=1(用户端自此可见)+ 发布后置(短信后移到此刻)
|
||||
if (notice.getStatus() == null || notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode()) {
|
||||
notice.setStatus(NoticeStatusEnum.PUBLISHED.getCode());
|
||||
justPublished = true;
|
||||
}
|
||||
}
|
||||
noticeRepository.saveAndFlush(notice);
|
||||
if (justPublished) {
|
||||
sendSmsAfterPublish(notice);
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过后的短信后置(绑流程通知 create 时不发,方案 §5.2 短信时机后移) */
|
||||
private void sendSmsAfterPublish(Notice notice) {
|
||||
if (notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
|
||||
return;
|
||||
}
|
||||
// 与直发链路 trySendSms 的 isImmediatePublish 语义对齐:定时未到点的通知此刻不发
|
||||
// (可见性已生效),到点由 SmsCatchupJob 补发(§3.2 修 BP-R5-2 短信永久丢失缺口)
|
||||
if (notice.getTimingSendTime() != null
|
||||
&& notice.getTimingSendTime().isAfter(LocalDateTime.now())) {
|
||||
return;
|
||||
}
|
||||
smsSendService.sendOnce(notice);
|
||||
}
|
||||
|
||||
/** 审批终态(20 通过 / 30 驳回 / 40 撤回)——终态行的状态与结果明细只允许被新一轮(本地先置 0)改写 */
|
||||
private static boolean isTerminal(Integer auditStatus) {
|
||||
return auditStatus != null
|
||||
&& (auditStatus == FlowAuditStatusEnum.APPROVED.getCode()
|
||||
|| auditStatus == FlowAuditStatusEnum.REJECTED.getCode()
|
||||
|| auditStatus == FlowAuditStatusEnum.WITHDRAWN.getCode());
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
private static Long longVal(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asLong();
|
||||
}
|
||||
|
||||
private static Integer intVal(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asInt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.appauth.AppSignInterceptor;
|
||||
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||
import com.yunhai.platform.openapi.TargetSpec;
|
||||
import com.yunhai.platform.openapi.YschOpenCall;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
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)。
|
||||
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FlowCenterClient implements FlowClient {
|
||||
|
||||
/** flow-center 业务类型常量(契约冻结:bizType="notice"、bizId=notice.id) */
|
||||
public static final String BIZ_TYPE = "notice";
|
||||
|
||||
private static final String NOT_CONNECTED = "流程中心未接入,该功能不可用";
|
||||
|
||||
private final NoticeFlowProperties properties;
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
public FlowCenterClient(NoticeFlowProperties properties,
|
||||
ObjectProvider<YschOpenCall> openCallProvider,
|
||||
ObjectProvider<LocalAppIdentityProvider> identityProvider,
|
||||
ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
YschOpenCall call = openCallProvider.getIfAvailable();
|
||||
if (call == null) {
|
||||
// 兜底自建(正常路径由 yh-openapi-starter 装配 YschOpenCall bean);identity 缺失时
|
||||
// AppSignInterceptor 自动跳过签名(请求裸发,enforce 端会 401——配置问题尽早暴露)
|
||||
call = new YschOpenCall(new com.yunhai.platform.openapi.YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath));
|
||||
}
|
||||
this.openCall = call;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
|
||||
requireEnabled();
|
||||
return openCall.getList(target(), "/oapi/flow/list", params("schId", schId),
|
||||
FlowModels.FlowSummaryVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
|
||||
requireEnabled();
|
||||
return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body,
|
||||
FlowModels.ApplyResultVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", bizType);
|
||||
query.put("bizId", bizId);
|
||||
return openCall.get(target(), "/oapi/flow/instance", query, FlowModels.InstanceVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
|
||||
requireEnabled();
|
||||
return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body,
|
||||
FlowModels.CallbackRegResultVo.class);
|
||||
}
|
||||
|
||||
private TargetSpec target() {
|
||||
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
||||
return TargetSpec.of(properties.getBaseUrl(), "");
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!properties.isEnabled()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> params(String k, Object v) {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
if (k != null && v != null) {
|
||||
m.put(k, String.valueOf(v));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程中心出站接缝(Notice 首接方案 §5.1,方法面与 svc-hall FlowCenterClient 一致)。
|
||||
* 接口保留三重意义:防依赖(业务层不感知 YschOpenCall)、可测试(fake 注入)、可降级(enabled 开关)。
|
||||
*/
|
||||
public interface FlowClient {
|
||||
|
||||
/** 已发布流程列表(编辑页"审核流程"选择器数据源) */
|
||||
List<FlowModels.FlowSummaryVo> listFlows(Integer schId);
|
||||
|
||||
/** 代发起(biz uk 幂等:重复 apply 同办件;30/40 态 R4 重启推进) */
|
||||
FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body);
|
||||
|
||||
/** 按业务键查办件(进度卡片/放行判定/对账兜底;不存在返回 null) */
|
||||
FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId);
|
||||
|
||||
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
|
||||
FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
|
||||
* 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
|
||||
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。
|
||||
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowGuard {
|
||||
|
||||
/** 管理员角色白名单(校级管理员/分管领导等,与既有菜单权限体系对齐) */
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private static final long ASSIGNEE_CACHE_MS = 60_000L;
|
||||
|
||||
private final FlowClient flowClient;
|
||||
private final NoticeFlowProperties properties;
|
||||
|
||||
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CachedAssignees {
|
||||
final List<Integer> userIds;
|
||||
final long expireAt;
|
||||
|
||||
CachedAssignees(List<Integer> userIds) {
|
||||
this.userIds = userIds;
|
||||
this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户可查看未发布通知;不可见抛 FORBIDDEN(文案=查不到语义)。
|
||||
* status=1 已发布通知不走本守卫(既有行为零变化)。
|
||||
*/
|
||||
public void requireUnpublishedVisible(Notice notice, BoaUser user) {
|
||||
if (notice == null || user == null) {
|
||||
throw notFound();
|
||||
}
|
||||
// 1) 提交人本人
|
||||
if (StringUtils.hasText(notice.getPublisherAccount())
|
||||
&& notice.getPublisherAccount().equals(user.getAccount())) {
|
||||
return;
|
||||
}
|
||||
// 2) 本校管理员(type=1 校管 或 roleId 命中白名单)
|
||||
if (isAdmin(user)) {
|
||||
return;
|
||||
}
|
||||
// 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见)
|
||||
if (notice.getFlowInstanceId() != null && properties.isEnabled()
|
||||
&& currentAssignees(notice).contains(user.getId())) {
|
||||
return;
|
||||
}
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
private boolean isAdmin(BoaUser user) {
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return true;
|
||||
}
|
||||
if (user.getRoleIds() != null) {
|
||||
for (Integer roleId : user.getRoleIds()) {
|
||||
if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Integer> currentAssignees(Notice notice) {
|
||||
long now = System.currentTimeMillis();
|
||||
CachedAssignees cached = assigneeCache.get(notice.getFlowInstanceId());
|
||||
if (cached != null && cached.expireAt > now) {
|
||||
return cached.userIds == null ? java.util.Collections.emptyList() : cached.userIds;
|
||||
}
|
||||
List<Integer> assignees = null;
|
||||
try {
|
||||
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
|
||||
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
|
||||
if (instance != null) {
|
||||
assignees = instance.getAssigneeIds();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 流程中心不可达:fail-closed(不放行),日志留痕
|
||||
log.warn("[notice-flow] 办理人查询失败(fail-closed):notice={} {}", notice.getId(), e.toString());
|
||||
}
|
||||
assigneeCache.put(notice.getFlowInstanceId(),
|
||||
new CachedAssignees(assignees == null ? java.util.Collections.emptyList() : assignees));
|
||||
return assignees == null ? java.util.Collections.emptyList() : assignees;
|
||||
}
|
||||
|
||||
private BizException notFound() {
|
||||
return new BizException(ErrorCode.FORBIDDEN, "通知不存在或未发布");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接缝配置(notice.flow.* / notice.front.*)。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "notice.flow")
|
||||
public class NoticeFlowProperties {
|
||||
|
||||
/** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** flow-center 基址(Tier 0 目标;本地 http://localhost:8085,生产由 projman 注入) */
|
||||
private String baseUrl = "http://localhost:8085";
|
||||
|
||||
/**
|
||||
* 本应用对外可达基址(回调注册用):flow-center 探活与事件推送都打这里。
|
||||
* 默认回落 baseUrl 同端口的本服务直连地址;生产经网关时由 projman 注入(如 http://notice:8070/notice)。
|
||||
*/
|
||||
private String callbackBase = "http://localhost:8101";
|
||||
|
||||
/** 回调路径(挂在本服务上,FlowCallbackVerifyFilter 只拦 /callback/*) */
|
||||
private String callbackPath = "/callback/flow";
|
||||
|
||||
/** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */
|
||||
private String frontBaseUrl = "http://localhost:8077";
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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.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.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接合服务:提交/重提(D9 AFTER_COMMIT 出站)、回调注册管理、进度组装。
|
||||
* 提交语义(方案 §5.2):apply 幂等(bizId=notice.id),失败保持 0 待提交可重推;
|
||||
* 10 审批中禁止重复提交/编辑/删除(删除需先撤回)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowService {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final NoticeFlowRegRepository regRepository;
|
||||
private final UpFileRepository upFileRepository;
|
||||
private final FlowClient flowClient;
|
||||
private final NoticeFlowProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppAuthProperties appAuthProperties;
|
||||
private final CurrentBoaUser currentBoaUser;
|
||||
private final NoticeFlowGuard noticeFlowGuard;
|
||||
|
||||
/**
|
||||
* 注册后事务提交了再提交审核(D9:apply 出站不进 DB 事务,防 HTTP 长调用拖住连接);
|
||||
* 无活动事务时立即执行(测试/兜底)。
|
||||
* <p>E2E 实测教训:afterCommit 回调若留在原请求线程执行,OSIV 绑定的 EntityManager
|
||||
* 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行)——故提交动作
|
||||
* 一律切到独立线程(干净事务 + 无 ThreadLocal 残留),提交人身份在请求线程先捕获透传。
|
||||
*/
|
||||
public void submitAfterCommit(Integer noticeId) {
|
||||
final BoaUser user = currentUser();
|
||||
Runnable task = () -> trySubmitQuietly(noticeId, user);
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
SUBMIT_EXECUTOR.submit(task);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底) */
|
||||
private static final java.util.concurrent.ExecutorService SUBMIT_EXECUTOR =
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "notice-flow-submit");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/** 提交失败静默保持 0 待提交(行已落库不回滚;列表"待提交"态即提示,编辑页可重推) */
|
||||
private void trySubmitQuietly(Integer noticeId, BoaUser user) {
|
||||
try {
|
||||
trySubmitFlow(noticeId, user);
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-flow] 提交审核失败(保持待提交,可重推):notice={} {}", noticeId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交/重提审核(幂等入口:编辑页重提按钮 / create 后置共用)。
|
||||
* audit=0/30/40 可提交;10 审批中拒绝;apply 成功回填 flow_instance_id + audit=10。
|
||||
* apply 失败抛业务异常,notice 行保持 0 待提交(不回滚已落库的编辑内容)。
|
||||
*/
|
||||
public void trySubmitFlow(Integer noticeId) {
|
||||
trySubmitFlow(noticeId, currentUser());
|
||||
}
|
||||
|
||||
/** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */
|
||||
public void trySubmitFlow(Integer noticeId, BoaUser user) {
|
||||
Notice notice = requireNotice(noticeId);
|
||||
// BP-R5-3:Trace/TrySubmit 原先仅验存在性,任意登录用户可对他人通知触发重提——补
|
||||
// NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
if (!StringUtils.hasText(notice.getFlowGuid())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未绑定审核流程");
|
||||
}
|
||||
Integer audit = notice.getAuditStatus();
|
||||
if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回");
|
||||
}
|
||||
ensureRegistered(notice);
|
||||
FlowModels.ApplyBody body = buildApplyBody(notice, user);
|
||||
FlowModels.ApplyResultVo result = flowClient.apply(notice.getSchId(), body);
|
||||
// E2E 实测教训:此处曾整实体 save——apply 出站期间回调事件可能已并发推进
|
||||
// (submitted 先行置 10 / completed 置 20),旧快照盲写会把状态打回;改为窄列原子更新,
|
||||
// 且 audit=20(已通过)不回退(apply 返回晚于 completed 送达的合法场景)
|
||||
noticeRepository.markApproving(noticeId, result.getInstanceId());
|
||||
log.info("[notice-flow] 提交审核成功:notice={} instance={} duplicated={}",
|
||||
noticeId, result.getInstanceId(), result.isDuplicated());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调注册:仅 reg 缺失时注册并落 secret。<b>绝不重复调用</b>——flow-center 重复注册会轮换
|
||||
* secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。
|
||||
*/
|
||||
void ensureRegistered(Notice notice) {
|
||||
Integer schId = notice.getSchId();
|
||||
String flowGuid = notice.getFlowGuid();
|
||||
String appKey = appAuthProperties.getAppKey();
|
||||
NoticeFlowReg reg = regRepository
|
||||
.findBySchIdAndAppKeyAndFlowGuid(schId, appKey, flowGuid)
|
||||
.orElse(null);
|
||||
if (reg != null && StringUtils.hasText(reg.getSecret()) && reg.getStatus() == 0) {
|
||||
return;
|
||||
}
|
||||
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
|
||||
body.setFlowGuid(flowGuid);
|
||||
body.setCallbackUrl(callbackUrl());
|
||||
FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body);
|
||||
if (reg == null) {
|
||||
reg = new NoticeFlowReg();
|
||||
reg.setSchId(schId);
|
||||
reg.setAppKey(appKey);
|
||||
reg.setFlowGuid(flowGuid);
|
||||
}
|
||||
reg.setCallbackUrl(callbackUrl());
|
||||
reg.setEventTypes(null);
|
||||
reg.setSecret(result.getSecret());
|
||||
reg.setStatus(0);
|
||||
regRepository.save(reg);
|
||||
}
|
||||
|
||||
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文) */
|
||||
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
|
||||
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
|
||||
body.setFlowGuid(notice.getFlowGuid());
|
||||
body.setBizType(FlowCenterClient.BIZ_TYPE);
|
||||
body.setBizId(String.valueOf(notice.getId()));
|
||||
body.setBizTitle(notice.getTitle());
|
||||
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId());
|
||||
body.setBizSnapshot(buildSnapshot(notice));
|
||||
if (user != null) {
|
||||
body.setSubmitterId(user.getId());
|
||||
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 业务摘要:{title, summary(纯文本截 300 字), attachmentNames[]}——富文本正文不传(概览 §十边界) */
|
||||
private Map<String, Object> buildSnapshot(Notice notice) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("title", notice.getTitle());
|
||||
snapshot.put("summary", plainSummary(notice.getContent()));
|
||||
List<String> names = new ArrayList<>();
|
||||
for (UpFile f : upFileRepository.findByTopicTypeAndTopicId("notice", notice.getId())) {
|
||||
if (StringUtils.hasText(f.getFileTrueName())) {
|
||||
names.add(f.getFileTrueName());
|
||||
}
|
||||
}
|
||||
snapshot.put("attachmentNames", names);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** 富文本剥标签取纯文本摘要(截断 300 字) */
|
||||
private String plainSummary(String content) {
|
||||
if (!StringUtils.hasText(content)) {
|
||||
return "";
|
||||
}
|
||||
String text = content.replaceAll("<[^>]+>", "").replaceAll(" ", " ").trim();
|
||||
return text.length() > 300 ? text.substring(0, 300) : text;
|
||||
}
|
||||
|
||||
/** 进度组装(trace 接口):auditStatus/status/version/trace[]/currentNodes/nodeSummary;未绑流程返回未接入语义 */
|
||||
public Map<String, Object> buildTrace(Integer noticeId) {
|
||||
return buildTrace(noticeId, currentUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度组装(带访问者,BP-R5-3):未发布通知(status=0,审核载体)仅提交人/管理员/办理人
|
||||
* 可见轨迹与驳回意见(NoticeFlowGuard 三分支);已发布(status=1)内容本就公开,登录即可看。
|
||||
*/
|
||||
public Map<String, Object> buildTrace(Integer noticeId, BoaUser user) {
|
||||
Notice notice = requireNotice(noticeId);
|
||||
if (notice.getStatus() == null || notice.getStatus() != 1) {
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("bound", StringUtils.hasText(notice.getFlowGuid()));
|
||||
result.put("flowGuid", notice.getFlowGuid());
|
||||
result.put("flowInstanceId", notice.getFlowInstanceId());
|
||||
result.put("auditStatus", notice.getAuditStatus());
|
||||
result.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
|
||||
result.put("status", notice.getStatus());
|
||||
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
|
||||
return result; // 未接入流程中心(编辑页/详情页据此隐藏进度卡片)
|
||||
}
|
||||
result.put("flowResult", parseJson(notice.getFlowResultJson()));
|
||||
if (properties.isEnabled()) {
|
||||
try {
|
||||
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
|
||||
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
|
||||
if (instance != null) {
|
||||
result.put("instanceStatus", instance.getStatus());
|
||||
result.put("version", instance.getVersion());
|
||||
result.put("nodeSummary", instance.getNodeSummary());
|
||||
result.put("currentNodes", instance.getCurrentNodes());
|
||||
result.put("assigneeIds", instance.getAssigneeIds());
|
||||
result.put("submitTime", instance.getSubmitTime());
|
||||
result.put("finishTime", instance.getFinishTime());
|
||||
result.put("trace", parseJson(instance.getTraceJson()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
|
||||
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */
|
||||
public List<FlowModels.FlowSummaryVo> options(Integer schId) {
|
||||
if (!properties.isEnabled()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return flowClient.listFlows(schId);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-flow] 流程列表查询失败:{}", e.toString());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核流程列表获取失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private BoaUser currentUser() {
|
||||
return currentBoaUser == null ? null : currentBoaUser.get();
|
||||
}
|
||||
|
||||
private Notice requireNotice(Integer noticeId) {
|
||||
return noticeRepository.findById(noticeId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||
}
|
||||
|
||||
private String callbackUrl() {
|
||||
String base = properties.getCallbackBase();
|
||||
if (base == null) {
|
||||
base = "";
|
||||
}
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
return base + properties.getCallbackPath();
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String json) {
|
||||
if (!StringUtils.hasText(json)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public class ClassService implements IClassService {
|
||||
if (!StringUtils.hasText(userClassHost)) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.userclass)");
|
||||
}
|
||||
|
||||
String url = userClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId;
|
||||
try {
|
||||
ResponseEntity<List<SchTeaList>> resp = restTemplate.exchange(
|
||||
|
||||
@@ -71,6 +71,9 @@ public class NoticeService implements INoticeService {
|
||||
@Autowired
|
||||
private SmsSender smsSender;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.sms.SmsSendService smsSendService;
|
||||
|
||||
@Autowired
|
||||
private IFileService fileService;
|
||||
|
||||
@@ -80,7 +83,15 @@ public class NoticeService implements INoticeService {
|
||||
@Autowired
|
||||
private CurrentBoaUser currentBoaUser;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.flow.NoticeFlowService noticeFlowService;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard;
|
||||
|
||||
private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode();
|
||||
private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode();
|
||||
private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode();
|
||||
|
||||
/**
|
||||
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
|
||||
@@ -120,23 +131,50 @@ public class NoticeService implements INoticeService {
|
||||
entity.setId(null);
|
||||
entity.setDeptName(resolveDeptName(request));
|
||||
normalizeReceiveList(entity, request.getReceiveList());
|
||||
// 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性
|
||||
entity.setStatus(PUBLISHED);
|
||||
// 发布分流(先审后发):绑 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);
|
||||
noticeRepository.save(entity);
|
||||
// 写入接收人关联表(非全员时)
|
||||
// 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害)
|
||||
saveReceivers(entity);
|
||||
// 绑定附件(前端上传后回传的 fileIds 逗号串)
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
|
||||
trySendSms(entity);
|
||||
if (flowBound) {
|
||||
// 短信时机后移:绑流程的通知 create 时不发,回调 completed 才发(审核通过才发)
|
||||
noticeFlowService.submitAfterCommit(entity.getId());
|
||||
} else {
|
||||
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
|
||||
trySendSms(entity);
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见,
|
||||
* 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。
|
||||
*/
|
||||
private void requireVisibleIfUnpublished(Integer serid) {
|
||||
Notice notice = noticeRepository.findById(serid).orElse(null);
|
||||
if (notice == null || notice.getStatus() == null
|
||||
|| notice.getStatus() == NoticeStatusEnum.PUBLISHED.getCode()) {
|
||||
return; // 不存在(沿用后续查询的报错)或已发布(既有行为)
|
||||
}
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, currentBoaUser.get());
|
||||
}
|
||||
|
||||
// ============================ 编辑 ============================
|
||||
|
||||
@Override
|
||||
@@ -145,6 +183,12 @@ public class NoticeService implements INoticeService {
|
||||
Objects.requireNonNull(id, "通知ID不得为空");
|
||||
Notice entity = noticeRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
|
||||
// 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提
|
||||
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());
|
||||
@@ -169,7 +213,25 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
}
|
||||
// 审核绑定变更:新绑/换绑 → 回到 0 待提交(提交动作 AFTER_COMMIT 出站);解绑 → 直发即发布
|
||||
if (nowFlowBound) {
|
||||
entity.setFlowGuid(request.getFlowGuid().trim());
|
||||
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.setFlowGuid(null);
|
||||
entity.setAuditStatus(null);
|
||||
entity.setFlowInstanceId(null);
|
||||
entity.setLastInstanceVersion(null);
|
||||
entity.setFlowResultJson(null);
|
||||
}
|
||||
noticeRepository.save(entity);
|
||||
if (nowFlowBound) {
|
||||
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
|
||||
noticeFlowService.submitAfterCommit(entity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */
|
||||
@@ -201,9 +263,14 @@ public class NoticeService implements INoticeService {
|
||||
return;
|
||||
}
|
||||
for (Integer id : ids) {
|
||||
if (!noticeRepository.existsById(id)) {
|
||||
Notice row = noticeRepository.findById(id).orElse(null);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
if (row.getAuditStatus() != null && row.getAuditStatus() == AUDIT_APPROVING) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"通知「" + row.getTitle() + "」审批中不可删除,请先撤回");
|
||||
}
|
||||
noticeReadRepository.deleteByNoticeId(id);
|
||||
noticeReceiverRepository.deleteByNoticeId(id);
|
||||
upFileRepository.deleteByTopicTypeAndTopicId("notice", id);
|
||||
@@ -410,7 +477,9 @@ entity.setIsPulled(0);
|
||||
String account = currentAccount();
|
||||
boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account);
|
||||
NoticeVo vo = toVo(entity, read);
|
||||
if (!read) {
|
||||
// 仅已发布通知标读:审批人(NoticeFlowGuard 放行的三类人)查看未发布通知属审核动作,
|
||||
// 不得记入阅读统计污染已读名单(走查报告 §七.4)
|
||||
if (!read && entity.getStatus() != null && entity.getStatus() == PUBLISHED) {
|
||||
markRead(id);
|
||||
vo.setRead(true);
|
||||
}
|
||||
@@ -739,6 +808,7 @@ entity.setIsPulled(0);
|
||||
|
||||
@Override
|
||||
public NoticeDetailVo getDetailMap(Integer serid) {
|
||||
requireVisibleIfUnpublished(serid);
|
||||
NoticeVo vo = getDetail(serid);
|
||||
NoticeDetailVo data = new NoticeDetailVo();
|
||||
data.setSerid(serid);
|
||||
@@ -770,6 +840,20 @@ entity.setIsPulled(0);
|
||||
} else {
|
||||
data.setTlist(parseReceiveAccounts(rl));
|
||||
}
|
||||
// 先审后发镜像(编辑页驳回/撤回态展示意见;详情页进度卡片入口)
|
||||
data.setFlowGuid(vo.getFlowGuid());
|
||||
data.setAuditStatus(vo.getAuditStatus());
|
||||
data.setAuditStatusDesc(vo.getAuditStatusDesc());
|
||||
data.setFlowInstanceId(vo.getFlowInstanceId());
|
||||
Notice flowSource = noticeRepository.findById(serid).orElse(null);
|
||||
if (flowSource != null && StringUtils.hasText(flowSource.getFlowResultJson())) {
|
||||
try {
|
||||
data.setFlowResult(new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(flowSource.getFlowResultJson(), Object.class));
|
||||
} catch (Exception ignore) {
|
||||
data.setFlowResult(null);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -835,6 +919,11 @@ entity.setIsPulled(0);
|
||||
}
|
||||
vo.setRead(read);
|
||||
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
|
||||
// 先审后发镜像(未绑流程为 null,前端据此隐藏审核列/进度卡片)
|
||||
vo.setFlowGuid(entity.getFlowGuid());
|
||||
vo.setAuditStatus(entity.getAuditStatus());
|
||||
vo.setAuditStatusDesc(com.yunhai.notice.enums.FlowAuditStatusEnum.descOf(entity.getAuditStatus()));
|
||||
vo.setFlowInstanceId(entity.getFlowInstanceId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -853,10 +942,10 @@ entity.setIsPulled(0);
|
||||
return;
|
||||
}
|
||||
if (!isImmediatePublish(entity)) {
|
||||
// 定时未到点:此刻不发(可见性靠查询谓词延后),到点由 SmsCatchupJob 补发(§3.2)
|
||||
return;
|
||||
}
|
||||
smsSender.sendNoticeSms(entity.getId(), entity.getTitle(),
|
||||
parseReceiveList(entity.getReceiveList()));
|
||||
smsSendService.sendOnce(entity);
|
||||
}
|
||||
|
||||
private boolean isImmediatePublish(Notice entity) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.notice.service.sms;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.utils.ReceiveParser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 短信统一发送(§3.2 / BP-R5-2):直发(publish 即时)、审批通过(completed 回调后置)、
|
||||
* 到点补发(SmsCatchupJob)三条链路共用,以 sms_sent_at 原子认领防重复——
|
||||
* 认领成功才发送,失败归还标记待下轮补发。修复"审批通过时未到定时点 → 短信永久丢失"缺口。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmsSendService {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final SmsSender smsSender;
|
||||
|
||||
/**
|
||||
* 发一次短信(幂等):sms_flag!=1 或已发过(认领失败)直接跳过。
|
||||
* 发送抛异常时归还认领(下轮补发可重试)后原样上抛——发布/回调链路维持既有 5xx 语义。
|
||||
*/
|
||||
@Transactional
|
||||
public boolean sendOnce(Notice notice) {
|
||||
if (notice == null || notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (noticeRepository.markSmsSent(notice.getId(), LocalDateTime.now()) == 0) {
|
||||
return false; // 已发送过(并发/重复调度/重发布轮次的闸门)
|
||||
}
|
||||
try {
|
||||
smsSender.sendNoticeSms(notice.getId(), notice.getTitle(),
|
||||
ReceiveParser.parse(notice.getReceiveList()));
|
||||
log.info("[notice-sms] 短信已发送 notice={} title={}", notice.getId(), notice.getTitle());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
noticeRepository.clearSmsSent(notice.getId());
|
||||
log.warn("[notice-sms] 短信发送失败(归还认领,待补发重试)notice={}:{}",
|
||||
notice.getId(), e.toString());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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,48 @@
|
||||
package com.yunhai.notice.utils;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 接收人列表解析工具(自 NoticeService.parseReceiveAccounts 提炼为公共类):
|
||||
* "ALL"/"UserAll"/"all"=全员(返回空列表);JSON 数组串或逗号串 → 账号列表;
|
||||
* 为空或解析失败返回空列表(表示全员通知)。
|
||||
*/
|
||||
public final class ReceiveParser {
|
||||
|
||||
private ReceiveParser() {
|
||||
}
|
||||
|
||||
public static List<String> parse(String receiveList) {
|
||||
if (receiveList == null || receiveList.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String rl = receiveList.trim();
|
||||
if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
if (rl.startsWith("[")) {
|
||||
String inner = rl.substring(1, rl.length() - 1).trim();
|
||||
if (!inner.isEmpty()) {
|
||||
for (String part : inner.split(",")) {
|
||||
String account = part.trim().replace("\"", "").replace("'", "");
|
||||
if (StringUtils.hasText(account) && !result.contains(account)) {
|
||||
result.add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (String part : rl.split(",")) {
|
||||
String account = part.trim();
|
||||
if (StringUtils.hasText(account) && !result.contains(account)) {
|
||||
result.add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,23 @@ sms:
|
||||
enabled: true
|
||||
module-id: notice
|
||||
|
||||
# 先审后发(流程中心托管接缝):enabled=false 时编辑页选择器隐藏、FlowClient 降级
|
||||
notice:
|
||||
flow:
|
||||
enabled: true
|
||||
# flow-center 基址(Tier 0 目标,裸地址直连 base-path 空)
|
||||
base-url: http://localhost:8085
|
||||
# 本应用对外可达基址(flow-center 回调探活/事件推送打这里;本地=后端直连端口)
|
||||
callback-base: http://localhost:8101
|
||||
callback-path: /callback/flow
|
||||
# Notice 前端基址(apply bizUrl 拼接,审批人点击跳回通知详情)
|
||||
front-base-url: http://localhost:8077
|
||||
# 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点"
|
||||
# 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql
|
||||
sms:
|
||||
catchup-enabled: true
|
||||
catchup-fixed-delay-ms: 300000
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
sso:
|
||||
@@ -61,9 +78,16 @@ yh:
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
# 本应用自身身份(客户端签名用):本地联调一律用 100617 与生产权威(deploy/notice.yaml)一致,
|
||||
# 消除与 UserHome 撞号的 100001 占位(升级方案 D8);flow-center trusted-apps 已同步收此号段
|
||||
app-key: 100617
|
||||
app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
|
||||
# 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过
|
||||
server:
|
||||
enforce-patterns: [/oapi/*]
|
||||
# 入站验签白名单:flow-center(回调注册探活外的 oapi 调用方 demo;无 Redis 不开 trust-registry)
|
||||
trusted-apps:
|
||||
"100019": "flow-center-dev-secret-100019"
|
||||
open:
|
||||
enabled: true
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.yunhai.notice.config;
|
||||
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* 回调验签过滤器单测(T2.3):四头缺失/时间窗外/签名错 → 401;合法 → 放行;
|
||||
* ping 探活 → 直接 200(不进业务链)。
|
||||
*/
|
||||
class FlowCallbackVerifyFilterTest {
|
||||
|
||||
private static final String SECRET = "verify-test-secret-0123456789";
|
||||
private static final String BODY =
|
||||
"{\"eventId\":\"e1\",\"event\":\"flow.completed\",\"schId\":1,\"flowGuid\":\"fg-1\"}";
|
||||
|
||||
private NoticeFlowRegRepository regRepository;
|
||||
private FlowCallbackFilterConfiguration.FlowCallbackVerifyFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
regRepository = Mockito.mock(NoticeFlowRegRepository.class);
|
||||
NoticeFlowReg reg = new NoticeFlowReg();
|
||||
reg.setSchId(1);
|
||||
reg.setFlowGuid("fg-1");
|
||||
reg.setSecret(SECRET);
|
||||
reg.setStatus(0);
|
||||
Mockito.when(regRepository.findBySchIdAndFlowGuid(1, "fg-1")).thenReturn(Optional.of(reg));
|
||||
FlowCallbackFilterConfiguration configuration =
|
||||
new FlowCallbackFilterConfiguration(regRepository, new ObjectMapper());
|
||||
filter = configuration.new FlowCallbackVerifyFilter();
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow");
|
||||
request.setContent(BODY.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return request;
|
||||
}
|
||||
|
||||
private String sign(String ts, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(SECRET.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingHeaders_401() throws Exception {
|
||||
MockHttpServletRequest request = request(); // 无任何 X-Flw-* 头
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timestampOutOfWindow_401() throws Exception {
|
||||
MockHttpServletRequest request = request();
|
||||
String oldTs = String.valueOf(System.currentTimeMillis() - 10 * 60 * 1000L); // 10 分钟前
|
||||
request.addHeader("X-Flw-EventId", "e1");
|
||||
request.addHeader("X-Flw-Timestamp", oldTs);
|
||||
request.addHeader("X-Flw-Nonce", "nonce00000000001");
|
||||
request.addHeader("X-Flw-Signature", sign(oldTs, "nonce00000000001", BODY));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignature_401() throws Exception {
|
||||
MockHttpServletRequest request = request();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
request.addHeader("X-Flw-EventId", "e1");
|
||||
request.addHeader("X-Flw-Timestamp", ts);
|
||||
request.addHeader("X-Flw-Nonce", "nonce00000000001");
|
||||
request.addHeader("X-Flw-Signature", "forged-signature-not-valid");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validSignature_chainProceeds() throws Exception {
|
||||
MockHttpServletRequest request = request();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
request.addHeader("X-Flw-EventId", "e1");
|
||||
request.addHeader("X-Flw-Timestamp", ts);
|
||||
request.addHeader("X-Flw-Nonce", "nonce00000000001");
|
||||
request.addHeader("X-Flw-Signature", sign(ts, "nonce00000000001", BODY));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
filter.doFilter(request, response, chain);
|
||||
assertEquals(200, response.getStatus());
|
||||
assertNotNull(chain.getRequest(), "合法签名应放行");
|
||||
// 放行给下游的是可重读 wrapper,控制器 @RequestBody 仍能拿到完整 body
|
||||
assertEquals(BODY, ((ReReadableRequestWrapper) chain.getRequest()).bodyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingProbe_200Immediately() throws Exception {
|
||||
String ping = "{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow");
|
||||
request.setContent(ping.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
filter.doFilter(request, response, chain);
|
||||
assertEquals(200, response.getStatus(), "注册探活 ping 应直接 200");
|
||||
assertNull(chain.getRequest(), "ping 不应进业务链");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRegistration_401() throws Exception {
|
||||
Mockito.when(regRepository.findBySchIdAndFlowGuid(1, "fg-1")).thenReturn(Optional.empty());
|
||||
MockHttpServletRequest request = request();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
request.addHeader("X-Flw-EventId", "e1");
|
||||
request.addHeader("X-Flw-Timestamp", ts);
|
||||
request.addHeader("X-Flw-Nonce", "nonce00000000001");
|
||||
request.addHeader("X-Flw-Signature", sign(ts, "nonce00000000001", BODY));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonPost_405() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/callback/flow");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
assertEquals(405, response.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
|
||||
/**
|
||||
* 回调瞬时失败 5xx 语义 IT(T2.3 核心断言,"免对账"成立的前提):
|
||||
* 发布后置(短信)故障 → 回调 500(非 200),事务整体回滚(事件行不留);
|
||||
* 故障恢复后重投同一事件 → 200 + 状态正确追平——证明 flow-center 持续重试可治愈。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai",
|
||||
"spring.jpa.hibernate.ddl-auto=update"})
|
||||
class FlowCallbackTransientIT {
|
||||
|
||||
private static final String REG_SECRET = "it-transient-secret-0123456789";
|
||||
private static final String FLOW_GUID = "it-transient-guid-01";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private NoticeRepository noticeRepository;
|
||||
@Autowired
|
||||
private NoticeFlowRegRepository regRepository;
|
||||
@Autowired
|
||||
private NoticeFlowEventRepository eventRepository;
|
||||
|
||||
@MockBean
|
||||
private SmsSender smsSender;
|
||||
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
eventRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
noticeRepository.deleteAll();
|
||||
|
||||
NoticeFlowReg reg = new NoticeFlowReg();
|
||||
reg.setSchId(1);
|
||||
reg.setAppKey("100617");
|
||||
reg.setFlowGuid(FLOW_GUID);
|
||||
reg.setCallbackUrl("http://localhost:" + port + "/callback/flow");
|
||||
reg.setSecret(REG_SECRET);
|
||||
reg.setStatus(0);
|
||||
regRepository.save(reg);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setTitle("瞬时失败通知");
|
||||
notice.setType(1);
|
||||
notice.setStatus(0);
|
||||
notice.setAuditStatus(10);
|
||||
notice.setFlowGuid(FLOW_GUID);
|
||||
notice.setSchId(1);
|
||||
notice.setPublisherAccount("it-user");
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
notice = noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailure_returns5xx_thenRecoversOnRetry() {
|
||||
// 阶段一:短信后置抛异常 → 500 + 事务回滚(事件行/notice 推进均不留)
|
||||
Mockito.doThrow(new RuntimeException("sms gateway down"))
|
||||
.when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
String eventId = uuid();
|
||||
ResponseEntity<String> first = postCompleted(eventId);
|
||||
assertEquals(500, first.getStatusCodeValue(), "瞬时失败必须回 5xx(重试治愈的唯一信号)");
|
||||
Notice after = reload();
|
||||
assertEquals(10, after.getAuditStatus(), "失败事务整体回滚");
|
||||
assertEquals(0, after.getStatus());
|
||||
assertEquals(0L, eventRepository.count(), "事件去重行随事务回滚(重投不撞 uk)");
|
||||
|
||||
// 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环)
|
||||
Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
ResponseEntity<String> second = postCompleted(eventId);
|
||||
assertEquals(200, second.getStatusCodeValue());
|
||||
Notice recovered = reload();
|
||||
assertEquals(20, recovered.getAuditStatus());
|
||||
assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布");
|
||||
assertEquals(1L, eventRepository.count());
|
||||
// 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次
|
||||
Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
private Notice reload() {
|
||||
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> postCompleted(String eventId) {
|
||||
String body = "{"
|
||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"flow.completed\","
|
||||
+ "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527,"
|
||||
+ "\"instanceVersion\":3,"
|
||||
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"瞬时失败通知\","
|
||||
+ "\"data\":{\"result\":\"APPROVED\",\"opinion\":\"同意\"}}";
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = uuid().substring(0, 16);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Flw-EventId", eventId);
|
||||
headers.set("X-Flw-Timestamp", ts);
|
||||
headers.set("X-Flw-Nonce", nonce);
|
||||
headers.set("X-Flw-Signature", hmac(REG_SECRET, ts, nonce, body));
|
||||
return new org.springframework.boot.test.web.client.TestRestTemplate().postForEntity(
|
||||
"http://localhost:" + port + "/callback/flow",
|
||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
||||
}
|
||||
|
||||
private static String hmac(String secret, String ts, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String uuid() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.support.OapiSignedClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Notice 流程接缝 IT(T2.1/T2.3/T2.5,RANDOM_PORT 真实过滤链 + bsch_notice_test):
|
||||
* ① X-Flw 回调:合法签名 200 推进状态 / 坏签名 401 / 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等;
|
||||
* ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401);
|
||||
* ③ notice 表 5 新列与 notice_flow_reg uk 的真实落库。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai",
|
||||
"spring.jpa.hibernate.ddl-auto=update"})
|
||||
class NoticeFlowApiIT {
|
||||
|
||||
private static final String REG_SECRET = "it-callback-secret-0123456789";
|
||||
private static final String FLOW_GUID = "it-flow-guid-0001";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private NoticeRepository noticeRepository;
|
||||
@Autowired
|
||||
private NoticeFlowRegRepository regRepository;
|
||||
@Autowired
|
||||
private NoticeFlowEventRepository eventRepository;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
eventRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
noticeRepository.deleteAll();
|
||||
|
||||
NoticeFlowReg reg = new NoticeFlowReg();
|
||||
reg.setSchId(1);
|
||||
reg.setAppKey("100617");
|
||||
reg.setFlowGuid(FLOW_GUID);
|
||||
reg.setCallbackUrl("http://localhost:" + port + "/callback/flow");
|
||||
reg.setSecret(REG_SECRET);
|
||||
reg.setStatus(0);
|
||||
regRepository.save(reg);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setTitle("IT 审核通知");
|
||||
notice.setContent("<p>it</p>");
|
||||
notice.setType(1);
|
||||
notice.setStatus(0);
|
||||
notice.setAuditStatus(10);
|
||||
notice.setFlowGuid(FLOW_GUID);
|
||||
notice.setSchId(1);
|
||||
notice.setPublisherAccount("it-user");
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
notice = noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
// ==================== ① X-Flw 回调 ====================
|
||||
|
||||
@Test
|
||||
void signedCompletedEvent_advancesNoticeToPublished() {
|
||||
String eventId = uuid();
|
||||
String body = eventBody(eventId, "flow.completed", 3,
|
||||
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}");
|
||||
ResponseEntity<String> resp = postSignedCallback(REG_SECRET, body);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "合法签名事件应 200");
|
||||
|
||||
Notice updated = reload();
|
||||
assertEquals(20, updated.getAuditStatus());
|
||||
assertEquals(1, updated.getStatus(), "completed → status=1 正式发布");
|
||||
assertEquals(3, updated.getLastInstanceVersion());
|
||||
assertNotNull(updated.getFlowResultJson());
|
||||
assertTrue(updated.getFlowResultJson().contains("同意"));
|
||||
// 事件去重行落库(事务提交)
|
||||
assertEquals(1L, eventRepository.count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateEventId_idempotentSecondDelivery() {
|
||||
String eventId = uuid();
|
||||
String body = eventBody(eventId, "flow.completed", 3, "{\"result\":\"APPROVED\"}");
|
||||
assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue());
|
||||
assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue(),
|
||||
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
||||
assertEquals(20, reload().getAuditStatus());
|
||||
assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFieldsBody_200_silentDrop() {
|
||||
// 合法 JSON 但缺事件字段:过滤链可过(含 flowGuid/schId 可验签),业务层判毒丢弃回 200
|
||||
String body = "{\"eventId\":\"" + uuid() + "\",\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\"}";
|
||||
ResponseEntity<String> resp = postSignedCallback(REG_SECRET, body);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "缺字段毒消息回 200 防无限重放");
|
||||
assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进");
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignature_401() {
|
||||
String body = eventBody(uuid(), "flow.completed", 3, "{}");
|
||||
ResponseEntity<String> resp = postCallback(body, "forged-signature");
|
||||
assertEquals(401, resp.getStatusCodeValue());
|
||||
assertEquals(10, reload().getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingProbe_200_withoutSignature() {
|
||||
String body = "{\"event\":\"ping\",\"flowGuid\":\"" + FLOW_GUID + "\",\"occurredTime\":\"2026-09-13 00:00:00\"}";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
ResponseEntity<String> resp = restTemplate().postForEntity(url("/callback/flow"),
|
||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "注册探活 ping 直接 200");
|
||||
assertEquals(0L, eventRepository.count());
|
||||
}
|
||||
|
||||
// ==================== ② /oapi/notice/getDict 三态 ====================
|
||||
|
||||
@Test
|
||||
void oapiGetDict_threeStates() {
|
||||
OapiSignedClient client = new OapiSignedClient(url(""), "100019", "flow-center-dev-secret-100019");
|
||||
// 匿名(无签名头)→ 401
|
||||
assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
||||
// 错误密钥 → 401
|
||||
assertEquals(401, client.getWithBadSecret("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
||||
// 合法签名 → 200(信封 data 为字典数组——空校库也是合法数组)
|
||||
ResponseEntity<JsonNode> ok = client.get("/oapi/notice/getDict?type=1", JsonNode.class);
|
||||
assertEquals(200, ok.getStatusCodeValue());
|
||||
assertTrue(ok.getBody() != null, "应返回信封(code=0)");
|
||||
}
|
||||
|
||||
// ==================== ③ 实体落库 ====================
|
||||
|
||||
@Test
|
||||
void flowColumns_roundtrip_andRegUk() {
|
||||
Notice row = new Notice();
|
||||
row.setTitle("列回读");
|
||||
row.setType(1);
|
||||
row.setStatus(0);
|
||||
row.setFlowGuid("fg-cols");
|
||||
row.setFlowInstanceId(12345L);
|
||||
row.setAuditStatus(30);
|
||||
row.setLastInstanceVersion(4);
|
||||
row.setFlowResultJson("{\"opinion\":\"改后重提\"}");
|
||||
row.setSchId(1);
|
||||
row.setPublisherAccount("it-user");
|
||||
Notice saved = noticeRepository.save(row);
|
||||
Notice loaded = noticeRepository.findById(saved.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals("fg-cols", loaded.getFlowGuid());
|
||||
assertEquals(12345L, loaded.getFlowInstanceId());
|
||||
assertEquals(30, loaded.getAuditStatus());
|
||||
assertEquals(4, loaded.getLastInstanceVersion());
|
||||
assertEquals("{\"opinion\":\"改后重提\"}", loaded.getFlowResultJson());
|
||||
// reg uk(sch,app,flow) 冲突路径:同键二连插抛约束冲突
|
||||
NoticeFlowReg dup = new NoticeFlowReg();
|
||||
dup.setSchId(1);
|
||||
dup.setAppKey("100617");
|
||||
dup.setFlowGuid(FLOW_GUID);
|
||||
dup.setSecret("another-secret-0123456789");
|
||||
dup.setStatus(0);
|
||||
try {
|
||||
regRepository.saveAndFlush(dup);
|
||||
throw new AssertionError("uk 冲突应抛异常");
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException expected) {
|
||||
// uk 生效
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
private Notice reload() {
|
||||
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
|
||||
private String url(String path) {
|
||||
return "http://localhost:" + port + path;
|
||||
}
|
||||
|
||||
private org.springframework.boot.test.web.client.TestRestTemplate restTemplate() {
|
||||
return new org.springframework.boot.test.web.client.TestRestTemplate();
|
||||
}
|
||||
|
||||
private String eventBody(String eventId, String event, int version, String dataJson) {
|
||||
return "{"
|
||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
||||
+ "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527,"
|
||||
+ "\"instanceVersion\":" + version + ","
|
||||
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"IT 审核通知\","
|
||||
+ "\"occurredTime\":\"2026-09-13 00:00:00\","
|
||||
+ "\"data\":" + dataJson + "}";
|
||||
}
|
||||
|
||||
private ResponseEntity<String> postCallback(String body, String signature) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Flw-EventId", uuid());
|
||||
headers.set("X-Flw-Timestamp", String.valueOf(System.currentTimeMillis()));
|
||||
headers.set("X-Flw-Nonce", uuid().substring(0, 16));
|
||||
headers.set("X-Flw-Signature", signature);
|
||||
return restTemplate().postForEntity(url("/callback/flow"),
|
||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
||||
}
|
||||
|
||||
/** 合法签名回调(与生产 CallbackPushService 同构:ts/nonce/sign 三者同源) */
|
||||
private ResponseEntity<String> postSignedCallback(String secret, String body) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = uuid().substring(0, 16);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Flw-EventId", uuid());
|
||||
headers.set("X-Flw-Timestamp", ts);
|
||||
headers.set("X-Flw-Nonce", nonce);
|
||||
headers.set("X-Flw-Signature", hmac(secret, ts, nonce, body));
|
||||
return restTemplate().postForEntity(url("/callback/flow"),
|
||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
||||
}
|
||||
|
||||
private static String hmac(String secret, String ts, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String uuid() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yunhai.notice.job;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSendService;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 短信到点补发单测(§3.2 修 BP-R5-2:审批通过早于定时点 → 短信永久丢失):
|
||||
* 到点恰发一次;未到点(扫描谓词不含)不发;并发/重复调度认领失败不双发;
|
||||
* 发送失败归还认领不阻断批次。
|
||||
*/
|
||||
@DisplayName("SmsCatchupJob/SmsSendService - 到点补发与防重复")
|
||||
class SmsCatchupJobTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private SmsSender smsSender;
|
||||
private SmsSendService smsSendService;
|
||||
private SmsCatchupJob job;
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
smsSendService = new SmsSendService(noticeRepository, smsSender);
|
||||
job = new SmsCatchupJob(noticeRepository, smsSendService);
|
||||
ReflectionTestUtils.setField(job, "enabled", true);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setTitle("定时通知");
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(1);
|
||||
notice.setSmsFlag(1);
|
||||
notice.setTimingSendTime(LocalDateTime.now().minusMinutes(10)); // 已到点
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("到点补发恰一次:认领成功 → 发送一次;下轮扫描已空 → 不再发")
|
||||
void dueNotice_sentExactlyOnce() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
|
||||
// 下一轮:扫描已无待发行(sms_sent_at 已置)
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未到点不发:扫描谓词(JPQL timingSendTime<=now)不含未到点行 → 空批不发送")
|
||||
void notDueNotice_notSent() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.emptyList()); // 未到点的行不进扫描结果
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发/重复调度不双发:认领返回 0(他处已发)→ 跳过发送")
|
||||
void concurrentOrDuplicateClaim_noDoubleSend() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(0); // 已被认领
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送失败:归还认领(可下轮重试)且不阻断批次(后续行继续)")
|
||||
void sendFailure_returnsClaimAndContinuesBatch() {
|
||||
Notice ok = new Notice();
|
||||
ok.setId(43);
|
||||
ok.setTitle("第二条");
|
||||
ok.setStatus(1);
|
||||
ok.setSmsFlag(1);
|
||||
ok.setReceiveList("[\"tea2\"]");
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(java.util.Arrays.asList(notice, ok));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("sms gateway down"))
|
||||
.doNothing()
|
||||
.when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
|
||||
assertDoesNotThrow(() -> job.catchupDueSms());
|
||||
verify(smsSender, times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); // 第二条仍被尝试
|
||||
verify(noticeRepository).clearSmsSent(42); // 失败行归还认领
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("认领时间落库:markSmsSent 携带当前时间(审计可追溯)")
|
||||
void claimCarriesTimestamp() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
job.catchupDueSms();
|
||||
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(noticeRepository).markSmsSent(anyInt(), captor.capture());
|
||||
assertEquals(true, captor.getValue().isAfter(LocalDateTime.now().minusMinutes(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关关闭(notice.sms.catchup-enabled=false)不扫描")
|
||||
void disabledSwitch_skips() {
|
||||
ReflectionTestUtils.setField(job, "enabled", false);
|
||||
job.catchupDueSms();
|
||||
verify(noticeRepository, never()).findSmsDue(any(LocalDateTime.class), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowEvent;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 回调事件处理单测(T2.3 5xx 语义核心断言):六事件映射;completed → status=1 + sms 恰一次;
|
||||
* eventId 重复跳过;version 早于已消费丢弃;毒 JSON/缺字段/不属本应用静默 200(不抛);
|
||||
* DB 异常 → TransientCallbackException(→ controller 回 5xx)。
|
||||
*/
|
||||
class FlowCallbackServiceTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeFlowEventRepository eventRepository;
|
||||
private SmsSender smsSender;
|
||||
private FlowCallbackService service;
|
||||
private Notice notice;
|
||||
private final AtomicInteger saveCount = new AtomicInteger();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
eventRepository = mock(NoticeFlowEventRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
// 统一发送链(§3.2):认领桩=1 表示未发过(首次),0=已发(重复闸门)
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
service = new FlowCallbackService(noticeRepository, eventRepository,
|
||||
new com.yunhai.notice.service.sms.SmsSendService(noticeRepository, smsSender),
|
||||
new ObjectMapper());
|
||||
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setTitle("审核通知标题");
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(0);
|
||||
notice.setFlowGuid("fg-1");
|
||||
notice.setAuditStatus(10);
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(notice));
|
||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.of(notice));
|
||||
when(noticeRepository.saveAndFlush(any(Notice.class))).thenAnswer(inv -> {
|
||||
saveCount.incrementAndGet();
|
||||
return inv.getArgument(0);
|
||||
});
|
||||
when(eventRepository.saveAndFlush(any(NoticeFlowEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private String event(String eventId, String event, int version, String dataJson) {
|
||||
return "{"
|
||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
||||
+ "\"schId\":1,\"flowGuid\":\"fg-1\",\"instanceId\":9527,"
|
||||
+ "\"instanceVersion\":" + version + ","
|
||||
+ "\"bizType\":\"notice\",\"bizId\":\"42\",\"bizTitle\":\"t\","
|
||||
+ "\"data\":" + (dataJson == null ? "{}" : dataJson) + "}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void inFlightEvents_mapToApproving() {
|
||||
service.handle(event("e1", "flow.submitted", 1, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
service.handle(event("e2", "flow.task.created", 1, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
service.handle(event("e3", "flow.task.handled", 2, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus(), "审批中事件不得提前发布");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completed_publishesAndSendsSmsOnce() {
|
||||
service.handle(event("e4", "flow.completed", 3,
|
||||
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getStatus(), "审核通过置 status=1 正式发布");
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertTrue(notice.getFlowResultJson().contains("APPROVED"), "终态明细落 flow_result_json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completed_beforeTimingPoint_smsDeferredToCatchup() {
|
||||
// §3.2:审批通过早于定时点——短信此刻不发也不认领,留给 SmsCatchupJob 到点补发
|
||||
notice.setTimingSendTime(java.time.LocalDateTime.now().plusHours(1));
|
||||
service.handle(event("e-df", "flow.completed", 3, "{\"result\":\"APPROVED\"}"));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getStatus(), "可见性即时生效不受影响");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
verify(noticeRepository, never()).markSmsSent(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTwice_smsOnlyOnce() {
|
||||
// 重置 audit 初始为 20 再收到迟到 completed:status 已 1 → justPublished=false → 不再发 sms
|
||||
service.handle(event("e4", "flow.completed", 3, "{\"result\":\"APPROVED\"}"));
|
||||
notice.setStatus(1);
|
||||
notice.setAuditStatus(20);
|
||||
service.handle(event("e4x", "flow.completed", 4, "{\"result\":\"APPROVED\"}"));
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejected_mapsTo30_withResultJson() {
|
||||
service.handle(event("e5", "flow.rejected", 3,
|
||||
"{\"result\":\"REJECTED\",\"opinion\":\"内容不妥\",\"rejectNode\":\"终审\"}"));
|
||||
assertEquals(30, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus(), "驳回不发布");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertTrue(notice.getFlowResultJson().contains("内容不妥"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawn_mapsTo40() {
|
||||
service.handle(event("e6", "flow.withdrawn", 2, null));
|
||||
assertEquals(40, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateEventId_skipped() {
|
||||
when(eventRepository.existsByEventId("dup")).thenReturn(true);
|
||||
service.handle(event("dup", "flow.completed", 3, null));
|
||||
assertEquals(10, notice.getAuditStatus(), "重复投递不得推进状态");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersion_dropped() {
|
||||
notice.setLastInstanceVersion(5);
|
||||
service.handle(event("e7", "flow.completed", 3, null)); // 迟到的低版本事件
|
||||
assertEquals(10, notice.getAuditStatus(), "乱序事件丢弃");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameVersionDifferentEvent_processed() {
|
||||
// submitted 与 task.created 天然同版本:等值不丢(幂等映射)
|
||||
notice.setLastInstanceVersion(1);
|
||||
service.handle(event("e8", "flow.task.created", 1, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getLastInstanceVersion());
|
||||
}
|
||||
|
||||
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置(completed 先落库,随后到 task.handled) =====
|
||||
|
||||
@Test
|
||||
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
|
||||
// completed(v4) 已落库(audit=20 + result json),迟到的 task.handled(v4) 不得打回 10
|
||||
service.handle(event("e-t1", "flow.completed", 4, "{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
String frozen = notice.getFlowResultJson();
|
||||
|
||||
service.handle(event("e-t2", "flow.task.handled", 4, "{\"opinion\":\"同意\"}")); // 不抛 → 200
|
||||
assertEquals(20, notice.getAuditStatus(), "终态行拒绝非终态事件(终态不回退)");
|
||||
assertEquals(frozen, notice.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
|
||||
// 理论上不会出现(终态后引擎不再产事件),防御:v5 迟到的 in-flight 事件同样拒绝
|
||||
service.handle(event("e-t3", "flow.completed", 4, "{\"result\":\"APPROVED\"}"));
|
||||
service.handle(event("e-t4", "flow.task.handled", 5, null));
|
||||
assertEquals(20, notice.getAuditStatus(), "终态后任何非终态事件都不回退(R4 重提由 edit 先置 0,不冲突)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
|
||||
// 驳回(30)已带意见落档,另一终态事件不得覆盖已落定结果
|
||||
service.handle(event("e-t5", "flow.rejected", 4, "{\"result\":\"REJECTED\",\"opinion\":\"材料不齐\"}"));
|
||||
assertEquals(30, notice.getAuditStatus());
|
||||
String frozen = notice.getFlowResultJson();
|
||||
service.handle(event("e-t6", "flow.withdrawn", 4, null));
|
||||
assertEquals(30, notice.getAuditStatus(), "已终态不被另一终态改写");
|
||||
assertEquals(frozen, notice.getFlowResultJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalGuard_doesNotBlockR4Resubmission_afterLocalReset() {
|
||||
// R4 重提链路:edit 先本地置 audit=0 → 新一轮 submitted 正常推进到 10
|
||||
service.handle(event("e-t7", "flow.rejected", 4, "{\"result\":\"REJECTED\"}"));
|
||||
notice.setAuditStatus(0); // NoticeFlowService 编辑保存时本地重置
|
||||
service.handle(event("e-t8", "flow.submitted", 5, null));
|
||||
assertEquals(10, notice.getAuditStatus(), "重置后新一轮事件正常推进(守卫只拦终态行的回退)");
|
||||
assertEquals(5, notice.getLastInstanceVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisonJson_silent200() {
|
||||
service.handle("not-a-json{{{");
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFields_silent200() {
|
||||
service.handle("{\"event\":\"flow.completed\"}");
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignBizType_silent200() {
|
||||
service.handle(event("e9", "flow.completed", 3, null).replace("\"notice\"", "\"svc_item\""));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownNotice_silent200() {
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.empty());
|
||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
|
||||
service.handle(event("e10", "flow.completed", 3, null)); // 不抛
|
||||
}
|
||||
|
||||
@Test
|
||||
void dbFailure_throwsTransient() {
|
||||
when(noticeRepository.saveAndFlush(any(Notice.class)))
|
||||
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("db down"));
|
||||
assertThrows(FlowCallbackService.TransientCallbackException.class,
|
||||
() -> service.handle(event("e11", "flow.completed", 3, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void smsFailure_throwsTransient() {
|
||||
doThrow(new RuntimeException("sms gateway down")).when(smsSender)
|
||||
.sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertThrows(FlowCallbackService.TransientCallbackException.class,
|
||||
() -> service.handle(event("e12", "flow.completed", 3, null)),
|
||||
"发布后置失败也应回 5xx 让流程中心重试(事件行随事务回滚)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.openapi.TargetSpec;
|
||||
import com.yunhai.platform.openapi.YschOpenCall;
|
||||
import com.yunhai.platform.openapi.YschCallProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* FlowCenterClient 契约对齐测试(T2.1):JDK HttpServer 伪 flow-center,捕获 Notice 出站请求,
|
||||
* 断言 URL/入参 JSON 与 flow-center /oapi/flow/** 契约逐字对齐(bizType="notice");
|
||||
* enabled=false → 全部方法抛业务异常(文案含"流程中心未接入")。
|
||||
* 客户端真实经 YschOpenCall(Tier 0)出站——同时验证信封解析与错误映射。
|
||||
*/
|
||||
class FlowCenterClientTest {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final List<Captured> captured = new CopyOnWriteArrayList<>();
|
||||
|
||||
static class Captured {
|
||||
String method;
|
||||
String path;
|
||||
String query;
|
||||
JsonNode body;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void startStub() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
port = server.getAddress().getPort();
|
||||
server.createContext("/", exchange -> {
|
||||
Captured c = new Captured();
|
||||
c.method = exchange.getRequestMethod();
|
||||
c.path = exchange.getRequestURI().getPath();
|
||||
c.query = exchange.getRequestURI().getRawQuery();
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
String raw = new String(out.toByteArray(), StandardCharsets.UTF_8);
|
||||
c.body = raw.isEmpty() ? null : objectMapper.readTree(raw);
|
||||
}
|
||||
captured.add(c);
|
||||
route(exchange);
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopStub() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 伪 flow-center 四端点(信封 {code:0,data:...}) */
|
||||
private void route(HttpExchange exchange) throws IOException {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
if ("/oapi/flow/list".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":[{\"flowGuid\":\"fg-1\",\"name\":\"信息发布审核\",\"version\":3,\"nodeSummary\":\"编辑审核→终审\"}]}");
|
||||
} else if ("/oapi/flow/apply".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"duplicated\":false}}");
|
||||
} else if ("/oapi/flow/instance".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"status\":10,\"version\":2,\"nodeSummary\":\"n1\",\"traceJson\":\"[]\",\"assigneeIds\":[301,302],\"currentNodes\":\"n1\"}}");
|
||||
} else if ("/oapi/flow/callback/reg".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"secret\":\"reg-secret-0123456789abcdef\"}}");
|
||||
} else {
|
||||
respond(exchange, 404, "{\"code\":5,\"msg\":\"not found\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 固定返回的 ObjectProvider 桩(构造注入用) */
|
||||
static class FixedProvider<T> implements org.springframework.beans.factory.ObjectProvider<T> {
|
||||
private final T value;
|
||||
|
||||
FixedProvider(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject(Object... args) {
|
||||
if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject() {
|
||||
if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfUnique() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.stream.Stream<T> stream() {
|
||||
return value == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(value);
|
||||
}
|
||||
}
|
||||
|
||||
private FlowCenterClient newClient(boolean enabled) {
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(enabled);
|
||||
props.setBaseUrl("http://localhost:" + port);
|
||||
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> (request, body, execution) -> execution.execute(request, body));
|
||||
return new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper);
|
||||
}
|
||||
|
||||
private FlowCenterClient newClient() {
|
||||
return newClient(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFlows_contractShape() {
|
||||
List<FlowModels.FlowSummaryVo> flows = newClient().listFlows(1);
|
||||
assertEquals(1, flows.size());
|
||||
assertEquals("fg-1", flows.get(0).getFlowGuid());
|
||||
assertEquals("信息发布审核", flows.get(0).getName());
|
||||
assertEquals(3, flows.get(0).getVersion());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("GET", c.method);
|
||||
assertEquals("/oapi/flow/list", c.path);
|
||||
assertEquals("schId=1", c.query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_contractFields_bizTypeNotice() {
|
||||
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
|
||||
body.setFlowGuid("fg-1");
|
||||
body.setBizType(FlowCenterClient.BIZ_TYPE);
|
||||
body.setBizId("42");
|
||||
body.setBizTitle("九月开学安排");
|
||||
body.setBizUrl("http://localhost:8077/#/list/detail?serid=42");
|
||||
body.setSubmitterId(201);
|
||||
body.setSubmitterName("张三");
|
||||
FlowModels.ApplyResultVo result = newClient().apply(1, body);
|
||||
assertEquals(9527L, result.getInstanceId());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("POST", c.method);
|
||||
assertEquals("/oapi/flow/apply", c.path);
|
||||
assertEquals("schId=1", c.query);
|
||||
assertEquals("notice", c.body.get("bizType").asText(), "契约冻结:bizType=notice");
|
||||
assertEquals("42", c.body.get("bizId").asText());
|
||||
assertEquals("fg-1", c.body.get("flowGuid").asText());
|
||||
assertEquals("九月开学安排", c.body.get("bizTitle").asText());
|
||||
assertEquals(201, c.body.get("submitterId").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceByBiz_contractFields() {
|
||||
FlowModels.InstanceVo vo = newClient().getInstanceByBiz(1, "notice", "42");
|
||||
assertEquals(9527L, vo.getInstanceId());
|
||||
assertEquals(10, vo.getStatus());
|
||||
assertEquals(2, vo.getVersion());
|
||||
assertEquals(2, vo.getAssigneeIds().size());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("/oapi/flow/instance", c.path);
|
||||
assertTrue(c.query.contains("bizType=notice") && c.query.contains("bizId=42") && c.query.contains("schId=1"),
|
||||
"instance 查询应带三参数: " + c.query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCallback_contractFields() {
|
||||
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
|
||||
body.setFlowGuid("fg-1");
|
||||
body.setCallbackUrl("http://localhost:8101/callback/flow");
|
||||
FlowModels.CallbackRegResultVo result = newClient().registerCallback(1, body);
|
||||
assertEquals("reg-secret-0123456789abcdef", result.getSecret());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("POST", c.method);
|
||||
assertEquals("/oapi/flow/callback/reg", c.path);
|
||||
assertEquals("http://localhost:8101/callback/flow", c.body.get("callbackUrl").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabled_allMethodsThrowBusinessException() {
|
||||
FlowCenterClient client = newClient(false);
|
||||
BizException ex = assertThrows(BizException.class, () -> client.listFlows(1));
|
||||
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
|
||||
assertThrows(BizException.class, () -> client.apply(1, new FlowModels.ApplyBody()));
|
||||
assertThrows(BizException.class, () -> client.getInstanceByBiz(1, "notice", "1"));
|
||||
assertThrows(BizException.class, () -> client.registerCallback(1, new FlowModels.CallbackRegBody()));
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人 → 放行;
|
||||
* 无关人/无身份 → 403"通知不存在或未发布"。
|
||||
*/
|
||||
class NoticeFlowGuardTest {
|
||||
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowGuard guard;
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
flowClient = mock(FlowClient.class);
|
||||
NoticeFlowProperties properties = new NoticeFlowProperties();
|
||||
properties.setEnabled(true);
|
||||
guard = new NoticeFlowGuard(flowClient, properties);
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(0);
|
||||
notice.setFlowGuid("fg-1");
|
||||
notice.setFlowInstanceId(9527L);
|
||||
notice.setPublisherAccount("zhangsan");
|
||||
}
|
||||
|
||||
private BoaUser user(Integer id, String account, Integer type, java.util.List<Integer> roleIds) {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(id);
|
||||
user.setAccount(account);
|
||||
user.setType(type);
|
||||
user.setRoleIds(roleIds);
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
user.setCurrentRole(role);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void stubAssignees(Integer... ids) {
|
||||
FlowModels.InstanceVo instance = new FlowModels.InstanceVo();
|
||||
instance.setAssigneeIds(Arrays.asList(ids));
|
||||
when(flowClient.getInstanceByBiz(any(), anyString(), anyString())).thenReturn(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitter_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schoolAdmin_byType_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(999, "admin1", 1, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schoolAdmin_byRoleId_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(999, "teacher5", 0, Collections.singletonList(5))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignee_allowed() {
|
||||
stubAssignees(301, 302);
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedUser_forbidden() {
|
||||
stubAssignees(301, 302);
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(404, "stranger", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noIdentity_forbidden() {
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notYetSubmitted_flowInstanceMissing_forbiddenExceptPrivileged() {
|
||||
notice.setFlowInstanceId(null); // 待提交(apply 未成功)→ 办理人维度不存在
|
||||
stubAssignees(301);
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowCenterDown_failClosed() {
|
||||
when(flowClient.getInstanceByBiz(any(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("flow-center down"));
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)),
|
||||
"流程中心不可达时 fail-closed 不放行");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.model.notice.NoticeRequest;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeReadRepository;
|
||||
import com.yunhai.notice.repository.NoticeReceiverRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.notice.service.IFileService;
|
||||
import com.yunhai.notice.service.IDictService;
|
||||
import com.yunhai.notice.service.impl.NoticeService;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 发布分流/守卫/重提单测(T2.2,fake FlowClient 注入):
|
||||
* 未绑流程=原直发链路零破坏哨兵;绑流程=status=0/audit=0→apply→回填 10;apply 失败保持 0 可重推;
|
||||
* 10 审批中锁编辑/删除/重提;30/40 可编辑重提复用同 bizId;sms 时序后移。
|
||||
*/
|
||||
class NoticeFlowPublishTest {
|
||||
|
||||
private NoticeService noticeService;
|
||||
private NoticeRepository noticeRepository;
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowService noticeFlowService;
|
||||
private SmsSender smsSender;
|
||||
private IDictService dictService;
|
||||
private CurrentBoaUser currentBoaUser;
|
||||
|
||||
private final AtomicInteger applyCalls = new AtomicInteger();
|
||||
private Notice savedEntity;
|
||||
private final java.util.List<Integer> auditAtSave = new java.util.ArrayList<>();
|
||||
private final java.util.List<Integer> statusAtSave = new java.util.ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
auditAtSave.clear();
|
||||
statusAtSave.clear();
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
dictService = mock(IDictService.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
currentBoaUser = mock(CurrentBoaUser.class);
|
||||
|
||||
// repository.save 回带 id 的实体(模拟自增主键);findById 回最近保存实体
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> {
|
||||
Notice n = inv.getArgument(0);
|
||||
if (n.getId() == null) {
|
||||
n.setId(77);
|
||||
}
|
||||
savedEntity = n;
|
||||
auditAtSave.add(n.getAuditStatus()); // 快照(避免 captor 引用别名)
|
||||
statusAtSave.add(n.getStatus());
|
||||
return n;
|
||||
});
|
||||
when(noticeRepository.findById(77)).thenAnswer(inv -> Optional.ofNullable(savedEntity));
|
||||
// 短信统一发送链(§3.2):认领桩=1 表示未发过(首次发送放行)
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
// 模拟窄列原子更新(作用于当前 findById 可见的实体;audit=20 不回退语义一并体现)
|
||||
when(noticeRepository.markApproving(anyInt(), any())).thenAnswer(inv -> {
|
||||
Notice current = noticeRepository.findById(inv.getArgument(0)).orElse(null);
|
||||
if (current != null) {
|
||||
current.setFlowInstanceId(inv.getArgument(1));
|
||||
if (current.getAuditStatus() == null || current.getAuditStatus() != 20) {
|
||||
current.setAuditStatus(10);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
when(dictService.resolveDeptName(any())).thenReturn("办公室");
|
||||
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(true);
|
||||
props.setFrontBaseUrl("http://localhost:8077");
|
||||
AppAuthProperties appAuth = new AppAuthProperties();
|
||||
appAuth.setAppKey("100617");
|
||||
|
||||
NoticeFlowRegRepository regRepository = mock(NoticeFlowRegRepository.class);
|
||||
when(regRepository.findBySchIdAndAppKeyAndFlowGuid(any(), any(), any())).thenReturn(Optional.empty());
|
||||
when(flowClient.registerCallback(any(), any())).thenAnswer(inv -> {
|
||||
FlowModels.CallbackRegResultVo vo = new FlowModels.CallbackRegResultVo();
|
||||
vo.setSecret("reg-secret-0123456789");
|
||||
return vo;
|
||||
});
|
||||
applyCalls.set(0);
|
||||
when(flowClient.apply(anyInt(), any())).thenAnswer(inv -> {
|
||||
applyCalls.incrementAndGet();
|
||||
FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo();
|
||||
vo.setInstanceId(9527L);
|
||||
vo.setDuplicated(applyCalls.get() > 1);
|
||||
return vo;
|
||||
});
|
||||
|
||||
noticeFlowService = new NoticeFlowService(noticeRepository, regRepository,
|
||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser,
|
||||
new NoticeFlowGuard(flowClient, props));
|
||||
|
||||
noticeService = new NoticeService();
|
||||
ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "upFileRepository", mock(UpFileRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "smsSender", smsSender);
|
||||
ReflectionTestUtils.setField(noticeService, "smsSendService",
|
||||
new com.yunhai.notice.service.sms.SmsSendService(noticeRepository, smsSender));
|
||||
ReflectionTestUtils.setField(noticeService, "fileService", mock(IFileService.class));
|
||||
ReflectionTestUtils.setField(noticeService, "dictService", dictService);
|
||||
ReflectionTestUtils.setField(noticeService, "currentBoaUser", currentBoaUser);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeFlowService", noticeFlowService);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeFlowGuard", mock(NoticeFlowGuard.class));
|
||||
|
||||
// 登录态:张三(201)/本校 1
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setLoginName("zhangsan");
|
||||
loginUser.setDisplayName("张三");
|
||||
UserContextHolder.set(loginUser);
|
||||
BoaUser boaUser = new BoaUser();
|
||||
boaUser.setId(201);
|
||||
boaUser.setAccount("zhangsan");
|
||||
boaUser.setName("张三");
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
boaUser.setCurrentRole(role);
|
||||
when(currentBoaUser.get()).thenReturn(boaUser);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserContextHolder.clear();
|
||||
}
|
||||
|
||||
private NoticeRequest request(String flowGuid) {
|
||||
NoticeRequest request = new NoticeRequest();
|
||||
request.setTitle("九月开学安排");
|
||||
request.setContent("<p>内容</p>");
|
||||
request.setType(1);
|
||||
request.setDeptId("d1");
|
||||
request.setReceiveList("[\"tea1\",\"tea2\"]");
|
||||
request.setSmsFlag(1);
|
||||
request.setFlowGuid(flowGuid);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundFlow_directPublish_zeroSideEffects() {
|
||||
// 存量零破坏哨兵:未绑流程 → status=1、sms 即时、flow 零交互
|
||||
Integer id = noticeService.publish(request(null));
|
||||
ArgumentCaptor<Notice> captor = ArgumentCaptor.forClass(Notice.class);
|
||||
verify(noticeRepository, times(1)).save(captor.capture());
|
||||
assertEquals(1, captor.getValue().getStatus());
|
||||
assertEquals("zhangsan", captor.getValue().getPublisherAccount());
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
verify(flowClient, never()).registerCallback(anyInt(), any());
|
||||
assertNotNull(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundFlow_status0_audit0_thenApplyBackfills() {
|
||||
// 单测无活动事务 → submitAfterCommit 立即执行(等价 AFTER_COMMIT 语义验证)
|
||||
noticeService.publish(request("fg-review"));
|
||||
verify(noticeRepository, times(1)).save(any(Notice.class)); // 仅落库;apply 后走窄列原子更新
|
||||
assertEquals(0, auditAtSave.get(0), "首存 audit=0 待提交");
|
||||
assertEquals(0, firstSavedStatus(), "绑流程通知应落 status=0 未发布");
|
||||
assertEquals("fg-review", savedEntity.getFlowGuid());
|
||||
// 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体)
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
||||
verify(noticeRepository).markApproving(eq(77), eq(9527L));
|
||||
// sms 后置:create 时不发
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() {
|
||||
when(flowClient.apply(anyInt(), any()))
|
||||
.thenThrow(new RuntimeException("flow-center down"))
|
||||
.thenAnswer(inv -> {
|
||||
FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo();
|
||||
vo.setInstanceId(9527L);
|
||||
return vo;
|
||||
});
|
||||
noticeService.publish(request("fg-review")); // 出站失败被吞(保持 0 待提交)
|
||||
assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)");
|
||||
// 重推入口:trySubmitFlow 幂等治愈 → 10
|
||||
noticeFlowService.trySubmitFlow(77);
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
||||
verify(noticeRepository, times(1)).markApproving(eq(77), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void trySubmit_rejectedWhenApproving() {
|
||||
noticeService.publish(request("fg-review")); // → 10
|
||||
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77));
|
||||
assertTrue(ex.getMessage().contains("审批中"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void trySubmit_byStranger_denied() {
|
||||
// BP-R5-3:无关登录用户不得对他人通知触发重提(403,且不得触达 apply 出站)
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
BoaUser stranger = new BoaUser();
|
||||
stranger.setId(999);
|
||||
stranger.setAccount("stranger");
|
||||
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77, stranger));
|
||||
assertTrue(ex.getMessage().contains("通知不存在或未发布"));
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editAndDelete_rejectedWhenApproving() {
|
||||
noticeService.publish(request("fg-review")); // → audit=10
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertThrows(BizException.class, () -> noticeService.edit(77, request("fg-review")));
|
||||
assertThrows(BizException.class, () -> noticeService.deleteBatch(java.util.Collections.singletonList(77)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editAfterRejected_resubmitsSameBizId() {
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
noticeService.edit(77, request("fg-review"));
|
||||
// 编辑后 AFTER_COMMIT(单测立即)重提,复用同一 bizId=notice.id
|
||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||
verify(flowClient, times(1)).apply(anyInt(), body.capture());
|
||||
assertEquals("77", body.getValue().getBizId());
|
||||
assertEquals(10, rejected.getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyParams_contract() {
|
||||
noticeService.publish(request("fg-review"));
|
||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||
verify(flowClient).apply(anyInt(), body.capture());
|
||||
FlowModels.ApplyBody b = body.getValue();
|
||||
assertEquals("fg-review", b.getFlowGuid());
|
||||
assertEquals("notice", b.getBizType());
|
||||
assertEquals("77", b.getBizId());
|
||||
assertEquals("九月开学安排", b.getBizTitle());
|
||||
assertEquals("http://localhost:8077/#/list/detail?serid=77", b.getBizUrl());
|
||||
assertEquals(201, b.getSubmitterId());
|
||||
assertEquals("张三", b.getSubmitterName());
|
||||
// 快照:title + summary(纯文本) + attachmentNames[]
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, Object> snapshot = (java.util.Map<String, Object>) b.getBizSnapshot();
|
||||
assertEquals("九月开学安排", snapshot.get("title"));
|
||||
assertEquals("内容", snapshot.get("summary"));
|
||||
assertTrue(snapshot.get("attachmentNames") instanceof java.util.List);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindFlowOnEdit_clearsAuditAndPublishes() {
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
noticeService.edit(77, request(null)); // 解绑 → 直发
|
||||
assertEquals(null, rejected.getFlowGuid());
|
||||
assertEquals(null, rejected.getAuditStatus());
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
}
|
||||
|
||||
/** 首次保存时的 status 快照 */
|
||||
private Integer firstSavedStatus() {
|
||||
return statusAtSave.get(0);
|
||||
}
|
||||
|
||||
private Notice pendingNotice(int auditStatus) {
|
||||
// 模拟已落库的绑流程通知(status=0,audit=指定态)
|
||||
Notice n = new Notice();
|
||||
n.setId(77);
|
||||
n.setTitle("九月开学安排");
|
||||
n.setContent("<p>内容</p>");
|
||||
n.setStatus(0);
|
||||
n.setAuditStatus(auditStatus);
|
||||
n.setFlowGuid("fg-review");
|
||||
n.setSchId(1);
|
||||
n.setPublisherAccount("zhangsan");
|
||||
n.setSmsFlag(1);
|
||||
n.setReceiveList("[\"tea1\",\"tea2\"]");
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批进度组装单测(T2.4):未绑流程返回"未接入"语义(bound=false,前端隐藏卡片);
|
||||
* 绑定通知合并本地镜像(audit/flowResult)+ flow-center 实时(version/trace/assignee);
|
||||
* 流程中心不可达时降级为本地镜像不抛异常。
|
||||
*/
|
||||
class NoticeFlowTraceTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowService service;
|
||||
private Notice bound;
|
||||
private Notice unbound;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(true);
|
||||
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class),
|
||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||
new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props));
|
||||
|
||||
bound = new Notice();
|
||||
bound.setId(42);
|
||||
bound.setSchId(1);
|
||||
bound.setStatus(0);
|
||||
bound.setFlowGuid("fg-1");
|
||||
bound.setAuditStatus(10);
|
||||
bound.setFlowInstanceId(9527L);
|
||||
bound.setFlowResultJson("{\"opinion\":\"同意\"}");
|
||||
bound.setPublisherAccount("zhangsan");
|
||||
|
||||
unbound = new Notice();
|
||||
unbound.setId(43);
|
||||
unbound.setSchId(1);
|
||||
unbound.setStatus(1);
|
||||
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(bound));
|
||||
when(noticeRepository.findById(43)).thenReturn(Optional.of(unbound));
|
||||
}
|
||||
|
||||
/** 未发布通知的合法访问者(提交人本人) */
|
||||
private static com.yunhai.platform.utils.model.BoaUser owner() {
|
||||
com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201);
|
||||
u.setAccount("zhangsan");
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 无关登录用户(非提交人/管理员/办理人) */
|
||||
private static com.yunhai.platform.utils.model.BoaUser stranger() {
|
||||
com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999);
|
||||
u.setAccount("stranger");
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundNotice_returnsNotIntegrated() {
|
||||
// 已发布(status=1)内容公开:任意登录用户可看(不受守卫限制)
|
||||
Map<String, Object> trace = service.buildTrace(43, stranger());
|
||||
assertEquals(false, trace.get("bound"));
|
||||
assertEquals(null, trace.get("auditStatus"));
|
||||
assertEquals(1, trace.get("status"));
|
||||
assertFalse(trace.containsKey("trace"), "未绑流程不应有进度明细");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedTrace_byStranger_denied() {
|
||||
// BP-R5-3:未发布通知的轨迹/驳回意见仅三类人可见,其余 403(查不到语义)
|
||||
com.yunhai.platform.apistd.BizException e = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.buildTrace(42, stranger()));
|
||||
assertTrue(e.getMessage().contains("通知不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedTrace_byOwnerVisible() {
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(null);
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundNotice_mergesLocalMirrorAndLiveInstance() {
|
||||
FlowModels.InstanceVo instance = new FlowModels.InstanceVo();
|
||||
instance.setInstanceId(9527L);
|
||||
instance.setStatus(10);
|
||||
instance.setVersion(3);
|
||||
instance.setNodeSummary("编辑审核→终审");
|
||||
instance.setCurrentNodes("n2");
|
||||
instance.setAssigneeIds(Arrays.asList(301, 302));
|
||||
instance.setTraceJson("[{\"node\":\"n1\",\"status\":1}]");
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(instance);
|
||||
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
assertEquals(10, trace.get("auditStatus"));
|
||||
assertEquals("审批中", trace.get("auditStatusDesc"));
|
||||
assertEquals(3, trace.get("version"));
|
||||
assertEquals("n2", trace.get("currentNodes"));
|
||||
assertNotNull(trace.get("trace"));
|
||||
assertNotNull(trace.get("flowResult"));
|
||||
assertEquals(Arrays.asList(301, 302), trace.get("assigneeIds"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowCenterDown_degradesToLocalMirror() {
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any()))
|
||||
.thenThrow(new RuntimeException("flow-center down"));
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
assertEquals(10, trace.get("auditStatus"), "流程中心停机时详情仍可用(本地镜像)");
|
||||
assertTrue(((Map<String, Object>) trace).containsKey("flowResult"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yunhai.notice.service.impl;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeReadRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 详情标读门禁单测(走查报告 §七.4):仅已发布(status=1)通知在 detail 时标记已读——
|
||||
* 审批人查看未发布通知(NoticeFlowGuard 放行的审核动作)不再被记入已读名单污染阅读统计。
|
||||
*/
|
||||
@DisplayName("NoticeService.detail - 标读门禁(仅已发布)")
|
||||
class NoticeDetailReadTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeReadRepository noticeReadRepository;
|
||||
private NoticeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
noticeReadRepository = mock(NoticeReadRepository.class);
|
||||
service = new NoticeService();
|
||||
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository);
|
||||
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setLoginName("approver1");
|
||||
loginUser.setDisplayName("审批人甲");
|
||||
UserContextHolder.set(loginUser);
|
||||
when(noticeReadRepository.existsByNoticeIdAndReaderAccount(anyInt(), anyString())).thenReturn(false);
|
||||
when(noticeReadRepository.save(any(com.yunhai.notice.entity.NoticeRead.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserContextHolder.clear();
|
||||
}
|
||||
|
||||
private Notice notice(int status) {
|
||||
Notice n = new Notice();
|
||||
n.setId(42);
|
||||
n.setTitle("审核中通知");
|
||||
n.setSchId(1);
|
||||
n.setStatus(status);
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||
return n;
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedNotice_detailMarksRead() {
|
||||
notice(1);
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedNotice_detailDoesNotMarkRead() {
|
||||
notice(0); // 审批人经 NoticeFlowGuard 查看未发布通知:不标读
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyRead_notMarkedAgain() {
|
||||
notice(1);
|
||||
when(noticeReadRepository.existsByNoticeIdAndReaderAccount(42, "approver1")).thenReturn(true);
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.yunhai.notice.service.impl;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.model.notice.NoticeRequest;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 驳回后编辑重提的清态单测(业务人视角走查 NEW-3):
|
||||
* Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期
|
||||
* 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。
|
||||
*/
|
||||
@DisplayName("NoticeService.edit - 重提清旧驳回意见")
|
||||
class NoticeResubmitClearResultTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeFlowService noticeFlowService;
|
||||
private NoticeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
noticeFlowService = mock(NoticeFlowService.class);
|
||||
service = new NoticeService();
|
||||
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService);
|
||||
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
|
||||
}
|
||||
|
||||
/** 被驳回的通知:audit=30 + 已落定上一轮驳回意见 */
|
||||
private Notice rejectedNotice() {
|
||||
Notice n = new Notice();
|
||||
n.setId(42);
|
||||
n.setTitle("旧标题");
|
||||
n.setSchId(1);
|
||||
n.setStatus(0);
|
||||
n.setFlowGuid("fg-1");
|
||||
n.setFlowInstanceId(9011L);
|
||||
n.setAuditStatus(FlowAuditStatusEnum.REJECTED.getCode());
|
||||
n.setFlowResultJson("{\"result\":\"REJECTED\",\"opinion\":\"内容不妥\"}");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||
return n;
|
||||
}
|
||||
|
||||
private NoticeRequest editRequest(String flowGuid) {
|
||||
NoticeRequest req = new NoticeRequest();
|
||||
ReflectionTestUtils.setField(req, "title", "改后标题");
|
||||
ReflectionTestUtils.setField(req, "content", "改后正文");
|
||||
ReflectionTestUtils.setField(req, "flowGuid", flowGuid);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void editResubmit_clearsStaleRejectOpinion() {
|
||||
Notice n = rejectedNotice();
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.edit(42, editRequest("fg-1"));
|
||||
|
||||
assertEquals(FlowAuditStatusEnum.PENDING_SUBMIT.getCode(), n.getAuditStatus(),
|
||||
"重提回到 0 待提交");
|
||||
assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示");
|
||||
assertEquals("改后标题", n.getTitle());
|
||||
verify(noticeFlowService).submitAfterCommit(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindFlow_alsoClearsResultAndInstance() {
|
||||
Notice n = rejectedNotice();
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.edit(42, editRequest(null)); // 解绑流程 → 直发
|
||||
|
||||
assertNull(n.getFlowGuid());
|
||||
assertNull(n.getFlowInstanceId());
|
||||
assertNull(n.getFlowResultJson());
|
||||
assertNull(n.getAuditStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.yunhai.notice.support;
|
||||
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.springframework.http.HttpEntity;
|
||||
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.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* oapi 契约测试客户端(照 flow-center OapiSignedClient 同构):手工三件套签名,
|
||||
* 故意不走共享 IDHttp——测试要能看到 401 原始响应。
|
||||
*/
|
||||
public class OapiSignedClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final String appKey;
|
||||
private final String appSecret;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public OapiSignedClient(String baseUrl, String appKey, String appSecret) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.appKey = appKey;
|
||||
this.appSecret = appSecret;
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
this.restTemplate.setErrorHandler(new org.springframework.web.client.ResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> get(String pathAndQuery, Class<T> type) {
|
||||
return exchange(HttpMethod.GET, pathAndQuery, null, type);
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> post(String pathAndQuery, Object body, Class<T> type) {
|
||||
return exchange(HttpMethod.POST, pathAndQuery, body, type);
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<T> exchange(HttpMethod method, String pathAndQuery, Object body, Class<T> type) {
|
||||
String target = signTarget(pathAndQuery);
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest(method.name(), target, ts, appSecret);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
if (body != null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, method,
|
||||
body == null ? new HttpEntity<>(headers) : new HttpEntity<>(body, headers), type);
|
||||
}
|
||||
|
||||
private static String signTarget(String pathAndQuery) {
|
||||
int q = pathAndQuery.indexOf('?');
|
||||
if (q < 0) {
|
||||
return pathAndQuery;
|
||||
}
|
||||
String path = pathAndQuery.substring(0, q);
|
||||
String[] pairs = pathAndQuery.substring(q + 1).split("&");
|
||||
java.util.Arrays.sort(pairs);
|
||||
StringBuilder sb = new StringBuilder(path);
|
||||
for (String pair : pairs) {
|
||||
if (sb.indexOf("?") < 0) {
|
||||
sb.append("?");
|
||||
} else if (sb.charAt(sb.length() - 1) != '?') {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(pair);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 无签名调用(401 用例) */
|
||||
public ResponseEntity<String> getRaw(String pathAndQuery) {
|
||||
return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class);
|
||||
}
|
||||
|
||||
/** 带错误密钥的调用(401 用例) */
|
||||
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", signTarget(pathAndQuery), ts, "bad-secret-000000000000");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user