Compare commits

..
Author SHA1 Message Date
chenxing d99ce81cc0 refactor(file): 附件根目录对齐平台标准 FILE_PATH——优先级 FILE_PATH(共享 application-platform.yml 一份)>FILE_UPLOAD_PATH(notice 过渡键)>file.upload-path(本地开发)
build / build-push (push) Successful in 2m31s
其他子应用(userhome/userbase/flow-center)代码即读 FILE_PATH,各子应用值相同
(/data/upload),收敛到共享 application-platform.yml 一份后子应用无需再各自维护;
notice 的 FILE_WEB_PATH/UPLOAD_WEB_PATH_HTTP 为 projman 模板键,代码无消费点。
2026-10-10 16:04:31 +08:00
chenxing b96de59b6d docs(file): FILE_UPLOAD_PATH 注释改为配置文件顶层键口径(与 flow-center 的 FILE_PATH 同玩法,环境变量亦可)
build / build-push (push) Successful in 2m38s
2026-10-10 15:54:07 +08:00
chenxing a4e8702dc0 fix(file): 附件存储根目录优先读平台标准环境变量 FILE_UPLOAD_PATH——修复生产附件随容器重建丢失
build / build-push (push) Successful in 1m27s
生产容器 env 只有旧 dotnet 风格 FILE_PATH 顶层键(Spring 不识别),@Value(${file.upload-path})
回落到打包默认 D:/data/upload,Linux 下被锚定为 /app/D:/data/upload 写进容器可写层,
每次 projman 重新部署附件全部丢失(2026-10 事故,卷里仅剩 9/1 前的 3 个文件)。

