Compare commits
40
Commits
5cc6730941
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
687bb65b3a | ||
|
|
9347ddb409 | ||
|
|
7c5391458c | ||
|
|
986a875279 | ||
|
|
d0af9d481c | ||
|
|
414dbfc2b2 | ||
|
|
d99ce81cc0 | ||
|
|
b96de59b6d | ||
|
|
a4e8702dc0 | ||
|
|
48cd8884d8 | ||
|
|
b85f5e16fb | ||
|
|
81a988f96a | ||
|
|
5efe9c09fd | ||
|
|
e6e91157be | ||
|
|
09eb27c6cf | ||
|
|
0721cf3b7f | ||
|
|
0d05986f8c | ||
|
|
867809fe37 | ||
|
|
a103d661fc | ||
|
|
0216d0655c | ||
|
|
082ef0af0a | ||
|
|
4ac3626b92 | ||
|
|
a7e93cb22d | ||
|
|
58aadaa95a | ||
|
|
df9b9e0aa5 | ||
|
|
5db629cfdb | ||
|
|
a5204680e6 | ||
|
|
08e9531b5f | ||
|
|
b3c5326163 | ||
|
|
8852c7ab02 | ||
|
|
01ce87ed7a | ||
|
|
bfb50a205d | ||
|
|
138b226ab0 | ||
|
|
fffcf1c66d | ||
|
|
69c2a41dd2 | ||
|
|
5384a08475 | ||
|
|
d5f25b8029 | ||
|
|
7cf17528ef | ||
|
|
2c8125a549 | ||
|
|
9351d6e9ab |
@@ -25,7 +25,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- 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 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -55,18 +58,24 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 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 \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/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 mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
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 "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
|
||||
@@ -49,4 +49,6 @@ yh:
|
||||
100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
|
||||
|
||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||
FILE_PATH: /data/upload
|
||||
# 键名必须是 file.upload-path(Java 端 @Value 按此读取;旧 FILE_PATH 顶层键不生效)
|
||||
file:
|
||||
upload-path: /data/upload
|
||||
|
||||
@@ -112,22 +112,41 @@
|
||||
<version>1.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台架包:认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面 + 统一响应 -->
|
||||
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
|
||||
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
|
||||
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-auth-spring-boot-starter</artifactId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</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-app-auth-spring-boot-starter</artifactId>
|
||||
<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/* 平台验签),
|
||||
接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-openapi-spring-boot-starter</artifactId>
|
||||
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
|
||||
</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>yh-api-std</artifactId>
|
||||
<artifactId>ysch-flow-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
|
||||
@@ -145,11 +164,9 @@
|
||||
<version>3.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 缓存(Spring Cache + Redis,字典等低频变更数据缓存) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<!-- 入站验签走中央密钥链(注册表模式)必须显式引 data-redis:yh-app-auth 的该依赖是
|
||||
optional、不传递。副作用是 RedisHealthIndicator 会被装配,故 yml 里关掉
|
||||
management.health.redis.enabled,防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
@@ -184,6 +201,18 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- 本工程测试命名约定:纯单元 *Test + 集成 *IT(连本地 MySQL),统一由 surefire 执行
|
||||
(surefire 默认只匹配 *Test.java,不追加则 *IT 永不执行) -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package com.yunhai.notice;
|
||||
import com.alibaba.fastjson.parser.ParserConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.yunhai.notice")
|
||||
@EnableTransactionManagement
|
||||
@EnableScheduling
|
||||
public class NoticeApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
/**
|
||||
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
|
||||
* 全局路径常量(三层接口面,闭集):/api PC 管理端(CAS)、/mobiapi 移动端(token)、/oapi 开放接口(应用签名)。
|
||||
* 其余前缀(如旧 /manage、/{platform} 通配)一律 404(方案 §4.1 路径闭集)。
|
||||
*/
|
||||
public enum Config {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ public class BaseApiController {
|
||||
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
|
||||
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,4 +70,12 @@ public class DictController {
|
||||
public void sort(@RequestParam("ids") String ids) {
|
||||
dictService.ordbyDicIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开通短信(前端 getSchModuleIsSms,迁移自 base/GetSchModuleIsSms)。
|
||||
*/
|
||||
@GetMapping("Sync")
|
||||
public Boolean sync() {
|
||||
return dictService.getSchModuleIsSms();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.yunhai.notice.controller.api;
|
||||
|
||||
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.NoticeFlowEmbedService;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowGuard;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批(第三步 T-C4):"我的待审核"数据面 + v2.1 业务详情卡片/记录/完整办理换址。
|
||||
* 前端不直调 oapi——operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段,
|
||||
* 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。
|
||||
* <ul>
|
||||
* <li>GET Approval/Todo:待办分页(bizType=notice 固定);</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>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/notice/approval/")
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeApprovalController {
|
||||
|
||||
private final NoticeApprovalClient approvalClient;
|
||||
private final NoticeFlowEmbedService embedService;
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final NoticeFlowGuard noticeFlowGuard;
|
||||
|
||||
/** 我的待审核(列表页数据源) */
|
||||
@GetMapping("Todo")
|
||||
public Map<String, Object> todo(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Operator op = requireOperator(user);
|
||||
return approvalClient.todoTasks(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/** 办件视角(对账/历史;status 10 审批中/20 完成/30 拒绝/40 撤回) */
|
||||
@GetMapping("Instances")
|
||||
public Map<String, Object> instances(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Operator op = requireOperator(user);
|
||||
return approvalClient.instances(op.schId, status, page, size);
|
||||
}
|
||||
|
||||
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
|
||||
@PostMapping("Approve")
|
||||
public void approve(@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
Operator op = requireOperator(user);
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
String edgeName = body == null ? null : body.get("edgeName");
|
||||
approvalClient.approve(taskId, op.schId, op.userId, op.name, opinion, edgeName);
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填) */
|
||||
@PostMapping("Reject")
|
||||
public void reject(@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
Operator op = requireOperator(user);
|
||||
approvalClient.reject(taskId, op.schId, op.userId, op.name,
|
||||
body == null ? null : body.get("opinion"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在该通知上的待办(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 提交人口径一致) */
|
||||
private static Operator requireOperator(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
|
||||
}
|
||||
String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount();
|
||||
return new Operator(user.getId(), user.getCurrentRole().getSchId(), name);
|
||||
}
|
||||
|
||||
private static final class Operator {
|
||||
final Integer userId;
|
||||
final Integer schId;
|
||||
final String name;
|
||||
|
||||
Operator(Integer userId, Integer schId, String name) {
|
||||
this.userId = userId;
|
||||
this.schId = schId;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.yunhai.notice.controller.api;
|
||||
|
||||
import com.yunhai.notice.config.Config;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeCc;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.repository.NoticeCcRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 应用内「抄送给我的」(2026-09-24):数据源是 flow.cc.sent 回调落的 notice_cc 表
|
||||
* (NOTIFY 节点激活即抄送,审批过程中就可见——此时通知尚未发布,属知会不是可读)。
|
||||
* 身份一律取 CAS 登录态(BoaUser),与 NoticeApprovalController 同口径,防伪造查询人。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/notice/cc/")
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeCcController {
|
||||
|
||||
private final NoticeCcRepository noticeCcRepository;
|
||||
private final NoticeRepository noticeRepository;
|
||||
|
||||
/** 抄送给我的(分页,sent_time 倒序;行内回填通知标题/部门/审核状态供列表直渲染) */
|
||||
@GetMapping("My")
|
||||
public Map<String, Object> my(BoaUser user,
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "size", defaultValue = "10") Integer size) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
|
||||
}
|
||||
int pageIndex = page == null || page < 1 ? 1 : page;
|
||||
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
|
||||
Page<NoticeCc> ccPage = noticeCcRepository.findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(
|
||||
user.getCurrentRole().getSchId(), user.getId(), PageRequest.of(pageIndex - 1, pageSize));
|
||||
|
||||
// 批量回填通知信息(被删通知保留抄送行,标题置空由前端兜底)
|
||||
Map<Integer, Notice> notices = ccPage.getContent().isEmpty()
|
||||
? new HashMap<>()
|
||||
: noticeRepository.findAllById(ccPage.getContent().stream()
|
||||
.map(NoticeCc::getNoticeId).collect(Collectors.toSet())).stream()
|
||||
.collect(Collectors.toMap(Notice::getId, Function.identity()));
|
||||
List<Map<String, Object>> content = new ArrayList<>();
|
||||
for (NoticeCc cc : ccPage.getContent()) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", cc.getId());
|
||||
row.put("noticeId", cc.getNoticeId());
|
||||
row.put("flowInstanceId", cc.getFlowInstanceId());
|
||||
row.put("nodeId", cc.getNodeId());
|
||||
row.put("nodeName", cc.getNodeName());
|
||||
row.put("sentTime", cc.getSentTime());
|
||||
Notice notice = notices.get(cc.getNoticeId());
|
||||
if (notice != null) {
|
||||
row.put("title", notice.getTitle());
|
||||
row.put("deptName", notice.getDeptName());
|
||||
row.put("publisherName", notice.getPublisherName());
|
||||
row.put("noticeStatus", notice.getStatus());
|
||||
row.put("auditStatus", notice.getAuditStatus());
|
||||
row.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
|
||||
} else {
|
||||
row.put("title", null);
|
||||
row.put("noticeStatus", null);
|
||||
row.put("auditStatus", null);
|
||||
row.put("auditStatusDesc", "通知已删除");
|
||||
}
|
||||
content.add(row);
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", ccPage.getTotalElements());
|
||||
result.put("content", content);
|
||||
result.put("pageIndex", pageIndex);
|
||||
result.put("pageSize", pageSize);
|
||||
result.put("totalPages", ccPage.getTotalPages());
|
||||
result.put("hasNext", ccPage.hasNext());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.yunhai.notice.controller.api;
|
||||
|
||||
import com.yunhai.notice.config.Config;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowService;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接缝接口(先审后发)。
|
||||
* <ul>
|
||||
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
|
||||
* <li>GET Flow/Binding:应用设置 > 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);</li>
|
||||
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
|
||||
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
||||
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
||||
* <li>GET Flow/Embed:办件进度嵌入页地址(Drawer iframe 直嵌,可见性与 Trace 同口径)。</li>
|
||||
* <li>GET Flow/DiagramDef:流程定义示意图(绑定页/发通知页 Drawer,登录即可读——与 Binding 同口径)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/notice/flow/")
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowController {
|
||||
|
||||
private final NoticeFlowService noticeFlowService;
|
||||
private final com.yunhai.notice.service.flow.NoticeFlowEmbedService noticeFlowEmbedService;
|
||||
private final com.yunhai.notice.service.flow.FlowClient flowClient;
|
||||
|
||||
/** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */
|
||||
@GetMapping("Options")
|
||||
public List<FlowModels.FlowSummaryVo> options(BoaUser user,
|
||||
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用设置 > 流程绑定:本校绑定视图(bound/flowGuid/flowName/flowEnabled)。
|
||||
* 登录即可读——发通知页靠它决定「提交审核」还是「发送」。
|
||||
*/
|
||||
@GetMapping("Binding")
|
||||
public Map<String, Object> binding(BoaUser user) {
|
||||
return noticeFlowService.bindingView(currentSchId(user), user);
|
||||
}
|
||||
|
||||
/** 保存/解除绑定(flowGuid 空=解绑);仅本校管理员可改 */
|
||||
@PostMapping("Binding/Save")
|
||||
public Map<String, Object> saveBinding(@RequestParam(value = "flowGuid", required = false) String flowGuid,
|
||||
BoaUser user) {
|
||||
return noticeFlowService.saveBinding(user, flowGuid);
|
||||
}
|
||||
|
||||
/** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */
|
||||
@PostMapping("TrySubmit")
|
||||
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
noticeFlowService.trySubmitFlow(serid, user);
|
||||
}
|
||||
|
||||
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false 的未接入语义;未发布仅三类人可见) */
|
||||
@GetMapping("Trace")
|
||||
public Map<String, Object> trace(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
return noticeFlowService.buildTrace(serid, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面,现后端经 SDK 托管件中转;
|
||||
* 仅创建人本人 + 审批中,撤回后状态由 flow.withdrawn 回调推进)。
|
||||
*/
|
||||
@PostMapping("Withdraw")
|
||||
public void withdraw(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
noticeFlowService.withdrawNotice(serid, user);
|
||||
}
|
||||
|
||||
/** 流程示意图(PNG 流,同源直嵌 img;可见性与 Trace 同口径,token 不落前端) */
|
||||
@GetMapping(value = "Diagram", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
|
||||
public byte[] diagram(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
return noticeFlowService.diagramImage(serid, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程定义示意图(PNG 流,绑定页/发通知页 Drawer 同源直嵌 img)。def 口径无办件,
|
||||
* 不适用 NoticeFlowGuard 办件守卫——与绑定读取(Binding,登录即可读)同口径:CAS 登录即可,
|
||||
* schId 从登录用户推导,本校限定由 flow-center 按 schId+flowGuid 收敛;
|
||||
* 绑定已解除/无已发布版时对端报业务错(JSON 信封),前端 img onerror 转提示,不留 500。
|
||||
*/
|
||||
@GetMapping(value = "DiagramDef", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE)
|
||||
public byte[] diagramDef(@RequestParam("flowGuid") String flowGuid, BoaUser user) {
|
||||
if (flowGuid == null || flowGuid.trim().isEmpty()) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "缺少流程标识");
|
||||
}
|
||||
return flowClient.diagramImageByFlowGuid(currentSchId(user), flowGuid.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页地址(详情页 Drawer 内 iframe 直嵌 flow-center 统一进度页)。
|
||||
* 可见性与 Trace/Diagram 同口径;pageUrl 含短时 token,现取现发不落前端缓存。
|
||||
*/
|
||||
@GetMapping("Embed")
|
||||
public Map<String, String> embed(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||
return noticeFlowEmbedService.embedUrl(serid, user);
|
||||
}
|
||||
|
||||
private Integer currentSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null) {
|
||||
return null;
|
||||
}
|
||||
return user.getCurrentRole().getSchId();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.notice.controller.oapi;
|
||||
|
||||
import com.yunhai.notice.service.IDictService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 开放接口 demo(两级模型试点 P2.1:Notice 作为提供方开放 /oapi/**)。
|
||||
* 强制验签由 starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}):
|
||||
* 浏览器/匿名直访 401,注册应用带 X-App-* 签名三件套直调通过(trusted-apps 收调用方密钥)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/notice")
|
||||
@RequiredArgsConstructor
|
||||
public class OapiDictController {
|
||||
|
||||
private final IDictService dictService;
|
||||
|
||||
/** 部门字典(demo:type=1 部门;调用方签名鉴权由 enforce-patterns 强制) */
|
||||
@GetMapping("/getDict")
|
||||
public List<Map<String, Object>> getDict(@RequestParam(value = "type", defaultValue = "1") Integer type) {
|
||||
return dictService.getSysDic(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.yunhai.notice.controller.oapi;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件路由判断端点(2026-09-24 设计方案 §5.2):flow-center CONDITION 节点(routerKey=
|
||||
* notice.dept-judge)激活时同步调用——发文部门=校长室走"校长室"线(免校长审核),其余走"其他部门"线。
|
||||
* <ul>
|
||||
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center(100019)的应用签名,零新概念;</li>
|
||||
* <li>纯读幂等:凭 bizId 查 notice 表比对发文部门(variables 里有 formData 的部门则免查库,两口径一致);
|
||||
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
|
||||
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
|
||||
* <li>轻实现:同步 3s 超时,只做一次主键查询。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/notice/flow/route")
|
||||
@RequiredArgsConstructor
|
||||
public class OapiFlowRouteController {
|
||||
|
||||
private static final String OUTLET_PRINCIPAL = "校长室";
|
||||
private static final String OUTLET_OTHER = "其他部门";
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
|
||||
/** 判断请求体(契约见《子应用接入流程中心规范.md》条件路由判断接口;字段忽略未知向前兼容) */
|
||||
@Data
|
||||
public static class JudgeBody {
|
||||
private Long schId;
|
||||
private Long instanceId;
|
||||
private Integer instanceVersion;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private String routerKey;
|
||||
private List<String> outlets;
|
||||
private Map<String, Object> variables;
|
||||
}
|
||||
|
||||
@PostMapping("/dept-judge")
|
||||
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
|
||||
Integer noticeId;
|
||||
try {
|
||||
noticeId = Integer.valueOf(String.valueOf(body.getBizId()).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
|
||||
}
|
||||
// formData 优先(apply 时透传了发文部门则免查库),缺失回落查表——两口径同一数据源
|
||||
String deptName = variablesDeptName(body);
|
||||
String deptId = null;
|
||||
if (!StringUtils.hasText(deptName)) {
|
||||
Notice notice = noticeRepository.findById(noticeId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "通知不存在:" + noticeId));
|
||||
deptId = notice.getDeptId();
|
||||
deptName = notice.getDeptName();
|
||||
}
|
||||
String outlet = OUTLET_PRINCIPAL.equals(deptName) ? OUTLET_PRINCIPAL : OUTLET_OTHER;
|
||||
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
|
||||
if (!outlets.contains(outlet)) {
|
||||
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
|
||||
+ "(deptId=" + deptId + ", deptName=" + deptName + ")");
|
||||
}
|
||||
return Collections.singletonMap("outlet", outlet);
|
||||
}
|
||||
|
||||
/** formData 里的发文部门(apply 时 bizSnapshot 同源透传;缺字段返回空串回落查表) */
|
||||
private static String variablesDeptName(JudgeBody body) {
|
||||
Map<String, Object> variables = body.getVariables();
|
||||
if (variables == null) {
|
||||
return "";
|
||||
}
|
||||
Object v = variables.get("deptName");
|
||||
return v == null ? "" : String.valueOf(v).trim();
|
||||
}
|
||||
}
|
||||
@@ -145,4 +145,34 @@ public class Notice extends BaseEntity {
|
||||
*/
|
||||
@Column(name = "sms_flag")
|
||||
private Integer smsFlag = 0;
|
||||
|
||||
/**
|
||||
* 短信实际发送时间(防重复发送的幂等标记):NULL=未发。
|
||||
* 直发/审批通过/到点补发三条链路共用(SmsSendService 原子认领);本地随 ddl-auto=update
|
||||
* 自动加列,生产执行 deploy/sql/20260913_add_sms_sent_at.sql。
|
||||
*/
|
||||
@Column(name = "sms_sent_at")
|
||||
private LocalDateTime smsSentAt;
|
||||
|
||||
// ===== 先审后发(流程中心托管):全部可空,NULL=未绑流程(原直发链路,存量零迁移) =====
|
||||
|
||||
/** 绑定的流程 GUID;NULL=直发(原链路一字不改) */
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程中心办件 id(apply 成功回填) */
|
||||
@Column(name = "flow_instance_id")
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 审核镜像状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(FlowAuditStatusEnum) */
|
||||
@Column(name = "audit_status")
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 已消费的最大回调 instanceVersion(乱序丢弃闸门) */
|
||||
@Column(name = "last_instance_version")
|
||||
private Integer lastInstanceVersion;
|
||||
|
||||
/** 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示) */
|
||||
@Column(name = "flow_result_json", columnDefinition = "LONGTEXT")
|
||||
private String flowResultJson;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 流程抄送留痕(2026-09-24):flow-center NOTIFY 节点的 flow.cc.sent 回调逐人一行,
|
||||
* 应用内「抄送给我的」列表数据源。幂等靠 starter 的 flow_callback_event(uk event_id),
|
||||
* 本表只增不改——同一通知多节点抄送/退回重走重复抄送各成一行,轨迹按时间归位。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "notice_cc",
|
||||
indexes = {
|
||||
@Index(name = "idx_notice_cc_user", columnList = "sch_id, cc_user_id, sent_time"),
|
||||
@Index(name = "idx_notice_cc_notice", columnList = "notice_id")})
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class NoticeCc extends BaseEntity {
|
||||
|
||||
/** 学校ID(多租户隔离,与 notice.sch_id 同域) */
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 关联通知(信封 bizId) */
|
||||
@Column(name = "notice_id", nullable = false)
|
||||
private Integer noticeId;
|
||||
|
||||
/** flow-center 办件 id(信封 instanceId) */
|
||||
@Column(name = "flow_instance_id")
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 抄送发生节点 id(data.nodeId) */
|
||||
@Column(name = "node_id", length = 64)
|
||||
private String nodeId;
|
||||
|
||||
/** 抄送环节名快照(data.nodeName) */
|
||||
@Column(name = "node_name", length = 128)
|
||||
private String nodeName;
|
||||
|
||||
/** 被抄送人用户 id(data.ccUserId,平台统一用户目录) */
|
||||
@Column(name = "cc_user_id", nullable = false)
|
||||
private Integer ccUserId;
|
||||
|
||||
/** 被抄送人姓名快照(data.ccUserName) */
|
||||
@Column(name = "cc_user_name", length = 64)
|
||||
private String ccUserName;
|
||||
|
||||
/** 抄送时间(事件处理时刻)。DATETIME(3) 与 flow-center flw_cc.sent_time 同口径 */
|
||||
@Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime sentTime;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
import com.yunhai.notice.common.listener.NoticeAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条)。
|
||||
* <p>
|
||||
* 语义(先审后发规则单一事实源):「通知对象=全部用户」且本校已绑定流程时,发通知必须走审核
|
||||
* (status=0 审核载体,回调 completed 才发布);「通知对象=指定人员」与未绑定一律直发。
|
||||
* 行不存在 = 未绑定。通知落库时把 flow_guid 快照进 notice 行,故改绑/解绑不影响在途审批。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notice_flow_binding",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_binding_sch", columnNames = {"sch_id"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(NoticeAuditListener.class)
|
||||
public class NoticeFlowBinding extends BaseEntity {
|
||||
|
||||
/** 租户键(一校一条绑定) */
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 绑定的流程 GUID(flow-center flw_def.flow_guid,跨版本稳定) */
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程名快照(流程中心不可达时设置页/提示条仍可显示) */
|
||||
@Column(name = "flow_name", length = 128)
|
||||
private String flowName;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.yunhai.notice.common.listener.NoticeAuditListener;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 流程中心回调事件去重表(照 svc-hall ServiceFlowEvent / flow-center 事件 uk 同构):
|
||||
* eventId 唯一约束兜底并发;重复投递(flow-center 持续重试语义下必然发生)命中唯一键即跳过。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notice_flow_event",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_event", columnNames = "event_id"),
|
||||
indexes = @Index(name = "idx_notice_flow_event_inst", columnList = "instance_id"))
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(NoticeAuditListener.class)
|
||||
public class NoticeFlowEvent extends BaseEntity {
|
||||
|
||||
/** 事件唯一键(32 位 uuid,flow-center 重试不变) */
|
||||
@Column(name = "event_id", length = 40, nullable = false)
|
||||
private String eventId;
|
||||
|
||||
/** 事件码:flow.submitted / task.created / task.handled / completed / rejected / withdrawn / ping */
|
||||
@Column(name = "event", length = 32)
|
||||
private String event;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
@Column(name = "instance_id")
|
||||
private Long instanceId;
|
||||
|
||||
@Column(name = "receive_time")
|
||||
private java.time.LocalDateTime receiveTime;
|
||||
|
||||
@Column(name = "payload_json", columnDefinition = "LONGTEXT")
|
||||
private String payloadJson;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.notice.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import com.yunhai.notice.common.listener.NoticeAuditListener;
|
||||
|
||||
/**
|
||||
* 流程中心回调注册(照 svc-hall ServiceFlowReg / flow-center FlwCallbackReg 同构):
|
||||
* sch+appKey+flowGuid 唯一;secret 为 flow-center reg 接口下发时落库的回调验签密钥。
|
||||
* 注意:flow-center 重复注册会<b>轮换 secret</b>,故仅在缺失时注册(ensureRegistered),
|
||||
* 绝不重复调用——失配恢复手段=管理端重新保存绑定。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notice_flow_reg",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_reg", columnNames = {"sch_id", "app_key", "flow_guid"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(NoticeAuditListener.class)
|
||||
public class NoticeFlowReg extends BaseEntity {
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 本应用在流程中心侧的 appKey(yh.app-auth.app-key,出站签名同一身份) */
|
||||
@Column(name = "app_key", length = 64)
|
||||
private String appKey;
|
||||
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
@Column(name = "callback_url", length = 500)
|
||||
private String callbackUrl;
|
||||
|
||||
/** 订阅事件逗号分隔,空=全部 */
|
||||
@Column(name = "event_types", length = 200)
|
||||
private String eventTypes;
|
||||
|
||||
/** flow-center reg 接口下发的回调验签密钥 */
|
||||
@Column(name = "secret", length = 64)
|
||||
private String secret;
|
||||
|
||||
/** 0 启用 / 1 停用(预留) */
|
||||
@Column(name = "status")
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.notice.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 通知审核状态(audit_status 列,对齐 flow-center FlwEnums.InstanceStatus 与 svc-hall ApplyFlowStatusEnum;
|
||||
* 未提交 0 为 Notice 特有——apply 尚未成功的暂态)。仅在绑定了审核流程(flow_guid 非空)时有意义。
|
||||
*/
|
||||
@Getter
|
||||
public enum FlowAuditStatusEnum {
|
||||
|
||||
PENDING_SUBMIT(0, "待提交"),
|
||||
APPROVING(10, "审批中"),
|
||||
APPROVED(20, "已通过"),
|
||||
REJECTED(30, "已驳回"),
|
||||
WITHDRAWN(40, "已撤回");
|
||||
|
||||
private final int code;
|
||||
private final String desc;
|
||||
|
||||
FlowAuditStatusEnum(int code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public static FlowAuditStatusEnum of(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
for (FlowAuditStatusEnum e : values()) {
|
||||
if (e.code == code) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String descOf(Integer code) {
|
||||
FlowAuditStatusEnum e = of(code);
|
||||
return e == null ? null : e.getDesc();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@ package com.yunhai.notice.enums;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 通知状态。无草稿态:发通知一律置为「已发布」,
|
||||
* 通知状态。status=1 已发布仍是用户端唯一可见态;
|
||||
* 先审后发引入 status=0 未发布(审核载体:绑流程通知提交后置 0,回调 completed 才置 1,
|
||||
* 审核进度独立成列 audit_status)。未绑流程的通知仍无草稿态(直发即 1),
|
||||
* 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。
|
||||
* 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。
|
||||
*/
|
||||
@Getter
|
||||
public enum NoticeStatusEnum {
|
||||
|
||||
UNPUBLISHED(0, "未发布"),
|
||||
PUBLISHED(1, "已发布");
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.notice.job;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSendService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 短信到点补发调度(§3.2 / BP-R5-2):扫"已发布 + 要发短信 + 从未发过 + 已到定时点"的行补发。
|
||||
* 覆盖场景:定时发送的通知在定时点之前审批通过——发布链路按设计跳过短信(可见性即时生效),
|
||||
* 原实现无任何补发导致短信永久丢失。幂等由 SmsSendService 的 sms_sent_at 原子认领保证
|
||||
* (并发/重复调度不双发);单条失败不阻断批次(归还认领,下轮重试)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SmsCatchupJob {
|
||||
|
||||
/** 单批上限(防御性:避免超大存量发布时一次性拉满) */
|
||||
private static final int BATCH = 200;
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final SmsSendService smsSendService;
|
||||
|
||||
@Value("${notice.sms.catchup-enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
/** fixedDelay=5min(上一轮跑完起算,单实例内天然不重叠;跨实例由原子认领闸) */
|
||||
@Scheduled(fixedDelayString = "${notice.sms.catchup-fixed-delay-ms:300000}", initialDelay = 30_000)
|
||||
public void catchupDueSms() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
List<Notice> due = noticeRepository.findSmsDue(LocalDateTime.now(), PageRequest.of(0, BATCH));
|
||||
if (due.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("[notice-sms] 到点补发扫描:{} 条待发", due.size());
|
||||
int sent = 0;
|
||||
for (Notice notice : due) {
|
||||
try {
|
||||
if (smsSendService.sendOnce(notice)) {
|
||||
sent++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-sms] 补发单条失败(下轮重试)notice={}:{}", notice.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
log.info("[notice-sms] 到点补发完成:本轮发送 {} 条", sent);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。
|
||||
* 班级管理接口-教师(userclass {@code /oapi/DbClass/GetSchteaList} 响应项)。
|
||||
* 对应 C# BestSchool.Entity.Model.SchTeaList。
|
||||
*/
|
||||
public class SchTeaList {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.yunhai.notice.model.flow;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowSummary;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowInstance;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowProgress;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 流程中心 /oapi/flow/** 契约模型。<br>
|
||||
* 2026-09-16 起全部改为 {@code extends} SDK 门面 DTO({@code yh-openapi-client FlowDto},
|
||||
* 与 flow-center OapiModels 逐字对齐,契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5)
|
||||
* ——字段单一事实源收敛到 SDK,本类仅保留 Notice 侧类型名(消费方签名零改动),
|
||||
* 并可直接作为 {@code YschFlowOpenApi} 泛型出入参传递。
|
||||
*/
|
||||
public final class FlowModels {
|
||||
|
||||
private FlowModels() {
|
||||
}
|
||||
|
||||
/** 流程摘要(GET /oapi/flow/list 元素) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class FlowSummaryVo extends FlowSummary {
|
||||
}
|
||||
|
||||
/** 代发起请求体(POST /oapi/flow/apply) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class ApplyBody extends FlowApplyBody {
|
||||
}
|
||||
|
||||
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ApplyResultVo extends FlowApplyResult {
|
||||
}
|
||||
|
||||
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class InstanceVo extends FlowInstance {
|
||||
}
|
||||
|
||||
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class CallbackRegBody extends FlowCallbackRegBody {
|
||||
}
|
||||
|
||||
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class CallbackRegResultVo extends FlowCallbackRegResult {
|
||||
}
|
||||
|
||||
/** 办件进度(GET /oapi/flow/progress;steps[0] 固定"发起申请",2026-09-17 接入) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ProgressVo extends FlowProgress {
|
||||
}
|
||||
|
||||
/** 流程示意图换图地址(GET /oapi/flow/diagram/url;imgUrl 为 flow-center 免签相对路径) */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class DiagramUrlVo extends FlowDiagramUrl {
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,23 @@ public class NoticeDetailVo {
|
||||
@JsonProperty("tlist")
|
||||
private Object tlist;
|
||||
|
||||
// ===== 先审后发(编辑页驳回/撤回态展示) =====
|
||||
|
||||
/** 绑定的审核流程 GUID(null=直发未接流程) */
|
||||
private String flowGuid;
|
||||
|
||||
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑) */
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 审核状态文案 */
|
||||
private String auditStatusDesc;
|
||||
|
||||
/** 流程中心办件 id(撤回/催办直调 flow-center 用) */
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 终态明细(result/opinion/rejectNode;null=无) */
|
||||
private Object flowResult;
|
||||
|
||||
/**
|
||||
* 附件项(旧 ChaoSend 数组元素)。
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,13 @@ public class NoticeQuery extends BasePageQuery {
|
||||
/** 状态过滤:null 不过滤;1 已发布 */
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status):
|
||||
* 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
|
||||
* <b>-1 = 无需审核</b>(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。
|
||||
*/
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 类型过滤:1 普通 / 2 重要 */
|
||||
private Integer type;
|
||||
|
||||
|
||||
@@ -50,4 +50,7 @@ public class NoticeRequest {
|
||||
|
||||
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
|
||||
private Integer sourceId;
|
||||
|
||||
// 审核流程不再由请求传入:绑定在「应用设置 > 流程绑定」(notice_flow_binding,按学校一条),
|
||||
// 由 NoticeService 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ public class NoticeVo {
|
||||
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
|
||||
private String UpFile;
|
||||
|
||||
// ===== 先审后发(流程中心托管) =====
|
||||
|
||||
/** 绑定的审核流程 GUID(null=直发,未接流程中心) */
|
||||
private String flowGuid;
|
||||
|
||||
/** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑流程) */
|
||||
private Integer auditStatus;
|
||||
|
||||
/** 审核状态文案(待提交/审批中/已通过/已驳回/已撤回;null=未绑流程) */
|
||||
private String auditStatusDesc;
|
||||
|
||||
/** 流程中心办件 id(提交成功后非空;详情页撤回/催办直调 flow-center 用) */
|
||||
private Long flowInstanceId;
|
||||
|
||||
private String createBy;
|
||||
private LocalDateTime createTime;
|
||||
private String updateBy;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeCc;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* 流程抄送留痕仓储(「抄送给我的」主路径)。
|
||||
*/
|
||||
public interface NoticeCcRepository extends JpaRepository<NoticeCc, Integer> {
|
||||
|
||||
/** 按学校 + 被抄送人分页(sent_time 倒序,倒序索引 idx_notice_cc_user 覆盖) */
|
||||
Page<NoticeCc> findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(Integer schId, Integer ccUserId,
|
||||
Pageable pageable);
|
||||
|
||||
/** 未发布通知可见守卫第 4 条:当前用户是否被抄送该通知(NoticeFlowGuard) */
|
||||
boolean existsByNoticeIdAndCcUserId(Integer noticeId, Integer ccUserId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NoticeFlowBindingRepository extends JpaRepository<NoticeFlowBinding, Integer> {
|
||||
|
||||
Optional<NoticeFlowBinding> findBySchId(Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeFlowEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface NoticeFlowEventRepository extends JpaRepository<NoticeFlowEvent, Integer> {
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NoticeFlowRegRepository extends JpaRepository<NoticeFlowReg, Integer> {
|
||||
|
||||
Optional<NoticeFlowReg> findBySchIdAndAppKeyAndFlowGuid(Integer schId, String appKey, String flowGuid);
|
||||
|
||||
Optional<NoticeFlowReg> findBySchIdAndFlowGuid(Integer schId, String flowGuid);
|
||||
|
||||
List<NoticeFlowReg> findBySchIdAndStatusOrderByUpdateTimeDesc(Integer schId, Integer status);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.yunhai.notice.repository;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -20,6 +22,80 @@ public interface NoticeRepository extends JpaRepository<Notice, Integer>,
|
||||
*/
|
||||
long countByStatus(Integer status);
|
||||
|
||||
/** 回调事件处理专用:行级悲观锁读(并发推送串行化,防整实体保存互相覆盖丢更新) */
|
||||
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||
@org.springframework.data.jpa.repository.Query("select n from Notice n where n.id = :id")
|
||||
Optional<Notice> findByIdForUpdate(@org.springframework.data.repository.query.Param("id") Integer id);
|
||||
|
||||
/**
|
||||
* 提交审核后的原子回填(窄列更新,防旧快照整实体覆盖并发回调的推进):
|
||||
* 置 flow_instance_id + audit=10;若回调已并发先行落定 audit=20(审核通过)则保持 20 不回退。
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.flowInstanceId = :instanceId, "
|
||||
+ "n.auditStatus = (case when n.auditStatus = 20 then n.auditStatus else 10 end) "
|
||||
+ "where n.id = :id")
|
||||
int markApproving(@org.springframework.data.repository.query.Param("id") Integer id,
|
||||
@org.springframework.data.repository.query.Param("instanceId") Long instanceId);
|
||||
|
||||
/**
|
||||
* 回调推进在途态的窄列更新(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(未发)时生效。
|
||||
* 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.smsSentAt = :sentAt where n.id = :id and n.smsSentAt is null")
|
||||
int markSmsSent(@Param("id") Integer id, @Param("sentAt") LocalDateTime sentAt);
|
||||
|
||||
/** 发送失败归还认领(下轮补发调度可重试) */
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
"update Notice n set n.smsSentAt = null where n.id = :id and n.smsSentAt is not null")
|
||||
int clearSmsSent(@Param("id") Integer id);
|
||||
|
||||
/**
|
||||
* 到点待补发短信清单(§3.2 SmsCatchupJob 扫描):已发布 + 要发短信 + 从未发过 + 已到定时点
|
||||
* (无定时点即发;覆盖"审批通过早于定时点被跳过"的行——BP-R5-2 短信永久丢失缺口)。
|
||||
*/
|
||||
@Query("select n from Notice n where n.status = 1 and n.smsFlag = 1 and n.smsSentAt is null"
|
||||
+ " and (n.timingSendTime is null or n.timingSendTime <= :now) order by n.id asc")
|
||||
List<Notice> findSmsDue(@Param("now") LocalDateTime now,
|
||||
org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
/**
|
||||
* 已发布且已到点(定时发送未到点不计入)的通知数,用于用户端未读数统计。
|
||||
*/
|
||||
|
||||
@@ -5,14 +5,14 @@ import com.yunhai.notice.model.SchTeaList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班级管理(bschclass)开放接口客户端。
|
||||
* 基址取配置 {@code yh.open.bschclass},当前仅提供教师查询,后续班级/年级等接口按需追加。
|
||||
* 班级管理(userclass)开放接口客户端。
|
||||
* 2026-09-16 起委托 SDK 类型化门面 {@code YschClassOpenApi}(服务目录 userclass),当前仅提供教师查询。
|
||||
*/
|
||||
public interface IClassService {
|
||||
|
||||
/**
|
||||
* 获取指定学校全部教师(含所属组 GroupIds)。
|
||||
* 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
|
||||
* 对应 userclass 开放接口:GET {@code {userclass}/oapi/DbClass/GetSchteaList?Group={schId}}
|
||||
*
|
||||
* @param schId 学校ID(即用户当前身份的 schId)
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ public interface IUserCenterService {
|
||||
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
|
||||
* <ol>
|
||||
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
|
||||
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
|
||||
* <li>userclass(班级管理)获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
|
||||
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
|
||||
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
|
||||
* </ol>
|
||||
@@ -40,8 +40,8 @@ public interface IUserCenterService {
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的模块树(左侧菜单)。
|
||||
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
|
||||
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
|
||||
* <p>从基础平台拉取当前用户模块树(含学校上下文);<b>层级由用户中心 {@code BoaModule.children} 下发</b>,
|
||||
* 本侧仅映射为前端 layout 期望的字段形状({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param schId 学校ID(可为 null)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.appauth.AppSignInterceptor;
|
||||
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key=
|
||||
*{@code flow-center}:yh.call.targets > yh.open.services > 枚举 base-host 兜底,
|
||||
* 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键
|
||||
* {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射
|
||||
* 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。
|
||||
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FlowCenterClient implements FlowClient {
|
||||
|
||||
/** flow-center 业务类型常量(契约冻结:bizType="notice"、bizId=notice.id) */
|
||||
public static final String BIZ_TYPE = "notice";
|
||||
|
||||
private static final String NOT_CONNECTED = "流程中心未接入,该功能不可用";
|
||||
|
||||
private final NoticeFlowProperties properties;
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
private final boolean directoryResolved;
|
||||
|
||||
public FlowCenterClient(NoticeFlowProperties properties,
|
||||
ObjectProvider<YschOpenCall> openCallProvider,
|
||||
ObjectProvider<LocalAppIdentityProvider> identityProvider,
|
||||
ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
YschOpenCall call = openCallProvider.getIfAvailable();
|
||||
if (call == null) {
|
||||
// 兜底自建(正常路径由 yh-openapi-starter 装配 YschOpenCall bean);identity 缺失时
|
||||
// AppSignInterceptor 自动跳过签名(请求裸发,enforce 端会 401——配置问题尽早暴露)
|
||||
call = new YschOpenCall(new com.yunhai.platform.openapi.core.YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> java.util.Collections.singletonList(
|
||||
(org.springframework.http.client.ClientHttpRequestInterceptor)
|
||||
new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath)));
|
||||
}
|
||||
this.openCall = call;
|
||||
// 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc
|
||||
this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key());
|
||||
if (directoryResolved) {
|
||||
com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(call);
|
||||
}
|
||||
String legacy = properties.getBaseUrl() == null ? "" : properties.getBaseUrl().trim();
|
||||
if (properties.isEnabled() && !directoryResolved && legacy.isEmpty()) {
|
||||
// 目录未配且 legacy 键也空:构造期 fail-fast(本键已无默认值),
|
||||
// 避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
|
||||
// enabled=false(显式未接入)时不校验——地址本就不需要。
|
||||
throw new IllegalStateException("流程中心调用地址未配置:请配置服务目录 yh.open.services."
|
||||
+ com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key()
|
||||
+ "(推荐,容器直连完整 URL)或回落键 notice.flow.base-url");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.listFlows(
|
||||
schId, FlowModels.FlowSummaryVo.class);
|
||||
}
|
||||
return openCall.getList(target(), "/oapi/flow/list", params("schId", schId),
|
||||
FlowModels.FlowSummaryVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.apply(
|
||||
schId, body, FlowModels.ApplyResultVo.class);
|
||||
}
|
||||
return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body,
|
||||
FlowModels.ApplyResultVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.instance(
|
||||
bizType, bizId, schId, FlowModels.InstanceVo.class);
|
||||
}
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", bizType);
|
||||
query.put("bizId", bizId);
|
||||
return openCall.get(target(), "/oapi/flow/instance", query, FlowModels.InstanceVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.registerCallback(
|
||||
schId, body, FlowModels.CallbackRegResultVo.class);
|
||||
}
|
||||
return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body,
|
||||
FlowModels.CallbackRegResultVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
com.yunhai.platform.openapi.flow.YschFlowOpenApi.withdraw(schId, instanceId, submitterId);
|
||||
return;
|
||||
}
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("submitterId", String.valueOf(submitterId));
|
||||
// 无请求体:对端 POST 无 @RequestBody,query 传参即可(body 传 null 即零长度请求体)
|
||||
openCall.post(target(), "/oapi/flow/instance/" + instanceId + "/withdraw", query, null, Void.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlowModels.ProgressVo progress(Integer schId, Long instanceId) {
|
||||
requireEnabled();
|
||||
if (directoryResolved) {
|
||||
return com.yunhai.platform.openapi.flow.YschFlowOpenApi.progress(
|
||||
instanceId, BIZ_TYPE, null, schId, FlowModels.ProgressVo.class);
|
||||
}
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("instanceId", String.valueOf(instanceId));
|
||||
query.put("schId", String.valueOf(schId));
|
||||
return openCall.get(target(), "/oapi/flow/progress", query, FlowModels.ProgressVo.class);
|
||||
}
|
||||
|
||||
/** 免签出图端点(flow-center OpenDiagramImageController,相对其 context-path) */
|
||||
private static final String DIAGRAM_IMG_PATH = "/open/flow/diagram/img";
|
||||
|
||||
@Override
|
||||
public byte[] diagramImage(Integer schId, Long instanceId) {
|
||||
requireEnabled();
|
||||
String imgUrl;
|
||||
if (directoryResolved) {
|
||||
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
|
||||
instanceId, null, null, schId, FlowModels.DiagramUrlVo.class);
|
||||
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||
} else {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("instanceId", String.valueOf(instanceId));
|
||||
query.put("schId", String.valueOf(schId));
|
||||
FlowModels.DiagramUrlVo dto =
|
||||
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
|
||||
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||
}
|
||||
String token = tokenOf(imgUrl);
|
||||
if (token == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
|
||||
}
|
||||
Map<String, String> imgQuery = new HashMap<>();
|
||||
imgQuery.put("token", token);
|
||||
byte[] bytes = directoryResolved
|
||||
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
|
||||
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从换图地址中取 token(形如 {@code /open/flow/diagram/img?token=xxx})。
|
||||
* 包可见:契约测试冻结"token 只在后端流转、前端拿不到"的取用形态。
|
||||
*/
|
||||
static String tokenOf(String imgUrl) {
|
||||
if (imgUrl == null || imgUrl.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int q = imgUrl.indexOf("?token=");
|
||||
if (q < 0) {
|
||||
return null;
|
||||
}
|
||||
String token = imgUrl.substring(q + "?token=".length());
|
||||
int amp = token.indexOf('&');
|
||||
if (amp >= 0) {
|
||||
token = token.substring(0, amp);
|
||||
}
|
||||
return token.isEmpty() ? null : token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] diagramImageByFlowGuid(Integer schId, String flowGuid) {
|
||||
requireEnabled();
|
||||
String imgUrl;
|
||||
if (directoryResolved) {
|
||||
FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl(
|
||||
null, flowGuid, null, schId, FlowModels.DiagramUrlVo.class);
|
||||
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||
} else {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("flowGuid", flowGuid);
|
||||
query.put("schId", String.valueOf(schId));
|
||||
FlowModels.DiagramUrlVo dto =
|
||||
openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class);
|
||||
imgUrl = dto == null ? null : dto.getImgUrl();
|
||||
}
|
||||
String token = tokenOf(imgUrl);
|
||||
if (token == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址");
|
||||
}
|
||||
Map<String, String> imgQuery = new HashMap<>();
|
||||
imgQuery.put("token", token);
|
||||
byte[] bytes = directoryResolved
|
||||
? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery)
|
||||
: openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery);
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private TargetSpec target() {
|
||||
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
||||
return TargetSpec.of(properties.getBaseUrl(), "");
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!properties.isEnabled()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> params(String k, Object v) {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
if (k != null && v != null) {
|
||||
m.put(k, String.valueOf(v));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程中心出站接缝(Notice 首接方案 §5.1,方法面与 svc-hall FlowCenterClient 一致)。
|
||||
* 接口保留三重意义:防依赖(业务层不感知 YschOpenCall)、可测试(fake 注入)、可降级(enabled 开关)。
|
||||
*/
|
||||
public interface FlowClient {
|
||||
|
||||
/** 已发布流程列表(编辑页"审核流程"选择器数据源) */
|
||||
List<FlowModels.FlowSummaryVo> listFlows(Integer schId);
|
||||
|
||||
/** 代发起(biz uk 幂等:重复 apply 同办件;30/40 态 R4 重启推进) */
|
||||
FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body);
|
||||
|
||||
/** 按业务键查办件(进度卡片/放行判定/对账兜底;不存在返回 null) */
|
||||
FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId);
|
||||
|
||||
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
|
||||
FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body);
|
||||
|
||||
/**
|
||||
* 撤回(托管件,2026-09-17 接入):对端按 submitterId 与办件提交人逐字复核,
|
||||
* 应用签名只证明"哪个应用在调"不证明"谁在撤"——防业务应用越权代撤他人办件。
|
||||
*/
|
||||
void withdraw(Integer schId, Long instanceId, Integer submitterId);
|
||||
|
||||
/** 办件进度(结构化 steps,instanceId 定位;oapi 只校验 schId,调用方先做可见性守卫) */
|
||||
FlowModels.ProgressVo progress(Integer schId, Long instanceId);
|
||||
|
||||
/** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */
|
||||
byte[] diagramImage(Integer schId, Long instanceId);
|
||||
|
||||
/**
|
||||
* 流程定义示意图字节(def 口径,绑定页/发通知页 Drawer):按 flowGuid 取最高已发布版出图。
|
||||
* 与办件图同一免签 token 通道;本校限定由 flow-center 按 schId+flowGuid 收敛(绑定已解除/
|
||||
* 无已发布版 → 业务异常文案,不留 500)。
|
||||
*/
|
||||
byte[] diagramImageByFlowGuid(Integer schId, String flowGuid);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批出站客户端(第三步 T-C4):待办/办件查询 + 通过/驳回,经网关打 flow-center
|
||||
* {@code /oapi/flow/tasks|task/**}(2026-09-23 已上线)。
|
||||
* <p>
|
||||
* <b>刻意用 {@link YschOpenCall} 泛化调用而非 YschFlowOpenApi 新门面方法</b>——四方法
|
||||
* (todoTasks/instances/approveTask/rejectTask)在 SDK main 已编码但**未发版**,notice 运行时
|
||||
* 依赖的是 nexus 已发布快照;泛化调用与 FlowCenterClient legacy 分支同一条
|
||||
* resolveTarget 解析链(targets > services > 枚举 base-host 兜底=网关),零发版依赖。
|
||||
* SDK 发版后可平替为门面方法(行为等价,测试锁 URL 不变)。
|
||||
* <p>
|
||||
* 身份口径:operatorId/operatorName 由调用方(controller)从 CAS 登录态取,本类不猜身份;
|
||||
* 对端按任务办理人逐字复核(非办理人→业务码拒绝),notice 不重复校验。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class NoticeApprovalClient {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOW_CENTER.key();
|
||||
private static final String NOT_CONNECTED = "流程中心未接入,审批功能不可用";
|
||||
|
||||
private final NoticeFlowProperties properties;
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
public NoticeApprovalClient(NoticeFlowProperties properties,
|
||||
ObjectProvider<YschOpenCall> openCallProvider) {
|
||||
this.properties = properties;
|
||||
this.openCall = openCallProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
/** 我的待办(bizType 固定 notice):Map 形态即 flow-center BasePageResult JSON(total/content/…) */
|
||||
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("assigneeId", String.valueOf(assigneeId));
|
||||
query.put("bizType", FlowCenterClient.BIZ_TYPE);
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class);
|
||||
}
|
||||
|
||||
/** 办件分页(bizType 固定 notice;status 可空) */
|
||||
public Map<String, Object> instances(Integer schId, Integer status, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", FlowCenterClient.BIZ_TYPE);
|
||||
if (status != null) {
|
||||
query.put("status", String.valueOf(status));
|
||||
}
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在本通知上的待办(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 透传) */
|
||||
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
|
||||
String opinion, String edgeName) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (opinion != null && !opinion.trim().isEmpty()) {
|
||||
body.put("opinion", opinion.trim());
|
||||
}
|
||||
if (edgeName != null && !edgeName.trim().isEmpty()) {
|
||||
body.put("edgeName", edgeName.trim());
|
||||
}
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填由对端强制;此处前置校验给前端更快的反馈) */
|
||||
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
|
||||
requireEnabled();
|
||||
if (opinion == null || opinion.trim().isEmpty()) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见");
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", opinion.trim());
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
private static Map<String, String> operatorQuery(Integer schId, Integer operatorId, String operatorName) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("operatorId", String.valueOf(operatorId));
|
||||
query.put("operatorName", operatorName);
|
||||
return query;
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!properties.isEnabled() || openCall == null) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.enums.NoticeStatusEnum;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
|
||||
* 验签(平台 X-App-*,/oapi/* 强制)、ping、必填校验、bizType 过滤、eventId 幂等、
|
||||
* instanceVersion 乱序门由 starter 接收管线承担;本类只做通知侧状态机推进——
|
||||
* <br>事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 发布
|
||||
* + 短信后置);rejected→30;withdrawn→40;cc.sent→只落 notice_cc 抄送行(不动状态机,
|
||||
* 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。
|
||||
* <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
|
||||
* 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。
|
||||
*
|
||||
* <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
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowCallbackHandler implements FlowCallbackHandler {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||
|
||||
@Override
|
||||
public void onEvent(FlowCallbackEvent event) {
|
||||
Integer noticeId;
|
||||
try {
|
||||
noticeId = Integer.valueOf(event.getBizId().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", event.getBizId());
|
||||
return;
|
||||
}
|
||||
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
|
||||
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
|
||||
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
|
||||
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", event.getBizId());
|
||||
return;
|
||||
}
|
||||
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的 notice 版本为准)
|
||||
if (event.getInstanceVersion() != null && notice.getLastInstanceVersion() != null
|
||||
&& event.getInstanceVersion() < notice.getLastInstanceVersion()) {
|
||||
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
|
||||
noticeId, event.getInstanceVersion(), notice.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
advance(notice, event);
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
private void advance(Notice notice, FlowCallbackEvent event) {
|
||||
String code = event.getEvent();
|
||||
Integer instanceVersion = event.getInstanceVersion();
|
||||
Integer target;
|
||||
switch (code) {
|
||||
case "flow.cc.sent":
|
||||
recordCc(notice, event);
|
||||
return;
|
||||
case "flow.completed":
|
||||
target = FlowAuditStatusEnum.APPROVED.getCode();
|
||||
break;
|
||||
case "flow.rejected":
|
||||
target = FlowAuditStatusEnum.REJECTED.getCode();
|
||||
break;
|
||||
case "flow.withdrawn":
|
||||
target = FlowAuditStatusEnum.WITHDRAWN.getCode();
|
||||
break;
|
||||
case "flow.submitted":
|
||||
case "flow.task.created":
|
||||
case "flow.task.handled":
|
||||
target = FlowAuditStatusEnum.APPROVING.getCode();
|
||||
break;
|
||||
default:
|
||||
log.warn("[notice-flow] 未知事件(丢弃):{}", code);
|
||||
return;
|
||||
}
|
||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
|
||||
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
|
||||
if (isTerminal(notice.getAuditStatus())) {
|
||||
if (!isTerminal(target)) {
|
||||
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
|
||||
notice.getId(), notice.getAuditStatus(), code, instanceVersion);
|
||||
return;
|
||||
}
|
||||
if (StringUtils.hasText(notice.getFlowResultJson())) {
|
||||
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
|
||||
notice.getId(), notice.getAuditStatus(), code);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notice.setAuditStatus(target);
|
||||
if (instanceVersion != null) {
|
||||
notice.setLastInstanceVersion(instanceVersion);
|
||||
}
|
||||
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
|
||||
if (isTerminal(target)) {
|
||||
notice.setFlowResultJson(snapshot(event));
|
||||
}
|
||||
boolean justPublished = false;
|
||||
if (target == FlowAuditStatusEnum.APPROVED.getCode()) {
|
||||
// 审核通过 = 正式发布:置 status=1(用户端自此可见)+ 发布后置(短信后移到此刻)
|
||||
if (notice.getStatus() == null || notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode()) {
|
||||
notice.setStatus(NoticeStatusEnum.PUBLISHED.getCode());
|
||||
justPublished = true;
|
||||
}
|
||||
}
|
||||
noticeRepository.saveAndFlush(notice);
|
||||
if (justPublished) {
|
||||
sendSmsAfterPublish(notice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送留痕(2026-09-24,flow.cc.sent):逐人一条事件逐条落 notice_cc,供应用内
|
||||
* 「抄送给我的」列表渲染、点击跳通知详情。不动审核状态机(抄送是知会不是审批,
|
||||
* taskId 恒 null 也不落任务);已通过行锁与乱序闸门,重放由 eventId 幂等拦截。
|
||||
* data 缺 ccUserId 属上游脏数据:记日志跳过(回 200),落库异常照旧外抛回 5xx 重试。
|
||||
*/
|
||||
private void recordCc(Notice notice, FlowCallbackEvent event) {
|
||||
Map<String, Object> data = event.getData();
|
||||
Object ccUserId = data == null ? null : data.get("ccUserId");
|
||||
if (ccUserId == null || String.valueOf(ccUserId).isEmpty()) {
|
||||
log.warn("[notice-flow] cc.sent 缺 ccUserId(丢弃):notice={} eventId={}",
|
||||
notice.getId(), event.getEventId());
|
||||
return;
|
||||
}
|
||||
com.yunhai.notice.entity.NoticeCc cc = new com.yunhai.notice.entity.NoticeCc();
|
||||
cc.setSchId(notice.getSchId());
|
||||
cc.setNoticeId(notice.getId());
|
||||
cc.setFlowInstanceId(event.getInstanceId());
|
||||
cc.setNodeId(data.get("nodeId") == null ? null : String.valueOf(data.get("nodeId")));
|
||||
cc.setNodeName(data.get("nodeName") == null ? null : String.valueOf(data.get("nodeName")));
|
||||
cc.setCcUserId(Integer.valueOf(String.valueOf(ccUserId)));
|
||||
cc.setCcUserName(data.get("ccUserName") == null ? null : String.valueOf(data.get("ccUserName")));
|
||||
cc.setSentTime(LocalDateTime.now());
|
||||
noticeCcRepository.save(cc);
|
||||
log.info("[notice-flow] 抄送留痕:notice={} instance={} ccUser={} node={}",
|
||||
notice.getId(), event.getInstanceId(), cc.getCcUserId(), cc.getNodeName());
|
||||
}
|
||||
|
||||
private String snapshot(FlowCallbackEvent event) { if (event.getData() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(event.getData());
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-flow] 终态快照序列化失败(置 null 不阻断推进):{}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过后的短信后置(绑流程通知 create 时不发,方案 §5.2 短信时机后移) */
|
||||
private void sendSmsAfterPublish(Notice notice) {
|
||||
if (notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
|
||||
return;
|
||||
}
|
||||
// 与直发链路 trySendSms 的 isImmediatePublish 语义对齐:定时未到点的通知此刻不发
|
||||
// (可见性已生效),到点由 SmsCatchupJob 补发(§3.2 修 BP-R5-2 短信永久丢失缺口)
|
||||
if (notice.getTimingSendTime() != null
|
||||
&& notice.getTimingSendTime().isAfter(LocalDateTime.now())) {
|
||||
return;
|
||||
}
|
||||
smsSendService.sendOnce(notice);
|
||||
}
|
||||
|
||||
/** 审批终态(20 通过 / 30 驳回 / 40 撤回)——终态行的状态与结果明细只允许被新一轮(本地先置 0)改写 */
|
||||
private static boolean isTerminal(Integer auditStatus) {
|
||||
return auditStatus != null
|
||||
&& (auditStatus == FlowAuditStatusEnum.APPROVED.getCode()
|
||||
|| auditStatus == FlowAuditStatusEnum.REJECTED.getCode()
|
||||
|| auditStatus == FlowAuditStatusEnum.WITHDRAWN.getCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowEmbedUrl;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页换址(2026-10-03 嵌入页方案):子应用 Drawer 内 iframe 直嵌 flow-center
|
||||
* 统一进度页,零样式复制。与 Diagram 同口径的用户级守卫(未发布仅提交人/管理员/办理人
|
||||
* /被抄送人可见);oapi 层只认 schId——"谁能看"必须落在本端点,应用签名不替代身份。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowEmbedService {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final NoticeFlowGuard noticeFlowGuard;
|
||||
|
||||
/**
|
||||
* 取嵌入页地址(pageUrl 含 10 分钟短时 token):现取现发、不缓存、不落库、
|
||||
* 不打进应用日志(token 泄露窗口即有效期,纪律见方案 §6.1/§7)。
|
||||
*/
|
||||
public Map<String, String> embedUrl(Integer noticeId, BoaUser user) {
|
||||
Notice notice = noticeRepository.findById(noticeId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||
if (notice.getStatus() == null || notice.getStatus() != 1) {
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
}
|
||||
if (notice.getFlowInstanceId() == null || notice.getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程");
|
||||
}
|
||||
FlowEmbedUrl embed = YschFlowOpenApi.embedUrl(
|
||||
null, FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()),
|
||||
notice.getSchId(), FlowEmbedUrl.class);
|
||||
if (embed == null || !StringUtils.hasText(embed.getPageUrl())) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址获取失败,请稍后重试");
|
||||
}
|
||||
return Collections.singletonMap("pageUrl", embed.getPageUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
|
||||
* 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
|
||||
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/
|
||||
* 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。
|
||||
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowGuard {
|
||||
|
||||
/** 管理员角色白名单(校级管理员/分管领导等,与既有菜单权限体系对齐) */
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private static final long ASSIGNEE_CACHE_MS = 60_000L;
|
||||
|
||||
private final FlowClient flowClient;
|
||||
private final NoticeFlowProperties properties;
|
||||
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||
|
||||
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CachedAssignees {
|
||||
final List<Integer> userIds;
|
||||
final long expireAt;
|
||||
|
||||
CachedAssignees(List<Integer> userIds) {
|
||||
this.userIds = userIds;
|
||||
this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户可查看未发布通知;不可见抛 FORBIDDEN(文案=查不到语义)。
|
||||
* status=1 已发布通知不走本守卫(既有行为零变化)。
|
||||
*/
|
||||
public void requireUnpublishedVisible(Notice notice, BoaUser user) {
|
||||
if (notice == null || user == null) {
|
||||
throw notFound();
|
||||
}
|
||||
// 1) 提交人本人
|
||||
if (StringUtils.hasText(notice.getPublisherAccount())
|
||||
&& notice.getPublisherAccount().equals(user.getAccount())) {
|
||||
return;
|
||||
}
|
||||
// 2) 本校管理员(type=1 校管 或 roleId 命中白名单)
|
||||
if (isAdmin(user)) {
|
||||
return;
|
||||
}
|
||||
// 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见)
|
||||
if (notice.getFlowInstanceId() != null && properties.isEnabled()
|
||||
&& currentAssignees(notice).contains(user.getId())) {
|
||||
return;
|
||||
}
|
||||
// 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心)
|
||||
if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) {
|
||||
return;
|
||||
}
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员校验(应用设置 > 流程绑定保存用):绑定决定全校"发全部用户是否需要审核",
|
||||
* 与"谁能看未发布通知"同一口径({@link #ADMIN_ROLE_IDS})。
|
||||
*/
|
||||
public void requireAdmin(BoaUser user) {
|
||||
if (user == null || !isAdmin(user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅本校管理员可修改审核流程绑定");
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否本校管理员(口径同下方守卫;绑定页据此决定是否可编辑) */
|
||||
public boolean isAdmin(BoaUser user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return true;
|
||||
}
|
||||
if (user.getRoleIds() != null) {
|
||||
for (Integer roleId : user.getRoleIds()) {
|
||||
if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Integer> currentAssignees(Notice notice) {
|
||||
long now = System.currentTimeMillis();
|
||||
CachedAssignees cached = assigneeCache.get(notice.getFlowInstanceId());
|
||||
if (cached != null && cached.expireAt > now) {
|
||||
return cached.userIds == null ? java.util.Collections.emptyList() : cached.userIds;
|
||||
}
|
||||
List<Integer> assignees = null;
|
||||
try {
|
||||
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
|
||||
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
|
||||
if (instance != null) {
|
||||
assignees = instance.getAssigneeIds();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 流程中心不可达:fail-closed(不放行),日志留痕
|
||||
log.warn("[notice-flow] 办理人查询失败(fail-closed):notice={} {}", notice.getId(), e.toString());
|
||||
}
|
||||
assigneeCache.put(notice.getFlowInstanceId(),
|
||||
new CachedAssignees(assignees == null ? java.util.Collections.emptyList() : assignees));
|
||||
return assignees == null ? java.util.Collections.emptyList() : assignees;
|
||||
}
|
||||
|
||||
private BizException notFound() {
|
||||
return new BizException(ErrorCode.FORBIDDEN, "通知不存在或未发布");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接缝配置(notice.flow.*)。front-base-url 已删(2026-09-26):
|
||||
* bizUrl 改传裸相对路径,绝对化由接收方按页面归属承担(自指地址优化 Phase 2)。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "notice.flow")
|
||||
public class NoticeFlowProperties {
|
||||
|
||||
/** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* flow-center 基址的 <b>legacy 回落</b>(Tier 0 ad-hoc TargetSpec 路径)。
|
||||
* 主路径是服务目录 {@code yh.open.services.flowcenter} → {@code YschFlowOpenApi} 门面;
|
||||
* 本键仅迁移期兼容,<b>不再给默认值</b>——两者都空时 {@code FlowCenterClient} 构造期
|
||||
* fail-fast,避免静默打 localhost 把"地址没配"伪装成"流程中心不可达"。
|
||||
*/
|
||||
private String baseUrl = "";
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||
import com.yunhai.notice.entity.UpFile;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.notice.utils.ReceiveParser;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.yschflow.FlowApplySpec;
|
||||
import com.yunhai.platform.yschflow.FlowSubmitTemplate;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知-流程中心接合服务:提交/重提(D9 AFTER_COMMIT 出站)、回调注册管理、进度组装。
|
||||
* 提交语义(方案 §5.2):apply 幂等(bizId=notice.id),失败保持 0 待提交可重推;
|
||||
* 10 审批中禁止重复提交/编辑/删除(删除需先撤回)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoticeFlowService {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final NoticeFlowBindingRepository bindingRepository;
|
||||
private final UpFileRepository upFileRepository;
|
||||
private final FlowClient flowClient;
|
||||
private final NoticeFlowProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CurrentBoaUser currentBoaUser;
|
||||
private final NoticeFlowGuard noticeFlowGuard;
|
||||
private final FlowSubmitTemplate flowSubmitTemplate;
|
||||
|
||||
/**
|
||||
* 提交审核(D9 AFTER_COMMIT 出站,编排纪律交 {@link FlowSubmitTemplate},2026-10-17 T3 迁移):
|
||||
* 出站动作在事务提交后的独立线程执行,提交人身份与单据快照在请求线程先取好。
|
||||
* <p>E2E 实测教训(迁移前本类自带线程池,语义不变):afterCommit 回调若留在原请求线程执行,
|
||||
* OSIV 绑定的 EntityManager 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行);
|
||||
* 「能不能提交」的守卫也必须在提交后的状态上判定,故随 spec 的 preflight 一起搬到出站线程。</p>
|
||||
*/
|
||||
public void submitAfterCommit(Integer noticeId) {
|
||||
final BoaUser user = currentUser();
|
||||
final Notice notice = requireNotice(noticeId);
|
||||
flowSubmitTemplate.submitAfterCommit(noticeId,
|
||||
specOf(notice, user).preflight(() -> checkSubmittable(notice, user)));
|
||||
}
|
||||
|
||||
/** 提交/重提审核(幂等入口:编辑页重提按钮);失败当场抛业务异常给调用方 */
|
||||
public void trySubmitFlow(Integer noticeId) {
|
||||
trySubmitFlow(noticeId, currentUser());
|
||||
}
|
||||
|
||||
/** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */
|
||||
public void trySubmitFlow(Integer noticeId, BoaUser user) {
|
||||
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 原先仅验存在性,任意登录用户可对他人通知触发重提——补
|
||||
// NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
if (!StringUtils.hasText(notice.getFlowGuid())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未绑定审核流程");
|
||||
}
|
||||
Integer audit = notice.getAuditStatus();
|
||||
if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起编排入参(契约冻结:bizId=notice.id、bizType=notice;bizUrl 不再手工传,
|
||||
* 由 starter 按 {@code yh.flow-biz.detail-route} 拼装,产物与迁移前的
|
||||
* {@code JumpTarget.page("list/detail").param("serid", id)} 逐字节相同)。
|
||||
* 标题/表单/快照都取自请求线程读到的已提交实体值,出站前不落库。
|
||||
*/
|
||||
private FlowApplySpec specOf(Notice notice, BoaUser user) {
|
||||
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)));
|
||||
}
|
||||
|
||||
/** formData 键与变量契约同源(yh.flow-biz.variables=[deptId, deptName],发送前由 starter 本地预检) */
|
||||
private Map<String, Object> buildFormData(Notice notice) {
|
||||
Map<String, Object> formData = new LinkedHashMap<>();
|
||||
formData.put("deptId", notice.getDeptId());
|
||||
formData.put("deptName", notice.getDeptName());
|
||||
return formData;
|
||||
}
|
||||
|
||||
/** 业务摘要:原键照旧(流程中心透传、嵌入页不展示)+ 约定键 summaryFields(嵌入页「业务信息」唯一渲染源) */
|
||||
private Map<String, Object> buildSnapshot(Notice notice) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("title", notice.getTitle());
|
||||
snapshot.put("summary", plainSummary(notice.getContent()));
|
||||
List<String> names = new ArrayList<>();
|
||||
for (UpFile f : upFileRepository.findByTopicTypeAndTopicId("notice", notice.getId())) {
|
||||
if (StringUtils.hasText(f.getFileTrueName())) {
|
||||
names.add(f.getFileTrueName());
|
||||
}
|
||||
}
|
||||
snapshot.put("attachmentNames", names);
|
||||
snapshot.put("summaryFields", buildSummaryFields(notice));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** 嵌入页「业务信息」三行:通知标题 / 来源部门 / 通知对象 */
|
||||
private List<Map<String, Object>> buildSummaryFields(Notice notice) {
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
fields.add(summaryField("通知标题", notice.getTitle()));
|
||||
fields.add(summaryField("来源部门", notice.getDeptName()));
|
||||
fields.add(summaryField("通知对象", buildReceiveSummary(notice)));
|
||||
return fields;
|
||||
}
|
||||
|
||||
private Map<String, Object> summaryField(String label, Object value) {
|
||||
Map<String, Object> field = new LinkedHashMap<>();
|
||||
field.put("label", label);
|
||||
field.put("value", value == null ? "" : value);
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知对象展示串:全员(receive_list 空 / ALL / UserAll)⇒ "全部";指定 ⇒ 前 3 个姓名以「、」连接,
|
||||
* 多于 3 人再追 " 等N人"(如 "张三、李四、王五 等11人")。
|
||||
* 姓名经用户中心解析(手法同 NoticeService.getReadDetailGrouped),失败回落原 id/账号;
|
||||
* 只解析前 3 个——本方法在 apply 提交时执行,避免 N 次远程调用。
|
||||
*/
|
||||
private String buildReceiveSummary(Notice notice) {
|
||||
List<String> accounts = ReceiveParser.parse(notice.getReceiveList());
|
||||
if (accounts.isEmpty()) {
|
||||
return "全部";
|
||||
}
|
||||
int total = accounts.size();
|
||||
List<String> shown = new ArrayList<>();
|
||||
for (String account : accounts.subList(0, Math.min(3, total))) {
|
||||
shown.add(resolveReceiverName(account));
|
||||
}
|
||||
String joined = String.join("、", shown);
|
||||
return total > 3 ? joined + " 等" + total + "人" : joined;
|
||||
}
|
||||
|
||||
/** 接收明细可能存登录名,也可能是历史数字 userId:先按登录名查,查不到且纯数字再按 id 查 */
|
||||
private String resolveReceiverName(String account) {
|
||||
try {
|
||||
BoaUser user = YschBaseOpenApi.getUserInfo(account);
|
||||
if (user == null && StringUtils.hasText(account) && account.chars().allMatch(Character::isDigit)) {
|
||||
user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account));
|
||||
}
|
||||
return user != null && StringUtils.hasText(user.getName()) ? user.getName() : account;
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-flow] 通知对象姓名解析失败 account={}", account, e);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
/** 富文本剥标签取纯文本摘要(截断 300 字) */
|
||||
private String plainSummary(String content) {
|
||||
if (!StringUtils.hasText(content)) {
|
||||
return "";
|
||||
}
|
||||
String text = content.replaceAll("<[^>]+>", "").replaceAll(" ", " ").trim();
|
||||
return text.length() > 300 ? text.substring(0, 300) : text;
|
||||
}
|
||||
|
||||
/** 进度组装(trace 接口):auditStatus/status/version/trace[]/currentNodes/nodeSummary;未绑流程返回未接入语义 */
|
||||
public Map<String, Object> buildTrace(Integer noticeId) {
|
||||
return buildTrace(noticeId, currentUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度组装(带访问者,BP-R5-3):未发布通知(status=0,审核载体)仅提交人/管理员/办理人
|
||||
* 可见轨迹与驳回意见(NoticeFlowGuard 三分支);已发布(status=1)内容本就公开,登录即可看。
|
||||
*/
|
||||
public Map<String, Object> buildTrace(Integer noticeId, BoaUser user) {
|
||||
Notice notice = requireNotice(noticeId);
|
||||
if (notice.getStatus() == null || notice.getStatus() != 1) {
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("bound", StringUtils.hasText(notice.getFlowGuid()));
|
||||
result.put("flowGuid", notice.getFlowGuid());
|
||||
result.put("flowInstanceId", notice.getFlowInstanceId());
|
||||
result.put("auditStatus", notice.getAuditStatus());
|
||||
result.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
|
||||
result.put("status", notice.getStatus());
|
||||
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
|
||||
return result; // 未接入流程中心(编辑页/详情页据此隐藏进度卡片)
|
||||
}
|
||||
result.put("flowResult", parseJson(notice.getFlowResultJson()));
|
||||
if (properties.isEnabled()) {
|
||||
try {
|
||||
FlowModels.InstanceVo instance = flowClient.getInstanceByBiz(
|
||||
notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId()));
|
||||
if (instance != null) {
|
||||
result.put("instanceStatus", instance.getStatus());
|
||||
result.put("version", instance.getVersion());
|
||||
result.put("nodeSummary", instance.getNodeSummary());
|
||||
result.put("currentNodes", instance.getCurrentNodes());
|
||||
result.put("assigneeIds", instance.getAssigneeIds());
|
||||
result.put("submitTime", instance.getSubmitTime());
|
||||
result.put("finishTime", instance.getFinishTime());
|
||||
result.put("trace", parseJson(instance.getTraceJson()));
|
||||
}
|
||||
if (notice.getFlowInstanceId() != null) {
|
||||
// 结构化步骤(steps[0] 固定"发起申请",同节点多任务每人一行)——
|
||||
// 2026-09-17 接入 SDK progress,详情页时间线数据源;失败降级不阻断
|
||||
FlowModels.ProgressVo progress =
|
||||
flowClient.progress(notice.getSchId(), notice.getFlowInstanceId());
|
||||
if (progress != null) {
|
||||
result.put("steps", progress.getSteps());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
|
||||
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面——跨应用 CAS 票据 + CORS
|
||||
* 双重外溢,且"谁能撤"落在前端)。现经 SDK 托管件后端中转:①本人(publisherAccount)
|
||||
* = 权威授权;②审批中(10) 且已有实例;③对端再按 submitterId 复核一次。
|
||||
* 撤回后镜像状态由 flow.withdrawn 回调推进(40),本方法不改本地状态——单一写入口。
|
||||
*/
|
||||
public void withdrawNotice(Integer noticeId, BoaUser user) {
|
||||
Notice notice = requireNotice(noticeId);
|
||||
if (user == null || !StringUtils.hasText(notice.getPublisherAccount())
|
||||
|| !notice.getPublisherAccount().equals(user.getAccount())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅通知创建人可撤回审核");
|
||||
}
|
||||
Integer audit = notice.getAuditStatus();
|
||||
if (audit == null || audit != FlowAuditStatusEnum.APPROVING.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的通知可撤回");
|
||||
}
|
||||
if (notice.getFlowInstanceId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无需撤回");
|
||||
}
|
||||
flowClient.withdraw(notice.getSchId(), notice.getFlowInstanceId(), user.getId());
|
||||
log.info("[notice-flow] 撤回审核:notice={} instance={} user={}",
|
||||
noticeId, notice.getFlowInstanceId(), user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程示意图(PNG 流,同源直嵌 {@code <img>}):可见性与 Trace 同口径(未发布仅三类人)。
|
||||
* token 与字节都在服务端流转,前端不再需要知道 flow-center 地址。
|
||||
*/
|
||||
public byte[] diagramImage(Integer noticeId, BoaUser user) {
|
||||
Notice notice = requireNotice(noticeId);
|
||||
if (notice.getStatus() == null || notice.getStatus() != 1) {
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, user);
|
||||
}
|
||||
if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知未接入审核流程");
|
||||
}
|
||||
if (notice.getFlowInstanceId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无流程图");
|
||||
}
|
||||
return flowClient.diagramImage(notice.getSchId(), notice.getFlowInstanceId());
|
||||
}
|
||||
|
||||
/** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */
|
||||
public List<FlowModels.FlowSummaryVo> options(Integer schId) {
|
||||
if (!properties.isEnabled()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return flowClient.listFlows(schId);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("[notice-flow] 流程列表查询失败:{}", e.toString());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核流程列表获取失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private BoaUser currentUser() {
|
||||
return currentBoaUser == null ? null : currentBoaUser.get();
|
||||
}
|
||||
|
||||
// ==================== 应用级流程绑定(应用设置 > 流程绑定) ====================
|
||||
|
||||
/**
|
||||
* 本校绑定视图(设置页展示 / 发通知页按钮显隐):bound / flowGuid / flowName / flowEnabled / canEdit。
|
||||
* 无学校身份或未绑定 → bound=false。读取不需要管理员(发通知页人人都要据此判断是否走审核)。
|
||||
*/
|
||||
public Map<String, Object> bindingView(Integer schId, BoaUser user) {
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
NoticeFlowBinding binding = schId == null ? null : bindingRepository.findBySchId(schId).orElse(null);
|
||||
boolean bound = binding != null && StringUtils.hasText(binding.getFlowGuid());
|
||||
view.put("bound", bound);
|
||||
view.put("flowGuid", bound ? binding.getFlowGuid() : null);
|
||||
view.put("flowName", bound ? binding.getFlowName() : null);
|
||||
view.put("flowEnabled", properties.isEnabled());
|
||||
view.put("canEdit", noticeFlowGuard.isAdmin(user));
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发通知分流唯一判定入口:本校已绑定且流程中心可用时返回绑定 GUID;否则 null(走直发)。
|
||||
* 流程中心未接入(properties.enabled=false)时不进审核轨道——否则通知会永远停在未发布态。
|
||||
*/
|
||||
public String boundFlowGuid(Integer schId) {
|
||||
if (schId == null || !properties.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
|
||||
if (binding == null || !StringUtils.hasText(binding.getFlowGuid())) {
|
||||
return null;
|
||||
}
|
||||
return binding.getFlowGuid();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存绑定(空 flowGuid=解绑,直接删行)。绑定的流程必须是流程中心"已发布"流程——
|
||||
* 值由 options() 同一数据源校验,避免手改请求把不存在的 GUID 落库(发通知时才发现)。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> saveBinding(BoaUser user, String flowGuid) {
|
||||
noticeFlowGuard.requireAdmin(user);
|
||||
Integer schId = currentSchId(user);
|
||||
NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null);
|
||||
if (!StringUtils.hasText(flowGuid)) {
|
||||
if (binding != null) {
|
||||
bindingRepository.delete(binding);
|
||||
log.info("[notice-flow] 解除流程绑定:schId={} 原流程={}", schId, binding.getFlowGuid());
|
||||
}
|
||||
return bindingView(schId, user);
|
||||
}
|
||||
String name = requirePublishedFlowName(schId, flowGuid.trim());
|
||||
if (binding == null) {
|
||||
binding = new NoticeFlowBinding();
|
||||
binding.setSchId(schId);
|
||||
}
|
||||
binding.setFlowGuid(flowGuid.trim());
|
||||
binding.setFlowName(name);
|
||||
bindingRepository.save(binding);
|
||||
log.info("[notice-flow] 保存流程绑定:schId={} flowGuid={} name={}", schId, binding.getFlowGuid(), name);
|
||||
return bindingView(schId, user);
|
||||
}
|
||||
|
||||
/** 校验流程 GUID 属于本校已发布流程并返回流程名(流程中心不可达时抛业务异常,不落脏绑定) */
|
||||
private String requirePublishedFlowName(Integer schId, String flowGuid) {
|
||||
for (FlowModels.FlowSummaryVo flow : options(schId)) {
|
||||
if (flowGuid.equals(flow.getFlowGuid())) {
|
||||
return flow.getName();
|
||||
}
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + flowGuid);
|
||||
}
|
||||
|
||||
private Integer currentSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无法确定当前学校身份,请重新登录后再试");
|
||||
}
|
||||
return user.getCurrentRole().getSchId();
|
||||
}
|
||||
|
||||
private Notice requireNotice(Integer noticeId) {
|
||||
return noticeRepository.findById(noticeId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,62 +2,52 @@ package com.yunhai.notice.service.impl;
|
||||
|
||||
import com.yunhai.notice.model.SchTeaList;
|
||||
import com.yunhai.notice.service.IClassService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.openapi.clazz.YschClassOpenApi;
|
||||
import com.yunhai.platform.openapi.clazz.model.SchTea;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班级管理(bschclass)开放接口实现。
|
||||
* bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。
|
||||
* 班级管理(userclass)开放接口实现。<br>
|
||||
* 2026-09-16 SDK 重构起改为委托类型化门面 {@link YschClassOpenApi}(服务目录 key=userclass:
|
||||
* services/枚举兜底统一解析,签名/信封/错误语义由 SDK 承载),替代原手搓 RestTemplate +
|
||||
* legacy 键 {@code yh.open.userclass} 直连(无签名旁路)。Notice 侧模型 {@link SchTeaList}
|
||||
* 保留(消费方签名零改动),此处仅做字段映射。
|
||||
*/
|
||||
@Service
|
||||
public class ClassService implements IClassService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClassService.class);
|
||||
|
||||
/** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */
|
||||
@Value("${yh.open.bschclass:}")
|
||||
private String bschClassHost;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public ClassService() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
factory.setReadTimeout(10000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SchTeaList> getSchTeaList(int schId) {
|
||||
if (!StringUtils.hasText(bschClassHost)) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
|
||||
}
|
||||
String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId;
|
||||
try {
|
||||
ResponseEntity<List<SchTeaList>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<SchTeaList>>() {
|
||||
});
|
||||
List<SchTeaList> list = resp.getBody();
|
||||
log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size());
|
||||
return list != null ? list : Collections.emptyList();
|
||||
} catch (RestClientException e) {
|
||||
log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e);
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败");
|
||||
List<SchTea> teachers = YschClassOpenApi.getSchTeaList(schId);
|
||||
log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, teachers == null ? 0 : teachers.size());
|
||||
if (teachers == null || teachers.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<SchTeaList> result = new ArrayList<>(teachers.size());
|
||||
for (SchTea tea : teachers) {
|
||||
result.add(map(tea));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SchTeaList map(SchTea tea) {
|
||||
SchTeaList t = new SchTeaList();
|
||||
t.setSchSerID(tea.getSchSerId());
|
||||
t.setTeaTel(tea.getTeaTel());
|
||||
t.setTeaName(tea.getTeaName());
|
||||
t.setUserTeaSerID(tea.getUserTeaSerId());
|
||||
t.setTeaIDCard(tea.getTeaIdCard());
|
||||
t.setTeaNo(tea.getTeaNo());
|
||||
t.setGroupIds(tea.getGroupIds());
|
||||
t.setGroupId(tea.getGroupId());
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.yunhai.notice.service.IContactsService;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
@@ -53,11 +53,29 @@ public class FileService implements IFileService {
|
||||
@Autowired
|
||||
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 static final String DEFAULT_APP_TYPE = "notice";
|
||||
|
||||
/**
|
||||
* 解析存储绝对路径。uploadPath 需以绝对路径配置(生产容器内 /data/upload);
|
||||
* 若误配为相对路径,此处统一锚定到进程工作目录——否则 MultipartFile.transferTo
|
||||
* 会把相对目标解析到 Tomcat multipart 临时目录,与后续读取(按工作目录解析)分裂。
|
||||
*/
|
||||
private Path resolvePath(String relative) {
|
||||
return Paths.get(uploadPath, relative).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
// ============================ 上传 ============================
|
||||
|
||||
@Override
|
||||
@@ -83,7 +101,7 @@ public class FileService implements IFileService {
|
||||
String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName;
|
||||
|
||||
// 物理写入
|
||||
Path fullPath = Paths.get(uploadPath, relativePath);
|
||||
Path fullPath = resolvePath(relativePath);
|
||||
try {
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
file.transferTo(fullPath.toFile());
|
||||
@@ -150,7 +168,7 @@ public class FileService implements IFileService {
|
||||
private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) {
|
||||
UpFile file = upFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
|
||||
Path fullPath = Paths.get(uploadPath, file.getFileName());
|
||||
Path fullPath = resolvePath(file.getFileName());
|
||||
if (!Files.exists(fullPath)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName());
|
||||
}
|
||||
@@ -218,7 +236,7 @@ public class FileService implements IFileService {
|
||||
UpFile file = upFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
|
||||
// 删物理文件
|
||||
Path fullPath = Paths.get(uploadPath, file.getFileName());
|
||||
Path fullPath = resolvePath(file.getFileName());
|
||||
try {
|
||||
Files.deleteIfExists(fullPath);
|
||||
} catch (Exception e) {
|
||||
@@ -243,7 +261,7 @@ public class FileService implements IFileService {
|
||||
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
|
||||
Set<String> used = new HashSet<>();
|
||||
for (UpFile f : files) {
|
||||
Path p = Paths.get(uploadPath, f.getFileName());
|
||||
Path p = resolvePath(f.getFileName());
|
||||
if (!Files.exists(p)) {
|
||||
log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
|
||||
continue;
|
||||
|
||||
@@ -21,7 +21,7 @@ import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -71,6 +71,9 @@ public class NoticeService implements INoticeService {
|
||||
@Autowired
|
||||
private SmsSender smsSender;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.sms.SmsSendService smsSendService;
|
||||
|
||||
@Autowired
|
||||
private IFileService fileService;
|
||||
|
||||
@@ -80,7 +83,18 @@ public class NoticeService implements INoticeService {
|
||||
@Autowired
|
||||
private CurrentBoaUser currentBoaUser;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.flow.NoticeFlowService noticeFlowService;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.notice.service.msg.NoticeUserMsgNotifier noticeUserMsgNotifier;
|
||||
|
||||
private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode();
|
||||
private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode();
|
||||
private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode();
|
||||
|
||||
/**
|
||||
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
|
||||
@@ -120,23 +134,61 @@ public class NoticeService implements INoticeService {
|
||||
entity.setId(null);
|
||||
entity.setDeptName(resolveDeptName(request));
|
||||
normalizeReceiveList(entity, request.getReceiveList());
|
||||
// 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性
|
||||
entity.setStatus(PUBLISHED);
|
||||
entity.setPublisherAccount(currentAccount());
|
||||
entity.setPublisherName(currentName());
|
||||
entity.setSchId(currentSchId());
|
||||
entity.setIsPulled(0);
|
||||
entity.setReadCount(0);
|
||||
// 发布分流(先审后发):应用设置绑定了流程 且 通知对象=全部用户 → status=0 审核载体 + audit=0
|
||||
// 待提交,提交动作 AFTER_COMMIT 出站(D9:apply 不进 DB 事务,失败保持 0 可幂等重推);
|
||||
// 指定人员 / 未绑定 → 原直发链路一字不改(status=1 即发布)——绑定值由后端判定,请求无法绕过
|
||||
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
|
||||
boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList());
|
||||
if (needAudit) {
|
||||
entity.setStatus(UNPUBLISHED);
|
||||
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
||||
entity.setFlowGuid(boundFlowGuid);
|
||||
// 定时发送与审核互斥(审核通过前本就不发布;前端已隐藏定时入口,此处兜底清空)
|
||||
entity.setTimingSendTime(null);
|
||||
} else {
|
||||
entity.setStatus(PUBLISHED);
|
||||
}
|
||||
noticeRepository.save(entity);
|
||||
// 写入接收人关联表(非全员时)
|
||||
// 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害)
|
||||
saveReceivers(entity);
|
||||
// 绑定附件(前端上传后回传的 fileIds 逗号串)
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
if (needAudit) {
|
||||
// 短信时机后移:走审核的通知 create 时不发,回调 completed 才发(审核通过才发)
|
||||
noticeFlowService.submitAfterCommit(entity.getId());
|
||||
} else {
|
||||
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
|
||||
trySendSms(entity);
|
||||
// 站内消息推送(afterCommit,指定人员;ALL 全员无法枚举一期不推,见 NoticeUserMsgNotifier)
|
||||
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
|
||||
parseReceiveAccounts(entity.getReceiveList()));
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
/** 通知对象是否全员(normalizeReceiveList 已把 all/UserAll/ALL 归一为 "ALL") */
|
||||
private boolean isAllReceiver(String receiveList) {
|
||||
return receiveList != null && "ALL".equalsIgnoreCase(receiveList.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见,
|
||||
* 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。
|
||||
*/
|
||||
private void requireVisibleIfUnpublished(Integer serid) {
|
||||
Notice notice = noticeRepository.findById(serid).orElse(null);
|
||||
if (notice == null || notice.getStatus() == null
|
||||
|| notice.getStatus() == NoticeStatusEnum.PUBLISHED.getCode()) {
|
||||
return; // 不存在(沿用后续查询的报错)或已发布(既有行为)
|
||||
}
|
||||
noticeFlowGuard.requireUnpublishedVisible(notice, currentBoaUser.get());
|
||||
}
|
||||
|
||||
// ============================ 编辑 ============================
|
||||
|
||||
@Override
|
||||
@@ -145,6 +197,11 @@ public class NoticeService implements INoticeService {
|
||||
Objects.requireNonNull(id, "通知ID不得为空");
|
||||
Notice entity = noticeRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id));
|
||||
boolean wasPublished = entity.getStatus() == PUBLISHED;
|
||||
// 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提
|
||||
if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回");
|
||||
}
|
||||
entity.setTitle(request.getTitle());
|
||||
entity.setContent(request.getContent());
|
||||
entity.setType(request.getType());
|
||||
@@ -169,7 +226,45 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
}
|
||||
// 分流重算(每次保存都按当前绑定与通知对象判定,规则与 create 一致):
|
||||
// 全体用户 + 已绑定 → 进/留审核轨道;指定人员 → 退出轨道直发
|
||||
boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid());
|
||||
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
|
||||
boolean needAudit = isAllReceiver(entity.getReceiveList()) && boundFlowGuid != null;
|
||||
if (needAudit) {
|
||||
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
|
||||
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
|
||||
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
||||
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(NoticeFlowCallbackHandler 终态快照),
|
||||
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
|
||||
entity.setFlowResultJson(null);
|
||||
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
|
||||
entity.setTimingSendTime(null); // 定时发送与审核互斥
|
||||
} else {
|
||||
// 直发(指定人员 / 未绑定):清审核镜像字段并置发布
|
||||
// (原实现在此漏置 status,会把解绑/改指定人员的通知永久留在未发布态)
|
||||
entity.setFlowGuid(null);
|
||||
entity.setAuditStatus(null);
|
||||
entity.setFlowInstanceId(null);
|
||||
entity.setLastInstanceVersion(null);
|
||||
entity.setFlowResultJson(null);
|
||||
entity.setStatus(PUBLISHED);
|
||||
}
|
||||
noticeRepository.save(entity);
|
||||
if (needAudit) {
|
||||
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
|
||||
noticeFlowService.submitAfterCommit(entity.getId());
|
||||
} else {
|
||||
if (wasFlowBound && !wasPublished) {
|
||||
// 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发)
|
||||
trySendSms(entity);
|
||||
}
|
||||
if (!wasPublished) {
|
||||
// 首次从非发布态转直发:推送站内消息(已发布通知的普通编辑不重复打扰)
|
||||
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
|
||||
parseReceiveAccounts(entity.getReceiveList()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */
|
||||
@@ -201,9 +296,14 @@ public class NoticeService implements INoticeService {
|
||||
return;
|
||||
}
|
||||
for (Integer id : ids) {
|
||||
if (!noticeRepository.existsById(id)) {
|
||||
Notice row = noticeRepository.findById(id).orElse(null);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
if (row.getAuditStatus() != null && row.getAuditStatus() == AUDIT_APPROVING) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"通知「" + row.getTitle() + "」审批中不可删除,请先撤回");
|
||||
}
|
||||
noticeReadRepository.deleteByNoticeId(id);
|
||||
noticeReceiverRepository.deleteByNoticeId(id);
|
||||
upFileRepository.deleteByTopicTypeAndTopicId("notice", id);
|
||||
@@ -254,17 +354,34 @@ public class NoticeService implements INoticeService {
|
||||
entity.setId(null);
|
||||
entity.setDeptName(resolveDeptName(request));
|
||||
normalizeReceiveList(entity, request.getReceiveList());
|
||||
entity.setStatus(PUBLISHED);
|
||||
entity.setPublisherAccount(currentAccount());
|
||||
entity.setPublisherName(currentName());
|
||||
entity.setParentId(sourceId);
|
||||
entity.setIsPulled(0);
|
||||
entity.setReadCount(0);
|
||||
entity.setParentId(sourceId);
|
||||
entity.setSchId(currentSchId());
|
||||
// 转发同样是"发通知":分流规则与 publish 一致(绑定 + 全员 → 审核;否则直发)。
|
||||
// 不这样收口的话,全员通知可绕开审核——转发一条给全部用户即绕过先审后发。
|
||||
String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId());
|
||||
boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList());
|
||||
if (needAudit) {
|
||||
entity.setStatus(UNPUBLISHED);
|
||||
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
||||
entity.setFlowGuid(boundFlowGuid);
|
||||
entity.setTimingSendTime(null);
|
||||
} else {
|
||||
entity.setStatus(PUBLISHED);
|
||||
}
|
||||
noticeRepository.save(entity);
|
||||
saveReceivers(entity);
|
||||
if (needAudit) {
|
||||
noticeFlowService.submitAfterCommit(entity.getId());
|
||||
} else {
|
||||
trySendSms(entity);
|
||||
// 转发直发后推送站内消息(与 publish 同口径)
|
||||
noticeUserMsgNotifier.notifyPublishedAfterCommit(entity,
|
||||
parseReceiveAccounts(entity.getReceiveList()));
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@@ -356,6 +473,14 @@ entity.setIsPulled(0);
|
||||
if (query.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), query.getStatus()));
|
||||
}
|
||||
// 审核状态筛选:-1 = 无需审核(audit_status is null,直发通知;审批态列显示 "—" 的那些行)
|
||||
if (query.getAuditStatus() != null) {
|
||||
if (query.getAuditStatus() < 0) {
|
||||
predicates.add(cb.isNull(root.get("auditStatus")));
|
||||
} else {
|
||||
predicates.add(cb.equal(root.get("auditStatus"), query.getAuditStatus()));
|
||||
}
|
||||
}
|
||||
appendCommonPredicates(query, root, cb, predicates);
|
||||
// scope="user" → 只看自己发布的通知
|
||||
if ("user".equalsIgnoreCase(query.getScope())) {
|
||||
@@ -410,7 +535,9 @@ entity.setIsPulled(0);
|
||||
String account = currentAccount();
|
||||
boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account);
|
||||
NoticeVo vo = toVo(entity, read);
|
||||
if (!read) {
|
||||
// 仅已发布通知标读:审批人(NoticeFlowGuard 放行的三类人)查看未发布通知属审核动作,
|
||||
// 不得记入阅读统计污染已读名单(走查报告 §七.4)
|
||||
if (!read && entity.getStatus() != null && entity.getStatus() == PUBLISHED) {
|
||||
markRead(id);
|
||||
vo.setRead(true);
|
||||
}
|
||||
@@ -580,6 +707,8 @@ entity.setIsPulled(0);
|
||||
if (receivers.isEmpty()) {
|
||||
receivers = parseReceiveAccounts(notice.getReceiveList());
|
||||
}
|
||||
// 空态(ALL 全员通知)返回的是不可变 emptyList,先拷贝再追加
|
||||
receivers = new ArrayList<>(receivers);
|
||||
// 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整
|
||||
for (String account : readAccounts) {
|
||||
if (!receivers.contains(account)) {
|
||||
@@ -673,7 +802,8 @@ entity.setIsPulled(0);
|
||||
.filter(a -> !readAccounts.contains(a))
|
||||
.collect(Collectors.toList());
|
||||
log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts);
|
||||
// TODO: 接入 yh-platform 消息推送后,对 unreadAccounts 发送催办消息
|
||||
// 接入消息中心:对未读接收人推送催办站内消息(推送失败仅记日志,不阻断催收)
|
||||
noticeUserMsgNotifier.notifyExpedited(notice, unreadAccounts);
|
||||
}
|
||||
|
||||
// ============================ 统计 ============================
|
||||
@@ -739,6 +869,7 @@ entity.setIsPulled(0);
|
||||
|
||||
@Override
|
||||
public NoticeDetailVo getDetailMap(Integer serid) {
|
||||
requireVisibleIfUnpublished(serid);
|
||||
NoticeVo vo = getDetail(serid);
|
||||
NoticeDetailVo data = new NoticeDetailVo();
|
||||
data.setSerid(serid);
|
||||
@@ -770,6 +901,20 @@ entity.setIsPulled(0);
|
||||
} else {
|
||||
data.setTlist(parseReceiveAccounts(rl));
|
||||
}
|
||||
// 先审后发镜像(编辑页驳回/撤回态展示意见;详情页进度卡片入口)
|
||||
data.setFlowGuid(vo.getFlowGuid());
|
||||
data.setAuditStatus(vo.getAuditStatus());
|
||||
data.setAuditStatusDesc(vo.getAuditStatusDesc());
|
||||
data.setFlowInstanceId(vo.getFlowInstanceId());
|
||||
Notice flowSource = noticeRepository.findById(serid).orElse(null);
|
||||
if (flowSource != null && StringUtils.hasText(flowSource.getFlowResultJson())) {
|
||||
try {
|
||||
data.setFlowResult(new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(flowSource.getFlowResultJson(), Object.class));
|
||||
} catch (Exception ignore) {
|
||||
data.setFlowResult(null);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -835,6 +980,11 @@ entity.setIsPulled(0);
|
||||
}
|
||||
vo.setRead(read);
|
||||
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
|
||||
// 先审后发镜像(未绑流程为 null,前端据此隐藏审核列/进度卡片)
|
||||
vo.setFlowGuid(entity.getFlowGuid());
|
||||
vo.setAuditStatus(entity.getAuditStatus());
|
||||
vo.setAuditStatusDesc(com.yunhai.notice.enums.FlowAuditStatusEnum.descOf(entity.getAuditStatus()));
|
||||
vo.setFlowInstanceId(entity.getFlowInstanceId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -853,10 +1003,10 @@ entity.setIsPulled(0);
|
||||
return;
|
||||
}
|
||||
if (!isImmediatePublish(entity)) {
|
||||
// 定时未到点:此刻不发(可见性靠查询谓词延后),到点由 SmsCatchupJob 补发(§3.2)
|
||||
return;
|
||||
}
|
||||
smsSender.sendNoticeSms(entity.getId(), entity.getTitle(),
|
||||
parseReceiveList(entity.getReceiveList()));
|
||||
smsSendService.sendOnce(entity);
|
||||
}
|
||||
|
||||
private boolean isImmediatePublish(Notice entity) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.yunhai.notice.service.impl;
|
||||
import com.yunhai.notice.model.SchTeaList;
|
||||
import com.yunhai.notice.service.IClassService;
|
||||
import com.yunhai.notice.service.IUserCenterService;
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
@@ -14,7 +14,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户中心门面服务实现。
|
||||
@@ -50,7 +49,7 @@ public class UserCenterService implements IUserCenterService {
|
||||
}
|
||||
flattenGroup(root, 0, flatNodes);
|
||||
|
||||
// 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点
|
||||
// 2. userclass(班级管理)全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点
|
||||
Set<Integer> treeGroupIds = new HashSet<>();
|
||||
for (Map<String, Object> node : flatNodes) {
|
||||
treeGroupIds.add((Integer) node.get("id"));
|
||||
@@ -219,20 +218,20 @@ public class UserCenterService implements IUserCenterService {
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
|
||||
return buildModuleTree(modules);
|
||||
// 用户中心已按 BoaModule.children 下发模块树:本地不再重组层级,仅做前端字段改名映射
|
||||
return toFrontendTree(modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。
|
||||
* 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。
|
||||
* BoaModule 树 → 前端 layout 期望的字段形状(递归,<b>只改名不组树</b>)。<br>
|
||||
* 层级与兄弟顺序均由用户中心 {@code BoaModule.children} + customId 升序决定,本侧不重排;
|
||||
* 叶子节点 children 为空列表(用户中心保证),前端可无判空递归。
|
||||
*/
|
||||
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
||||
Map<Integer, BoaModule> moduleMap = modules.stream()
|
||||
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
|
||||
|
||||
// 每个模块节点(含 children 占位)
|
||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
private List<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", m.getId());
|
||||
@@ -244,25 +243,9 @@ public class UserCenterService implements IUserCenterService {
|
||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||
node.put("usable", true);
|
||||
node.put("children", new ArrayList<>());
|
||||
nodeMap.put(m.getId(), node);
|
||||
node.put("children", toFrontendTree(m.getChildren()));
|
||||
result.add(node);
|
||||
}
|
||||
|
||||
// 挂父子关系
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = nodeMap.get(m.getId());
|
||||
int parentId = m.getParentId() != null ? m.getParentId() : 0;
|
||||
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
||||
children.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
|
||||
return roots;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.platform.openapi.core.JumpTarget;
|
||||
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 通知公告 → 消息中心推送器(方案《消息中心建设方案》§6.1 试点生产方)。<br>
|
||||
* 接入点:publish/forward 直发分支、edit 转直发(从未发布到发布)、expedite 催办未读。
|
||||
* 先审后发(ALL + 绑定流程)审核通过才可见,但该路径接收人恒为 ALL 无法枚举——一期不推
|
||||
* (催办入口可对已发布通知补推未读提醒)。<br>
|
||||
* 容错语义(对齐 PartyLearn 范式):发送失败仅记日志,绝不阻断通知主流程;
|
||||
* msgNo 幂等键 "ntc-{action}-{id}-{yyMMddHHmmss}"——同一秒内重试安全,跨秒重复动作视为再次提醒。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class NoticeUserMsgNotifier {
|
||||
|
||||
/** SDK 单次接收人上限(oapi 契约:去重后 ≤1000,超出报错) */
|
||||
public static final int MAX_RECV_PER_SEND = 1000;
|
||||
|
||||
private static final DateTimeFormatter MSG_NO_TS = DateTimeFormatter.ofPattern("yyMMddHHmmss");
|
||||
|
||||
private final UserMsgClient userMsgClient;
|
||||
private final UserDirectory userDirectory;
|
||||
private final NoticeUserMsgProperties properties;
|
||||
|
||||
public NoticeUserMsgNotifier(UserMsgClient userMsgClient, UserDirectory userDirectory,
|
||||
NoticeUserMsgProperties properties) {
|
||||
this.userMsgClient = userMsgClient;
|
||||
this.userDirectory = userDirectory;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布/转发/编辑转直发后推送(在发送事务内调用:注册 afterCommit,
|
||||
* 避免通知主体尚未提交时收件端先收到消息)。
|
||||
*
|
||||
* @param accounts 接收人账号串列表(数字ID或登录名;ALL 通知为空列表 → 不推)
|
||||
*/
|
||||
public void notifyPublishedAfterCommit(Notice notice, List<String> accounts) {
|
||||
if (!properties.isPushEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
doNotify(notice, accounts, "pub", "notice");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
doNotify(notice, accounts, "pub", "notice");
|
||||
}
|
||||
}
|
||||
|
||||
/** 催办即时推送(expedite 无事务上下文,读侧已查完库) */
|
||||
public void notifyExpedited(Notice notice, List<String> unreadAccounts) {
|
||||
if (!properties.isPushEnabled()) {
|
||||
return;
|
||||
}
|
||||
doNotify(notice, unreadAccounts, "exp", "notice.expedite");
|
||||
}
|
||||
|
||||
private void doNotify(Notice notice, List<String> accounts, String action, String bizType) {
|
||||
if (accounts == null || accounts.isEmpty()) {
|
||||
log.info("[usermsg] 通知 {} 无可枚举接收人(ALL 全员),跳过站内消息", notice.getId());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<Integer> userIds = new LinkedHashSet<>();
|
||||
for (String account : accounts) {
|
||||
Integer userId = userDirectory.toUserId(account);
|
||||
if (userId == null) {
|
||||
log.warn("[usermsg] 接收人无法解析为用户ID,跳过 notice={} account={}", notice.getId(), account);
|
||||
} else {
|
||||
userIds.add(userId);
|
||||
}
|
||||
}
|
||||
if (userIds.isEmpty()) {
|
||||
log.warn("[usermsg] 通知 {} 接收人全部无法解析,未推送", notice.getId());
|
||||
return;
|
||||
}
|
||||
List<Integer> all = new ArrayList<>(userIds);
|
||||
int chunks = (all.size() + MAX_RECV_PER_SEND - 1) / MAX_RECV_PER_SEND;
|
||||
for (int i = 0; i < chunks; i++) {
|
||||
List<Integer> part = all.subList(i * MAX_RECV_PER_SEND, Math.min((i + 1) * MAX_RECV_PER_SEND, all.size()));
|
||||
try {
|
||||
userMsgClient.sendUserMsg(buildRequest(notice, action, bizType, part, chunks > 1 ? i + 1 : 0));
|
||||
} catch (Exception e) {
|
||||
log.warn("[usermsg] 站内消息发送失败(不影响通知主流程) notice={} part={}/{}:{}",
|
||||
notice.getId(), i + 1, chunks, e.toString());
|
||||
}
|
||||
}
|
||||
log.info("[usermsg] 通知 {} 站内消息已推送 {} 人(action={})", notice.getId(), all.size(), action);
|
||||
} catch (Exception e) {
|
||||
log.warn("[usermsg] 站内消息推送异常(不影响通知主流程) notice={}:{}", notice.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private SendMsgRequest buildRequest(Notice notice, String action, String bizType,
|
||||
List<Integer> userIds, int chunkIndex) {
|
||||
SendMsgRequest request = new SendMsgRequest();
|
||||
request.setUserIds(userIds);
|
||||
request.setTitle("通知公告");
|
||||
request.setContent(buildContent(notice));
|
||||
String msgNo = "ntc-" + action + "-" + notice.getId() + "-"
|
||||
+ MSG_NO_TS.format(LocalDateTime.now())
|
||||
+ (chunkIndex > 0 ? "-" + chunkIndex : "");
|
||||
request.setMsgNo(msgNo);
|
||||
request.setBizType(bizType);
|
||||
request.setBizId(String.valueOf(notice.getId()));
|
||||
// 0系统通知;跳转 Notice 前端详情页(type=1 打开即标读)。jumpApp=notice 声明页面归属:
|
||||
// 接收方按它查基址拼绝对地址(bizType=notice.expedite 等细分分类也能命中 notice 应用键)
|
||||
request.setMsgType(0);
|
||||
request.app("notice")
|
||||
.jump(JumpTarget.page("list/detail").param("serid", notice.getId()).param("type", 1));
|
||||
request.setSchId(notice.getSchId());
|
||||
return request;
|
||||
}
|
||||
|
||||
/** 消息文案:对齐旧版"您有一条来自xx老师的xx通知"口吻 */
|
||||
private String buildContent(Notice notice) {
|
||||
String publisher = StringUtils.hasText(notice.getPublisherName()) ? notice.getPublisherName() : "管理员";
|
||||
String title = StringUtils.hasText(notice.getTitle()) ? notice.getTitle() : "";
|
||||
if (title.length() > 120) {
|
||||
title = title.substring(0, 120) + "…";
|
||||
}
|
||||
return "您有一条来自「" + publisher + "」的通知《" + title + "》,请及时查阅。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 通知公告 → 消息中心(UserMsg)推送配置。<br>
|
||||
* push-enabled 默认关闭:UserMsg 未部署的环境(本地无配置/生产上线前)不产生空转告警——
|
||||
* 上线 UserMsg 后在环境配置显式打开(对齐 flow-center.usermsg 空转的反面教材)。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "notice.usermsg")
|
||||
public class NoticeUserMsgProperties {
|
||||
|
||||
/** 是否向 UserMsg 推送站内消息(发布/转发/编辑转直发/催办) */
|
||||
private boolean pushEnabled = false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import com.yunhai.platform.openapi.msg.YschMsgOpenApi;
|
||||
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* SDK 实现:委托 yh-openapi-client 的 msg 域门面(服务目录 key={@code usermsg},
|
||||
* 地址解析 targets > yh.open.services.usermsg > 枚举 base-host 兜底;签名/信封全由 SDK 承载)。
|
||||
* 门面由 yh-openapi-spring-boot-starter 自动装配;未装配时调用抛 IllegalStateException
|
||||
* (推送方按容错语义捕获记日志,不阻断主流程)。
|
||||
*/
|
||||
@Component
|
||||
public class SdkUserMsgClient implements UserMsgClient {
|
||||
|
||||
@Override
|
||||
public void sendUserMsg(SendMsgRequest request) {
|
||||
YschMsgOpenApi.sendUserMsg(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 用户中心实现:纯数字视为 userId 直接收敛;其余按登录名查用户中心取 id
|
||||
* (对齐 NoticeService.getReadDetailGrouped 的既有归一化语义)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class UserCenterUserDirectory implements UserDirectory {
|
||||
|
||||
@Override
|
||||
public Integer toUserId(String account) {
|
||||
if (!StringUtils.hasText(account)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = account.trim();
|
||||
if (trimmed.chars().allMatch(Character::isDigit)) {
|
||||
try {
|
||||
return Integer.valueOf(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
BoaUser user = YschBaseOpenApi.getUserInfo(trimmed);
|
||||
return user == null ? null : user.getId();
|
||||
} catch (Exception e) {
|
||||
log.warn("[usermsg] 登录名换 userId 失败 account={}:{}", trimmed, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
/**
|
||||
* 接收人账号 → 数字用户ID 归一接口(测试缝)。<br>
|
||||
* 新数据 receiveList 存数字用户ID(组织树叶 id,UserBase userId);
|
||||
* 历史数据可能存登录名——登录名经用户中心换 userId。
|
||||
*/
|
||||
public interface UserDirectory {
|
||||
|
||||
/**
|
||||
* 归一为数字用户ID:纯数字直接解析;否则按登录名查用户中心。
|
||||
*
|
||||
* @return 数字用户ID;解析失败返回 null(调用方跳过该人)
|
||||
*/
|
||||
Integer toUserId(String account);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
|
||||
|
||||
/**
|
||||
* 消息中心发送客户端接口(测试缝):生产实现委托 SDK 门面 YschMsgOpenApi,
|
||||
* 单测可 mock 以验证发送参数与容错语义。
|
||||
*/
|
||||
public interface UserMsgClient {
|
||||
|
||||
/** POST /oapi/msg/sendUserMsg(应用签名;同 appKey+msgNo 幂等返回原消息 id) */
|
||||
void sendUserMsg(SendMsgRequest request);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.notice.service.sms;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.utils.ReceiveParser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 短信统一发送(§3.2 / BP-R5-2):直发(publish 即时)、审批通过(completed 回调后置)、
|
||||
* 到点补发(SmsCatchupJob)三条链路共用,以 sms_sent_at 原子认领防重复——
|
||||
* 认领成功才发送,失败归还标记待下轮补发。修复"审批通过时未到定时点 → 短信永久丢失"缺口。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmsSendService {
|
||||
|
||||
private final NoticeRepository noticeRepository;
|
||||
private final SmsSender smsSender;
|
||||
|
||||
/**
|
||||
* 发一次短信(幂等):sms_flag!=1 或已发过(认领失败)直接跳过。
|
||||
* 发送抛异常时归还认领(下轮补发可重试)后原样上抛——发布/回调链路维持既有 5xx 语义。
|
||||
*/
|
||||
@Transactional
|
||||
public boolean sendOnce(Notice notice) {
|
||||
if (notice == null || notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (noticeRepository.markSmsSent(notice.getId(), LocalDateTime.now()) == 0) {
|
||||
return false; // 已发送过(并发/重复调度/重发布轮次的闸门)
|
||||
}
|
||||
try {
|
||||
smsSender.sendNoticeSms(notice.getId(), notice.getTitle(),
|
||||
ReceiveParser.parse(notice.getReceiveList()));
|
||||
log.info("[notice-sms] 短信已发送 notice={} title={}", notice.getId(), notice.getTitle());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
noticeRepository.clearSmsSent(notice.getId());
|
||||
log.warn("[notice-sms] 短信发送失败(归还认领,待补发重试)notice={}:{}",
|
||||
notice.getId(), e.toString());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.notice.utils;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 接收人列表解析工具(自 NoticeService.parseReceiveAccounts 提炼为公共类):
|
||||
* "ALL"/"UserAll"/"all"=全员(返回空列表);JSON 数组串或逗号串 → 账号列表;
|
||||
* 为空或解析失败返回空列表(表示全员通知)。
|
||||
*/
|
||||
public final class ReceiveParser {
|
||||
|
||||
private ReceiveParser() {
|
||||
}
|
||||
|
||||
public static List<String> parse(String receiveList) {
|
||||
if (receiveList == null || receiveList.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String rl = receiveList.trim();
|
||||
if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
if (rl.startsWith("[")) {
|
||||
String inner = rl.substring(1, rl.length() - 1).trim();
|
||||
if (!inner.isEmpty()) {
|
||||
for (String part : inner.split(",")) {
|
||||
String account = part.trim().replace("\"", "").replace("'", "");
|
||||
if (StringUtils.hasText(account) && !result.contains(account)) {
|
||||
result.add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (String part : rl.split(",")) {
|
||||
String account = part.trim();
|
||||
if (StringUtils.hasText(account) && !result.contains(account)) {
|
||||
result.add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,11 @@ spring:
|
||||
simple:
|
||||
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:
|
||||
upload-path: D:/data/upload
|
||||
|
||||
@@ -50,10 +54,33 @@ sms:
|
||||
enabled: true
|
||||
module-id: notice
|
||||
|
||||
# 先审后发(流程中心托管接缝):enabled=false 时编辑页选择器隐藏、FlowClient 降级
|
||||
notice:
|
||||
flow:
|
||||
enabled: true
|
||||
# flow-center 地址不再单独配置:见上方 yh.open.services.flow-center(SDK 门面 YschFlowOpenApi)。
|
||||
# legacy 回落键 notice.flow.base-url 不配即空,且代码侧不给默认值——与目录配置都缺时构造期
|
||||
# fail-fast,不静默打 localhost。
|
||||
# 回调地址相关键(callback-base / callback-base-path / callback-path)已于 2026-09-24 删除:
|
||||
# 注册地址改由 yh.flow-callback 三段推导(见下方该段),这三个键已无任何代码引用。
|
||||
# 前端基址 front-base-url 已于 2026-09-26 删除:apply bizUrl 改传裸相对路径
|
||||
# (/list/detail?serid=...),绝对化由接收方按页面归属(notice)拼——Notice 零地址类配置。
|
||||
# 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点"
|
||||
# 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql
|
||||
sms:
|
||||
catchup-enabled: true
|
||||
catchup-fixed-delay-ms: 300000
|
||||
# 消息中心(UserMsg)站内消息推送(发布/转发/编辑转直发/催办):
|
||||
# push-enabled 默认 false(未部署 UserMsg 的环境无空转告警);UserMsg 上线后打开
|
||||
usermsg:
|
||||
push-enabled: true
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com/cas/
|
||||
# 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),
|
||||
# 票据校验=根路径 /serviceValidate(160 实测;/cas/ 前缀仅存量本地默认值)
|
||||
server-url-prefix: http://sso.yunhaixingtong.com/
|
||||
client-host-url: http://localhost:8101
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
@@ -61,12 +88,70 @@ yh:
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
# 本应用自身身份(客户端签名用)。本地联调用 100009(160 UserBase/中央密钥链在册身份,
|
||||
# 互通验证通过;100617 在 160 侧未被 UserBase 信任会导致出站 401)。生产仍为 100617
|
||||
#(deploy/notice.yaml 唯一真源);flow-center trusted-apps 已同步收 100009 号段
|
||||
app-key: 100009
|
||||
app-secret: dcc36c98299a4d36bb0ec39d42008e8c
|
||||
# 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过
|
||||
server:
|
||||
enforce-patterns: [/oapi/*]
|
||||
# 入站验签改走中央密钥链(注册表模式):共享 Redis sign:app:* 单一真源,凡在 UserBase
|
||||
# 注册过的 appKey 自动可验,不再维护白名单(原 trusted-apps 每条都要跟着 UserBase 注册表手改,
|
||||
# 且形成双份真源)。注意:Redis 不可用时验签一律 401(fail-closed,无本地兜底)。
|
||||
# 本地要跑 oapi 回调而没起 Redis 时,可临时打开下面白名单当兜底(勿提交到生产配置):
|
||||
# trusted-apps:
|
||||
# "100009": "dcc36c98299a4d36bb0ec39d42008e8c"
|
||||
trust-registry: true
|
||||
flow-callback:
|
||||
# 流程中心回调接收端 starter(2026-09-20 迁移):端点/幂等/乱序门由 starter 承担,
|
||||
# 业务映射见 NoticeFlowCallbackHandler;验签仍由上方 /oapi/* 平台强制验签负责(X-Flw 不启用)
|
||||
# path 取 starter 默认值 /oapi/flow/callback(2026-09-24 由 /oapi/notice/flow/callback 收敛)
|
||||
path: /oapi/flow/callback
|
||||
biz-types: [notice]
|
||||
# 漂移自动核对(2026-09-24 开):端点路径/ctx/base-path 变更后,镜像行有效但登记 URL 与当前
|
||||
# 期望不一致时自动重新注册,替代"删镜像行→重新保存绑定"的手工恢复路径。注意重注册会轮换 secret。
|
||||
reconcile-on-register: true
|
||||
# 注册地址不再显式配置(2026-09-24 摘除 callback-url):由 starter 推导
|
||||
# (open-host ?: yh.open.base-host) + /{前缀} + path
|
||||
# 前缀三层回落:target → base-path → 运行时自证 context-path(ServletContext 容器级,
|
||||
# 2026-09-26 起);本地无挂载前缀时空串/"/"视为无前缀,URL 退化为 host+path,不报错
|
||||
# ⇒ 生产 = http://open-gateway-service:10013 + /notice + /oapi/flow/callback
|
||||
# base-path 可不填(运行时会自证出与真实 ctx 同源的值;显式配置仅作覆盖/逃生口保留)。
|
||||
# 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
|
||||
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:
|
||||
enabled: true
|
||||
# 四域服务目录零配置:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
user-center-url: http://base.yunhaixingtong.com/userbase
|
||||
# 班级管理(bschclass)服务基址
|
||||
bschclass: http://base.yunhaixingtong.com/bschclass
|
||||
# flow-center 显式指向本地流程中心(默认枚举兜底会打 base 域网关,网关无 /oapi 路由);
|
||||
# 生产改容器直连 http://flow-center-service:10008/flow-center(projman 注入)。
|
||||
services:
|
||||
flow-center: http://localhost:8085
|
||||
# usermsg 显式指向本地消息中心(生产部署 UserMsg 后改容器直连,如 http://usermsg-service:8080/usermsg)
|
||||
usermsg: http://localhost:8080
|
||||
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
|
||||
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
|
||||
# 或同名键 yh.env-config.source.front 注入(projman 配置管理维护),未配置时端点返回 {},
|
||||
# 前端走同域推导兜底(serviceHost/baseHost 正确,ssoHost 为空登出跳转不可用)。
|
||||
# 如需本地联调可打开:
|
||||
# env-config:
|
||||
# source:
|
||||
# front: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"http://localhost:8101","relayConf":{"ziyuankuHost":"https://base.bestsch.com/BschZyk","zhuanxiangHost":"https://base.bestsch.com/BschZncpd"}}'
|
||||
|
||||
# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health
|
||||
# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用(同 flow-center 口径)。
|
||||
management:
|
||||
health:
|
||||
redis:
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 流程抄送留痕(2026-09-24):flow.cc.sent 回调逐人一行,应用内「抄送给我的」数据源。
|
||||
-- 幂等靠 yh-flow-callback starter 的 flow_callback_event(uk event_id),本表只增不改。
|
||||
-- 本地 ddl-auto=update 会自动建表;生产执行 deploy/sql/20260924_add_notice_cc.sql。
|
||||
CREATE TABLE IF NOT EXISTS `notice_cc` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`sch_id` INT NULL COMMENT '学校ID(多租户隔离)',
|
||||
`notice_id` INT NOT NULL COMMENT '关联通知(信封 bizId)',
|
||||
`flow_instance_id` BIGINT NULL COMMENT 'flow-center 办件 id(信封 instanceId)',
|
||||
`node_id` VARCHAR(64) NULL COMMENT '抄送发生节点 id(data.nodeId)',
|
||||
`node_name` VARCHAR(128) NULL COMMENT '抄送环节名快照(data.nodeName)',
|
||||
`cc_user_id` INT NOT NULL COMMENT '被抄送人用户 id(data.ccUserId)',
|
||||
`cc_user_name` VARCHAR(64) NULL COMMENT '被抄送人姓名快照(data.ccUserName)',
|
||||
`sent_time` DATETIME(3) NOT NULL COMMENT '抄送时间(事件处理时刻,毫秒精度与 flw_cc 同口径)',
|
||||
`create_by` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_notice_cc_user` (`sch_id`, `cc_user_id`, `sent_time`),
|
||||
KEY `idx_notice_cc_notice` (`notice_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程抄送留痕(flow.cc.sent 回调落行)';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条)
|
||||
-- 语义:绑定后「通知对象=全部用户」必须审核通过才发布;「指定人员」与未绑定一律直发。
|
||||
-- 通知落库时把 flow_guid 快照进 notice 行,改绑/解绑不影响在途审批。
|
||||
-- 本地 ddl-auto=update 会自动建表;生产执行本脚本。
|
||||
CREATE TABLE IF NOT EXISTS `notice_flow_binding` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`sch_id` INT NULL COMMENT '学校ID(一校一条)',
|
||||
`flow_guid` VARCHAR(32) NULL COMMENT 'flow-center 流程 GUID(flw_def.flow_guid,跨版本稳定)',
|
||||
`flow_name` VARCHAR(128) NULL COMMENT '流程名快照(流程中心不可达时展示兜底)',
|
||||
`create_by` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_notice_flow_binding_sch` (`sch_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='应用级审核流程绑定';
|
||||
@@ -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,51 @@
|
||||
package com.yunhai.notice.config;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* env-config 端点契约(Cms/UserHome 同款机制,前端 SPA 运行时配置来源)。
|
||||
* 必须起真实端口:端点靠平台 EnvConfigSsoBypassFilter(HIGHEST+3)forward 旁路 CAS 过滤链
|
||||
* (yh.sso.url-pattern=/api/*),MockMvc 的模拟 RequestDispatcher 不执行 forward 派发,
|
||||
* 只有真实 Tomcat 上才能验证「匿名 GET 直达 Controller 而非 302 登录页」。
|
||||
* 本用例同时是平台 starter 的回归哨兵:envConfigSsoBypassFilter 的
|
||||
* {@code @ConditionalOnMissingBean} 曾因 FilterRegistrationBean 泛型擦除误判跳过(平台已修)。
|
||||
*
|
||||
* <p>数据源 PropertyEnvConfigSource 读键 yh.env-config.source.<target>,本类注入 front 值
|
||||
* (前端部署目录名 front,生产由 projman 配置管理维护);未配置的 target 由平台
|
||||
* EnvConfigService 归一为 "{}" 返回(接口恒 200,前端走同域推导兜底)。
|
||||
*/
|
||||
@DisplayName("EnvConfigApi - /api/env-config 免登录运行时配置端点(真实端口)")
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"yh.env-config.source.front={\"baseHost\":\"http://base.yunhaixingtong.com\",\"ssoHost\":\"http://sso.yunhaixingtong.com\",\"serviceHost\":\"/notice\"}",
|
||||
// 专属测试库(仿 Cms bsch_cms_test):本机无开发库 bsch_notice 也能起上下文,且不碰开发库;
|
||||
// createDatabaseIfNotExist 首次运行自动建库,表结构由 ddl-auto=update 生成
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai"})
|
||||
class EnvConfigApiIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate rest;
|
||||
|
||||
@Test
|
||||
@DisplayName("target=front 已配置:旁路 CAS 匿名 200,返回注入的 JSON 原文(无 {code,msg,data} 包装)")
|
||||
void configuredTarget_bypassesCasAndReturnsInjectedJson() {
|
||||
ResponseEntity<String> resp = rest.getForEntity("/api/env-config?target=front", String.class);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(resp.getBody()).isEqualTo(
|
||||
"{\"baseHost\":\"http://base.yunhaixingtong.com\",\"ssoHost\":\"http://sso.yunhaixingtong.com\",\"serviceHost\":\"/notice\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("target 未配置(如 admin):仍免登录 200,归一返回 {}")
|
||||
void unconfiguredTarget_returnsEmptyObjectAnonymously() {
|
||||
ResponseEntity<String> resp = rest.getForEntity("/api/env-config?target=admin", String.class);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(resp.getBody()).isEqualTo("{}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.yunhai.notice.controller.oapi;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 条件路由判断端点单测(2026-09-24 §5.2):部门比对两分支、formData 优先免查库、
|
||||
* 未命中 outlets 抛错(挂起而非默认放行)、通知缺失/脏 bizId 抛错。
|
||||
* 验签(/oapi/* 强制 100019 应用签名)由 AppAuthFilter 承担,FlowCallbackTransientIT 同款管线已覆盖。
|
||||
*/
|
||||
class OapiFlowRouteControllerTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private OapiFlowRouteController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
controller = new OapiFlowRouteController(noticeRepository);
|
||||
}
|
||||
|
||||
private OapiFlowRouteController.JudgeBody body(String bizId, Map<String, Object> variables) {
|
||||
OapiFlowRouteController.JudgeBody b = new OapiFlowRouteController.JudgeBody();
|
||||
b.setSchId(1L);
|
||||
b.setInstanceId(9527L);
|
||||
b.setBizType("notice");
|
||||
b.setBizId(bizId);
|
||||
b.setOutlets(Arrays.asList("校长室", "其他部门"));
|
||||
b.setVariables(variables);
|
||||
return b;
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalDept_routesToPrincipalOutlet() {
|
||||
Notice n = new Notice();
|
||||
n.setId(42);
|
||||
n.setDeptId("xzbx");
|
||||
n.setDeptName("校长室");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||
|
||||
Map<String, Object> out = controller.deptJudge(body("42", null));
|
||||
|
||||
assertEquals("校长室", out.get("outlet"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherDept_routesToOtherOutlet() {
|
||||
Notice n = new Notice();
|
||||
n.setId(43);
|
||||
n.setDeptId("jwc");
|
||||
n.setDeptName("教务处");
|
||||
when(noticeRepository.findById(43)).thenReturn(Optional.of(n));
|
||||
|
||||
assertEquals("其他部门", controller.deptJudge(body("43", null)).get("outlet"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void variablesDeptName_winsWithoutDbHit() {
|
||||
// formData 优先:variables 带部门名时不查库(纯读轻实现),口径与查表一致
|
||||
Map<String, Object> variables = new HashMap<>();
|
||||
variables.put("deptName", "校长室");
|
||||
when(noticeRepository.findById(anyInt())).thenReturn(Optional.empty());
|
||||
|
||||
assertEquals("校长室", controller.deptJudge(body("456", variables)).get("outlet"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outletNotInContract_throws_neverDefaults() {
|
||||
Notice n = new Notice();
|
||||
n.setId(44);
|
||||
n.setDeptName("校长室");
|
||||
when(noticeRepository.findById(44)).thenReturn(Optional.of(n));
|
||||
OapiFlowRouteController.JudgeBody b = body("44", null);
|
||||
b.setOutlets(Collections.singletonList("其他部门")); // 流程定义与端点出口契约不一致
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> controller.deptJudge(b));
|
||||
assertEquals(true, e.getMessage().contains("未命中出口"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingNotice_throws() {
|
||||
when(noticeRepository.findById(99)).thenReturn(Optional.empty());
|
||||
assertThrows(BizException.class, () -> controller.deptJudge(body("99", null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalBizId_throws() {
|
||||
assertThrows(BizException.class, () -> controller.deptJudge(body("abc", null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
|
||||
/**
|
||||
* 回调瞬时失败 5xx 语义 IT(T2.3 核心断言,"免对账"成立的前提):
|
||||
* 发布后置(短信)故障 → 回调 500(非 200),事务整体回滚(事件行不留);
|
||||
* 故障恢复后重投同一事件 → 200 + 状态正确追平——证明 flow-center 持续重试可治愈。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai",
|
||||
"spring.jpa.hibernate.ddl-auto=update",
|
||||
// 验签确定性(同 NoticeFlowApiIT):本地 Redis 无 sign:app:100019 时回落白名单,勿依赖环境
|
||||
"yh.app-auth.trust-registry=false",
|
||||
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
|
||||
class FlowCallbackTransientIT {
|
||||
|
||||
private static final String FLOW_GUID = "it-transient-guid-01";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private NoticeRepository noticeRepository;
|
||||
@Autowired
|
||||
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||
|
||||
@MockBean
|
||||
private SmsSender smsSender;
|
||||
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM flow_callback_event");
|
||||
noticeRepository.deleteAll();
|
||||
|
||||
notice = new Notice();
|
||||
notice.setTitle("瞬时失败通知");
|
||||
notice.setType(1);
|
||||
notice.setStatus(0);
|
||||
notice.setAuditStatus(10);
|
||||
notice.setFlowGuid(FLOW_GUID);
|
||||
notice.setSchId(1);
|
||||
notice.setPublisherAccount("it-user");
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
notice = noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailure_returns5xx_thenRecoversOnRetry() {
|
||||
// 阶段一:短信后置抛异常 → 500 + 事务回滚(事件行/notice 推进均不留)
|
||||
Mockito.doThrow(new RuntimeException("sms gateway down"))
|
||||
.when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
String eventId = uuid();
|
||||
ResponseEntity<String> first = postCompleted(eventId);
|
||||
assertEquals(500, first.getStatusCodeValue(), "瞬时失败必须回 5xx(重试治愈的唯一信号)");
|
||||
Notice after = reload();
|
||||
assertEquals(10, after.getAuditStatus(), "失败事务整体回滚");
|
||||
assertEquals(0, after.getStatus());
|
||||
assertEquals(0L, callbackEventCount(), "事件去重行随事务回滚(重投不撞 uk)");
|
||||
|
||||
// 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环)
|
||||
Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
ResponseEntity<String> second = postCompleted(eventId);
|
||||
assertEquals(200, second.getStatusCodeValue());
|
||||
Notice recovered = reload();
|
||||
assertEquals(20, recovered.getAuditStatus());
|
||||
assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布");
|
||||
assertEquals(1L, callbackEventCount());
|
||||
// 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次
|
||||
Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
private Notice reload() {
|
||||
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> postCompleted(String eventId) {
|
||||
String body = "{"
|
||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"flow.completed\","
|
||||
+ "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527,"
|
||||
+ "\"instanceVersion\":3,"
|
||||
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"瞬时失败通知\","
|
||||
+ "\"data\":{\"result\":\"APPROVED\",\"opinion\":\"同意\"}}";
|
||||
// 回调端点挂在 /oapi/* 下:以推送方 flow-center(100019)身份带应用签名投递
|
||||
return new com.yunhai.notice.support.OapiSignedClient(
|
||||
"http://localhost:" + port, "100019", "flow-center-dev-secret-100019")
|
||||
.post("/oapi/flow/callback", body, String.class);
|
||||
}
|
||||
|
||||
private static String uuid() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
|
||||
private long callbackEventCount() {
|
||||
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
|
||||
return c == null ? 0 : c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.service.flow.NoticeApprovalClient;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批代理客户端(N06 TDD):URL/参数逐字断言(与 flow-center OapiFlowController 契约对齐)、
|
||||
* bizType=notice 固定、reject 空 opinion 前置拒绝、未接入业务异常。
|
||||
*/
|
||||
class NoticeApprovalClientTest {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOW_CENTER.key();
|
||||
|
||||
private final NoticeFlowProperties properties = new NoticeFlowProperties();
|
||||
private YschOpenCall call;
|
||||
private NoticeApprovalClient client;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
call = mock(YschOpenCall.class);
|
||||
ObjectProvider<YschOpenCall> provider = Mockito.mock(ObjectProvider.class);
|
||||
when(provider.getIfAvailable()).thenReturn(call);
|
||||
client = new NoticeApprovalClient(properties, provider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void todoTasks_urlAndParams_charExact() {
|
||||
Map<String, Object> page = new HashMap<>();
|
||||
when(call.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class))).thenReturn(page);
|
||||
client.todoTasks(27, 10006, 2, 20);
|
||||
Map<String, String> expected = new LinkedHashMap<>();
|
||||
expected.put("schId", "27");
|
||||
expected.put("assigneeId", "10006");
|
||||
expected.put("bizType", "notice");
|
||||
expected.put("page", "2");
|
||||
expected.put("size", "20");
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), eq(expected), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void todoTasks_nullPaging_omitted() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
|
||||
client.todoTasks(27, 10006, null, null);
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), Mockito.argThat(q ->
|
||||
q.size() == 3 && "notice".equals(q.get("bizType"))
|
||||
&& !q.containsKey("page") && !q.containsKey("size")), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void instances_urlAndParams_charExact() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>());
|
||||
client.instances(27, 10, 1, 50);
|
||||
Map<String, String> expected = new LinkedHashMap<>();
|
||||
expected.put("schId", "27");
|
||||
expected.put("bizType", "notice");
|
||||
expected.put("status", "10");
|
||||
expected.put("page", "1");
|
||||
expected.put("size", "50");
|
||||
verify(call).get(eq(TARGET), eq("/oapi/flow/instances"), eq(expected), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_urlParamsAndBody() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", "同意");
|
||||
body.put("edgeName", "通过");
|
||||
client.approve(106L, 27, 10006, "刘文娟", "同意", "通过");
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", "27");
|
||||
query.put("operatorId", "10006");
|
||||
query.put("operatorName", "刘文娟");
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), eq(query), eq(body), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_blankOpinionAndEdge_omittedFromBody() {
|
||||
client.approve(106L, 27, 10006, "甲", " ", null);
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/106/approve"), anyMap(),
|
||||
eq(new LinkedHashMap<String, Object>()), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_blankOpinion_rejectedLocally() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> client.reject(106L, 27, 10006, "甲", " "));
|
||||
assertTrue(e.getMessage().contains("意见"));
|
||||
verify(call, Mockito.never()).post(any(String.class), any(String.class), anyMap(), any(), any(Class.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_urlParamsAndBody() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("opinion", "材料不全");
|
||||
client.reject(107L, 27, 10006, "甲", "材料不全");
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", "27");
|
||||
query.put("operatorId", "10006");
|
||||
query.put("operatorName", "甲");
|
||||
verify(call).post(eq(TARGET), eq("/oapi/flow/task/107/reject"), eq(query), eq(body), eq(Void.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledFlow_bizException() {
|
||||
properties.setEnabled(false);
|
||||
assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null));
|
||||
assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null));
|
||||
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
|
||||
void outboundError_propagatesAsIs() {
|
||||
when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new RuntimeException("connect timeout"));
|
||||
assertThrows(RuntimeException.class, () -> client.todoTasks(27, 10006, null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.yunhai.notice.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.support.OapiSignedClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Notice 流程接缝 IT(T2.1/T2.3/T2.5,RANDOM_PORT 真实过滤链 + bsch_notice_test):
|
||||
* ① 回调端点 <b>/oapi/flow/callback</b> 的应用验签(复用平台能力):以 flow-center 身份
|
||||
* (appKey=100019,密钥取 trusted-apps)合法签名 200 推进状态 / 坏签名 401 / 匿名 401 /
|
||||
* 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等;
|
||||
* ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401);
|
||||
* ③ notice 表 5 新列、notice_flow_reg uk、notice_flow_binding uk 的真实落库。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai",
|
||||
"spring.jpa.hibernate.ddl-auto=update",
|
||||
// 验签确定性(2026-09-24):主 yml 已切中央密钥链(trust-registry,Redis sign:app:* 单一真源),
|
||||
// 本地 Redis 无 100019 密钥时合法签名也会 401。IT 勿依赖环境:回落白名单直配本用例的身份对
|
||||
// (等价 yml 注释里"本地跑 oapi 回调可临时打开白名单"的口径,只作用于测试上下文)。
|
||||
"yh.app-auth.trust-registry=false",
|
||||
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
|
||||
class NoticeFlowApiIT {
|
||||
|
||||
/** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */
|
||||
private static final String CALLBACK_PATH = "/oapi/flow/callback";
|
||||
/** 推送方身份:flow-center 的 appKey/secret(Notice trusted-apps 白名单) */
|
||||
private static final String FLOW_APP_KEY = "100019";
|
||||
private static final String FLOW_APP_SECRET = "flow-center-dev-secret-100019";
|
||||
private static final String REG_SECRET = "it-callback-secret-0123456789";
|
||||
private static final String FLOW_GUID = "it-flow-guid-0001";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private NoticeRepository noticeRepository;
|
||||
@Autowired
|
||||
private NoticeFlowRegRepository regRepository;
|
||||
@Autowired
|
||||
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||
@Autowired
|
||||
private NoticeFlowBindingRepository bindingRepository;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM flow_callback_event");
|
||||
jdbcTemplate.update("DELETE FROM flow_callback_reg");
|
||||
regRepository.deleteAll();
|
||||
bindingRepository.deleteAll();
|
||||
noticeRepository.deleteAll();
|
||||
|
||||
NoticeFlowReg reg = new NoticeFlowReg();
|
||||
reg.setSchId(1);
|
||||
reg.setAppKey("100617");
|
||||
reg.setFlowGuid(FLOW_GUID);
|
||||
reg.setCallbackUrl("http://localhost:" + port + CALLBACK_PATH);
|
||||
reg.setSecret(REG_SECRET);
|
||||
reg.setStatus(0);
|
||||
regRepository.save(reg);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setTitle("IT 审核通知");
|
||||
notice.setContent("<p>it</p>");
|
||||
notice.setType(1);
|
||||
notice.setStatus(0);
|
||||
notice.setAuditStatus(10);
|
||||
notice.setFlowGuid(FLOW_GUID);
|
||||
notice.setSchId(1);
|
||||
notice.setPublisherAccount("it-user");
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
notice = noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
// ==================== ① 回调端点应用验签 ====================
|
||||
|
||||
@Test
|
||||
void signedCompletedEvent_advancesNoticeToPublished() {
|
||||
String body = eventBody(uuid(), "flow.completed", 3,
|
||||
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}");
|
||||
ResponseEntity<String> resp = flowCenter().post(CALLBACK_PATH, body, String.class);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "合法应用签名事件应 200");
|
||||
|
||||
Notice updated = reload();
|
||||
assertEquals(20, updated.getAuditStatus());
|
||||
assertEquals(1, updated.getStatus(), "completed → status=1 正式发布");
|
||||
assertEquals(3, updated.getLastInstanceVersion());
|
||||
assertNotNull(updated.getFlowResultJson());
|
||||
assertTrue(updated.getFlowResultJson().contains("同意"));
|
||||
// 事件去重行落库(事务提交)
|
||||
assertEquals(1L, callbackEventCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateEventId_idempotentSecondDelivery() {
|
||||
String body = eventBody(uuid(), "flow.completed", 3, "{\"result\":\"APPROVED\"}");
|
||||
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue());
|
||||
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
||||
assertEquals(20, reload().getAuditStatus());
|
||||
assertEquals(1L, callbackEventCount(), "eventId 唯一:重复投递不落第二行");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFieldsBody_200_silentDrop() {
|
||||
// 合法 JSON 但缺事件字段:应用验签可过,业务层判毒丢弃回 200
|
||||
String body = "{\"eventId\":\"" + uuid() + "\",\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\"}";
|
||||
ResponseEntity<String> resp = flowCenter().post(CALLBACK_PATH, body, String.class);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "缺字段毒消息回 200 防无限重放");
|
||||
assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进");
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignature_401() {
|
||||
String body = eventBody(uuid(), "flow.completed", 3, "{}");
|
||||
ResponseEntity<String> resp = flowCenter().postWithBadSecret(CALLBACK_PATH, body);
|
||||
assertEquals(401, resp.getStatusCodeValue(), "坏签名必须 401");
|
||||
assertEquals(10, reload().getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousCallback_401() {
|
||||
// 回调不再有独立 X-Flw 验签过滤器:匿名直访由 platform enforce-patterns 拦下
|
||||
String body = eventBody(uuid(), "flow.completed", 3, "{}");
|
||||
ResponseEntity<String> resp = flowCenter().postRaw(CALLBACK_PATH, body);
|
||||
assertEquals(401, resp.getStatusCodeValue(), "匿名回调必须 401(/oapi/* 强制验签)");
|
||||
assertEquals(10, reload().getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingProbe_requiresAppSignature() {
|
||||
String body = "{\"event\":\"ping\",\"flowGuid\":\"" + FLOW_GUID + "\",\"occurredTime\":\"2026-09-13 00:00:00\"}";
|
||||
// 探活 ping 同样要带应用签名(推送方 CallbackAppSigner 会给探活补头)
|
||||
assertEquals(401, flowCenter().postRaw(CALLBACK_PATH, body).getStatusCodeValue(),
|
||||
"无签名 ping 应被 401 拦下");
|
||||
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||
"带签名的 ping 回 200(注册探活只判可达)");
|
||||
assertEquals(0L, callbackEventCount(), "ping 不落事件行");
|
||||
}
|
||||
|
||||
// ==================== ② /oapi/notice/getDict 三态 ====================
|
||||
|
||||
@Test
|
||||
void oapiGetDict_threeStates() {
|
||||
OapiSignedClient client = new OapiSignedClient(url(""), FLOW_APP_KEY, FLOW_APP_SECRET);
|
||||
// 匿名(无签名头)→ 401
|
||||
assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
||||
// 错误密钥 → 401
|
||||
assertEquals(401, client.getWithBadSecret("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
||||
// 合法签名 → 200(信封 data 为字典数组——空校库也是合法数组)
|
||||
ResponseEntity<JsonNode> ok = client.get("/oapi/notice/getDict?type=1", JsonNode.class);
|
||||
assertEquals(200, ok.getStatusCodeValue());
|
||||
assertTrue(ok.getBody() != null, "应返回信封(code=0)");
|
||||
}
|
||||
|
||||
// ==================== ③ 实体落库 ====================
|
||||
|
||||
@Test
|
||||
void flowColumns_roundtrip_andRegUk() {
|
||||
Notice row = new Notice();
|
||||
row.setTitle("列回读");
|
||||
row.setType(1);
|
||||
row.setStatus(0);
|
||||
row.setFlowGuid("fg-cols");
|
||||
row.setFlowInstanceId(12345L);
|
||||
row.setAuditStatus(30);
|
||||
row.setLastInstanceVersion(4);
|
||||
row.setFlowResultJson("{\"opinion\":\"改后重提\"}");
|
||||
row.setSchId(1);
|
||||
row.setPublisherAccount("it-user");
|
||||
Notice saved = noticeRepository.save(row);
|
||||
Notice loaded = noticeRepository.findById(saved.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals("fg-cols", loaded.getFlowGuid());
|
||||
assertEquals(12345L, loaded.getFlowInstanceId());
|
||||
assertEquals(30, loaded.getAuditStatus());
|
||||
assertEquals(4, loaded.getLastInstanceVersion());
|
||||
assertEquals("{\"opinion\":\"改后重提\"}", loaded.getFlowResultJson());
|
||||
// reg uk(sch,app,flow) 冲突路径:同键二连插抛约束冲突
|
||||
NoticeFlowReg dup = new NoticeFlowReg();
|
||||
dup.setSchId(1);
|
||||
dup.setAppKey("100617");
|
||||
dup.setFlowGuid(FLOW_GUID);
|
||||
dup.setSecret("another-secret-0123456789");
|
||||
dup.setStatus(0);
|
||||
try {
|
||||
regRepository.saveAndFlush(dup);
|
||||
throw new AssertionError("uk 冲突应抛异常");
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException expected) {
|
||||
// uk 生效
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowBinding_ukPerSchool_andRoundtrip() {
|
||||
NoticeFlowBinding binding = new NoticeFlowBinding();
|
||||
binding.setSchId(9);
|
||||
binding.setFlowGuid(FLOW_GUID);
|
||||
binding.setFlowName("信息发布审核");
|
||||
bindingRepository.saveAndFlush(binding);
|
||||
NoticeFlowBinding loaded = bindingRepository.findBySchId(9).orElseThrow(IllegalStateException::new);
|
||||
assertEquals(FLOW_GUID, loaded.getFlowGuid());
|
||||
assertEquals("信息发布审核", loaded.getFlowName());
|
||||
// uk(sch_id):一校只能有一条绑定
|
||||
NoticeFlowBinding dup = new NoticeFlowBinding();
|
||||
dup.setSchId(9);
|
||||
dup.setFlowGuid("another-guid");
|
||||
try {
|
||||
bindingRepository.saveAndFlush(dup);
|
||||
throw new AssertionError("uk 冲突应抛异常");
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException expected) {
|
||||
// uk 生效
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
private OapiSignedClient flowCenter() {
|
||||
return new OapiSignedClient(url(""), FLOW_APP_KEY, FLOW_APP_SECRET);
|
||||
}
|
||||
|
||||
private Notice reload() {
|
||||
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
|
||||
private String url(String path) {
|
||||
return "http://localhost:" + port + path;
|
||||
}
|
||||
|
||||
private String eventBody(String eventId, String event, int version, String dataJson) {
|
||||
return "{"
|
||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
||||
+ "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527,"
|
||||
+ "\"instanceVersion\":" + version + ","
|
||||
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"IT 审核通知\","
|
||||
+ "\"occurredTime\":\"2026-09-13 00:00:00\","
|
||||
+ "\"data\":" + dataJson + "}";
|
||||
}
|
||||
|
||||
private static String uuid() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
|
||||
private long callbackEventCount() {
|
||||
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
|
||||
return c == null ? 0 : c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yunhai.notice.job;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSendService;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 短信到点补发单测(§3.2 修 BP-R5-2:审批通过早于定时点 → 短信永久丢失):
|
||||
* 到点恰发一次;未到点(扫描谓词不含)不发;并发/重复调度认领失败不双发;
|
||||
* 发送失败归还认领不阻断批次。
|
||||
*/
|
||||
@DisplayName("SmsCatchupJob/SmsSendService - 到点补发与防重复")
|
||||
class SmsCatchupJobTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private SmsSender smsSender;
|
||||
private SmsSendService smsSendService;
|
||||
private SmsCatchupJob job;
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
smsSendService = new SmsSendService(noticeRepository, smsSender);
|
||||
job = new SmsCatchupJob(noticeRepository, smsSendService);
|
||||
ReflectionTestUtils.setField(job, "enabled", true);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setTitle("定时通知");
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(1);
|
||||
notice.setSmsFlag(1);
|
||||
notice.setTimingSendTime(LocalDateTime.now().minusMinutes(10)); // 已到点
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("到点补发恰一次:认领成功 → 发送一次;下轮扫描已空 → 不再发")
|
||||
void dueNotice_sentExactlyOnce() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
|
||||
// 下一轮:扫描已无待发行(sms_sent_at 已置)
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未到点不发:扫描谓词(JPQL timingSendTime<=now)不含未到点行 → 空批不发送")
|
||||
void notDueNotice_notSent() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.emptyList()); // 未到点的行不进扫描结果
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发/重复调度不双发:认领返回 0(他处已发)→ 跳过发送")
|
||||
void concurrentOrDuplicateClaim_noDoubleSend() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(0); // 已被认领
|
||||
job.catchupDueSms();
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送失败:归还认领(可下轮重试)且不阻断批次(后续行继续)")
|
||||
void sendFailure_returnsClaimAndContinuesBatch() {
|
||||
Notice ok = new Notice();
|
||||
ok.setId(43);
|
||||
ok.setTitle("第二条");
|
||||
ok.setStatus(1);
|
||||
ok.setSmsFlag(1);
|
||||
ok.setReceiveList("[\"tea2\"]");
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(java.util.Arrays.asList(notice, ok));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("sms gateway down"))
|
||||
.doNothing()
|
||||
.when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
|
||||
assertDoesNotThrow(() -> job.catchupDueSms());
|
||||
verify(smsSender, times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); // 第二条仍被尝试
|
||||
verify(noticeRepository).clearSmsSent(42); // 失败行归还认领
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("认领时间落库:markSmsSent 携带当前时间(审计可追溯)")
|
||||
void claimCarriesTimestamp() {
|
||||
when(noticeRepository.findSmsDue(any(LocalDateTime.class), any()))
|
||||
.thenReturn(Collections.singletonList(notice));
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
job.catchupDueSms();
|
||||
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(noticeRepository).markSmsSent(anyInt(), captor.capture());
|
||||
assertEquals(true, captor.getValue().isAfter(LocalDateTime.now().minusMinutes(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关关闭(notice.sms.catchup-enabled=false)不扫描")
|
||||
void disabledSwitch_skips() {
|
||||
ReflectionTestUtils.setField(job, "enabled", false);
|
||||
job.catchupDueSms();
|
||||
verify(noticeRepository, never()).findSmsDue(any(LocalDateTime.class), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,253 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.openapi.core.YschCallProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* FlowCenterClient 契约对齐测试(T2.1):JDK HttpServer 伪 flow-center,捕获 Notice 出站请求,
|
||||
* 断言 URL/入参 JSON 与 flow-center /oapi/flow/** 契约逐字对齐(bizType="notice");
|
||||
* enabled=false → 全部方法抛业务异常(文案含"流程中心未接入")。
|
||||
* 客户端真实经 YschOpenCall(Tier 0)出站——同时验证信封解析与错误映射。
|
||||
*/
|
||||
class FlowCenterClientTest {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final List<Captured> captured = new CopyOnWriteArrayList<>();
|
||||
|
||||
static class Captured {
|
||||
String method;
|
||||
String path;
|
||||
String query;
|
||||
JsonNode body;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void startStub() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
port = server.getAddress().getPort();
|
||||
server.createContext("/", exchange -> {
|
||||
Captured c = new Captured();
|
||||
c.method = exchange.getRequestMethod();
|
||||
c.path = exchange.getRequestURI().getPath();
|
||||
c.query = exchange.getRequestURI().getRawQuery();
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
String raw = new String(out.toByteArray(), StandardCharsets.UTF_8);
|
||||
c.body = raw.isEmpty() ? null : objectMapper.readTree(raw);
|
||||
}
|
||||
captured.add(c);
|
||||
route(exchange);
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopStub() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 伪 flow-center 四端点(信封 {code:0,data:...}) */
|
||||
private void route(HttpExchange exchange) throws IOException {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
if ("/oapi/flow/list".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":[{\"flowGuid\":\"fg-1\",\"name\":\"信息发布审核\",\"version\":3,\"nodeSummary\":\"编辑审核→终审\"}]}");
|
||||
} else if ("/oapi/flow/apply".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"duplicated\":false}}");
|
||||
} else if ("/oapi/flow/instance".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"status\":10,\"version\":2,\"nodeSummary\":\"n1\",\"traceJson\":\"[]\",\"assigneeIds\":[301,302],\"currentNodes\":\"n1\"}}");
|
||||
} else if ("/oapi/flow/callback/reg".equals(path)) {
|
||||
respond(exchange, 200, "{\"code\":0,\"data\":{\"secret\":\"reg-secret-0123456789abcdef\"}}");
|
||||
} else {
|
||||
respond(exchange, 404, "{\"code\":5,\"msg\":\"not found\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 固定返回的 ObjectProvider 桩(构造注入用) */
|
||||
static class FixedProvider<T> implements org.springframework.beans.factory.ObjectProvider<T> {
|
||||
private final T value;
|
||||
|
||||
FixedProvider(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject(Object... args) {
|
||||
if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject() {
|
||||
if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfUnique() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.stream.Stream<T> stream() {
|
||||
return value == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(value);
|
||||
}
|
||||
}
|
||||
|
||||
private FlowCenterClient newClient(boolean enabled) {
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(enabled);
|
||||
props.setBaseUrl("http://localhost:" + port);
|
||||
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> java.util.Collections.singletonList(
|
||||
(org.springframework.http.client.ClientHttpRequestInterceptor)
|
||||
(request, body, execution) -> execution.execute(request, body)));
|
||||
return new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录与 legacy 键皆未配且 enabled=true:构造期 fail-fast(不静默打 localhost)")
|
||||
void misconfiguredFailsFast() {
|
||||
NoticeFlowProperties props = new NoticeFlowProperties(); // baseUrl 已无默认值
|
||||
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> java.util.Collections.emptyList());
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||
() -> new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper));
|
||||
assertTrue(e.getMessage().contains("yh.open.services.flow-center"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("notice.flow.base-url"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enabled=false 且地址全缺:不 fail-fast(显式未接入,地址本就不需要)")
|
||||
void disabledWithoutAddressIsAllowed() {
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(false);
|
||||
YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper,
|
||||
(host, basePath) -> java.util.Collections.emptyList());
|
||||
FlowCenterClient client = new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper);
|
||||
BizException ex = assertThrows(BizException.class, () -> client.listFlows(1));
|
||||
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
|
||||
}
|
||||
|
||||
private FlowCenterClient newClient() {
|
||||
return newClient(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFlows_contractShape() {
|
||||
List<FlowModels.FlowSummaryVo> flows = newClient().listFlows(1);
|
||||
assertEquals(1, flows.size());
|
||||
assertEquals("fg-1", flows.get(0).getFlowGuid());
|
||||
assertEquals("信息发布审核", flows.get(0).getName());
|
||||
assertEquals(3, flows.get(0).getVersion());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("GET", c.method);
|
||||
assertEquals("/oapi/flow/list", c.path);
|
||||
assertEquals("schId=1", c.query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_contractFields_bizTypeNotice() {
|
||||
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
|
||||
body.setFlowGuid("fg-1");
|
||||
body.setBizType(FlowCenterClient.BIZ_TYPE);
|
||||
body.setBizId("42");
|
||||
body.setBizTitle("九月开学安排");
|
||||
body.setBizUrl("http://localhost:8077/#/list/detail?serid=42");
|
||||
body.setSubmitterId(201);
|
||||
body.setSubmitterName("张三");
|
||||
FlowModels.ApplyResultVo result = newClient().apply(1, body);
|
||||
assertEquals(9527L, result.getInstanceId());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("POST", c.method);
|
||||
assertEquals("/oapi/flow/apply", c.path);
|
||||
assertEquals("schId=1", c.query);
|
||||
assertEquals("notice", c.body.get("bizType").asText(), "契约冻结:bizType=notice");
|
||||
assertEquals("42", c.body.get("bizId").asText());
|
||||
assertEquals("fg-1", c.body.get("flowGuid").asText());
|
||||
assertEquals("九月开学安排", c.body.get("bizTitle").asText());
|
||||
assertEquals(201, c.body.get("submitterId").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceByBiz_contractFields() {
|
||||
FlowModels.InstanceVo vo = newClient().getInstanceByBiz(1, "notice", "42");
|
||||
assertEquals(9527L, vo.getInstanceId());
|
||||
assertEquals(10, vo.getStatus());
|
||||
assertEquals(2, vo.getVersion());
|
||||
assertEquals(2, vo.getAssigneeIds().size());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("/oapi/flow/instance", c.path);
|
||||
assertTrue(c.query.contains("bizType=notice") && c.query.contains("bizId=42") && c.query.contains("schId=1"),
|
||||
"instance 查询应带三参数: " + c.query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCallback_contractFields() {
|
||||
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
|
||||
body.setFlowGuid("fg-1");
|
||||
body.setCallbackUrl("http://localhost:8101/oapi/flow/callback");
|
||||
FlowModels.CallbackRegResultVo result = newClient().registerCallback(1, body);
|
||||
assertEquals("reg-secret-0123456789abcdef", result.getSecret());
|
||||
Captured c = captured.get(0);
|
||||
assertEquals("POST", c.method);
|
||||
assertEquals("/oapi/flow/callback/reg", c.path);
|
||||
assertEquals("http://localhost:8101/oapi/flow/callback", c.body.get("callbackUrl").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabled_allMethodsThrowBusinessException() {
|
||||
FlowCenterClient client = newClient(false);
|
||||
BizException ex = assertThrows(BizException.class, () -> client.listFlows(1));
|
||||
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
|
||||
assertThrows(BizException.class, () -> client.apply(1, new FlowModels.ApplyBody()));
|
||||
assertThrows(BizException.class, () -> client.getInstanceByBiz(1, "notice", "1"));
|
||||
assertThrows(BizException.class, () -> client.registerCallback(1, new FlowModels.CallbackRegBody()));
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。<br><br>
|
||||
* 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须等于冻结字面量
|
||||
* {@code /oapi/flow/callback}——不一致时 flow-center 探活/推送全部 404,而本地单测因为走
|
||||
* 伪服务端不会发现。<br>
|
||||
* 2026-09-24:字面量由 {@code /oapi/notice/flow/callback} 收敛为 starter 默认值
|
||||
* (网关契约 {@code /{target}/oapi/**} 首段已由 target 承担,端点内不再带应用名前缀);
|
||||
* 同日清理 {@code notice.flow.callback-base} / {@code callback-base-path} / {@code callback-path}
|
||||
* 三个已无代码引用的历史键——注册地址改由 {@code yh.flow-callback} 三段推导,
|
||||
* 故本测试不再做双键一致性比对,改为断言历史键不得回流。<br>
|
||||
* 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController
|
||||
* 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。
|
||||
*/
|
||||
@DisplayName("Notice 回调路径配置 - 与 starter 端点映射咬合")
|
||||
class NoticeFlowCallbackConfigContractTest {
|
||||
|
||||
private static final String FROZEN_CALLBACK_PATH = "/oapi/flow/callback";
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object yamlValue(String path) throws Exception {
|
||||
try (InputStream in = NoticeFlowCallbackConfigContractTest.class.getClassLoader()
|
||||
.getResourceAsStream("application.yml")) {
|
||||
assertNotNull(in, "application.yml 必须在测试类路径上");
|
||||
Map<String, Object> root = new Yaml().load(in);
|
||||
Object cur = root;
|
||||
for (String seg : path.split("\\.")) {
|
||||
if (!(cur instanceof Map)) {
|
||||
return null;
|
||||
}
|
||||
cur = ((Map<String, Object>) cur).get(seg);
|
||||
if (cur == null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("yh.flow-callback.path 等于冻结字面量(starter 端点映射唯一真源)")
|
||||
void starterPathMatchesFrozenLiteral() throws Exception {
|
||||
Object starterConfigured = yamlValue("yh.flow-callback.path");
|
||||
String starterPath = starterConfigured == null
|
||||
? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里)
|
||||
: String.valueOf(starterConfigured);
|
||||
|
||||
assertEquals(FROZEN_CALLBACK_PATH, starterPath,
|
||||
"yh.flow-callback.path 变了:flow-center 派发会 404,请同步生产配置与注册契约");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史回调键(callback-base / -base-path / -path)已清理,不得回流")
|
||||
void legacyCallbackKeysAreGone() throws Exception {
|
||||
assertNull(yamlValue("notice.flow.callback-base"),
|
||||
"notice.flow.callback-base 已无代码引用,不应再出现在配置里");
|
||||
assertNull(yamlValue("notice.flow.callback-base-path"),
|
||||
"notice.flow.callback-base-path 已无代码引用,不应再出现在配置里");
|
||||
assertNull(yamlValue("notice.flow.callback-path"),
|
||||
"notice.flow.callback-path 已无代码引用,不应再出现在配置里");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.sms.SmsSendService;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 Notice 侧状态机;
|
||||
* 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的
|
||||
* FlowCallbackReceiverTest 覆盖):六事件映射;completed → status=1 + sms 恰一次;
|
||||
* 单据级乱序丢弃;终态不回退守卫;cc.sent 只落抄送行不动状态机(2026-09-24);
|
||||
* 异常直接外抛(starter 回 5xx)。
|
||||
*/
|
||||
class NoticeFlowCallbackHandlerTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private SmsSender smsSender;
|
||||
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||
private NoticeFlowCallbackHandler handler;
|
||||
private Notice notice;
|
||||
private final AtomicInteger saveCount = new AtomicInteger();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
|
||||
// 统一发送链(§3.2):认领桩=1 表示未发过(首次),0=已发(重复闸门)
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
handler = new NoticeFlowCallbackHandler(noticeRepository,
|
||||
new SmsSendService(noticeRepository, smsSender), new ObjectMapper(), noticeCcRepository);
|
||||
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setTitle("审核通知标题");
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(0);
|
||||
notice.setFlowGuid("fg-1");
|
||||
notice.setAuditStatus(10);
|
||||
notice.setSmsFlag(1);
|
||||
notice.setReceiveList("[\"tea1\"]");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(notice));
|
||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.of(notice));
|
||||
when(noticeRepository.saveAndFlush(any(Notice.class))).thenAnswer(inv -> {
|
||||
saveCount.incrementAndGet();
|
||||
return inv.getArgument(0);
|
||||
});
|
||||
}
|
||||
|
||||
private FlowCallbackEvent event(String eventId, String event, int version, String result, String opinion) {
|
||||
FlowCallbackEvent e = new FlowCallbackEvent();
|
||||
e.setEventId(eventId);
|
||||
e.setEvent(event);
|
||||
e.setSchId(1);
|
||||
e.setFlowGuid("fg-1");
|
||||
e.setInstanceId(9527L);
|
||||
e.setInstanceVersion(version);
|
||||
e.setBizType("notice");
|
||||
e.setBizId("42");
|
||||
e.setBizTitle("t");
|
||||
e.setTaskId(7L);
|
||||
e.setOperatorUserId(201);
|
||||
e.setOperatorName("张三");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (result != null) {
|
||||
data.put("result", result);
|
||||
}
|
||||
if (opinion != null) {
|
||||
data.put("opinion", opinion);
|
||||
}
|
||||
e.setData(data);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void inFlightEvents_mapToApproving() {
|
||||
handler.onEvent(event("e1", "flow.submitted", 1, null, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
handler.onEvent(event("e2", "flow.task.created", 1, null, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
handler.onEvent(event("e3", "flow.task.handled", 2, null, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus(), "审批中事件不得提前发布");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completed_publishesAndSendsSmsOnce() {
|
||||
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", "同意"));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getStatus(), "审核通过置 status=1 正式发布");
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertTrue(notice.getFlowResultJson().contains("APPROVED"), "终态明细落 flow_result_json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completed_beforeTimingPoint_smsDeferredToCatchup() {
|
||||
// §3.2:审批通过早于定时点——短信此刻不发也不认领,留给 SmsCatchupJob 到点补发
|
||||
notice.setTimingSendTime(java.time.LocalDateTime.now().plusHours(1));
|
||||
handler.onEvent(event("e-df", "flow.completed", 3, "APPROVED", null));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getStatus(), "可见性即时生效不受影响");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
verify(noticeRepository, never()).markSmsSent(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTwice_smsOnlyOnce() {
|
||||
// 已发布行再收到终态事件:status 已 1 → justPublished=false → 不再发 sms
|
||||
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", null));
|
||||
notice.setStatus(1);
|
||||
notice.setAuditStatus(20);
|
||||
handler.onEvent(event("e4x", "flow.completed", 4, "APPROVED", null));
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejected_mapsTo30_withResultJson() {
|
||||
handler.onEvent(event("e5", "flow.rejected", 3, "REJECTED", "内容不妥"));
|
||||
assertEquals(30, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus(), "驳回不发布");
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertTrue(notice.getFlowResultJson().contains("内容不妥"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawn_mapsTo40() {
|
||||
handler.onEvent(event("e6", "flow.withdrawn", 2, "WITHDRAWN", null));
|
||||
assertEquals(40, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalBizId_silentReturn() {
|
||||
FlowCallbackEvent e = event("e-x", "flow.completed", 3, null, null);
|
||||
e.setBizId("not-a-number");
|
||||
handler.onEvent(e); // 不抛(starter 回 200)
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownNotice_silentReturn() {
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.empty());
|
||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
|
||||
handler.onEvent(event("e10", "flow.completed", 3, null, null)); // 不抛
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersion_dropped() {
|
||||
notice.setLastInstanceVersion(5);
|
||||
handler.onEvent(event("e7", "flow.completed", 3, null, null)); // 迟到的低版本事件
|
||||
assertEquals(10, notice.getAuditStatus(), "乱序事件丢弃");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameVersionDifferentEvent_processed() {
|
||||
// submitted 与 task.created 天然同版本:等值不丢(幂等映射)
|
||||
notice.setLastInstanceVersion(1);
|
||||
handler.onEvent(event("e8", "flow.task.created", 1, null, null));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
assertEquals(1, notice.getLastInstanceVersion());
|
||||
}
|
||||
|
||||
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置(completed 先落库,随后到 task.handled) =====
|
||||
|
||||
@Test
|
||||
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
|
||||
handler.onEvent(event("e-t1", "flow.completed", 4, "APPROVED", "同意"));
|
||||
assertEquals(20, notice.getAuditStatus());
|
||||
String frozen = notice.getFlowResultJson();
|
||||
|
||||
handler.onEvent(event("e-t2", "flow.task.handled", 4, null, "同意")); // 不抛 → 200
|
||||
assertEquals(20, notice.getAuditStatus(), "终态行拒绝非终态事件(终态不回退)");
|
||||
assertEquals(frozen, notice.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
|
||||
handler.onEvent(event("e-t3", "flow.completed", 4, "APPROVED", null));
|
||||
handler.onEvent(event("e-t4", "flow.task.handled", 5, null, null));
|
||||
assertEquals(20, notice.getAuditStatus(), "终态后任何非终态事件都不回退(R4 重提由 edit 先置 0,不冲突)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
|
||||
handler.onEvent(event("e-t5", "flow.rejected", 4, "REJECTED", "材料不齐"));
|
||||
assertEquals(30, notice.getAuditStatus());
|
||||
String frozen = notice.getFlowResultJson();
|
||||
handler.onEvent(event("e-t6", "flow.withdrawn", 4, "WITHDRAWN", null));
|
||||
assertEquals(30, notice.getAuditStatus(), "已终态不被另一终态改写");
|
||||
assertEquals(frozen, notice.getFlowResultJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalGuard_doesNotBlockR4Resubmission_afterLocalReset() {
|
||||
handler.onEvent(event("e-t7", "flow.rejected", 4, "REJECTED", null));
|
||||
notice.setAuditStatus(0); // NoticeFlowService 编辑保存时本地重置
|
||||
handler.onEvent(event("e-t8", "flow.submitted", 5, null, null));
|
||||
assertEquals(10, notice.getAuditStatus(), "重置后新一轮事件正常推进(守卫只拦终态行的回退)");
|
||||
assertEquals(5, notice.getLastInstanceVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownEvent_silentReturn() {
|
||||
handler.onEvent(event("e-u1", "flow.returned", 3, null, null)); // Notice 不消费的已知事件
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
}
|
||||
|
||||
// ===== cc.sent(2026-09-24):只落 notice_cc 抄送行,不动审核状态机 =====
|
||||
|
||||
private FlowCallbackEvent ccEvent(String eventId, int version, Integer ccUserId) {
|
||||
FlowCallbackEvent e = event(eventId, "flow.cc.sent", version, null, null);
|
||||
e.setTaskId(null); // 抄送无任务
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", "cc1");
|
||||
data.put("nodeName", "抄送校长");
|
||||
data.put("ccId", 501L);
|
||||
if (ccUserId != null) {
|
||||
data.put("ccUserId", ccUserId);
|
||||
data.put("ccUserName", "校长甲");
|
||||
}
|
||||
e.setData(data);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void ccSent_recordsRow_withoutTouchingStateMachine() {
|
||||
handler.onEvent(ccEvent("e-cc1", 3, 301));
|
||||
verify(noticeCcRepository, times(1)).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||
org.mockito.ArgumentCaptor<com.yunhai.notice.entity.NoticeCc> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(com.yunhai.notice.entity.NoticeCc.class);
|
||||
verify(noticeCcRepository).save(captor.capture());
|
||||
com.yunhai.notice.entity.NoticeCc saved = captor.getValue();
|
||||
assertEquals(42, saved.getNoticeId());
|
||||
assertEquals(9527L, saved.getFlowInstanceId());
|
||||
assertEquals("cc1", saved.getNodeId());
|
||||
assertEquals("抄送校长", saved.getNodeName());
|
||||
assertEquals(301, saved.getCcUserId());
|
||||
assertEquals("校长甲", saved.getCcUserName());
|
||||
// 知会不是审批:审核状态/发布状态/版本快照全部不动
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
assertEquals(0, notice.getStatus());
|
||||
verify(noticeRepository, never()).saveAndFlush(any(Notice.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ccSent_missingCcUserId_skippedSilently() {
|
||||
handler.onEvent(ccEvent("e-cc2", 3, null)); // 上游脏数据:不抛(回 200),不落行
|
||||
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||
assertEquals(10, notice.getAuditStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ccSent_staleVersion_dropped() {
|
||||
notice.setLastInstanceVersion(5);
|
||||
handler.onEvent(ccEvent("e-cc3", 3, 301)); // 迟到的低版本抄送
|
||||
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ccSent_dbFailure_propagates() {
|
||||
when(noticeCcRepository.save(any(com.yunhai.notice.entity.NoticeCc.class)))
|
||||
.thenThrow(new DataAccessResourceFailureException("db down"));
|
||||
assertThrows(DataAccessResourceFailureException.class,
|
||||
() -> handler.onEvent(ccEvent("e-cc4", 3, 301)),
|
||||
"抄送落库失败回 5xx 让 flow-center 重试(事件行随事务回滚)");
|
||||
}
|
||||
|
||||
// ===== 异常外抛 = starter 回 5xx(flow-center 重试追平,免对账语义) =====
|
||||
|
||||
@Test
|
||||
void dbFailure_propagates() {
|
||||
when(noticeRepository.saveAndFlush(any(Notice.class)))
|
||||
.thenThrow(new DataAccessResourceFailureException("db down"));
|
||||
assertThrows(DataAccessResourceFailureException.class,
|
||||
() -> handler.onEvent(event("e11", "flow.completed", 3, null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void smsFailure_propagates() {
|
||||
doThrow(new RuntimeException("sms gateway down")).when(smsSender)
|
||||
.sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> handler.onEvent(event("e12", "flow.completed", 3, null, null)),
|
||||
"发布后置失败也应回 5xx 让流程中心重试(事件行随事务回滚)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人/被抄送人 → 放行;
|
||||
* 无关人/无身份 → 403"通知不存在或未发布"。
|
||||
*/
|
||||
class NoticeFlowGuardTest {
|
||||
|
||||
private FlowClient flowClient;
|
||||
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||
private NoticeFlowGuard guard;
|
||||
private Notice notice;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
flowClient = mock(FlowClient.class);
|
||||
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
|
||||
NoticeFlowProperties properties = new NoticeFlowProperties();
|
||||
properties.setEnabled(true);
|
||||
guard = new NoticeFlowGuard(flowClient, properties, noticeCcRepository);
|
||||
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
|
||||
notice = new Notice();
|
||||
notice.setId(42);
|
||||
notice.setSchId(1);
|
||||
notice.setStatus(0);
|
||||
notice.setFlowGuid("fg-1");
|
||||
notice.setFlowInstanceId(9527L);
|
||||
notice.setPublisherAccount("zhangsan");
|
||||
}
|
||||
|
||||
private BoaUser user(Integer id, String account, Integer type, java.util.List<Integer> roleIds) {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(id);
|
||||
user.setAccount(account);
|
||||
user.setType(type);
|
||||
user.setRoleIds(roleIds);
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
user.setCurrentRole(role);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void stubAssignees(Integer... ids) {
|
||||
FlowModels.InstanceVo instance = new FlowModels.InstanceVo();
|
||||
instance.setAssigneeIds(Arrays.asList(ids));
|
||||
when(flowClient.getInstanceByBiz(any(), anyString(), anyString())).thenReturn(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitter_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schoolAdmin_byType_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(999, "admin1", 1, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schoolAdmin_byRoleId_allowed() {
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(999, "teacher5", 0, Collections.singletonList(5))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignee_allowed() {
|
||||
stubAssignees(301, 302);
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
||||
}
|
||||
|
||||
// 被抄送人放行(2026-09-24 第 4 条):本地 notice_cc 有行即可见,「抄送给我的」点开依赖此规则
|
||||
@Test
|
||||
void ccRecipient_allowed_evenIfFlowCenterDown() {
|
||||
when(flowClient.getInstanceByBiz(any(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("flow-center down"));
|
||||
when(noticeCcRepository.existsByNoticeIdAndCcUserId(42, 501)).thenReturn(true);
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(501, "principal", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notCcRecipient_forbidden() {
|
||||
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
|
||||
stubAssignees(301);
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(502, "not-cc", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedUser_forbidden() {
|
||||
stubAssignees(301, 302);
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(404, "stranger", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noIdentity_forbidden() {
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notYetSubmitted_flowInstanceMissing_forbiddenExceptPrivileged() {
|
||||
notice.setFlowInstanceId(null); // 待提交(apply 未成功)→ 办理人维度不存在
|
||||
stubAssignees(301);
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
||||
assertDoesNotThrow(() ->
|
||||
guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowCenterDown_failClosed() {
|
||||
when(flowClient.getInstanceByBiz(any(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("flow-center down"));
|
||||
assertThrows(BizException.class, () ->
|
||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)),
|
||||
"流程中心不可达时 fail-closed 不放行");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||
import com.yunhai.notice.entity.NoticeFlowEvent;
|
||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.model.notice.NoticeRequest;
|
||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeReadRepository;
|
||||
import com.yunhai.notice.repository.NoticeReceiverRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.notice.service.IFileService;
|
||||
import com.yunhai.notice.service.IDictService;
|
||||
import com.yunhai.notice.service.impl.NoticeService;
|
||||
import com.yunhai.notice.service.sms.SmsSender;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.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;
|
||||
|
||||
/**
|
||||
* 发布分流/守卫/重提单测(fake FlowClient 注入)。<p>
|
||||
* 分流规则(应用级绑定,规则单一事实源=NoticeService):
|
||||
* <b>已绑定 + 通知对象=全部用户 → 审核</b>(status=0/audit=0→apply→回填 10);
|
||||
* <b>已绑定 + 指定人员 → 直发</b>(原链路零变化);<b>未绑定 → 直发</b>(存量零破坏哨兵)。
|
||||
* 另覆盖:apply 失败保持 0 可重推、10 审批中锁编辑/删除/重提、30/40 编辑重提复用同 bizId、短信时机后移。
|
||||
*/
|
||||
class NoticeFlowPublishTest {
|
||||
|
||||
private static final String BINDING_GUID = "fg-review";
|
||||
|
||||
private NoticeService noticeService;
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeFlowBindingRepository bindingRepository;
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowService noticeFlowService;
|
||||
private SmsSender smsSender;
|
||||
private IDictService dictService;
|
||||
private CurrentBoaUser currentBoaUser;
|
||||
|
||||
private final AtomicInteger applyCalls = new AtomicInteger();
|
||||
private Notice savedEntity;
|
||||
private final java.util.List<Integer> auditAtSave = new java.util.ArrayList<>();
|
||||
private final java.util.List<Integer> statusAtSave = new java.util.ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
auditAtSave.clear();
|
||||
statusAtSave.clear();
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
bindingRepository = mock(NoticeFlowBindingRepository.class);
|
||||
smsSender = mock(SmsSender.class);
|
||||
dictService = mock(IDictService.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
currentBoaUser = mock(CurrentBoaUser.class);
|
||||
|
||||
// repository.save 回带 id 的实体(模拟自增主键);findById 回最近保存实体
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> {
|
||||
Notice n = inv.getArgument(0);
|
||||
if (n.getId() == null) {
|
||||
n.setId(77);
|
||||
}
|
||||
savedEntity = n;
|
||||
auditAtSave.add(n.getAuditStatus()); // 快照(避免 captor 引用别名)
|
||||
statusAtSave.add(n.getStatus());
|
||||
return n;
|
||||
});
|
||||
when(noticeRepository.findById(77)).thenAnswer(inv -> Optional.ofNullable(savedEntity));
|
||||
// 短信统一发送链(§3.2):认领桩=1 表示未发过(首次发送放行)
|
||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||
// 模拟窄列原子更新(作用于当前 findById 可见的实体;audit=20 不回退语义一并体现)
|
||||
when(noticeRepository.markApproving(anyInt(), any())).thenAnswer(inv -> {
|
||||
Notice current = noticeRepository.findById(inv.getArgument(0)).orElse(null);
|
||||
if (current != null) {
|
||||
current.setFlowInstanceId(inv.getArgument(1));
|
||||
if (current.getAuditStatus() == null || current.getAuditStatus() != 20) {
|
||||
current.setAuditStatus(10);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
when(dictService.resolveDeptName(any())).thenReturn("办公室");
|
||||
// 默认未绑定;绑定场景由 bindFlow() 显式打开
|
||||
when(bindingRepository.findBySchId(any())).thenReturn(Optional.empty());
|
||||
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(true);
|
||||
AppAuthProperties appAuth = new AppAuthProperties();
|
||||
appAuth.setAppKey("100617");
|
||||
|
||||
NoticeFlowRegRepository regRepository = mock(NoticeFlowRegRepository.class);
|
||||
when(regRepository.findBySchIdAndAppKeyAndFlowGuid(any(), any(), any())).thenReturn(Optional.empty());
|
||||
when(flowClient.registerCallback(any(), any())).thenAnswer(inv -> {
|
||||
FlowModels.CallbackRegResultVo vo = new FlowModels.CallbackRegResultVo();
|
||||
vo.setSecret("reg-secret-0123456789");
|
||||
return vo;
|
||||
});
|
||||
applyCalls.set(0);
|
||||
when(flowClient.apply(anyInt(), any())).thenAnswer(inv -> {
|
||||
applyCalls.incrementAndGet();
|
||||
FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo();
|
||||
vo.setInstanceId(9527L);
|
||||
vo.setDuplicated(applyCalls.get() > 1);
|
||||
return vo;
|
||||
});
|
||||
|
||||
noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository,
|
||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||
currentBoaUser,
|
||||
new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)),
|
||||
com.yunhai.notice.support.FlowSubmitTemplates.forTests(flowClient));
|
||||
|
||||
noticeService = new NoticeService();
|
||||
ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier",
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
|
||||
|
||||
ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "upFileRepository", mock(UpFileRepository.class));
|
||||
ReflectionTestUtils.setField(noticeService, "smsSender", smsSender);
|
||||
ReflectionTestUtils.setField(noticeService, "smsSendService",
|
||||
new com.yunhai.notice.service.sms.SmsSendService(noticeRepository, smsSender));
|
||||
ReflectionTestUtils.setField(noticeService, "fileService", mock(IFileService.class));
|
||||
ReflectionTestUtils.setField(noticeService, "dictService", dictService);
|
||||
ReflectionTestUtils.setField(noticeService, "currentBoaUser", currentBoaUser);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeFlowService", noticeFlowService);
|
||||
ReflectionTestUtils.setField(noticeService, "noticeFlowGuard", mock(NoticeFlowGuard.class));
|
||||
|
||||
// 登录态:张三(201)/本校 1
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setLoginName("zhangsan");
|
||||
loginUser.setDisplayName("张三");
|
||||
UserContextHolder.set(loginUser);
|
||||
BoaUser boaUser = new BoaUser();
|
||||
boaUser.setId(201);
|
||||
boaUser.setAccount("zhangsan");
|
||||
boaUser.setName("张三");
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
boaUser.setCurrentRole(role);
|
||||
when(currentBoaUser.get()).thenReturn(boaUser);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserContextHolder.clear();
|
||||
}
|
||||
|
||||
/** 打开本校应用级流程绑定(应用设置 > 流程绑定) */
|
||||
private void bindFlow(String flowGuid) {
|
||||
NoticeFlowBinding binding = new NoticeFlowBinding();
|
||||
binding.setId(1);
|
||||
binding.setSchId(1);
|
||||
binding.setFlowGuid(flowGuid);
|
||||
binding.setFlowName("信息发布审核");
|
||||
when(bindingRepository.findBySchId(1)).thenReturn(Optional.of(binding));
|
||||
}
|
||||
|
||||
/** 请求(receiveList="all" 全员 / 指定人员串) */
|
||||
private NoticeRequest request(String receiveList) {
|
||||
NoticeRequest request = new NoticeRequest();
|
||||
request.setTitle("九月开学安排");
|
||||
request.setContent("<p>内容</p>");
|
||||
request.setType(1);
|
||||
request.setDeptId("d1");
|
||||
request.setReceiveList(receiveList);
|
||||
request.setSmsFlag(1);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundFlow_directPublish_zeroSideEffects() {
|
||||
// 存量零破坏哨兵:未绑流程 → 即便全员也 status=1、sms 即时、flow 零交互
|
||||
Integer id = noticeService.publish(request("all"));
|
||||
ArgumentCaptor<Notice> captor = ArgumentCaptor.forClass(Notice.class);
|
||||
verify(noticeRepository, times(1)).save(captor.capture());
|
||||
assertEquals(1, captor.getValue().getStatus());
|
||||
assertNull(captor.getValue().getAuditStatus());
|
||||
assertNull(captor.getValue().getFlowGuid());
|
||||
assertEquals("zhangsan", captor.getValue().getPublisherAccount());
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
verify(flowClient, never()).registerCallback(anyInt(), any());
|
||||
assertNotNull(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundFlow_allReceivers_entersAudit() {
|
||||
// 核心新规则:已绑定 + 通知对象=全部用户 → 审核载体
|
||||
bindFlow(BINDING_GUID);
|
||||
noticeService.publish(request("all"));
|
||||
verify(noticeRepository, times(1)).save(any(Notice.class)); // 仅落库;apply 后走窄列原子更新
|
||||
assertEquals(0, auditAtSave.get(0), "首存 audit=0 待提交");
|
||||
assertEquals(0, firstSavedStatus(), "走审核的通知应落 status=0 未发布");
|
||||
assertEquals(BINDING_GUID, savedEntity.getFlowGuid(), "flow_guid 取应用绑定快照");
|
||||
// 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体)
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
||||
verify(noticeRepository).markApproving(eq(77), eq(9527L));
|
||||
// sms 后置:create 时不发
|
||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundFlow_specifiedReceivers_directPublish() {
|
||||
// 核心新规则:已绑定但通知对象=指定人员 → 直发(与绑定前完全一致)
|
||||
bindFlow(BINDING_GUID);
|
||||
noticeService.publish(request("[\"tea1\",\"tea2\"]"));
|
||||
assertEquals(1, firstSavedStatus(), "指定人员不受绑定影响,直接发布");
|
||||
assertNull(savedEntity.getAuditStatus());
|
||||
assertNull(savedEntity.getFlowGuid());
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundFlow_allReceivers_timingSendTimeCleared() {
|
||||
// 定时发送与审核互斥:走审核时清空(前端隐藏入口,后端兜底)
|
||||
bindFlow(BINDING_GUID);
|
||||
NoticeRequest request = request("all");
|
||||
request.setTimingSendTime(java.time.LocalDateTime.now().plusDays(1));
|
||||
noticeService.publish(request);
|
||||
assertNull(savedEntity.getTimingSendTime(), "走审核的通知不保留定时发送时间");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() {
|
||||
bindFlow(BINDING_GUID);
|
||||
when(flowClient.apply(anyInt(), any()))
|
||||
.thenThrow(new RuntimeException("flow-center down"))
|
||||
.thenAnswer(inv -> {
|
||||
FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo();
|
||||
vo.setInstanceId(9527L);
|
||||
return vo;
|
||||
});
|
||||
noticeService.publish(request("all")); // 出站失败被吞(保持 0 待提交)
|
||||
assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)");
|
||||
// 重推入口:trySubmitFlow 幂等治愈 → 10
|
||||
noticeFlowService.trySubmitFlow(77);
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
||||
verify(noticeRepository, times(1)).markApproving(eq(77), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void trySubmit_rejectedWhenApproving() {
|
||||
bindFlow(BINDING_GUID);
|
||||
noticeService.publish(request("all")); // → 10
|
||||
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77));
|
||||
assertTrue(ex.getMessage().contains("审批中"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void trySubmit_byStranger_denied() {
|
||||
// BP-R5-3:无关登录用户不得对他人通知触发重提(403,且不得触达 apply 出站)
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
BoaUser stranger = new BoaUser();
|
||||
stranger.setId(999);
|
||||
stranger.setAccount("stranger");
|
||||
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77, stranger));
|
||||
assertTrue(ex.getMessage().contains("通知不存在或未发布"));
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editAndDelete_rejectedWhenApproving() {
|
||||
bindFlow(BINDING_GUID);
|
||||
noticeService.publish(request("all")); // → audit=10
|
||||
assertEquals(10, savedEntity.getAuditStatus());
|
||||
assertThrows(BizException.class, () -> noticeService.edit(77, request("all")));
|
||||
assertThrows(BizException.class, () -> noticeService.deleteBatch(java.util.Collections.singletonList(77)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editAfterRejected_resubmitsSameBizId() {
|
||||
bindFlow(BINDING_GUID);
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
noticeService.edit(77, request("all"));
|
||||
// 编辑后 AFTER_COMMIT(单测立即)重提,复用同一 bizId=notice.id
|
||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||
verify(flowClient, times(1)).apply(anyInt(), body.capture());
|
||||
assertEquals("77", body.getValue().getBizId());
|
||||
assertEquals(10, rejected.getAuditStatus());
|
||||
assertEquals(0, rejected.getStatus(), "重审期间仍未发布");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyParams_contract() {
|
||||
bindFlow(BINDING_GUID);
|
||||
noticeService.publish(request("all"));
|
||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||
verify(flowClient).apply(anyInt(), body.capture());
|
||||
FlowModels.ApplyBody b = body.getValue();
|
||||
assertEquals(BINDING_GUID, b.getFlowGuid());
|
||||
assertEquals("notice", b.getBizType());
|
||||
assertEquals("77", b.getBizId());
|
||||
assertEquals("九月开学安排", b.getBizTitle());
|
||||
assertEquals("/list/detail?serid=77", b.getBizUrl());
|
||||
assertEquals(201, b.getSubmitterId());
|
||||
assertEquals("张三", b.getSubmitterName());
|
||||
// 快照:title + summary(纯文本) + attachmentNames[]
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, Object> snapshot = (java.util.Map<String, Object>) b.getBizSnapshot();
|
||||
assertEquals("九月开学安排", snapshot.get("title"));
|
||||
assertEquals("内容", snapshot.get("summary"));
|
||||
assertTrue(snapshot.get("attachmentNames") instanceof java.util.List);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editToSpecified_publishesAndClearsAudit() {
|
||||
// 由审核轨道改投指定人员 → 退出轨道直发(原实现漏置 status,会把通知留在未发布态)
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
noticeService.edit(77, request("[\"tea1\"]")); // 未绑定 + 指定人员
|
||||
assertNull(rejected.getFlowGuid());
|
||||
assertNull(rejected.getAuditStatus());
|
||||
assertEquals(1, rejected.getStatus(), "退出审核轨道必须置为已发布");
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindWhileRejected_editToAll_publishesDirectly() {
|
||||
// 解绑后存量通知编辑:不再强制审核(规则=未绑定即直发)
|
||||
Notice rejected = pendingNotice(30);
|
||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||
noticeService.edit(77, request("all"));
|
||||
assertEquals(1, rejected.getStatus());
|
||||
assertNull(rejected.getFlowGuid());
|
||||
verify(flowClient, never()).apply(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingViewAndBoundFlowGuid() {
|
||||
// 未绑定视图
|
||||
java.util.Map<String, Object> view = noticeFlowService.bindingView(1, adminUser());
|
||||
assertEquals(false, view.get("bound"));
|
||||
assertEquals(true, view.get("canEdit"), "校管可编辑");
|
||||
assertNull(noticeFlowService.boundFlowGuid(1));
|
||||
// 绑定后:视图与分流入口同时生效
|
||||
bindFlow(BINDING_GUID);
|
||||
java.util.Map<String, Object> bound = noticeFlowService.bindingView(1, adminUser());
|
||||
assertEquals(true, bound.get("bound"));
|
||||
assertEquals(BINDING_GUID, bound.get("flowGuid"));
|
||||
assertEquals("信息发布审核", bound.get("flowName"));
|
||||
assertEquals(BINDING_GUID, noticeFlowService.boundFlowGuid(1));
|
||||
// 普通教师:能看绑定态但不能编辑
|
||||
BoaUser plain = new BoaUser();
|
||||
plain.setId(301);
|
||||
plain.setAccount("teacher");
|
||||
plain.setRoleIds(java.util.Collections.singletonList(9));
|
||||
assertEquals(false, noticeFlowService.bindingView(1, plain).get("canEdit"));
|
||||
}
|
||||
|
||||
/** 首次保存时的 status 快照 */
|
||||
private Integer firstSavedStatus() {
|
||||
return statusAtSave.get(0);
|
||||
}
|
||||
|
||||
// ==================== 应用设置 > 流程绑定 保存(管理员守卫 + 值校验) ====================
|
||||
|
||||
@Test
|
||||
void saveBinding_nonAdmin_denied() {
|
||||
BoaUser plain = new BoaUser();
|
||||
plain.setId(301);
|
||||
plain.setAccount("teacher");
|
||||
plain.setRoleIds(java.util.Collections.singletonList(9)); // 非管理员角色
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
plain.setCurrentRole(role);
|
||||
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.saveBinding(plain, BINDING_GUID));
|
||||
assertTrue(ex.getMessage().contains("管理员"));
|
||||
verify(bindingRepository, never()).save(any(NoticeFlowBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveBinding_unknownFlow_rejected() {
|
||||
when(flowClient.listFlows(1)).thenReturn(new java.util.ArrayList<>()); // 该流程不存在/未发布
|
||||
BizException ex = assertThrows(BizException.class,
|
||||
() -> noticeFlowService.saveBinding(adminUser(), BINDING_GUID));
|
||||
assertTrue(ex.getMessage().contains("流程不存在或未发布"));
|
||||
verify(bindingRepository, never()).save(any(NoticeFlowBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveBinding_adminBound_thenUnbindDeletesRow() {
|
||||
FlowModels.FlowSummaryVo flow = new FlowModels.FlowSummaryVo();
|
||||
flow.setFlowGuid(BINDING_GUID);
|
||||
flow.setName("信息发布审核");
|
||||
when(flowClient.listFlows(1)).thenReturn(java.util.Collections.singletonList(flow));
|
||||
// 内存库:save 落进 store、findBySchId 从 store 读(saveBinding 末尾回读视图)
|
||||
java.util.concurrent.atomic.AtomicReference<NoticeFlowBinding> store = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
when(bindingRepository.findBySchId(1)).thenAnswer(inv -> Optional.ofNullable(store.get()));
|
||||
when(bindingRepository.save(any(NoticeFlowBinding.class))).thenAnswer(inv -> {
|
||||
store.set(inv.getArgument(0));
|
||||
return inv.getArgument(0);
|
||||
});
|
||||
org.mockito.Mockito.doAnswer(inv -> {
|
||||
store.set(null);
|
||||
return null;
|
||||
}).when(bindingRepository).delete(any(NoticeFlowBinding.class));
|
||||
|
||||
java.util.Map<String, Object> view = noticeFlowService.saveBinding(adminUser(), BINDING_GUID);
|
||||
assertEquals(true, view.get("bound"));
|
||||
assertEquals("信息发布审核", view.get("flowName"));
|
||||
assertEquals(1, store.get().getSchId());
|
||||
assertEquals(BINDING_GUID, store.get().getFlowGuid());
|
||||
|
||||
// 解绑(flowGuid 空)→ 删行 + 视图回到未绑定
|
||||
NoticeFlowBinding existing = store.get();
|
||||
java.util.Map<String, Object> after = noticeFlowService.saveBinding(adminUser(), " ");
|
||||
assertEquals(false, after.get("bound"));
|
||||
verify(bindingRepository).delete(existing);
|
||||
}
|
||||
|
||||
/** 本校管理员(type=1 校管) */
|
||||
private BoaUser adminUser() {
|
||||
BoaUser admin = new BoaUser();
|
||||
admin.setId(1);
|
||||
admin.setAccount("principal");
|
||||
ReflectionTestUtils.setField(admin, "type", 1);
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(1);
|
||||
admin.setCurrentRole(role);
|
||||
return admin;
|
||||
}
|
||||
|
||||
private Notice pendingNotice(int auditStatus) {
|
||||
// 模拟已落库的绑流程通知(status=0,audit=指定态)
|
||||
Notice n = new Notice();
|
||||
n.setId(77);
|
||||
n.setTitle("九月开学安排");
|
||||
n.setContent("<p>内容</p>");
|
||||
n.setStatus(0);
|
||||
n.setAuditStatus(auditStatus);
|
||||
n.setFlowGuid(BINDING_GUID);
|
||||
n.setSchId(1);
|
||||
n.setPublisherAccount("zhangsan");
|
||||
n.setSmsFlag(1);
|
||||
n.setReceiveList("[\"tea1\",\"tea2\"]");
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.model.flow.FlowModels;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 审批进度组装单测(T2.4):未绑流程返回"未接入"语义(bound=false,前端隐藏卡片);
|
||||
* 绑定通知合并本地镜像(audit/flowResult)+ flow-center 实时(version/trace/assignee);
|
||||
* 流程中心不可达时降级为本地镜像不抛异常。
|
||||
*/
|
||||
class NoticeFlowTraceTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowService service;
|
||||
private Notice bound;
|
||||
private Notice unbound;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(true);
|
||||
service = new NoticeFlowService(noticeRepository,
|
||||
mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class),
|
||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||
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.setId(42);
|
||||
bound.setSchId(1);
|
||||
bound.setStatus(0);
|
||||
bound.setFlowGuid("fg-1");
|
||||
bound.setAuditStatus(10);
|
||||
bound.setFlowInstanceId(9527L);
|
||||
bound.setFlowResultJson("{\"opinion\":\"同意\"}");
|
||||
bound.setPublisherAccount("zhangsan");
|
||||
|
||||
unbound = new Notice();
|
||||
unbound.setId(43);
|
||||
unbound.setSchId(1);
|
||||
unbound.setStatus(1);
|
||||
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(bound));
|
||||
when(noticeRepository.findById(43)).thenReturn(Optional.of(unbound));
|
||||
}
|
||||
|
||||
/** 未发布通知的合法访问者(提交人本人) */
|
||||
private static com.yunhai.platform.utils.model.BoaUser owner() {
|
||||
com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201);
|
||||
u.setAccount("zhangsan");
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 无关登录用户(非提交人/管理员/办理人) */
|
||||
private static com.yunhai.platform.utils.model.BoaUser stranger() {
|
||||
com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999);
|
||||
u.setAccount("stranger");
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundNotice_returnsNotIntegrated() {
|
||||
// 已发布(status=1)内容公开:任意登录用户可看(不受守卫限制)
|
||||
Map<String, Object> trace = service.buildTrace(43, stranger());
|
||||
assertEquals(false, trace.get("bound"));
|
||||
assertEquals(null, trace.get("auditStatus"));
|
||||
assertEquals(1, trace.get("status"));
|
||||
assertFalse(trace.containsKey("trace"), "未绑流程不应有进度明细");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedTrace_byStranger_denied() {
|
||||
// BP-R5-3:未发布通知的轨迹/驳回意见仅三类人可见,其余 403(查不到语义)
|
||||
com.yunhai.platform.apistd.BizException e = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.buildTrace(42, stranger()));
|
||||
assertTrue(e.getMessage().contains("通知不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedTrace_byOwnerVisible() {
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(null);
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundNotice_mergesLocalMirrorAndLiveInstance() {
|
||||
FlowModels.InstanceVo instance = new FlowModels.InstanceVo();
|
||||
instance.setInstanceId(9527L);
|
||||
instance.setStatus(10);
|
||||
instance.setVersion(3);
|
||||
instance.setNodeSummary("编辑审核→终审");
|
||||
instance.setCurrentNodes("n2");
|
||||
instance.setAssigneeIds(Arrays.asList(301, 302));
|
||||
instance.setTraceJson("[{\"node\":\"n1\",\"status\":1}]");
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(instance);
|
||||
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
assertEquals(10, trace.get("auditStatus"));
|
||||
assertEquals("审批中", trace.get("auditStatusDesc"));
|
||||
assertEquals(3, trace.get("version"));
|
||||
assertEquals("n2", trace.get("currentNodes"));
|
||||
assertNotNull(trace.get("trace"));
|
||||
assertNotNull(trace.get("flowResult"));
|
||||
assertEquals(Arrays.asList(301, 302), trace.get("assigneeIds"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowCenterDown_degradesToLocalMirror() {
|
||||
when(flowClient.getInstanceByBiz(any(), any(), any()))
|
||||
.thenThrow(new RuntimeException("flow-center down"));
|
||||
Map<String, Object> trace = service.buildTrace(42, owner());
|
||||
assertEquals(true, trace.get("bound"));
|
||||
assertEquals(10, trace.get("auditStatus"), "流程中心停机时详情仍可用(本地镜像)");
|
||||
assertTrue(((Map<String, Object>) trace).containsKey("flowResult"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.yunhai.notice.service.flow;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.repository.UpFileRepository;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 撤回中转单测(2026-09-17 接入 SDK 托管件):
|
||||
* 非创建人 403 / 非审批中拒绝 / 未进入流程拒绝,且三者都不出站;
|
||||
* 创建人对审批中通知出站撤回(本地不改状态,由 flow.withdrawn 回调推进)。
|
||||
*/
|
||||
class NoticeFlowWithdrawTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private FlowClient flowClient;
|
||||
private NoticeFlowService service;
|
||||
private Notice approving;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||
props.setEnabled(true);
|
||||
service = new NoticeFlowService(noticeRepository,
|
||||
mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props,
|
||||
new ObjectMapper(), mock(CurrentBoaUser.class),
|
||||
new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)),
|
||||
com.yunhai.notice.support.FlowSubmitTemplates.forTests(flowClient));
|
||||
|
||||
approving = new Notice();
|
||||
approving.setId(42);
|
||||
approving.setSchId(27);
|
||||
approving.setStatus(0);
|
||||
approving.setFlowGuid("fg-1");
|
||||
approving.setAuditStatus(10);
|
||||
approving.setFlowInstanceId(9527L);
|
||||
approving.setPublisherAccount("zhangsan");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(approving));
|
||||
}
|
||||
|
||||
private static BoaUser owner() {
|
||||
BoaUser u = new BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201);
|
||||
u.setAccount("zhangsan");
|
||||
return u;
|
||||
}
|
||||
|
||||
private static BoaUser stranger() {
|
||||
BoaUser u = new BoaUser();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999);
|
||||
u.setAccount("stranger");
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_byStranger_forbiddenAndNoOutbound() {
|
||||
com.yunhai.platform.apistd.BizException e = assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.withdrawNotice(42, stranger()));
|
||||
assertTrue(e.getMessage().contains("仅通知创建人"));
|
||||
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_whenNotApproving_rejected() {
|
||||
approving.setAuditStatus(20);
|
||||
com.yunhai.platform.apistd.BizException e = assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.withdrawNotice(42, owner()));
|
||||
assertTrue(e.getMessage().contains("仅审批中"));
|
||||
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_withoutInstance_rejected() {
|
||||
approving.setFlowInstanceId(null);
|
||||
com.yunhai.platform.apistd.BizException e = assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.withdrawNotice(42, owner()));
|
||||
assertTrue(e.getMessage().contains("尚未进入流程"));
|
||||
verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_byOwner_outboundWithSubmitterId() {
|
||||
service.withdrawNotice(42, owner());
|
||||
verify(flowClient).withdraw(27, 9527L, 201);
|
||||
}
|
||||
|
||||
@Test
|
||||
void diagram_byStranger_unpublished_forbidden() {
|
||||
com.yunhai.platform.apistd.BizException e = assertThrows(
|
||||
com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.diagramImage(42, stranger()));
|
||||
// 未发布通知的流程图与 Trace 同口径(三类人),stranger 404 语义
|
||||
assertTrue(e.getMessage().contains("通知不存在或未发布"));
|
||||
verify(flowClient, Mockito.never()).diagramImage(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void diagram_byOwner_returnsBytes() {
|
||||
when(flowClient.diagramImage(27, 9527L)).thenReturn(new byte[]{1, 2, 3});
|
||||
byte[] png = service.diagramImage(42, owner());
|
||||
assertTrue(png.length == 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yunhai.notice.service.impl;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.repository.NoticeReadRepository;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 详情标读门禁单测(走查报告 §七.4):仅已发布(status=1)通知在 detail 时标记已读——
|
||||
* 审批人查看未发布通知(NoticeFlowGuard 放行的审核动作)不再被记入已读名单污染阅读统计。
|
||||
*/
|
||||
@DisplayName("NoticeService.detail - 标读门禁(仅已发布)")
|
||||
class NoticeDetailReadTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeReadRepository noticeReadRepository;
|
||||
private NoticeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
noticeReadRepository = mock(NoticeReadRepository.class);
|
||||
service = new NoticeService();
|
||||
ReflectionTestUtils.setField(service, "noticeUserMsgNotifier",
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
|
||||
|
||||
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository);
|
||||
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setLoginName("approver1");
|
||||
loginUser.setDisplayName("审批人甲");
|
||||
UserContextHolder.set(loginUser);
|
||||
when(noticeReadRepository.existsByNoticeIdAndReaderAccount(anyInt(), anyString())).thenReturn(false);
|
||||
when(noticeReadRepository.save(any(com.yunhai.notice.entity.NoticeRead.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserContextHolder.clear();
|
||||
}
|
||||
|
||||
private Notice notice(int status) {
|
||||
Notice n = new Notice();
|
||||
n.setId(42);
|
||||
n.setTitle("审核中通知");
|
||||
n.setSchId(1);
|
||||
n.setStatus(status);
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||
return n;
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedNotice_detailMarksRead() {
|
||||
notice(1);
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedNotice_detailDoesNotMarkRead() {
|
||||
notice(0); // 审批人经 NoticeFlowGuard 查看未发布通知:不标读
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyRead_notMarkedAgain() {
|
||||
notice(1);
|
||||
when(noticeReadRepository.existsByNoticeIdAndReaderAccount(42, "approver1")).thenReturn(true);
|
||||
service.detail(42);
|
||||
verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,117 @@
|
||||
package com.yunhai.notice.service.impl;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||
import com.yunhai.notice.model.notice.NoticeRequest;
|
||||
import com.yunhai.notice.repository.NoticeRepository;
|
||||
import com.yunhai.notice.service.flow.NoticeFlowService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 驳回后编辑重提的清态单测(业务人视角走查 NEW-3):
|
||||
* Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期
|
||||
* 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。
|
||||
* <p>
|
||||
* 分流判定按新规则(应用级绑定 + 通知对象=全部用户才审核),故用例需显式给出
|
||||
* boundFlowGuid(应用设置里的绑定)与 receiveList。
|
||||
*/
|
||||
@DisplayName("NoticeService.edit - 重提清旧驳回意见")
|
||||
class NoticeResubmitClearResultTest {
|
||||
|
||||
private NoticeRepository noticeRepository;
|
||||
private NoticeFlowService noticeFlowService;
|
||||
private NoticeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
noticeRepository = mock(NoticeRepository.class);
|
||||
noticeFlowService = mock(NoticeFlowService.class);
|
||||
service = new NoticeService();
|
||||
ReflectionTestUtils.setField(service, "noticeUserMsgNotifier",
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgNotifier(
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class),
|
||||
org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class),
|
||||
new com.yunhai.notice.service.msg.NoticeUserMsgProperties()));
|
||||
|
||||
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||
ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService);
|
||||
ReflectionTestUtils.setField(service, "noticeReceiverRepository",
|
||||
mock(com.yunhai.notice.repository.NoticeReceiverRepository.class));
|
||||
ReflectionTestUtils.setField(service, "upFileRepository",
|
||||
mock(com.yunhai.notice.repository.UpFileRepository.class));
|
||||
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
|
||||
}
|
||||
|
||||
/** 被驳回的通知:audit=30 + 已落定上一轮驳回意见 */
|
||||
private Notice rejectedNotice() {
|
||||
Notice n = new Notice();
|
||||
n.setId(42);
|
||||
n.setTitle("旧标题");
|
||||
n.setSchId(1);
|
||||
n.setStatus(0);
|
||||
n.setFlowGuid("fg-1");
|
||||
n.setFlowInstanceId(9011L);
|
||||
n.setAuditStatus(FlowAuditStatusEnum.REJECTED.getCode());
|
||||
n.setFlowResultJson("{\"result\":\"REJECTED\",\"opinion\":\"内容不妥\"}");
|
||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 编辑请求(receiveList:全员 "all" / 指定人员串) */
|
||||
private NoticeRequest editRequest(String receiveList) {
|
||||
NoticeRequest req = new NoticeRequest();
|
||||
ReflectionTestUtils.setField(req, "title", "改后标题");
|
||||
ReflectionTestUtils.setField(req, "content", "改后正文");
|
||||
ReflectionTestUtils.setField(req, "receiveList", receiveList);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void editResubmit_clearsStaleRejectOpinion() {
|
||||
Notice n = rejectedNotice();
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
// 应用设置绑定了流程 fg-1 且通知对象=全部用户 → 仍在审核轨道
|
||||
when(noticeFlowService.boundFlowGuid(1)).thenReturn("fg-1");
|
||||
|
||||
service.edit(42, editRequest("all"));
|
||||
|
||||
assertEquals(FlowAuditStatusEnum.PENDING_SUBMIT.getCode(), n.getAuditStatus(),
|
||||
"重提回到 0 待提交");
|
||||
assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示");
|
||||
assertEquals("fg-1", n.getFlowGuid(), "沿用该通知已有流程快照");
|
||||
assertEquals(0, n.getStatus(), "重审期间对用户不可见");
|
||||
assertEquals("改后标题", n.getTitle());
|
||||
verify(noticeFlowService).submitAfterCommit(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editToSpecified_publishesDirectlyAndClearsResult() {
|
||||
Notice n = rejectedNotice();
|
||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
// 未绑定(或已解绑)→ 指定人员直发
|
||||
when(noticeFlowService.boundFlowGuid(1)).thenReturn(null);
|
||||
|
||||
service.edit(42, editRequest("[\"tea1\"]"));
|
||||
|
||||
assertNull(n.getFlowGuid());
|
||||
assertNull(n.getFlowInstanceId());
|
||||
assertNull(n.getFlowResultJson());
|
||||
assertNull(n.getAuditStatus());
|
||||
assertEquals(1, n.getStatus(), "退出审核轨道必须置为已发布");
|
||||
verify(noticeFlowService, never()).submitAfterCommit(anyInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.yunhai.notice.service.msg;
|
||||
|
||||
import com.yunhai.notice.entity.Notice;
|
||||
import com.yunhai.platform.openapi.msg.model.SendMsgRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 通知 → 消息中心推送器单测(mock UserMsgClient/UserDirectory 缝,纯 Mockito 不起 Spring):
|
||||
* 归一化/跳过语义/事务 afterCommit 时机/分片/容错/文案字段。
|
||||
*/
|
||||
class NoticeUserMsgNotifierTest {
|
||||
|
||||
private UserMsgClient client;
|
||||
private UserDirectory directory;
|
||||
private NoticeUserMsgProperties properties;
|
||||
private NoticeUserMsgNotifier notifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
client = mock(UserMsgClient.class);
|
||||
directory = mock(UserDirectory.class);
|
||||
properties = new NoticeUserMsgProperties();
|
||||
properties.setPushEnabled(true);
|
||||
notifier = new NoticeUserMsgNotifier(client, directory, properties);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
private Notice notice(int id) {
|
||||
Notice notice = new Notice();
|
||||
ReflectionTestUtils.setField(notice, "id", id);
|
||||
notice.setTitle("关于开展2026年秋季学期教学检查的通知");
|
||||
notice.setPublisherName("刘文娟");
|
||||
notice.setSchId(3);
|
||||
return notice;
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_numericAccounts_sentAsUserIdsAfterCommit() {
|
||||
when(directory.toUserId("10006")).thenReturn(10006);
|
||||
when(directory.toUserId("10029")).thenReturn(10029);
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006", "10029"));
|
||||
// 事务未提交:不应即时发送
|
||||
verify(client, never()).sendUserMsg(any());
|
||||
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
sync.afterCommit();
|
||||
}
|
||||
|
||||
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
|
||||
verify(client).sendUserMsg(captor.capture());
|
||||
SendMsgRequest req = captor.getValue();
|
||||
assertEquals(Arrays.asList(10006, 10029), req.getUserIds());
|
||||
assertEquals("通知公告", req.getTitle());
|
||||
assertTrue(req.getContent().contains("刘文娟") && req.getContent().contains("教学检查"));
|
||||
assertEquals("notice", req.getBizType());
|
||||
assertEquals("1", req.getBizId());
|
||||
assertEquals(0, req.getMsgType());
|
||||
assertEquals(1, req.getJumpType());
|
||||
assertEquals("/list/detail?serid=1&type=1", req.getJumpPath());
|
||||
assertEquals(3, req.getSchId());
|
||||
assertTrue(req.getMsgNo().startsWith("ntc-pub-1-"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_disabled_noSend() {
|
||||
properties.setPushEnabled(false);
|
||||
notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006"));
|
||||
verify(client, never()).sendUserMsg(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_allReceivers_emptyAccounts_skipped() {
|
||||
notifier.notifyPublishedAfterCommit(notice(1), Collections.emptyList());
|
||||
verify(client, never()).sendUserMsg(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_unresolvableAccount_skippedOthersStillSent() {
|
||||
when(directory.toUserId("ghost")).thenReturn(null);
|
||||
when(directory.toUserId("10006")).thenReturn(10006);
|
||||
|
||||
notifier.notifyPublishedAfterCommit(notice(2), Arrays.asList("ghost", "10006"));
|
||||
|
||||
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
|
||||
verify(client).sendUserMsg(captor.capture());
|
||||
assertEquals(Collections.singletonList(10006), captor.getValue().getUserIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_allAccountsUnresolvable_noSend() {
|
||||
when(directory.toUserId(any())).thenReturn(null);
|
||||
notifier.notifyPublishedAfterCommit(notice(3), Arrays.asList("a", "b"));
|
||||
verify(client, never()).sendUserMsg(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_overThousandReceivers_chunkedWithDistinctMsgNo() {
|
||||
List<String> accounts = IntStream.rangeClosed(1, 1005).mapToObj(String::valueOf).collect(Collectors.toList());
|
||||
IntStream.rangeClosed(1, 1005).forEach(i -> when(directory.toUserId(String.valueOf(i))).thenReturn(i));
|
||||
|
||||
notifier.notifyPublishedAfterCommit(notice(4), accounts);
|
||||
|
||||
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
|
||||
verify(client, org.mockito.Mockito.times(2)).sendUserMsg(captor.capture());
|
||||
List<SendMsgRequest> sent = captor.getAllValues();
|
||||
assertEquals(1000, sent.get(0).getUserIds().size());
|
||||
assertEquals(5, sent.get(1).getUserIds().size());
|
||||
assertEquals(500500, sent.get(0).getUserIds().stream().reduce(0, Integer::sum));
|
||||
assertEquals(1001 + 1002 + 1003 + 1004 + 1005, sent.get(1).getUserIds().stream().reduce(0, Integer::sum));
|
||||
// 同一 action 同秒:主体 msgNo 相同,分片加 -1/-2 后缀,保证 (appKey,msgNo) 不冲突
|
||||
assertTrue(sent.get(0).getMsgNo().endsWith("-1"));
|
||||
assertTrue(sent.get(1).getMsgNo().endsWith("-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_sendThrows_swallowed() {
|
||||
when(directory.toUserId("10006")).thenReturn(10006);
|
||||
doThrow(new RuntimeException("usermsg down")).when(client).sendUserMsg(any());
|
||||
|
||||
notifier.notifyPublishedAfterCommit(notice(5), Collections.singletonList("10006"));
|
||||
|
||||
verify(client).sendUserMsg(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expedite_usesExpediteBizTypeAndMsgNo() {
|
||||
when(directory.toUserId("10006")).thenReturn(10006);
|
||||
|
||||
notifier.notifyExpedited(notice(6), Collections.singletonList("10006"));
|
||||
|
||||
ArgumentCaptor<SendMsgRequest> captor = ArgumentCaptor.forClass(SendMsgRequest.class);
|
||||
verify(client).sendUserMsg(captor.capture());
|
||||
assertEquals("notice.expedite", captor.getValue().getBizType());
|
||||
// Phase 2:细分分类(notice.expedite)不配基址键,靠归属声明 jumpApp=notice 命中应用键
|
||||
assertEquals("notice", captor.getValue().getJumpApp());
|
||||
assertEquals("/list/detail?serid=6&type=1", captor.getValue().getJumpPath());
|
||||
assertTrue(captor.getValue().getMsgNo().startsWith("ntc-exp-6-"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_noTransactionContext_sendsImmediately() {
|
||||
when(directory.toUserId("10006")).thenReturn(10006);
|
||||
|
||||
notifier.notifyPublishedAfterCommit(notice(7), Collections.singletonList("10006"));
|
||||
|
||||
verify(client).sendUserMsg(any());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.yunhai.notice.support;
|
||||
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* oapi 契约测试客户端(照 flow-center OapiSignedClient 同构):手工三件套签名,
|
||||
* 故意不走共享 IDHttp——测试要能看到 401 原始响应。
|
||||
*/
|
||||
public class OapiSignedClient {
|
||||
|
||||
static {
|
||||
// JDK8 安全加固后:POST 带流式 body 收到 401 会抛 HttpRetryException
|
||||
// ("cannot retry due to server authentication, in streaming mode") 而非返回 401 响应,
|
||||
// 期望 401 的用例因此失败(依 JDK 小版本而异)。换 Apache HttpClient 的 request factory
|
||||
//(httpclient 已在类路径),401 按普通响应返回。
|
||||
}
|
||||
|
||||
private final String baseUrl;
|
||||
private final String appKey;
|
||||
private final String appSecret;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public OapiSignedClient(String baseUrl, String appKey, String appSecret) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.appKey = appKey;
|
||||
this.appSecret = appSecret;
|
||||
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(
|
||||
HttpClientBuilder.create().build());
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
this.restTemplate.setErrorHandler(new org.springframework.web.client.ResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> get(String pathAndQuery, Class<T> type) {
|
||||
return exchange(HttpMethod.GET, pathAndQuery, null, type);
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> post(String pathAndQuery, Object body, Class<T> type) {
|
||||
return exchange(HttpMethod.POST, pathAndQuery, body, type);
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<T> exchange(HttpMethod method, String pathAndQuery, Object body, Class<T> type) {
|
||||
String target = signTarget(pathAndQuery);
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest(method.name(), target, ts, appSecret);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
if (body != null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, method,
|
||||
body == null ? new HttpEntity<>(headers) : new HttpEntity<>(body, headers), type);
|
||||
}
|
||||
|
||||
private static String signTarget(String pathAndQuery) {
|
||||
int q = pathAndQuery.indexOf('?');
|
||||
if (q < 0) {
|
||||
return pathAndQuery;
|
||||
}
|
||||
String path = pathAndQuery.substring(0, q);
|
||||
String[] pairs = pathAndQuery.substring(q + 1).split("&");
|
||||
java.util.Arrays.sort(pairs);
|
||||
StringBuilder sb = new StringBuilder(path);
|
||||
for (String pair : pairs) {
|
||||
if (sb.indexOf("?") < 0) {
|
||||
sb.append("?");
|
||||
} else if (sb.charAt(sb.length() - 1) != '?') {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(pair);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 无签名调用(401 用例) */
|
||||
public ResponseEntity<String> getRaw(String pathAndQuery) {
|
||||
return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class);
|
||||
}
|
||||
|
||||
/** 无签名的 POST(回调端点匿名 401 用例) */
|
||||
public ResponseEntity<String> postRaw(String pathAndQuery, Object body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
}
|
||||
|
||||
/** 带错误密钥的 POST(回调端点坏签名 401 用例) */
|
||||
public ResponseEntity<String> postWithBadSecret(String pathAndQuery, Object body) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("POST", signTarget(pathAndQuery), ts, "bad-secret-000000000000");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
}
|
||||
|
||||
/** 带错误密钥的调用(401 用例) */
|
||||
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", signTarget(pathAndQuery), ts, "bad-secret-000000000000");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user