86 changed files with 483 additions and 2075 deletions
-33
View File
@@ -133,38 +133,6 @@
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-parent</artifactId> <artifactId>nutzboot-parent</artifactId>
@@ -183,7 +151,6 @@
<easypoi.version>4.5.0</easypoi.version> <easypoi.version>4.5.0</easypoi.version>
<jsoup.veision>1.15.3</jsoup.veision> <jsoup.veision>1.15.3</jsoup.veision>
<mysql-connector-java.version>8.0.28</mysql-connector-java.version> <mysql-connector-java.version>8.0.28</mysql-connector-java.version>
<tomcat.version>9.0.121</tomcat.version>
<sa-token.version>1.25.0</sa-token.version> <sa-token.version>1.25.0</sa-token.version>
<easy-captcha.version>1.6.2</easy-captcha.version> <easy-captcha.version>1.6.2</easy-captcha.version>
<emoji-java.veision>4.0.0</emoji-java.veision> <emoji-java.veision>4.0.0</emoji-java.veision>
+1 -50
View File
@@ -7,7 +7,6 @@
<properties> <properties>
<nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version> <nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version>
<nutz.version>1.r.70-SNAPSHOT</nutz.version> <nutz.version>1.r.70-SNAPSHOT</nutz.version>
<tomcat.version>9.0.121</tomcat.version>
<mysql-connector-java.version>8.0.28</mysql-connector-java.version> <mysql-connector-java.version>8.0.28</mysql-connector-java.version>
<jaxb-api.version>2.3.1</jaxb-api.version> <jaxb-api.version>2.3.1</jaxb-api.version>
<slf4j.version>2.0.8</slf4j.version> <slf4j.version>2.0.8</slf4j.version>
@@ -145,22 +144,8 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-starter-tomcat</artifactId> <artifactId>nutzboot-starter-jetty</artifactId>
<version>${nutzboot.version}</version> <version>${nutzboot.version}</version>
<exclusions>
<exclusion>
<groupId>org.gandon.tomcat</groupId>
<artifactId>juli-to-slf4j</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.nutz</groupId>
<artifactId>nutz-plugins-websocket</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
@@ -439,40 +424,6 @@
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Servlet API 由 Tomcat 提供,防止旧 API 混入可执行 JAR。 -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
<!-- 显式覆盖导入的 NutzBoot BOM,避免回退到其默认 Tomcat 8.5。 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-parent</artifactId> <artifactId>nutzboot-parent</artifactId>
@@ -11,7 +11,6 @@ import com.budwk.app.web.commons.auth.satoken.SaTokenDaoRedisImpl;
import com.budwk.app.web.commons.auth.satoken.StpInterfaceImpl; import com.budwk.app.web.commons.auth.satoken.StpInterfaceImpl;
import com.budwk.app.web.commons.base.Globals; import com.budwk.app.web.commons.base.Globals;
import com.budwk.app.web.commons.ext.pubsub.WebPubSub; import com.budwk.app.web.commons.ext.pubsub.WebPubSub;
import com.budwk.app.web.commons.ext.handler.WkTomcatConfiguration;
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService; import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.beetl.core.GroupTemplate; import org.beetl.core.GroupTemplate;
@@ -66,7 +65,6 @@ public class MainLauncher {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
NbApp nb = new NbApp().setArgs(args).setPrintProcDoc(true); NbApp nb = new NbApp().setArgs(args).setPrintProcDoc(true);
nb.getAppContext().setMainPackage("com.budwk"); nb.getAppContext().setMainPackage("com.budwk");
nb.addListener(new WkTomcatConfiguration());
nb.run(); nb.run();
} }
@@ -1,20 +0,0 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 成功发送记录的持久化服务,不负责再次请求门户。 */
public interface PortalTodoLogService {
/**
* 保存一次已确认成功的发送快照;使用当前业务事务,外层回滚时记录也可能回滚。
* @param requestId 本次调用的32位关联编号,对应日志主键
* @param requestUrl 实际发送接口地址,不传认证头
* @param requestBody 实际发送的完整JSON字符串
* @param responseBody 门户原始响应正文
* @param response 解析后的成功结果,含HTTP状态、业务码、提示、data及traceId
* @param requestTime 请求开始的毫秒时间戳
* @param elapsedMillis 接口耗时,不含数据库保存时间
* @return 无返回值;保存异常由调用端单独处理,不改变门户调用结果
*/
void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis);
}
@@ -1,26 +0,0 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoRequest;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 信息门户待办接口,可在业务 service 中注入后独立调用。 */
public interface PortalTodoService {
/**
* @param request 业务名称、唯一 ID、单据编码、接收人登录账号及标题、正文、完整 PC 链接
* @return 调用结果;success 表示门户成功,skipped 表示开关拦截,msg/traceId 用于排查
*/
PortalTodoResponse sendTodo(PortalTodoSendRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;审核完成使用此方法,门户保留已办记录
*/
PortalTodoResponse doneTodo(PortalTodoRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;撤销或删除业务时使用,失败不会抛出网络异常阻断业务
*/
PortalTodoResponse removeTodo(PortalTodoRequest request);
}
@@ -1,58 +0,0 @@
# 门户待办调用
在业务 service 中注入 `PortalTodoService`。三个方法均返回 `PortalTodoResponse`,不会因门户 HTTP 失败主动抛出异常。
```java
@Inject
private PortalTodoService portalTodoService;
// 同一待办发送、完成、删除时保留下面四项定位数据。
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("职工福利审核");
request.setEntityId(taskId);
request.setEntityNumber(businessNumber);
request.setTargets(List.of(receiverLoginName));
request.setSubject("职工福利审核待办");
request.setContent("您有一条职工福利申请需要审核。");
request.setLink(auditPageUrl);
request.setMobileLink(h5AuditPageUrl);
request.setCreator(senderLoginName);
request.setCreatorName(senderName);
PortalTodoResponse result = portalTodoService.sendTodo(request);
```
审核完成时调用 `portalTodoService.doneTodo(request)`;撤销或删除时调用 `portalTodoService.removeTodo(request)`。这两个操作也可单独构造 `PortalTodoRequest`,只设置四项定位数据,不需要标题和内容。
- `success=true`:门户接口返回成功,实际展示仍以门户为准。
- `skipped=true`:开发模式或开关关闭,没有发送请求。
- 两者均为 `false`:校验、网络或门户业务失败,使用 `msg``code``traceId` 排查。
- `data` 按门户原值返回,不当作 `notifyId` 使用。
## 启用配置
1. 部署配置设置 `portal.todo.base-url``portal.todo.username``portal.todo.password`。密码按原始字符填写,不编码、不转义增删。
2. 按当前项目约定,`Globals.sso` 必须为 `true`(来自 `cas.enable`)。
3. 在现有系统参数管理中配置 `AppPortalTodo=true` 并刷新系统配置;未配置默认关闭,与 `AppSms` 无关。
4. 设置正确的项目 `AppDomain`,供 wf 构建审核链接。
wf 会自动同步通用任务变化,业务代码无需再次发送同一 wf 待办。HTTP 超时不自动重试;本地事务无法撤销已成功的外部调用,需要根据日志人工补偿。
PC链接使用任务的 `formKey`,手机链接使用 `h5FormKey`,补齐 `AppDomain` 并携带编码后的 `taskId``bizId``taskKey`。未配置手机表单时记录提示并回退到 `/platform/h5` 手机首页。独立调用的 `mobileLink` 应传完整HTTP(S)地址,未传也回退到项目手机首页。修改只影响新发送的待办,不更新门户已有链接。
## 发送记录与控制台排查
成功的 `send` 请求写入 `portal_todo_log`,每次请求一条,保存实际发送的完整JSON、原始响应正文、业务标识、标题正文、发送人、接收人账号数组、链接、请求时间、耗时及返回状态。失败、跳过、已办和删除不写成功发送记录。
控制台搜索 `——————————————学校OA待办发送`,按“调用编号”关联“请求开始”和“请求结束”。结束日志包含成功/失败/跳过、HTTP状态、业务码、提示、traceId、耗时和响应正文;无响应的网络异常仍输出结束记录。日志不打印Basic凭证或认证头。
成功发送但保存记录失败时另输出“发送成功但记录入库失败”,门户返回结果保持成功,不自动重发。保存沿用业务事务,外层回滚时记录也可能回滚;控制台“已写入当前事务”不表示外层事务已提交。
表结构通过现有实体扫描机制管理:`MainLauncher.init_sys` 仅在DEBUG日志级别时自动建表。生产部署需确认该表已创建,本功能不生成或执行额外SQL,不提供后台查询页面。
## 验证说明
`PortalTodoServiceTest` 使用本地模拟 HTTP 服务;`FlowPortalTodoServiceTest` 使用内存 Dao 替身。两者不连接真实门户或业务数据库。
串并行会签、转办、撤回和流程删除仍需在测试部署中走完整业务流程验收。提案附议等脱离标准任务状态的业务特例未单独适配。
现有 `activate` 方法只恢复 `INTERRUPT` 状态任务,并不恢复 `PENDING`;本次保持该业务规则,仅为实际恢复的任务重发待办。
@@ -1,56 +0,0 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.PortalTodoLog;
import com.budwk.app.base.portal.model.PortalTodoResponse;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 记录实际请求报文的业务快照,不读取可变化的人员或流程数据。 */
@IocBean
public class PortalTodoLogServiceImpl implements PortalTodoLogService {
@Inject
private Dao dao;
@Override
@Aop(TransAop.READ_COMMITTED)
public void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis) {
// 防止跳过或HTTP失败被误记为成功发送;多接收人作为一次请求保留原数组。
if (response == null || !response.isSuccess() || response.isSkipped()
|| response.getHttpStatus() == null || response.getHttpStatus() < 200
|| response.getHttpStatus() >= 300) return;
JSONObject payload = JSONUtil.parseObj(requestBody);
PortalTodoLog record = new PortalTodoLog();
record.setId(requestId);
record.setAppName(payload.getStr("appName"));
record.setModuleName(payload.getStr("moduleName"));
record.setEntityName(payload.getStr("entityName"));
record.setEntityId(payload.getStr("entityId"));
record.setEntityNumber(payload.getStr("entityNumber"));
record.setSubject(payload.getStr("subject"));
record.setContent(payload.getStr("content"));
record.setCreator(payload.getStr("creator"));
record.setCreatorName(payload.getStr("creatorName"));
record.setTargets(JSONUtil.toJsonStr(payload.getJSONArray("targets")));
record.setLink(payload.getStr("link"));
record.setMobileLink(payload.getStr("mobileLink"));
record.setRequestUrl(requestUrl);
record.setRequestBody(requestBody);
record.setResponseBody(responseBody);
record.setRequestTime(requestTime);
record.setElapsedMillis(elapsedMillis);
record.setHttpStatus(response.getHttpStatus());
record.setSuccess(response.isSuccess());
record.setCode(response.getCode());
record.setMsg(response.getMsg());
record.setData(response.getData());
record.setTraceId(response.getTraceId());
dao.insert(record);
}
}
@@ -1,193 +0,0 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.*;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/** Basic 认证的门户 REST 客户端,所有操作共用环境拦截及结果处理。 */
@IocBean
@Slf4j
public class PortalTodoServiceImpl implements PortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private PropertiesProxy conf;
@Inject
private PortalTodoLogService portalTodoLogService;
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse sendTodo(PortalTodoSendRequest request) {
return post("send", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse doneTodo(PortalTodoRequest request) {
return post("done", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse removeTodo(PortalTodoRequest request) {
return post("removeTodo", request);
}
/** 先拦截环境与开关,再校验参数并请求;禁止自动重试不确定是否已送达的发送。 */
private PortalTodoResponse post(String operation, PortalTodoRequest request) {
PortalTodoResponse result = new PortalTodoResponse();
String requestId = UUID.randomUUID().toString().replace("-", "");
long requestTime = System.currentTimeMillis();
long startedNanos = System.nanoTime();
String requestUrl = null;
String requestBody = null;
String responseBody = null;
String exceptionType = null;
HttpURLConnection connection = null;
try {
// 环境与功能开关均开启才调用门户,拦截早于参数及认证配置校验。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
result.setSkipped(true);
result.setCode("SKIPPED");
result.setMsg(!Globals.sso ? "开发模式不调用门户待办" : "门户待办开关未开启");
return result;
}
JSONObject payload = payload(operation, request);
requestBody = payload.toString();
String baseUrl = required(conf.get("portal.todo.base-url"), "门户地址未配置");
String username = required(conf.get("portal.todo.username"), "门户认证用户名未配置");
String password = required(conf.get("portal.todo.password"), "门户认证密码未配置");
requestUrl = StrUtil.removeSuffix(baseUrl, "/")
+ "/openapi/sys-notifybus/sysNotifyComponent/" + operation;
// 打印实际发送的同一份JSON,不输出Basic用户名、密码或Authorization头。
log.info("{}【请求开始】调用编号={},操作={},地址={},发送内容={}",
LOG_PREFIX, requestId, operation, requestUrl, requestBody);
connection = (HttpURLConnection) URI.create(requestUrl).toURL().openConnection();
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setUseCaches(false);
connection.setConnectTimeout(conf.getInt("portal.todo.connect-timeout", 5000));
connection.setReadTimeout(conf.getInt("portal.todo.read-timeout", 10000));
connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder()
.encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)));
try (OutputStream out = connection.getOutputStream()) {
out.write(requestBody.getBytes(StandardCharsets.UTF_8));
}
int status = connection.getResponseCode();
result.setHttpStatus(status);
try (InputStream in = status >= 400 ? connection.getErrorStream() : connection.getInputStream()) {
// 先保留原始响应,非JSON、解析失败等情况也能在控制台排查。
responseBody = in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8);
JSONObject json = JSONUtil.parseObj(responseBody);
result.setCode(json.getStr("code"));
result.setMsg(json.getStr("msg"));
result.setData(json.getStr("data"));
result.setTraceId(json.getStr("traceId"));
result.setSuccess(status >= 200 && status < 300 && Boolean.TRUE.equals(json.getBool("success")));
}
} catch (Exception e) {
result.setSuccess(false);
result.setCode("CLIENT_ERROR");
exceptionType = e.getClass().getSimpleName();
// 异常仅记录类型,不打印可能携带认证信息的异常原文。
result.setMsg(e instanceof IllegalArgumentException ? "门户参数、配置或响应格式无效" : "门户请求失败或超时");
} finally {
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
// 将多行响应编码为单行JSON字符串,确保每条日志均可用统一前缀定位。
String outcome = result.isSkipped() ? "已跳过" : result.isSuccess() ? "成功" : "失败";
String format = "{}【请求结束-{}】调用编号={},操作={},业务ID={}HTTP状态={},耗时={}mscode={}msg={}traceId={},异常类型={},返回内容={}";
Object[] values = {LOG_PREFIX, outcome, requestId, operation,
request == null ? null : request.getEntityId(), result.getHttpStatus(), elapsedMillis,
result.getCode(), JSONUtil.toJsonStr(result.getMsg()), result.getTraceId(), exceptionType,
JSONUtil.toJsonStr(responseBody)};
if (result.isSuccess() || result.isSkipped()) log.info(format, values);
else log.error(format, values);
if (connection != null) connection.disconnect();
// 仅send成功落库。日志保存异常不能改写门户成功结果,也不能触发再次发送。
if ("send".equals(operation) && result.isSuccess() && !result.isSkipped()) {
try {
portalTodoLogService.recordSuccess(requestId, requestUrl, requestBody, responseBody,
result, requestTime, elapsedMillis);
log.info("{}【记录已写入当前事务】调用编号={},traceId={}", LOG_PREFIX, requestId, result.getTraceId());
} catch (Exception e) {
log.error("{}【发送成功但记录入库失败】调用编号={},业务ID={}traceId={},异常类型={}",
LOG_PREFIX, requestId, request.getEntityId(), result.getTraceId(), e.getClass().getSimpleName());
}
}
}
return result;
}
/** 只发送接口使用的字段;删除和已办不得混入标题、正文等发送参数。 */
private JSONObject payload(String operation, PortalTodoRequest request) {
if (request == null) throw new IllegalArgumentException("请求不能为空");
if (request.getTargets() == null || request.getTargets().isEmpty()
|| request.getTargets().stream().anyMatch(StrUtil::isBlank)) {
throw new IllegalArgumentException("接收人账号不能为空");
}
JSONObject json = new JSONObject();
json.set("appName", "智慧工会平台");
json.set("moduleName", "智慧工会平台");
json.set("entityName", required(request.getEntityName(), "业务名称不能为空"));
json.set("entityId", required(request.getEntityId(), "业务ID不能为空"));
json.set("entityNumber", required(request.getEntityNumber(), "单据编码不能为空"));
json.set("targets", request.getTargets().stream().distinct().toList());
json.set("notifyType", "todo");
json.set("orgProperty", "fdLoginName");
json.set("todoType", 1);
json.set("async", false);
if ("send".equals(operation)) {
PortalTodoSendRequest send = (PortalTodoSendRequest) request;
json.set("subject", required(send.getSubject(), "标题不能为空"));
json.set("content", required(send.getContent(), "正文不能为空"));
String link = required(send.getLink(), "PC链接不能为空");
URI uri = URI.create(link);
if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) throw new IllegalArgumentException("PC链接必须为完整HTTP地址");
json.set("link", link);
// 独立调用可传完整H5地址;未提供时使用手机首页,避免门户打开空白页。
String mobileLink = send.getMobileLink();
if (StrUtil.isBlank(mobileLink)) {
mobileLink = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/"))
.resolve("/platform/h5").toString();
}
URI mobileUri = URI.create(mobileLink);
if (!("http".equalsIgnoreCase(mobileUri.getScheme()) || "https".equalsIgnoreCase(mobileUri.getScheme()))
|| mobileUri.getHost() == null) throw new IllegalArgumentException("手机链接必须为完整HTTP地址");
json.set("mobileLink", mobileLink);
if (StrUtil.isNotBlank(send.getCreator())) json.set("creator", send.getCreator());
if (StrUtil.isNotBlank(send.getCreatorName())) json.set("creatorName", send.getCreatorName());
json.set("queryType", 1);
json.set("todoLevel", 3);
}
return json;
}
private String required(String value, String message) {
if (StrUtil.isBlank(value)) throw new IllegalArgumentException(message);
return value;
}
}
@@ -1,99 +0,0 @@
package com.budwk.app.base.portal.model;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
/** 每次发送成功的请求保存一条快照,多个接收人仍归属于同一次请求。 */
@Data
@EqualsAndHashCode(callSuper = true)
@Table("portal_todo_log")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("学校OA待办成功发送记录")
public class PortalTodoLog extends BaseModel {
@Name
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("请求关联编号,与控制台调用编号一致")
private String id;
@Column
@Comment("来源系统")
private String appName;
@Column
@Comment("来源模块")
private String moduleName;
@Column
@Comment("来源业务名称")
private String entityName;
@Column
@Comment("业务记录或wf任务ID")
private String entityId;
@Column
@Comment("业务单据编码")
private String entityNumber;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("待办标题")
private String subject;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("待办正文")
private String content;
@Column
@Comment("发送人登录账号")
private String creator;
@Column
@Comment("发送人姓名")
private String creatorName;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("接收人登录账号JSON数组,保留工号前导零")
private String targets;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("PC跳转地址")
private String link;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("移动端跳转地址")
private String mobileLink;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("发送接口地址,不含认证信息")
private String requestUrl;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("实际发送的完整JSON报文")
private String requestBody;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户完整响应正文")
private String responseBody;
@Column
@Comment("请求开始时间,毫秒时间戳")
private Long requestTime;
@Column
@Comment("接口调用耗时,毫秒,不含日志入库")
private Long elapsedMillis;
@Column
@Comment("HTTP响应状态码")
private Integer httpStatus;
@Column
@Comment("门户是否返回成功,不代表接收人已查看")
private Boolean success;
@Column
@Comment("门户业务状态码")
private String code;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("门户返回提示")
private String msg;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户原样返回的数据,不推断为notifyId")
private String data;
@Column
@Comment("门户链路追踪编号")
private String traceId;
}
@@ -1,17 +0,0 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import java.util.List;
/** 门户待办定位参数;发送、已办和删除时必须使用同一组业务标识及人员账号。 */
@Data
public class PortalTodoRequest {
/** 来源业务名称,同一待办生命周期内不得更改。 */
private String entityName;
/** 业务记录或任务的唯一 ID,不能仅使用可重复的节点名称。 */
private String entityId;
/** 所属业务单据编码。 */
private String entityNumber;
/** 门户登录账号数组,保留工号前导零,不传本系统人员 ID。 */
private List<String> targets;
}
@@ -1,18 +0,0 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
/** 门户调用结果;跳过不表示发送成功,网络异常时 httpStatus 为空。 */
@Data
public class PortalTodoResponse {
private boolean success;
private boolean skipped;
private Integer httpStatus;
/** 门户业务错误码,或本地 SKIPPED / CLIENT_ERROR。 */
private String code;
private String msg;
/** 门户原样返回的数据,不推断为 notifyId。 */
private String data;
/** 提供给门户管理员排查的链路标识。 */
private String traceId;
}
@@ -1,22 +0,0 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 发送待办参数;继承的定位参数需由业务方保留,供后续已办、删除使用。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class PortalTodoSendRequest extends PortalTodoRequest {
/** 待办标题。 */
private String subject;
/** 待办正文。 */
private String content;
/** 发送人门户登录账号,可不填。 */
private String creator;
/** 发送人姓名,可不填。 */
private String creatorName;
/** 可访问的 PC 业务页面完整地址,必填。 */
private String link;
/** 可访问的H5业务页面完整HTTP(S)地址;未传时回退到项目手机首页。 */
private String mobileLink;
}
@@ -93,15 +93,6 @@ public class FlowTodoCenterController {
return Result.success(pagination); return Result.success(pagination);
} }
/**
* 分页查询当前用户的已办任务,仅返回列表展示及详情跳转所需的信息。
*
* @param pageNumber 查询页码
* @param pageSize 每页记录数
* @param searchKeyword 流程名称查询关键字
* @param category 流程分类
* @return 已办任务分页结果,不包含完整流程变量和任务表单数据
*/
@At @At
@SaCheckLogin @SaCheckLogin
public Result done(Integer pageNumber, Integer pageSize, String searchKeyword, String category) { public Result done(Integer pageNumber, Integer pageSize, String searchKeyword, String category) {
@@ -115,13 +106,8 @@ public class FlowTodoCenterController {
t.h5FormKey, t.h5FormKey,
t.createdAt, t.createdAt,
t.finishTime, t.finishTime,
-- 保留任务变量字段类型,但已办列表不返回其中的业务表单数据。 t.variable AS taskVariable,
'{}' AS taskVariable, ins.variable,
-- 列表仅需流程名称和申请人,避免完整变量中的身份证等信息泄露。
JSON_OBJECT(
'instanceName', ins.variable -> '$.instanceName',
'initiatorName', ins.variable -> '$.initiatorName'
) AS variable,
ins.state AS instanceState, ins.state AS instanceState,
ins.id AS instanceId, ins.id AS instanceId,
ins.businessNo, ins.businessNo,
@@ -1,66 +0,0 @@
package com.budwk.app.flow.listenter;
import com.budwk.app.flow.engine.event.ProcessEvent;
import com.budwk.app.flow.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 门户专用监听器;发送从实际参与人新增入口触发,不重复消费任务开始事件。 */
@IocBean
@Slf4j
public class ProcessPortalTodoEventListener implements ProcessEventListener {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** @param event 任务结束/撤销或实例结束事件;按真实状态区分已办和移除,异常不阻断流程。 */
@Override
public void onEvent(ProcessEvent event) {
log.info("{}【WF事件入口】事件={},来源ID={}", LOG_PREFIX, event.getEventType(), event.getSourceId());
// 未启用门户同步时停止消费事件,保留日志便于定位跳过原因。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
log.info("{}【WF事件跳过】事件={},来源ID={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, event.getEventType(), event.getSourceId(),
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
ProcessTask task = dao.fetch(ProcessTask.class, event.getSourceId());
if (task == null) {
log.warn("{}【WF事件跳过】来源ID={},原因=任务不存在", LOG_PREFIX, event.getSourceId());
return;
}
if (ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
flowPortalTodoService.doneTask(task.getId());
} else if (ProcessTaskStateEnum.ABANDON.getCode().equals(task.getTaskState())) {
flowPortalTodoService.removeTask(task.getId(), null);
} else {
log.info("{}【WF事件跳过】任务ID={},原因=结束事件的任务状态无需同步,任务状态={}",
LOG_PREFIX, task.getId(), task.getTaskState());
}
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_REVOKE) {
flowPortalTodoService.removeTask(event.getSourceId(), null);
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_INSTANCE_END) {
flowPortalTodoService.removeInstance(event.getSourceId());
} else {
// 开始事件不再次发送,发送已由新增参与人入口负责。
log.info("{}【WF事件跳过】事件={},来源ID={},原因=该事件无需本监听器同步,发送由新增参与人入口负责",
LOG_PREFIX, event.getEventType(), event.getSourceId());
}
} catch (Exception e) {
log.warn("{}【WF事件同步失败】来源ID={},异常类型={}", LOG_PREFIX, event.getSourceId(), e.getClass().getSimpleName());
}
}
}
@@ -35,9 +35,6 @@ public class FlowCommonService {
@Inject @Inject
private Dao dao; private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** /**
* 执行任务 * 执行任务
* @param args * @param args
@@ -114,12 +111,9 @@ public class FlowCommonService {
// 撤销任务 // 撤销任务
List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId)); List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId));
// 收集全部被撤回的任务,包含并行会签的非直接子任务,避免门户残留待办。
java.util.Set<Long> revokedTaskIds = new java.util.LinkedHashSet<>();
for (ProcessTask task : taskList) { for (ProcessTask task : taskList) {
task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(task); dao.update(task);
revokedTaskIds.add(task.getId());
} }
// 会签并行任务 撤销后续任务 // 会签并行任务 撤销后续任务
if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) { if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) {
@@ -128,7 +122,6 @@ public class FlowCommonService {
for (ProcessTask doingTask : doingTasks) { for (ProcessTask doingTask : doingTasks) {
doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(doingTask); dao.update(doingTask);
revokedTaskIds.add(doingTask.getId());
} }
} }
@@ -143,12 +136,6 @@ public class FlowCommonService {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build()); ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build());
} }
// 直接子任务由撤回事件同步;额外并行任务在此补齐,不重复删除同一任务。
taskList.forEach(task -> revokedTaskIds.remove(task.getId()));
revokedTaskIds.forEach(id -> flowPortalTodoService.removeTask(id, null));
// 原任务重新进入办理状态,需要恢复它在门户中的待办。
flowPortalTodoService.sendTask(selfTask.getId(), null);
return Result.success(); return Result.success();
} }
@@ -1,213 +0,0 @@
package com.budwk.app.flow.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* wf 门户适配,所有标识由任务持久化数据重建,不依赖当前登录请求。
*/
@IocBean
@Slf4j
public class FlowPortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private PortalTodoService portalTodoService;
/**
* @param taskId wf 任务 ID
* @param actors 本次新增的参与人,null 表示当前任务全部参与人;空列表不发送
* @return 无返回值;门户异常只记录,不中断工作流
*/
@Aop(TransAop.READ_COMMITTED)
public void sendTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "send");
}
/**
* @param taskId 已完成的 wf 任务 ID;参与人账号沿用原任务,异常只记录。
*/
@Aop(TransAop.READ_COMMITTED)
public void doneTask(Long taskId) {
sync(taskId, null, "done");
}
/**
* @param taskId 原 wf 任务 ID,必须在删除任务记录之前调用
* @param actors 被移除的参与人快照;null 表示原任务全部参与人
* @return 无返回值;不改变 wf 数据,门户失败仅记录
*/
@Aop(TransAop.READ_COMMITTED)
public void removeTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "removeTodo");
}
/**
* @param instanceId wf 实例 ID;实例结束或删除前清理非已完成任务的门户待办。
*/
@Aop(TransAop.READ_COMMITTED)
public void removeInstance(Long instanceId) {
log.info("{}【WF实例清理入口】实例ID={}", LOG_PREFIX, instanceId);
if (!enabled()) {
log.info("{}【WF跳过】实例ID={},原因={}sso={}AppPortalTodo={}", LOG_PREFIX, instanceId,
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
List<ProcessTask> tasks = dao.query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", instanceId)
.and(ProcessTask::getTaskState, "!=", ProcessTaskStateEnum.FINISHED.getCode()));
if (tasks.isEmpty()) {
log.info("{}【WF跳过】实例ID={},原因=没有需要清理的任务", LOG_PREFIX, instanceId);
}
for (ProcessTask task : tasks) sync(task.getId(), null, "removeTodo");
} catch (Exception e) {
log.warn("{}【WF实例清理失败】实例ID={},异常类型={}", LOG_PREFIX, instanceId, e.getClass().getSimpleName());
}
}
private boolean enabled() {
// WF同步与独立门户调用使用相同的环境及功能开关。
return Globals.sso && Globals.MyConfig.getBoolean("AppPortalTodo", false);
}
/**
* 包括数据读取和参数组装在内的异常均隔离,避免影响已授权的 wf 业务操作。
*/
private void sync(Long taskId, List<ProcessTaskActor> actors, String operation) {
// 在开关判断之前记录入口,使未进入HTTP客户端的调用也能明确定位跳过原因。
log.info("{}【WF同步入口】任务ID={},操作={}", LOG_PREFIX, taskId, operation);
if (!enabled()) {
log.info("{}【WF跳过】任务ID={},操作={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, taskId, operation, !Globals.sso ? "开发模式" : "门户待办开关未开启",
Globals.sso, Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
if (task == null) {
log.warn("{}【WF跳过】任务ID={},操作={},原因=任务不存在", LOG_PREFIX, taskId, operation);
return;
}
// 首次申请节点由发起人直接提交,无需发送瞬时待办;退回申请节点仍需发送。
if (Long.valueOf(0).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=首次申请节点无需发送待办", LOG_PREFIX, taskId, operation);
return;
}
if ("send".equals(operation) && !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=任务非办理状态,任务状态={}",
LOG_PREFIX, taskId, operation, task.getTaskState());
return;
}
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
if (instance == null) {
log.warn("{}【WF跳过】任务ID={},操作={},实例ID={},原因=流程实例不存在",
LOG_PREFIX, taskId, operation, task.getProcessInstanceId());
return;
}
List<ProcessTaskActor> recipients = actors == null ? dao.query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", taskId)) : actors;
if (recipients.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人列表为空", LOG_PREFIX, taskId, operation);
return;
}
if (recipients.stream().anyMatch(actor -> StrUtil.isBlank(actor.getActorAccount()))) {
log.warn("{}【WF账号缺失】任务ID={},操作={},部分参与人没有登录账号,将过滤这些人员",
LOG_PREFIX, taskId, operation);
}
List<String> accounts = recipients.stream().map(ProcessTaskActor::getActorAccount)
.filter(StrUtil::isNotBlank).distinct().toList();
if (accounts.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人均缺少有效登录账号", LOG_PREFIX, taskId, operation);
return;
}
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("wf");
request.setEntityId(String.valueOf(taskId));
request.setEntityNumber(StrUtil.blankToDefault(instance.getBusinessNo(), String.valueOf(instance.getId())));
request.setTargets(accounts);
if ("send".equals(operation)) {
String name = StrUtil.isBlank(instance.getVariable()) ? null : JSONUtil.parseObj(instance.getVariable())
.getStr(FlowConst.PROCESS_INSTANCE_NAME);
name = StrUtil.blankToDefault(name, request.getEntityNumber());
String node = StrUtil.blankToDefault(task.getDisplayName(), task.getTaskName());
request.setSubject(name + "-" + node);
request.setContent("您有一条待办任务,实例:" + name + ",处理环节:" + node);
request.setLink(buildLink(task, instance));
// H5使用任务自身的手机表单,不使用PC表单替代;缺失配置时明确记录回退原因。
if (StrUtil.isBlank(task.getH5FormKey())) {
log.warn("{}【H5入口回退】任务ID={},原因=未配置h5FormKey,将跳转手机首页", LOG_PREFIX, taskId);
}
request.setMobileLink(buildFormLink(task, instance, task.getH5FormKey(), "/platform/h5"));
if (StrUtil.isNotBlank(instance.getOperator())) {
View_user creator = dao.fetch(View_user.class, Cnd.where("id", "=", instance.getOperator()));
if (creator != null) {
request.setCreator(creator.getLoginname());
request.setCreatorName(creator.getUsername());
}
}
portalTodoService.sendTodo(request);
} else if ("done".equals(operation)) {
portalTodoService.doneTodo(request);
} else {
portalTodoService.removeTodo(request);
}
} catch (Exception e) {
log.warn("{}【WF同步失败】任务ID={},操作={},异常类型={}", LOG_PREFIX, taskId, operation, e.getClass().getSimpleName());
}
}
/**
* 复用 wf 表单入口参数,保留原链接查询和片段,避免工号或业务编号中的特殊字符破坏地址。
*/
private String buildLink(ProcessTask task, ProcessInstance instance) {
return buildFormLink(task, instance, task.getFormKey(), "/flow/todoCenter");
}
/**
* @param task 提供taskId、taskKey的WF任务
* @param instance 提供bizIdbusinessNo)的流程实例
* @param formKey 对应端的业务表单地址,支持相对路径及完整HTTP(S)地址
* @param fallback 表单未配置时的入口路径;回退入口不附加业务参数
* @return 补齐项目域名并编码任务参数的地址,保留已有查询参数和锚点
*/
private String buildFormLink(ProcessTask task, ProcessInstance instance, String formKey, String fallback) {
String form = StrUtil.blankToDefault(formKey, fallback);
String link = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/")).resolve(form).toString();
if (StrUtil.isBlank(formKey)) return link;
int fragmentAt = link.indexOf('#');
String fragment = fragmentAt < 0 ? "" : link.substring(fragmentAt);
if (fragmentAt >= 0) link = link.substring(0, fragmentAt);
return link + (link.contains("?") ? "&" : "?") + "taskId=" + task.getId()
+ "&bizId=" + encode(StrUtil.blankToDefault(instance.getBusinessNo(), ""))
+ "&taskKey=" + encode(task.getTaskName()) + fragment;
}
private String encode(String value) {
return URLEncoder.encode(StrUtil.nullToEmpty(value), StandardCharsets.UTF_8);
}
}
@@ -28,7 +28,6 @@ import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.HighLightVO; import com.budwk.app.flow.vo.HighLightVO;
@@ -58,9 +57,6 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public boolean save(ProcessInstance param) { public boolean save(ProcessInstance param) {
@@ -202,16 +198,10 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode()); processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 终止后任务不可办理,同步清理门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void resume(Long processInstanceId, String operator) { public void resume(Long processInstanceId, String operator) {
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
Date now = new Date(); Date now = new Date();
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
@@ -222,12 +212,9 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
// 仅恢复本次由终止状态重新进入办理的任务,避免重复发送已有待办。
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void pending(Long processInstanceId, String operator) { public void pending(Long processInstanceId, String operator) {
Date now = new Date(); Date now = new Date();
// 1. 将该流程实例产生的任务状态修改为挂起 // 1. 将该流程实例产生的任务状态修改为挂起
@@ -240,17 +227,10 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode()); processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 挂起期间不应继续展示可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void activate(Long processInstanceId, String operator) { public void activate(Long processInstanceId, String operator) {
// 沿用当前 activate 的状态选择规则,只同步实际被恢复的任务。
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
processInstance.setState(ProcessInstanceStateEnum.DOING.getCode()); processInstance.setState(ProcessInstanceStateEnum.DOING.getCode());
@@ -260,7 +240,6 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@@ -344,7 +323,6 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void withdraw(Long processInstanceId, String operator) { public void withdraw(Long processInstanceId, String operator) {
// Date now = new Date(); // Date now = new Date();
// 1. 将该流程实例状态修改为撤回 // 1. 将该流程实例状态修改为撤回
@@ -364,8 +342,6 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setUpdatedBy(operator); processTask.setUpdatedBy(operator);
} }
dao().update(taskList, "taskState|updateAt|updateBy"); dao().update(taskList, "taskState|updateAt|updateBy");
// 实例撤回没有任务撤回事件,在此同步删除已不可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
// int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) // int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
@@ -579,8 +555,6 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void deleteProcessInstanceById(Long processInstanceId) { public void deleteProcessInstanceById(Long processInstanceId) {
// 在实例、任务和参与人删除前清理门户,否则无法重建原待办定位参数。
flowPortalTodoService.removeInstance(processInstanceId);
List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)); List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId));
List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList(); List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList();
// 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。 // 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。
@@ -27,7 +27,6 @@ import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor; import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.*; import com.budwk.app.flow.enums.*;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.flow.vo.ProcessTaskVO;
@@ -57,9 +56,6 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
public boolean save(ProcessTask param) { public boolean save(ProcessTask param) {
param.setId(null); param.setId(null);
@@ -129,7 +125,6 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void finishProcessTask(Long processTaskId, String operator, Dict args) { public void finishProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -154,7 +149,6 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void abandonProcessTask(Long processTaskId, String operator, Dict args) { public void abandonProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -234,13 +228,10 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
if (CollectionUtil.isEmpty(actors)) return; if (CollectionUtil.isEmpty(actors)) return;
List<String> dbActors = getTaskActors(processTaskId); List<String> dbActors = getTaskActors(processTaskId);
List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList(); List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList();
if (newActors.isEmpty()) return;
Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)"); Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)");
sql.setParam("ids", newActors); sql.setParam("ids", newActors);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
List<ProcessTaskActor> addedActors = new ArrayList<>();
for (NutMap actor : list) { for (NutMap actor : list) {
ProcessTaskActor processTaskActor = new ProcessTaskActor(); ProcessTaskActor processTaskActor = new ProcessTaskActor();
processTaskActor.setProcessTaskId(processTaskId); processTaskActor.setProcessTaskId(processTaskId);
@@ -252,10 +243,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
processTaskActor.setCreatedAt(System.currentTimeMillis()); processTaskActor.setCreatedAt(System.currentTimeMillis());
System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString())); System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString()));
insert(processTaskActor); insert(processTaskActor);
addedActors.add(processTaskActor);
} }
// 只为实际新增的账号发送,覆盖普通任务、串并行会签、转办和加签,避免事件重复发送。
flowPortalTodoService.sendTask(processTaskId, addedActors);
} }
@Override @Override
@@ -302,14 +290,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void removeTaskActor(Long processTaskId, List<String> actors) { public void removeTaskActor(Long processTaskId, List<String> actors) {
if (CollectionUtil.isEmpty(actors)) return;
// 先保留被移除人员的门户账号,参与人记录清理后无法再据此定位待办。
List<ProcessTaskActor> removedActors = dao().query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId)
.and(ProcessTaskActor::getActorId, "in", actors));
flowPortalTodoService.removeTask(processTaskId, removedActors);
dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors)); dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors));
} }
@@ -500,9 +481,6 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
task.setVariable(JSONUtil.toJsonStr(variable)); task.setVariable(JSONUtil.toJsonStr(variable));
updateIgnoreNull(task); updateIgnoreNull(task);
// 转办后原参与人不再办理,先清理原任务门户待办;新任务在添加参与人时发送。
flowPortalTodoService.removeTask(taskId, null);
// 创建转办任务 // 创建转办任务
ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id"); ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id");
newTask.setTaskParentId(taskId); newTask.setTaskParentId(taskId);
@@ -33,10 +33,6 @@ import org.nutz.mvc.upload.TempFile;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -47,9 +43,6 @@ import java.nio.file.StandardOpenOption;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -61,9 +54,6 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
static String IMG_BASE64_PATTERN = "<img\\s+[^>]*src\\s*=\\s*['\"](data:image/[^'\"]+;base64,[^'\"]+)['\"][^>]*>"; static String IMG_BASE64_PATTERN = "<img\\s+[^>]*src\\s*=\\s*['\"](data:image/[^'\"]+;base64,[^'\"]+)['\"][^>]*>";
private static final int DOWNLOAD_FILE_CACHE_LIMIT = 2000; private static final int DOWNLOAD_FILE_CACHE_LIMIT = 2000;
private final Map<String, Sys_file> downloadFileCache = new ConcurrentHashMap<>(); private final Map<String, Sys_file> downloadFileCache = new ConcurrentHashMap<>();
private static final Set<String> UPLOAD_SUFFIXES = Set.of("gif", "jpg", "jpeg", "png", "doc", "docx",
"xls", "xlsx", "rar", "zip", "7z", "txt", "pdf", "pptx", "ppt", "mp4");
private static final Set<String> ACTIVE_SUFFIXES = Set.of("svg", "svgz", "html", "htm", "xhtml", "xml", "js", "mjs", "swf");
public SysFileServiceImpl(Dao dao) { public SysFileServiceImpl(Dao dao) {
super(dao); super(dao);
@@ -96,13 +86,11 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
@Override @Override
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setHeader("X-Content-Type-Options", "nosniff"); if (handleCachedFileRequest(id, request, response)) {
response.setHeader("Cache-Control", "private, no-store"); return;
}
Sys_file sys_file = fetchDownloadFile(id); Sys_file sys_file = fetchDownloadFile(id);
// 历史主动内容也必须在缓存协商前拦截,避免旧 SVG 继续在业务域下执行。 if (!ObjectUtil.isEmpty(sys_file) && handleCachedImageRequest(sys_file, request, response)) {
if (sys_file != null && (ACTIVE_SUFFIXES.contains(StrUtil.blankToDefault(sys_file.getSuffix(), "").toLowerCase(Locale.ROOT))
|| ACTIVE_SUFFIXES.contains(FileUtil.extName(sys_file.getName()).toLowerCase(Locale.ROOT)))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "不允许访问此类型的附件");
return; return;
} }
if (ObjectUtil.isEmpty(sys_file)) { if (ObjectUtil.isEmpty(sys_file)) {
@@ -129,6 +117,16 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
} }
private boolean handleCachedFileRequest(String id, HttpServletRequest request, HttpServletResponse response) {
String etag = buildFileEtag(id);
if (etag.equals(request.getHeader("If-None-Match"))) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
setImageCacheHeaders(response, etag);
return true;
}
return false;
}
private Sys_file fetchDownloadFile(String id) { private Sys_file fetchDownloadFile(String id) {
if (StrUtil.isBlank(id)) { if (StrUtil.isBlank(id)) {
return null; return null;
@@ -147,15 +145,23 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
return sysFile; return sysFile;
} }
private boolean writeImageResponse(Sys_file sysFile, byte[] bytes, HttpServletResponse response) throws IOException { private boolean handleCachedImageRequest(Sys_file sysFile, HttpServletRequest request, HttpServletResponse response) {
if (!isImage(sysFile)) { if (!isImage(sysFile)) {
return false; return false;
} }
String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase(Locale.ROOT); String etag = buildFileEtag(sysFile);
if (!isValidImage(bytes, suffix)) { if (etag.equals(request.getHeader("If-None-Match"))) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "图片内容与文件类型不符"); response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
setImageCacheHeaders(response, etag);
return true; return true;
} }
return false;
}
private boolean writeImageResponse(Sys_file sysFile, byte[] bytes, HttpServletResponse response) throws IOException {
if (!isImage(sysFile)) {
return false;
}
setImageCacheHeaders(response, buildFileEtag(sysFile)); setImageCacheHeaders(response, buildFileEtag(sysFile));
response.setHeader("Content-Disposition", "inline;filename=" + java.net.URLEncoder.encode(sysFile.getName(), java.nio.charset.StandardCharsets.UTF_8)); response.setHeader("Content-Disposition", "inline;filename=" + java.net.URLEncoder.encode(sysFile.getName(), java.nio.charset.StandardCharsets.UTF_8));
response.setHeader("Content-Length", String.valueOf(bytes.length)); response.setHeader("Content-Length", String.valueOf(bytes.length));
@@ -165,9 +171,9 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
private void setImageCacheHeaders(HttpServletResponse response, String etag) { private void setImageCacheHeaders(HttpServletResponse response, String etag) {
response.setHeader("Cache-Control", "private, no-cache"); response.setHeader("Cache-Control", "public, max-age=604800, immutable");
response.setHeader("ETag", etag); response.setHeader("ETag", etag);
response.setDateHeader("Expires", 0); response.setDateHeader("Expires", System.currentTimeMillis() + 604800000L);
} }
private String buildFileEtag(Sys_file sysFile) { private String buildFileEtag(Sys_file sysFile) {
@@ -175,12 +181,12 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
private String buildFileEtag(String id) { private String buildFileEtag(String id) {
return "\"safe-" + id + "\""; return "\"" + id + "\"";
} }
private boolean isImage(Sys_file sysFile) { private boolean isImage(Sys_file sysFile) {
String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase(); String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase();
return "jpg".equals(suffix) || "jpeg".equals(suffix) || "png".equals(suffix) || "gif".equals(suffix) || "webp".equals(suffix) || "bmp".equals(suffix); return "jpg".equals(suffix) || "jpeg".equals(suffix) || "png".equals(suffix) || "gif".equals(suffix) || "webp".equals(suffix) || "bmp".equals(suffix) || "svg".equals(suffix);
} }
private String getImageContentType(Sys_file sysFile) { private String getImageContentType(Sys_file sysFile) {
@@ -191,6 +197,7 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
case "gif" -> "image/gif"; case "gif" -> "image/gif";
case "webp" -> "image/webp"; case "webp" -> "image/webp";
case "bmp" -> "image/bmp"; case "bmp" -> "image/bmp";
case "svg" -> "image/svg+xml";
default -> "application/octet-stream"; default -> "application/octet-stream";
}; };
} }
@@ -303,7 +310,6 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
* 存储文件 * 存储文件
**/ **/
private String storageFile(String engine, TempFile file, boolean returnFileId) { private String storageFile(String engine, TempFile file, boolean returnFileId) {
validateUpload(file);
// 如果引擎为空,默认使用本地 // 如果引擎为空,默认使用本地
if (ObjectUtil.isEmpty(engine)) { if (ObjectUtil.isEmpty(engine)) {
engine = SysFileEngineTypeEnum.LOCAL.getValue(); engine = SysFileEngineTypeEnum.LOCAL.getValue();
@@ -377,53 +383,6 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
} }
/** 所有业务上传共用校验,客户端声明的 MIME 类型不能作为图片真实性依据。 */
private void validateUpload(TempFile file) {
if (file == null || StrUtil.isBlank(file.getSubmittedFileName())) {
throw new BaseException("请选择要上传的文件");
}
String suffix = FileUtil.extName(file.getSubmittedFileName()).toLowerCase(Locale.ROOT);
if (!UPLOAD_SUFFIXES.contains(suffix)) {
throw new BaseException("不允许上传此类型的文件");
}
if (Set.of("gif", "jpg", "jpeg", "png").contains(suffix)) {
try {
if (!isValidImage(Files.readAllBytes(file.getFile().toPath()), suffix)) {
throw new BaseException("图片内容与文件类型不符");
}
} catch (IOException e) {
throw new BaseException("图片读取失败");
}
}
}
/** 解码真实图片并限制像素数量,阻止伪装为图片的 SVG 和异常大尺寸图片。 */
private boolean isValidImage(byte[] bytes, String suffix) {
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
if (input == null) {
return false;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
return false;
}
ImageReader reader = readers.next();
try {
reader.setInput(input, true, true);
String expected = "jpg".equals(suffix) ? "jpeg" : suffix;
if (!expected.equalsIgnoreCase(reader.getFormatName())) {
return false;
}
long pixels = (long) reader.getWidth(0) * reader.getHeight(0);
return pixels > 0 && pixels <= 40000000 && reader.read(0) != null;
} finally {
reader.dispose();
}
} catch (IOException | RuntimeException e) {
return false;
}
}
/** /**
* 存储文件 * 存储文件
*/ */
@@ -10,9 +10,6 @@ import cn.dev33.satoken.servlet.model.SaStorageForServlet;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.Mvcs; import org.nutz.mvc.Mvcs;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponseWrapper;
/** /**
* @author wizzer@qq.com * @author wizzer@qq.com
*/ */
@@ -25,20 +22,7 @@ public class SaTokenContextImpl implements SaTokenContext {
@Override @Override
public SaResponse getResponse() { public SaResponse getResponse() {
// 当前 Sa-Token 版本没有 Cookie 安全属性配置,在写入登录 Cookie 时统一补齐。 return new SaResponseForServlet(Mvcs.getResp());
return new SaResponseForServlet(new HttpServletResponseWrapper(Mvcs.getResp()) {
/**
* 限制登录 Cookie 的脚本访问,并在 HTTPS 请求中仅允许安全传输。
*
* @param cookie Sa-Token 写入或清除的 Cookie
*/
@Override
public void addCookie(Cookie cookie) {
cookie.setHttpOnly(true);
cookie.setSecure(cookie.getSecure() || Mvcs.getReq().isSecure());
super.addCookie(cookie);
}
});
} }
@Override @Override
@@ -1,6 +1,7 @@
package com.budwk.app.web.commons.ext.handler; package com.budwk.app.web.commons.ext.handler;
import org.nutz.boot.starter.WebServletFace; import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.servlet.ErrorPageErrorHandler;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -8,63 +9,28 @@ import org.nutz.log.Log;
import org.nutz.log.Logs; import org.nutz.log.Logs;
import javax.servlet.RequestDispatcher; import javax.servlet.RequestDispatcher;
import javax.servlet.DispatcherType;
import javax.servlet.Servlet;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.ServletRequest; import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServlet;
import java.io.IOException; import java.io.IOException;
/** /**
* 错误页拦截器,登陆后台显示友好提示 * 错误页拦截器,登陆后台显示友好提示
*/ */
@IocBean @IocBean
public class WkErrorPageHandler extends HttpServlet implements WebServletFace { public class WkErrorPageHandler extends ErrorPageErrorHandler {
private static final Log log = Logs.get(); private static final Log log = Logs.get();
/**
* 返回容器错误处理 Servlet 的注册名称。
* @return 独立于业务路由的 Servlet 名称
*/
@Override @Override
public String getName() { public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
return "wkErrorPage";
}
/**
* 返回容器错误页的内部派发地址。
* @return 错误页映射路径
*/
@Override
public String getPathSpec() {
return "/__container_error";
}
/**
* 将当前错误处理实例交给 NutzBoot 注册。
* @return 当前 Servlet 实例
*/
@Override
public Servlet getServlet() {
return this;
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
if (request.getDispatcherType() != DispatcherType.ERROR) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (response.getStatus() == 403 || response.getStatus() == 404 || response.getStatus() == 500) { if (response.getStatus() == 403 || response.getStatus() == 404 || response.getStatus() == 500) {
try { try {
if (isAjax(request)) { if (isAjax(request)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(Json.toJson(new NutMap("code", "-1").setv("msg", response.getStatus() + " error"))); response.getWriter().write(Json.toJson(new NutMap("code", "-1").setv("msg", response.getStatus() + " error")));
return; return;
} else { } else {
request.setAttribute("original_request_uri", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI)); request.setAttribute("original_request_uri", request.getRequestURI());
RequestDispatcher rd = request.getRequestDispatcher("/platform/home/" + response.getStatus()); RequestDispatcher rd = request.getRequestDispatcher("/platform/home/" + response.getStatus());
rd.forward(request, response); rd.forward(request, response);
return; return;
@@ -73,7 +39,7 @@ public class WkErrorPageHandler extends HttpServlet implements WebServletFace {
log.error(e); log.error(e);
} }
} }
// 不回落到容器默认错误页,避免泄露异常堆栈和容器信息。 super.handle(target, baseRequest, request, response);
} }
private boolean isAjax(ServletRequest req) { private boolean isAjax(ServletRequest req) {
@@ -1,88 +0,0 @@
package com.budwk.app.web.commons.ext.handler;
import com.budwk.app.web.commons.ext.websocket.WkWebSocket;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.Executor;
import org.apache.catalina.core.StandardThreadExecutor;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.valves.ErrorReportValve;
import org.apache.tomcat.websocket.server.WsSci;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import org.nutz.boot.NbApp;
import org.nutz.boot.starter.tomcat.TomcatStarter;
import org.nutz.boot.tools.NbAppEventListener;
import org.nutz.ioc.impl.PropertiesProxy;
import java.util.Set;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
/**
* 在容器启动前补齐 NutzBoot Tomcat 的 WebSocket 注册和线程池绑定。
*/
public class WkTomcatConfiguration implements NbAppEventListener {
/**
* 配置官方嵌入式 Tomcat,保留项目的监听地址与消息推送能力。
*
* @param app 当前 NutzBoot 应用
* @param eventType 只在服务器启动前配置,避免重复注册端点
*/
@Override
public void whenStartServers(NbApp app, EventType eventType) {
if (eventType != EventType.before) {
return;
}
TomcatStarter starter = app.getAppContext().getIoc().get(TomcatStarter.class);
Tomcat tomcat = starter.getServer();
PropertiesProxy conf = app.getAppContext().getConf();
tomcat.getConnector().setProperty("address", starter.getHost());
// Starter 创建了共享线程池,但尚未把它交给 Connector 使用。
for (Executor executor : tomcat.getService().findExecutors()) {
if (executor instanceof StandardThreadExecutor) {
StandardThreadExecutor threads = (StandardThreadExecutor) executor;
threads.setMinSpareThreads(conf.getInt("tomcat.executor.minSpareThreads", 10));
threads.setMaxIdleTime(conf.getInt("tomcat.executor.maxIdleTime", 60000));
tomcat.getConnector().getProtocolHandler().setExecutor(threads);
break;
}
}
// NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。
StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath());
context.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true));
context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class));
// Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。
// Nutz 的过滤器在前一个监听器中注册,须等其完成后才能添加映射。
context.addApplicationLifecycleListener(new ServletContextListener() {
/**
* 在 Nutz 注册完成后补充业务错误页的 ERROR 派发映射。
* @param event 当前 Servlet 上下文初始化事件
*/
@Override
public void contextInitialized(ServletContextEvent event) {
FilterMap errorPages = new FilterMap();
errorPages.setFilterName("nutz");
errorPages.setDispatcher("ERROR");
// ERROR 再转发时 URL 匹配路径不变,按目标 Servlet 匹配才能进入 MVC。
errorPages.addServletName("default");
context.addFilterMap(errorPages);
}
});
// Host 的兜底错误页也不能重新暴露此前已关闭的版本和堆栈。
ErrorReportValve errorReport = null;
for (org.apache.catalina.Valve valve : tomcat.getHost().getPipeline().getValves()) {
if (valve instanceof ErrorReportValve) {
errorReport = (ErrorReportValve) valve;
break;
}
}
if (errorReport == null) {
errorReport = new ErrorReportValve();
tomcat.getHost().getPipeline().addValve(errorReport);
}
errorReport.setShowReport(false);
errorReport.setShowServerInfo(false);
}
}
@@ -54,7 +54,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/apply") @At("/platform/unionReimburse/apply")
@Api("工会报销申请") @Api("工会报销申请")
@Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}") @Ok("json:full")
@Slf4j @Slf4j
public class UnionReimburseApplyController { public class UnionReimburseApplyController {
@@ -151,11 +151,14 @@ public class UnionReimburseApplyController {
username as userName, username as userName,
loginname as loginName, loginname as loginName,
sex, sex,
mobile,
technicalTitle, technicalTitle,
IFNULL(unitname, '暂无') as unitName, IFNULL(unitname, '暂无') as unitName,
unitid as unitId, unitid as unitId,
unionid as unionId, unionid as unionId,
unionname as unionName, unionname as unionName,
DATE(birthday) AS birthday,
idCard,
unionCode unionCode
from from
vw_user vw_user
@@ -170,9 +173,6 @@ public class UnionReimburseApplyController {
} }
cnd.and(View_user::getId, "!=", SecurityUtil.getUserId()); cnd.and(View_user::getId, "!=", SecurityUtil.getUserId());
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (StrUtil.isBlank(SecurityUtil.getUnionId())) {
return Result.success(java.util.Collections.emptyList());
}
cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId()); cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId());
} }
sql.setCondition(cnd); sql.setCondition(cnd);
@@ -34,7 +34,7 @@ import java.util.List;
*/ */
@IocBean @IocBean
@At("/platform/unionReimburse/certifierUserSign") @At("/platform/unionReimburse/certifierUserSign")
@Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}") @Ok("json:full")
@Api("证明人签字") @Api("证明人签字")
@Slf4j @Slf4j
public class UnionReimburseCertifierUserSignController { public class UnionReimburseCertifierUserSignController {
@@ -68,7 +68,7 @@ import java.util.HashMap;
@IocBean @IocBean
@At("/platform/unionReimburse/mine") @At("/platform/unionReimburse/mine")
@Api("工会报销我的") @Api("工会报销我的")
@Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}") @Ok("json:full")
@Slf4j @Slf4j
public class UnionReimburseMineController { public class UnionReimburseMineController {
@@ -29,7 +29,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/review") @At("/platform/unionReimburse/review")
@Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}") @Ok("json:full")
@Api("工会报销审核") @Api("工会报销审核")
public class UnionReimburseReviewController { public class UnionReimburseReviewController {
@@ -38,7 +38,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/statistics") @At("/platform/unionReimburse/statistics")
@Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}") @Ok("json:full")
@Api("慰问统计") @Api("慰问统计")
@Slf4j @Slf4j
public class UnionReimburseStatisticsController { public class UnionReimburseStatisticsController {
@@ -4,12 +4,10 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.sys.services.SysFileService; import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub;
@@ -127,37 +125,6 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (oldRecord == null) { if (oldRecord == null) {
return Result.error("未找到对应的报销记录"); return Result.error("未找到对应的报销记录");
} }
if (!isAdmin() && !Objects.equals(oldRecord.getUserId(), SecurityUtil.getUserId())) {
return Result.error("无权修改此报销申请");
}
}
// 人员身份和敏感资料由服务端补齐,避免前端删减字段导致丢失或被篡改。
if (oldRecord == null) {
View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
if (handler == null) {
return Result.error("经办人信息不存在");
}
unionReimburse.setUserId(handler.getId());
unionReimburse.setUserName(handler.getUsername());
unionReimburse.setLoginName(handler.getLoginname());
unionReimburse.setUnitId(handler.getUnitId());
unionReimburse.setUnitName(handler.getUnitName());
unionReimburse.setUnionId(handler.getUnionId());
unionReimburse.setUnionName(handler.getUnionName());
} else {
// 修改历史单据时保留原经办组织,避免人员调动改变经费归属。
unionReimburse.setUserId(oldRecord.getUserId());
unionReimburse.setUserName(oldRecord.getUserName());
unionReimburse.setLoginName(oldRecord.getLoginName());
unionReimburse.setUnitId(oldRecord.getUnitId());
unionReimburse.setUnitName(oldRecord.getUnitName());
unionReimburse.setUnionId(oldRecord.getUnionId());
unionReimburse.setUnionName(oldRecord.getUnionName());
}
Result recipientResult = fillCondolencePersonalData(unionReimburse, oldRecord);
if (recipientResult != null) {
return recipientResult;
} }
normalizeInvoiceDetails(unionReimburse); normalizeInvoiceDetails(unionReimburse);
@@ -212,104 +179,14 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse == null) { if (unionReimburse == null) {
return null; return null;
} }
if (!canReadApply(unionReimburse)) {
throw new BaseException("无权查看此报销申请");
}
List<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query( List<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query(
UnionReimburseInvoiceDetail.class, UnionReimburseInvoiceDetail.class,
Cnd.where("reimburseId", "=", id).asc("createdAt") Cnd.where("reimburseId", "=", id).asc("createdAt")
); );
unionReimburse.setInvoiceDetails(invoiceDetails == null ? new ArrayList<>() : invoiceDetails); unionReimburse.setInvoiceDetails(invoiceDetails == null ? new ArrayList<>() : invoiceDetails);
// 申请页和详情页共用此接口,原始身份资料仅保留在服务端业务记录中。
unionReimburse.setCondolenceIdCard(null);
unionReimburse.setCondolenceBirthday(null);
unionReimburse.setCondolenceMobile(null);
return unionReimburse; return unionReimburse;
} }
/** 保留历史单据的身份资料快照,新选人员须符合人员查询的数据范围。 */
private Result fillCondolencePersonalData(UnionReimburse form, UnionReimburse oldRecord) {
form.setCondolenceIdCard(null);
form.setCondolenceBirthday(null);
form.setCondolenceMobile(null);
if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) {
return null;
}
if (StrUtil.isBlank(form.getCondolenceUserId())) {
return null;
}
if (oldRecord != null && Objects.equals(oldRecord.getCondolenceUserId(), form.getCondolenceUserId())) {
form.setCondolenceIdCard(oldRecord.getCondolenceIdCard());
form.setCondolenceBirthday(oldRecord.getCondolenceBirthday());
form.setCondolenceMobile(oldRecord.getCondolenceMobile());
form.setCondolenceUnionId(oldRecord.getCondolenceUnionId());
form.setCondolenceUnitId(oldRecord.getCondolenceUnitId());
form.setCondolenceUserName(oldRecord.getCondolenceUserName());
form.setCondolenceLoginName(oldRecord.getCondolenceLoginName());
form.setCondolenceUnitName(oldRecord.getCondolenceUnitName());
form.setCondolenceUnionName(oldRecord.getCondolenceUnionName());
form.setCondolenceUnionCode(oldRecord.getCondolenceUnionCode());
form.setCondolenceSex(oldRecord.getCondolenceSex());
return null;
}
View_user user = dao().fetch(View_user.class, Cnd.where("id", "=", form.getCondolenceUserId()));
if (user == null || Objects.equals(user.getId(), SecurityUtil.getUserId())
|| (!isAdmin() && (StrUtil.isBlank(SecurityUtil.getUnionId())
|| !Objects.equals(SecurityUtil.getUnionId(), user.getUnionId())))) {
return Result.error("无权选择此慰问对象");
}
form.setCondolenceUserName(user.getUsername());
form.setCondolenceLoginName(user.getLoginname());
form.setCondolenceUnitId(user.getUnitId());
form.setCondolenceUnitName(user.getUnitName());
form.setCondolenceUnionId(user.getUnionId());
form.setCondolenceUnionName(user.getUnionName());
form.setCondolenceUnionCode(user.getUnionCode());
form.setCondolenceSex(user.getSex());
form.setCondolenceIdCard(user.getIdCard());
form.setCondolenceBirthday(user.getBirthday() == null ? null : DateUtil.formatDate(user.getBirthday()));
form.setCondolenceMobile(user.getMobile());
return null;
}
/** 详情访问沿用经办、审核、统计和流程参与范围,不能仅凭单据 ID 查询。 */
private boolean canReadApply(UnionReimburse form) {
String userId = SecurityUtil.getUserId();
if (StrUtil.isBlank(userId)) {
return false;
}
if (isAdmin() || userId.equals(form.getUserId())) {
return true;
}
if (form.getStateId() != 1 && (AuthUtil.hasPermission("unionReimburse.review")
|| AuthUtil.hasPermission("h5.unionReimburse.review"))) {
return true;
}
if ((form.getStateId() == 2 || form.getStateId() == 3)
&& StrUtil.isNotBlank(SecurityUtil.getUnionId())
&& Objects.equals(SecurityUtil.getUnionId(), form.getCondolenceUnionId())
&& (AuthUtil.hasPermission("unionReimburse.statistics")
|| AuthUtil.hasPermission("h5.unionReimburse.statistics"))) {
return true;
}
// 旧流程单据允许任务参与人和抄送人回看,关系必须由数据库确认。
Sql sql = Sqls.create("""
SELECT COUNT(*) FROM wf_process_instance i
WHERE i.businessNo = @id AND (
EXISTS (SELECT 1 FROM wf_process_task t
INNER JOIN wf_process_task_actor a ON a.processTaskId = t.id
WHERE t.processInstanceId = i.id AND a.actorId = @userId)
OR EXISTS (SELECT 1 FROM wf_process_cc_instance c
WHERE c.processInstanceId = i.id AND c.actorId = @userId)
)
""");
sql.setParam("id", form.getId());
sql.setParam("userId", userId);
sql.setCallback(Sqls.callback.integer());
dao().execute(sql);
return sql.getInt() > 0;
}
@Override @Override
public void deleteApply(String id) { public void deleteApply(String id) {
if (StrUtil.isBlank(id)) { if (StrUtil.isBlank(id)) {
@@ -365,11 +242,14 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
u.username AS userName, u.username AS userName,
u.loginname AS loginName, u.loginname AS loginName,
u.sex, u.sex,
u.mobile,
u.technicalTitle, u.technicalTitle,
IFNULL(u.unitname, '暂无') AS unitName, IFNULL(u.unitname, '暂无') AS unitName,
u.unitid AS unitId, u.unitid AS unitId,
u.unionid AS unionId, u.unionid AS unionId,
u.unionname AS unionName, u.unionname AS unionName,
DATE(u.birthday) AS birthday,
u.idCard,
u.unionCode u.unionCode
FROM FROM
vw_user u vw_user u
@@ -1,66 +1,23 @@
package com.budwk.app.zhgh.democratic.proposal.controller.dashboard; package com.budwk.app.zhgh.democratic.proposal.controller.dashboard;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.flow.engine.model.ProcessModel;
import com.budwk.app.flow.engine.model.TaskModel;
import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalInfo;
import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService; import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@IocBean @IocBean
@At("/platform/proposal/dashboard") @At("/platform/proposal/dashboard")
@Api("提案数据看板") @Api("提案数据看板")
@Ok("json:full") @Ok("json:full")
public class ProposalDashboardController { public class ProposalDashboardController {
/**
* 工作台表格允许排序的字段映射,值为查询中的真实字段或安全别名。
*/
private static final Map<String, String> DASHBOARD_ORDER_COLUMNS = Map.ofEntries(
Map.entry("code", "info.code"),
Map.entry("name", "info.name"),
Map.entry("createUserName", "info.createUserName"),
Map.entry("typeName", "typeName"),
Map.entry("delegationName", "delegationName"),
Map.entry("caseFilingResult", "info.caseFilingResult"),
Map.entry("isConsolidation", "info.isConsolidation"),
Map.entry("masterUnitName", "masterUnitName"),
Map.entry("slaveUnitNames", "slaveUnitNames"),
Map.entry("curTaskName", "curTaskName"),
Map.entry("instanceState", "instanceState")
);
@Inject @Inject
private ProposalCommonService proposalCommonService; private ProposalCommonService proposalCommonService;
@Inject
private ProcessDefineService processDefineService;
@Inject
private SysDictService sysDictService;
@Inject
private Dao dao;
@At("") @At("")
@Ok("beetl:/platform/zhgh/democratic/proposal/dashboard/index.html") @Ok("beetl:/platform/zhgh/democratic/proposal/dashboard/index.html")
@@ -68,147 +25,30 @@ public class ProposalDashboardController {
public void index() { public void index() {
} }
/**
* 接收届次ID查询卡片;Result.data 为卡片列表,含 count 或 todoCount/doneCount。
* @param sessionId 教代会届次ID;为空时卡片数量为零
* @return 统一结果对象,data 为各节点和立案结果统计
*/
@At @At
@SaCheckPermission("proposal.dashboard") @SaCheckPermission("proposal.dashboard")
public Result listNode(@Param("sessionId") String sessionId) { public Result listNode(@Param("sessionId") String sessionId) {
List<NutMap> nodes = new ArrayList<>(); return Result.success(proposalCommonService.listDashboardNodes(sessionId));
// 提案总数
nodes.add(NutMap.NEW().addv("name", "提案总数").addv("id", "total").addv("type", "total").addv("count", 0));
// 节点
ProcessDefine define = processDefineService.getLastByName("JDHTA_NC");
ProcessModel processModel = processDefineService.processDefineToModel(define);
// 按流程图的连线关系遍历任务节点,保证看板展示顺序与流程图一致。
List<TaskModel> taskModels = processModel.getModels(TaskModel.class);
List<String> excludeNodes = new ArrayList<>(3);
excludeNodes.add("撰写提案");
excludeNodes.add("邀请附议人");
excludeNodes.add("提案附议");
// opinion_unit_reply、two_unit_reply 为临时屏蔽的承办单位答复节点,不在看板展示。
List<String> hiddenNodeIds = List.of("opinion_unit_reply", "two_unit_reply");
List<NutMap> taskNodes = new ArrayList<>(taskModels.stream()
.filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
.filter(taskModel -> !hiddenNodeIds.contains(taskModel.getName()))
.map(taskModel -> NutMap.NEW()
.addv("name", taskModel.getDisplayName())
.addv("id", taskModel.getName())
.addv("type", "task")
.addv("count", 0)
.addv("todoCount", 0)
.addv("doneCount", 0)
).toList());
// 立案结果
List<Sys_dict> caseFilingResult = sysDictService.getSubListByCode("PROPOSAL_CASE_FILING_RESULT");
List<NutMap> caseFilingResultNodes = new ArrayList<>();
for (Sys_dict dict : caseFilingResult) {
caseFilingResultNodes.add(NutMap.NEW()
.addv("name", dict.getName())
.addv("id", dict.getCode())
.addv("type", "caseFilingResult")
.addv("count", 0));
}
// 立案结果需展示在委员会确认承办单位之前,反馈评价统一固定到看板末尾。
List<NutMap> feedbackNodes = taskNodes.stream()
.filter(taskNode -> "feedback".equals(taskNode.getString("id")))
.toList();
taskNodes.removeIf(taskNode -> "feedback".equals(taskNode.getString("id")));
int committeeFilingUnitIndex = taskNodes.size();
for (int i = 0; i < taskNodes.size(); i++) {
if ("committeeFilingUnit".equals(taskNodes.get(i).getString("id"))) {
committeeFilingUnitIndex = i;
break;
}
}
taskNodes.addAll(committeeFilingUnitIndex, caseFilingResultNodes);
nodes.addAll(taskNodes);
nodes.addAll(feedbackNodes);
Map<String, NutMap> taskProposalCounts = proposalCommonService.countDashboardTaskProposals(sessionId);
for (NutMap node : nodes) {
if (node.getString("type").equals("task")) {
NutMap taskCount = taskProposalCounts.get(node.getString("id"));
node.put("todoCount", taskCount == null ? 0 : taskCount.getLong("todoCount", 0L));
node.put("doneCount", taskCount == null ? 0 : taskCount.getLong("doneCount", 0L));
} else if (node.getString("type").equals("total")) {
int count = dao.count(ProposalInfo.class, Cnd.where(ProposalInfo::getSessionId, "=", sessionId));
node.put("count", count);
} else if (node.getString("type").equals("caseFilingResult")) {
int count = dao.count(ProposalInfo.class, Cnd.where(ProposalInfo::getSessionId, "=", sessionId).and(ProposalInfo::getCaseFilingResult, "=", node.getString("id")));
node.put("count", count);
}
}
return Result.success(nodes);
} }
/**
* 接收看板筛选参数,返回与卡片统计范围一致的提案分页数据。
* @param pageForm pageNumber/pageSize 为页码和每页条数,pageOrderName/pageOrderBy 为排序参数
* @param sessionId 教代会届次ID;为空时返回空列表
* @param selectNodeId task 节点编码或立案结果字典编码
* @param selectNodeType task、total 或 caseFilingResult;为空时按总数查询
* @param selectNodeMode done 表示已办,其余值表示待办
* @return Result.data.list 为提案列表,Result.data.totalCount 为筛选后的总数
*/
@At @At
@SaCheckPermission("proposal.dashboard") @SaCheckPermission("proposal.dashboard")
public Result pageData(PageForm pageForm, String sessionId, String selectNodeId, String selectNodeType, String selectNodeMode) { public Result pageData(PageForm pageForm, String sessionId, String selectNodeId, String selectNodeType, String selectNodeMode) {
Sql sql = Sqls.create(""" return Result.success(proposalCommonService.pageDashboardData(pageForm, sessionId,
SELECT selectNodeId, selectNodeType, selectNodeMode));
info.*,
type.name AS typeName,
tcs.fullName AS sessionName,
tcd.`name` AS delegationName,
ins.id AS instanceId,
ins.businessNo,
ins.state instanceState,
ins.variable instanceVariable,
ins.processDefineId instanceProcessDefineId,
t.displayName curTaskName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 1 THEN pru.unitName END) AS masterUnitName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 0 THEN pru.unitName END) AS slaveUnitNames
FROM
proposal_info info
LEFT JOIN proposal_type type on type.id = info.typeId
LEFT JOIN teacher_congress_session tcs on tcs.id = info.sessionId
LEFT JOIN teacher_congress_delegation tcd on tcd.id = info.delegationId
LEFT JOIN teacher_congress_delegate tcde on tcde.loginName = info.createUserLoginName
LEFT JOIN wf_process_instance ins ON ins.businessNo = info.id
LEFT JOIN proposal_reply_unit pru ON pru.proposalId = info.id
LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = @taskState
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("info.sessionId", "=", sessionId);
cnd.groupBy("info.id");
// 默认显示进行中的任务
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
if (StrUtil.isNotBlank(selectNodeType) && StrUtil.isNotBlank(selectNodeId)) {
switch (selectNodeType) {
case "task":
cnd.and("t.taskName", "=", selectNodeId);
if (selectNodeMode.equals("todo")) {
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
} else {
sql.setParam("taskState", ProcessTaskStateEnum.FINISHED.getCode());
}
break;
case "total":
break;
case "caseFilingResult":
cnd.and("info.caseFilingResult", "=", selectNodeId);
break;
}
}
// 根据工作台字段白名单追加排序,未选择排序时保持原有查询顺序。
proposalCommonService.applySafePageOrder(cnd, pageForm, DASHBOARD_ORDER_COLUMNS);
sql.setCondition(cnd);
Pagination pagination = proposalCommonService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pagination);
} }
} }
@@ -288,4 +288,22 @@ public interface ProposalCommonService extends BaseService<ProposalInfo> {
* @return 键为流程节点编码、值为待办和已办数量的统计结果 * @return 键为流程节点编码、值为待办和已办数量的统计结果
*/ */
Map<String, NutMap> countDashboardTaskProposals(String sessionId); Map<String, NutMap> countDashboardTaskProposals(String sessionId);
/**
* 查询看板卡片,统计范围与列表一致。
* @param sessionId 届次ID;为空时数量为零
* @return 包含节点编码、类型、名称和统计数量的卡片列表
*/
List<NutMap> listDashboardNodes(String sessionId);
/**
* 查询看板提案分页数据。
* @param pageForm 分页及排序参数
* @param sessionId 届次ID;为空时返回空列表
* @param selectNodeId 流程节点编码或立案结果编码
* @param selectNodeType task、total 或 caseFilingResult
* @param selectNodeMode done 表示已办,其余值表示待办
* @return 分页对象,list 为提案记录,totalCount 为相同条件下的提案总数
*/
Pagination pageDashboardData(PageForm pageForm, String sessionId, String selectNodeId,
String selectNodeType, String selectNodeMode);
} }
@@ -23,6 +23,10 @@ import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil; import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.engine.model.ProcessModel;
import com.budwk.app.flow.engine.model.TaskModel;
import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.engine.util.FlowUtil; import com.budwk.app.flow.engine.util.FlowUtil;
import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
@@ -82,6 +86,24 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService { public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService {
/**
* 工作台表格允许排序的字段映射,值为查询中的真实字段或安全别名。
*/
private static final Map<String, String> DASHBOARD_ORDER_COLUMNS = Map.ofEntries(
Map.entry("code", "info.code"),
Map.entry("name", "info.name"),
Map.entry("createUserName", "info.createUserName"),
Map.entry("typeName", "typeName"),
Map.entry("delegationName", "delegationName"),
Map.entry("caseFilingResult", "info.caseFilingResult"),
Map.entry("caseFilingType", "info.caseFilingType"),
Map.entry("isConsolidation", "info.isConsolidation"),
Map.entry("masterUnitName", "masterUnitName"),
Map.entry("slaveUnitNames", "slaveUnitNames"),
Map.entry("curTaskName", "curTaskName"),
Map.entry("instanceState", "instanceState")
);
/** 确定立案提案的 Word 模板编码。 */ /** 确定立案提案的 Word 模板编码。 */
private static final String PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE = "proposal_confirm_filing"; private static final String PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE = "proposal_confirm_filing";
@@ -187,6 +209,8 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
@Inject @Inject
private SysDictService sysDictService; private SysDictService sysDictService;
@Inject @Inject
private ProcessDefineService processDefineService;
@Inject
private SysUserService sysUserService; private SysUserService sysUserService;
@Inject @Inject
private TeacherCongressDelegateService teacherCongressDelegateService; private TeacherCongressDelegateService teacherCongressDelegateService;
@@ -506,6 +530,216 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
return sql; return sql;
} }
/**
* 获取提案看板卡片,沿用现有节点顺序,数量与下方列表共用同一数据范围。
* @param sessionId 教代会届次ID;为空时所有卡片数量为零
* @return 卡片列表;任务卡片含 todoCount/doneCount,其他卡片含 count
*/
@Override
public List<NutMap> listDashboardNodes(String sessionId) {
List<NutMap> nodes = new ArrayList<>();
// 提案总数
nodes.add(NutMap.NEW().addv("name", "提案总数").addv("id", "total").addv("type", "total").addv("count", 0));
// 节点
ProcessDefine define = processDefineService.getLastByName("JDHTA_NC");
ProcessModel processModel = processDefineService.processDefineToModel(define);
// 按流程图的连线关系遍历任务节点,保证看板展示顺序与流程图一致。
List<TaskModel> taskModels = processModel.getModels(TaskModel.class);
List<String> excludeNodes = new ArrayList<>(3);
excludeNodes.add("撰写提案");
excludeNodes.add("邀请附议人");
excludeNodes.add("提案附议");
// opinion_unit_reply、two_unit_reply 为临时屏蔽的承办单位答复节点,不在看板展示。
List<String> hiddenNodeIds = List.of("opinion_unit_reply", "two_unit_reply");
List<NutMap> taskNodes = new ArrayList<>(taskModels.stream()
.filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
.filter(taskModel -> !hiddenNodeIds.contains(taskModel.getName()))
.map(taskModel -> NutMap.NEW()
.addv("name", taskModel.getDisplayName())
.addv("id", taskModel.getName())
.addv("type", "task")
.addv("count", 0)
.addv("todoCount", 0)
.addv("doneCount", 0)
).toList());
// 立案结果
List<Sys_dict> caseFilingResult = sysDictService.getSubListByCode("PROPOSAL_CASE_FILING_RESULT");
List<NutMap> caseFilingResultNodes = new ArrayList<>();
for (Sys_dict dict : caseFilingResult) {
caseFilingResultNodes.add(NutMap.NEW()
.addv("name", dict.getName())
.addv("id", dict.getCode())
.addv("type", "caseFilingResult")
.addv("count", 0));
}
// 立案结果需展示在委员会确认承办单位之前,反馈评价统一固定到看板末尾。
List<NutMap> feedbackNodes = taskNodes.stream()
.filter(taskNode -> "feedback".equals(taskNode.getString("id")))
.toList();
taskNodes.removeIf(taskNode -> "feedback".equals(taskNode.getString("id")));
int committeeFilingUnitIndex = taskNodes.size();
for (int i = 0; i < taskNodes.size(); i++) {
if ("committeeFilingUnit".equals(taskNodes.get(i).getString("id"))) {
committeeFilingUnitIndex = i;
break;
}
}
taskNodes.addAll(committeeFilingUnitIndex, caseFilingResultNodes);
nodes.addAll(taskNodes);
nodes.addAll(feedbackNodes);
Map<String, Long> categoryCounts = countDashboardCategories(sessionId);
Map<String, NutMap> taskProposalCounts = countDashboardTaskProposals(sessionId);
for (NutMap node : nodes) {
if (node.getString("type").equals("task")) {
NutMap taskCount = taskProposalCounts.get(node.getString("id"));
node.put("todoCount", taskCount == null ? 0 : taskCount.getLong("todoCount", 0L));
node.put("doneCount", taskCount == null ? 0 : taskCount.getLong("doneCount", 0L));
} else if (node.getString("type").equals("total")) {
node.put("count", categoryCounts.getOrDefault("total", 0L));
} else if (node.getString("type").equals("caseFilingResult")) {
node.put("count", categoryCounts.getOrDefault(node.getString("id"), 0L));
}
}
return nodes;
}
/**
* 查询看板提案列表,统一使用附议完成后的提案范围,按提案ID去重。
* @param pageForm 分页和白名单排序参数
* @param sessionId 教代会届次ID;为空时返回空列表
* @param selectNodeId 节点编码或立案结果字典编码
* @param selectNodeType task、total、caseFilingResult;为空时查询总数列表
* @param selectNodeMode done 查询已办任务,其余值查询待办任务
* @return 含 list 和 totalCount 的分页结果
*/
@Override
public Pagination pageDashboardData(PageForm pageForm, String sessionId, String selectNodeId,
String selectNodeType, String selectNodeMode) {
Sql sql = Sqls.create("""
SELECT
info.*,
type.name AS typeName,
tcs.fullName AS sessionName,
tcd.`name` AS delegationName,
ins.id AS instanceId,
ins.businessNo,
ins.state instanceState,
ins.variable instanceVariable,
ins.processDefineId instanceProcessDefineId,
t.displayName curTaskName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 1 THEN pru.unitName END) AS masterUnitName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 0 THEN pru.unitName END) AS slaveUnitNames
FROM
proposal_info info
LEFT JOIN proposal_type type on type.id = info.typeId
LEFT JOIN teacher_congress_session tcs on tcs.id = info.sessionId
LEFT JOIN teacher_congress_delegation tcd on tcd.id = info.delegationId
LEFT JOIN teacher_congress_delegate tcde on tcde.loginName = info.createUserLoginName
LEFT JOIN wf_process_instance ins ON ins.businessNo = info.id
LEFT JOIN proposal_reply_unit pru ON pru.proposalId = info.id
LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = @taskState
$condition
""");
Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("info.id");
// 默认显示进行中的任务
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
if (StrUtil.isNotBlank(selectNodeType) && StrUtil.isNotBlank(selectNodeId)) {
switch (selectNodeType) {
case "task":
cnd.and("t.taskName", "=", selectNodeId);
if (!"done".equals(selectNodeMode)) {
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
} else {
sql.setParam("taskState", ProcessTaskStateEnum.FINISHED.getCode());
}
break;
case "total":
break;
case "caseFilingResult":
cnd.and("info.caseFilingResult", "=", selectNodeId);
break;
}
}
// 只接受白名单排序字段,复用现有分页能力。
applySafePageOrder(cnd, pageForm, DASHBOARD_ORDER_COLUMNS);
sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
/**
* 统计看板总数及立案结果;一次按结果分组后求和,确保卡片与列表口径一致。
* @param sessionId 教代会届次ID
* @return total 为提案总数,其他键为立案结果编码
*/
private Map<String, Long> countDashboardCategories(String sessionId) {
Sql sql = Sqls.create("SELECT info.caseFilingResult, COUNT(1) AS proposalCount FROM proposal_info info $condition");
Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("info.caseFilingResult");
sql.setCondition(cnd);
Map<String, Long> counts = new HashMap<>();
long total = 0L;
for (NutMap row : listMap(sql)) {
long count = row.getLong("proposalCount", 0L);
total += count;
if (StrUtil.isNotBlank(row.getString("caseFilingResult"))) {
counts.put(row.getString("caseFilingResult"), count);
}
}
counts.put("total", total);
return counts;
}
/**
* 构建看板统一范围:迁入提案按参考业务阶段排除草稿、附议阶段;新提案按实际WF任务判断。
* 历史迁入实例可能缺少当前任务,不能仅因缺任务而漏算已进入审核的历史提案。
* 新WF提案的旧 stateCode 不随每个节点更新,必须读取有效审核任务,并排除已退回撰写/附议的实例。
* @param sessionId 届次ID;清空选择时返回空范围,避免跨届次查询
* @return 仅包含筛选条件、不包含排序和分组的 Cnd
*/
private Cnd buildDashboardCondition(String sessionId) {
Cnd cnd = Cnd.where("info.sessionId", "=", StrUtil.blankToDefault(sessionId, ""));
cnd.and(new Static("""
EXISTS (
SELECT 1 FROM wf_process_instance scopeIns
WHERE scopeIns.businessNo = info.id
AND (
(JSON_UNQUOTE(JSON_EXTRACT(scopeIns.variable, '$.legacyV3')) = 'true' AND info.stateCode >= 200)
OR (
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(scopeIns.variable, '$.legacyV3')), 'false') <> 'true'
AND EXISTS (
SELECT 1 FROM wf_process_task scopeTask
WHERE scopeTask.processInstanceId = scopeIns.id
AND scopeTask.taskName NOT IN ('startTask', 'invite', 'second')
AND scopeTask.taskState IN (10, 20)
)
AND NOT EXISTS (
SELECT 1 FROM wf_process_task earlyTask
WHERE earlyTask.processInstanceId = scopeIns.id
AND earlyTask.taskName IN ('startTask', 'invite', 'second')
AND earlyTask.taskState = 10
)
)
)
)
"""));
return cnd;
}
/** /**
* 按提案ID去重统计数据看板的流程节点数量。 * 按提案ID去重统计数据看板的流程节点数量。
* 同一提案因撤回、退回等再次经过同一节点时,任务表会保留多条已办记录; * 同一提案因撤回、退回等再次经过同一节点时,任务表会保留多条已办记录;
@@ -524,10 +758,11 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
FROM wf_process_task t FROM wf_process_task t
INNER JOIN wf_process_instance ins ON ins.id = t.processInstanceId INNER JOIN wf_process_instance ins ON ins.id = t.processInstanceId
INNER JOIN proposal_info info ON info.id = ins.businessNo INNER JOIN proposal_info info ON info.id = ins.businessNo
WHERE info.sessionId = @sessionId $condition
GROUP BY t.taskName
"""); """);
sql.setParam("sessionId", sessionId); Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("t.taskName");
sql.setCondition(cnd);
sql.setParam("doingState", ProcessTaskStateEnum.DOING.getCode()); sql.setParam("doingState", ProcessTaskStateEnum.DOING.getCode());
sql.setParam("finishedState", ProcessTaskStateEnum.FINISHED.getCode()); sql.setParam("finishedState", ProcessTaskStateEnum.FINISHED.getCode());
List<NutMap> taskCounts = listMap(sql); List<NutMap> taskCounts = listMap(sql);
@@ -11,6 +11,7 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.eclipse.jetty.util.StringUtil;
import org.nutz.dao.entity.annotation.*; import org.nutz.dao.entity.annotation.*;
import org.nutz.dao.interceptor.annotation.PrevInsert; import org.nutz.dao.interceptor.annotation.PrevInsert;
@@ -160,13 +161,13 @@ public class MallGoods extends BaseModel {
//循环sku判定sku是否有效 //循环sku判定sku是否有效
for (Map<String, Object> sku : goodsOperationDTO.getSkuList()) { for (Map<String, Object> sku : goodsOperationDTO.getSkuList()) {
//判定参数不能为空 //判定参数不能为空
if ((!sku.containsKey("price") || CharSequenceUtil.isEmpty(sku.get("price").toString()) || Convert.toDouble(sku.get("price")) <= 0)) { if ((!sku.containsKey("price") || StringUtil.isEmpty(sku.get("price").toString()) || Convert.toDouble(sku.get("price")) <= 0)) {
throw new BaseException("商品SKU价格不能小于等于0"); throw new BaseException("商品SKU价格不能小于等于0");
} }
// if ((!sku.containsKey("cost") || StringUtil.isEmpty(sku.get("cost").toString()) || Convert.toDouble(sku.get("cost")) <= 0)) { // if ((!sku.containsKey("cost") || StringUtil.isEmpty(sku.get("cost").toString()) || Convert.toDouble(sku.get("cost")) <= 0)) {
// throw new BaseException("商品SKU成本价不能小于等于0"); // throw new BaseException("商品SKU成本价不能小于等于0");
// } // }
if (!sku.containsKey("quantity") || CharSequenceUtil.isEmpty(sku.get("quantity").toString()) || Convert.toInt(sku.get("quantity").toString()) < 0) { if (!sku.containsKey("quantity") || StringUtil.isEmpty(sku.get("quantity").toString()) || Convert.toInt(sku.get("quantity").toString()) < 0) {
throw new BaseException("商品库存数量不能为负数"); throw new BaseException("商品库存数量不能为负数");
} }
sku.values().forEach(i -> { sku.values().forEach(i -> {
@@ -127,6 +127,7 @@ public class CondolenceApplyController {
username as userName, username as userName,
loginname as loginName, loginname as loginName,
sex, sex,
mobile,
technicalTitle, technicalTitle,
IFNULL(unitname, '暂无') as unitName, IFNULL(unitname, '暂无') as unitName,
unitid as unitId, unitid as unitId,
@@ -145,9 +146,6 @@ public class CondolenceApplyController {
} }
if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if(AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_CHAIRMAN.name())) { if(AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_CHAIRMAN.name())) {
if (StrUtil.isBlank(SecurityUtil.getUnionId())) {
return Result.success(java.util.Collections.emptyList());
}
cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId()); cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId());
} else { } else {
cnd.and(View_user::getId, "=", SecurityUtil.getUserId()); cnd.and(View_user::getId, "=", SecurityUtil.getUserId());
+8 -12
View File
@@ -16,20 +16,16 @@ sm4cbc:
keyHex: 00112233445566778899aabbccddeeff keyHex: 00112233445566778899aabbccddeeff
ivHex: 0102030405060708090a0b0c0d0e0f10 ivHex: 0102030405060708090a0b0c0d0e0f10
tomcat: jetty:
contextPath: "" contextPath: /
# 容器错误响应不披露版本;Session Cookie 保持 HttpOnly。 threadpool:
session: idleTimeout: 60000
cookie: minThreads: 10
httponly: true
executor:
maxIdleTime: 60000
minSpareThreads: 10
maxThreads: 500 maxThreads: 500
page: page:
403: /__container_error 403: /error/403.html
404: /__container_error 404: /error/404.html
500: /__container_error 500: /error/500.html
#结合ftp使用,或用nginx代理ftp路径 #结合ftp使用,或用nginx代理ftp路径
#staticPath: /Users/wizzer/temp/files #staticPath: /Users/wizzer/temp/files
#开发模式静态资源 #开发模式静态资源
+2 -2
View File
@@ -14,7 +14,7 @@ var ioc={
// ( 2097152 2M) // ( 2097152 2M)
maxFileSize : 2097152, maxFileSize : 2097152,
// //
nameFilter : '(?i)^(.+[.])(gif|jpg|jpeg|png)$' nameFilter : '^(.+[.])(gif|jpg|png|svg)$'
} }
}, },
imageUpload : { imageUpload : {
@@ -32,7 +32,7 @@ var ioc={
// ( 209715200 200M) // ( 209715200 200M)
maxFileSize : 209715200, maxFileSize : 209715200,
// //
nameFilter : '(?i)^(.+[.])(gif|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$' nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$'
} }
}, },
fileUpload : { fileUpload : {
@@ -187,6 +187,31 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="生日"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceBirthday" label="生日">
<el-input
:value="formData.condolenceBirthday && $moment(formData.condolenceBirthday).isValid() ?$moment(formData.condolenceBirthday).format('YYYY-MM-DD') : ''"
show-word-limit placeholder="请输入生日" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceIdCard" label="身份证号">
<el-input v-model="formData.condolenceIdCard" show-word-limit
placeholder="请输入身份证号" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="联系方式"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceMobile" label="联系方式">
<el-input v-model="formData.condolenceMobile" show-word-limit
placeholder="请输入联系方式" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="慰问类型" <el-descriptions-item label="慰问类型"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'"> v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item label="慰问类型" prop="condolenceTypeId"> <el-form-item label="慰问类型" prop="condolenceTypeId">
@@ -791,6 +816,7 @@ layout("/layouts/platform.html"){
trigger: ["change", "blur"] trigger: ["change", "blur"]
}, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}], }, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}],
bankOfDeposit: [{required: true, message: "请填写开户行", trigger: ["change", "blur"]}], bankOfDeposit: [{required: true, message: "请填写开户行", trigger: ["change", "blur"]}],
condolenceMobile: [{pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur"}],
condolenceTime: [{required: true, message: "请选择慰问时间", trigger: ["change", "blur"]}], condolenceTime: [{required: true, message: "请选择慰问时间", trigger: ["change", "blur"]}],
// invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}], // invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}],
// invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}], // invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}],
@@ -1161,7 +1187,10 @@ layout("/layouts/platform.html"){
unionId, unionId,
unionName, unionName,
unionCode, unionCode,
sex sex,
birthday,
idCard,
mobile
} = user } = user
this.$set(this.formData, "condolenceUserName", userName) this.$set(this.formData, "condolenceUserName", userName)
this.$set(this.formData, "condolenceLoginName", loginName) this.$set(this.formData, "condolenceLoginName", loginName)
@@ -1171,6 +1200,9 @@ layout("/layouts/platform.html"){
this.$set(this.formData, "condolenceUnionName", unionName) this.$set(this.formData, "condolenceUnionName", unionName)
this.$set(this.formData, "condolenceUnionCode", unionCode) this.$set(this.formData, "condolenceUnionCode", unionCode)
this.$set(this.formData, "condolenceSex", sex) this.$set(this.formData, "condolenceSex", sex)
this.$set(this.formData, "condolenceBirthday", birthday)
this.$set(this.formData, "condolenceIdCard", idCard)
this.$set(this.formData, "condolenceMobile", mobile)
} }
}, },
typeChange(val) { typeChange(val) {
@@ -86,6 +86,18 @@ const unionReimburseInfo = {
<span>{{ viewData.condolenceSex }}</span> <span>{{ viewData.condolenceSex }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="生日" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceBirthday && $moment(viewData.condolenceBirthday).isValid() ? $moment(viewData.condolenceBirthday).format('YYYY-MM-DD') : '' }}</span>
</el-descriptions-item>
<el-descriptions-item label="身份证号" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceIdCard }}</span>
</el-descriptions-item>
<el-descriptions-item label="联系方式" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceMobile }}</span>
</el-descriptions-item>
<el-descriptions-item label="慰问类型" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'"> <el-descriptions-item label="慰问类型" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.typeName }}</span> <span>{{ viewData.typeName }}</span>
</el-descriptions-item> </el-descriptions-item>
@@ -206,6 +206,15 @@ layout("/layouts/platform.html"){
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT" :value="row.caseFilingResult"></dict-tag> <dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT" :value="row.caseFilingResult"></dict-tag>
</template> </template>
</el-table-column> </el-table-column>
<!-- 是否重点复用立案类型字段;未立案与类型未填写分开展示,避免空值被误认为普通提案。 -->
<el-table-column label="是否重点" prop="caseFilingType" sortable="custom" width="110">
<template slot-scope="{row}">
<dict-tag v-if="row.caseFilingResult === 'CONFIRM_FILING' && row.caseFilingType"
:options="dict.type.PROPOSAL_CASE_FILING_TYPE" :value="row.caseFilingType"></dict-tag>
<span v-else-if="!row.caseFilingResult || row.caseFilingResult === 'CONFIRM_FILING'">未确定</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="是否并案" prop="isConsolidation" sortable="custom" width="100"> <el-table-column label="是否并案" prop="isConsolidation" sortable="custom" width="100">
<template scope="{row}"> <template scope="{row}">
<el-tag size="mini" v-if="row.isConsolidation" type="success"></el-tag> <el-tag size="mini" v-if="row.isConsolidation" type="success"></el-tag>
@@ -241,7 +250,7 @@ layout("/layouts/platform.html"){
new Vue({ new Vue({
el: '#app', el: '#app',
dicts: ["PROPOSAL_TYPE", "PROPOSAL_CASE_FILING_RESULT"], dicts: ["PROPOSAL_TYPE", "PROPOSAL_CASE_FILING_RESULT", "PROPOSAL_CASE_FILING_TYPE"],
components: { components: {
"proposal-info": PROPOSAL_INFO "proposal-info": PROPOSAL_INFO
}, },
@@ -252,6 +261,8 @@ layout("/layouts/platform.html"){
nodes: [], nodes: [],
nodeDisplayModes: {}, // 存储每个节点的显示模式 nodeDisplayModes: {}, // 存储每个节点的显示模式
pageForm: { pageForm: {
sessionId: null,
selectNodeMode: "todo",
selectNodeId: null, selectNodeId: null,
selectNodeType: null, selectNodeType: null,
pageSize: 5 pageSize: 5
@@ -268,6 +279,10 @@ layout("/layouts/platform.html"){
// 届次改变 // 届次改变
sessionChange() { sessionChange() {
// 切换届次后回到总数卡片,避免沿用上一届次的分类筛选。
this.$set(this.pageForm, "selectNodeId", null)
this.$set(this.pageForm, "selectNodeType", null)
this.$set(this.pageForm, "selectNodeMode", "todo")
this.getNodes() this.getNodes()
this.doSearch() this.doSearch()
}, },
@@ -276,8 +291,8 @@ layout("/layouts/platform.html"){
listSession() { listSession() {
this.$axios.post("/platform/proposal/common/listSession").then((res) => { this.$axios.post("/platform/proposal/common/listSession").then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.sessionOptions = res.data this.$set(this, "sessionOptions", res.data || [])
if (this.sessionOptions) { if (this.sessionOptions.length) {
this.$set(this.pageForm, "sessionId", this.sessionOptions[0].id) this.$set(this.pageForm, "sessionId", this.sessionOptions[0].id)
this.getNodes() this.getNodes()
this.doSearch() this.doSearch()
@@ -290,7 +305,7 @@ layout("/layouts/platform.html"){
getNodes() { getNodes() {
this.$axios.post('/platform/proposal/dashboard/listNode', {sessionId: this.pageForm.sessionId}).then(res => { this.$axios.post('/platform/proposal/dashboard/listNode', {sessionId: this.pageForm.sessionId}).then(res => {
if (res.code === 0) { if (res.code === 0) {
this.nodes = res.data this.$set(this, "nodes", res.data || [])
} }
}) })
}, },
@@ -303,9 +318,9 @@ layout("/layouts/platform.html"){
// 节点选择 // 节点选择
selectNode(node, index) { selectNode(node, index) {
this.pageForm.selectNodeId = node.id this.$set(this.pageForm, "selectNodeId", node.id)
this.pageForm.selectNodeType = node.type this.$set(this.pageForm, "selectNodeType", node.type)
this.pageForm.selectNodeMode = this.getNodeDisplayMode(node.id) this.$set(this.pageForm, "selectNodeMode", this.getNodeDisplayMode(node.id))
this.doSearch() this.doSearch()
}, },
@@ -297,10 +297,11 @@ layout("/layouts/platform.html"){
{{memberNumberData.memberNum}} {{memberNumberData.memberNum}}
<span style="font-size: 14px">&ensp;</span> <span style="font-size: 14px">&ensp;</span>
</div> </div>
<!-- <div class="stat-meta" v-loading="loading.memberNumber">--> <div class="stat-meta" style="text-align: right" v-loading="loading.memberNumber">
<!-- 年同比 {{memberNumberData.ratio}}&ensp;--> 年同比 {{memberNumberData.ratio}}&ensp;
<!-- <i :class="memberNumberData.diff >= 0 ? 'el-icon-top trend-up' : 'el-icon-bottom trend-down'"></i>--> <i v-if="memberNumberData.diff > 0" class="el-icon-top trend-up"></i>
<!-- </div>--> <i v-else-if="memberNumberData.diff < 0" class="el-icon-bottom trend-down"></i>
</div>
</div> </div>
<div class="card stats-card"> <div class="card stats-card">
@@ -393,8 +394,8 @@ layout("/layouts/platform.html"){
memberNumberData: { memberNumberData: {
memberNum: "--", memberNum: "--",
lastYearMemberNum: "--", lastYearMemberNum: "--",
ratio: "0.0%", ratio: "",
diff: 0 diff: null
}, },
memberPercentage: "--", memberPercentage: "--",
unionMember: [] unionMember: []
@@ -403,23 +404,39 @@ layout("/layouts/platform.html"){
}, },
methods: { methods: {
/** /**
* 会员数 * 无需传参;接口返回 memberNum 当前会员数和 lastYearMemberNum 去年归档会员数。
* @returns {Promise<void>} * @returns {Promise<void>} 更新会员数、同比及涨跌方向,请求结束后关闭加载状态。
*/ */
async getMemberNumber() { getMemberNumber() {
this.loading.memberNumber = true this.$set(this.loading, "memberNumber", true)
this.$axios.post("/platform/member/info/board/memberNumber").then((resp) => { return this.$axios.post("/platform/member/info/board/memberNumber").then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
const { const data = resp.data || {}
data: { memberNum, lastYearMemberNum } // 兼容数字字符串,但不把空值、布尔值或异常人数转换成有效的零人数。
} = resp const memberNum = typeof data.memberNum === "number" || (typeof data.memberNum === "string" && data.memberNum.trim() !== "") ? Number(data.memberNum) : NaN
const diff = memberNum - lastYearMemberNum const lastYearMemberNum = typeof data.lastYearMemberNum === "number" || (typeof data.lastYearMemberNum === "string" && data.lastYearMemberNum.trim() !== "") ? Number(data.lastYearMemberNum) : NaN
const ratio = (lastYearMemberNum ? (Math.abs(diff) / lastYearMemberNum) * 100 : (diff / 1) * 10).toFixed(2) + " %" const validMemberNum = Number.isSafeInteger(memberNum) && memberNum >= 0
this.memberNumberData = { memberNum, lastYearMemberNum, diff, ratio } const validLastYearMemberNum = Number.isSafeInteger(lastYearMemberNum) && lastYearMemberNum >= 0
this.loading.memberNumber = false let diff = null
let ratio = "—"
// 去年为零或没有归档人数时无法计算同比;仅在基数有效且大于零时显示涨跌。
if (validMemberNum && validLastYearMemberNum && lastYearMemberNum > 0) {
diff = memberNum - lastYearMemberNum
// 先按万分比对涨跌幅绝对值四舍五入,再恢复负号,避免 4.525% 被直接格式化成 4.52%。
const changePercent = Math.round(Math.abs(diff) * 10000 / lastYearMemberNum) / 100
ratio = diff === 0 ? "持平" : (diff < 0 && changePercent > 0 ? "-" : "") + changePercent.toFixed(2) + " %"
}
this.$set(this, "memberNumberData", {
memberNum: validMemberNum ? memberNum : "--",
lastYearMemberNum: validLastYearMemberNum ? lastYearMemberNum : "--",
diff: diff,
ratio: ratio
})
} else { } else {
this.$message.warning(resp.msg) this.$message.warning(resp.msg)
} }
}).finally(() => {
this.$set(this.loading, "memberNumber", false)
}) })
}, },
/** /**
@@ -484,18 +501,23 @@ layout("/layouts/platform.html"){
}) })
}, },
/** /**
* 会员占比 * 无需传参;接口 data 为 0~1 的会员比例,兼容数值字符串后用于水波图。
* @returns {Promise<void>} * @returns {Promise<void>} 完成比例校验和图表更新,并在请求结束时关闭加载状态。
*/ */
async getMemberPercentage() { getMemberPercentage() {
this.loading.memberPercentage = true this.$set(this.loading, "memberPercentage", true)
this.$axios.post("/platform/member/info/board/memberPercentage").then((resp) => { return this.$axios.post("/platform/member/info/board/memberPercentage").then((resp) => {
this.loading.memberPercentage = false
if (resp.code === 0) { if (resp.code === 0) {
const { data } = resp const { data } = resp
this.memberPercentage = data.toFixed(2) // 空值、非数值和超出比例范围的数据不能作为 0% 展示;合法的零值需要保留。
const percent = typeof data === "number" || (typeof data === "string" && data.trim() !== "") ? Number(data) : NaN
if (!Number.isFinite(percent) || percent < 0 || percent > 1) {
this.$message.warning("会员占比数据异常,请稍后重试")
return
}
this.$set(this, "memberPercentage", percent.toFixed(2))
if (chart.memberPercentageChart) { if (chart.memberPercentageChart) {
chart.memberPercentageChart.changeData(data) chart.memberPercentageChart.changeData(percent)
return return
} }
chart.memberPercentageChart = new G2Plot.Liquid("memberPercentageChart", { chart.memberPercentageChart = new G2Plot.Liquid("memberPercentageChart", {
@@ -507,13 +529,17 @@ layout("/layouts/platform.html"){
length: 128 length: 128
}, },
autoFit: true, autoFit: true,
percent: data, percent: percent,
color: '#217050', color: '#217050',
statistic: { statistic: {
content: { content: {
style: { style: {
fontSize: 16, fontSize: 16,
fill: '#fff', // 色文字 // 色文字配浅色描边,确保低水位白底和高水位绿底上的百分比均可读。
fill: '#1f2937',
fontWeight: 'bold',
stroke: '#fff',
lineWidth: 0.5,
} }
} }
} }
@@ -522,6 +548,8 @@ layout("/layouts/platform.html"){
} else { } else {
this.$message.warning(resp.msg) this.$message.warning(resp.msg)
} }
}).finally(() => {
this.$set(this.loading, "memberPercentage", false)
}) })
}, },
/** /**
@@ -579,6 +579,9 @@ layout("/layouts/platform_h5.html"){
</van-action-sheet> </van-action-sheet>
<van-field label="性别" v-model="formData.condolenceSex" readonly placeholder="自动带出"></van-field> <van-field label="性别" v-model="formData.condolenceSex" readonly placeholder="自动带出"></van-field>
<van-field label="生日" :value="formatDateText(formData.condolenceBirthday)" readonly placeholder="自动带出"></van-field>
<van-field label="身份证号" v-model="formData.condolenceIdCard" readonly placeholder="自动带出"></van-field>
<van-field label="联系方式" v-model="formData.condolenceMobile" readonly placeholder="自动带出"></van-field>
<van-field <van-field
v-model="formData.typeName" v-model="formData.typeName"
@@ -1409,7 +1412,7 @@ layout("/layouts/platform_h5.html"){
}, },
normalizeContactFields() { normalizeContactFields() {
// 提交前裁剪联系方式空格,避免系统手机号带空格时后端格式校验失败。 // 提交前裁剪联系方式空格,避免系统手机号带空格时后端格式校验失败。
const fields = ["mobile"] const fields = ["mobile", "condolenceMobile"]
fields.forEach((fieldName) => { fields.forEach((fieldName) => {
if (this.formData[fieldName]) { if (this.formData[fieldName]) {
this.$set(this.formData, fieldName, String(this.formData[fieldName]).trim()) this.$set(this.formData, fieldName, String(this.formData[fieldName]).trim())
@@ -2206,6 +2209,7 @@ layout("/layouts/platform_h5.html"){
const selectedUser = this.condolenceOptions.find(user => user.id === o.value) const selectedUser = this.condolenceOptions.find(user => user.id === o.value)
if (selectedUser) { if (selectedUser) {
this.fillCondolenceUser(selectedUser) this.fillCondolenceUser(selectedUser)
this.$set(this.formData, "condolenceMobile", selectedUser.mobile)
} }
}, },
onCondolenceTypeConfirm(o) { onCondolenceTypeConfirm(o) {
@@ -2337,6 +2341,9 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "condolenceUnionName", user.unionName) this.$set(this.formData, "condolenceUnionName", user.unionName)
this.$set(this.formData, "condolenceUnionCode", user.unionCode) this.$set(this.formData, "condolenceUnionCode", user.unionCode)
this.$set(this.formData, "condolenceSex", user.sex) this.$set(this.formData, "condolenceSex", user.sex)
this.$set(this.formData, "condolenceBirthday", user.birthday)
this.$set(this.formData, "condolenceIdCard", user.idCard)
this.$set(this.formData, "condolenceMobile", user.mobile)
}, },
// 查询慰问类型 // 查询慰问类型
queryCondolenceType() { queryCondolenceType() {
@@ -100,6 +100,18 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">性别</div> <div class="union-info-label">性别</div>
<div class="union-info-value">{{ viewData.condolenceSex }}</div> <div class="union-info-value">{{ viewData.condolenceSex }}</div>
</div> </div>
<div class="union-info-row">
<div class="union-info-label">生日</div>
<div class="union-info-value">{{ formatDateText(viewData.condolenceBirthday) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">身份证号</div>
<div class="union-info-value">{{ viewData.condolenceIdCard }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">联系方式</div>
<div class="union-info-value">{{ viewData.condolenceMobile }}</div>
</div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">慰问类型</div> <div class="union-info-label">慰问类型</div>
<div class="union-info-value">{{ viewData.typeName || viewData.condolenceTypeName }}</div> <div class="union-info-value">{{ viewData.typeName || viewData.condolenceTypeName }}</div>
@@ -1 +0,0 @@
71eb5eaecf2b7fd91d9dd7409a56314b9f2f132b3dffbb9e13ee56d0999b8c59
@@ -1 +0,0 @@
0d9b4629144a1fd15ed01362ce0dc2c9c5246e4f57250dd95fb52dbbbb75c532
@@ -1,150 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-client</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Asynchronous HTTP Client</name>
<properties>
<bundle-symbolic-name>${project.groupId}.client</bundle-symbolic-name>
<jetty.test.policy.loc>target/test-policy</jetty.test.policy.loc>
<spotbugs.onlyAnalyze>org.eclipse.client.*</spotbugs.onlyAnalyze>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack</id>
<phase>generate-test-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-policy</artifactId>
<version>${jetty-test-policy.version}</version>
<type>jar</type>
<overWrite>true</overWrite>
<includes>**/*.keystore,**/*.pem</includes>
<outputDirectory>${jetty.test.policy.loc}</outputDirectory>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<shadedArtifactAttached>true</shadedArtifactAttached>
<shadedClassifierName>hybrid</shadedClassifierName>
<artifactSet>
<includes>
<include>org.eclipse.jetty:jetty-http</include>
<include>org.eclipse.jetty:jetty-io</include>
<include>org.eclipse.jetty:jetty-util</include>
</includes>
</artifactSet>
<relocations>
<relocation>
<pattern>org.eclipse.jetty.http</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.http</shadedPattern>
</relocation>
<relocation>
<pattern>org.eclipse.jetty.io</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.io</shadedPattern>
</relocation>
<relocation>
<pattern>org.eclipse.jetty.util</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.util</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-io</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-jmx</artifactId>
<version>9.4.57.v20241219</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.kerby</groupId>
<artifactId>kerb-simplekdc</artifactId>
<version>2.1.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- transitive dependency defined as a range and we don't want that -->
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.5.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
</dependency>
</dependencies>
</project>
@@ -1 +0,0 @@
989a013455688dc528a988c41514fbfece0e3354a54398af5a404cda582c94bd
@@ -1 +0,0 @@
dcfe694ef6f27d9d6268e2fb135a1c9a7aabdfa55e7d738fc8df79f573086027
@@ -1 +0,0 @@
90058e595b73d85b96e192b7e6806098f83467680a95466974f445d6706ed682
@@ -1,107 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>jetty-project</artifactId>
<groupId>org.eclipse.jetty</groupId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Http Utility</name>
<properties>
<bundle-symbolic-name>${project.groupId}.http</bundle-symbolic-name>
<spotbugs.onlyAnalyze>org.eclipse.jetty.http.*</spotbugs.onlyAnalyze>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-io</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<instructions>
<Require-Capability>osgi.serviceloader; filter:="(osgi.serviceloader=org.eclipse.jetty.http.HttpFieldPreEncoder)";resolution:=optional;cardinality:=multiple, osgi.extender; filter:="(osgi.extender=osgi.serviceloader.processor)";resolution:=optional, osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)";resolution:=optional</Require-Capability>
<Provide-Capability>osgi.serviceloader; osgi.serviceloader=org.eclipse.jetty.http.HttpFieldPreEncoder</Provide-Capability>
</instructions>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>${jmhjar.name}</finalName>
<shadeTestJar>true</shadeTestJar>
<artifactSet>
<includes>
<include>org.openjdk.jmh:jmh-core</include>
</includes>
</artifactSet>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>org.openjdk.jmh:jmh-core</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1 +0,0 @@
967fff3457fb62eb7485fe303cc4162edc1c02b15498c2f8401fbca263649ab8
@@ -1 +0,0 @@
770e71875fa37effaab5c11e932b9a52c2e019236062575b0403760242b0bf08
@@ -1 +0,0 @@
d757e04e165d940c019e9346c36e9bf8d1a71c8b6b196519bda4200e2a16f5d4
@@ -1,89 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-jaspi</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: JASPI Security</name>
<description>Jetty security infrastructure</description>
<properties>
<bundle-symbolic-name>${project.groupId}.security.jaspi</bundle-symbolic-name>
<spotbugs.onlyAnalyze>org.eclipse.jetty.jaspi.*</spotbugs.onlyAnalyze>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>manifest</goal>
</goals>
<configuration>
<instructions>
<Require-Capability>osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)"</Require-Capability>
<Provide-Capability>osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory</Provide-Capability>
</instructions>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.orbit</groupId>
<artifactId>javax.security.auth.message</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<!-- Must stay on 3.x as 4.x is for Java 11 -->
<version>3.0.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>javax.activation-api</artifactId>
<version>1.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.geronimo.components</groupId>
<artifactId>geronimo-jaspi</artifactId>
<version>2.0.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.geronimo.specs</groupId>
<artifactId>geronimo-jaspic_1.0_spec</artifactId>
</exclusion>
</exclusions>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1 +0,0 @@
8e49cd7df44f050c96d896726129fa5c9d0ac5895261cda774c8c899e617e5a6
@@ -1 +0,0 @@
a89e010985a3a347c700c32a535f92f89b6c2548cabcf5e434563b2a59c37b2d
@@ -1,56 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-security</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Security</name>
<description>Jetty security infrastructure</description>
<properties>
<bundle-symbolic-name>${project.groupId}.security</bundle-symbolic-name>
<spotbugs.onlyAnalyze>org.eclipse.jetty.security.*</spotbugs.onlyAnalyze>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>manifest</goal>
</goals>
<configuration>
<instructions>
<Require-Capability>osgi.serviceloader; filter:="(osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory)";resolution:=optional;cardinality:=multiple, osgi.extender; filter:="(osgi.extender=osgi.serviceloader.processor)";resolution:=optional</Require-Capability>
</instructions>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1 +0,0 @@
c2697118caa818c3f6b17bce98e60f971a56905a96b69eef9b3f34d0bcd1e729
@@ -1 +0,0 @@
e9f670a373dd91b8ce752d1cc601ffb4890a8cffe67a3fb3addca09c9b0d3405
@@ -1 +0,0 @@
d2a396bd5f8f30ed1b6664e4cc07ddd0df9e1ef4a25452291f24e475890ef037
@@ -1,108 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Server Core</name>
<description>The core jetty server artifact.</description>
<properties>
<bundle-symbolic-name>${project.groupId}.server</bundle-symbolic-name>
<spotbugs.onlyAnalyze>org.eclipse.jetty.server.*</spotbugs.onlyAnalyze>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-resources-keystore</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<encoding>UTF-8</encoding>
<outputDirectory>${project.build.directory}/jetty-config-files</outputDirectory>
<resources>
<resource>
<directory>src/main/config</directory>
<includes>
<include>**/**keystore**</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-io</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-xml</artifactId>
<version>9.4.57.v20241219</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-jmx</artifactId>
<version>9.4.57.v20241219</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util-ajax</artifactId>
<version>9.4.57.v20241219</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1 +0,0 @@
e1bf730db18ea8cd98c95bee03673b3601f34322e30e91d3cfd8210c0d7dd00c