Compare commits

...
11 Commits
Author SHA1 Message Date
ops 687bb65b3a fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:27 +00:00
ops 9347ddb409 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:59 +00:00
chenxing 7c5391458c [v2.1] B 审批代理补齐:增 ByBiz/RecordUrl/TaskPageUrl 端点,Approve 透传 edgeName;身份仍全取 CAS 会话前端不可传;补 Controller/Client 单测
build / build-push (push) Successful in 1m17s
2026-10-19 16:55:41 +08:00
chenxing 986a875279 [ysch-flow] S3.3~S3.5 notice 迁移试点:手写 Handler 换声明式 Adapter
build / build-push (push) Successful in 1m45s
事件→状态机推进改由 NoticeFlowBizAdapter 一次声明、starter 的 FlowBizCallbackBridge 执行;
纪律逐条等价搬运(行锁读单、未绑流程/单据不存在/脏 bizId 吞掉回 200、last_instance_version
乱序二道门、audit_status 终态不回退与重复终态不重跑副作用、抄送逐人留痕)。落库换成新增
窄列方法 updateAuditFromFlow/updateAuditResultFromFlow/markPublished,不再整实体 save
(apply 回填与并发回调互打的 E2E 教训);通过即发布 + 短信后置留在 onPassed 钩子,
未到定时点不发由 SmsCatchupJob 补发的口径不变。抄送按 F7 显式开镜像(本应用有自家列表)。

发起编排上交 FlowSubmitTemplate(AFTER_COMMIT/独立线程/串行/duplicated 按成功/失败静默
保持待提交/bizUrl 按 detail-route 自动拼/变量契约预检),出站仍走本应用 FlowClient 以保住
enabled=false 降级与 legacy base-url 回落;守卫在异步路径由 spec.preflight 于出站线程判定。
attempt 归属核对刻意未接(notice bizId 无 attempt 段,接上会丢首轮 submitted,保守处理)。