新优先级:FILE_UPLOAD_PATH 环境变量(子应用平台新标准,projman 注入 /data/upload)
> file.upload-path(application.yml,仅本地 Windows 开发)> /data/upload 兜底。
deploy/notice.yaml 与 docker-compose.notice.yml 文档块同步更新。
2026-10-10 15:46:07 +08:00
chenxing 48cd8884d8 fix(notice): 已读明细空态 receivers 不可变列表修复 + SDK 暴露面测试补齐
build / build-push (push) Successful in 1m2s
- NoticeService.getReadDetailGrouped:receivers 空态(全员通知)返回不可变 emptyList,追加已读者前先拷贝为 ArrayList,避免 UnsupportedOperationException
- 新增 NoticeReadDetailGroupedAllTypeTest、NoticeSdkAdviceExposureTest
2026-10-10 15:17:08 +08:00
chenxing b85f5e16fb feat(notice): 移动端 API 接口面(/mobiapi)——路径常量闭集、访问拦截器与控制器
- Config:API_PATH 由通配 /{platform} 收敛为 /api(PC 管理端,CAS 会话),新增 MOBI_API_PATH=/mobiapi(移动端,平台 Bearer token)
- 新增 MobiAccessInterceptor:移动端访问拦截(token 校验)
- 新增 MobiWebMvcConfig:挂载 /mobiapi 前缀与拦截器
- 新增 BaseMobiController / NoticeMobiController:移动端通知接口
- 随附测试:MobiAccessInterceptorTest / MobiWebMvcConfigTest / NoticeMobiControllerTest
2026-10-10 15:17:06 +08:00
chenxing 81a988f96a feat(notice): 流程快照增约定键 summaryFields(通知标题/来源部门/通知对象)
build / build-push (push) Successful in 1m4s
供 flow-center 嵌入页「业务信息」直出中文键名,零后端改动(bizSnapshot 透传)。
通知对象:全员 ⇒「全部」;指定 ⇒ 前 3 个姓名以「、」连接,多于 3 人再追「 等N人」。
原有键(title/summary/attachmentNames 等)照旧保留。
2026-10-10 14:56:10 +08:00
chenxing 5efe9c09fd Merge remote-tracking branch 'origin/main' into feat/flow-embed
build / build-push (push) Successful in 2m19s
2026-10-09 20:56:28 +08:00
chenxing e6e91157be feat(notice): 新增 Flow/DiagramDef 端点——flowGuid 口径后端中转 PNG(SDK diagramUrl 双路径+token 复用),供绑定页/发通知页 Drawer 修复 404 坏链;守卫与绑定读取同口径(CAS 登录即可) 2026-10-09 20:04:28 +08:00
chenxing 09eb27c6cf fix(appearance): 503 改 sendError 直写——绕过 GlobalExceptionHandler 兜成 200+code:6 信封(线上实测发现)
build / build-push (push) Failing after 1m3s
2026-10-09 00:40:13 +08:00
chenxing 0721cf3b7f fix(appearance): 503 改 sendError 直写——绕过 GlobalExceptionHandler 兜成 200+code:6 信封(线上实测发现) 2026-10-09 00:39:47 +08:00
chenxing 0d05986f8c feat(appearance): 外观偏好同源读代理端点 GET /api/appearance(oapi 代理轮)
build / build-push (push) Successful in 3m30s
- YschOpenCall(target=userhome) 经 open-gateway 签名直调 /oapi/appearance,JsonNode 整体透传(哑管道)
- 降级铁律:下游不可达 503(init 侧静默不动本地键),绝不伪装 200+全 null 清键
2026-10-08 23:59:25 +08:00
chenxing 867809fe37 feat(appearance): 外观偏好同源读代理端点 GET /api/appearance(oapi 代理轮)
- YschOpenCall(target=userhome) 经 open-gateway 签名直调 /oapi/appearance,JsonNode 整体透传(哑管道)
- 降级铁律:下游不可达 503(init 侧静默不动本地键),绝不伪装 200+全 null 清键
2026-10-08 23:27:33 +08:00
chenxing a103d661fc feat(notice): 接入流程进度嵌入页(守卫端点 + Drawer iframe) 2026-10-03 16:59:41 +08:00
chenxing 0216d0655c feat(msg): JumpTarget 标准化接入——两处跳转换 SDK 结构化门面(app+jump 链式/setBizJump),jumpPath 与 bizUrl 输出逐字节零差异;随车自指地址 Phase2 镜像收口(front-base-url 与 callback-* 键删除、裸相对路径、jumpApp 归属声明,测试同步);application.yml 服务目录键 flowcenter→flow-center 归一(D1 P3 尾巴)
build / build-push (push) Successful in 2m15s
2026-09-29 11:00:05 +08:00
chenxing 082ef0af0a fix(test): NoticeApprovalClientTest 漏改 FLOWCENTER→FLOW_CENTER(D1 补漏)——test/notice/flow 包不在首轮扫描路径,CI testCompile 拦获
build / build-push (push) Successful in 2m25s
2026-09-28 18:39:48 +08:00
chenxing 4ac3626b92 refactor(flow): 目录键归一 flowcenter→flow-center(D1)——PlatformService.FLOW_CENTER 机械跟随,FlowCenterClientTest 失败文案断言同步新键;NoticeFlowProperties 注释残留随移动门户线 WIP 后续处理
build / build-push (push) Failing after 7s
2026-09-28 17:26:30 +08:00
chenxing a7e93cb22d refactor(flow): 回调端点收敛为 starter 默认 /oapi/flow/callback + 开漂移自动核对
build / build-push (push) Successful in 1m5s
网关契约 /{target}/oapi/** 的首段已由 target 承担,应用内端点再加一段
<target> 是冗余(Notice 的 /oapi/notice/flow/callback 属历史遗留:其 oapi
Controller 统一建在 /oapi/notice/** 下)。

- application.yml: notice.flow.callback-path 与 yh.flow-callback.path
  由 /oapi/notice/flow/callback 收敛为 /oapi/flow/callback
- application.yml: 摘除显式 callback-url,注册地址改由 starter 推导
  (open-host ?: yh.open.base-host) + /{target ?: base-path} + path
  ⇒ 生产 = http://open-gateway-service:10013/notice/oapi/flow/callback
  回滚绳:把全地址写回 callback-url 即可,无需回滚镜像
- application.yml: 开 reconcile-on-register=true —— 端点/ctx/base-path
  变更后镜像行有效但登记 URL 不一致时自动重注册,替代"删镜像行→重新保存
  绑定"的手工恢复路径(注意:重注册会轮换 secret)
- NoticeFlowProperties: callbackPath 默认值同步
- 测试 4 处同步冻结字面量/硬编码路径/断言

测试:mvn -o test → 114/114 通过(含 NoticeFlowApiIT 9 例、FlowCallbackTransientIT
1 例在真实过滤链上按新端点 200)
2026-09-24 11:57:01 +08:00
chenxing 58aadaa95a feat(flow): 迁 yh-flow-callback starter + 对接抄送与条件路由判断端点
build / build-push (push) Successful in 1m8s
迁 starter(2026-09-20 方案):删手写 OapiFlowCallbackController/FlowCallbackService,改 NoticeFlowCallbackHandler 实现 SPI;验签/eventId 幂等/乱序门交 starter 管线,yml 切中央密钥链(trust-registry)
抄送(2026-09-24):flow.cc.sent 落 notice_cc 表 + GET /api/notice/cc/My 应用内「抄送给我的」;未发布通知可见守卫加第 4 条「被抄送人」
条件路由(2026-09-24):POST /oapi/notice/flow/route/dept-judge(纯读幂等;formData 优先免查库;未命中 outlets 抛错绝不默认放行);apply 的 formData 带发文部门(deptId/deptName)
IT 验签确定性:NoticeFlowApiIT/FlowCallbackTransientIT 属性覆盖回落白名单(trusted-apps.100019),不再依赖本地 Redis 密钥内容
生产 DDL:deploy/sql/20260924_add_notice_cc.sql;mvn test 114 全绿
2026-09-24 01:19:26 +08:00
chenxing df9b9e0aa5 feat(approval): 待审核代理——Todo/Instances/Approve/Reject(operator=CAS 登录态)
build / build-push (push) Successful in 1m2s
- NoticeApprovalClient:YschOpenCall 泛化调 flow-center /oapi/flow/tasks|task/**
  (SDK 四方法未发版,泛化调用零发版依赖,同 resolveTarget 解析链经网关)
- NoticeApprovalController:operatorId/Name 一律取 BoaUser 登录态,请求不收身份字段;
  reject 空 opinion 前置业务码拒绝;未接入流程中心降级 FORBIDDEN
- 9 用例:URL/参数逐字断言+bizType 固定+未接入+异常透传(90 存量单测无回归)
2026-09-23 00:28:32 +08:00
chenxing 5db629cfdb feat(msg): 接入消息中心——NoticeUserMsgNotifier(发布/转发/催办→UserMsg WS 实时推送,afterCommit 容错+分片+幂等)+ SDK 门面客户端 + 用户目录归一(09-19 二期验收 9/9)
build / build-push (push) Successful in 57s
2026-09-19 18:55:17 +08:00
chenxing a5204680e6 ci: 重触发构建(上次 runner OOM exit137)
build / build-push (push) Successful in 1m7s
2026-09-17 01:16:32 +08:00
chenxing 08e9531b5f feat(flow): 接入新流程体系——SDK withdraw/progress/diagramUrl:撤回后端中转+同源流程图端点+Trace 结构化 steps(治前端直调外溢)
build / build-push (push) Failing after 33s
2026-09-17 01:10:36 +08:00
chenxing b3c5326163 fix(flow): 回调路径过期值修正 + 地址缺失 fail-fast + 服务目录显式化
build / build-push (push) Successful in 2m10s
- application.yml:notice.flow.callback-path 由过期的 /callback/flow 改回
  /oapi/notice/flow/callback(/callback/* 已随 X-Flw-* 过滤器下线,旧值会让本地注册的
  回调地址 404);新增 yh.open.services.flowcenter,删 notice.flow.base-url。
- NoticeFlowProperties.baseUrl 默认值去掉(localhost:8085 静默回落是隐患);
  FlowCenterClient 在 enabled=true 且「目录与 legacy 键都缺」时构造期 fail-fast
  (enabled=false 不校验——显式未接入本就不需要地址)。
- 测试:新增 NoticeFlowCallbackConfigContractTest(反射拼控制器映射,冻结
  callback-path/callback-base-path 与控制器/Runtime ctx 的咬合);FlowCenterClientTest
  补 fail-fast 与 disabled 两态。68 测试全绿。
2026-09-16 21:06:17 +08:00
55 changed files with 3277 additions and 672 deletions
+7
View File
@@ -120,6 +120,13 @@
<artifactId>yh-spring-boot-starter</artifactId> <artifactId>yh-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- 流程中心回调接收端 starter(2026-09-20 迁移):验签外置(/oapi/* 平台验签),
接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。 <!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 --> 版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency> <dependency>
@@ -0,0 +1,34 @@
package com.yunhai.notice.common.interceptor;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.auth.UserContextHolder;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 移动端 /mobiapi 访问拦截(口径 = 登录即可访问)。
* <p>不做访问级别判级——移动端主体含学生(旧 SUPER/BUREAU/SCHOOL/NONE 判级照抄会全 403)。
* token 解析、401+40101 信封与 pre-token 豁免由平台 SDK {@code LoginUserInterceptor} 承担
* (tokenSurfaces 缺省 /mobiapi);本拦截器是业务侧兜底:SDK 之后的任何 /mobiapi 业务请求,
* 身份上下文为空即 401,防止依赖链路变化时出现"匿名可达的业务端点"。</p>
*/
public class MobiAccessInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (UserContextHolder.get() == null && BoaUserContext.get() == null) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}");
} catch (Exception ignored) {
// 响应已提交等场景:状态码已设置,写体失败不掩盖原始语义
}
return false;
}
return true;
}
}
@@ -1,11 +1,14 @@
package com.yunhai.notice.config; package com.yunhai.notice.config;
/** /**
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 * 全局路径常量(三层接口面,闭集):/api PC 管理端(CAS)、/mobiapi 移动端(token)、/oapi 开放接口(应用签名)。
* 其余前缀(如旧 /manage、/{platform} 通配)一律 404(方案 §4.1 路径闭集)。
*/ */
public enum Config { public enum Config {
Inst; Inst;
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔 // 末尾不带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会拼出字面双斜杠而 404
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话)
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 Bearer token)
public static final String OPEN_API_PATH = "/oapi"; public static final String OPEN_API_PATH = "/oapi";
} }
@@ -0,0 +1,27 @@
package com.yunhai.notice.config;
import com.yunhai.notice.common.interceptor.MobiAccessInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 移动端 /mobiapi 拦截装配。
* <p>{@code LOWEST_PRECEDENCE} 保证本配置器在 SDK 的 {@code YhAuthWebMvcConfigurer}
* (无 @Order)之后注册,登录态解析先于本兜底拦截执行。</p>
* <p>排除 {@code /mobiapi/auth/**}:其中 login/bind/exchange 是 SDK 定义的 pre-token 端点
* ({@code LoginUserInterceptor.PRE_TOKEN_ENDPOINTS}),设计上匿名可达——它们是登录链本身,
* 不是"业务免登录端点";logout/jsapiSignature 仍由 SDK 按 token 强制。业务端点无任何白名单。</p>
*/
@Configuration
public class MobiWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new MobiAccessInterceptor())
.addPathPatterns("/mobiapi/**")
.excludePathPatterns("/mobiapi/auth/**")
.order(Ordered.LOWEST_PRECEDENCE);
}
}
@@ -0,0 +1,64 @@
package com.yunhai.notice.controller.api;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.notice.config.Config;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 外观偏好同源读代理(oapi 代理轮 2026-10-08,全端统一形态)。
*
* <p>浏览器(通道 init 异步段)只与本应用【同源】取数——CAS 会话天然有效,无需 iframe 引导;
* 后端经 open-gateway 签名直调 UserHome(target=userhome,寻址走 yh.open.services,
* 勿写死 host)。设置入口收敛在 UserHome 设置中心,本端点【只读不写】。</p>
*
* <p>契约 = UserHome /oapi/appearance 出参【整体透传】(JsonNode 哑管道):字段加减只在
* UserHome + 通道 init 两端演进,本应用零改动。现行字段 {@code {theme, avatar, logoUrl}}——
* theme/avatar 为服务端合成后的【生效值】(个人→学校→平台级联,不拆 my/sch);
* null=未配置,init 侧清对应本地键回落默认观感。</p>
*
* <p>降级铁律:UserHome/网关不可达时【必须】以非 2xx 返回(503),绝不能伪装成 200+全 null——
* init 会把全 null 收敛成清键,等于 UserHome 停服一次就清空全员本地观感
* (对齐直连时代「fetch 失败静默、不动键」的语义)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH)
public class AppearanceProxyController {
private static final Logger log = LoggerFactory.getLogger(AppearanceProxyController.class);
/** open_api_target 注册表中的 userhome;寻址 yh.call.targets → yh.open.services → 枚举兜底 */
private static final String TARGET_USERHOME = "userhome";
private final YschOpenCall openCall;
public AppearanceProxyController(YschOpenCall openCall) {
this.openCall = openCall;
}
@GetMapping("appearance")
public JsonNode appearance(BoaUser authUserWithDept, HttpServletResponse response) throws IOException {
try {
Map<String, String> q = new HashMap<>();
q.put("userId", String.valueOf(authUserWithDept.getId())); // 服务端取登录人,不收前端参数
return openCall.get(TARGET_USERHOME, "/oapi/appearance", q, JsonNode.class);
} catch (Exception e) {
// 降级铁律:sendError 直写 503 绕过 GlobalExceptionHandler(否则被兜成 200+code:6 信封,
// init 侧靠信封防御兜底,但语义上必须让浏览器拿到非 2xx)
log.warn("[appearance] UserHome 外观偏好不可达(503,init 侧静默不动本地键)userId={}:{}",
authUserWithDept.getId(), e.toString());
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
}
}
@@ -0,0 +1,98 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.service.flow.NoticeApprovalClient;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 应用内审批(第三步 T-C4):"我的待审核"数据面。前端不直调 oapi——
* operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段,
* 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。
* <ul>
* <li>GET Approval/Todo:待办分页(bizType=notice 固定);</li>
* <li>GET Approval/Instances:办件分页(status 可选);</li>
* <li>POST Approval/Approve / Reject:办理(body 仅 opinion/edgeName)。</li>
* </ul>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/approval/")
@RequiredArgsConstructor
public class NoticeApprovalController {
private final NoticeApprovalClient approvalClient;
/** 我的待审核(列表页数据源) */
@GetMapping("Todo")
public Map<String, Object> todo(BoaUser user,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Operator op = requireOperator(user);
return approvalClient.todoTasks(op.schId, op.userId, page, size);
}
/** 办件视角(对账/历史;status 10 审批中/20 完成/30 拒绝/40 撤回) */
@GetMapping("Instances")
public Map<String, Object> instances(BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Operator op = requireOperator(user);
return approvalClient.instances(op.schId, status, page, size);
}
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
@PostMapping("Approve")
public void approve(@RequestParam("taskId") Long taskId, BoaUser user,
@RequestBody(required = false) Map<String, String> body) {
Operator op = requireOperator(user);
String opinion = body == null ? null : body.get("opinion");
String edgeName = body == null ? null : body.get("edgeName");
approvalClient.approve(taskId, op.schId, op.userId, op.name, opinion, edgeName);
}
/** 拒绝(opinion 必填) */
@PostMapping("Reject")
public void reject(@RequestParam("taskId") Long taskId, BoaUser user,
@RequestBody(required = false) Map<String, String> body) {
Operator op = requireOperator(user);
approvalClient.reject(taskId, op.schId, op.userId, op.name,
body == null ? null : body.get("opinion"));
}
/** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */
private static Operator requireOperator(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
}
String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount();
return new Operator(user.getId(), user.getCurrentRole().getSchId(), name);
}
private static final class Operator {
final Integer userId;
final Integer schId;
final String name;
Operator(Integer userId, Integer schId, String name) {
this.userId = userId;
this.schId = schId;
this.name = name;
}
}
}
@@ -0,0 +1,96 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeCc;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.repository.NoticeCcRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 应用内「抄送给我的」(2026-09-24):数据源是 flow.cc.sent 回调落的 notice_cc 表
* (NOTIFY 节点激活即抄送,审批过程中就可见——此时通知尚未发布,属知会不是可读)。
* 身份一律取 CAS 登录态(BoaUser),与 NoticeApprovalController 同口径,防伪造查询人。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/cc/")
@RequiredArgsConstructor
public class NoticeCcController {
private final NoticeCcRepository noticeCcRepository;
private final NoticeRepository noticeRepository;
/** 抄送给我的(分页,sent_time 倒序;行内回填通知标题/部门/审核状态供列表直渲染) */
@GetMapping("My")
public Map<String, Object> my(BoaUser user,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
}
int pageIndex = page == null || page < 1 ? 1 : page;
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
Page<NoticeCc> ccPage = noticeCcRepository.findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(
user.getCurrentRole().getSchId(), user.getId(), PageRequest.of(pageIndex - 1, pageSize));
// 批量回填通知信息(被删通知保留抄送行,标题置空由前端兜底)
Map<Integer, Notice> notices = ccPage.getContent().isEmpty()
? new HashMap<>()
: noticeRepository.findAllById(ccPage.getContent().stream()
.map(NoticeCc::getNoticeId).collect(Collectors.toSet())).stream()
.collect(Collectors.toMap(Notice::getId, Function.identity()));
List<Map<String, Object>> content = new ArrayList<>();
for (NoticeCc cc : ccPage.getContent()) {
Map<String, Object> row = new HashMap<>();
row.put("id", cc.getId());
row.put("noticeId", cc.getNoticeId());
row.put("flowInstanceId", cc.getFlowInstanceId());
row.put("nodeId", cc.getNodeId());
row.put("nodeName", cc.getNodeName());
row.put("sentTime", cc.getSentTime());
Notice notice = notices.get(cc.getNoticeId());
if (notice != null) {
row.put("title", notice.getTitle());
row.put("deptName", notice.getDeptName());
row.put("publisherName", notice.getPublisherName());
row.put("noticeStatus", notice.getStatus());
row.put("auditStatus", notice.getAuditStatus());
row.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
} else {
row.put("title", null);
row.put("noticeStatus", null);
row.put("auditStatus", null);
row.put("auditStatusDesc", "通知已删除");
}
content.add(row);
}
Map<String, Object> result = new HashMap<>();
result.put("total", ccPage.getTotalElements());
result.put("content", content);
result.put("pageIndex", pageIndex);
result.put("pageSize", pageSize);
result.put("totalPages", ccPage.getTotalPages());
result.put("hasNext", ccPage.hasNext());
return result;
}
}
@@ -22,6 +22,8 @@ import java.util.Map;
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li> * <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li> * <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li> * <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
* <li>GET Flow/Embed:办件进度嵌入页地址(Drawer iframe 直嵌,可见性与 Trace 同口径)。</li>
* <li>GET Flow/DiagramDef:流程定义示意图(绑定页/发通知页 Drawer,登录即可读——与 Binding 同口径)。</li>
* </ul> * </ul>
*/ */
@RestController @RestController
@@ -30,6 +32,8 @@ import java.util.Map;
public class NoticeFlowController { public class NoticeFlowController {
private final NoticeFlowService noticeFlowService; private final NoticeFlowService noticeFlowService;
private final com.yunhai.notice.service.flow.NoticeFlowEmbedService noticeFlowEmbedService;
private final com.yunhai.notice.service.flow.FlowClient flowClient;
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */ /** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
@GetMapping("Options") @GetMapping("Options")
@@ -66,6 +70,45 @@ public class NoticeFlowController {
return noticeFlowService.buildTrace(serid, user); return noticeFlowService.buildTrace(serid, user);
} }
/**
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面,现后端经 SDK 托管件中转;
* 仅创建人本人 + 审批中,撤回后状态由 flow.withdrawn 回调推进)。
*/
@PostMapping("Withdraw")
public void withdraw(@RequestParam("serid") Integer serid, BoaUser user) {
noticeFlowService.withdrawNotice(serid, user);
}
/** 流程示意图(PNG 流,同源直嵌 img;可见性与 Trace 同口径,token 不落前端) */
@GetMapping(value = "Diagram", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
public byte[] diagram(@RequestParam("serid") Integer serid, BoaUser user) {
return noticeFlowService.diagramImage(serid, user);
}
/**
* 流程定义示意图(PNG 流,绑定页/发通知页 Drawer 同源直嵌 img)。def 口径无办件,
* 不适用 NoticeFlowGuard 办件守卫——与绑定读取(Binding,登录即可读)同口径:CAS 登录即可,
* schId 从登录用户推导,本校限定由 flow-center 按 schId+flowGuid 收敛;
* 绑定已解除/无已发布版时对端报业务错(JSON 信封),前端 img onerror 转提示,不留 500。
*/
@GetMapping(value = "DiagramDef", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
public byte[] diagramDef(@RequestParam("flowGuid") String flowGuid, BoaUser user) {
if (flowGuid == null || flowGuid.trim().isEmpty()) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "缺少流程标识");
}
return flowClient.diagramImageByFlowGuid(currentSchId(user), flowGuid.trim());
}
/**
* 办件进度嵌入页地址(详情页 Drawer 内 iframe 直嵌 flow-center 统一进度页)。
* 可见性与 Trace/Diagram 同口径;pageUrl 含短时 token,现取现发不落前端缓存。
*/
@GetMapping("Embed")
public Map<String, String> embed(@RequestParam("serid") Integer serid, BoaUser user) {
return noticeFlowEmbedService.embedUrl(serid, user);
}
private Integer currentSchId(BoaUser user) { private Integer currentSchId(BoaUser user) {
if (user == null || user.getCurrentRole() == null) { if (user == null || user.getCurrentRole() == null) {
return null; return null;
@@ -0,0 +1,85 @@
package com.yunhai.notice.controller.mobiapi;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.notice.UpFileResultVo;
import com.yunhai.notice.service.IFileService;
import com.yunhai.notice.service.IUserCenterService;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
/**
* 平台代理域移动端接口(/mobiapi/base/,token 身份)。
* <p>端点从 {@code controller.api.BaseApiController} 按移动端需要迁出,业务逻辑仍在
* {@link IUserCenterService} / {@link IFileService},本控制器仅"取参 → 调 Service → 返回";
* 管理端专用端点留在 api/ 面,本期不删。</p>
* <p>出入参口径与 api 侧一致(路径名沿用旧移动端 base/FilePreview|FileDown 调用形状;
* 文件预览/下载在 api 侧实际位于 FileController 的 file/Down|Preview,此处对齐其
* IFileService 签名)。登出不在此暴露——SDK 已提供 {@code POST /mobiapi/auth/logout}。</p>
*/
@RestController
@RequestMapping(value = Config.MOBI_API_PATH + "/base/")
public class BaseMobiController {
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
/** 获取当前登录用户信息(token 链注入)。 */
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
/** 获取当前用户已授权的模块列表(移动端仅用于发布权限判定)。 */
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
/** 获取当前用户学校组及用户(树形结构)。 */
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getSchoolAllTree(schId);
}
/** 获取指定组内用户(教师 + 管理员)。 */
@GetMapping("GetGroupUser")
public List<Map<String, Object>> getGroupUser(
@RequestParam("gid") Integer gid,
@RequestParam(value = "role", defaultValue = "2") Integer role) {
return userCenterService.getGroupUser(gid, role);
}
/** 附件上传(单文件)。返回 { fileid, host, fileurl, name, type, size }。 */
@PostMapping("FileUpload")
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "notice");
}
/** 附件在线预览(inline 输出,前端取 Blob 渲染;type 兼容旧调用参数)。 */
@GetMapping("FilePreview")
public void filePreview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/** 附件下载。 */
@GetMapping("FileDown")
public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
}
@@ -0,0 +1,86 @@
package com.yunhai.notice.controller.mobiapi;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
/**
* 通知公告移动端接口(/mobiapi 面,token 身份)。
* <p>路径前缀:{@code Config.MOBI_API_PATH + "/notice/"};方法名/参数与
* {@code controller.api.NoticeController} 保持一致,注入同一 {@link INoticeService},
* 不改 Service 实现;返回裸对象,由 ApiResponseAdvice 统一包装 {code, msg, data}。</p>
* <p>口径:登录即可访问(移动端主体含学生),不按管理端访问级别判级;
* 拦截与 401 语义见 {@code config.MobiWebMvcConfig} / {@code common.interceptor.MobiAccessInterceptor}。</p>
*/
@RestController
@RequestMapping(value = Config.MOBI_API_PATH + "/notice/")
public class NoticeMobiController {
@Autowired
private INoticeService noticeService;
/** 已收通知列表。 */
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
/** 已发/全部通知列表(type=user 只看自己)。 */
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
/** 查看通知详情(type=1 标记已读,type=0 不标记)。 */
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
/** 阅读情况(按学校组分组)。 */
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
/** 催收未读。 */
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
/** 一键全部已读。 */
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
/** 上一篇/下一篇通知ID({prevId, nextId},0=无)。 */
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
/** 批量下载通知全部附件(打包 zip)。 */
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
}
@@ -1,53 +0,0 @@
package com.yunhai.notice.controller.oapi;
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.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 流程中心回调接收端点(挂在开放接口域 /oapi/notice/flow/callback)。
* <p>
* <b>验签(复用平台应用验签能力)</b>:不建独立回调验签过滤器——回调路径落在
* {@code yh.app-auth.server.enforce-patterns: [/oapi/*]} 内,由平台
* {@code AppAuthFilter}(X-App-Key/X-Timestamp/X-Signature,HmacSHA256(method\npath\n ts),
* 密钥取 trusted-apps 里 flow-center 的 appKey=100019)验签 + {@code RequireAppSignFilter}
* 拒绝匿名请求。推送方 flow-center 侧由 {@code CallbackAppSigner} 用同一套 canonical 规则签名
* (注册时申报的 base-path 即本服务 context-path,见 {@link com.yunhai.notice.service.flow.NoticeFlowProperties})。
* 原来的 /callback/* + X-Flw-* 自定义过滤器已下线;X-Flw-* 头仍由 flow-center 保留发送,本服务不再校验。
* <p>
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平。
*/
@Slf4j
@RestController
@RequestMapping("/oapi/notice/flow")
@RequiredArgsConstructor
public class OapiFlowCallbackController {
private final FlowCallbackService callbackService;
@PostMapping("/callback")
public ResponseEntity<String> callback(@RequestBody byte[] body,
@RequestAttribute(name = "yh.app.auth", required = false) String appKey) {
try {
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
return ResponseEntity.ok("{\"ok\":true}");
} catch (FlowCallbackService.TransientCallbackException e) {
log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): 调用方={} {}", appKey, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
} catch (Exception e) {
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)调用方={}", appKey, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
}
}
}
@@ -0,0 +1,92 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
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;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断端点(2026-09-24 设计方案 §5.2):flow-center CONDITION 节点(routerKey=
* notice.dept-judge)激活时同步调用——发文部门=校长室走"校长室"线(免校长审核),其余走"其他部门"线。
* <ul>
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center(100019)的应用签名,零新概念;</li>
* <li>纯读幂等:凭 bizId 查 notice 表比对发文部门(variables 里有 formData 的部门则免查库,两口径一致);
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
* <li>轻实现:同步 3s 超时,只做一次主键查询。</li>
* </ul>
*/
@RestController
@RequestMapping("/oapi/notice/flow/route")
@RequiredArgsConstructor
public class OapiFlowRouteController {
private static final String OUTLET_PRINCIPAL = "校长室";
private static final String OUTLET_OTHER = "其他部门";
private final NoticeRepository noticeRepository;
/** 判断请求体(契约见《子应用接入流程中心规范.md》条件路由判断接口;字段忽略未知向前兼容) */
@Data
public static class JudgeBody {
private Long schId;
private Long instanceId;
private Integer instanceVersion;
private String bizType;
private String bizId;
private String nodeId;
private String nodeName;
private String routerKey;
private List<String> outlets;
private Map<String, Object> variables;
}
@PostMapping("/dept-judge")
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
Integer noticeId;
try {
noticeId = Integer.valueOf(String.valueOf(body.getBizId()).trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
}
// formData 优先(apply 时透传了发文部门则免查库),缺失回落查表——两口径同一数据源
String deptName = variablesDeptName(body);
String deptId = null;
if (!StringUtils.hasText(deptName)) {
Notice notice = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "通知不存在:" + noticeId));
deptId = notice.getDeptId();
deptName = notice.getDeptName();
}
String outlet = OUTLET_PRINCIPAL.equals(deptName) ? OUTLET_PRINCIPAL : OUTLET_OTHER;
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
if (!outlets.contains(outlet)) {
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
throw new BizException(ErrorCode.PARAM_ERROR,
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ "(deptId=" + deptId + ", deptName=" + deptName + ")");
}
return Collections.singletonMap("outlet", outlet);
}
/** formData 里的发文部门(apply 时 bizSnapshot 同源透传;缺字段返回空串回落查表) */
private static String variablesDeptName(JudgeBody body) {
Map<String, Object> variables = body.getVariables();
if (variables == null) {
return "";
}
Object v = variables.get("deptName");
return v == null ? "" : String.valueOf(v).trim();
}
}
@@ -0,0 +1,59 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 流程抄送留痕(2026-09-24):flow-center NOTIFY 节点的 flow.cc.sent 回调逐人一行,
* 应用内「抄送给我的」列表数据源。幂等靠 starter 的 flow_callback_event(uk event_id),
* 本表只增不改——同一通知多节点抄送/退回重走重复抄送各成一行,轨迹按时间归位。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_cc",
indexes = {
@Index(name = "idx_notice_cc_user", columnList = "sch_id, cc_user_id, sent_time"),
@Index(name = "idx_notice_cc_notice", columnList = "notice_id")})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeCc extends BaseEntity {
/** 学校ID(多租户隔离,与 notice.sch_id 同域) */
@Column(name = "sch_id")
private Integer schId;
/** 关联通知(信封 bizId) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** flow-center 办件 id(信封 instanceId) */
@Column(name = "flow_instance_id")
private Long flowInstanceId;
/** 抄送发生节点 id(data.nodeId) */
@Column(name = "node_id", length = 64)
private String nodeId;
/** 抄送环节名快照(data.nodeName) */
@Column(name = "node_name", length = 128)
private String nodeName;
/** 被抄送人用户 id(data.ccUserId,平台统一用户目录) */
@Column(name = "cc_user_id", nullable = false)
private Integer ccUserId;
/** 被抄送人姓名快照(data.ccUserName) */
@Column(name = "cc_user_name", length = 64)
private String ccUserName;
/** 抄送时间(事件处理时刻)。DATETIME(3) 与 flow-center flw_cc.sent_time 同口径 */
@Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime sentTime;
}
@@ -7,6 +7,8 @@ import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowInstance; import com.yunhai.platform.openapi.flow.model.FlowInstance;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody; import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult; import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult;
import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -61,4 +63,18 @@ public final class FlowModels {
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public static class CallbackRegResultVo extends FlowCallbackRegResult { public static class CallbackRegResultVo extends FlowCallbackRegResult {
} }
/** 办件进度(GET /oapi/flow/progress;steps[0] 固定"发起申请",2026-09-17 接入) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ProgressVo extends FlowProgress {
}
/** 流程示意图换图地址(GET /oapi/flow/diagram/url;imgUrl 为 flow-center 免签相对路径) */
@Data
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public static class DiagramUrlVo extends FlowDiagramUrl {
}
} }
@@ -0,0 +1,19 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeCc;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 流程抄送留痕仓储(「抄送给我的」主路径)。
*/
public interface NoticeCcRepository extends JpaRepository<NoticeCc, Integer> {
/** 按学校 + 被抄送人分页(sent_time 倒序,倒序索引 idx_notice_cc_user 覆盖) */
Page<NoticeCc> findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(Integer schId, Integer ccUserId,
Pageable pageable);
/** 未发布通知可见守卫第 4 条:当前用户是否被抄送该通知(NoticeFlowGuard) */
boolean existsByNoticeIdAndCcUserId(Integer noticeId, Integer ccUserId);
}
@@ -1,227 +0,0 @@
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");
// 注册探活回声:ping 体只有 eventId/event/flowGuid,无 bizType/bizId,必须在必填校验之前返回
// (原实现靠回调验签过滤器短路;迁到 /oapi 后 ping 会真到 controller,由应用验签保证来源可信)
if ("ping".equals(event)) {
return;
}
String bizType = text(payload, "bizType");
String bizId = text(payload, "bizId");
Long instanceId = longVal(payload, "instanceId");
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 (!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();
}
}
@@ -18,7 +18,7 @@ import java.util.Map;
/** /**
* FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key= * FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key=
* {@code flowcenter}:yh.call.targets &gt; yh.open.services &gt; 枚举 base-host 兜底, *{@code flow-center}:yh.call.targets &gt; yh.open.services &gt; 枚举 base-host 兜底,
* 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键 * 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键
* {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射 * {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射
* 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。 * 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。
@@ -54,10 +54,19 @@ public class FlowCenterClient implements FlowClient {
} }
this.openCall = call; this.openCall = call;
// 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc // 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc
this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.core.PlatformService.FLOWCENTER.key()); this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key());
if (directoryResolved) { if (directoryResolved) {
com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(call); com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(call);
} }
String legacy = properties.getBaseUrl() == null ? "" : properties.getBaseUrl().trim();
if (properties.isEnabled() && !directoryResolved && legacy.isEmpty()) {
// 目录未配且 legacy 键也空:构造期 fail-fast(本键已无默认值),
// 避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
// enabled=false(显式未接入)时不校验——地址本就不需要。
throw new IllegalStateException("流程中心调用地址未配置:请配置服务目录 yh.open.services."
+ com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key()
+ "(推荐,容器直连完整 URL)或回落键 notice.flow.base-url");
}
} }
@Override @Override
@@ -107,6 +116,118 @@ public class FlowCenterClient implements FlowClient {
FlowModels.CallbackRegResultVo.class); FlowModels.CallbackRegResultVo.class);
} }
@Override
public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
requireEnabled();
if (directoryResolved) {
com.yunhai.platform.openapi.flow.YschFlowOpenApi.withdraw(schId, instanceId, submitterId);
return;
}
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("submitterId", String.valueOf(submitterId));
// 无请求体:对端 POST 无 @RequestBody,query 传参即可(body 传 null 即零长度请求体)
openCall.post(target(), "/oapi/flow/instance/" + instanceId + "/withdraw", query, null, Void.class);
}
@Override
public FlowModels.ProgressVo progress(Integer schId, Long instanceId) {
requireEnabled();
if (directoryResolved) {
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.progress(
instanceId, BIZ_TYPE, null, schId, FlowModels.ProgressVo.class);
}
Map<String, String> query = new HashMap<>();
query.put("instanceId", String.valueOf(instanceId));
query.put("schId", String.valueOf(schId));
return openCall.get(target(), "/oapi/flow/progress", query, FlowModels.ProgressVo.class);
}
/** 免签出图端点(flow-center OpenDiagramImageController,相对其 context-path) */
private static final String DIAGRAM_IMG_PATH = "/open/flow/diagram/img";
@Override
public byte[] diagramImage(Integer schId, Long instanceId) {
requireEnabled();
String imgUrl;
if (directoryResolved) {
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
instanceId, null, null, schId, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
} else {
Map<String, String> query = new HashMap<>();
query.put("instanceId", String.valueOf(instanceId));
query.put("schId", String.valueOf(schId));
FlowModels.DiagramUrlVo dto =
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
}
String token = tokenOf(imgUrl);
if (token == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
}
Map<String, String> imgQuery = new HashMap<>();
imgQuery.put("token", token);
byte[] bytes = directoryResolved
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
if (bytes == null || bytes.length == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
}
return bytes;
}
/**
* 从换图地址中取 token(形如 {@code /open/flow/diagram/img?token=xxx})。
* 包可见:契约测试冻结"token 只在后端流转、前端拿不到"的取用形态。
*/
static String tokenOf(String imgUrl) {
if (imgUrl == null || imgUrl.isEmpty()) {
return null;
}
int q = imgUrl.indexOf("?token=");
if (q < 0) {
return null;
}
String token = imgUrl.substring(q + "?token=".length());
int amp = token.indexOf('&');
if (amp >= 0) {
token = token.substring(0, amp);
}
return token.isEmpty() ? null : token;
}
@Override
public byte[] diagramImageByFlowGuid(Integer schId, String flowGuid) {
requireEnabled();
String imgUrl;
if (directoryResolved) {
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
null, flowGuid, null, schId, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
} else {
Map<String, String> query = new HashMap<>();
query.put("flowGuid", flowGuid);
query.put("schId", String.valueOf(schId));
FlowModels.DiagramUrlVo dto =
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
imgUrl = dto == null ? null : dto.getImgUrl();
}
String token = tokenOf(imgUrl);
if (token == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
}
Map<String, String> imgQuery = new HashMap<>();
imgQuery.put("token", token);
byte[] bytes = directoryResolved
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
if (bytes == null || bytes.length == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
}
return bytes;
}
private TargetSpec target() { private TargetSpec target() {
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定) // base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
return TargetSpec.of(properties.getBaseUrl(), ""); return TargetSpec.of(properties.getBaseUrl(), "");
@@ -21,4 +21,23 @@ public interface FlowClient {
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */ /** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body); FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body);
/**
* 撤回(托管件,2026-09-17 接入):对端按 submitterId 与办件提交人逐字复核,
* 应用签名只证明"哪个应用在调"不证明"谁在撤"——防业务应用越权代撤他人办件。
*/
void withdraw(Integer schId, Long instanceId, Integer submitterId);
/** 办件进度(结构化 steps,instanceId 定位;oapi 只校验 schId,调用方先做可见性守卫) */
FlowModels.ProgressVo progress(Integer schId, Long instanceId);
/** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */
byte[] diagramImage(Integer schId, Long instanceId);
/**
* 流程定义示意图字节(def 口径,绑定页/发通知页 Drawer):按 flowGuid 取最高已发布版出图。
* 与办件图同一免签 token 通道;本校限定由 flow-center 按 schId+flowGuid 收敛(绑定已解除/
* 无已发布版 → 业务异常文案,不留 500)。
*/
byte[] diagramImageByFlowGuid(Integer schId, String flowGuid);
} }
@@ -0,0 +1,117 @@
package com.yunhai.notice.service.flow;
import com.yunhai.platform.openapi.core.PlatformService;
import com.yunhai.platform.openapi.core.YschOpenCall;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 应用内审批出站客户端(第三步 T-C4):待办/办件查询 + 通过/驳回,经网关打 flow-center
* {@code /oapi/flow/tasks|task/**}(2026-09-23 已上线)。
* <p>
* <b>刻意用 {@link YschOpenCall} 泛化调用而非 YschFlowOpenApi 新门面方法</b>——四方法
* (todoTasks/instances/approveTask/rejectTask)在 SDK main 已编码但**未发版**,notice 运行时
* 依赖的是 nexus 已发布快照;泛化调用与 FlowCenterClient legacy 分支同一条
* resolveTarget 解析链(targets &gt; services &gt; 枚举 base-host 兜底=网关),零发版依赖。
* SDK 发版后可平替为门面方法(行为等价,测试锁 URL 不变)。
* <p>
* 身份口径:operatorId/operatorName 由调用方(controller)从 CAS 登录态取,本类不猜身份;
* 对端按任务办理人逐字复核(非办理人→业务码拒绝),notice 不重复校验。
*/
@Slf4j
@Service
public class NoticeApprovalClient {
private static final String TARGET = PlatformService.FLOW_CENTER.key();
private static final String NOT_CONNECTED = "流程中心未接入,审批功能不可用";
private final NoticeFlowProperties properties;
private final YschOpenCall openCall;
public NoticeApprovalClient(NoticeFlowProperties properties,
ObjectProvider<YschOpenCall> openCallProvider) {
this.properties = properties;
this.openCall = openCallProvider.getIfAvailable();
}
/** 我的待办(bizType 固定 notice):Map 形态即 flow-center BasePageResult JSON(total/content/…) */
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("assigneeId", String.valueOf(assigneeId));
query.put("bizType", FlowCenterClient.BIZ_TYPE);
if (page != null) {
query.put("page", String.valueOf(page));
}
if (size != null) {
query.put("size", String.valueOf(size));
}
return openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class);
}
/** 办件分页(bizType 固定 notice;status 可空) */
public Map<String, Object> instances(Integer schId, Integer status, Integer page, Integer size) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("bizType", FlowCenterClient.BIZ_TYPE);
if (status != null) {
query.put("status", String.valueOf(status));
}
if (page != null) {
query.put("page", String.valueOf(page));
}
if (size != null) {
query.put("size", String.valueOf(size));
}
return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class);
}
/** 同意(opinion 可空;多出口 edgeName 透传) */
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
String opinion, String edgeName) {
requireEnabled();
Map<String, Object> body = new LinkedHashMap<>();
if (opinion != null && !opinion.trim().isEmpty()) {
body.put("opinion", opinion.trim());
}
if (edgeName != null && !edgeName.trim().isEmpty()) {
body.put("edgeName", edgeName.trim());
}
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve",
operatorQuery(schId, operatorId, operatorName), body, Void.class);
}
/** 拒绝(opinion 必填由对端强制;此处前置校验给前端更快的反馈) */
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
requireEnabled();
if (opinion == null || opinion.trim().isEmpty()) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("opinion", opinion.trim());
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
operatorQuery(schId, operatorId, operatorName), body, Void.class);
}
private static Map<String, String> operatorQuery(Integer schId, Integer operatorId, String operatorName) {
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("operatorId", String.valueOf(operatorId));
query.put("operatorName", operatorName);
return query;
}
private void requireEnabled() {
if (!properties.isEnabled() || openCall == null) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, NOT_CONNECTED);
}
}
}
@@ -0,0 +1,187 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
* 验签(平台 X-App-*,/oapi/* 强制)、ping、必填校验、bizType 过滤、eventId 幂等、
* instanceVersion 乱序门由 starter 接收管线承担;本类只做通知侧状态机推进——
* <br>事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 发布
* + 短信后置);rejected→30;withdrawn→40;cc.sent→只落 notice_cc 抄送行(不动状态机,
* 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。
* <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
* 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowCallbackHandler implements FlowCallbackHandler {
private final NoticeRepository noticeRepository;
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
private final ObjectMapper objectMapper;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
@Override
public void onEvent(FlowCallbackEvent event) {
Integer noticeId;
try {
noticeId = Integer.valueOf(event.getBizId().trim());
} catch (NumberFormatException e) {
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", event.getBizId());
return;
}
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", event.getBizId());
return;
}
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的 notice 版本为准)
if (event.getInstanceVersion() != null && notice.getLastInstanceVersion() != null
&& event.getInstanceVersion() < notice.getLastInstanceVersion()) {
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
noticeId, event.getInstanceVersion(), notice.getLastInstanceVersion());
return;
}
advance(notice, event);
}
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
private void advance(Notice notice, FlowCallbackEvent event) {
String code = event.getEvent();
Integer instanceVersion = event.getInstanceVersion();
Integer target;
switch (code) {
case "flow.cc.sent":
recordCc(notice, event);
return;
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] 未知事件(丢弃):{}", code);
return;
}
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
if (isTerminal(notice.getAuditStatus())) {
if (!isTerminal(target)) {
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
notice.getId(), notice.getAuditStatus(), code, instanceVersion);
return;
}
if (StringUtils.hasText(notice.getFlowResultJson())) {
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
notice.getId(), notice.getAuditStatus(), code);
return;
}
}
notice.setAuditStatus(target);
if (instanceVersion != null) {
notice.setLastInstanceVersion(instanceVersion);
}
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
if (isTerminal(target)) {
notice.setFlowResultJson(snapshot(event));
}
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);
}
}
/**
* 抄送留痕(2026-09-24,flow.cc.sent):逐人一条事件逐条落 notice_cc,供应用内
* 「抄送给我的」列表渲染、点击跳通知详情。不动审核状态机(抄送是知会不是审批,
* taskId 恒 null 也不落任务);已通过行锁与乱序闸门,重放由 eventId 幂等拦截。
* data 缺 ccUserId 属上游脏数据:记日志跳过(回 200),落库异常照旧外抛回 5xx 重试。
*/
private void recordCc(Notice notice, FlowCallbackEvent event) {
Map<String, Object> data = event.getData();
Object ccUserId = data == null ? null : data.get("ccUserId");
if (ccUserId == null || String.valueOf(ccUserId).isEmpty()) {
log.warn("[notice-flow] cc.sent 缺 ccUserId(丢弃):notice={} eventId={}",
notice.getId(), event.getEventId());
return;
}
com.yunhai.notice.entity.NoticeCc cc = new com.yunhai.notice.entity.NoticeCc();
cc.setSchId(notice.getSchId());
cc.setNoticeId(notice.getId());
cc.setFlowInstanceId(event.getInstanceId());
cc.setNodeId(data.get("nodeId") == null ? null : String.valueOf(data.get("nodeId")));
cc.setNodeName(data.get("nodeName") == null ? null : String.valueOf(data.get("nodeName")));
cc.setCcUserId(Integer.valueOf(String.valueOf(ccUserId)));
cc.setCcUserName(data.get("ccUserName") == null ? null : String.valueOf(data.get("ccUserName")));
cc.setSentTime(LocalDateTime.now());
noticeCcRepository.save(cc);
log.info("[notice-flow] 抄送留痕:notice={} instance={} ccUser={} node={}",
notice.getId(), event.getInstanceId(), cc.getCcUserId(), cc.getNodeName());
}
private String snapshot(FlowCallbackEvent event) { if (event.getData() == null) {
return null;
}
try {
return objectMapper.writeValueAsString(event.getData());
} catch (Exception e) {
log.warn("[notice-flow] 终态快照序列化失败(置 null 不阻断推进):{}", e.toString());
return null;
}
}
/** 审核通过后的短信后置(绑流程通知 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());
}
}
@@ -0,0 +1,52 @@
package com.yunhai.notice.service.flow;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.model.FlowEmbedUrl;
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.Collections;
import java.util.Map;
/**
* 办件进度嵌入页换址(2026-10-03 嵌入页方案):子应用 Drawer 内 iframe 直嵌 flow-center
* 统一进度页,零样式复制。与 Diagram 同口径的用户级守卫(未发布仅提交人/管理员/办理人
* /被抄送人可见);oapi 层只认 schId——"谁能看"必须落在本端点,应用签名不替代身份。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowEmbedService {
private final NoticeRepository noticeRepository;
private final NoticeFlowGuard noticeFlowGuard;
/**
* 取嵌入页地址(pageUrl 含 10 分钟短时 token):现取现发、不缓存、不落库、
* 不打进应用日志(token 泄露窗口即有效期,纪律见方案 §6.1/§7)。
*/
public Map<String, String> embedUrl(Integer noticeId, BoaUser user) {
Notice notice = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
if (notice.getStatus() == null || notice.getStatus() != 1) {
noticeFlowGuard.requireUnpublishedVisible(notice, user);
}
if (notice.getFlowInstanceId() == null || notice.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程");
}
FlowEmbedUrl embed = YschFlowOpenApi.embedUrl(
null, FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()),
notice.getSchId(), FlowEmbedUrl.class);
if (embed == null || !StringUtils.hasText(embed.getPageUrl())) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址获取失败,请稍后重试");
}
return Collections.singletonMap("pageUrl", embed.getPageUrl());
}
}
@@ -17,8 +17,9 @@ import java.util.concurrent.ConcurrentHashMap;
/** /**
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。 * 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
* 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/ * 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。 * 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/
* 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。 * 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
*/ */
@Slf4j @Slf4j
@@ -33,6 +34,7 @@ public class NoticeFlowGuard {
private final FlowClient flowClient; private final FlowClient flowClient;
private final NoticeFlowProperties properties; private final NoticeFlowProperties properties;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>(); private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
@@ -68,6 +70,10 @@ public class NoticeFlowGuard {
&& currentAssignees(notice).contains(user.getId())) { && currentAssignees(notice).contains(user.getId())) {
return; return;
} }
// 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心)
if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) {
return;
}
throw notFound(); throw notFound();
} }
@@ -5,7 +5,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* 通知-流程中心接缝配置(notice.flow.* / notice.front.*)。 * 通知-流程中心接缝配置(notice.flow.*)。front-base-url 已删(2026-09-26):
* bizUrl 改传裸相对路径,绝对化由接收方按页面归属承担(自指地址优化 Phase 2)。
*/ */
@Data @Data
@Component @Component
@@ -15,28 +16,11 @@ public class NoticeFlowProperties {
/** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */ /** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */
private boolean enabled = true; private boolean enabled = true;
/** flow-center 基址(Tier 0 目标;本地 http://localhost:8085,生产由 projman 注入) */
private String baseUrl = "http://localhost:8085";
/** /**
* 本应用对外可达基址(回调注册用):flow-center 探活与事件推送都打这里。 * flow-center 基址的 <b>legacy 回落</b>(Tier 0 ad-hoc TargetSpec 路径)。
* 默认回落 baseUrl 同端口的本服务直连地址;生产经网关时由 projman 注入(如 http://notice:8070/notice)。 * 主路径是服务目录 {@code yh.open.services.flowcenter} → {@code YschFlowOpenApi} 门面;
* 本键仅迁移期兼容,<b>不再给默认值</b>——两者都空时 {@code FlowCenterClient} 构造期
* fail-fast,避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
*/ */
private String callbackBase = "http://localhost:8101"; private String baseUrl = "";
/**
* 回调路径:挂在开放接口域下,由平台应用验签(/oapi/* 强制)保护,不再有独立回调过滤器。
*/
private String callbackPath = "/oapi/notice/flow/callback";
/**
* 本服务部署 context-path(可选,无则空):注册时随 basePath 一并申报给 flow-center,
* 推送方按此从回调 URL 的 path 中剥离后再签 X-Signature,与平台 AppAuthFilter 用
* request.contextPath 剥离后的 path 对齐。生产 Notice 部署在 /notice 前缀下,须配 /notice;
* 配错的表现是回调 401(推送方签的 target 与本服务验签 target 不一致)。
*/
private String callbackBasePath = "";
/** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */
private String frontBaseUrl = "http://localhost:8077";
} }
@@ -4,17 +4,17 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice; import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeFlowBinding; import com.yunhai.notice.entity.NoticeFlowBinding;
import com.yunhai.notice.entity.NoticeFlowReg;
import com.yunhai.notice.entity.UpFile; import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.FlowAuditStatusEnum; import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.model.flow.FlowModels; import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.repository.NoticeFlowBindingRepository; import com.yunhai.notice.repository.NoticeFlowBindingRepository;
import com.yunhai.notice.repository.NoticeFlowRegRepository;
import com.yunhai.notice.repository.NoticeRepository; import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository; import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.notice.utils.ReceiveParser;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.spring.CurrentBoaUser; import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.openapi.core.JumpTarget;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -41,13 +41,12 @@ import java.util.Map;
public class NoticeFlowService { public class NoticeFlowService {
private final NoticeRepository noticeRepository; private final NoticeRepository noticeRepository;
private final NoticeFlowRegRepository regRepository;
private final NoticeFlowBindingRepository bindingRepository; private final NoticeFlowBindingRepository bindingRepository;
private final UpFileRepository upFileRepository; private final UpFileRepository upFileRepository;
private final FlowClient flowClient; private final FlowClient flowClient;
private final NoticeFlowProperties properties; private final NoticeFlowProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AppAuthProperties appAuthProperties; private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
private final CurrentBoaUser currentBoaUser; private final CurrentBoaUser currentBoaUser;
private final NoticeFlowGuard noticeFlowGuard; private final NoticeFlowGuard noticeFlowGuard;
@@ -124,47 +123,30 @@ public class NoticeFlowService {
} }
/** /**
* 回调注册:仅 reg 缺失时注册并落 secret。<b>绝不重复调用</b>——flow-center 重复注册会轮换 * 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):
* secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。 * 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。<b>绝不重复调用</b>——
* flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。
*/ */
void ensureRegistered(Notice notice) { void ensureRegistered(Notice notice) {
Integer schId = notice.getSchId(); callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid());
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());
// 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐)
body.setBasePath(properties.getCallbackBasePath());
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 前端详情、快照只收摘要不收富文本正文) */ /** apply 出参组装(契约冻结:bizId=notice.id、bizUrl=裸相对路径、快照只收摘要不收富文本正文;
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径。
* bizUrl 自 2026-09-26 起为裸相对路径(不含域名/ctx/#,接收方按页面归属拼绝对地址),
* Notice 不再需要配置自己的前端基址(自指地址优化 Phase 2) */
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) { FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
FlowModels.ApplyBody body = new FlowModels.ApplyBody(); FlowModels.ApplyBody body = new FlowModels.ApplyBody();
body.setFlowGuid(notice.getFlowGuid()); body.setFlowGuid(notice.getFlowGuid());
body.setBizType(FlowCenterClient.BIZ_TYPE); body.setBizType(FlowCenterClient.BIZ_TYPE);
body.setBizId(String.valueOf(notice.getId())); body.setBizId(String.valueOf(notice.getId()));
body.setBizTitle(notice.getTitle()); body.setBizTitle(notice.getTitle());
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId()); body.setBizJump(JumpTarget.page("list/detail").param("serid", notice.getId()));
body.setBizSnapshot(buildSnapshot(notice)); body.setBizSnapshot(buildSnapshot(notice));
Map<String, Object> formData = new LinkedHashMap<>();
formData.put("deptId", notice.getDeptId());
formData.put("deptName", notice.getDeptName());
body.setFormData(formData);
if (user != null) { if (user != null) {
body.setSubmitterId(user.getId()); body.setSubmitterId(user.getId());
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount()); body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
@@ -172,7 +154,7 @@ public class NoticeFlowService {
return body; return body;
} }
/** 业务摘要:{title, summary(纯文本截 300 字), attachmentNames[]}——富文本正文不传(概览 §十边界) */ /** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */
private Map<String, Object> buildSnapshot(Notice notice) { private Map<String, Object> buildSnapshot(Notice notice) {
Map<String, Object> snapshot = new LinkedHashMap<>(); Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("title", notice.getTitle()); snapshot.put("title", notice.getTitle());
@@ -184,9 +166,60 @@ public class NoticeFlowService {
} }
} }
snapshot.put("attachmentNames", names); snapshot.put("attachmentNames", names);
snapshot.put("summaryFields", buildSummaryFields(notice));
return snapshot; return snapshot;
} }
/** 嵌入页「业务信息」三行:通知标题 / 来源部门 / 通知对象 */
private List<Map<String, Object>> buildSummaryFields(Notice notice) {
List<Map<String, Object>> fields = new ArrayList<>();
fields.add(summaryField("通知标题", notice.getTitle()));
fields.add(summaryField("来源部门", notice.getDeptName()));
fields.add(summaryField("通知对象", buildReceiveSummary(notice)));
return fields;
}
private Map<String, Object> summaryField(String label, Object value) {
Map<String, Object> field = new LinkedHashMap<>();
field.put("label", label);
field.put("value", value == null ? "" : value);
return field;
}
/**
* 通知对象展示串:全员(receive_list 空 / ALL / UserAll)⇒ "全部";指定 ⇒ 前 3 个姓名以「、」连接,
* 多于 3 人再追 " 等N人"(如 "张三、李四、王五 等11人")。
* 姓名经用户中心解析(手法同 NoticeService.getReadDetailGrouped),失败回落原 id/账号;
* 只解析前 3 个——本方法在 apply 提交时执行,避免 N 次远程调用。
*/
private String buildReceiveSummary(Notice notice) {
List<String> accounts = ReceiveParser.parse(notice.getReceiveList());
if (accounts.isEmpty()) {
return "全部";
}
int total = accounts.size();
List<String> shown = new ArrayList<>();
for (String account : accounts.subList(0, Math.min(3, total))) {
shown.add(resolveReceiverName(account));
}
String joined = String.join("、", shown);
return total > 3 ? joined + " 等" + total + "人" : joined;
}
/** 接收明细可能存登录名,也可能是历史数字 userId:先按登录名查,查不到且纯数字再按 id 查 */
private String resolveReceiverName(String account) {
try {
BoaUser user = YschBaseOpenApi.getUserInfo(account);
if (user == null && StringUtils.hasText(account) && account.chars().allMatch(Character::isDigit)) {
user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account));
}
return user != null && StringUtils.hasText(user.getName()) ? user.getName() : account;
} catch (Exception e) {
log.warn("[notice-flow] 通知对象姓名解析失败 account={}", account, e);
return account;
}
}
/** 富文本剥标签取纯文本摘要(截断 300 字) */ /** 富文本剥标签取纯文本摘要(截断 300 字) */
private String plainSummary(String content) { private String plainSummary(String content) {
if (!StringUtils.hasText(content)) { if (!StringUtils.hasText(content)) {
@@ -235,6 +268,15 @@ public class NoticeFlowService {
result.put("finishTime", instance.getFinishTime()); result.put("finishTime", instance.getFinishTime());
result.put("trace", parseJson(instance.getTraceJson())); result.put("trace", parseJson(instance.getTraceJson()));
} }
if (notice.getFlowInstanceId() != null) {
// 结构化步骤(steps[0] 固定"发起申请",同节点多任务每人一行)——
// 2026-09-17 接入 SDK progress,详情页时间线数据源;失败降级不阻断
FlowModels.ProgressVo progress =
flowClient.progress(notice.getSchId(), notice.getFlowInstanceId());
if (progress != null) {
result.put("steps", progress.getSteps());
}
}
} catch (Exception e) { } catch (Exception e) {
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用) // 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString()); log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
@@ -243,6 +285,48 @@ public class NoticeFlowService {
return result; return result;
} }
/**
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面——跨应用 CAS 票据 + CORS
* 双重外溢,且"谁能撤"落在前端)。现经 SDK 托管件后端中转:①本人(publisherAccount)
* = 权威授权;②审批中(10) 且已有实例;③对端再按 submitterId 复核一次。
* 撤回后镜像状态由 flow.withdrawn 回调推进(40),本方法不改本地状态——单一写入口。
*/
public void withdrawNotice(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
if (user == null || !StringUtils.hasText(notice.getPublisherAccount())
|| !notice.getPublisherAccount().equals(user.getAccount())) {
throw new BizException(ErrorCode.FORBIDDEN, "仅通知创建人可撤回审核");
}
Integer audit = notice.getAuditStatus();
if (audit == null || audit != FlowAuditStatusEnum.APPROVING.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的通知可撤回");
}
if (notice.getFlowInstanceId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无需撤回");
}
flowClient.withdraw(notice.getSchId(), notice.getFlowInstanceId(), user.getId());
log.info("[notice-flow] 撤回审核:notice={} instance={} user={}",
noticeId, notice.getFlowInstanceId(), user.getId());
}
/**
* 流程示意图(PNG 流,同源直嵌 {@code <img>}):可见性与 Trace 同口径(未发布仅三类人)。
* token 与字节都在服务端流转,前端不再需要知道 flow-center 地址。
*/
public byte[] diagramImage(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId);
if (notice.getStatus() == null || notice.getStatus() != 1) {
noticeFlowGuard.requireUnpublishedVisible(notice, user);
}
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未接入审核流程");
}
if (notice.getFlowInstanceId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无流程图");
}
return flowClient.diagramImage(notice.getSchId(), notice.getFlowInstanceId());
}
/** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */ /** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */
public List<FlowModels.FlowSummaryVo> options(Integer schId) { public List<FlowModels.FlowSummaryVo> options(Integer schId) {
if (!properties.isEnabled()) { if (!properties.isEnabled()) {
@@ -345,19 +429,7 @@ public class NoticeFlowService {
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
} }
private String callbackUrl() { private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) {
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; return null;
} }
try { try {
@@ -53,7 +53,16 @@ public class FileService implements IFileService {
@Autowired @Autowired
private UpFileRepository upFileRepository; private UpFileRepository upFileRepository;
@Value("${file.upload-path}") /**
* 附件存储根目录(生产容器内 /data/upload,对应 projman 卷 /data/uploads/notice)。
* 平台标准(与 userhome/userbase/flow-center 同款):FILE_PATH 各子应用值相同
* (/data/upload),统一收敛在共享配置 application-platform.yml 一份,子应用不再各自维护;
* notice 曾走过渡键 FILE_UPLOAD_PATH(prod 配置 2026-10-10 已加,保留兼容);
* file.upload-path(application.yml 的 D:/data/upload)仅本地 Windows 开发用。
* 教训:生产曾因键名不匹配回落到 D:/data/upload——Linux 锚定为 /app/D:/data/upload
* 写进容器可写层,每次重新部署文件即丢(2026-10 事故)。
*/
@Value("${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}")
private String uploadPath; private String uploadPath;
private static final String DEFAULT_APP_TYPE = "notice"; private static final String DEFAULT_APP_TYPE = "notice";
@@ -89,6 +89,9 @@ public class NoticeService implements INoticeService {
@Autowired @Autowired
private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard; private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard;
@Autowired
private com.yunhai.notice.service.msg.NoticeUserMsgNotifier noticeUserMsgNotifier;
private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode(); private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode();
private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode(); private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode();
private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode(); private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode();
@@ -161,6 +164,9 @@ public class NoticeService implements INoticeService {
} else { } else {
// 立即发布且开启短信时预留短信发送(真实通道后续接入) // 立即发布且开启短信时预留短信发送(真实通道后续接入)
trySendSms(entity); trySendSms(entity);
// 站内消息推送(afterCommit,指定人员;ALL 全员无法枚举一期不推,见 NoticeUserMsgNotifier)
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
parseReceiveAccounts(entity.getReceiveList()));
} }
return entity.getId(); return entity.getId();
} }
@@ -191,6 +197,7 @@ public class NoticeService implements INoticeService {
Objects.requireNonNull(id, "通知ID不得为空"); Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id) Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
boolean wasPublished = entity.getStatus() == PUBLISHED;
// 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提 // 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提
if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) { if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) {
throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回"); throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回");
@@ -228,7 +235,7 @@ public class NoticeService implements INoticeService {
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定 // 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid); entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173), // 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(NoticeFlowCallbackHandler 终态快照),
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能) // 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
entity.setFlowResultJson(null); entity.setFlowResultJson(null);
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见 entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
@@ -247,10 +254,17 @@ public class NoticeService implements INoticeService {
if (needAudit) { if (needAudit) {
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续 // 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
noticeFlowService.submitAfterCommit(entity.getId()); noticeFlowService.submitAfterCommit(entity.getId());
} else if (wasFlowBound) { } else {
if (wasFlowBound && !wasPublished) {
// 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发) // 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发)
trySendSms(entity); trySendSms(entity);
} }
if (!wasPublished) {
// 首次从非发布态转直发:推送站内消息(已发布通知的普通编辑不重复打扰)
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
parseReceiveAccounts(entity.getReceiveList()));
}
}
} }
/** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */ /** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */
@@ -364,6 +378,9 @@ public class NoticeService implements INoticeService {
noticeFlowService.submitAfterCommit(entity.getId()); noticeFlowService.submitAfterCommit(entity.getId());
} else { } else {
trySendSms(entity); trySendSms(entity);
// 转发直发后推送站内消息(与 publish 同口径)
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
parseReceiveAccounts(entity.getReceiveList()));
} }
return entity.getId(); return entity.getId();
} }
@@ -690,6 +707,8 @@ public class NoticeService implements INoticeService {
if (receivers.isEmpty()) { if (receivers.isEmpty()) {
receivers = parseReceiveAccounts(notice.getReceiveList()); receivers = parseReceiveAccounts(notice.getReceiveList());
} }
// 空态(ALL 全员通知)返回的是不可变 emptyList,先拷贝再追加
receivers = new ArrayList<>(receivers);
// 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整 // 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整
for (String account : readAccounts) { for (String account : readAccounts) {
if (!receivers.contains(account)) { if (!receivers.contains(account)) {
@@ -783,7 +802,8 @@ public class NoticeService implements INoticeService {
.filter(a -> !readAccounts.contains(a)) .filter(a -> !readAccounts.contains(a))
.collect(Collectors.toList()); .collect(Collectors.toList());
log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts); log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts);
// TODO: 接入 yh-platform 消息推送后,对 unreadAccounts 发送催办消息 // 接入消息中心:对未读接收人推送催办站内消息(推送失败仅记日志,不阻断催收)
noticeUserMsgNotifier.notifyExpedited(notice, unreadAccounts);
} }
// ============================ 统计 ============================ // ============================ 统计 ============================
@@ -0,0 +1,143 @@
package com.yunhai.notice.service.msg;
import com.yunhai.notice.entity.Notice;
import com.yunhai.platform.openapi.core.JumpTarget;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 通知公告 → 消息中心推送器(方案《消息中心建设方案》§6.1 试点生产方)。<br>
* 接入点:publish/forward 直发分支、edit 转直发(从未发布到发布)、expedite 催办未读。
* 先审后发(ALL + 绑定流程)审核通过才可见,但该路径接收人恒为 ALL 无法枚举——一期不推
* (催办入口可对已发布通知补推未读提醒)。<br>
* 容错语义(对齐 PartyLearn 范式):发送失败仅记日志,绝不阻断通知主流程;
* msgNo 幂等键 "ntc-{action}-{id}-{yyMMddHHmmss}"——同一秒内重试安全,跨秒重复动作视为再次提醒。
*/
@Slf4j
@Component
public class NoticeUserMsgNotifier {
/** SDK 单次接收人上限(oapi 契约:去重后 ≤1000,超出报错) */
public static final int MAX_RECV_PER_SEND = 1000;
private static final DateTimeFormatter MSG_NO_TS = DateTimeFormatter.ofPattern("yyMMddHHmmss");
private final UserMsgClient userMsgClient;
private final UserDirectory userDirectory;
private final NoticeUserMsgProperties properties;
public NoticeUserMsgNotifier(UserMsgClient userMsgClient, UserDirectory userDirectory,
NoticeUserMsgProperties properties) {
this.userMsgClient = userMsgClient;
this.userDirectory = userDirectory;
this.properties = properties;
}
/**
* 发布/转发/编辑转直发后推送(在发送事务内调用:注册 afterCommit,
* 避免通知主体尚未提交时收件端先收到消息)。
*
* @param accounts 接收人账号串列表(数字ID或登录名;ALL 通知为空列表 → 不推)
*/
public void notifyPublishedAfterCommit(Notice notice, List<String> accounts) {
if (!properties.isPushEnabled()) {
return;
}
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doNotify(notice, accounts, "pub", "notice");
}
});
} else {
doNotify(notice, accounts, "pub", "notice");
}
}
/** 催办即时推送(expedite 无事务上下文,读侧已查完库) */
public void notifyExpedited(Notice notice, List<String> unreadAccounts) {
if (!properties.isPushEnabled()) {
return;
}
doNotify(notice, unreadAccounts, "exp", "notice.expedite");
}
private void doNotify(Notice notice, List<String> accounts, String action, String bizType) {
if (accounts == null || accounts.isEmpty()) {
log.info("[usermsg] 通知 {} 无可枚举接收人(ALL 全员),跳过站内消息", notice.getId());
return;
}
try {
Set<Integer> userIds = new LinkedHashSet<>();
for (String account : accounts) {
Integer userId = userDirectory.toUserId(account);
if (userId == null) {
log.warn("[usermsg] 接收人无法解析为用户ID,跳过 notice={} account={}", notice.getId(), account);
} else {
userIds.add(userId);
}
}
if (userIds.isEmpty()) {
log.warn("[usermsg] 通知 {} 接收人全部无法解析,未推送", notice.getId());
return;
}
List<Integer> all = new ArrayList<>(userIds);
int chunks = (all.size() + MAX_RECV_PER_SEND - 1) / MAX_RECV_PER_SEND;
for (int i = 0; i < chunks; i++) {
List<Integer> part = all.subList(i * MAX_RECV_PER_SEND, Math.min((i + 1) * MAX_RECV_PER_SEND, all.size()));
try {
userMsgClient.sendUserMsg(buildRequest(notice, action, bizType, part, chunks > 1 ? i + 1 : 0));
} catch (Exception e) {
log.warn("[usermsg] 站内消息发送失败(不影响通知主流程) notice={} part={}/{}:{}",
notice.getId(), i + 1, chunks, e.toString());
}
}
log.info("[usermsg] 通知 {} 站内消息已推送 {} 人(action={})", notice.getId(), all.size(), action);
} catch (Exception e) {
log.warn("[usermsg] 站内消息推送异常(不影响通知主流程) notice={}:{}", notice.getId(), e.toString());
}
}
private SendMsgRequest buildRequest(Notice notice, String action, String bizType,
List<Integer> userIds, int chunkIndex) {
SendMsgRequest request = new SendMsgRequest();
request.setUserIds(userIds);
request.setTitle("通知公告");
request.setContent(buildContent(notice));
String msgNo = "ntc-" + action + "-" + notice.getId() + "-"
+ MSG_NO_TS.format(LocalDateTime.now())
+ (chunkIndex > 0 ? "-" + chunkIndex : "");
request.setMsgNo(msgNo);
request.setBizType(bizType);
request.setBizId(String.valueOf(notice.getId()));
// 0系统通知;跳转 Notice 前端详情页(type=1 打开即标读)。jumpApp=notice 声明页面归属:
// 接收方按它查基址拼绝对地址(bizType=notice.expedite 等细分分类也能命中 notice 应用键)
request.setMsgType(0);
request.app("notice")
.jump(JumpTarget.page("list/detail").param("serid", notice.getId()).param("type", 1));
request.setSchId(notice.getSchId());
return request;
}
/** 消息文案:对齐旧版"您有一条来自xx老师的xx通知"口吻 */
private String buildContent(Notice notice) {
String publisher = StringUtils.hasText(notice.getPublisherName()) ? notice.getPublisherName() : "管理员";
String title = StringUtils.hasText(notice.getTitle()) ? notice.getTitle() : "";
if (title.length() > 120) {
title = title.substring(0, 120) + "…";
}
return "您有一条来自「" + publisher + "」的通知《" + title + "》,请及时查阅。";
}
}
@@ -0,0 +1,19 @@
package com.yunhai.notice.service.msg;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 通知公告 → 消息中心(UserMsg)推送配置。<br>
* push-enabled 默认关闭:UserMsg 未部署的环境(本地无配置/生产上线前)不产生空转告警——
* 上线 UserMsg 后在环境配置显式打开(对齐 flow-center.usermsg 空转的反面教材)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "notice.usermsg")
public class NoticeUserMsgProperties {
/** 是否向 UserMsg 推送站内消息(发布/转发/编辑转直发/催办) */
private boolean pushEnabled = false;
}
@@ -0,0 +1,20 @@
package com.yunhai.notice.service.msg;
import com.yunhai.platform.openapi.msg.YschMsgOpenApi;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
import org.springframework.stereotype.Component;
/**
* SDK 实现:委托 yh-openapi-client 的 msg 域门面(服务目录 key={@code usermsg},
* 地址解析 targets &gt; yh.open.services.usermsg &gt; 枚举 base-host 兜底;签名/信封全由 SDK 承载)。
* 门面由 yh-openapi-spring-boot-starter 自动装配;未装配时调用抛 IllegalStateException
* (推送方按容错语义捕获记日志,不阻断主流程)。
*/
@Component
public class SdkUserMsgClient implements UserMsgClient {
@Override
public void sendUserMsg(SendMsgRequest request) {
YschMsgOpenApi.sendUserMsg(request);
}
}
@@ -0,0 +1,38 @@
package com.yunhai.notice.service.msg;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 用户中心实现:纯数字视为 userId 直接收敛;其余按登录名查用户中心取 id
* (对齐 NoticeService.getReadDetailGrouped 的既有归一化语义)。
*/
@Slf4j
@Component
public class UserCenterUserDirectory implements UserDirectory {
@Override
public Integer toUserId(String account) {
if (!StringUtils.hasText(account)) {
return null;
}
String trimmed = account.trim();
if (trimmed.chars().allMatch(Character::isDigit)) {
try {
return Integer.valueOf(trimmed);
} catch (NumberFormatException e) {
return null;
}
}
try {
BoaUser user = YschBaseOpenApi.getUserInfo(trimmed);
return user == null ? null : user.getId();
} catch (Exception e) {
log.warn("[usermsg] 登录名换 userId 失败 account={}:{}", trimmed, e.toString());
return null;
}
}
}
@@ -0,0 +1,16 @@
package com.yunhai.notice.service.msg;
/**
* 接收人账号 → 数字用户ID 归一接口(测试缝)。<br>
* 新数据 receiveList 存数字用户ID(组织树叶 id,UserBase userId);
* 历史数据可能存登录名——登录名经用户中心换 userId。
*/
public interface UserDirectory {
/**
* 归一为数字用户ID:纯数字直接解析;否则按登录名查用户中心。
*
* @return 数字用户ID;解析失败返回 null(调用方跳过该人)
*/
Integer toUserId(String account);
}
@@ -0,0 +1,13 @@
package com.yunhai.notice.service.msg;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
/**
* 消息中心发送客户端接口(测试缝):生产实现委托 SDK 门面 YschMsgOpenApi,
* 单测可 mock 以验证发送参数与容错语义。
*/
public interface UserMsgClient {
/** POST /oapi/msg/sendUserMsg(应用签名;同 appKey+msgNo 幂等返回原消息 id) */
void sendUserMsg(SendMsgRequest request);
}
+47 -14
View File
@@ -41,7 +41,11 @@ spring:
simple: simple:
auto-startup: false auto-startup: false
# 文件上传存储路径(Docker 部署时与 volume 挂载一致) # 文件上传存储路径——仅供本地 Windows 开发兜底;生产读 FILE_PATH(平台统一,
# 共享配置 application-platform.yml 一份 /data/upload,子应用不再各自维护),
# notice 过渡键 FILE_UPLOAD_PATH(prod 配置已加)仍兼容。
# 切勿在生产只依赖本键:历史教训是 Linux 容器把 D:/data/upload
# 锚定成 /app/D:/data/upload 写进容器可写层,每次更新部署附件全部丢失(2026-10)。
file: file:
upload-path: D:/data/upload upload-path: D:/data/upload
@@ -54,23 +58,29 @@ sms:
notice: notice:
flow: flow:
enabled: true enabled: true
# flow-center 基址(Tier 0 目标,裸地址直连 base-path 空) # flow-center 地址不再单独配置:见上方 yh.open.services.flow-center(SDK 门面 YschFlowOpenApi)。
base-url: http://localhost:8085 # legacy 回落键 notice.flow.base-url 不配即空,且代码侧不给默认值——与目录配置都缺时构造期
# 本应用对外可达基址(flow-center 回调探活/事件推送打这里;本地=后端直连端口) # fail-fast,不静默打 localhost。
callback-base: http://localhost:8101 # 回调地址相关键(callback-base / callback-base-path / callback-path)已于 2026-09-24 删除:
callback-path: /callback/flow # 注册地址改由 yh.flow-callback 三段推导(见下方该段),这三个键已无任何代码引用。
# Notice 前端基址(apply bizUrl 拼接,审批人点击跳回通知详情) # 前端基址 front-base-url 已于 2026-09-26 删除:apply bizUrl 改传裸相对路径
front-base-url: http://localhost:8077 # (/list/detail?serid=...),绝对化由接收方按页面归属(notice)拼——Notice 零地址类配置。
# 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点" # 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点"
# 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql # 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql
sms: sms:
catchup-enabled: true catchup-enabled: true
catchup-fixed-delay-ms: 300000 catchup-fixed-delay-ms: 300000
# 消息中心(UserMsg)站内消息推送(发布/转发/编辑转直发/催办):
# push-enabled 默认 false(未部署 UserMsg 的环境无空转告警);UserMsg 上线后打开
usermsg:
push-enabled: true
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh: yh:
sso: sso:
server-url-prefix: http://sso.yunhaixingtong.com/cas/ # 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),
# 票据校验=根路径 /serviceValidate(160 实测;/cas/ 前缀仅存量本地默认值)
server-url-prefix: http://sso.yunhaixingtong.com/
client-host-url: http://localhost:8101 client-host-url: http://localhost:8101
allow-headers: all allow-headers: all
allow-origins: all allow-origins: all
@@ -78,10 +88,11 @@ yh:
mobi-api-pattern: /mobiapi/* mobi-api-pattern: /mobiapi/*
app-auth: app-auth:
enabled: true enabled: true
# 本应用自身身份(客户端签名用):本地联调一律用 100617 与生产权威(deploy/notice.yaml)一致, # 本应用自身身份(客户端签名用)。本地联调用 100009(160 UserBase/中央密钥链在册身份,
# 消除与 UserHome 撞号的 100001 占位(升级方案 D8);flow-center trusted-apps 已同步收此号段 # 互通验证通过;100617 在 160 侧未被 UserBase 信任会导致出站 401)。生产仍为 100617
app-key: 100617 #(deploy/notice.yaml 唯一真源);flow-center trusted-apps 已同步收 100009 号段
app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 app-key: 100009
app-secret: dcc36c98299a4d36bb0ec39d42008e8c
# 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过 # 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过
server: server:
enforce-patterns: [/oapi/*] enforce-patterns: [/oapi/*]
@@ -92,9 +103,31 @@ yh:
# trusted-apps: # trusted-apps:
# "100009": "dcc36c98299a4d36bb0ec39d42008e8c" # "100009": "dcc36c98299a4d36bb0ec39d42008e8c"
trust-registry: true trust-registry: true
flow-callback:
# 流程中心回调接收端 starter(2026-09-20 迁移):端点/幂等/乱序门由 starter 承担,
# 业务映射见 NoticeFlowCallbackHandler;验签仍由上方 /oapi/* 平台强制验签负责(X-Flw 不启用)
# path 取 starter 默认值 /oapi/flow/callback(2026-09-24 由 /oapi/notice/flow/callback 收敛)
path: /oapi/flow/callback
biz-types: [notice]
# 漂移自动核对(2026-09-24 开):端点路径/ctx/base-path 变更后,镜像行有效但登记 URL 与当前
# 期望不一致时自动重新注册,替代"删镜像行→重新保存绑定"的手工恢复路径。注意重注册会轮换 secret。
reconcile-on-register: true
# 注册地址不再显式配置(2026-09-24 摘除 callback-url):由 starter 推导
# (open-host ?: yh.open.base-host) + /{前缀} + path
# 前缀三层回落:target → base-path → 运行时自证 context-path(ServletContext 容器级,
# 2026-09-26 起);本地无挂载前缀时空串/"/"视为无前缀,URL 退化为 host+path,不报错
# ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback
# base-path 可不填(运行时会自证出与真实 ctx 同源的值;显式配置仅作覆盖/逃生口保留)。
# 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
open: open:
# 四域服务目录零配置:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx # 四域服务目录零配置:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
# flow-center 显式指向本地流程中心(默认枚举兜底会打 base 域网关,网关无 /oapi 路由);
# 生产改容器直连 http://flow-center-service:10008/flow-center(projman 注入)。
services:
flow-center: http://localhost:8085
# usermsg 显式指向本地消息中心(生产部署 UserMsg 后改容器直连,如 http://usermsg-service:8080/usermsg)
usermsg: http://localhost:8080
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
@@ -0,0 +1,21 @@
-- 流程抄送留痕(2026-09-24):flow.cc.sent 回调逐人一行,应用内「抄送给我的」数据源。
-- 幂等靠 yh-flow-callback starter 的 flow_callback_event(uk event_id),本表只增不改。
-- 本地 ddl-auto=update 会自动建表;生产执行 deploy/sql/20260924_add_notice_cc.sql。
CREATE TABLE IF NOT EXISTS `notice_cc` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`sch_id` INT NULL COMMENT '学校ID(多租户隔离)',
`notice_id` INT NOT NULL COMMENT '关联通知(信封 bizId)',
`flow_instance_id` BIGINT NULL COMMENT 'flow-center 办件 id(信封 instanceId)',
`node_id` VARCHAR(64) NULL COMMENT '抄送发生节点 id(data.nodeId)',
`node_name` VARCHAR(128) NULL COMMENT '抄送环节名快照(data.nodeName)',
`cc_user_id` INT NOT NULL COMMENT '被抄送人用户 id(data.ccUserId)',
`cc_user_name` VARCHAR(64) NULL COMMENT '被抄送人姓名快照(data.ccUserName)',
`sent_time` DATETIME(3) NOT NULL COMMENT '抄送时间(事件处理时刻,毫秒精度与 flw_cc 同口径)',
`create_by` VARCHAR(64) NULL COMMENT '创建人',
`create_time` DATETIME NULL COMMENT '创建时间',
`update_by` VARCHAR(64) NULL COMMENT '更新人',
`update_time` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_notice_cc_user` (`sch_id`, `cc_user_id`, `sent_time`),
KEY `idx_notice_cc_notice` (`notice_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程抄送留痕(flow.cc.sent 回调落行)';
@@ -0,0 +1,76 @@
package com.yunhai.notice.common.interceptor;
import com.yunhai.platform.auth.BoaUserContext;
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.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 移动端兜底拦截契约(批次3 T5):<br>
* ① 上下文为空 → 401 + 40101 信封;② 非空 → 放行;<br>
* ③ 断言不存在访问级别判级:最低权限制用户(学生 NONE:无角色/无学校语境)同样放行——
* 移动端主体含学生,照抄管理端 SUPER/BUREAU/SCHOOL/NONE 判级会全 403。
*/
@DisplayName("MobiAccessInterceptor - 登录即可,不判级")
class MobiAccessInterceptorTest {
private final MobiAccessInterceptor interceptor = new MobiAccessInterceptor();
@BeforeEach
void setUp() {
UserContextHolder.clear();
BoaUserContext.clear();
}
@AfterEach
void tearDown() {
UserContextHolder.clear();
BoaUserContext.clear();
}
@Test
@DisplayName("上下文为空 → 401 + 40101 信封")
void anonymousRejected401() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean pass = interceptor.preHandle(req, res, new Object());
assertFalse(pass);
assertEquals(401, res.getStatus());
assertEquals("{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}",
res.getContentAsString());
}
@Test
@DisplayName("LoginUser 在位 → 放行")
void loginContextPasses() {
UserContextHolder.set(new LoginUser());
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
MockHttpServletResponse res = new MockHttpServletResponse();
assertTrue(interceptor.preHandle(req, res, new Object()));
assertEquals(200, res.getStatus());
}
@Test
@DisplayName("BoaUser 在位(无角色/学校语境的学生形态)→ 放行,无判级")
void studentShapedContextPassesWithoutLevelCheck() {
// 故意不设任何角色/权限字段:若实现引入访问级别判级,本用例将开始失败(回归哨)
BoaUserContext.set(new com.yunhai.platform.utils.model.BoaUser());
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List");
MockHttpServletResponse res = new MockHttpServletResponse();
assertTrue(interceptor.preHandle(req, res, new Object()));
assertEquals(200, res.getStatus());
}
}
@@ -0,0 +1,86 @@
package com.yunhai.notice.config;
import com.yunhai.notice.common.interceptor.MobiAccessInterceptor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 移动端拦截装配契约(批次3 T5):/mobiapi/** 已注册兜底拦截、/mobiapi/auth/** 豁免
* (SDK pre-token 端点 login/bind/exchange 是登录链本身,设计上匿名可达)。
*/
@DisplayName("MobiWebMvcConfig - 拦截装配")
class MobiWebMvcConfigTest {
@Test
@DisplayName("/mobiapi/** 注册 MobiAccessInterceptor 且豁免 /mobiapi/auth/**")
void registersMobiInterceptorWithAuthExclusion() throws Exception {
MobiWebMvcConfig config = new MobiWebMvcConfig();
InterceptorRegistry registry = new InterceptorRegistry();
config.addInterceptors(registry);
List<InterceptorRegistration> registrations = registrationsOf(registry);
assertEquals(1, registrations.size(), "应恰好注册一个拦截器");
InterceptorRegistration reg = registrations.get(0);
assertTrue(interceptorOf(reg) instanceof MobiAccessInterceptor,
"拦截器类型应为 MobiAccessInterceptor");
assertEquals(Arrays.asList("/mobiapi/**"), pathPatternsOf(reg), "拦截面应为 /mobiapi/**");
assertEquals(Arrays.asList("/mobiapi/auth/**"), excludePatternsOf(reg),
"豁免面应为 /mobiapi/auth/**(pre-token 端点是登录链,非业务白名单)");
}
// ---- InterceptorRegistration 无公开读取器,用反射断言(结构契约测试口径) ----
private static Object interceptorOf(InterceptorRegistration reg) throws Exception {
Field f = InterceptorRegistration.class.getDeclaredField("interceptor");
f.setAccessible(true);
return f.get(reg);
}
private static List<InterceptorRegistration> registrationsOf(InterceptorRegistry registry) throws Exception {
Field f = InterceptorRegistry.class.getDeclaredField("registrations");
f.setAccessible(true);
@SuppressWarnings("unchecked")
List<InterceptorRegistration> list = (List<InterceptorRegistration>) f.get(registry);
return list;
}
private static List<String> pathPatternsOf(InterceptorRegistration reg) throws Exception {
Object include = patternsField(reg, "includePatterns");
return asStringList(include, "/mobiapi/**");
}
private static List<String> excludePatternsOf(InterceptorRegistration reg) throws Exception {
Object exclude = patternsField(reg, "excludePatterns");
return asStringList(exclude, "/mobiapi/auth/**");
}
private static Object patternsField(InterceptorRegistration reg, String name) throws Exception {
Field f = InterceptorRegistration.class.getDeclaredField(name);
f.setAccessible(true);
return f.get(reg);
}
private static List<String> asStringList(Object patterns, String expectedSingle) {
// PatternsRequestCondition 内部持有 List<String>(PathMatcher 语义,Ant 风格)
assertNotNull(patterns, "注册时必须显式声明路径模式(缺 " + expectedSingle + ")");
List<String> out = new ArrayList<>();
if (patterns instanceof Iterable) {
for (Object o : (Iterable<?>) patterns) {
out.add(String.valueOf(o));
}
}
return out;
}
}
@@ -0,0 +1,152 @@
package com.yunhai.notice.controller.mobiapi;
import com.yunhai.notice.model.notice.NoticeDetailVo;
import com.yunhai.notice.model.notice.NoticePageResult;
import com.yunhai.notice.model.notice.NoticeQuery;
import com.yunhai.notice.model.notice.NoticeVo;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.platform.apistd.spring.ApiResponseAdvice;
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.data.domain.Sort;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 移动端通知契约测试(批次3 T5):<br>
* ① 端点齐全性:List/SendList/Detail/ReadDetail/Expedite/InitializeRed/GetNextPrev/AllFileDown 八个映射在位;<br>
* ② 薄透传等价:与 {@code api.NoticeController} 注入同一 INoticeService、同参调用(Mock 入参捕获比对);<br>
* ③ 响应信封 {code,msg,data}(code:0,经 ApiResponseAdvice 包装,standalone MockMvc 注册同款 Advice);<br>
* ④ 401/200:拦截语义属 {@code MobiAccessInterceptorTest},此处验登录上下文在位时 200。
*/
@DisplayName("NoticeMobiController - 契约(端点齐全 / 薄透传 / 信封)")
class NoticeMobiControllerTest {
private INoticeService noticeService;
private NoticeMobiController controller;
@BeforeEach
void setUp() {
noticeService = mock(INoticeService.class);
controller = new NoticeMobiController();
org.springframework.test.util.ReflectionTestUtils.setField(controller, "noticeService", noticeService);
}
/** ① 端点齐全性:八个移动端路径都存在且方法级映射非空 */
@Test
@DisplayName("端点齐全:八路径映射在位")
void allEightEndpointsMapped() {
Set<String> paths = new HashSet<>();
for (Method m : NoticeMobiController.class.getDeclaredMethods()) {
GetMapping gm = m.getAnnotation(GetMapping.class);
PostMapping pm = m.getAnnotation(PostMapping.class);
if (gm != null) {
paths.addAll(Arrays.asList(gm.value()));
}
if (pm != null) {
paths.addAll(Arrays.asList(pm.value()));
}
}
Set<String> expected = new HashSet<>(Arrays.asList(
"List", "SendList", "Detail", "ReadDetail",
"Expedite", "InitializeRed", "GetNextPrev", "AllFileDown"));
Set<String> missing = new HashSet<>(expected);
missing.removeAll(paths);
assertTrue(missing.isEmpty(), "缺少端点: " + missing + ",实际=" + paths);
assertEquals(expected.size(), paths.size(), "出现未预期端点: " + paths);
}
/** ② 薄透传:List 与 api 侧同参(同一 INoticeService.userList(query, pageable)) */
@Test
@DisplayName("薄透传:List 同参透传 INoticeService.userList")
void listDelegatesSameAsApiController() {
NoticeVo vo = new NoticeVo();
vo.setId(82);
vo.setTitle("契约测试");
NoticePageResultStub stub = new NoticePageResultStub(
Collections.singletonList(vo), 1, 1, 10, 1, false, 3);
when(noticeService.userList(any(), any())).thenReturn(stub);
NoticeQuery query = new NoticeQuery();
query.setPageIndex(1);
query.setPageSize(10);
Sort sort = Sort.by(Sort.Direction.DESC, "topmost", "createTime");
NoticePageResult<NoticeVo> out = controller.list(query, sort);
ArgumentCaptor<NoticeQuery> qCap = ArgumentCaptor.forClass(NoticeQuery.class);
ArgumentCaptor<org.springframework.data.domain.Pageable> pCap =
ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
verify(noticeService).userList(qCap.capture(), pCap.capture());
assertEquals(1, qCap.getValue().getPageIndex());
assertEquals(10, qCap.getValue().getPageSize());
assertEquals(10, pCap.getValue().getPageSize());
assertNotNull(pCap.getValue().getSort().getOrderFor("topmost"));
assertEquals(82, out.getContent().get(0).getId());
assertEquals(3, out.getUnreadCount());
}
/** ③ 信封 + ④ 登录态 200:standalone MockMvc + 同款 ApiResponseAdvice(走无 Sort 参数的 Detail) */
@Test
@DisplayName("信封 {code,msg,data} code:0 且登录态 200")
void envelopeShapeAndAuthorized200() throws Exception {
NoticeDetailVo vo = new NoticeDetailVo();
when(noticeService.detailMap(77, 1)).thenReturn(vo);
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new ApiResponseAdvice())
.build();
// 模拟登录上下文(真实链路由 SDK LoginUserInterceptor 写入)
com.yunhai.platform.auth.UserContextHolder.set(new com.yunhai.platform.auth.LoginUser());
try {
mvc.perform(get("/mobiapi/notice/Detail").param("serid", "77").param("type", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").exists())
.andExpect(jsonPath("$.data").exists());
} finally {
com.yunhai.platform.auth.UserContextHolder.clear();
}
}
/** Detail 同参透传(type 默认 1 标读) */
@Test
@DisplayName("薄透传:Detail 同参透传 detailMap(serid,type)")
void detailDelegates() {
NoticeDetailVo vo = new NoticeDetailVo();
when(noticeService.detailMap(77, 1)).thenReturn(vo);
NoticeDetailVo out = controller.detail(77, 1);
assertNotNull(out);
verify(noticeService).detailMap(77, 1);
}
/** 测试桩:避免依赖具体实现类构造(行为等价于 NoticeServiceImpl 返回形状) */
private static class NoticePageResultStub extends com.yunhai.notice.model.notice.NoticePageResult<NoticeVo> {
NoticePageResultStub(List<NoticeVo> content, int total, int pageIndex, int pageSize,
int totalPages, boolean hasNext, long unreadCount) {
super(content, total, pageIndex, pageSize, totalPages, hasNext, unreadCount);
}
}
}
@@ -0,0 +1,105 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 条件路由判断端点单测(2026-09-24 §5.2):部门比对两分支、formData 优先免查库、
* 未命中 outlets 抛错(挂起而非默认放行)、通知缺失/脏 bizId 抛错。
* 验签(/oapi/* 强制 100019 应用签名)由 AppAuthFilter 承担,FlowCallbackTransientIT 同款管线已覆盖。
*/
class OapiFlowRouteControllerTest {
private NoticeRepository noticeRepository;
private OapiFlowRouteController controller;
@BeforeEach
void setUp() {
noticeRepository = mock(NoticeRepository.class);
controller = new OapiFlowRouteController(noticeRepository);
}
private OapiFlowRouteController.JudgeBody body(String bizId, Map<String, Object> variables) {
OapiFlowRouteController.JudgeBody b = new OapiFlowRouteController.JudgeBody();
b.setSchId(1L);
b.setInstanceId(9527L);
b.setBizType("notice");
b.setBizId(bizId);
b.setOutlets(Arrays.asList("校长室", "其他部门"));
b.setVariables(variables);
return b;
}
@Test
void principalDept_routesToPrincipalOutlet() {
Notice n = new Notice();
n.setId(42);
n.setDeptId("xzbx");
n.setDeptName("校长室");
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
Map<String, Object> out = controller.deptJudge(body("42", null));
assertEquals("校长室", out.get("outlet"));
}
@Test
void otherDept_routesToOtherOutlet() {
Notice n = new Notice();
n.setId(43);
n.setDeptId("jwc");
n.setDeptName("教务处");
when(noticeRepository.findById(43)).thenReturn(Optional.of(n));
assertEquals("其他部门", controller.deptJudge(body("43", null)).get("outlet"));
}
@Test
void variablesDeptName_winsWithoutDbHit() {
// formData 优先:variables 带部门名时不查库(纯读轻实现),口径与查表一致
Map<String, Object> variables = new HashMap<>();
variables.put("deptName", "校长室");
when(noticeRepository.findById(anyInt())).thenReturn(Optional.empty());
assertEquals("校长室", controller.deptJudge(body("456", variables)).get("outlet"));
}
@Test
void outletNotInContract_throws_neverDefaults() {
Notice n = new Notice();
n.setId(44);
n.setDeptName("校长室");
when(noticeRepository.findById(44)).thenReturn(Optional.of(n));
OapiFlowRouteController.JudgeBody b = body("44", null);
b.setOutlets(Collections.singletonList("其他部门")); // 流程定义与端点出口契约不一致
BizException e = assertThrows(BizException.class, () -> controller.deptJudge(b));
assertEquals(true, e.getMessage().contains("未命中出口"));
}
@Test
void missingNotice_throws() {
when(noticeRepository.findById(99)).thenReturn(Optional.empty());
assertThrows(BizException.class, () -> controller.deptJudge(body("99", null)));
}
@Test
void illegalBizId_throws() {
assertThrows(BizException.class, () -> controller.deptJudge(body("abc", null)));
}
}
@@ -1,9 +1,6 @@
package com.yunhai.notice.flow; package com.yunhai.notice.flow;
import com.yunhai.notice.entity.Notice; 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.repository.NoticeRepository;
import com.yunhai.notice.service.sms.SmsSender; import com.yunhai.notice.service.sms.SmsSender;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -30,10 +27,12 @@ import static org.mockito.ArgumentMatchers.anyString;
*/ */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { @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.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"}) "spring.jpa.hibernate.ddl-auto=update",
// 验签确定性(同 NoticeFlowApiIT):本地 Redis 无 sign:app:100019 时回落白名单,勿依赖环境
"yh.app-auth.trust-registry=false",
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
class FlowCallbackTransientIT { class FlowCallbackTransientIT {
private static final String REG_SECRET = "it-transient-secret-0123456789";
private static final String FLOW_GUID = "it-transient-guid-01"; private static final String FLOW_GUID = "it-transient-guid-01";
@LocalServerPort @LocalServerPort
@@ -42,9 +41,7 @@ class FlowCallbackTransientIT {
@Autowired @Autowired
private NoticeRepository noticeRepository; private NoticeRepository noticeRepository;
@Autowired @Autowired
private NoticeFlowRegRepository regRepository; private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
@Autowired
private NoticeFlowEventRepository eventRepository;
@MockBean @MockBean
private SmsSender smsSender; private SmsSender smsSender;
@@ -53,19 +50,9 @@ class FlowCallbackTransientIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
eventRepository.deleteAll(); jdbcTemplate.update("DELETE FROM flow_callback_event");
regRepository.deleteAll();
noticeRepository.deleteAll(); noticeRepository.deleteAll();
NoticeFlowReg reg = new NoticeFlowReg();
reg.setSchId(1);
reg.setAppKey("100617");
reg.setFlowGuid(FLOW_GUID);
reg.setCallbackUrl("http://localhost:" + port + "/oapi/notice/flow/callback");
reg.setSecret(REG_SECRET);
reg.setStatus(0);
regRepository.save(reg);
notice = new Notice(); notice = new Notice();
notice.setTitle("瞬时失败通知"); notice.setTitle("瞬时失败通知");
notice.setType(1); notice.setType(1);
@@ -90,7 +77,7 @@ class FlowCallbackTransientIT {
Notice after = reload(); Notice after = reload();
assertEquals(10, after.getAuditStatus(), "失败事务整体回滚"); assertEquals(10, after.getAuditStatus(), "失败事务整体回滚");
assertEquals(0, after.getStatus()); assertEquals(0, after.getStatus());
assertEquals(0L, eventRepository.count(), "事件去重行随事务回滚(重投不撞 uk)"); assertEquals(0L, callbackEventCount(), "事件去重行随事务回滚(重投不撞 uk)");
// 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环) // 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环)
Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList()); Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
@@ -99,7 +86,7 @@ class FlowCallbackTransientIT {
Notice recovered = reload(); Notice recovered = reload();
assertEquals(20, recovered.getAuditStatus()); assertEquals(20, recovered.getAuditStatus());
assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布"); assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布");
assertEquals(1L, eventRepository.count()); assertEquals(1L, callbackEventCount());
// 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次 // 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次
Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList());
} }
@@ -118,10 +105,16 @@ class FlowCallbackTransientIT {
// 回调端点挂在 /oapi/* 下:以推送方 flow-center(100019)身份带应用签名投递 // 回调端点挂在 /oapi/* 下:以推送方 flow-center(100019)身份带应用签名投递
return new com.yunhai.notice.support.OapiSignedClient( return new com.yunhai.notice.support.OapiSignedClient(
"http://localhost:" + port, "100019", "flow-center-dev-secret-100019") "http://localhost:" + port, "100019", "flow-center-dev-secret-100019")
.post("/oapi/notice/flow/callback", body, String.class); .post("/oapi/flow/callback", body, String.class);
} }
private static String uuid() { private static String uuid() {
return UUID.randomUUID().toString().replace("-", ""); return UUID.randomUUID().toString().replace("-", "");
} }
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
private long callbackEventCount() {
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
return c == null ? 0 : c;
}
} }
@@ -0,0 +1,138 @@
package com.yunhai.notice.flow;
import com.yunhai.notice.service.flow.NoticeApprovalClient;
import com.yunhai.notice.service.flow.NoticeFlowProperties;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.core.PlatformService;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
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.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 审批代理客户端(N06 TDD):URL/参数逐字断言(与 flow-center OapiFlowController 契约对齐)、
* bizType=notice 固定、reject 空 opinion 前置拒绝、未接入业务异常。
*/
class NoticeApprovalClientTest {
private static final String TARGET = PlatformService.FLOW_CENTER.key();
private final NoticeFlowProperties properties = new NoticeFlowProperties();
private YschOpenCall call;
private NoticeApprovalClient client;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
call = mock(YschOpenCall.class);
ObjectProvider<YschOpenCall> provider = Mockito.mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(call);
client = new NoticeApprovalClient(properties, provider);
}
@Test
void todoTasks_urlAndParams_charExact() {
Map<String, Object> page = new HashMap<>();
when(call.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class))).thenReturn(page);
client.todoTasks(27, 10006, 2, 20);
Map<String, String> expected = new LinkedHashMap<>();
expected.put("schId", "27");
expected.put("assigneeId", "10006");
expected.put("bizType", "notice");
expected.put("page", "2");
expected.put("size", "20");
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), eq(expected), eq(Map.class));
}
@Test
void todoTasks_nullPaging_omitted() {
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
client.todoTasks(27, 10006, null, null);
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), Mockito.argThat(q ->
q.size() == 3 && "notice".equals(q.get("bizType"))
&& !q.containsKey("page") && !q.containsKey("size")), eq(Map.class));
}
@Test
void instances_urlAndParams_charExact() {
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
client.instances(27, 10, 1, 50);
Map<String, String> expected = new LinkedHashMap<>();
expected.put("schId", "27");
expected.put("bizType", "notice");
expected.put("status", "10");
expected.put("page", "1");
expected.put("size", "50");
verify(call).get(eq(TARGET), eq("/oapi/flow/instances"), eq(expected), eq(Map.class));
}
@Test
void approve_urlParamsAndBody() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("opinion", "同意");
body.put("edgeName", "通过");
client.approve(106L, 27, 10006, "刘文娟", "同意", "通过");
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", "27");
query.put("operatorId", "10006");
query.put("operatorName", "刘文娟");
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), eq(query), eq(body), eq(Void.class));
}
@Test
void approve_blankOpinionAndEdge_omittedFromBody() {
client.approve(106L, 27, 10006, "甲", " ", null);
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), anyMap(),
eq(new LinkedHashMap<String, Object>()), eq(Void.class));
}
@Test
void reject_blankOpinion_rejectedLocally() {
BizException e = assertThrows(BizException.class,
() -> client.reject(106L, 27, 10006, "甲", " "));
assertTrue(e.getMessage().contains("意见"));
verify(call, Mockito.never()).post(any(String.class), any(String.class), anyMap(), any(), any(Class.class));
}
@Test
void reject_urlParamsAndBody() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("opinion", "材料不全");
client.reject(107L, 27, 10006, "甲", "材料不全");
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", "27");
query.put("operatorId", "10006");
query.put("operatorName", "甲");
verify(call).post(eq(TARGET), eq("/oapi/flow/task/107/reject"), eq(query), eq(body), eq(Void.class));
}
@Test
void disabledFlow_bizException() {
properties.setEnabled(false);
assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null));
assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null));
}
@Test
void outboundError_propagatesAsIs() {
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class)))
.thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class, () -> client.todoTasks(27, 10006, null, null));
}
}
@@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Notice 流程接缝 IT(T2.1/T2.3/T2.5,RANDOM_PORT 真实过滤链 + bsch_notice_test): * Notice 流程接缝 IT(T2.1/T2.3/T2.5,RANDOM_PORT 真实过滤链 + bsch_notice_test):
* ① 回调端点 <b>/oapi/notice/flow/callback</b> 的应用验签(复用平台能力):以 flow-center 身份 * ① 回调端点 <b>/oapi/flow/callback</b> 的应用验签(复用平台能力):以 flow-center 身份
* (appKey=100019,密钥取 trusted-apps)合法签名 200 推进状态 / 坏签名 401 / 匿名 401 / * (appKey=100019,密钥取 trusted-apps)合法签名 200 推进状态 / 坏签名 401 / 匿名 401 /
* 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等; * 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等;
* ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401); * ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401);
@@ -33,11 +33,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/ */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai", "spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai",
"spring.jpa.hibernate.ddl-auto=update"}) "spring.jpa.hibernate.ddl-auto=update",
// 验签确定性(2026-09-24):主 yml 已切中央密钥链(trust-registry,Redis sign:app:* 单一真源),
// 本地 Redis 无 100019 密钥时合法签名也会 401。IT 勿依赖环境:回落白名单直配本用例的身份对
// (等价 yml 注释里"本地跑 oapi 回调可临时打开白名单"的口径,只作用于测试上下文)。
"yh.app-auth.trust-registry=false",
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
class NoticeFlowApiIT { class NoticeFlowApiIT {
/** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */ /** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */
private static final String CALLBACK_PATH = "/oapi/notice/flow/callback"; private static final String CALLBACK_PATH = "/oapi/flow/callback";
/** 推送方身份:flow-center 的 appKey/secret(Notice trusted-apps 白名单) */ /** 推送方身份:flow-center 的 appKey/secret(Notice trusted-apps 白名单) */
private static final String FLOW_APP_KEY = "100019"; private static final String FLOW_APP_KEY = "100019";
private static final String FLOW_APP_SECRET = "flow-center-dev-secret-100019"; private static final String FLOW_APP_SECRET = "flow-center-dev-secret-100019";
@@ -52,7 +57,7 @@ class NoticeFlowApiIT {
@Autowired @Autowired
private NoticeFlowRegRepository regRepository; private NoticeFlowRegRepository regRepository;
@Autowired @Autowired
private NoticeFlowEventRepository eventRepository; private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
@Autowired @Autowired
private NoticeFlowBindingRepository bindingRepository; private NoticeFlowBindingRepository bindingRepository;
@Autowired @Autowired
@@ -62,7 +67,8 @@ class NoticeFlowApiIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
eventRepository.deleteAll(); jdbcTemplate.update("DELETE FROM flow_callback_event");
jdbcTemplate.update("DELETE FROM flow_callback_reg");
regRepository.deleteAll(); regRepository.deleteAll();
bindingRepository.deleteAll(); bindingRepository.deleteAll();
noticeRepository.deleteAll(); noticeRepository.deleteAll();
@@ -106,7 +112,7 @@ class NoticeFlowApiIT {
assertNotNull(updated.getFlowResultJson()); assertNotNull(updated.getFlowResultJson());
assertTrue(updated.getFlowResultJson().contains("同意")); assertTrue(updated.getFlowResultJson().contains("同意"));
// 事件去重行落库(事务提交) // 事件去重行落库(事务提交)
assertEquals(1L, eventRepository.count()); assertEquals(1L, callbackEventCount());
} }
@Test @Test
@@ -116,7 +122,7 @@ class NoticeFlowApiIT {
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
"重复投递必须回 200(flow-center 持续重试语义下的常态)"); "重复投递必须回 200(flow-center 持续重试语义下的常态)");
assertEquals(20, reload().getAuditStatus()); assertEquals(20, reload().getAuditStatus());
assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行"); assertEquals(1L, callbackEventCount(), "eventId 唯一:重复投递不落第二行");
} }
@Test @Test
@@ -153,7 +159,7 @@ class NoticeFlowApiIT {
"无签名 ping 应被 401 拦下"); "无签名 ping 应被 401 拦下");
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
"带签名的 ping 回 200(注册探活只判可达)"); "带签名的 ping 回 200(注册探活只判可达)");
assertEquals(0L, eventRepository.count(), "ping 不落事件行"); assertEquals(0L, callbackEventCount(), "ping 不落事件行");
} }
// ==================== ② /oapi/notice/getDict 三态 ==================== // ==================== ② /oapi/notice/getDict 三态 ====================
@@ -257,4 +263,10 @@ class NoticeFlowApiIT {
private static String uuid() { private static String uuid() {
return UUID.randomUUID().toString().replace("-", ""); return UUID.randomUUID().toString().replace("-", "");
} }
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
private long callbackEventCount() {
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
return c == null ? 0 : c;
}
} }
@@ -0,0 +1,112 @@
package com.yunhai.notice.sdk;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.apistd.spring.ApiStdProperties;
import com.yunhai.platform.apistd.spring.GlobalExceptionHandler;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.spring.OpenApiAutoConfiguration;
import com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* D4 应用级证据(SDK 健壮性自审 2026-09-27):Notice 依赖树上 advice 真的可用的三重证明——
* <ol>
* <li>类可达:Notice classpath(聚合 starter → yh-openapi-spring-boot-starter + yh-api-std)
* 上 OpenApiExceptionAdvice 可加载;</li>
* <li>装配可达:servlet web 应用上下文里仅注册 OpenApiAutoConfiguration(无 YschOpenCall 等
* 其它 bean),嵌套条件装配即产出 advice bean——证明自动注册不依赖应用额外声明;</li>
* <li>行为可达:与 GlobalExceptionHandler 同场,SDK 出向失败落 code=5+档位文案
* (A 档原文 / B 档目标+状态 / C 档通用),非 SDK 异常仍 code=6,401 仍 code=4。</li>
* </ol>
* 本测试在 Notice 工程内运行(对 .m2 中新装 SDK 编译),即"应用上下文里 advice 生效"的
* 发布前等价证明;线上运行时证据另由部署后分档验证补齐。
*/
class NoticeSdkAdviceExposureTest {
@RestController
static class SdkFailureController {
@GetMapping("/sdk/a")
public String addressing() {
// 真实 A 档形态:未配置的调用目标(ServiceDirectory.resolve 的原文)
throw new OpenApiException("unknown call target 'ghost': configure yh.open.services.ghost"
+ " (or yh.call.targets.ghost.base-host) first",
OpenApiException.FailureKind.ADDRESSING, "ghost");
}
@GetMapping("/sdk/b")
public String downstream() {
throw new OpenApiException("OpenAPI request failed: httpStatus=500", 500, "SECRET-DOWN",
OpenApiException.FailureKind.DOWNSTREAM, "usermsg");
}
@GetMapping("/sdk/other")
public String other() {
throw new IllegalStateException("boom");
}
@GetMapping("/sdk/unauthorized")
public String unauthorized() {
throw new DException(ErrorCode.UNAUTHORIZED, "验签失败", null, null, 401);
}
}
@Test
void adviceClass_resolvable_onNoticeClasspath() throws Exception {
assertSame(OpenApiExceptionAdvice.class,
Class.forName("com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice"));
assertSame(OpenApiException.FailureKind.class,
Class.forName("com.yunhai.platform.openapi.core.OpenApiException$FailureKind"));
}
@Test
void adviceBean_autoRegistered_inServletWebContext_withoutAppDeclaration() {
try (AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext()) {
ctx.register(OpenApiAutoConfiguration.class);
ctx.refresh();
assertNotNull(ctx.getBean(OpenApiExceptionAdvice.class),
"仅注册自动装配类(应用零声明)也应产出 advice bean");
}
}
@Test
void sdkFailures_tieredExposure_code5_fallbackIntact() throws Exception {
MockMvc mvc = MockMvcBuilders.standaloneSetup(new SdkFailureController())
.setControllerAdvice(new OpenApiExceptionAdvice(),
new GlobalExceptionHandler(new ApiStdProperties()))
.build();
// A 档:原文透出(可行动配置指引),code≠6
mvc.perform(get("/sdk/a"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(5))
.andExpect(jsonPath("$.msg").value(
"unknown call target 'ghost': configure yh.open.services.ghost"
+ " (or yh.call.targets.ghost.base-host) first"));
// B 档:目标名+HTTP 状态,下游响应体不外露
String body = mvc.perform(get("/sdk/b")).andReturn().getResponse().getContentAsString();
org.junit.jupiter.api.Assertions.assertTrue(body.contains("target=usermsg") && body.contains("HTTP 500"),
body);
org.junit.jupiter.api.Assertions.assertFalse(body.contains("SECRET-DOWN"), body);
// 非 SDK 异常:兜底未被打穿
mvc.perform(get("/sdk/other"))
.andExpect(jsonPath("$.code").value(6))
.andExpect(jsonPath("$.msg").value("内部服务错误"));
// 401 既有语义:code=4
mvc.perform(get("/sdk/unauthorized"))
.andExpect(jsonPath("$.code").value(4))
.andExpect(jsonPath("$.msg").value("验签失败"));
}
}
@@ -1,251 +0,0 @@
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 让流程中心重试(事件行随事务回滚)");
}
}
@@ -11,6 +11,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.IOException; import java.io.IOException;
@@ -141,6 +142,30 @@ class FlowCenterClientTest {
return new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper); return new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper);
} }
@Test
@DisplayName("目录与 legacy 键皆未配且 enabled=true:构造期 fail-fast(不静默打 localhost)")
void misconfiguredFailsFast() {
NoticeFlowProperties props = new NoticeFlowProperties(); // baseUrl 已无默认值
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
(host, basePath) -> java.util.Collections.emptyList());
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper));
assertTrue(e.getMessage().contains("yh.open.services.flow-center"), e.getMessage());
assertTrue(e.getMessage().contains("notice.flow.base-url"), e.getMessage());
}
@Test
@DisplayName("enabled=false 且地址全缺:不 fail-fast(显式未接入,地址本就不需要)")
void disabledWithoutAddressIsAllowed() {
NoticeFlowProperties props = new NoticeFlowProperties();
props.setEnabled(false);
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
(host, basePath) -> java.util.Collections.emptyList());
FlowCenterClient client = new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper);
BizException ex = assertThrows(BizException.class, () -> client.listFlows(1));
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
}
private FlowCenterClient newClient() { private FlowCenterClient newClient() {
return newClient(true); return newClient(true);
} }
@@ -198,13 +223,13 @@ class FlowCenterClientTest {
void registerCallback_contractFields() { void registerCallback_contractFields() {
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody(); FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
body.setFlowGuid("fg-1"); body.setFlowGuid("fg-1");
body.setCallbackUrl("http://localhost:8101/callback/flow"); body.setCallbackUrl("http://localhost:8101/oapi/flow/callback");
FlowModels.CallbackRegResultVo result = newClient().registerCallback(1, body); FlowModels.CallbackRegResultVo result = newClient().registerCallback(1, body);
assertEquals("reg-secret-0123456789abcdef", result.getSecret()); assertEquals("reg-secret-0123456789abcdef", result.getSecret());
Captured c = captured.get(0); Captured c = captured.get(0);
assertEquals("POST", c.method); assertEquals("POST", c.method);
assertEquals("/oapi/flow/callback/reg", c.path); assertEquals("/oapi/flow/callback/reg", c.path);
assertEquals("http://localhost:8101/callback/flow", c.body.get("callbackUrl").asText()); assertEquals("http://localhost:8101/oapi/flow/callback", c.body.get("callbackUrl").asText());
} }
@Test @Test
@@ -0,0 +1,74 @@
package com.yunhai.notice.service.flow;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。<br><br>
* 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须等于冻结字面量
* {@code /oapi/flow/callback}——不一致时 flow-center 探活/推送全部 404,而本地单测因为走
* 伪服务端不会发现。<br>
* 2026-09-24:字面量由 {@code /oapi/notice/flow/callback} 收敛为 starter 默认值
* (网关契约 {@code /{target}/oapi/**} 首段已由 target 承担,端点内不再带应用名前缀);
* 同日清理 {@code notice.flow.callback-base} / {@code callback-base-path} / {@code callback-path}
* 三个已无代码引用的历史键——注册地址改由 {@code yh.flow-callback} 三段推导,
* 故本测试不再做双键一致性比对,改为断言历史键不得回流。<br>
* 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController
* 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。
*/
@DisplayName("Notice 回调路径配置 - 与 starter 端点映射咬合")
class NoticeFlowCallbackConfigContractTest {
private static final String FROZEN_CALLBACK_PATH = "/oapi/flow/callback";
@SuppressWarnings("unchecked")
private static Object yamlValue(String path) throws Exception {
try (InputStream in = NoticeFlowCallbackConfigContractTest.class.getClassLoader()
.getResourceAsStream("application.yml")) {
assertNotNull(in, "application.yml 必须在测试类路径上");
Map<String, Object> root = new Yaml().load(in);
Object cur = root;
for (String seg : path.split("\\.")) {
if (!(cur instanceof Map)) {
return null;
}
cur = ((Map<String, Object>) cur).get(seg);
if (cur == null) {
return null;
}
}
return cur;
}
}
@Test
@DisplayName("yh.flow-callback.path 等于冻结字面量(starter 端点映射唯一真源)")
void starterPathMatchesFrozenLiteral() throws Exception {
Object starterConfigured = yamlValue("yh.flow-callback.path");
String starterPath = starterConfigured == null
? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里)
: String.valueOf(starterConfigured);
assertEquals(FROZEN_CALLBACK_PATH, starterPath,
"yh.flow-callback.path 变了:flow-center 派发会 404,请同步生产配置与注册契约");
}
@Test
@DisplayName("历史回调键(callback-base / -base-path / -path)已清理,不得回流")
void legacyCallbackKeysAreGone() throws Exception {
assertNull(yamlValue("notice.flow.callback-base"),
"notice.flow.callback-base 已无代码引用,不应再出现在配置里");
assertNull(yamlValue("notice.flow.callback-base-path"),
"notice.flow.callback-base-path 已无代码引用,不应再出现在配置里");
assertNull(yamlValue("notice.flow.callback-path"),
"notice.flow.callback-path 已无代码引用,不应再出现在配置里");
}
}
@@ -0,0 +1,311 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
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 com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import java.util.HashMap;
import java.util.Map;
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;
/**
* 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 Notice 侧状态机;
* 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的
* FlowCallbackReceiverTest 覆盖):六事件映射;completed → status=1 + sms 恰一次;
* 单据级乱序丢弃;终态不回退守卫;cc.sent 只落抄送行不动状态机(2026-09-24);
* 异常直接外抛(starter 回 5xx)。
*/
class NoticeFlowCallbackHandlerTest {
private NoticeRepository noticeRepository;
private SmsSender smsSender;
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
private NoticeFlowCallbackHandler handler;
private Notice notice;
private final AtomicInteger saveCount = new AtomicInteger();
@BeforeEach
void setUp() {
noticeRepository = mock(NoticeRepository.class);
smsSender = mock(SmsSender.class);
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
// 统一发送链(§3.2):认领桩=1 表示未发过(首次),0=已发(重复闸门)
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
handler = new NoticeFlowCallbackHandler(noticeRepository,
new SmsSendService(noticeRepository, smsSender), new ObjectMapper(), noticeCcRepository);
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);
});
}
private FlowCallbackEvent event(String eventId, String event, int version, String result, String opinion) {
FlowCallbackEvent e = new FlowCallbackEvent();
e.setEventId(eventId);
e.setEvent(event);
e.setSchId(1);
e.setFlowGuid("fg-1");
e.setInstanceId(9527L);
e.setInstanceVersion(version);
e.setBizType("notice");
e.setBizId("42");
e.setBizTitle("t");
e.setTaskId(7L);
e.setOperatorUserId(201);
e.setOperatorName("张三");
Map<String, Object> data = new HashMap<>();
if (result != null) {
data.put("result", result);
}
if (opinion != null) {
data.put("opinion", opinion);
}
e.setData(data);
return e;
}
@Test
void inFlightEvents_mapToApproving() {
handler.onEvent(event("e1", "flow.submitted", 1, null, null));
assertEquals(10, notice.getAuditStatus());
handler.onEvent(event("e2", "flow.task.created", 1, null, null));
assertEquals(10, notice.getAuditStatus());
handler.onEvent(event("e3", "flow.task.handled", 2, null, null));
assertEquals(10, notice.getAuditStatus());
assertEquals(0, notice.getStatus(), "审批中事件不得提前发布");
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
}
@Test
void completed_publishesAndSendsSmsOnce() {
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", "同意"));
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));
handler.onEvent(event("e-df", "flow.completed", 3, "APPROVED", null));
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() {
// 已发布行再收到终态事件:status 已 1 → justPublished=false → 不再发 sms
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", null));
notice.setStatus(1);
notice.setAuditStatus(20);
handler.onEvent(event("e4x", "flow.completed", 4, "APPROVED", null));
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
}
@Test
void rejected_mapsTo30_withResultJson() {
handler.onEvent(event("e5", "flow.rejected", 3, "REJECTED", "内容不妥"));
assertEquals(30, notice.getAuditStatus());
assertEquals(0, notice.getStatus(), "驳回不发布");
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
assertTrue(notice.getFlowResultJson().contains("内容不妥"));
}
@Test
void withdrawn_mapsTo40() {
handler.onEvent(event("e6", "flow.withdrawn", 2, "WITHDRAWN", null));
assertEquals(40, notice.getAuditStatus());
assertEquals(0, notice.getStatus());
}
@Test
void illegalBizId_silentReturn() {
FlowCallbackEvent e = event("e-x", "flow.completed", 3, null, null);
e.setBizId("not-a-number");
handler.onEvent(e); // 不抛(starter 回 200)
}
@Test
void unknownNotice_silentReturn() {
when(noticeRepository.findById(42)).thenReturn(Optional.empty());
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
handler.onEvent(event("e10", "flow.completed", 3, null, null)); // 不抛
}
@Test
void staleVersion_dropped() {
notice.setLastInstanceVersion(5);
handler.onEvent(event("e7", "flow.completed", 3, null, null)); // 迟到的低版本事件
assertEquals(10, notice.getAuditStatus(), "乱序事件丢弃");
}
@Test
void sameVersionDifferentEvent_processed() {
// submitted 与 task.created 天然同版本:等值不丢(幂等映射)
notice.setLastInstanceVersion(1);
handler.onEvent(event("e8", "flow.task.created", 1, null, null));
assertEquals(10, notice.getAuditStatus());
assertEquals(1, notice.getLastInstanceVersion());
}
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置(completed 先落库,随后到 task.handled) =====
@Test
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
handler.onEvent(event("e-t1", "flow.completed", 4, "APPROVED", "同意"));
assertEquals(20, notice.getAuditStatus());
String frozen = notice.getFlowResultJson();
handler.onEvent(event("e-t2", "flow.task.handled", 4, null, "同意")); // 不抛 → 200
assertEquals(20, notice.getAuditStatus(), "终态行拒绝非终态事件(终态不回退)");
assertEquals(frozen, notice.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
}
@Test
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
handler.onEvent(event("e-t3", "flow.completed", 4, "APPROVED", null));
handler.onEvent(event("e-t4", "flow.task.handled", 5, null, null));
assertEquals(20, notice.getAuditStatus(), "终态后任何非终态事件都不回退(R4 重提由 edit 先置 0,不冲突)");
}
@Test
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
handler.onEvent(event("e-t5", "flow.rejected", 4, "REJECTED", "材料不齐"));
assertEquals(30, notice.getAuditStatus());
String frozen = notice.getFlowResultJson();
handler.onEvent(event("e-t6", "flow.withdrawn", 4, "WITHDRAWN", null));
assertEquals(30, notice.getAuditStatus(), "已终态不被另一终态改写");
assertEquals(frozen, notice.getFlowResultJson());
}
@Test
void terminalGuard_doesNotBlockR4Resubmission_afterLocalReset() {
handler.onEvent(event("e-t7", "flow.rejected", 4, "REJECTED", null));
notice.setAuditStatus(0); // NoticeFlowService 编辑保存时本地重置
handler.onEvent(event("e-t8", "flow.submitted", 5, null, null));
assertEquals(10, notice.getAuditStatus(), "重置后新一轮事件正常推进(守卫只拦终态行的回退)");
assertEquals(5, notice.getLastInstanceVersion());
}
@Test
void unknownEvent_silentReturn() {
handler.onEvent(event("e-u1", "flow.returned", 3, null, null)); // Notice 不消费的已知事件
assertEquals(10, notice.getAuditStatus());
}
// ===== cc.sent(2026-09-24):只落 notice_cc 抄送行,不动审核状态机 =====
private FlowCallbackEvent ccEvent(String eventId, int version, Integer ccUserId) {
FlowCallbackEvent e = event(eventId, "flow.cc.sent", version, null, null);
e.setTaskId(null); // 抄送无任务
Map<String, Object> data = new HashMap<>();
data.put("nodeId", "cc1");
data.put("nodeName", "抄送校长");
data.put("ccId", 501L);
if (ccUserId != null) {
data.put("ccUserId", ccUserId);
data.put("ccUserName", "校长甲");
}
e.setData(data);
return e;
}
@Test
void ccSent_recordsRow_withoutTouchingStateMachine() {
handler.onEvent(ccEvent("e-cc1", 3, 301));
verify(noticeCcRepository, times(1)).save(any(com.yunhai.notice.entity.NoticeCc.class));
org.mockito.ArgumentCaptor<com.yunhai.notice.entity.NoticeCc> captor =
org.mockito.ArgumentCaptor.forClass(com.yunhai.notice.entity.NoticeCc.class);
verify(noticeCcRepository).save(captor.capture());
com.yunhai.notice.entity.NoticeCc saved = captor.getValue();
assertEquals(42, saved.getNoticeId());
assertEquals(9527L, saved.getFlowInstanceId());
assertEquals("cc1", saved.getNodeId());
assertEquals("抄送校长", saved.getNodeName());
assertEquals(301, saved.getCcUserId());
assertEquals("校长甲", saved.getCcUserName());
// 知会不是审批:审核状态/发布状态/版本快照全部不动
assertEquals(10, notice.getAuditStatus());
assertEquals(0, notice.getStatus());
verify(noticeRepository, never()).saveAndFlush(any(Notice.class));
}
@Test
void ccSent_missingCcUserId_skippedSilently() {
handler.onEvent(ccEvent("e-cc2", 3, null)); // 上游脏数据:不抛(回 200),不落行
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
assertEquals(10, notice.getAuditStatus());
}
@Test
void ccSent_staleVersion_dropped() {
notice.setLastInstanceVersion(5);
handler.onEvent(ccEvent("e-cc3", 3, 301)); // 迟到的低版本抄送
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
}
@Test
void ccSent_dbFailure_propagates() {
when(noticeCcRepository.save(any(com.yunhai.notice.entity.NoticeCc.class)))
.thenThrow(new DataAccessResourceFailureException("db down"));
assertThrows(DataAccessResourceFailureException.class,
() -> handler.onEvent(ccEvent("e-cc4", 3, 301)),
"抄送落库失败回 5xx 让 flow-center 重试(事件行随事务回滚)");
}
// ===== 异常外抛 = starter 回 5xx(flow-center 重试追平,免对账语义) =====
@Test
void dbFailure_propagates() {
when(noticeRepository.saveAndFlush(any(Notice.class)))
.thenThrow(new DataAccessResourceFailureException("db down"));
assertThrows(DataAccessResourceFailureException.class,
() -> handler.onEvent(event("e11", "flow.completed", 3, null, null)));
}
@Test
void smsFailure_propagates() {
doThrow(new RuntimeException("sms gateway down")).when(smsSender)
.sendNoticeSms(anyInt(), anyString(), anyList());
assertThrows(RuntimeException.class,
() -> handler.onEvent(event("e12", "flow.completed", 3, null, null)),
"发布后置失败也应回 5xx 让流程中心重试(事件行随事务回滚)");
}
}
@@ -19,21 +19,24 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人 → 放行; * 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人/被抄送人 → 放行;
* 无关人/无身份 → 403"通知不存在或未发布"。 * 无关人/无身份 → 403"通知不存在或未发布"。
*/ */
class NoticeFlowGuardTest { class NoticeFlowGuardTest {
private FlowClient flowClient; private FlowClient flowClient;
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
private NoticeFlowGuard guard; private NoticeFlowGuard guard;
private Notice notice; private Notice notice;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
flowClient = mock(FlowClient.class); flowClient = mock(FlowClient.class);
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
NoticeFlowProperties properties = new NoticeFlowProperties(); NoticeFlowProperties properties = new NoticeFlowProperties();
properties.setEnabled(true); properties.setEnabled(true);
guard = new NoticeFlowGuard(flowClient, properties); guard = new NoticeFlowGuard(flowClient, properties, noticeCcRepository);
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
notice = new Notice(); notice = new Notice();
notice.setId(42); notice.setId(42);
notice.setSchId(1); notice.setSchId(1);
@@ -86,6 +89,24 @@ class NoticeFlowGuardTest {
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null))); guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
} }
// 被抄送人放行(2026-09-24 第 4 条):本地 notice_cc 有行即可见,「抄送给我的」点开依赖此规则
@Test
void ccRecipient_allowed_evenIfFlowCenterDown() {
when(flowClient.getInstanceByBiz(any(), anyString(), anyString()))
.thenThrow(new RuntimeException("flow-center down"));
when(noticeCcRepository.existsByNoticeIdAndCcUserId(42, 501)).thenReturn(true);
assertDoesNotThrow(() ->
guard.requireUnpublishedVisible(notice, user(501, "principal", 0, null)));
}
@Test
void notCcRecipient_forbidden() {
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
stubAssignees(301);
assertThrows(BizException.class, () ->
guard.requireUnpublishedVisible(notice, user(502, "not-cc", 0, null)));
}
@Test @Test
void unrelatedUser_forbidden() { void unrelatedUser_forbidden() {
stubAssignees(301, 302); stubAssignees(301, 302);
@@ -117,7 +117,6 @@ class NoticeFlowPublishTest {
NoticeFlowProperties props = new NoticeFlowProperties(); NoticeFlowProperties props = new NoticeFlowProperties();
props.setEnabled(true); props.setEnabled(true);
props.setFrontBaseUrl("http://localhost:8077");
AppAuthProperties appAuth = new AppAuthProperties(); AppAuthProperties appAuth = new AppAuthProperties();
appAuth.setAppKey("100617"); appAuth.setAppKey("100617");
@@ -137,11 +136,18 @@ class NoticeFlowPublishTest {
return vo; return vo;
}); });
noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, bindingRepository, noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository,
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser, mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
new NoticeFlowGuard(flowClient, props)); mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), currentBoaUser,
new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
noticeService = new NoticeService(); noticeService = new NoticeService();
ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier",
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository);
ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class)); ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class));
ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class)); ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class));
@@ -326,7 +332,7 @@ class NoticeFlowPublishTest {
assertEquals("notice", b.getBizType()); assertEquals("notice", b.getBizType());
assertEquals("77", b.getBizId()); assertEquals("77", b.getBizId());
assertEquals("九月开学安排", b.getBizTitle()); assertEquals("九月开学安排", b.getBizTitle());
assertEquals("http://localhost:8077/#/list/detail?serid=77", b.getBizUrl()); assertEquals("/list/detail?serid=77", b.getBizUrl());
assertEquals(201, b.getSubmitterId()); assertEquals(201, b.getSubmitterId());
assertEquals("张三", b.getSubmitterName()); assertEquals("张三", b.getSubmitterName());
// 快照:title + summary(纯文本) + attachmentNames[] // 快照:title + summary(纯文本) + attachmentNames[]
@@ -44,10 +44,11 @@ class NoticeFlowTraceTest {
flowClient = mock(FlowClient.class); flowClient = mock(FlowClient.class);
NoticeFlowProperties props = new NoticeFlowProperties(); NoticeFlowProperties props = new NoticeFlowProperties();
props.setEnabled(true); props.setEnabled(true);
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), service = new NoticeFlowService(noticeRepository,
mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class), mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class),
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props)); mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
bound = new Notice(); bound = new Notice();
bound.setId(42); bound.setId(42);
@@ -0,0 +1,124 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
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 com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 撤回中转单测(2026-09-17 接入 SDK 托管件):
* 非创建人 403 / 非审批中拒绝 / 未进入流程拒绝,且三者都不出站;
* 创建人对审批中通知出站撤回(本地不改状态,由 flow.withdrawn 回调推进)。
*/
class NoticeFlowWithdrawTest {
private NoticeRepository noticeRepository;
private FlowClient flowClient;
private NoticeFlowService service;
private Notice approving;
@BeforeEach
void setUp() {
noticeRepository = mock(NoticeRepository.class);
flowClient = mock(FlowClient.class);
NoticeFlowProperties props = new NoticeFlowProperties();
props.setEnabled(true);
service = new NoticeFlowService(noticeRepository,
mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props,
new ObjectMapper(), mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
approving = new Notice();
approving.setId(42);
approving.setSchId(27);
approving.setStatus(0);
approving.setFlowGuid("fg-1");
approving.setAuditStatus(10);
approving.setFlowInstanceId(9527L);
approving.setPublisherAccount("zhangsan");
when(noticeRepository.findById(42)).thenReturn(Optional.of(approving));
}
private static BoaUser owner() {
BoaUser u = new BoaUser();
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201);
u.setAccount("zhangsan");
return u;
}
private static BoaUser stranger() {
BoaUser u = new BoaUser();
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999);
u.setAccount("stranger");
return u;
}
@Test
void withdraw_byStranger_forbiddenAndNoOutbound() {
com.yunhai.platform.apistd.BizException e = assertThrows(
com.yunhai.platform.apistd.BizException.class,
() -> service.withdrawNotice(42, stranger()));
assertTrue(e.getMessage().contains("仅通知创建人"));
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
}
@Test
void withdraw_whenNotApproving_rejected() {
approving.setAuditStatus(20);
com.yunhai.platform.apistd.BizException e = assertThrows(
com.yunhai.platform.apistd.BizException.class,
() -> service.withdrawNotice(42, owner()));
assertTrue(e.getMessage().contains("仅审批中"));
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
}
@Test
void withdraw_withoutInstance_rejected() {
approving.setFlowInstanceId(null);
com.yunhai.platform.apistd.BizException e = assertThrows(
com.yunhai.platform.apistd.BizException.class,
() -> service.withdrawNotice(42, owner()));
assertTrue(e.getMessage().contains("尚未进入流程"));
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
}
@Test
void withdraw_byOwner_outboundWithSubmitterId() {
service.withdrawNotice(42, owner());
verify(flowClient).withdraw(27, 9527L, 201);
}
@Test
void diagram_byStranger_unpublished_forbidden() {
com.yunhai.platform.apistd.BizException e = assertThrows(
com.yunhai.platform.apistd.BizException.class,
() -> service.diagramImage(42, stranger()));
// 未发布通知的流程图与 Trace 同口径(三类人),stranger 404 语义
assertTrue(e.getMessage().contains("通知不存在或未发布"));
verify(flowClient, Mockito.never()).diagramImage(Mockito.any(), Mockito.any());
}
@Test
void diagram_byOwner_returnsBytes() {
when(flowClient.diagramImage(27, 9527L)).thenReturn(new byte[]{1, 2, 3});
byte[] png = service.diagramImage(42, owner());
assertTrue(png.length == 3);
}
}
@@ -37,6 +37,12 @@ class NoticeDetailReadTest {
noticeRepository = mock(NoticeRepository.class); noticeRepository = mock(NoticeRepository.class);
noticeReadRepository = mock(NoticeReadRepository.class); noticeReadRepository = mock(NoticeReadRepository.class);
service = new NoticeService(); service = new NoticeService();
ReflectionTestUtils.setField(service, "noticeUserMsgNotifier",
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository); ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository);
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class)); ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
@@ -0,0 +1,83 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeRead;
import com.yunhai.notice.repository.NoticeReadRepository;
import com.yunhai.notice.repository.NoticeReceiverRepository;
import com.yunhai.notice.repository.NoticeRepository;
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.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 回归锁(批次3 T5):ALL 型全员通知的阅读情况分组不得再抛
* {@link UnsupportedOperationException}。<br>
* 根因:receive_list=ALL 时 parseReceiveAccounts 返回不可变空表,
* 历史实现直接在其上追加已读人 → 抛异常(PC api 侧同样命中)。
* 修复 = 追加前 new ArrayList 拷贝;本测试锁死该行为,防回归。
*/
@DisplayName("getReadDetailGrouped - ALL 型通知回归锁")
class NoticeReadDetailGroupedAllTypeTest {
private NoticeRepository noticeRepository;
private NoticeReadRepository noticeReadRepository;
private NoticeReceiverRepository noticeReceiverRepository;
private NoticeService service;
@BeforeEach
void setUp() {
noticeRepository = mock(NoticeRepository.class);
noticeReadRepository = mock(NoticeReadRepository.class);
noticeReceiverRepository = mock(NoticeReceiverRepository.class);
service = new NoticeService();
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository);
ReflectionTestUtils.setField(service, "noticeReceiverRepository", noticeReceiverRepository);
}
private Notice allTypeNotice() {
Notice n = new Notice();
ReflectionTestUtils.setField(n, "id", 77);
n.setTitle("E2E直发");
n.setReceiveList("ALL");
n.setSchId(990001);
return n;
}
@Test
@DisplayName("ALL 型 + 已读一人:不再抛 UnsupportedOperationException,已读人入组")
void allTypeNotice_appendsReadersWithoutException() {
when(noticeRepository.findById(77)).thenReturn(Optional.of(allTypeNotice()));
NoticeRead read = new NoticeRead();
read.setReaderAccount("flow_liu");
read.setReaderName("刘老师(提交人)");
read.setReadTime(LocalDateTime.of(2026, 9, 27, 15, 14, 25));
when(noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(77))
.thenReturn(Collections.singletonList(read));
// ALL 型不落接收明细
when(noticeReceiverRepository.findByNoticeId(77)).thenReturn(Collections.emptyList());
List<Map<String, Object>> groups = assertDoesNotThrow(() -> service.getReadDetailGrouped(77));
// 用户中心未装配(单测环境)→ 归「其他」组;已读人必须在列且状态 read
assertEquals(1, groups.size());
assertEquals("其他", groups.get(0).get("GroupName"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> teas = (List<Map<String, Object>>) groups.get(0).get("teaslist");
assertEquals(1, teas.size());
assertEquals("flow_liu", teas.get(0).get("account"));
assertEquals("read", teas.get(0).get("states"));
}
}
@@ -41,6 +41,12 @@ class NoticeResubmitClearResultTest {
noticeRepository = mock(NoticeRepository.class); noticeRepository = mock(NoticeRepository.class);
noticeFlowService = mock(NoticeFlowService.class); noticeFlowService = mock(NoticeFlowService.class);
service = new NoticeService(); service = new NoticeService();
ReflectionTestUtils.setField(service, "noticeUserMsgNotifier",
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService); ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService);
ReflectionTestUtils.setField(service, "noticeReceiverRepository", ReflectionTestUtils.setField(service, "noticeReceiverRepository",
@@ -0,0 +1,176 @@
package com.yunhai.notice.service.msg;
import com.yunhai.notice.entity.Notice;
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
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 org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 通知 → 消息中心推送器单测(mock UserMsgClient/UserDirectory 缝,纯 Mockito 不起 Spring):
* 归一化/跳过语义/事务 afterCommit 时机/分片/容错/文案字段。
*/
class NoticeUserMsgNotifierTest {
private UserMsgClient client;
private UserDirectory directory;
private NoticeUserMsgProperties properties;
private NoticeUserMsgNotifier notifier;
@BeforeEach
void setUp() {
client = mock(UserMsgClient.class);
directory = mock(UserDirectory.class);
properties = new NoticeUserMsgProperties();
properties.setPushEnabled(true);
notifier = new NoticeUserMsgNotifier(client, directory, properties);
}
@AfterEach
void tearDown() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
private Notice notice(int id) {
Notice notice = new Notice();
ReflectionTestUtils.setField(notice, "id", id);
notice.setTitle("关于开展2026年秋季学期教学检查的通知");
notice.setPublisherName("刘文娟");
notice.setSchId(3);
return notice;
}
@Test
void publish_numericAccounts_sentAsUserIdsAfterCommit() {
when(directory.toUserId("10006")).thenReturn(10006);
when(directory.toUserId("10029")).thenReturn(10029);
TransactionSynchronizationManager.initSynchronization();
notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006", "10029"));
// 事务未提交:不应即时发送
verify(client, never()).sendUserMsg(any());
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCommit();
}
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
verify(client).sendUserMsg(captor.capture());
SendMsgRequest req = captor.getValue();
assertEquals(Arrays.asList(10006, 10029), req.getUserIds());
assertEquals("通知公告", req.getTitle());
assertTrue(req.getContent().contains("刘文娟") && req.getContent().contains("教学检查"));
assertEquals("notice", req.getBizType());
assertEquals("1", req.getBizId());
assertEquals(0, req.getMsgType());
assertEquals(1, req.getJumpType());
assertEquals("/list/detail?serid=1&type=1", req.getJumpPath());
assertEquals(3, req.getSchId());
assertTrue(req.getMsgNo().startsWith("ntc-pub-1-"));
}
@Test
void publish_disabled_noSend() {
properties.setPushEnabled(false);
notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006"));
verify(client, never()).sendUserMsg(any());
}
@Test
void publish_allReceivers_emptyAccounts_skipped() {
notifier.notifyPublishedAfterCommit(notice(1), Collections.emptyList());
verify(client, never()).sendUserMsg(any());
}
@Test
void publish_unresolvableAccount_skippedOthersStillSent() {
when(directory.toUserId("ghost")).thenReturn(null);
when(directory.toUserId("10006")).thenReturn(10006);
notifier.notifyPublishedAfterCommit(notice(2), Arrays.asList("ghost", "10006"));
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
verify(client).sendUserMsg(captor.capture());
assertEquals(Collections.singletonList(10006), captor.getValue().getUserIds());
}
@Test
void publish_allAccountsUnresolvable_noSend() {
when(directory.toUserId(any())).thenReturn(null);
notifier.notifyPublishedAfterCommit(notice(3), Arrays.asList("a", "b"));
verify(client, never()).sendUserMsg(any());
}
@Test
void publish_overThousandReceivers_chunkedWithDistinctMsgNo() {
List<String> accounts = IntStream.rangeClosed(1, 1005).mapToObj(String::valueOf).collect(Collectors.toList());
IntStream.rangeClosed(1, 1005).forEach(i -> when(directory.toUserId(String.valueOf(i))).thenReturn(i));
notifier.notifyPublishedAfterCommit(notice(4), accounts);
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
verify(client, org.mockito.Mockito.times(2)).sendUserMsg(captor.capture());
List<SendMsgRequest> sent = captor.getAllValues();
assertEquals(1000, sent.get(0).getUserIds().size());
assertEquals(5, sent.get(1).getUserIds().size());
assertEquals(500500, sent.get(0).getUserIds().stream().reduce(0, Integer::sum));
assertEquals(1001 + 1002 + 1003 + 1004 + 1005, sent.get(1).getUserIds().stream().reduce(0, Integer::sum));
// 同一 action 同秒:主体 msgNo 相同,分片加 -1/-2 后缀,保证 (appKey,msgNo) 不冲突
assertTrue(sent.get(0).getMsgNo().endsWith("-1"));
assertTrue(sent.get(1).getMsgNo().endsWith("-2"));
}
@Test
void publish_sendThrows_swallowed() {
when(directory.toUserId("10006")).thenReturn(10006);
doThrow(new RuntimeException("usermsg down")).when(client).sendUserMsg(any());
notifier.notifyPublishedAfterCommit(notice(5), Collections.singletonList("10006"));
verify(client).sendUserMsg(any());
}
@Test
void expedite_usesExpediteBizTypeAndMsgNo() {
when(directory.toUserId("10006")).thenReturn(10006);
notifier.notifyExpedited(notice(6), Collections.singletonList("10006"));
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
verify(client).sendUserMsg(captor.capture());
assertEquals("notice.expedite", captor.getValue().getBizType());
// Phase 2:细分分类(notice.expedite)不配基址键,靠归属声明 jumpApp=notice 命中应用键
assertEquals("notice", captor.getValue().getJumpApp());
assertEquals("/list/detail?serid=6&type=1", captor.getValue().getJumpPath());
assertTrue(captor.getValue().getMsgNo().startsWith("ntc-exp-6-"));
}
@Test
void publish_noTransactionContext_sendsImmediately() {
when(directory.toUserId("10006")).thenReturn(10006);
notifier.notifyPublishedAfterCommit(notice(7), Collections.singletonList("10006"));
verify(client).sendUserMsg(any());
}
}
@@ -1,12 +1,14 @@
package com.yunhai.notice.support; package com.yunhai.notice.support;
import com.yunhai.platform.utils.crypto.SignUtil; import com.yunhai.platform.utils.crypto.SignUtil;
import org.apache.http.impl.client.HttpClientBuilder;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
/** /**
@@ -15,6 +17,13 @@ import org.springframework.web.client.RestTemplate;
*/ */
public class OapiSignedClient { public class OapiSignedClient {
static {
// JDK8 安全加固后:POST 带流式 body 收到 401 会抛 HttpRetryException
// ("cannot retry due to server authentication, in streaming mode") 而非返回 401 响应,
// 期望 401 的用例因此失败(依 JDK 小版本而异)。换 Apache HttpClient 的 request factory
//(httpclient 已在类路径),401 按普通响应返回。
}
private final String baseUrl; private final String baseUrl;
private final String appKey; private final String appKey;
private final String appSecret; private final String appSecret;
@@ -24,7 +33,8 @@ public class OapiSignedClient {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.appKey = appKey; this.appKey = appKey;
this.appSecret = appSecret; this.appSecret = appSecret;
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(
HttpClientBuilder.create().build());
factory.setConnectTimeout(3000); factory.setConnectTimeout(3000);
factory.setReadTimeout(5000); factory.setReadTimeout(5000);
this.restTemplate = new RestTemplate(factory); this.restTemplate = new RestTemplate(factory);