NoticeFlowCallbackHandler 只摘 @Service 保留代码作回退绳:恢复=加回 @Service + 删 yaml
的 yh.flow-biz 块(两者互斥,上下文已有手写 Handler 时桥不装配)。
mvn -o test 全绿:142 例(含新增 NoticeFlowBizAdapterTest 16 例等价性断言与原 21 例手写
Handler 用例);受影响 3 个测试的构造改用与生产同源的 FlowSubmitTemplates.forTests。
2026-10-17 23:44:35 +08:00
chenxing d0af9d481c [ysch-flow] S3.1~S3.2 接入 ysch-flow starter 并声明 yh.flow-biz 语义块
依赖不写版本(BOM 托管);存量 callback 件显式声明保留(回退绳类仍需其 SPI 类型)。
yaml 增 biz-type=notice、四态码 10/20/30/40(取 FlowAuditStatusEnum,0=待提交是本应用
特有暂态不入映射)、variables=[deptId,deptName]、detail-route=/list/detail?serid={bizId}
(与迁移前 JumpTarget 产物逐字节相同)。密钥段一字未动。
另修一处与本次无关的环境断点:appearance 件只由 feat/appearance-sdk 分支的 BOM 托管,
本分支 BOM 无该条目,notice/svc-hall/UserBase 均解析失败;给 notice 自己的依赖补显式
1.0.0-SNAPSHOT(jar 已在本地仓库),合流后应删本行回落 BOM。
2026-10-17 23:44:18 +08:00
chenxing 414dbfc2b2 外观SDK化任务E:本地 AppearanceProxyController 下沉平台 starter
build / build-push (push) Successful in 1m1s
删 Notice 本地哑管道(试点范本已完成使命),加 yh-appearance-spring-boot-starter
(BOM 管版本,YschOpenCall 走聚合包)。端点同 URL /api/appearance 同语义:三态
proxy/local/off,503 直写铁律内置(0721cf3 教训)。工作区他人 WIP(NoticeRead*
)未混入,择迹提交。
2026-10-12 09:39:10 +08:00
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
29 changed files with 1858 additions and 161 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl(alpine) - name: 安装 git / curl(alpine)
run: apk add --no-cache git curl run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存) - name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: | run: |
set -e set -e
docker rm -f mavenbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name mavenbuild \ # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \ -v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/ docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml" docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
run: | run: |
+22
View File
@@ -119,6 +119,19 @@
<groupId>com.yunhai.platform</groupId> <groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId> <artifactId>yh-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 经 YschOpenCall
代理 UserHome /oapi/appearance,userhome 地址走平台 application-platform.yml 兜底)。
平台件不进聚合包:本地同名控制器已删(2026-10-12 任务 E 收敛,starter 端点同 URL 同语义,
503 直写铁律已内置) -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
<!-- 显式版本(2026-10-17 过夜任务环境修复):该件由 BasePlatform 的 feat/appearance-sdk
分支引入并只在该分支的 yh-platform-dependencies 里托管;本次接入件工作基于
feat/flow-embed(BOM 无此条目),按 BOM 解析会缺版本导致整仓编译断。jar 本体已在本地仓库,
补版本号即可恢复。appearance 分支合流后应删掉本行回到 BOM 托管。 -->
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- 流程中心回调接收端 starter(2026-09-20 迁移):验签外置(/oapi/* 平台验签), <!-- 流程中心回调接收端 starter(2026-09-20 迁移):验签外置(/oapi/* 平台验签),
接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler --> 接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler -->
@@ -127,6 +140,15 @@
<artifactId>yh-flow-callback-spring-boot-starter</artifactId> <artifactId>yh-flow-callback-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- Tier 3 语义接入件迁移试点(2026-10-17 ysch-flow 最小闭环 T3):事件→状态声明化 +
发起编排模板 + 变量契约预检 + bizUrl 自动装配。它聚合传递上面 callback 件与 openapi 件,
本处保留 callback 显式声明(NoticeFlowCallbackHandler 作为回退绳仍在编译期需要其 SPI 类型)。
业务映射见 NoticeFlowBizAdapter,版本由 yh-platform-dependencies BOM 管理。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>ysch-flow-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);
}
}
@@ -1,64 +0,0 @@
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;
}
}
}
@@ -1,7 +1,11 @@
package com.yunhai.notice.controller.api; package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config; import com.yunhai.notice.config.Config;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.flow.NoticeApprovalClient; import com.yunhai.notice.service.flow.NoticeApprovalClient;
import com.yunhai.notice.service.flow.NoticeFlowEmbedService;
import com.yunhai.notice.service.flow.NoticeFlowGuard;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
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;
@@ -15,15 +19,19 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 应用内审批(第三步 T-C4):"我的待审核"数据面。前端不直调 oapi—— * 应用内审批(第三步 T-C4):"我的待审核"数据面 + v2.1 业务详情卡片/记录/完整办理换址。
* operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段, * 前端不直调 oapi——operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段,
* 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。 * 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。
* <ul> * <ul>
* <li>GET Approval/Todo:待办分页(bizType=notice 固定);</li> * <li>GET Approval/Todo:待办分页(bizType=notice 固定);</li>
* <li>GET Approval/Instances:办件分页(status 可选);</li> * <li>GET Approval/Instances:办件分页(status 可选);</li>
* <li>GET Approval/ByBiz:当前人在该通知上的待办(0/1/N,卡片据此决定可写还是只读);</li>
* <li>GET Approval/RecordUrl:审批记录抽屉地址(token 只读页,复用 {@link NoticeFlowEmbedService});</li>
* <li>GET Approval/TaskPageUrl:完整办理任务页地址(先确认 taskId 属于当前人待办);</li>
* <li>POST Approval/Approve / Reject:办理(body 仅 opinion/edgeName)。</li> * <li>POST Approval/Approve / Reject:办理(body 仅 opinion/edgeName)。</li>
* </ul> * </ul>
*/ */
@@ -33,6 +41,9 @@ import java.util.Map;
public class NoticeApprovalController { public class NoticeApprovalController {
private final NoticeApprovalClient approvalClient; private final NoticeApprovalClient approvalClient;
private final NoticeFlowEmbedService embedService;
private final NoticeRepository noticeRepository;
private final NoticeFlowGuard noticeFlowGuard;
/** 我的待审核(列表页数据源) */ /** 我的待审核(列表页数据源) */
@GetMapping("Todo") @GetMapping("Todo")
@@ -72,6 +83,79 @@ public class NoticeApprovalController {
body == null ? null : body.get("opinion")); body == null ? null : body.get("opinion"));
} }
/**
* 当前人在该通知上的待办(v2.1 卡片数据源)。身份只取会话;{@code serid} 先过与记录页
* 同一套可见性守卫(未发布通知仅提交人/管理员/办理人/被抄送人可见)。
* <p>出参 {@code tasks} 的 0/1/N 由前端据此决定"只读 / 可办理 / 多个待办引导去列表",
* 本端不猜选、不合并。办件不存在与"有办件但不是我的待办"同形(空 tasks),不用于探测。
*/
@GetMapping("ByBiz")
public Map<String, Object> byBiz(@RequestParam("serid") Integer serid, BoaUser user) {
Operator op = requireOperator(user);
requireVisible(serid, user);
return approvalClient.byBiz(op.schId, op.userId, serid);
}
/**
* 审批记录抽屉地址(token 只读页):直接复用 {@link NoticeFlowEmbedService#embedUrl},
* 那里已做通知存在性 + 未发布可见性 + "尚未进入流程"三重校验,且 token 现取现发不缓存。
* 与 {@code /api/notice/flow/Embed} 同一条实现,避免两个入口对同一 Drawer 漂移出不同守卫口径。
*/
@GetMapping("RecordUrl")
public Map<String, String> recordUrl(@RequestParam("serid") Integer serid, BoaUser user) {
requireOperator(user);
return embedService.embedUrl(serid, user);
}
/**
* 完整办理任务页地址(CAS 会话页,无 token)。
* <p>先打 byBiz 确认 {@code taskId} 确实在<b>当前人</b>该通知的待办里才签发——任务页是写面,
* 顺手签发一个别人的 taskId 等于替他人挂一个办理入口。flow-center 侧仍会按 assigneeId
* 逐字复核,本处只是不把无效地址散播到前端。
*/
@GetMapping("TaskPageUrl")
public Map<String, Object> taskPageUrl(@RequestParam("serid") Integer serid,
@RequestParam("taskId") Long taskId, BoaUser user) {
Operator op = requireOperator(user);
requireVisible(serid, user);
if (!isMyPendingTask(serid, taskId, op)) {
throw new BizException(ErrorCode.FORBIDDEN, "该任务不是你在本通知上的待办");
}
return approvalClient.taskPageUrl(op.schId, taskId);
}
/** byBiz 出参里是否含该 taskId(对端 tasks 元素为 {taskId,nodeId,nodeName,arriveTime,outlets}) */
@SuppressWarnings("unchecked")
private boolean isMyPendingTask(Integer serid, Long taskId, Operator op) {
Map<String, Object> data = approvalClient.byBiz(op.schId, op.userId, serid);
Object tasks = data == null ? null : data.get("tasks");
if (!(tasks instanceof List)) {
return false;
}
for (Object row : (List<Object>) tasks) {
if (row instanceof Map) {
Object id = ((Map<String, Object>) row).get("taskId");
if (id instanceof Number && taskId.equals(((Number) id).longValue())) {
return true;
}
}
}
return false;
}
/**
* 通知可见性守卫(口径与 {@link NoticeFlowEmbedService#embedUrl} 逐字一致):存在性 +
* 未发布(status!=1)走 {@link NoticeFlowGuard}。已发布通知照常放行(全校可见,审批数据面
* 只对办理人出 tasks)。
*/
private void requireVisible(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);
}
}
/** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */ /** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */
private static Operator requireOperator(BoaUser user) { private static Operator requireOperator(BoaUser user) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == 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);
}
}
@@ -40,6 +40,36 @@ public interface NoticeRepository extends JpaRepository<Notice, Integer>,
int markApproving(@org.springframework.data.repository.query.Param("id") Integer id, int markApproving(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("instanceId") Long instanceId); @org.springframework.data.repository.query.Param("instanceId") Long instanceId);
/**
* 回调推进在途态的窄列更新(2026-10-17 ysch-flow Adapter.persist 落点):只写 audit_status +
* last_instance_version,绝不整实体 save——同事务里 apply 返回的 markApproving 与并发推送
* (E2E 教训:整实体盲写会把已落定的 20 打回 10 并覆盖结果快照)。
*/
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
"update Notice n set n.auditStatus = :status, n.lastInstanceVersion = :version where n.id = :id")
int updateAuditFromFlow(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("status") Integer status,
@org.springframework.data.repository.query.Param("version") Integer version);
/** 回调落终态:审核码 + 版本 + 结果明细快照(flow_result_json,详情页进度卡片数据源) */
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
"update Notice n set n.auditStatus = :status, n.lastInstanceVersion = :version, "
+ "n.flowResultJson = :result where n.id = :id")
int updateAuditResultFromFlow(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("status") Integer status,
@org.springframework.data.repository.query.Param("version") Integer version,
@org.springframework.data.repository.query.Param("result") String result);
/** 审核通过=正式发布(status 0→1,用户端自此可见);窄列更新,与审核码同事务但分行 */
@org.springframework.transaction.annotation.Transactional
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query("update Notice n set n.status = 1 where n.id = :id")
int markPublished(@org.springframework.data.repository.query.Param("id") Integer id);
/** /**
* 短信发送原子认领(§3.2 防重复核心):置 sms_sent_at,仅当当前为 NULL(未发)时生效。 * 短信发送原子认领(§3.2 防重复核心):置 sms_sent_at,仅当当前为 NULL(未发)时生效。
* 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。 * 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。
@@ -72,6 +72,41 @@ public class NoticeApprovalClient {
return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class); return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class);
} }
/**
* 当前人在本通知上的待办(v2.1 业务详情卡片数据源,{@code /oapi/flow/tasks/by-biz})。
* <p>返回 Map 即对端 {@code ByBizTasksVo} JSON:{@code tasks[]} 0/1/N 三种计数都走 HTTP 200,
* <b>调用方不得据 tasks 反推"这个通知上有没有别人的待办"</b>——办件不存在、错学校、错 bizType、
* 非办理人四种情形出参完全同形(都是空 tasks),这是对端的防探测设计,本端不要试图区分。
* <p>{@code currentNodes} 是节点 <b>id</b> 逗号串(办件原文口径),人读名称取 {@code tasks[].nodeName}。
*/
@SuppressWarnings("unchecked")
public Map<String, Object> byBiz(Integer schId, Integer operatorId, Integer noticeId) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("bizType", FlowCenterClient.BIZ_TYPE);
query.put("bizId", String.valueOf(noticeId));
query.put("operatorId", String.valueOf(operatorId));
return openCall.get(TARGET, "/oapi/flow/tasks/by-biz", query, Map.class);
}
/**
* 完整办理任务页地址({@code /oapi/flow/embed/url?mode=task}):返回 CAS 会话页
* {@code {front}/#/task/{taskId}},<b>无 token</b>(写面,办理权由该页加载时的用户面接口按
* assigneeId 复核)。仍走 {@link YschOpenCall} 泛化调用——nexus 已发布快照的
* {@code YschFlowOpenApi} 只有 embedUrl/todoTasks/instances,没有 taskPageUrl 门面方法,
* 而本批纪律是不动 BasePlatform。SDK 补齐后可平替(URL 不变,测试锁死)。
*/
@SuppressWarnings("unchecked")
public Map<String, Object> taskPageUrl(Integer schId, Long taskId) {
requireEnabled();
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId));
query.put("mode", "task");
query.put("taskId", String.valueOf(taskId));
return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class);
}
/** 同意(opinion 可空;多出口 edgeName 透传) */ /** 同意(opinion 可空;多出口 edgeName 透传) */
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName, public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
String opinion, String edgeName) { String opinion, String edgeName) {
@@ -0,0 +1,183 @@
package com.yunhai.notice.service.flow;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeCc;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.repository.NoticeCcRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.sms.SmsSendService;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import com.yunhai.platform.yschflow.FlowApplyGateway;
import com.yunhai.platform.yschflow.FlowBizAdapter;
import com.yunhai.platform.yschflow.FlowBizIds;
import com.yunhai.platform.yschflow.FlowBizProperties;
import com.yunhai.platform.yschflow.FlowStateUpdate;
import com.yunhai.platform.yschflow.FlowSubmitTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 通知侧语义接入声明(2026-10-17 ysch-flow T3 迁移试点):把 {@code NoticeFlowCallbackHandler}
* 手写版的审核状态机推进改成一次声明,纪律(乱序/终态不回退/脏消息/抄送)由 starter 内置。
*
* <p><b>语义逐条对应(迁移即等价,不改行为)</b>:
* <ul>
* <li>行锁串行化:loader 用 {@code findByIdForUpdate}(并发推送排队,防丢更新);</li>
* <li>未绑流程/单据不存在:loader 返回 null → starter 按确定性脏消息吞掉(回 200 不重推);</li>
* <li>单据级乱序二道门:{@code recordInstanceVersion=last_instance_version};</li>
* <li>终态不回退:{@code status=audit_status},starter 判定后终态行的非终态/重复/冲突事件一律忽略;</li>
* <li>窄列原子更新:persist 走 {@code updateAuditFromFlow/updateAuditResultFromFlow},
* 绝不整实体 save(E2E 教训:apply 返回的 markApproving 与回调并发,盲写会把已落定的 20 打回 10);</li>
* <li>审核通过=正式发布 + 短信后置:onPassed 钩子(status 非 1 才置 1 并补发短信,
* 未到定时点不发——由 SmsCatchupJob 到点补发,BP-R5-2 口径);</li>
* <li>抄送留痕:notice 有自家「抄送我的」列表,故显式 {@code onCc} 镜像到 notice_cc
* (F7 默认不镜像,本应用属"确需自家展示才开 mirror"的那一类)。</li>
* </ul>
*
* <p><b>刻意未接的一项</b>:{@code recordInstanceId}/{@code recordBizId} 不接线——notice 的
* bizId 恒为 {@code notice.id}(无 attempt 段),迁移前也不做实例归属核对;接上会让"apply 已成功、
* markApproving 未提交"窗口内的首轮 submitted 事件被丢弃。按《任务书》§0.5 保守方向(少改、
* 语义等价)保留原判定面,attempt 归属留给 notice 引入重提换 attempt 的那一版再开。</p>
*
* <p>回退绳:删掉 yaml 的 {@code yh.flow-biz} 块并给 {@code NoticeFlowCallbackHandler} 加回
* {@code @Service},即回到 2026-09-20 的手写 Handler 形态(两者互斥:上下文里已存在手写
* FlowCallbackHandler 时 starter 的桥不装配)。</p>
*/
@Slf4j
@Configuration
public class NoticeFlowBizAdapter {
@Bean
public FlowBizAdapter<Notice> noticeFlowBiz(NoticeRepository noticeRepository,
NoticeCcRepository noticeCcRepository,
SmsSendService smsSendService,
FlowBizProperties properties) {
return FlowBizAdapter.<Notice>builder()
.bizType(FlowCenterClient.BIZ_TYPE)
// 状态码以 yaml yh.flow-biz.states 为单一真源(避免两处码表)
.states(properties.resolveStates())
.loader(bizId -> loadForUpdate(noticeRepository, bizId))
.status(Notice::getAuditStatus)
.recordInstanceVersion(Notice::getLastInstanceVersion)
.persist((notice, update) -> persistAudit(noticeRepository, notice, update))
.onPassed((notice, event) -> publishAndSms(noticeRepository, smsSendService, notice))
.onCc((notice, event) -> recordCc(noticeCcRepository, notice, event))
.build();
}
/**
* 发起编排模板:出站仍走本应用既有 {@link FlowClient}(保留 enabled=false 降级与
* legacy {@code notice.flow.base-url} 回落两条现状语义),AFTER_COMMIT/独立线程/串行/
* duplicated 按成功等编排纪律交给 starter。
*/
@Bean
public FlowSubmitTemplate flowSubmitTemplate(FlowBizProperties properties, FlowClient flowClient,
ObjectProvider<FlowCallbackRegistrar> registrarProvider) {
return new FlowSubmitTemplate(properties, gateway(flowClient), null, registrarProvider);
}
/**
* 出站网关:starter 组装的请求体转投本应用既有 {@link FlowClient}(保留 enabled=false 降级与
* legacy base-url 回落),公开供单测复用同一根线(测试替身与生产装配零分叉)。
*/
public static FlowApplyGateway gateway(FlowClient flowClient) {
return new FlowApplyGateway() {
@Override
public com.yunhai.platform.openapi.flow.model.FlowApplyResult apply(long schId, FlowApplyBody body) {
return flowClient.apply(Integer.valueOf((int) schId), toNoticeBody(body));
}
};
}
/** starter 组装的请求体转成本应用契约类型(字段逐项照搬,Notice 未用发起时指派,assigns 不透传) */
private static FlowModels.ApplyBody toNoticeBody(FlowApplyBody source) {
FlowModels.ApplyBody target = new FlowModels.ApplyBody();
target.setFlowGuid(source.getFlowGuid());
target.setBizType(source.getBizType());
target.setBizId(source.getBizId());
target.setBizTitle(source.getBizTitle());
target.setBizUrl(source.getBizUrl());
target.setBizSnapshot(source.getBizSnapshot());
target.setSubmitterId(source.getSubmitterId());
target.setSubmitterName(source.getSubmitterName());
target.setFormData(source.getFormData());
return target;
}
/** 行锁读单据;bizId 非法 / 通知不存在 / 未绑流程 → null(确定性丢弃,回 200) */
private static Notice loadForUpdate(NoticeRepository repository, String bizId) {
Integer noticeId = FlowBizIds.asInteger(bizId);
if (noticeId == null) {
throw new IllegalArgumentException("bizId 非通知主键:" + bizId);
}
Notice notice = repository.findByIdForUpdate(noticeId).orElse(null);
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
return null;
}
return notice;
}
private static void persistAudit(NoticeRepository repository, Notice notice, FlowStateUpdate update) {
Integer version = update.getInstanceVersion() == null
? notice.getLastInstanceVersion() : update.getInstanceVersion();
if (update.isTerminal()) {
repository.updateAuditResultFromFlow(notice.getId(), update.getCode(), version, update.getSnapshotJson());
} else {
repository.updateAuditFromFlow(notice.getId(), update.getCode(), version);
}
}
/** 审核通过=正式发布:置 status=1(用户端自此可见)+ 短信后置(只在本次真的从未发布→已发布时补发) */
private static void publishAndSms(NoticeRepository repository, SmsSendService smsSendService, Notice notice) {
boolean justPublished = notice.getStatus() == null
|| notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode();
if (!justPublished) {
return;
}
repository.markPublished(notice.getId());
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);
}
/**
* 抄送留痕(逐人一行,不动审核状态机):data 缺 ccUserId 属上游脏数据,记日志跳过(回 200),
* 落库异常照旧外抛回 5xx 重试。重放由 starter 的 eventId 幂等拦截。
*/
private static void recordCc(NoticeCcRepository ccRepository, 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;
}
NoticeCc cc = new 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());
ccRepository.save(cc);
log.info("[notice-flow] 抄送留痕:notice={} instance={} ccUser={} node={}",
notice.getId(), event.getInstanceId(), cc.getCcUserId(), cc.getNodeName());
}
}
@@ -9,7 +9,6 @@ import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent; import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -24,9 +23,14 @@ import java.util.Map;
* 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。 * 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。
* <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账); * <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
* 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。 * 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。
*
* <p><b>2026-10-17 T3 迁移:bean 已停用,代码原样保留作回退绳。</b>状态机推进改由
* {@link NoticeFlowBizAdapter} 声明、starter 的 {@code FlowBizCallbackBridge} 执行。
* 恢复本形态只需两步(语义与迁移前逐字节一致):① 给本类加回 {@code @Service}(连同其 import);
* ② 删除 yaml 的 {@code yh.flow-biz} 块——两者互斥(上下文已存在手写 FlowCallbackHandler 时
* starter 的桥不装配),故不会双写。</p>
*/ */
@Slf4j @Slf4j
@Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class NoticeFlowCallbackHandler implements FlowCallbackHandler { public class NoticeFlowCallbackHandler implements FlowCallbackHandler {
@@ -14,15 +14,14 @@ 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.base.YschBaseOpenApi;
import com.yunhai.platform.openapi.core.JumpTarget; import com.yunhai.platform.yschflow.FlowApplySpec;
import com.yunhai.platform.yschflow.FlowSubmitTemplate;
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;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
@@ -46,54 +45,25 @@ public class NoticeFlowService {
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 com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
private final CurrentBoaUser currentBoaUser; private final CurrentBoaUser currentBoaUser;
private final NoticeFlowGuard noticeFlowGuard; private final NoticeFlowGuard noticeFlowGuard;
private final FlowSubmitTemplate flowSubmitTemplate;
/** /**
* 注册后事务提交了再提交审核(D9:apply 出站不进 DB 事务,防 HTTP 长调用拖住连接); * 提交审核(D9 AFTER_COMMIT 出站,编排纪律交 {@link FlowSubmitTemplate},2026-10-17 T3 迁移):
* 无活动事务时立即执行(测试/兜底)。 * 出站动作在事务提交后的独立线程执行,提交人身份与单据快照在请求线程先取好。
* <p>E2E 实测教训:afterCommit 回调若留在原请求线程执行,OSIV 绑定的 EntityManager * <p>E2E 实测教训(迁移前本类自带线程池,语义不变):afterCommit 回调若留在原请求线程执行,
* 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行)——故提交动作 * OSIV 绑定的 EntityManager 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行);
* 一律切到独立线程(干净事务 + 无 ThreadLocal 残留),提交人身份在请求线程先捕获透传。 * 「能不能提交」的守卫也必须在提交后的状态上判定,故随 spec 的 preflight 一起搬到出站线程。</p>
*/ */
public void submitAfterCommit(Integer noticeId) { public void submitAfterCommit(Integer noticeId) {
final BoaUser user = currentUser(); final BoaUser user = currentUser();
Runnable task = () -> trySubmitQuietly(noticeId, user); final Notice notice = requireNotice(noticeId);
if (TransactionSynchronizationManager.isSynchronizationActive()) { flowSubmitTemplate.submitAfterCommit(noticeId,
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { specOf(notice, user).preflight(() -> checkSubmittable(notice, user)));
@Override
public void afterCommit() {
SUBMIT_EXECUTOR.submit(task);
}
});
} else {
task.run();
}
} }
/** 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底) */ /** 提交/重提审核(幂等入口:编辑页重提按钮);失败当场抛业务异常给调用方 */
private static final java.util.concurrent.ExecutorService SUBMIT_EXECUTOR =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "notice-flow-submit");
t.setDaemon(true);
return t;
});
/** 提交失败静默保持 0 待提交(行已落库不回滚;列表"待提交"态即提示,编辑页可重推) */
private void trySubmitQuietly(Integer noticeId, BoaUser user) {
try {
trySubmitFlow(noticeId, user);
} catch (Exception e) {
log.warn("[notice-flow] 提交审核失败(保持待提交,可重推):notice={} {}", noticeId, e.toString());
}
}
/**
* 提交/重提审核(幂等入口:编辑页重提按钮 / create 后置共用)。
* audit=0/30/40 可提交;10 审批中拒绝;apply 成功回填 flow_instance_id + audit=10。
* apply 失败抛业务异常,notice 行保持 0 待提交(不回滚已落库的编辑内容)。
*/
public void trySubmitFlow(Integer noticeId) { public void trySubmitFlow(Integer noticeId) {
trySubmitFlow(noticeId, currentUser()); trySubmitFlow(noticeId, currentUser());
} }
@@ -101,6 +71,15 @@ public class NoticeFlowService {
/** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */ /** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */
public void trySubmitFlow(Integer noticeId, BoaUser user) { public void trySubmitFlow(Integer noticeId, BoaUser user) {
Notice notice = requireNotice(noticeId); Notice notice = requireNotice(noticeId);
checkSubmittable(notice, user);
flowSubmitTemplate.submit(noticeId, specOf(notice, user));
}
/**
* 提交前置校验(同步入口当场抛、异步入口由 spec.preflight 在出站线程抛后静默保持待提交):
* BP-R5-3 三分支守卫 + 未绑流程 + 审批中禁止重复提交。
*/
private void checkSubmittable(Notice notice, BoaUser user) {
// BP-R5-3:Trace/TrySubmit 原先仅验存在性,任意登录用户可对他人通知触发重提——补 // BP-R5-3:Trace/TrySubmit 原先仅验存在性,任意登录用户可对他人通知触发重提——补
// NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐 // NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐
noticeFlowGuard.requireUnpublishedVisible(notice, user); noticeFlowGuard.requireUnpublishedVisible(notice, user);
@@ -111,47 +90,34 @@ public class NoticeFlowService {
if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) { if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) {
throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回"); throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回");
} }
ensureRegistered(notice);
FlowModels.ApplyBody body = buildApplyBody(notice, user);
FlowModels.ApplyResultVo result = flowClient.apply(notice.getSchId(), body);
// E2E 实测教训:此处曾整实体 save——apply 出站期间回调事件可能已并发推进
// (submitted 先行置 10 / completed 置 20),旧快照盲写会把状态打回;改为窄列原子更新,
// 且 audit=20(已通过)不回退(apply 返回晚于 completed 送达的合法场景)
noticeRepository.markApproving(noticeId, result.getInstanceId());
log.info("[notice-flow] 提交审核成功:notice={} instance={} duplicated={}",
noticeId, result.getInstanceId(), result.isDuplicated());
} }
/** /**
* 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}): * 发起编排入参(契约冻结:bizId=notice.id、bizType=notice;bizUrl 不再手工传,
* 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。<b>绝不重复调用</b>—— * 由 starter 按 {@code yh.flow-biz.detail-route} 拼装,产物与迁移前的
* flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。 * {@code JumpTarget.page("list/detail").param("serid", id)} 逐字节相同)。
* 标题/表单/快照都取自请求线程读到的已提交实体值,出站前不落库。
*/ */
void ensureRegistered(Notice notice) { private FlowApplySpec specOf(Notice notice, BoaUser user) {
callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid()); return FlowApplySpec.of(String.valueOf(notice.getId()), notice.getFlowGuid(),
() -> notice.getTitle(), () -> buildFormData(notice), () -> buildSnapshot(notice))
.bizType(FlowCenterClient.BIZ_TYPE)
.schId(notice.getSchId() == null ? null : Long.valueOf(notice.getSchId()))
.submitter(user == null ? null : user.getId(),
user == null ? null : (StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount()))
// E2E 实测教训:apply 出站期间回调可能已并发推进,回填必须是窄列原子 UPDATE,
// 且 audit=20(已通过)不回退——守卫在 markApproving 的 case-when 里
.onApplied((instanceId, duplicated) -> noticeRepository.markApproving(notice.getId(), instanceId))
.onSubmitError((bizKey, error) -> log.warn("[notice-flow] 提交审核失败(保持待提交,可重推):"
+ "notice={} {}", bizKey, String.valueOf(error)));
} }
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl=裸相对路径、快照只收摘要不收富文本正文; /** formData 键与变量契约同源(yh.flow-biz.variables=[deptId, deptName],发送前由 starter 本地预检) */
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径。 private Map<String, Object> buildFormData(Notice notice) {
* bizUrl 自 2026-09-26 起为裸相对路径(不含域名/ctx/#,接收方按页面归属拼绝对地址),
* Notice 不再需要配置自己的前端基址(自指地址优化 Phase 2) */
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
body.setFlowGuid(notice.getFlowGuid());
body.setBizType(FlowCenterClient.BIZ_TYPE);
body.setBizId(String.valueOf(notice.getId()));
body.setBizTitle(notice.getTitle());
body.setBizJump(JumpTarget.page("list/detail").param("serid", notice.getId()));
body.setBizSnapshot(buildSnapshot(notice));
Map<String, Object> formData = new LinkedHashMap<>(); Map<String, Object> formData = new LinkedHashMap<>();
formData.put("deptId", notice.getDeptId()); formData.put("deptId", notice.getDeptId());
formData.put("deptName", notice.getDeptName()); formData.put("deptName", notice.getDeptName());
body.setFormData(formData); return formData;
if (user != null) {
body.setSubmitterId(user.getId());
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
}
return body;
} }
/** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */ /** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */
@@ -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";
@@ -707,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)) {
+16 -1
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
@@ -115,6 +119,17 @@ yh:
# ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback # ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback
# base-path 可不填(运行时会自证出与真实 ctx 同源的值;显式配置仅作覆盖/逃生口保留)。 # base-path 可不填(运行时会自证出与真实 ctx 同源的值;显式配置仅作覆盖/逃生口保留)。
# 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。 # 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
flow-biz:
# ysch-flow 语义接入件(2026-10-17 T3 迁移试点):装配总闸是本键——删掉本块即整体不装配,
# NoticeFlowCallbackHandler 回退绳随之复位(两者互斥,同一上下文只允许一个 FlowCallbackHandler)。
biz-type: notice
# 四语义态 → audit_status 码表(FlowAuditStatusEnum;0=待提交是 Notice 特有暂态,不在映射内)
states: {approving: 10, passed: 20, rejected: 30, withdrawn: 40}
# 变量契约:条件路由表达式取的两个键(发起前本地预检 + 启动自检依据;与 NoticeFlowService.buildFormData 同口径)
variables: [deptId, deptName]
# bizUrl 自动装配模式:与迁移前 JumpTarget.page("list/detail").param("serid", id) 的产物
# 逐字节相同(/list/detail?serid=27,裸相对路径,绝对化由接收方按页面归属拼)
detail-route: /list/detail?serid={bizId}
open: open:
# 四域服务目录零配置:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx # 四域服务目录零配置:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
@@ -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,279 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.flow.NoticeApprovalClient;
import com.yunhai.notice.service.flow.NoticeFlowEmbedService;
import com.yunhai.notice.service.flow.NoticeFlowGuard;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
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.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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* v2.1 应用内审批代理端点单测(ByBiz / RecordUrl / TaskPageUrl)。
* <p>本类的存在意义全在"身份与地址"这两条纪律上:
* <ul>
* <li>身份只能来自 CAS 会话(BoaUser),请求参数里没有 operatorId/schId 的位置——
* 断言的是"传给出站客户端的正是会话里的值",而不是"参数被透传";</li>
* <li>任务页是写面:taskId 不在当前人待办里就不签发地址(宁可前端拿不到按钮,
* 也不发一个能点到别人办理页的地址)。</li>
* </ul>
*/
class NoticeApprovalControllerTest {
private static final int SCH = 27;
private static final int USER_ID = 10006;
private NoticeApprovalClient approvalClient;
private NoticeFlowEmbedService embedService;
private NoticeRepository noticeRepository;
private NoticeFlowGuard noticeFlowGuard;
private NoticeApprovalController controller;
@BeforeEach
void setUp() {
approvalClient = mock(NoticeApprovalClient.class);
embedService = mock(NoticeFlowEmbedService.class);
noticeRepository = mock(NoticeRepository.class);
noticeFlowGuard = mock(NoticeFlowGuard.class);
controller = new NoticeApprovalController(approvalClient, embedService, noticeRepository, noticeFlowGuard);
}
private BoaUser user() {
BoaUser user = new BoaUser();
user.setId(USER_ID);
user.setAccount("liuwenjuan");
user.setName("刘文娟");
user.setType(0);
BoaRoleInfo role = new BoaRoleInfo();
role.setSchId(SCH);
user.setCurrentRole(role);
return user;
}
private void stubNotice(Integer serid, Integer status) {
Notice notice = new Notice();
notice.setId(serid);
notice.setSchId(SCH);
notice.setStatus(status);
when(noticeRepository.findById(serid)).thenReturn(Optional.of(notice));
}
/** byBiz 出参里的 tasks(模拟对端 JSON 反序列化后的 Map 形态) */
private Map<String, Object> tasksOf(Long... taskIds) {
List<Object> rows = new ArrayList<>();
for (Long id : taskIds) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("taskId", id);
row.put("nodeId", "n1");
row.put("nodeName", "后勤资产处");
row.put("outlets", Collections.emptyList());
rows.add(row);
}
Map<String, Object> data = new HashMap<>();
data.put("instanceId", 123L);
data.put("tasks", rows);
return data;
}
// ===== ByBiz =====
/** 身份取自会话:出站用的是 BoaUser 的 schId/userId,前端参数里只有 serid */
@Test
void byBiz_identityFromSessionNotRequest() {
stubNotice(71, 1);
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L));
Map<String, Object> out = controller.byBiz(71, user());
verify(approvalClient).byBiz(eq(SCH), eq(USER_ID), eq(71));
assertEquals(1, ((List<?>) out.get("tasks")).size(), "1 条待办原样透出给卡片");
}
/** 已发布通知(status=1)不走未发布守卫;未发布(status=0)必须走 NoticeFlowGuard */
@Test
void byBiz_unpublishedNotice_goesThroughVisibilityGuard() {
stubNotice(70, 0);
BoaUser user = user();
controller.byBiz(70, user);
verify(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), eq(user));
}
@Test
void byBiz_publishedNotice_skipsUnpublishedGuard() {
stubNotice(71, 1);
controller.byBiz(71, user());
verify(noticeFlowGuard, never()).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class));
}
/** 通知不存在 → 拒绝,且不出站(不给流程中心发一个查不到对象的 byBiz) */
@Test
void byBiz_noticeMissing_rejectedAndNoOutbound() {
when(noticeRepository.findById(999)).thenReturn(Optional.empty());
BizException e = assertThrows(BizException.class, () -> controller.byBiz(999, user()));
assertTrue(e.getMessage().contains("通知不存在"), "文案须是查不到语义:" + e.getMessage());
verify(approvalClient, never()).byBiz(anyInt(), anyInt(), anyInt());
}
/** 不可见(守卫抛 FORBIDDEN)→ 原样拒绝 */
@Test
void byBiz_notVisible_guardThrows() {
stubNotice(70, 0);
org.mockito.Mockito.doThrow(new BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "通知不存在或未发布"))
.when(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class));
assertThrows(BizException.class, () -> controller.byBiz(70, user()));
verify(approvalClient, never()).byBiz(anyInt(), anyInt(), anyInt());
}
/** 未登录(BoaUser 空)→ 拒绝,身份字段无从伪造 */
@Test
void byBiz_noSession_rejected() {
stubNotice(71, 1);
assertThrows(BizException.class, () -> controller.byBiz(71, null));
BoaUser noSch = user();
noSch.setCurrentRole(null);
assertThrows(BizException.class, () -> controller.byBiz(71, noSch));
}
/** 0 条:对端 tasks=[] 时不得报错,卡片据此渲染只读 */
@Test
void byBiz_zeroTasks_passesThroughEmpty() {
stubNotice(72, 1);
Map<String, Object> empty = tasksOf();
when(approvalClient.byBiz(SCH, USER_ID, 72)).thenReturn(empty);
assertEquals(0, ((List<?>) controller.byBiz(72, user()).get("tasks")).size());
}
// ===== TaskPageUrl =====
/** taskId 在当前人待办里 → 签发任务页地址 */
@Test
void taskPageUrl_taskIsMine_issued() {
stubNotice(71, 1);
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L, 443L));
Map<String, Object> page = new HashMap<>();
page.put("pageUrl", "http://localhost:8088/#/task/443");
when(approvalClient.taskPageUrl(SCH, 443L)).thenReturn(page);
assertEquals("http://localhost:8088/#/task/443",
controller.taskPageUrl(71, 443L, user()).get("pageUrl"));
}
/** 核心越权用例:taskId 不属于当前人待办 → 拒绝签发,且不调用换址端点 */
@Test
void taskPageUrl_taskNotMine_notIssued() {
stubNotice(71, 1);
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L));
BizException e = assertThrows(BizException.class, () -> controller.taskPageUrl(71, 999L, user()));
assertTrue(e.getMessage().contains("不是你在本通知上的待办"), "文案须说明拒因:" + e.getMessage());
verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong());
}
/** 本人无待办(tasks=[])→ 同样拒绝,不为"空 tasks"开一条签发旁路 */
@Test
void taskPageUrl_noTasks_rejected() {
stubNotice(71, 1);
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf());
assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user()));
verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong());
}
/** 脏出参(tasks 缺位/非列表)按"不在待办"处理,不因解析失败而放行 */
@Test
void taskPageUrl_malformedPayload_failClosed() {
stubNotice(71, 1);
Map<String, Object> garbage = new HashMap<>();
garbage.put("tasks", "not-a-list");
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(garbage);
assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user()));
when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(null);
assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user()));
}
/** 不可见通知不得换任务页地址(与 ByBiz 同一道守卫) */
@Test
void taskPageUrl_unvisible_rejected() {
stubNotice(70, 0);
org.mockito.Mockito.doThrow(new BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "通知不存在或未发布"))
.when(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class));
assertThrows(BizException.class, () -> controller.taskPageUrl(70, 442L, user()));
verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong());
}
// ===== RecordUrl =====
/** 记录页复用 NoticeFlowEmbedService(三重校验与 token 现取现发都在那里),本端只做转发 */
@Test
void recordUrl_delegatesToEmbedService() {
BoaUser user = user();
when(embedService.embedUrl(71, user))
.thenReturn(Collections.singletonMap("pageUrl", "http://localhost:8088/#/embed/instance?token=x"));
assertEquals("http://localhost:8088/#/embed/instance?token=x",
controller.recordUrl(71, user).get("pageUrl"));
verify(embedService).embedUrl(71, user);
}
@Test
void recordUrl_notLoggedIn_rejected() {
assertThrows(BizException.class, () -> controller.recordUrl(71, null));
verify(embedService, never()).embedUrl(anyInt(), any(BoaUser.class));
}
// ===== Approve / Reject(回归位:确认改造后 edgeName 不丢、驳回意见仍被拦) =====
@Test
void approve_edgeNameStillPropagated() {
Map<String, String> body = new LinkedHashMap<>();
body.put("opinion", "同意");
body.put("edgeName", "转乙科");
controller.approve(442L, user(), body);
verify(approvalClient).approve(442L, SCH, USER_ID, "刘文娟", "同意", "转乙科");
}
/** 单出口快速通过:body 为空/不带 edgeName 时,出站就是 null,不擅自补一个出口名 */
@Test
void approve_withoutEdge_sendsNullEdgeName() {
controller.approve(442L, user(), Collections.emptyMap());
verify(approvalClient).approve(442L, SCH, USER_ID, "刘文娟", null, null);
}
/** 无会话即拒绝,且不出站——operator 永远不来自请求 */
@Test
void approve_noSession_rejected() {
assertThrows(BizException.class, () -> controller.approve(442L, null, new HashMap<>()));
verify(approvalClient, never()).approve(anyLong(), anyInt(), anyInt(), any(), any(), any());
}
/** 驳回意见必填由出站客户端前置强制;控制器原样抛出,不吞成"成功" */
@Test
void reject_blankOpinion_propagates() {
org.mockito.Mockito.doThrow(new BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见"))
.when(approvalClient).reject(eq(442L), eq(SCH), eq(USER_ID), eq("刘文娟"), any());
BizException e = assertThrows(BizException.class,
() -> controller.reject(442L, user(), Collections.singletonMap("opinion", " ")));
assertTrue(e.getMessage().contains("意见"), "须把拦截原因交给前端:" + e.getMessage());
}
}
@@ -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);
}
}
}
@@ -127,6 +127,58 @@ class NoticeApprovalClientTest {
properties.setEnabled(false); properties.setEnabled(false);
assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null)); assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null));
assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null)); assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null));
assertThrows(BizException.class, () -> client.byBiz(27, 10006, 71));
assertThrows(BizException.class, () -> client.taskPageUrl(27, 1L));
}
// ===== v2.1 应用内审批:by-biz 卡片数据源 + 完整办理任务页换址 =====
@Test
@SuppressWarnings("unchecked")
void byBiz_urlAndParams_charExact() {
Map<String, Object> data = new HashMap<>();
when(call.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class))).thenReturn(data);
client.byBiz(27, 10006, 71);
Map<String, String> expected = new LinkedHashMap<>();
expected.put("schId", "27");
expected.put("bizType", "notice");
expected.put("bizId", "71");
expected.put("operatorId", "10006");
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), eq(expected), eq(Map.class));
}
/** by-biz 四个参数一律必传(缺一即可能被对端当"可选过滤"放宽成越权查询) */
@Test
void byBiz_allFourParamsAlwaysSent() {
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
client.byBiz(27, 10006, 71);
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"),
Mockito.argThat(q -> q.size() == 4 && q.containsKey("schId") && q.containsKey("bizType")
&& q.containsKey("bizId") && q.containsKey("operatorId")), eq(Map.class));
}
/** 任务页换址走 mode=task(CAS 页、无 token);本批不动 BasePlatform,故仍是泛化调用 */
@Test
@SuppressWarnings("unchecked")
void taskPageUrl_modeTaskContractShape() {
Map<String, Object> page = new HashMap<>();
page.put("pageUrl", "http://localhost:8088/#/task/442");
when(call.get(eq(TARGET), eq("/oapi/flow/embed/url"), anyMap(), eq(Map.class))).thenReturn(page);
Map<String, Object> out = client.taskPageUrl(27, 442L);
assertEquals("http://localhost:8088/#/task/442", out.get("pageUrl"));
Map<String, String> expected = new LinkedHashMap<>();
expected.put("schId", "27");
expected.put("mode", "task");
expected.put("taskId", "442");
verify(call).get(eq(TARGET), eq("/oapi/flow/embed/url"), eq(expected), eq(Map.class));
}
/** 流程中心不可达:原样抛出,不吞异常、不伪装成"无待办"(卡片据此提示稍后重试而非渲染只读空态) */
@Test
void byBiz_unreachable_propagatesNotSwallowed() {
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class)))
.thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class, () -> client.byBiz(27, 10006, 71));
} }
@Test @Test
@@ -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("验签失败"));
}
}
@@ -0,0 +1,289 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeCc;
import com.yunhai.notice.repository.NoticeCcRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.sms.SmsSendService;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import com.yunhai.platform.yschflow.FlowBizAdapter;
import com.yunhai.platform.yschflow.FlowBizCallbackBridge;
import com.yunhai.platform.yschflow.FlowBizProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
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;
/**
* 迁移等价性锁定(S3.3):{@link NoticeFlowBizAdapter} 声明 + starter 桥执行,必须与停用的
* {@link NoticeFlowCallbackHandler} 手写版语义逐条一致——窄列更新、乱序二道门、终态不回退、
* 通过即发布+短信后置、抄送逐人留痕、脏消息回 200 不重推。
*/
@DisplayName("Notice 语义接入声明 - 与迁移前手写 Handler 等价")
class NoticeFlowBizAdapterTest {
private static final Integer NOTICE_ID = 7;
private static final Integer BOUND_VERSION = 3;
private NoticeRepository noticeRepository;
private NoticeCcRepository ccRepository;
private SmsSendService smsSendService;
private FlowBizCallbackBridge<Notice> bridge;
private Notice notice;
@BeforeEach
void setUp() {
noticeRepository = mock(NoticeRepository.class);
ccRepository = mock(NoticeCcRepository.class);
smsSendService = mock(SmsSendService.class);
notice = boundNotice();
when(noticeRepository.findByIdForUpdate(NOTICE_ID)).thenReturn(Optional.of(notice));
FlowBizAdapter<Notice> adapter = new NoticeFlowBizAdapter()
.noticeFlowBiz(noticeRepository, ccRepository, smsSendService, properties());
bridge = new FlowBizCallbackBridge<>(adapter, new ObjectMapper(), properties());
}
/** 与 application.yml 的 yh.flow-biz 同值 */
private static FlowBizProperties properties() {
FlowBizProperties properties = new FlowBizProperties();
properties.setBizType("notice");
properties.getStates().setApproving(10);
properties.getStates().setPassed(20);
properties.getStates().setRejected(30);
properties.getStates().setWithdrawn(40);
return properties;
}
private static Notice boundNotice() {
Notice entity = new Notice();
entity.setId(NOTICE_ID);
entity.setSchId(1);
entity.setFlowGuid("fg-1");
entity.setAuditStatus(10);
entity.setLastInstanceVersion(BOUND_VERSION);
entity.setStatus(0);
entity.setSmsFlag(1);
return entity;
}
private static FlowCallbackEvent event(String code, Integer version, Map<String, Object> data) {
FlowCallbackEvent event = new FlowCallbackEvent();
event.setEventId("evt-" + code + "-" + version);
event.setEvent(code);
event.setBizType("notice");
event.setBizId(String.valueOf(NOTICE_ID));
event.setInstanceId(900L);
event.setInstanceVersion(version);
event.setData(data);
return event;
}
private static Map<String, Object> terminalData() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("opinion", "同意");
data.put("result", "通过");
return data;
}
// ==================== 窄列推进 ====================
@Test
void approvingEvents_useNarrowColumnUpdate_neverWholeEntitySave() {
bridge.onEvent(event("flow.submitted", 4, null));
bridge.onEvent(event("flow.task.created", 5, null));
bridge.onEvent(event("flow.task.handled", 6, null));
verify(noticeRepository).updateAuditFromFlow(NOTICE_ID, 10, 4);
verify(noticeRepository).updateAuditFromFlow(NOTICE_ID, 10, 5);
verify(noticeRepository).updateAuditFromFlow(NOTICE_ID, 10, 6);
verify(noticeRepository, never()).save(any(Notice.class));
verify(noticeRepository, never()).saveAndFlush(any(Notice.class));
}
@Test
void eventWithoutVersion_keepsRecordedVersionInsteadOfNullingIt() {
bridge.onEvent(event("flow.task.handled", null, null));
verify(noticeRepository).updateAuditFromFlow(NOTICE_ID, 10, BOUND_VERSION);
}
@Test
void completed_writesTerminalSnapshotAndPublishesAndSendsSms() {
bridge.onEvent(event("flow.completed", 7, terminalData()));
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
verify(noticeRepository).updateAuditResultFromFlow(eq(NOTICE_ID), eq(20), eq(7), json.capture());
assertEquals("{\"opinion\":\"同意\",\"result\":\"通过\"}", json.getValue());
verify(noticeRepository).markPublished(NOTICE_ID);
verify(smsSendService).sendOnce(notice);
}
@Test
void rejected_andWithdrawn_writeTerminalWithoutPublish() {
bridge.onEvent(event("flow.rejected", 7, terminalData()));
verify(noticeRepository).updateAuditResultFromFlow(eq(NOTICE_ID), eq(30), eq(7), anyString());
notice.setAuditStatus(10);
bridge.onEvent(event("flow.withdrawn", 8, terminalData()));
verify(noticeRepository).updateAuditResultFromFlow(eq(NOTICE_ID), eq(40), eq(8), anyString());
verify(noticeRepository, never()).markPublished(anyInt());
verify(smsSendService, never()).sendOnce(any(Notice.class));
}
// ==================== 发布与短信后置的三种现状分支 ====================
@Test
void alreadyPublished_noSecondPublishNoSms() {
notice.setStatus(1);
bridge.onEvent(event("flow.completed", 7, terminalData()));
verify(noticeRepository, never()).markPublished(anyInt());
verify(smsSendService, never()).sendOnce(any(Notice.class));
}
@Test
void smsFlagOff_publishesButSendsNoSms() {
notice.setSmsFlag(0);
bridge.onEvent(event("flow.completed", 7, terminalData()));
verify(noticeRepository).markPublished(NOTICE_ID);
verify(smsSendService, never()).sendOnce(any(Notice.class));
}
@Test
void timingNotReached_publishesButSendsNoSmsYet() {
notice.setTimingSendTime(LocalDateTime.now().plusHours(2));
bridge.onEvent(event("flow.completed", 7, terminalData()));
verify(noticeRepository).markPublished(NOTICE_ID);
verify(smsSendService, never()).sendOnce(any(Notice.class));
}
// ==================== 纪律:不回退 / 不重复副作用 / 乱序 / 脏消息 ====================
@Test
void terminalRow_ignoresNonTerminalEvent() {
notice.setAuditStatus(20);
bridge.onEvent(event("flow.task.created", 8, null));
verify(noticeRepository, never()).updateAuditFromFlow(anyInt(), anyInt(), any());
}
@Test
void terminalRow_ignoresRepeatedCompleted_soSmsFiresOnce() {
bridge.onEvent(event("flow.completed", 7, terminalData()));
notice.setAuditStatus(20);
bridge.onEvent(event("flow.completed", 7, terminalData()));
verify(noticeRepository, times(1)).updateAuditResultFromFlow(anyInt(), anyInt(), anyInt(), anyString());
verify(smsSendService, times(1)).sendOnce(any(Notice.class));
}
@Test
void staleVersion_droppedByRecordGate() {
bridge.onEvent(event("flow.task.handled", 2, null));
verify(noticeRepository, never()).updateAuditFromFlow(anyInt(), anyInt(), any());
}
@Test
void unboundNotice_swallowedAndWritesNothing() {
notice.setFlowGuid(null);
assertDoesNotThrow(() -> bridge.onEvent(event("flow.completed", 7, terminalData())));
assertWritesNothing();
}
@Test
void missingNotice_swallowedAndWritesNothing() {
when(noticeRepository.findByIdForUpdate(NOTICE_ID)).thenReturn(Optional.empty());
assertDoesNotThrow(() -> bridge.onEvent(event("flow.completed", 7, terminalData())));
assertWritesNothing();
}
@Test
void dirtyBizId_swallowedAndNotEvenLookedUp() {
FlowCallbackEvent dirty = event("flow.completed", 7, terminalData());
dirty.setBizId("abc");
assertDoesNotThrow(() -> bridge.onEvent(dirty));
verify(noticeRepository, never()).findByIdForUpdate(anyInt());
assertWritesNothing();
}
private void assertWritesNothing() {
verify(noticeRepository, never()).updateAuditFromFlow(anyInt(), anyInt(), any());
verify(noticeRepository, never()).updateAuditResultFromFlow(anyInt(), anyInt(), any(), anyString());
verify(noticeRepository, never()).markPublished(anyInt());
}
// ==================== 抄送镜像(notice 有自家"抄送我的"列表) ====================
@Test
void ccSent_recordsRowPerPerson_withoutTouchingAudit() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ccUserId", 301);
data.put("ccUserName", "李四");
data.put("nodeId", "n3");
data.put("nodeName", "抄送分管领导");
bridge.onEvent(event("flow.cc.sent", 8, data));
ArgumentCaptor<NoticeCc> captor = ArgumentCaptor.forClass(NoticeCc.class);
verify(ccRepository).save(captor.capture());
NoticeCc row = captor.getValue();
assertEquals(Integer.valueOf(1), row.getSchId());
assertEquals(NOTICE_ID, row.getNoticeId());
assertEquals(Long.valueOf(900L), row.getFlowInstanceId());
assertEquals(Integer.valueOf(301), row.getCcUserId());
assertEquals("李四", row.getCcUserName());
assertEquals("n3", row.getNodeId());
assertEquals("抄送分管领导", row.getNodeName());
verify(noticeRepository, never()).updateAuditFromFlow(anyInt(), anyInt(), any());
verify(noticeRepository, never()).updateAuditResultFromFlow(anyInt(), anyInt(), any(), anyString());
}
@Test
void ccSentWithoutCcUserId_skippedAsDirtyData() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("nodeName", "抄送分管领导");
assertDoesNotThrow(() -> bridge.onEvent(event("flow.cc.sent", 8, data)));
verify(ccRepository, never()).save(any(NoticeCc.class));
}
@Test
void unknownEvent_swallowed() {
assertDoesNotThrow(() -> bridge.onEvent(event("flow.route.decided", 8, terminalData())));
verify(noticeRepository, never()).updateAuditFromFlow(anyInt(), anyInt(), any());
}
}
@@ -138,8 +138,9 @@ class NoticeFlowPublishTest {
noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository, noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository,
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), currentBoaUser, currentBoaUser,
new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class))); new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)),
com.yunhai.notice.support.FlowSubmitTemplates.forTests(flowClient));
noticeService = new NoticeService(); noticeService = new NoticeService();
ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier", ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier",
@@ -47,8 +47,8 @@ class NoticeFlowTraceTest {
service = new NoticeFlowService(noticeRepository, 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(),
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)),
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class))); com.yunhai.notice.support.FlowSubmitTemplates.forTests(flowClient));
bound = new Notice(); bound = new Notice();
bound.setId(42); bound.setId(42);
@@ -42,8 +42,9 @@ class NoticeFlowWithdrawTest {
props.setEnabled(true); props.setEnabled(true);
service = new NoticeFlowService(noticeRepository, service = new NoticeFlowService(noticeRepository,
mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props, mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props,
new ObjectMapper(), mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), new ObjectMapper(), mock(CurrentBoaUser.class),
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class))); new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)),
com.yunhai.notice.support.FlowSubmitTemplates.forTests(flowClient));
approving = new Notice(); approving = new Notice();
approving.setId(42); approving.setId(42);
@@ -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"));
}
}
@@ -0,0 +1,36 @@
package com.yunhai.notice.support;
import com.yunhai.notice.service.flow.FlowClient;
import com.yunhai.notice.service.flow.NoticeFlowBizAdapter;
import com.yunhai.platform.yschflow.FlowBizProperties;
import com.yunhai.platform.yschflow.FlowSubmitTemplate;
import java.util.Arrays;
/**
* 单测用发起模板:与生产装配同源(同一个 {@link NoticeFlowBizAdapter#gateway},出站仍落到
* 测试替身 FlowClient),只是不走真线程池(执行器传 null;单元测试无活动事务,
* {@code submitAfterCommit} 走内联分支,断言可确定性观察)。
*/
public final class FlowSubmitTemplates {
private FlowSubmitTemplates() {
}
public static FlowSubmitTemplate forTests(FlowClient flowClient) {
return new FlowSubmitTemplate(properties(), NoticeFlowBizAdapter.gateway(flowClient), null, null);
}
/** 与 application.yml 的 yh.flow-biz 块同值(测试里不引 yml,改这里必须同步 yml) */
private static FlowBizProperties properties() {
FlowBizProperties properties = new FlowBizProperties();
properties.setBizType("notice");
properties.getStates().setApproving(10);
properties.getStates().setPassed(20);
properties.getStates().setRejected(30);
properties.getStates().setWithdrawn(40);
properties.setVariables(Arrays.asList("deptId", "deptName"));
properties.setDetailRoute("/list/detail?serid={bizId}");
return properties;
}
}