初始化提交

This commit is contained in:
liweijie
2026-08-18 09:49:02 +08:00
commit 5a74ab0b0d
1102 changed files with 119160 additions and 0 deletions
@@ -0,0 +1,580 @@
package com.roomroot.jwgl.utils;
import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.vo.accountmanagement.AccountLoginCaptchaVO;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Locale;
import static com.roomroot.jwgl.utils.AccountManagementConstants.ACCOUNT_ID_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.BAD_REQUEST;
/**
* 账户登录图形验证码工具类。
*
* <p>统一负责验证码配置、字符生成、图片绘制、摘要计算、
* Servlet Session 保存以及一次性消费校验。</p>
*/
public final class AccountLoginCaptchaUtil {
/**
* 登录验证码使用的字符集合。
*
* <p>主动排除 0、1、I、O 等容易混淆的字符,降低用户识别错误率。</p>
*/
private static final String LOGIN_CAPTCHA_CHARACTERS =
"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
/**
* 单个登录验证码包含的字符数量。
*/
private static final int LOGIN_CAPTCHA_CODE_LENGTH = 4;
/**
* 登录验证码图片宽度。
*/
private static final int LOGIN_CAPTCHA_IMAGE_WIDTH = 140;
/**
* 登录验证码图片高度。
*/
private static final int LOGIN_CAPTCHA_IMAGE_HEIGHT = 48;
/**
* 登录验证码有效秒数。
*/
private static final int LOGIN_CAPTCHA_EXPIRES_SECONDS = 300;
/**
* Session 中保存验证码编号的属性名称。
*/
private static final String LOGIN_CAPTCHA_ID_SESSION_ATTRIBUTE =
AccountLoginCaptchaUtil.class.getName()
+ ".LOGIN_CAPTCHA_ID";
/**
* Session 中保存验证码答案摘要的属性名称。
*/
private static final String LOGIN_CAPTCHA_HASH_SESSION_ATTRIBUTE =
AccountLoginCaptchaUtil.class.getName()
+ ".LOGIN_CAPTCHA_HASH";
/**
* Session 中保存验证码到期时间的属性名称。
*/
private static final String
LOGIN_CAPTCHA_EXPIRES_AT_SESSION_ATTRIBUTE =
AccountLoginCaptchaUtil.class.getName()
+ ".LOGIN_CAPTCHA_EXPIRES_AT";
/**
* 生成验证码字符、干扰线和文字角度的安全随机数生成器。
*/
private static final SecureRandom LOGIN_CAPTCHA_RANDOM =
new SecureRandom();
/**
* PNG 图片的 Base64 Data URL 前缀。
*/
private static final String PNG_DATA_URL_PREFIX =
"data:image/png;base64,";
/**
* 私有化构造方法,防止验证码工具类被实例化。
*/
private AccountLoginCaptchaUtil() {
}
/**
* 生成验证码并保存到当前请求的 Servlet Session。
*
* @return 验证码编号、PNG 图片数据和有效秒数
*/
public static AccountLoginCaptchaVO generate() {
/*
* 第一步:取得当前请求的 Servlet Session。
* 生成接口允许为匿名用户创建新 Session。
*/
HttpSession session = getCurrentSession(true);
// 第二步:为本次验证码生成不可预测的独立编号。
String captchaId = UuidUtil.getOriginalUUID();
// 第三步:使用安全随机数生成四位易识别验证码内容。
String captchaCode = generateCode();
/*
* 第四步:使用验证码编号和答案共同生成摘要。
* Session 中不保存验证码明文。
*/
byte[] captchaHash = hash(captchaId, captchaCode);
/*
* 第五步:先完成图片生成。
* 图片编码失败时不会覆盖 Session 中原有可用验证码。
*/
String imageDataUrl = generateImageDataUrl(captchaCode);
// 第六步:按照固定有效秒数计算绝对到期时间。
long expiresAt = System.currentTimeMillis()
+ LOGIN_CAPTCHA_EXPIRES_SECONDS * 1000L;
/*
* 第七步:在 Session 锁内整体写入验证码校验材料。
* 刷新验证码会覆盖旧值,并避免并发请求读取到不完整数据。
*/
store(session, captchaId, captchaHash, expiresAt);
// 第八步:创建独立 VO,不向页面返回验证码答案或摘要。
AccountLoginCaptchaVO result = new AccountLoginCaptchaVO();
// 第九步:装配页面显示和后续登录提交所需字段。
result.setCaptchaId(captchaId);
result.setImageDataUrl(imageDataUrl);
result.setExpiresInSeconds(
Integer.valueOf(LOGIN_CAPTCHA_EXPIRES_SECONDS));
// 第十步:返回验证码生成结果。
return result;
}
/**
* 校验并消费当前请求 Session 中的一次性验证码。
*
* <p>无论验证码正确、错误或过期,读取后都会立即删除,
* 防止同一验证码被重复提交或并发重放。</p>
*
* @param captchaId 登录请求提交的验证码编号
* @param captchaCode 用户识别并输入的验证码内容
*/
public static void validateAndConsume(
String captchaId, String captchaCode) {
// 第一步:规范化并校验验证码编号。
String submittedCaptchaId = requiredText(
captchaId,
ACCOUNT_ID_MAX_LENGTH,
"验证码编号");
// 第二步:规范化验证码内容,并统一转换为大写。
String submittedCaptchaCode = requiredText(
captchaCode,
LOGIN_CAPTCHA_CODE_LENGTH,
"验证码").toUpperCase(Locale.ROOT);
if (submittedCaptchaCode.length()
!= LOGIN_CAPTCHA_CODE_LENGTH) {
throw new BusinessException(
BAD_REQUEST, "验证码必须为4个字符");
}
/*
* 第三步:只读取当前请求已经存在的 Session。
* 登录校验不能在验证码缺失时创建新的空 Session。
*/
HttpSession session = getCurrentSession(false);
if (session == null) {
throw new BusinessException(
BAD_REQUEST, "验证码已失效,请重新获取");
}
Object storedCaptchaId;
Object storedCaptchaHash;
Object storedExpiresAt;
try {
/*
* 第四步:在 Session 锁内读取并删除全部校验材料。
* 读取和删除构成原子消费步骤,并发请求不能复用同一验证码。
*/
synchronized (session) {
storedCaptchaId =
session.getAttribute(
LOGIN_CAPTCHA_ID_SESSION_ATTRIBUTE);
storedCaptchaHash =
session.getAttribute(
LOGIN_CAPTCHA_HASH_SESSION_ATTRIBUTE);
storedExpiresAt =
session.getAttribute(
LOGIN_CAPTCHA_EXPIRES_AT_SESSION_ATTRIBUTE);
/*
* 第五步:在进行任何正确性判断前删除验证码。
* 正确、错误、过期和编号不匹配都必须重新获取。
*/
session.removeAttribute(
LOGIN_CAPTCHA_ID_SESSION_ATTRIBUTE);
session.removeAttribute(
LOGIN_CAPTCHA_HASH_SESSION_ATTRIBUTE);
session.removeAttribute(
LOGIN_CAPTCHA_EXPIRES_AT_SESSION_ATTRIBUTE);
}
} catch (IllegalStateException exception) {
// Session 已被并发请求作废时按验证码失效处理。
throw new BusinessException(
BAD_REQUEST, "验证码已失效,请重新获取");
}
/*
* 第六步:校验 Session 属性类型和完整性。
* 缺少任一属性都表示当前 Session 没有可用验证码。
*/
if (!(storedCaptchaId instanceof String)
|| !(storedCaptchaHash instanceof byte[])
|| !(storedExpiresAt instanceof Long)) {
throw new BusinessException(
BAD_REQUEST, "验证码已失效,请重新获取");
}
// 第七步:验证码编号必须与当前 Session 中的编号完全一致。
if (!submittedCaptchaId.equals(storedCaptchaId)) {
throw new BusinessException(
BAD_REQUEST, "验证码已失效,请重新获取");
}
// 第八步:达到绝对到期时间后拒绝校验。
if (System.currentTimeMillis()
>= ((Long) storedExpiresAt).longValue()) {
throw new BusinessException(
BAD_REQUEST, "验证码已过期,请重新获取");
}
// 第九步:使用相同编号和规范化答案计算本次提交摘要。
byte[] submittedCaptchaHash =
hash(submittedCaptchaId, submittedCaptchaCode);
/*
* 第十步:使用常量时间比较验证码摘要。
* 验证码错误独立返回,不应累计账户密码失败次数。
*/
if (!MessageDigest.isEqual(
(byte[]) storedCaptchaHash,
submittedCaptchaHash)) {
throw new BusinessException(
BAD_REQUEST, "验证码错误,请重新获取");
}
}
/**
* 取得当前线程绑定的 Servlet Session。
*
* @param create 没有 Session 时是否允许创建
* @return 当前 Session;不允许创建且当前不存在时返回 null
*/
private static HttpSession getCurrentSession(boolean create) {
// 第一步:读取 Spring 绑定到当前线程的 Servlet 请求属性。
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
/*
* 第二步:生成验证码时必须存在 HTTP 请求上下文。
* 登录校验缺少请求上下文时按没有可用验证码 Session 处理。
*/
if (attributes == null) {
if (create) {
throw new BusinessException(
"无法建立登录验证码会话");
}
return null;
}
// 第三步:按照调用场景取得已有 Session 或允许容器创建新 Session。
return attributes.getRequest().getSession(create);
}
/**
* 将验证码校验材料整体写入 Session。
*
* @param session 当前验证码 Session
* @param captchaId 验证码编号
* @param captchaHash 验证码答案摘要
* @param expiresAt 验证码绝对到期时间
*/
private static void store(
HttpSession session,
String captchaId,
byte[] captchaHash,
long expiresAt) {
try {
/*
* 第一步:在 Session 锁内覆盖完整验证码数据。
* 生成和消费使用同一把锁,保证并发操作的一致性。
*/
synchronized (session) {
session.setAttribute(
LOGIN_CAPTCHA_ID_SESSION_ATTRIBUTE, captchaId);
session.setAttribute(
LOGIN_CAPTCHA_HASH_SESSION_ATTRIBUTE, captchaHash);
session.setAttribute(
LOGIN_CAPTCHA_EXPIRES_AT_SESSION_ATTRIBUTE,
Long.valueOf(expiresAt));
}
} catch (IllegalStateException exception) {
// 第二步:Session 被作废时拒绝返回无法继续使用的验证码。
throw new BusinessException(
BAD_REQUEST, "登录验证码会话已失效,请重新获取");
}
}
/**
* 生成登录验证码字符。
*
* @return 四位不包含易混淆字符的随机验证码
*/
private static String generateCode() {
// 第一步:按照固定验证码长度创建字符串容器。
StringBuilder captchaCode =
new StringBuilder(LOGIN_CAPTCHA_CODE_LENGTH);
/*
* 第二步:每一位均使用 SecureRandom 从允许字符集合中独立选择,
* 避免使用时间戳或普通 Random 产生可预测验证码。
*/
for (int index = 0;
index < LOGIN_CAPTCHA_CODE_LENGTH;
index++) {
int characterIndex =
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_CHARACTERS.length());
captchaCode.append(
LOGIN_CAPTCHA_CHARACTERS.charAt(characterIndex));
}
// 第三步:返回仅在当前生成流程中短暂使用的验证码明文。
return captchaCode.toString();
}
/**
* 使用验证码编号和答案生成 SHA-256 摘要。
*
* @param captchaId 验证码编号
* @param captchaCode 已规范为大写的验证码答案
* @return 验证码答案摘要
*/
private static byte[] hash(
String captchaId, String captchaCode) {
try {
// 第一步:取得 JDK 标准 SHA-256 消息摘要实现。
MessageDigest messageDigest =
MessageDigest.getInstance("SHA-256");
/*
* 第二步:把验证码编号和答案共同加入摘要。
* 不同验证码即使字符相同,也会得到不同摘要。
*/
String captchaValue =
captchaId + ":" + captchaCode;
// 第三步:使用固定 UTF-8 编码生成摘要字节。
return messageDigest.digest(
captchaValue.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException exception) {
/*
* Java 运行环境必须提供 SHA-256。
* 环境异常时不能降级为保存明文验证码。
*/
throw new BusinessException(
"登录验证码生成失败,请重试");
}
}
/**
* 将验证码字符绘制为 PNG Base64 Data URL。
*
* @param captchaCode 待绘制的验证码字符
* @return 可直接用于图片 src 属性的 Data URL
*/
private static String generateImageDataUrl(
String captchaCode) {
/*
* 第一步:创建固定尺寸 RGB 图片。
* 固定宽高可以避免验证码刷新时引起页面布局跳动。
*/
BufferedImage image = new BufferedImage(
LOGIN_CAPTCHA_IMAGE_WIDTH,
LOGIN_CAPTCHA_IMAGE_HEIGHT,
BufferedImage.TYPE_INT_RGB);
// 第二步:取得二维绘图对象,并确保完成后释放图形资源。
Graphics2D graphics = image.createGraphics();
try {
// 第三步:开启文字和图形抗锯齿。
graphics.setRenderingHint(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 第四步:绘制高对比度浅色背景。
graphics.setColor(new Color(248, 249, 250));
graphics.fillRect(
0, 0,
LOGIN_CAPTCHA_IMAGE_WIDTH,
LOGIN_CAPTCHA_IMAGE_HEIGHT);
/*
* 第五步:绘制六条浅色随机干扰线。
* 干扰线保持较浅,避免影响人工识别主要字符。
*/
for (int lineIndex = 0;
lineIndex < 6;
lineIndex++) {
graphics.setColor(new Color(
150 + LOGIN_CAPTCHA_RANDOM.nextInt(70),
150 + LOGIN_CAPTCHA_RANDOM.nextInt(70),
150 + LOGIN_CAPTCHA_RANDOM.nextInt(70)));
graphics.drawLine(
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_WIDTH),
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_HEIGHT),
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_WIDTH),
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_HEIGHT));
}
// 第六步:使用 Java 逻辑字体,避免依赖服务器安装特定字体。
graphics.setFont(new Font(
Font.SANS_SERIF, Font.BOLD, 28));
FontMetrics fontMetrics =
graphics.getFontMetrics();
// 第七步:计算文字基线,使验证码字符垂直居中。
int baseline =
(LOGIN_CAPTCHA_IMAGE_HEIGHT
- fontMetrics.getHeight()) / 2
+ fontMetrics.getAscent();
/*
* 第八步:逐个绘制验证码字符。
* 每个字符使用独立深色和轻微随机旋转。
*/
for (int characterIndex = 0;
characterIndex < captchaCode.length();
characterIndex++) {
int x = 17 + characterIndex * 28;
double angle =
(LOGIN_CAPTCHA_RANDOM.nextDouble() - 0.5D)
* 0.36D;
graphics.setColor(new Color(
20 + LOGIN_CAPTCHA_RANDOM.nextInt(90),
20 + LOGIN_CAPTCHA_RANDOM.nextInt(90),
20 + LOGIN_CAPTCHA_RANDOM.nextInt(90)));
// 保存原变换,防止当前旋转影响后续字符。
AffineTransform originalTransform =
graphics.getTransform();
graphics.rotate(
angle,
x + 10.0D,
baseline - 10.0D);
graphics.drawString(
String.valueOf(
captchaCode.charAt(characterIndex)),
x,
baseline);
graphics.setTransform(originalTransform);
}
/*
* 第九步:补充少量随机噪点和边框。
* 噪点不覆盖大面积区域,兼顾抗识别能力和人工可读性。
*/
for (int pointIndex = 0;
pointIndex < 40;
pointIndex++) {
graphics.setColor(new Color(
100 + LOGIN_CAPTCHA_RANDOM.nextInt(120),
100 + LOGIN_CAPTCHA_RANDOM.nextInt(120),
100 + LOGIN_CAPTCHA_RANDOM.nextInt(120)));
graphics.fillRect(
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_WIDTH),
LOGIN_CAPTCHA_RANDOM.nextInt(
LOGIN_CAPTCHA_IMAGE_HEIGHT),
1,
1);
}
graphics.setColor(new Color(180, 184, 188));
graphics.drawRect(
0,
0,
LOGIN_CAPTCHA_IMAGE_WIDTH - 1,
LOGIN_CAPTCHA_IMAGE_HEIGHT - 1);
} finally {
// 第十步:无论绘制是否成功,都释放 Graphics2D 资源。
graphics.dispose();
}
/*
* 第十一步:把内存图片编码为 PNG,再转换为 Base64 Data URL。
* 页面无需额外图片下载接口即可直接显示验证码。
*/
try (ByteArrayOutputStream outputStream =
new ByteArrayOutputStream()) {
boolean encoded =
ImageIO.write(image, "png", outputStream);
if (!encoded) {
throw new BusinessException(
"登录验证码生成失败,请重试");
}
// 第十二步:返回包含 PNG 媒体类型前缀的完整图片数据。
return PNG_DATA_URL_PREFIX
+ Base64.getEncoder().encodeToString(
outputStream.toByteArray());
} catch (IOException exception) {
// PNG 内存编码异常时返回统一错误。
throw new BusinessException(
"登录验证码生成失败,请重试");
}
}
/**
* 校验并规范化必填验证码文本。
*
* @param value 原始文本
* @param maxLength 最大长度
* @param fieldName 字段名称
* @return 去除首尾空白后的有效文本
*/
private static String requiredText(
String value, int maxLength, String fieldName) {
// 第一步:去除首尾空白,并把空字符串统一转换为 null。
String normalized =
value == null ? null : value.trim();
if (normalized != null && normalized.isEmpty()) {
normalized = null;
}
// 第二步:规范化后为空时返回必填参数错误。
if (normalized == null) {
throw new BusinessException(
BAD_REQUEST, fieldName + "不能为空");
}
// 第三步:超过允许长度时返回参数错误。
if (normalized.length() > maxLength) {
throw new BusinessException(
BAD_REQUEST,
fieldName + "不能超过"
+ maxLength + "个字符");
}
// 第四步:返回通过校验的文本。
return normalized;
}
}
@@ -0,0 +1,228 @@
package com.roomroot.jwgl.utils;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 账户管理常量工具类。
*/
public final class AccountManagementConstants {
/**
* 请求参数错误业务码。
*/
public static final int BAD_REQUEST = 400;
/**
* 数据不存在业务码。
*/
public static final int NOT_FOUND = 404;
/**
* 数据冲突业务码。
*/
public static final int CONFLICT = 409;
/**
* 登录账号规范化唯一索引名称。
*
* <p>业务层使用该固定名称识别数据库并发写入产生的登录账号冲突。</p>
*/
public static final String LOGIN_NAME_UNIQUE_INDEX =
"UK_登录信息表_账号_规范化";
/**
* 统一账号规范化唯一索引名称。
*
* <p>业务层使用该固定名称识别数据库并发写入产生的统一账号冲突。</p>
*/
public static final String UNIFIED_ACCOUNT_UNIQUE_INDEX =
"UK_登录信息表_统一账号_规范化";
/**
* 账户管理接口允许访问的在线角色类别。
*
* <p>该名称与现有系统在线用户表和管理员页面展示的角色类别保持一致。</p>
*/
public static final String ACCOUNT_ADMINISTRATOR_ROLE_CATEGORY =
"教务处超级管理员";
/**
* 当前 Servlet Session 中保存登录记录编号的属性名。
*
* <p>操作日志使用该编号关联产生本次操作的具体登录记录。</p>
*/
public static final String LOGIN_RECORD_ID_SESSION_ATTRIBUTE =
"ACCOUNT_LOGIN_RECORD_ID";
/**
* 账户管理接口未登录时的统一提示。
*/
public static final String AUTHENTICATION_REQUIRED_MESSAGE =
"登录状态已失效,请重新登录";
/**
* 本地账号密码校验失败时的统一提示。
*
* <p>账号不存在和密码错误使用同一提示,避免通过登录接口枚举有效账号。</p>
*/
public static final String ACCOUNT_LOGIN_FAILED_MESSAGE =
"登录账号或密码错误";
/**
* 本地账户处于密码失败临时锁定期时的统一提示。
*/
public static final String ACCOUNT_TEMPORARILY_LOCKED_MESSAGE =
"账户已临时锁定,请稍后再试";
/**
* 账户停用或合并后继续访问系统时的统一提示。
*/
public static final String ACCOUNT_UNAVAILABLE_MESSAGE =
"账户已停用或已合并,请联系管理员";
/**
* 账户管理接口权限不足时的统一提示。
*/
public static final String ACCOUNT_PERMISSION_DENIED_MESSAGE =
"当前账户没有账户管理权限";
/**
* UUID 类型账户编号最大长度。
*/
public static final int ACCOUNT_ID_MAX_LENGTH = 36;
/**
* 账户普通文本字段最大长度。
*/
public static final int ACCOUNT_TEXT_MAX_LENGTH = 50;
/**
* 联系电话最大长度。
*/
public static final int PHONE_MAX_LENGTH = 20;
/**
* 停用和合并原因最大长度。
*/
public static final int REASON_MAX_LENGTH = 200;
/**
* 自我介绍最大长度。
*/
public static final int INTRODUCTION_MAX_LENGTH = 2000;
/**
* 密码最小长度。
*/
public static final int PASSWORD_MIN_LENGTH = 8;
/**
* 密码最大长度。
*/
public static final int PASSWORD_MAX_LENGTH = 50;
/**
* BCrypt 能够完整处理的原始密码最大 UTF-8 字节数。
*/
public static final int PASSWORD_MAX_BYTES = 72;
/**
* 密码复杂度规则。
*
* <p>密码必须同时包含大写字母、小写字母、数字和特殊字符,
* 并且不能包含空白字符。</p>
*/
public static final String PASSWORD_COMPLEXITY_PATTERN =
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)"
+ "(?=.*[^A-Za-z\\d\\s])\\S+$";
/**
* 单个账户最多允许配置的角色身份数量。
*/
public static final int ROLE_ASSIGNMENT_MAX_SIZE = 500;
/**
* 机关人员角色编码。
*/
public static final String ROLE_DEPARTMENT_PERSONNEL =
"DEPARTMENT_PERSONNEL";
/**
* 教员角色编码。
*/
public static final String ROLE_TEACHER = "TEACHER";
/**
* 教研室角色编码。
*/
public static final String ROLE_RESEARCH_OFFICE = "RESEARCH_OFFICE";
/**
* 教学班次角色编码。
*/
public static final String ROLE_TEACHING_CLASS = "TEACHING_CLASS";
/**
* 听查课管理员角色编码。
*/
public static final String ROLE_INSPECTION_ADMINISTRATOR =
"INSPECTION_ADMINISTRATOR";
/**
* 学员角色编码。
*/
public static final String ROLE_STUDENT = "STUDENT";
/**
* 旧用户角色编码。
*/
public static final String ROLE_LEGACY_USER = "LEGACY_USER";
/**
* 按页面展示顺序保存的角色名称映射。
*/
public static final Map<String, String> ROLE_NAMES;
/**
* 按页面展示顺序保存的全部角色类型编码。
*/
public static final List<String> ROLE_TYPES;
static {
/*
* 第一步:使用有序映射登记系统实际存在的七类身份绑定,
* 保证角色类型接口、角色详情和配置选项保持同一展示顺序。
*/
LinkedHashMap<String, String> roleNames = new LinkedHashMap<>();
roleNames.put(ROLE_DEPARTMENT_PERSONNEL, "机关人员");
roleNames.put(ROLE_TEACHER, "教员");
roleNames.put(ROLE_RESEARCH_OFFICE, "教研室");
roleNames.put(ROLE_TEACHING_CLASS, "教学班次");
roleNames.put(ROLE_INSPECTION_ADMINISTRATOR, "听查课管理员");
roleNames.put(ROLE_STUDENT, "学员");
roleNames.put(ROLE_LEGACY_USER, "旧用户");
// 第二步:转换为只读映射,防止运行期间被其他业务代码修改。
ROLE_NAMES = Collections.unmodifiableMap(roleNames);
// 第三步:构造只读角色类型列表,供角色清理、迁移和页面选项统一复用。
ROLE_TYPES = Collections.unmodifiableList(Arrays.asList(
ROLE_DEPARTMENT_PERSONNEL,
ROLE_TEACHER,
ROLE_RESEARCH_OFFICE,
ROLE_TEACHING_CLASS,
ROLE_INSPECTION_ADMINISTRATOR,
ROLE_STUDENT,
ROLE_LEGACY_USER));
}
/**
* 私有化构造方法,防止常量工具类被实例化。
*/
private AccountManagementConstants() {
}
}
@@ -0,0 +1,917 @@
package com.roomroot.jwgl.utils;
import com.roomroot.jwgl.dto.accountmanagement.AccountCreateDTO;
import com.roomroot.jwgl.dto.accountmanagement.AccountUpdateDTO;
import com.roomroot.jwgl.entity.SSOUserAuthSession;
import com.roomroot.jwgl.entity.accountmanagement.AccountRecord;
import com.roomroot.jwgl.entity.accountmanagement.AccountRoleRecord;
import com.roomroot.common.exception.ServiceException;
import com.roomroot.jwgl.vo.accountmanagement.AccountLoginVO;
import com.roomroot.jwgl.vo.accountmanagement.AccountRoleVO;
import com.roomroot.jwgl.vo.accountmanagement.AccountSsoSessionVO;
import com.roomroot.jwgl.vo.accountmanagement.AccountVO;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import jakarta.servlet.http.HttpSession;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.IntSupplier;
import static com.roomroot.jwgl.utils.AccountManagementConstants.ACCOUNT_ID_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.ACCOUNT_TEXT_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.BAD_REQUEST;
import static com.roomroot.jwgl.utils.AccountManagementConstants.CONFLICT;
import static com.roomroot.jwgl.utils.AccountManagementConstants.INTRODUCTION_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.LOGIN_NAME_UNIQUE_INDEX;
import static com.roomroot.jwgl.utils.AccountManagementConstants.PASSWORD_COMPLEXITY_PATTERN;
import static com.roomroot.jwgl.utils.AccountManagementConstants.PASSWORD_MAX_BYTES;
import static com.roomroot.jwgl.utils.AccountManagementConstants.PASSWORD_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.PASSWORD_MIN_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.PHONE_MAX_LENGTH;
import static com.roomroot.jwgl.utils.AccountManagementConstants.ROLE_NAMES;
import static com.roomroot.jwgl.utils.AccountManagementConstants.ROLE_TYPES;
import static com.roomroot.jwgl.utils.AccountManagementConstants.UNIFIED_ACCOUNT_UNIQUE_INDEX;
/**
* 账户管理无状态辅助工具类。
*
* <p>集中处理账户参数校验、文本规范化、对象转换、角色排序、
* SSO 身份候选整理和数据库唯一约束异常识别。该类不访问 Mapper,
* 不负责事务和账户状态变更,账户业务流程仍由 Service 实现层编排。</p>
*/
public final class AccountManagementUtil {
/**
* 构造新增账户持久化记录。
*
* @param accountId 新账户编号
* @param dto 账户新增参数
* @param passwordEncoder 系统统一密码编码器
* @return 完成规范化和默认值初始化的持久化记录
*/
public static AccountRecord buildNewAccount(
String accountId,
AccountCreateDTO dto,
PasswordEncoder passwordEncoder) {
// 第一步:创建只在业务层和 Mapper 之间流转的持久化记录。
AccountRecord account = new AccountRecord();
// 第二步:设置生成的账户编号和经过校验的必填账户资料。
account.setId(accountId);
account.setLoginName(requiredText(
dto.getLoginName(),
ACCOUNT_TEXT_MAX_LENGTH,
"登录账号"));
account.setUserName(requiredText(
dto.getUserName(),
ACCOUNT_TEXT_MAX_LENGTH,
"用户姓名"));
// 第三步:规范化全部可选账户资料字段。
account.setUnifiedAccount(optionalText(
dto.getUnifiedAccount(),
ACCOUNT_TEXT_MAX_LENGTH,
"统一账号"));
account.setPhone(optionalText(
dto.getPhone(), PHONE_MAX_LENGTH, "联系电话"));
account.setIdCard(optionalText(
dto.getIdCard(),
ACCOUNT_TEXT_MAX_LENGTH,
"身份证号码"));
account.setPasswordQuestion(optionalText(
dto.getPasswordQuestion(),
ACCOUNT_TEXT_MAX_LENGTH,
"密码提示问题"));
account.setPasswordAnswer(optionalText(
dto.getPasswordAnswer(),
ACCOUNT_TEXT_MAX_LENGTH,
"密码提示答案"));
account.setIntroduction(optionalText(
dto.getIntroduction(),
INTRODUCTION_MAX_LENGTH,
"自我介绍"));
// 第四步:校验初始密码的长度、字节数和复杂度。
String password = requiredPassword(
dto.getPassword(), "初始密码");
// 第五步:只把 BCrypt 密码摘要写入内部持久化记录。
account.setPassword(passwordEncoder.encode(password));
// 第六步:显式初始化登录统计,避免依赖不同数据库环境的默认值。
// 注意:登录信息表的"上次登录时间"、"本次登录时间"、"失败次数"、"失败时间"均为 NOT NULL 约束,
// 新账户从未登录过时使用当前时间占位(上次/本次使用同一时间),失败次数为0,失败时间使用同一时间占位。
LocalDateTime now = LocalDateTime.now();
account.setLastLoginAt(now);
account.setCurrentLoginAt(now);
account.setFailedAttempts(Integer.valueOf(0));
account.setFailedAt(now);
// 第七步:显式初始化账户启用和未合并状态。
account.setEnabled(Boolean.TRUE);
account.setDisabledReason(null);
account.setDisabledAt(null);
account.setMergedToAccountId(null);
account.setMergedAt(null);
// 第八步:返回可直接写入数据库的账户记录。
return account;
}
/**
* 构造账户修改持久化记录。
*
* @param accountId 账户编号
* @param dto 账户修改参数
* @return 只包含允许普通修改字段的持久化记录
*/
public static AccountRecord buildUpdatedAccount(
String accountId, AccountUpdateDTO dto) {
// 第一步:创建独立持久化记录,避免直接修改查询得到的旧对象。
AccountRecord account = new AccountRecord();
// 第二步:设置不可变账户编号和经过校验的必填资料。
account.setId(accountId);
account.setLoginName(requiredText(
dto.getLoginName(),
ACCOUNT_TEXT_MAX_LENGTH,
"登录账号"));
account.setUserName(requiredText(
dto.getUserName(),
ACCOUNT_TEXT_MAX_LENGTH,
"用户姓名"));
// 第三步:设置经过规范化的可选账户资料。
account.setUnifiedAccount(optionalText(
dto.getUnifiedAccount(),
ACCOUNT_TEXT_MAX_LENGTH,
"统一账号"));
account.setPhone(optionalText(
dto.getPhone(), PHONE_MAX_LENGTH, "联系电话"));
account.setIdCard(optionalText(
dto.getIdCard(),
ACCOUNT_TEXT_MAX_LENGTH,
"身份证号码"));
account.setPasswordQuestion(optionalText(
dto.getPasswordQuestion(),
ACCOUNT_TEXT_MAX_LENGTH,
"密码提示问题"));
account.setPasswordAnswer(optionalText(
dto.getPasswordAnswer(),
ACCOUNT_TEXT_MAX_LENGTH,
"密码提示答案"));
account.setIntroduction(optionalText(
dto.getIntroduction(),
INTRODUCTION_MAX_LENGTH,
"自我介绍"));
// 第四步:返回普通修改接口允许写入的账户记录。
return account;
}
/**
* 执行可能触发账户名称唯一约束的数据库写入。
*
* <p>写入前查询用于尽早返回明确提示,数据库唯一索引用于阻止两个
* 并发事务同时通过查询后写入重复账号。本方法负责统一转换最终冲突。</p>
*
* @param writeOperation 新增或修改账户的数据库写入操作
* @return 数据库实际影响行数
*/
public static int executeAccountNameUniqueWrite(
IntSupplier writeOperation) {
try {
// 第一步:执行新增或修改账户的 Mapper 写入操作。
return writeOperation.getAsInt();
} catch (RuntimeException exception) {
/*
* 第二步:遍历数据库异常链,判断本次失败是否由账户名称
* 唯一索引引起,并尽可能区分登录账号和统一账号。
*/
String conflictMessage =
resolveAccountNameConflictMessage(exception);
// 第三步:非账户名称唯一冲突必须保留原异常,避免掩盖其他数据库故障。
if (conflictMessage == null) {
throw exception;
}
// 第四步:将数据库并发冲突转换为前端可直接处理的 409 业务异常。
throw new ServiceException(conflictMessage, CONFLICT);
}
}
/**
* 安全作废已经存在的 Servlet 会话。
*
* @param session 需要作废的 Servlet 会话
*/
public static void invalidateSession(HttpSession session) {
try {
// 作废会话中的全部认证状态和业务属性。
session.invalidate();
} catch (IllegalStateException ignored) {
/*
* 并发请求可能已经提前作废同一个会话。
* 该状态已经满足安全目标,因此无需再次向外抛出异常。
*/
}
}
/**
* 将内部角色记录转换为对外角色 VO。
*
* @param roleRecord 内部角色持久化记录
* @return 独立账户角色 VO
*/
public static AccountRoleVO toAccountRoleVO(
AccountRoleRecord roleRecord) {
// 第一步:创建独立 VO,避免持久化分组字段向页面暴露。
AccountRoleVO role = new AccountRoleVO();
// 第二步:复制角色身份、数据范围和可用状态。
role.setRoleType(roleRecord.getRoleType());
role.setTargetId(roleRecord.getTargetId());
role.setTargetName(roleRecord.getTargetName());
role.setDataScope(roleRecord.getDataScope());
role.setEnabled(roleRecord.getEnabled());
// 第三步:返回尚待统一规范化和排序的角色 VO。
return role;
}
/**
* 规范化角色类型、补充名称并稳定排序。
*
* @param roles 可修改角色集合
*/
public static void normalizeAndSortRoles(
List<AccountRoleVO> roles) {
// 第一步:从后向前移除空记录,并补充统一角色类型和名称。
for (int index = roles.size() - 1; index >= 0; index--) {
AccountRoleVO role = roles.get(index);
if (role == null) {
roles.remove(index);
continue;
}
// 角色类型统一转换为大写,兼容历史返回值格式差异。
String roleType = normalizeRoleType(role.getRoleType());
role.setRoleType(roleType);
role.setRoleName(ROLE_NAMES.get(roleType));
}
// 第二步:按照角色类型页面顺序、身份名称和身份编号稳定排序。
Collections.sort(roles, new Comparator<AccountRoleVO>() {
@Override
public int compare(
AccountRoleVO left, AccountRoleVO right) {
return compareRoles(left, right);
}
});
}
/**
* 整理 SSO 会话中可以用于匹配本地统一账号的身份候选值。
*
* <p>当前接口资料没有明确 LoginID 和 UserID 中哪一个固定对应本地统一账号,
* 因此按照 LoginID、UserID 的顺序同时保留,并忽略大小写完成去重。</p>
*
* @param ssoSession 已通过存在性和有效期校验的 SSO 会话
* @return 规范化并完成忽略大小写去重的统一账号候选值
*/
public static List<String> collectSsoUnifiedAccountCandidates(
SSOUserAuthSession ssoSession) {
/*
* 第一步:创建有序映射保存候选值。
* 键用于忽略大小写去重,值保留规范化后的原始文字供 Mapper 查询。
*/
Map<String, String> candidatesByKey =
new LinkedHashMap<>();
// 第二步:优先加入 SSO 会话中的 LoginID 候选值。
addSsoUnifiedAccountCandidate(
candidatesByKey, ssoSession.getLoginID());
// 第三步:再加入 SSO 会话中的 UserID 候选值。
addSsoUnifiedAccountCandidate(
candidatesByKey, ssoSession.getUserID());
// 第四步:复制有序映射中的值,返回与内部去重结构相互独立的候选列表。
return new ArrayList<>(candidatesByKey.values());
}
/**
* 将有效 SSO 会话和唯一的本地账户转换为对外返回 VO。
*
* @param ssoSession 已通过有效期校验的 SSO 会话
* @param account 已通过唯一性和可用状态校验的本地账户
* @param roles 已经规范化并完成排序的本地账户角色
* @return 不包含密码和 SSO 会话标识的独立返回 VO
*/
public static AccountSsoSessionVO toAccountSsoSessionVO(
SSOUserAuthSession ssoSession,
AccountRecord account,
List<AccountRoleVO> roles) {
// 第一步:创建独立 VO,避免直接向调用方暴露实体和持久化记录。
AccountSsoSessionVO vo = new AccountSsoSessionVO();
/*
* 第二步:复制 SSO 会话的非敏感核验信息。
* SessionKey 不执行赋值,避免服务端会话标识通过响应再次暴露。
*/
vo.setAppKey(ssoSession.getAppKey());
vo.setLoginId(ssoSession.getLoginID());
vo.setUserId(ssoSession.getUserID());
vo.setUserType(ssoSession.getUserType());
vo.setIpAddress(ssoSession.getIpAddress());
vo.setInvalidTime(ssoSession.getInvalidTime());
vo.setCreateTime(ssoSession.getCreateTime());
/*
* 第三步:只复制本地账户的标识、展示信息和启用状态。
* 密码、密码提示答案、登录失败记录及账户合并审计信息均不返回。
*/
vo.setAccountId(account.getId());
vo.setLoginName(account.getLoginName());
vo.setUserName(account.getUserName());
vo.setUnifiedAccount(account.getUnifiedAccount());
vo.setEnabled(isAccountEnabled(account));
// 第四步:复制角色列表,避免调用方修改业务层内部使用的集合对象。
vo.setRoles(new ArrayList<>(roles));
// 第五步:返回完成安全字段装配的 SSO 本地账户解析结果。
return vo;
}
/**
* 将本地账户持久化记录转换为登录结果 VO。
*
* @param account 已通过登录校验的账户记录
* @param roles 已经整理好的账户角色
* @return 不包含密码和登录会话编号的登录结果
*/
public static AccountLoginVO toAccountLoginVO(
AccountRecord account,
List<AccountRoleVO> roles) {
// 第一步:创建独立登录结果 VO,避免直接暴露内部账户实体。
AccountLoginVO vo = new AccountLoginVO();
// 第二步:复制登录后页面识别账户所需的非敏感字段。
vo.setAccountId(account.getId());
vo.setLoginName(account.getLoginName());
vo.setUserName(account.getUserName());
vo.setUnifiedAccount(account.getUnifiedAccount());
vo.setEnabled(isAccountEnabled(account));
// 第三步:复制独立角色集合,避免调用方修改业务层内部集合。
vo.setRoles(new ArrayList<>(roles));
// 第四步:返回不包含密码和 Servlet Session 编号的登录结果。
return vo;
}
/**
* 将账户持久化记录转换为对外 VO。
*
* @param account 账户持久化记录
* @param roles 账户角色列表
* @return 不包含密码和密码答案的账户 VO
*/
public static AccountVO toAccountVO(
AccountRecord account, List<AccountRoleVO> roles) {
// 第一步:创建独立返回对象,避免对外暴露数据库持久化记录。
AccountVO vo = new AccountVO();
// 第二步:复制账户标识和主要展示资料。
vo.setId(account.getId());
vo.setLoginName(account.getLoginName());
vo.setUserName(account.getUserName());
vo.setUnifiedAccount(account.getUnifiedAccount());
vo.setPhone(account.getPhone());
vo.setIdCard(account.getIdCard());
vo.setPasswordQuestion(account.getPasswordQuestion());
vo.setIntroduction(account.getIntroduction());
// 第三步:复制登录时间和临时失败统计。
vo.setLastLoginAt(account.getLastLoginAt());
vo.setCurrentLoginAt(account.getCurrentLoginAt());
vo.setFailedAttempts(account.getFailedAttempts());
vo.setFailedAt(account.getFailedAt());
// 第四步:复制账户启停和合并审计状态。
vo.setEnabled(isAccountEnabled(account));
vo.setDisabledReason(account.getDisabledReason());
vo.setDisabledAt(account.getDisabledAt());
vo.setMergedToAccountId(account.getMergedToAccountId());
vo.setMergedAt(account.getMergedAt());
/*
* 第五步:复制已经整理的角色 VO。
* 用户密码和密码提示答案没有对应赋值步骤,确保敏感字段不会返回页面。
*/
vo.setRoles(new ArrayList<>(roles));
// 第六步:返回组装完成的账户 VO。
return vo;
}
/**
* 判断账户当前是否启用。
*
* @param account 账户记录
* @return 启用时返回 true
*/
public static boolean isAccountEnabled(AccountRecord account) {
// 旧账户没有专用状态记录时按照默认启用状态处理。
return !Boolean.FALSE.equals(account.getEnabled());
}
/**
* 生成用于提示的账户名称。
*
* @param account 账户记录
* @return 优先使用登录账号的账户展示名称
*/
public static String displayAccountName(AccountRecord account) {
// 第一步:优先使用管理员最容易识别的登录账号。
String displayName = normalizeText(account.getLoginName());
if (displayName != null) {
return displayName;
}
// 第二步:登录账号异常为空时使用账户编号兜底。
return account.getId();
}
/**
* 生成角色绑定唯一键。
*
* @param roleType 角色类型
* @param targetId 身份编号
* @return 不会产生普通文本拼接歧义的唯一键
*/
public static String roleBindingKey(
String roleType, String targetId) {
// 使用不可见分隔符隔离角色类型和身份编号。
return roleType + '\u0000' + targetId;
}
/**
* 校验并规范化必填账户编号。
*
* @param accountId 原始账户编号
* @param fieldName 字段名称
* @return 有效账户编号
*/
public static String requireAccountId(
String accountId, String fieldName) {
// 复用必填文本校验并限制为 UUID 字段长度。
return requiredText(
accountId, ACCOUNT_ID_MAX_LENGTH, fieldName);
}
/**
* 校验并规范化可选账户编号。
*
* @param accountId 原始账户编号
* @param fieldName 字段名称
* @return 规范化账户编号;未填写时返回 null
*/
public static String optionalAccountId(
String accountId, String fieldName) {
// 复用可选文本校验并限制为 UUID 字段长度。
return optionalText(
accountId, ACCOUNT_ID_MAX_LENGTH, fieldName);
}
/**
* 校验并规范化必填角色类型。
*
* @param roleType 原始角色类型
* @return 系统支持的标准大写角色类型
*/
public static String requireRoleType(String roleType) {
// 第一步:去除首尾空白并统一转换为大写。
String normalizedRoleType = normalizeRoleType(roleType);
// 第二步:角色类型为空时返回必填参数错误。
if (normalizedRoleType == null) {
throw new ServiceException("角色类型不能为空",
BAD_REQUEST);
}
// 第三步:不属于系统七类角色时返回参数错误。
if (!ROLE_NAMES.containsKey(normalizedRoleType)) {
throw new ServiceException("不支持的角色类型:" + normalizedRoleType,
BAD_REQUEST);
}
// 第四步:返回标准角色类型。
return normalizedRoleType;
}
/**
* 校验并规范化可选角色类型。
*
* @param roleType 原始角色类型
* @return 标准角色类型;未填写时返回 null
*/
public static String optionalRoleType(String roleType) {
// 第一步:规范化角色类型。
String normalizedRoleType = normalizeRoleType(roleType);
// 第二步:未填写可选角色类型时直接返回 null。
if (normalizedRoleType == null) {
return null;
}
// 第三步:存在值时复用必填角色类型合法性校验。
return requireRoleType(normalizedRoleType);
}
/**
* 统一规范化角色类型。
*
* @param roleType 原始角色类型
* @return 去除空白并转换为大写的角色类型
*/
public static String normalizeRoleType(String roleType) {
// 第一步:使用统一文本规则去除首尾空白和空字符串。
String normalized = normalizeText(roleType);
// 第二步:存在值时按照固定区域规则转换为大写。
return normalized == null
? null
: normalized.toUpperCase(Locale.ROOT);
}
/**
* 校验登录用原始密码。
*
* <p>登录密码不执行 trim,也不重复执行注册时的复杂度校验;
* 只校验必填和长度,最终由 PasswordEncoder.matches 完成摘要比较。</p>
*
* @param password 原始登录密码
* @return 保持原样的原始登录密码
*/
public static String requiredLoginPassword(String password) {
// 复用旧密码校验规则,并保留登录接口原有字段提示。
return requiredExistingPassword(password, "登录密码");
}
/**
* 校验用于身份确认的已有密码。
*
* <p>已有密码不执行 trim,也不要求符合当前新密码复杂度规则,
* 只校验必填和长度,最终由 PasswordEncoder.matches 比较摘要。</p>
*
* @param password 原始已有密码
* @param fieldName 字段名称
* @return 保持原样的已有密码
*/
public static String requiredExistingPassword(
String password, String fieldName) {
// 第一步:拒绝 null、空字符串和只包含空白字符的已有密码。
if (password == null || password.trim().isEmpty()) {
throw new ServiceException(fieldName + "不能为空",
BAD_REQUEST);
}
// 第二步:校验已有密码长度,保持与账户密码字段的输入边界一致。
if (password.length() < PASSWORD_MIN_LENGTH
|| password.length() > PASSWORD_MAX_LENGTH) {
throw new ServiceException(fieldName + "长度必须在8到50个字符之间",
BAD_REQUEST);
}
// 第三步:返回未被 trim 或改写的原始密码,交给 PasswordEncoder 校验。
return password;
}
/**
* 校验必填原始密码。
*
* <p>密码不执行 trim,避免静默改变用户实际输入;
* 空白字符由复杂度规则统一拒绝。</p>
*
* @param password 原始密码
* @param fieldName 字段名称
* @return 通过完整校验的原始密码
*/
public static String requiredPassword(
String password, String fieldName) {
// 第一步:拒绝 null、空字符串和只包含空白字符的密码。
if (password == null || password.trim().isEmpty()) {
throw new ServiceException(fieldName + "不能为空",
BAD_REQUEST);
}
// 第二步:校验密码字符长度,防止过短密码和超长请求。
if (password.length() < PASSWORD_MIN_LENGTH
|| password.length() > PASSWORD_MAX_LENGTH) {
throw new ServiceException(fieldName + "长度必须在8到50个字符之间",
BAD_REQUEST);
}
/*
* 第三步:限制 UTF-8 字节数。
* BCrypt 最多完整处理 72 字节,提前拒绝可以避免不同密码因截断产生相同摘要。
*/
if (password.getBytes(StandardCharsets.UTF_8).length
> PASSWORD_MAX_BYTES) {
throw new ServiceException(fieldName + "的UTF-8编码不能超过72字节",
BAD_REQUEST);
}
/*
* 第四步:校验密码复杂度。
* 密码必须同时包含大写字母、小写字母、数字和特殊字符,
* 并且不能包含空格、换行或制表符等空白字符。
*/
if (!password.matches(PASSWORD_COMPLEXITY_PATTERN)) {
throw new ServiceException(fieldName
+ "必须包含大写字母、小写字母、数字和特殊字符,"
+ "且不能包含空白字符",
BAD_REQUEST);
}
// 第五步:返回保持原样的密码,交由 PasswordEncoder 立即编码。
return password;
}
/**
* 校验并规范化必填文本。
*
* @param value 原始文本
* @param maxLength 最大长度
* @param fieldName 字段名称
* @return 有效文本
*/
public static String requiredText(
String value, int maxLength, String fieldName) {
// 第一步:使用统一可选文本规则完成规范化和长度校验。
String normalized =
optionalText(value, maxLength, fieldName);
// 第二步:规范化后为空时返回必填参数错误。
if (normalized == null) {
throw new ServiceException(fieldName + "不能为空",
BAD_REQUEST);
}
// 第三步:返回通过校验的必填文本。
return normalized;
}
/**
* 校验并规范化可选文本。
*
* @param value 原始文本
* @param maxLength 最大长度
* @param fieldName 字段名称
* @return 规范化文本;未填写时返回 null
*/
public static String optionalText(
String value, int maxLength, String fieldName) {
// 第一步:去除首尾空白,并将空字符串统一转换为 null。
String normalized = normalizeText(value);
// 第二步:未填写可选文本时直接返回 null。
if (normalized == null) {
return null;
}
// 第三步:超过数据库字段长度时返回参数错误。
if (normalized.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过" + maxLength + "个字符",
BAD_REQUEST);
}
// 第四步:返回通过校验的可选文本。
return normalized;
}
/**
* 统一规范化文本。
*
* @param value 原始文本
* @return 去除首尾空白后的文本;无有效内容时返回 null
*/
public static String normalizeText(String value) {
// 原始值为 null 时直接返回 null。
if (value == null) {
return null;
}
// 去除用户输入或数据库文本中的首尾空白。
String normalized = value.trim();
// 去除空白后没有有效内容时统一返回 null。
return normalized.isEmpty() ? null : normalized;
}
/**
* 判断源文本是否包含关键字,并忽略英文字母大小写。
*
* @param source 源文本
* @param keyword 查询关键字
* @return 包含时返回 true
*/
public static boolean containsIgnoreCase(
String source, String keyword) {
// 源文本为空时不能匹配,否则按照固定区域规则转为小写后比较。
return source != null
&& source.toLowerCase(Locale.ROOT)
.contains(keyword.toLowerCase(Locale.ROOT));
}
/**
* 从异常链中解析账户名称唯一冲突提示。
*
* @param exception 数据库写入抛出的运行时异常
* @return 唯一冲突提示;不是账户名称唯一冲突时返回 null
*/
private static String resolveAccountNameConflictMessage(
RuntimeException exception) {
// 第一步:记录异常链中是否出现通用唯一约束冲突标志。
boolean uniqueConstraintViolation = false;
// 第二步:创建已访问异常集合,防止异常原因链异常循环。
Set<Throwable> visitedCauses = new HashSet<>();
// 第三步:从最外层数据库访问异常开始逐层检查原因。
Throwable currentCause = exception;
while (currentCause != null
&& visitedCauses.add(currentCause)) {
// 第四步:优先根据固定索引名称返回准确的字段冲突提示。
String exceptionMessage = currentCause.getMessage();
if (exceptionMessage != null) {
String normalizedMessage =
exceptionMessage.toUpperCase(Locale.ROOT);
if (normalizedMessage.contains(
UNIFIED_ACCOUNT_UNIQUE_INDEX
.toUpperCase(Locale.ROOT))) {
return "统一账号已存在";
}
if (normalizedMessage.contains(
LOGIN_NAME_UNIQUE_INDEX
.toUpperCase(Locale.ROOT))) {
return "登录账号已存在";
}
}
// 第五步:Spring 已转换为重复键异常时记录通用唯一冲突。
if (currentCause instanceof DuplicateKeyException) {
uniqueConstraintViolation = true;
}
/*
* 第六步:兼容未被 Spring 精确转换的达梦 SQLException。
* 仅通过达梦唯一约束特有错误码判断,不使用 SQLSTATE 23 前缀,
* 因为该前缀也覆盖 NOT NULL、外键等非唯一约束,会导致误判。
*/
if (currentCause instanceof SQLException) {
SQLException sqlException =
(SQLException) currentCause;
int errorCode = sqlException.getErrorCode();
if (errorCode == -6602
|| errorCode == -6625
|| errorCode == -6612) {
uniqueConstraintViolation = true;
}
}
// 第七步:继续检查当前异常包装的下一层原始原因。
currentCause = currentCause.getCause();
}
/*
* 第八步:无法从异常文本区分具体索引但确认属于唯一约束冲突时,
* 返回同时覆盖登录账号和统一账号的通用提示。
*/
return uniqueConstraintViolation
? "登录账号或统一账号已存在"
: null;
}
/**
* 向 SSO 统一账号候选映射中加入一个有效身份值。
*
* @param candidatesByKey 忽略大小写的候选值有序映射
* @param candidate SSO 会话中的原始身份值
*/
private static void addSsoUnifiedAccountCandidate(
Map<String, String> candidatesByKey,
String candidate) {
// 第一步:去除候选值首尾空白,并把空内容统一转换为 null。
String normalizedCandidate = normalizeText(candidate);
// 第二步:无有效内容的身份值不能用于查询本地统一账号。
if (normalizedCandidate == null) {
return;
}
// 第三步:使用固定区域规则生成忽略英文字母大小写的去重键。
String candidateKey =
normalizedCandidate.toLowerCase(Locale.ROOT);
// 第四步:同一身份值重复出现时保留最先加入的规范化结果。
candidatesByKey.putIfAbsent(
candidateKey, normalizedCandidate);
}
/**
* 比较两个角色 VO。
*
* @param left 左侧角色
* @param right 右侧角色
* @return 符合 Comparator 约定的比较结果
*/
private static int compareRoles(
AccountRoleVO left, AccountRoleVO right) {
// 第一优先级:按照系统定义的角色类型页面顺序排序。
int roleTypeResult = Integer.compare(
roleTypeOrder(left.getRoleType()),
roleTypeOrder(right.getRoleType()));
if (roleTypeResult != 0) {
return roleTypeResult;
}
// 第二优先级:同一角色类型内按照身份名称排序。
int targetNameResult = compareNullableText(
left.getTargetName(), right.getTargetName());
if (targetNameResult != 0) {
return targetNameResult;
}
// 第三优先级:身份名称相同时按照身份编号保证结果稳定。
return compareNullableText(
left.getTargetId(), right.getTargetId());
}
/**
* 取得角色类型的固定页面顺序。
*
* @param roleType 角色类型
* @return 角色顺序;未知类型排在最后
*/
private static int roleTypeOrder(String roleType) {
// 第一步:查询角色类型在固定列表中的位置。
int index = ROLE_TYPES.indexOf(roleType);
// 第二步:未知历史类型统一排在全部已知类型之后。
return index < 0 ? ROLE_TYPES.size() : index;
}
/**
* 比较两个允许为空的文本。
*
* @param left 左侧文本
* @param right 右侧文本
* @return 符合 Comparator 约定的比较结果
*/
private static int compareNullableText(
String left, String right) {
// 两侧都为空时视为相等。
if (left == null && right == null) {
return 0;
}
// 仅左侧为空时将左侧排在后面。
if (left == null) {
return 1;
}
// 仅右侧为空时将右侧排在后面。
if (right == null) {
return -1;
}
// 优先忽略英文字母大小写比较。
int ignoreCaseResult = left.compareToIgnoreCase(right);
// 忽略大小写后相等时再比较原文本,保证排序结果稳定。
return ignoreCaseResult == 0
? left.compareTo(right)
: ignoreCaseResult;
}
/**
* 私有化构造方法,防止无状态工具类被实例化。
*/
private AccountManagementUtil() {
}
}
@@ -0,0 +1,136 @@
package com.roomroot.jwgl.utils;
/**
* 基础管理功能公共常量工具类。
*
* <p>集中维护部别管理、隶属关系设置和部别人员管理使用的业务码、
* 字段长度、批量处理上限及数据库状态值,避免业务实现类直接声明固定数值。</p>
*/
public final class BasicManagementConstants {
/**
* 请求参数错误业务码。
*/
public static final int BAD_REQUEST = 400;
/**
* 数据不存在业务码。
*/
public static final int NOT_FOUND = 404;
/**
* 无权限业务码。
*/
public static final int FORBIDDEN = 403;
/**
* 数据冲突业务码。
*/
public static final int CONFLICT = 409;
/**
* UUID 等通用标识符的最大长度。
*/
public static final int IDENTIFIER_MAX_LENGTH = 36;
/**
* 教研室编号的最大长度。
*/
public static final int RESEARCH_OFFICE_ID_MAX_LENGTH = 50;
/**
* 教学班次编号的最大长度。
*/
public static final int TEACHING_CLASS_ID_MAX_LENGTH = 50;
/**
* 人员文本字段最大长度。
*/
public static final int PERSONNEL_TEXT_MAX_LENGTH = 50;
/**
* 操作人查询关键字的最大长度。
*/
public static final int OPERATOR_KEYWORD_MAX_LENGTH = 50;
/**
* 操作日志模块名称的最大长度。
*/
public static final int MODULE_NAME_MAX_LENGTH = 20;
/**
* 操作内容查询关键字的最大长度。
*/
public static final int CONTENT_KEYWORD_MAX_LENGTH = 100;
/**
* 请求地址查询关键字的最大长度。
*/
public static final int URL_KEYWORD_MAX_LENGTH = 200;
/**
* 操作日志写入内容的最大长度。
*/
public static final int OPERATION_CONTENT_MAX_LENGTH = 2000;
/**
* 操作日志写入请求地址的最大长度。
*/
public static final int OPERATION_URL_MAX_LENGTH = 200;
/**
* 当前请求中暂存操作日志登录记录编号的属性名。
*
* <p>统一日志拦截器在 Session 作废前保存编号,
* 供请求完成后的自动记录流程继续使用。</p>
*/
public static final String
OPERATION_LOG_LOGIN_RECORD_ID_REQUEST_ATTRIBUTE =
"OPERATION_LOG_LOGIN_RECORD_ID";
/**
* 当前请求是否发生业务处理失败的属性名。
*
* <p>全局异常处理器负责写入该标记,
* 操作日志拦截器在请求完成阶段据此补充失败操作日志。</p>
*/
public static final String
OPERATION_LOG_FAILED_REQUEST_ATTRIBUTE =
"OPERATION_LOG_FAILED";
/**
* 操作日志导出时单次分页查询的最大记录数。
*/
public static final int OPERATION_LOG_EXPORT_BATCH_SIZE = 100;
/**
* 单次批量导入最大人数。
*/
public static final int BATCH_IMPORT_MAX_SIZE = 1000;
/**
* 数据库存储的在职状态。
*/
public static final int ACTIVE_STATUS = 0;
/**
* 数据库存储的离职状态。
*/
public static final int DEPARTED_STATUS = 1;
/**
* 数据库存储的非主官状态。
*/
public static final int NON_PRINCIPAL_STATUS = 0;
/**
* 数据库存储的主官状态。
*/
public static final int PRINCIPAL_STATUS = 1;
/**
* 私有化构造方法,防止常量工具类被实例化。
*/
private BasicManagementConstants() {
}
}
@@ -0,0 +1,105 @@
package com.roomroot.jwgl.utils;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class DateCourseUtil {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 核心方法:传入起止日期,返回日期->星期+12课时分段
* @param startDate 开始日期 yyyy-MM-dd
* @param endDate 结束日期 yyyy-MM-dd
* @return key:日期字符串 value:星期+课时列表
*/
public static Map<String, Object> getDateWeekAndCourseMap(String startDate, String endDate) {
Map<String, Object> resultMap = new HashMap<>();
// 解析起止日期
LocalDate start = LocalDate.parse(startDate, FORMATTER);
LocalDate end = LocalDate.parse(endDate, FORMATTER);
LocalDate currentDate = start;
// 循环遍历区间所有日期(包含首尾)
while (!currentDate.isAfter(end)) {
Map<String, Map<String, Object>> result = new HashMap<>();
Map<String, Object> innerMap = new HashMap<>();
// 1. 设置中文星期
innerMap.put("week", getCnWeek(currentDate.getDayOfWeek()));
// 2. 生成当日12课时,两两拼接
List<String> courseSegList = build12CourseSegment();
innerMap.put("course", courseSegList);
// 日期作为key存入map
String dateStr = currentDate.format(FORMATTER);
resultMap.put(dateStr, innerMap);
// 日期向后+1天
currentDate = currentDate.plusDays(1);
}
return resultMap;
}
/**
* 构建固定12课时,每两节合并一段
* @return [1-2,3-4,5-6,7-8,9-10,11-12]
*/
private static List<String> build12CourseSegment() {
List<String> list = new ArrayList<>();
// 步长2,从1开始
for (int i = 1; i <= 12; i += 2) {
String seg = i + "-" + (i + 1);
list.add(seg);
}
return list;
}
/**
* DayOfWeek 转为中文星期
*/
private static String getCnWeek(DayOfWeek dayOfWeek) {
String week;
switch (dayOfWeek) {
case MONDAY:
week = "星期一";
break;
case TUESDAY:
week = "星期二";
break;
case WEDNESDAY:
week = "星期三";
break;
case THURSDAY:
week = "星期四";
break;
case FRIDAY:
week = "星期五";
break;
case SATURDAY:
week = "星期六";
break;
case SUNDAY:
week = "星期日";
break;
default:
throw new IllegalArgumentException("无效星期");
}
return week;
}
// 测试main方法
public static void main(String[] args) {
Map<String, Object> map = getDateWeekAndCourseMap("2026-07-01", "2026-08-03");
// 遍历打印查看结果
for (Map.Entry<String, Object> entry : map.entrySet()) {
System.out.println("");
}
}
}
@@ -0,0 +1,141 @@
package com.roomroot.jwgl.utils;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* JDK1.8 专用日期工具类,自动解析多种常见日期字符串
* 支持:横杠/斜杠/点分隔、纯数字紧凑格式、10/13位时间戳
*/
public class DateUtils {
// 所有支持的日期格式,可自行扩展
private static final List<String> DATE_PATTERNS;
static {
DATE_PATTERNS = new ArrayList<>();
DATE_PATTERNS.add("yyyy-MM-dd HH:mm:ss");
DATE_PATTERNS.add("yyyy-MM-dd HH:mm");
DATE_PATTERNS.add("yyyy-MM-dd");
DATE_PATTERNS.add("yyyy/MM/dd HH:mm:ss");
DATE_PATTERNS.add("yyyy/MM/dd");
DATE_PATTERNS.add("yyyy.MM.dd");
DATE_PATTERNS.add("yyyyMMddHHmmss");
DATE_PATTERNS.add("yyyyMMdd");
}
/**
* 自动识别多种日期字符串,转为 LocalDateTime
* @param dateStr 日期文本
* @return 解析成功返回LocalDateTime,失败/空返回null
*/
public static LocalDateTime parseAnyDate(String dateStr) {
if (dateStr == null || dateStr.trim().length() == 0) {
return null;
}
String source = dateStr.trim();
// 1. 判断是否为时间戳 10位秒 / 13位毫秒
if (source.matches("^\\d{10,13}$")) {
long ts = Long.parseLong(source);
Instant instant;
if (source.length() == 10) {
instant = Instant.ofEpochSecond(ts);
} else {
instant = Instant.ofEpochMilli(ts);
}
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
// 2. 遍历所有格式尝试解析
for (String pattern : DATE_PATTERNS) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
if (pattern.contains("HH")) {
// 带时分秒
return LocalDateTime.parse(source, formatter);
} else {
// 仅日期,补 00:00:00
LocalDate localDate = LocalDate.parse(source, formatter);
return LocalDateTime.of(localDate, LocalTime.MIN);
}
} catch (DateTimeParseException e) {
// 当前格式不匹配,继续循环
continue;
}
}
// 全部格式匹配失败
return null;
}
/**
* LocalDateTime 格式化输出
* @param dateTime 时间对象
* @param pattern 自定义格式,传null默认 yyyy-MM-dd HH:mm:ss
* @return 格式化字符串
*/
public static String formatDate(LocalDateTime dateTime, String pattern) {
if (dateTime == null) {
return "";
}
String fmt = pattern;
if (fmt == null || fmt.trim().length() == 0) {
fmt = "yyyy-MM-dd HH:mm:ss";
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(fmt);
return dateTime.format(formatter);
}
/**
* 默认格式输出 yyyy-MM-dd HH:mm:ss
*/
public static String formatDate(LocalDateTime dateTime) {
return formatDate(dateTime, null);
}
// ========== 兼容旧 java.util.Date 转换方法(JDK8通用) ==========
/**
* LocalDateTime 转 java.util.Date
*/
public static java.util.Date localDateTimeToDate(LocalDateTime localDateTime) {
if (localDateTime == null) {
return null;
}
Instant instant = localDateTime.atZone(ZoneId.systemDefault()).toInstant();
return java.util.Date.from(instant);
}
/**
* java.util.Date 转 LocalDateTime
*/
public static LocalDateTime dateToLocalDateTime(java.util.Date date) {
if (date == null) {
return null;
}
return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
}
// 测试主方法
public static void main(String[] args) {
String[] testArr = {
"2026-07-21",
"2026/07/21 15:20:30",
"2026.07.21",
"20260721123000",
"1784604600",
"1784604600000",
""
};
for (String s : testArr) {
LocalDateTime dt = parseAnyDate(s);
System.out.println("原始值:" + s + " 解析结果:" + formatDate(dt));
}
}
}
@@ -0,0 +1,107 @@
package com.roomroot.jwgl.utils;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.function.Function;
/**
* Excel 导出工具类。
*/
public class ExcelExportUtil {
private ExcelExportUtil() {
}
/**
* 导出数据为 Excel 字节数组。
*
* @param sheetName 工作表名称
* @param headers 表头数组
* @param data 数据列表
* @param getters 每列对应的数据取值函数数组
* @param <T> 数据类型
* @return Excel 文件字节数组
* @throws Exception 导出异常
*/
@SafeVarargs
public static <T> byte[] export(String sheetName, String[] headers,
List<T> data, Function<T, Object>... getters) throws Exception {
Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
Sheet sheet = workbook.createSheet(sheetName);
// 创建表头样式
CellStyle headerStyle = createHeaderStyle(workbook);
// 写入表头
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
// 写入数据行
for (int rowIndex = 0; rowIndex < data.size(); rowIndex++) {
Row row = sheet.createRow(rowIndex + 1);
T item = data.get(rowIndex);
for (int colIndex = 0; colIndex < getters.length; colIndex++) {
Cell cell = row.createCell(colIndex);
Object value = getters[colIndex].apply(item);
setCellValue(cell, value);
}
}
// 自动调整列宽
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
// 增加一些额外宽度,避免中文显示不完整
int currentWidth = sheet.getColumnWidth(i);
sheet.setColumnWidth(i, currentWidth + 512);
}
workbook.write(outputStream);
return outputStream.toByteArray();
} finally {
workbook.close();
outputStream.close();
}
}
/**
* 创建表头样式。
*/
private static CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
return style;
}
/**
* 设置单元格值。
*/
private static void setCellValue(Cell cell, Object value) {
if (value == null) {
cell.setCellValue("");
} else if (value instanceof Number) {
cell.setCellValue(((Number) value).doubleValue());
} else if (value instanceof Boolean) {
cell.setCellValue((Boolean) value);
} else {
cell.setCellValue(value.toString());
}
}
}
@@ -0,0 +1,338 @@
package com.roomroot.jwgl.utils;
import com.roomroot.jwgl.dto.courserunning.ClassesImportDTO;
import com.roomroot.jwgl.dto.courserunning.SemesterImportDTO;
import com.roomroot.jwgl.dto.courserunning.SingleCourseImportDTO;
import com.roomroot.jwgl.dto.departmentpersonnel.DepartmentPersonnelCreateDTO;
import com.roomroot.jwgl.entity.JXSSJH;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
public class ExcelParseUtil {
private ExcelParseUtil() {
}
public static List<DepartmentPersonnelCreateDTO> parseDepartmentPersonnelExcel(InputStream inputStream, String fileName) throws Exception {
List<DepartmentPersonnelCreateDTO> result = new ArrayList<>();
Workbook workbook = createWorkbook(inputStream, fileName);
try {
Sheet sheet = workbook.getSheetAt(0);
int firstDataRow = 1;
int lastRowNum = sheet.getLastRowNum();
for (int rowNum = firstDataRow; rowNum <= lastRowNum; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
DepartmentPersonnelCreateDTO dto = parsePersonnelRow(row, rowNum + 1);
if (dto != null && dto.getDepartmentId() != null && dto.getName() != null) {
result.add(dto);
}
}
} finally {
workbook.close();
}
return result;
}
/**
* 解析整学期课程导入 Excel 文件。
*
* @param inputStream Excel 文件输入流
* @param fileName 文件名
* @return 学期导入参数列表
* @throws Exception 解析异常
*/
public static List<SemesterImportDTO> parseCourseSemesterImportExcel(
InputStream inputStream, String fileName) throws Exception {
List<SemesterImportDTO> result = new ArrayList<>();
Workbook workbook = createWorkbook(inputStream, fileName);
try {
Sheet sheet = workbook.getSheetAt(0);
int firstDataRow = 1;
int lastRowNum = sheet.getLastRowNum();
for (int rowNum = firstDataRow; rowNum <= lastRowNum; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
SemesterImportDTO dto = new SemesterImportDTO();
dto.setNd(parseIntCell(row.getCell(0)));
dto.setXqdc(parseIntCell(row.getCell(1)));
if (dto.getNd() != null && dto.getXqdc() != null) {
result.add(dto);
}
}
} finally {
workbook.close();
}
return result;
}
/**
* 解析教学班次课程导入 Excel 文件。
*
* @param inputStream Excel 文件输入流
* @param fileName 文件名
* @return 教学班次导入参数列表
* @throws Exception 解析异常
*/
public static List<ClassesImportDTO> parseCourseClassesImportExcel(
InputStream inputStream, String fileName) throws Exception {
List<ClassesImportDTO> result = new ArrayList<>();
Workbook workbook = createWorkbook(inputStream, fileName);
try {
Sheet sheet = workbook.getSheetAt(0);
int firstDataRow = 1;
int lastRowNum = sheet.getLastRowNum();
for (int rowNum = firstDataRow; rowNum <= lastRowNum; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
ClassesImportDTO dto = new ClassesImportDTO();
dto.setClassBh(getCellStringValue(row.getCell(0)));
dto.setNd(parseIntCell(row.getCell(1)));
if (dto.getClassBh() != null && dto.getNd() != null) {
result.add(dto);
}
}
} finally {
workbook.close();
}
return result;
}
/**
* 解析单门课程导入 Excel 文件。
*
* @param inputStream Excel 文件输入流
* @param fileName 文件名
* @return 单门课程导入参数列表
* @throws Exception 解析异常
*/
public static List<SingleCourseImportDTO> parseCourseSingleImportExcel(
InputStream inputStream, String fileName) throws Exception {
List<SingleCourseImportDTO> result = new ArrayList<>();
Workbook workbook = createWorkbook(inputStream, fileName);
try {
Sheet sheet = workbook.getSheetAt(0);
int firstDataRow = 1;
int lastRowNum = sheet.getLastRowNum();
for (int rowNum = firstDataRow; rowNum <= lastRowNum; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
SingleCourseImportDTO dto = new SingleCourseImportDTO();
dto.setCourseBh(getCellStringValue(row.getCell(0)));
dto.setClassBh(getCellStringValue(row.getCell(1)));
dto.setNd(parseIntCell(row.getCell(2)));
if (dto.getCourseBh() != null && dto.getClassBh() != null && dto.getNd() != null) {
result.add(dto);
}
}
} finally {
workbook.close();
}
return result;
}
private static Workbook createWorkbook(InputStream inputStream, String fileName) throws Exception {
String name = fileName == null ? "" : fileName.toLowerCase();
if (!name.endsWith(".xlsx") && !name.endsWith(".xls")) {
throw new IllegalArgumentException("不支持的文件格式,仅支持.xlsx和.xls格式");
}
return WorkbookFactory.create(inputStream);
}
private static DepartmentPersonnelCreateDTO parsePersonnelRow(Row row, int rowIndex) {
DepartmentPersonnelCreateDTO dto = new DepartmentPersonnelCreateDTO();
dto.setDepartmentId(getCellStringValue(row.getCell(0)));
dto.setName(getCellStringValue(row.getCell(1)));
dto.setSequenceNumber(getCellStringValue(row.getCell(2)));
dto.setPosition(getCellStringValue(row.getCell(3)));
dto.setCardNumber(getCellStringValue(row.getCell(4)));
dto.setPinyin(getCellStringValue(row.getCell(5)));
dto.setPrincipal(parseBooleanValue(row.getCell(6)));
return dto;
}
/**
* 解析单元格整数值。
*
* @param cell 单元格
* @return 整数值,无法解析时返回 null
*/
private static Integer parseIntCell(Cell cell) {
String value = getCellStringValue(cell);
if (value == null || value.isEmpty()) {
return null;
}
try {
return Integer.valueOf(value);
} catch (NumberFormatException e) {
return null;
}
}
private static String getCellStringValue(Cell cell) {
if (cell == null) {
return null;
}
switch (cell.getCellType()) {
case STRING:
String value = cell.getStringCellValue();
return value != null ? value.trim() : null;
case NUMERIC:
double numericValue = cell.getNumericCellValue();
if (numericValue == Math.floor(numericValue)) {
return String.valueOf((long) numericValue);
}
return String.valueOf(numericValue);
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
try {
return cell.getStringCellValue();
} catch (Exception e) {
return String.valueOf(cell.getNumericCellValue());
}
default:
return null;
}
}
/**
* 解析教学实施计划导入 Excel 文件。
* Excel 表头顺序:序号、年度、原序号、日期、原节次、节次、课程名称、班次、
* 责任单位、授课教员、教学场地、教学内容、教学方法、教员备注、教务备注、检查结果、课程表编号
*
* @param inputStream Excel 文件输入流
* @param fileName 文件名
* @return 教学实施计划实体列表(标识号和登录编号待外部填充)
* @throws Exception 解析异常
*/
public static List<JXSSJH> parseJXSSJHExcel(InputStream inputStream, String fileName) throws Exception {
List<JXSSJH> result = new ArrayList<>();
Workbook workbook = createWorkbook(inputStream, fileName);
try {
Sheet sheet = workbook.getSheetAt(0);
int firstDataRow = 1;
int lastRowNum = sheet.getLastRowNum();
for (int rowNum = firstDataRow; rowNum <= lastRowNum; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
JXSSJH entity = new JXSSJH();
entity.setXh(parseIntCell(row.getCell(0)));
entity.setNd(parseIntCell(row.getCell(1)));
entity.setYxh(getCellStringValue(row.getCell(2)));
entity.setRq(parseDateTimeCell(row.getCell(3)));
entity.setYjc(getCellStringValue(row.getCell(4)));
entity.setJc(parseIntCell(row.getCell(5)));
entity.setKcmc(getCellStringValue(row.getCell(6)));
entity.setBc(getCellStringValue(row.getCell(7)));
entity.setZrdw(getCellStringValue(row.getCell(8)));
entity.setSkjy(getCellStringValue(row.getCell(9)));
entity.setJxcd(getCellStringValue(row.getCell(10)));
entity.setJxnr(getCellStringValue(row.getCell(11)));
entity.setJxff(getCellStringValue(row.getCell(12)));
entity.setJybz(getCellStringValue(row.getCell(13)));
entity.setJwbz(getCellStringValue(row.getCell(14)));
entity.setJcjg(getCellStringValue(row.getCell(15)));
entity.setKcbbh(getCellStringValue(row.getCell(16)));
// 至少有课程名称才视为有效行
if (entity.getKcmc() != null && !entity.getKcmc().isEmpty()) {
result.add(entity);
}
}
} finally {
workbook.close();
}
return result;
}
/**
* 解析日期时间单元格,支持多种格式。
*/
private static LocalDateTime parseDateTimeCell(Cell cell) {
if (cell == null) {
return null;
}
// 优先尝试POI日期类型
if (cell.getCellType() == CellType.NUMERIC && org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue();
}
// 字符串格式解析
String value = getCellStringValue(cell);
if (value == null || value.isEmpty()) {
return null;
}
// 尝试多种常见格式
String[] patterns = {
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd",
"yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/MM/dd"
};
for (String pattern : patterns) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
if (pattern.contains("HH")) {
return LocalDateTime.parse(value, formatter);
} else {
return LocalDate.parse(value, formatter).atStartOfDay();
}
} catch (Exception ignored) {
}
}
return null;
}
private static Boolean parseBooleanValue(Cell cell) {
String value = getCellStringValue(cell);
if (value == null || value.isEmpty()) {
return false;
}
return "是".equals(value) || "true".equalsIgnoreCase(value) || "1".equals(value) || "√".equals(value);
}
}
@@ -0,0 +1,131 @@
package com.roomroot.jwgl.utils;
/**
* 通知公告管理常量工具类。
*
* <p>集中维护通知公告分页、文本字段和状态流转需要使用的固定值,
* 避免后续业务实现类重复声明相同数字和文本。</p>
*/
public final class NoticeAnnouncementConstants {
/**
* 通知公告分页允许的最大单页记录数。
*/
public static final int MAX_PAGE_SIZE = 100;
/**
* 公告编号的最大长度。
*/
public static final int NOTICE_ID_MAX_LENGTH = 50;
/**
* 公告标题的最大长度。
*/
public static final int TITLE_MAX_LENGTH = 100;
/**
* 公告正文允许的最大长度。
*
* <p>数据库字段为大文本类型,该限制用于防止单次请求提交异常大的正文。</p>
*/
public static final int CONTENT_MAX_LENGTH = 100000;
/**
* 公告类别的最大长度。
*/
public static final int CATEGORY_MAX_LENGTH = 50;
/**
* 公告状态的最大长度。
*/
public static final int STATUS_MAX_LENGTH = 50;
/**
* 公告备注的最大长度。
*/
public static final int REMARK_MAX_LENGTH = 500;
/**
* 接收人查询关键字的最大长度。
*/
public static final int RECIPIENT_KEYWORD_MAX_LENGTH = 100;
/**
* 发布范围类型的最大长度。
*/
public static final int SCOPE_TYPE_MAX_LENGTH = 20;
/**
* 单次指定发布允许选择的最大部别数量。
*/
public static final int MAX_DEPARTMENT_SCOPE_SIZE = 200;
/**
* 公告优先级允许的最大值。
*/
public static final int PRIORITY_MAX_VALUE = 9999;
/**
* 接收人快照单次批量写入的最大记录数。
*/
public static final int RECIPIENT_INSERT_BATCH_SIZE = 200;
/**
* 公告默认类别。
*/
public static final String DEFAULT_CATEGORY = "公告";
/**
* 公告拟制状态。
*/
public static final String STATUS_DRAFT = "拟制";
/**
* 公告已发布状态。
*/
public static final String STATUS_PUBLISHED = "已发布";
/**
* 公告已下架状态。
*/
public static final String STATUS_DOWN = "已下架";
/**
* 面向全校有效账户发布。
*/
public static final String SCOPE_ALL = "ALL";
/**
* 面向全部有效教员账户发布。
*/
public static final String SCOPE_ALL_TEACHERS =
"ALL_TEACHERS";
/**
* 面向指定部别关联账户发布。
*/
public static final String SCOPE_DEPARTMENTS =
"DEPARTMENTS";
/**
* 数据库只能还原为定向发布时使用的范围类型。
*/
public static final String SCOPE_TARGETED = "TARGETED";
/**
* 未登录业务错误码。
*/
public static final int UNAUTHORIZED = 401;
/**
* 当前会话未登录时的统一提示。
*/
public static final String AUTHENTICATION_REQUIRED_MESSAGE =
"请先登录后再操作";
/**
* 私有化构造方法,防止常量工具类被实例化。
*/
private NoticeAnnouncementConstants() {
}
}
@@ -0,0 +1,947 @@
package com.roomroot.jwgl.utils;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementCreateDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementIdDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementPriorityDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementPublishDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementQueryDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementRecipientInsertDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementRecipientQueryDTO;
import com.roomroot.jwgl.dto.noticeannouncement.NoticeAnnouncementUpdateDTO;
import com.roomroot.jwgl.dto.noticeannouncement.UserNoticeAnnouncementQueryDTO;
import com.roomroot.common.exception.ServiceException;
import com.roomroot.jwgl.vo.noticeannouncement.NoticeAnnouncementDetailVO;
import com.roomroot.jwgl.vo.noticeannouncement.NoticeAnnouncementStatisticsVO;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
import static com.roomroot.jwgl.utils.BasicManagementConstants.CONFLICT;
import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.AUTHENTICATION_REQUIRED_MESSAGE;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.CATEGORY_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.CONTENT_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.MAX_DEPARTMENT_SCOPE_SIZE;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.MAX_PAGE_SIZE;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.NOTICE_ID_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.PRIORITY_MAX_VALUE;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.RECIPIENT_KEYWORD_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.REMARK_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.SCOPE_ALL;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.SCOPE_ALL_TEACHERS;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.SCOPE_DEPARTMENTS;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.SCOPE_TARGETED;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.STATUS_DOWN;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.STATUS_DRAFT;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.STATUS_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.STATUS_PUBLISHED;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.TITLE_MAX_LENGTH;
import static com.roomroot.jwgl.utils.NoticeAnnouncementConstants.UNAUTHORIZED;
/**
* 通知公告管理无状态辅助工具类。
*
* <p>集中处理 DTO 复制、文本规范化、状态流转校验、发布范围校验、
* 会话识别、接收人快照组装和统计结果补全,使业务实现类只保留流程编排。</p>
*/
public final class NoticeAnnouncementUtil {
/**
* 复制、规范化并校验通知公告后台分页查询参数。
*
* @param dto 页面提交的查询参数
* @return 可直接用于业务查询的独立参数对象
*/
public static NoticeAnnouncementQueryDTO normalizeQuery(
NoticeAnnouncementQueryDTO dto) {
// 第一步:兼容业务层直接传入 null,并使用 DTO 中定义的默认分页参数。
NoticeAnnouncementQueryDTO source =
dto == null ? new NoticeAnnouncementQueryDTO() : dto;
// 第二步:校验分页、优先级和发布日期区间。
validatePage(source.getPageNum(), source.getPageSize());
validatePriorityRange(
source.getMinPriority(),
source.getMaxPriority());
validatePublishDateRange(
source.getPublishStartDate(),
source.getPublishEndDate());
// 第三步:创建独立查询对象,避免修改调用方持有的原始 DTO。
NoticeAnnouncementQueryDTO query =
new NoticeAnnouncementQueryDTO();
// 第四步:复制已经校验通过的分页、数值和日期条件。
query.setPageNum(source.getPageNum());
query.setPageSize(source.getPageSize());
query.setMinPriority(source.getMinPriority());
query.setMaxPriority(source.getMaxPriority());
query.setPublishStartDate(source.getPublishStartDate());
query.setPublishEndDate(source.getPublishEndDate());
// 第五步:规范化文本查询条件。
query.setTitle(optionalText(
source.getTitle(),
TITLE_MAX_LENGTH,
"公告标题"));
query.setCategory(optionalText(
source.getCategory(),
CATEGORY_MAX_LENGTH,
"公告类别"));
query.setStatus(normalizeOptionalStatus(
source.getStatus()));
// 第六步:返回完成校验和规范化的独立查询对象。
return query;
}
/**
* 复制、规范化并校验新增公告参数。
*
* @param dto 页面提交的新增参数
* @return 可直接用于数据库写入的独立参数对象
*/
public static NoticeAnnouncementCreateDTO normalizeCreate(
NoticeAnnouncementCreateDTO dto) {
// 第一步:拒绝绕过 Controller 后传入的空新增参数。
if (dto == null) {
throw new ServiceException("公告新增参数不能为空",
BAD_REQUEST);
}
// 第二步:校验优先级范围。
validatePriority(dto.getPriority());
// 第三步:创建独立参数对象并规范化全部可编辑字段。
NoticeAnnouncementCreateDTO normalized =
new NoticeAnnouncementCreateDTO();
copyEditableFields(dto, normalized);
// 第四步:返回可安全用于写入的公告内容。
return normalized;
}
/**
* 复制、规范化并校验修改公告参数。
*
* @param dto 页面提交的修改参数
* @return 可直接用于数据库更新的独立参数对象
*/
public static NoticeAnnouncementUpdateDTO normalizeUpdate(
NoticeAnnouncementUpdateDTO dto) {
// 第一步:拒绝绕过 Controller 后传入的空修改参数。
if (dto == null) {
throw new ServiceException("公告修改参数不能为空",
BAD_REQUEST);
}
// 第二步:复用新增参数规则校验和规范化可编辑内容。
NoticeAnnouncementCreateDTO content =
normalizeCreate(dto);
// 第三步:创建独立修改对象并规范化公告编号。
NoticeAnnouncementUpdateDTO normalized =
new NoticeAnnouncementUpdateDTO();
normalized.setId(normalizeNoticeId(dto.getId()));
// 第四步:复制已经规范化的公告内容。
normalized.setTitle(content.getTitle());
normalized.setContent(content.getContent());
normalized.setCategory(content.getCategory());
normalized.setPriority(content.getPriority());
normalized.setRemark(content.getRemark());
// 第五步:返回可安全用于更新的修改参数。
return normalized;
}
/**
* 规范化公告编号 DTO。
*
* @param dto 页面提交的公告编号参数
* @return 去除首尾空白后的公告编号
*/
public static String normalizeNoticeId(
NoticeAnnouncementIdDTO dto) {
// 第一步:空 DTO 按照缺少公告编号处理。
String id = dto == null ? null : dto.getId();
// 第二步:复用公告编号文本规则完成规范化。
return normalizeNoticeId(id);
}
/**
* 复制、规范化并校验公告发布参数。
*
* @param dto 页面提交的发布参数
* @return 可直接用于业务发布流程的独立参数对象
*/
public static NoticeAnnouncementPublishDTO normalizePublish(
NoticeAnnouncementPublishDTO dto) {
// 第一步:拒绝绕过 Controller 后传入的空发布参数。
if (dto == null) {
throw new ServiceException("公告发布参数不能为空",
BAD_REQUEST);
}
// 第二步:规范化公告编号和发布范围类型。
String id = normalizeNoticeId(dto.getId());
String scopeType = requiredText(
dto.getScopeType(),
NoticeAnnouncementConstants.SCOPE_TYPE_MAX_LENGTH,
"发布范围").toUpperCase(Locale.ROOT);
// 第三步:校验发布范围类型是否属于系统支持范围。
if (!SCOPE_ALL.equals(scopeType)
&& !SCOPE_ALL_TEACHERS.equals(scopeType)
&& !SCOPE_DEPARTMENTS.equals(scopeType)) {
throw new ServiceException("发布范围必须为ALL、ALL_TEACHERS或DEPARTMENTS",
BAD_REQUEST);
}
// 第四步:规范化并去重指定部别编号。
List<String> departmentIds =
normalizeDepartmentIds(dto.getDepartmentIds());
/*
* 第五步:指定部别发布必须选择至少一个部别;
* 全校和全体教员发布不能携带无效的部别范围。
*/
if (SCOPE_DEPARTMENTS.equals(scopeType)
&& departmentIds.isEmpty()) {
throw new ServiceException("指定部别发布至少选择一个部别",
BAD_REQUEST);
}
if (!SCOPE_DEPARTMENTS.equals(scopeType)
&& !departmentIds.isEmpty()) {
throw new ServiceException("当前发布范围不能同时提交部别编号",
BAD_REQUEST);
}
// 第六步:创建并返回独立的发布参数对象。
NoticeAnnouncementPublishDTO normalized =
new NoticeAnnouncementPublishDTO();
normalized.setId(id);
normalized.setScopeType(scopeType);
normalized.setDepartmentIds(departmentIds);
return normalized;
}
/**
* 复制、规范化并校验公告优先级调整参数。
*
* @param dto 页面提交的优先级参数
* @return 可直接用于更新的独立参数对象
*/
public static NoticeAnnouncementPriorityDTO normalizePriority(
NoticeAnnouncementPriorityDTO dto) {
// 第一步:拒绝绕过 Controller 后传入的空优先级参数。
if (dto == null) {
throw new ServiceException("公告优先级参数不能为空",
BAD_REQUEST);
}
// 第二步:校验优先级是否处于允许范围。
validatePriority(dto.getPriority());
// 第三步:创建并返回独立的优先级参数对象。
NoticeAnnouncementPriorityDTO normalized =
new NoticeAnnouncementPriorityDTO();
normalized.setId(normalizeNoticeId(dto.getId()));
normalized.setPriority(dto.getPriority());
return normalized;
}
/**
* 复制、规范化并校验接收人分页查询参数。
*
* @param dto 页面提交的接收人查询参数
* @return 可直接用于数据库查询的独立参数对象
*/
public static NoticeAnnouncementRecipientQueryDTO
normalizeRecipientQuery(
NoticeAnnouncementRecipientQueryDTO dto) {
// 第一步:拒绝缺少公告编号的空查询参数。
if (dto == null) {
throw new ServiceException("接收人查询参数不能为空",
BAD_REQUEST);
}
// 第二步:校验分页参数。
validatePage(dto.getPageNum(), dto.getPageSize());
// 第三步:创建独立查询对象并复制规范化后的条件。
NoticeAnnouncementRecipientQueryDTO normalized =
new NoticeAnnouncementRecipientQueryDTO();
normalized.setId(normalizeNoticeId(dto.getId()));
normalized.setPageNum(dto.getPageNum());
normalized.setPageSize(dto.getPageSize());
normalized.setKeyword(optionalText(
dto.getKeyword(),
RECIPIENT_KEYWORD_MAX_LENGTH,
"接收人关键字"));
normalized.setRead(dto.getRead());
// 第四步:返回完成规范化的接收人查询参数。
return normalized;
}
/**
* 复制、规范化并校验当前用户公告分页参数。
*
* @param dto 页面提交的用户公告查询参数
* @return 可直接用于数据库查询的独立参数对象
*/
public static UserNoticeAnnouncementQueryDTO
normalizeUserQuery(
UserNoticeAnnouncementQueryDTO dto) {
// 第一步:兼容业务层传入 null,并使用默认分页参数。
UserNoticeAnnouncementQueryDTO source =
dto == null
? new UserNoticeAnnouncementQueryDTO()
: dto;
// 第二步:校验分页参数。
validatePage(source.getPageNum(), source.getPageSize());
// 第三步:创建独立查询对象并规范化全部筛选条件。
UserNoticeAnnouncementQueryDTO normalized =
new UserNoticeAnnouncementQueryDTO();
normalized.setPageNum(source.getPageNum());
normalized.setPageSize(source.getPageSize());
normalized.setTitle(optionalText(
source.getTitle(),
TITLE_MAX_LENGTH,
"公告标题"));
normalized.setCategory(optionalText(
source.getCategory(),
CATEGORY_MAX_LENGTH,
"公告类别"));
normalized.setUnreadOnly(
Boolean.TRUE.equals(source.getUnreadOnly()));
// 第四步:返回完成规范化的用户公告查询参数。
return normalized;
}
/**
* 从当前请求取得已存在的服务端会话编号。
*
* @return 当前 Servlet Session 编号
*/
public static String requireCurrentSessionId() {
// 第一步:读取当前线程绑定的 HTTP 请求上下文。
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes == null) {
throw new ServiceException(AUTHENTICATION_REQUIRED_MESSAGE,
UNAUTHORIZED);
}
// 第二步:取得已有 Servlet Session,不为匿名请求创建新会话。
HttpServletRequest request = attributes.getRequest();
HttpSession session = request.getSession(false);
if (session == null) {
throw new ServiceException(AUTHENTICATION_REQUIRED_MESSAGE,
UNAUTHORIZED);
}
// 第三步:返回只由服务端生成和维护的会话编号。
return session.getId();
}
/**
* 校验并规范化数据库根据当前会话查询到的账户编号。
*
* @param accountId 当前会话对应的账户编号
* @return 去除首尾空白后的真实账户编号
*/
public static String requireCurrentAccountId(
String accountId) {
// 第一步:会话不存在对应账户时按照未登录处理。
if (accountId == null
|| accountId.trim().isEmpty()) {
throw new ServiceException(AUTHENTICATION_REQUIRED_MESSAGE,
UNAUTHORIZED);
}
// 第二步:返回规范化后的账户编号供用户公告查询复用。
return accountId.trim();
}
/**
* 去除空账户编号并按照原始顺序去重。
*
* @param accountIds 数据库解析出的接收账户编号
* @return 可用于生成接收人快照的唯一账户编号集合
*/
public static List<String> normalizeRecipientAccountIds(
List<String> accountIds) {
// 第一步:空查询结果统一转换为空集合。
if (accountIds == null || accountIds.isEmpty()) {
return Collections.emptyList();
}
// 第二步:使用有序集合去除重复账户,并保留数据库返回顺序。
Set<String> uniqueAccountIds = new LinkedHashSet<>();
for (String accountId : accountIds) {
// 第三步:忽略数据库异常返回的空账户编号。
if (accountId != null
&& !accountId.trim().isEmpty()) {
uniqueAccountIds.add(accountId.trim());
}
}
// 第四步:返回独立列表,避免调用方修改内部去重集合。
return new ArrayList<>(uniqueAccountIds);
}
/**
* 为接收账户生成通告用户信息批量写入参数。
*
* @param accountIds 已完成去重的接收账户编号
* @return 包含服务端记录编号的接收人快照
*/
public static List<NoticeAnnouncementRecipientInsertDTO>
createRecipientRecords(
List<String> accountIds) {
// 第一步:没有接收账户时返回稳定的空集合。
if (accountIds == null || accountIds.isEmpty()) {
return Collections.emptyList();
}
// 第二步:为每个接收账户生成独立的通告用户信息记录编号。
List<NoticeAnnouncementRecipientInsertDTO> records =
new ArrayList<>(accountIds.size());
for (String accountId : accountIds) {
NoticeAnnouncementRecipientInsertDTO record =
new NoticeAnnouncementRecipientInsertDTO();
record.setId(UuidUtil.getOriginalUUID());
record.setAccountId(accountId);
records.add(record);
}
// 第三步:返回可供 Mapper 分批写入的接收人快照。
return records;
}
/**
* 补全公告详情中的发布范围和统计默认值。
*
* @param detail 数据库查询到的公告详情
* @return 可直接返回页面的公告详情
*/
public static NoticeAnnouncementDetailVO completeDetail(
NoticeAnnouncementDetailVO detail) {
// 第一步:空详情直接返回,由业务层决定资源不存在提示。
if (detail == null) {
return null;
}
// 第二步:根据现有“全体”字段还原全校或定向发布类型。
if (Boolean.TRUE.equals(detail.getAll())) {
detail.setScopeType(SCOPE_ALL);
detail.setScopeName("全校");
} else {
detail.setScopeType(SCOPE_TARGETED);
detail.setScopeName("定向发布");
}
// 第三步:将数据库空统计值统一补为0。
if (detail.getRecipientCount() == null) {
detail.setRecipientCount(Long.valueOf(0L));
}
if (detail.getReaderCount() == null) {
detail.setReaderCount(Long.valueOf(0L));
}
if (detail.getReadCount() == null) {
detail.setReadCount(Integer.valueOf(0));
}
// 第四步:返回完成展示字段补全的公告详情。
return detail;
}
/**
* 补全公告阅读统计中的未读人数和阅读率。
*
* @param statistics 数据库汇总结果
* @param noticeId 公告编号
* @return 可直接返回页面的统计结果
*/
public static NoticeAnnouncementStatisticsVO
completeStatistics(
NoticeAnnouncementStatisticsVO statistics,
String noticeId) {
// 第一步:兼容没有接收人记录时数据库返回空对象的情况。
NoticeAnnouncementStatisticsVO result =
statistics == null
? new NoticeAnnouncementStatisticsVO()
: statistics;
// 第二步:补充公告编号和空统计值。
result.setId(noticeId);
long recipientCount = number(result.getRecipientCount());
long receivedCount = number(result.getReceivedCount());
long readerCount = number(result.getReaderCount());
result.setRecipientCount(Long.valueOf(recipientCount));
result.setReceivedCount(Long.valueOf(receivedCount));
result.setReaderCount(Long.valueOf(readerCount));
// 第三步:根据接收人数和已读人数计算未读人数。
long unreadCount =
Math.max(0L, recipientCount - readerCount);
result.setUnreadCount(Long.valueOf(unreadCount));
// 第四步:没有接收人时阅读率为0,避免除零异常。
if (recipientCount == 0L) {
result.setReadRate(
BigDecimal.ZERO.setScale(
2, RoundingMode.HALF_UP));
return result;
}
// 第五步:阅读率按照百分比计算并保留两位小数。
BigDecimal readRate =
BigDecimal.valueOf(readerCount)
.multiply(BigDecimal.valueOf(100L))
.divide(
BigDecimal.valueOf(recipientCount),
2,
RoundingMode.HALF_UP);
result.setReadRate(readRate);
return result;
}
/**
* 返回发布范围中文名称。
*
* @param scopeType 已完成规范化的发布范围类型
* @return 发布范围中文名称
*/
public static String resolveScopeName(
String scopeType) {
// 第一步:识别全校发布范围。
if (SCOPE_ALL.equals(scopeType)) {
return "全校";
}
// 第二步:识别全体教员发布范围。
if (SCOPE_ALL_TEACHERS.equals(scopeType)) {
return "全体教员";
}
// 第三步:其余已通过校验的范围为指定部别。
return "指定部别";
}
/**
* 校验公告状态是否允许修改。
*
* @param status 当前公告状态
*/
public static void requireEditableStatus(
String status) {
// 第一步:公告不存在时返回明确的资源不存在提示。
requireExistingStatus(status);
// 第二步:只有拟制或已下架公告允许修改。
if (!STATUS_DRAFT.equals(status)
&& !STATUS_DOWN.equals(status)) {
throw new ServiceException("已发布公告不能修改,请先下架",
CONFLICT);
}
}
/**
* 校验公告状态是否允许发布。
*
* @param status 当前公告状态
*/
public static void requirePublishableStatus(
String status) {
// 第一步:公告不存在时返回明确的资源不存在提示。
requireExistingStatus(status);
// 第二步:只有拟制或已下架公告允许发布或重新发布。
if (!STATUS_DRAFT.equals(status)
&& !STATUS_DOWN.equals(status)) {
throw new ServiceException("公告已经发布,请勿重复发布",
CONFLICT);
}
}
/**
* 校验公告状态是否允许下架。
*
* @param status 当前公告状态
*/
public static void requireDownableStatus(
String status) {
// 第一步:公告不存在时返回明确的资源不存在提示。
requireExistingStatus(status);
// 第二步:只有已发布公告允许下架。
if (!STATUS_PUBLISHED.equals(status)) {
throw new ServiceException("只有已发布公告可以下架",
CONFLICT);
}
}
/**
* 校验公告状态是否允许删除。
*
* @param status 当前公告状态
*/
public static void requireDeletableStatus(
String status) {
// 第一步:公告不存在时返回明确的资源不存在提示。
requireExistingStatus(status);
// 第二步:已发布公告必须先下架,不能直接删除。
if (STATUS_PUBLISHED.equals(status)) {
throw new ServiceException("已发布公告不能删除,请先下架",
CONFLICT);
}
// 第三步:只允许删除拟制或已下架公告。
if (!STATUS_DRAFT.equals(status)
&& !STATUS_DOWN.equals(status)) {
throw new ServiceException("当前公告状态不允许删除",
CONFLICT);
}
}
/**
* 校验公告是否存在。
*
* @param status 数据库查询到的公告状态
*/
public static void requireExistingStatus(
String status) {
// 状态为空表示公告编号不存在。
if (status == null || status.trim().isEmpty()) {
throw new ServiceException("通知公告不存在",
NOT_FOUND);
}
}
/**
* 将查询开始日期转换为当天零点。
*
* @param date 查询开始日期
* @return 当天零点;日期为空时返回 null
*/
public static LocalDateTime startOfDay(LocalDate date) {
// 未填写开始日期时不生成数据库开始时间条件。
return date == null ? null : date.atStartOfDay();
}
/**
* 将查询结束日期转换为下一天零点,供数据库使用小于条件包含完整结束日期。
*
* @param date 查询结束日期
* @return 下一天零点;日期为空时返回 null
*/
public static LocalDateTime startOfNextDay(LocalDate date) {
// 第一步:未填写结束日期时不生成数据库结束时间条件。
if (date == null) {
return null;
}
try {
// 第二步:使用下一天零点作为排他上界,完整包含结束日期内的全部时刻。
return date.plusDays(1L).atStartOfDay();
} catch (DateTimeException exception) {
// 第三步:无法生成下一天时返回明确的请求参数错误。
throw new ServiceException("发布结束日期超出系统支持范围",
BAD_REQUEST);
}
}
/**
* 复制并规范化公告可编辑字段。
*
* @param source 原始公告内容
* @param target 独立公告内容对象
*/
private static void copyEditableFields(
NoticeAnnouncementCreateDTO source,
NoticeAnnouncementCreateDTO target) {
// 第一步:公告标题和正文为必填文本。
target.setTitle(requiredText(
source.getTitle(),
TITLE_MAX_LENGTH,
"公告标题"));
target.setContent(requiredText(
source.getContent(),
CONTENT_MAX_LENGTH,
"公告内容"));
// 第二步:公告类别为必填文本,优先级使用已经校验通过的数值。
target.setCategory(requiredText(
source.getCategory(),
CATEGORY_MAX_LENGTH,
"公告类别"));
target.setPriority(source.getPriority());
// 第三步:公告备注为空白时统一转换为 null。
target.setRemark(optionalText(
source.getRemark(),
REMARK_MAX_LENGTH,
"公告备注"));
}
/**
* 规范化公告编号文本。
*
* @param id 原始公告编号
* @return 规范化后的公告编号
*/
private static String normalizeNoticeId(
String id) {
// 公告编号属于必填文本,统一执行非空和长度校验。
return requiredText(
id,
NOTICE_ID_MAX_LENGTH,
"公告编号");
}
/**
* 规范化指定部别编号集合。
*
* @param departmentIds 原始部别编号集合
* @return 去除空值和重复值后的部别编号集合
*/
private static List<String> normalizeDepartmentIds(
List<String> departmentIds) {
// 第一步:未提交部别编号时返回稳定的空集合。
if (departmentIds == null
|| departmentIds.isEmpty()) {
return Collections.emptyList();
}
// 第二步:限制单次发布范围大小,避免生成异常大的查询条件。
if (departmentIds.size()
> MAX_DEPARTMENT_SCOPE_SIZE) {
throw new ServiceException("单次最多选择200个部别",
BAD_REQUEST);
}
// 第三步:规范化每个部别编号并按照提交顺序去重。
Set<String> uniqueIds = new LinkedHashSet<>();
for (String departmentId : departmentIds) {
uniqueIds.add(requiredText(
departmentId,
NOTICE_ID_MAX_LENGTH,
"部别编号"));
}
// 第四步:返回独立的部别编号列表。
return new ArrayList<>(uniqueIds);
}
/**
* 规范化可选公告状态并校验状态白名单。
*
* @param status 原始状态
* @return 规范化后的状态;未填写时返回 null
*/
private static String normalizeOptionalStatus(
String status) {
// 第一步:规范化可选状态文本。
String normalized = optionalText(
status,
STATUS_MAX_LENGTH,
"公告状态");
if (normalized == null) {
return null;
}
// 第二步:只允许查询通知公告业务定义的三种状态。
if (!STATUS_DRAFT.equals(normalized)
&& !STATUS_PUBLISHED.equals(normalized)
&& !STATUS_DOWN.equals(normalized)) {
throw new ServiceException("不支持的公告状态",
BAD_REQUEST);
}
// 第三步:返回通过白名单校验的状态。
return normalized;
}
/**
* 校验分页参数。
*
* @param pageNum 当前页码
* @param pageSize 每页记录数
*/
private static void validatePage(
Integer pageNum, Integer pageSize) {
// 第一步:页码必须从1开始。
if (pageNum == null || pageNum.intValue() < 1) {
throw new ServiceException("页码最小为1",
BAD_REQUEST);
}
// 第二步:每页记录数必须处于系统允许范围内。
if (pageSize == null
|| pageSize.intValue() < 1
|| pageSize.intValue() > MAX_PAGE_SIZE) {
throw new ServiceException("每页大小必须在1到100之间",
BAD_REQUEST);
}
}
/**
* 校验优先级查询区间。
*
* @param minPriority 最低优先级
* @param maxPriority 最高优先级
*/
private static void validatePriorityRange(
Integer minPriority, Integer maxPriority) {
// 第一步:分别校验两个可选优先级边界。
if (minPriority != null) {
validatePriority(minPriority);
}
if (maxPriority != null) {
validatePriority(maxPriority);
}
// 第二步:同时填写上下限时,最低优先级不能大于最高优先级。
if (minPriority != null
&& maxPriority != null
&& minPriority.intValue()
> maxPriority.intValue()) {
throw new ServiceException("最低优先级不能大于最高优先级",
BAD_REQUEST);
}
}
/**
* 校验单个公告优先级。
*
* @param priority 公告优先级
*/
private static void validatePriority(
Integer priority) {
// 优先级必须存在且处于系统允许范围。
if (priority == null
|| priority.intValue() < 0
|| priority.intValue()
> PRIORITY_MAX_VALUE) {
throw new ServiceException("公告优先级必须在0到9999之间",
BAD_REQUEST);
}
}
/**
* 校验发布日期查询区间。
*
* @param startDate 发布开始日期
* @param endDate 发布结束日期
*/
private static void validatePublishDateRange(
LocalDate startDate, LocalDate endDate) {
// 同时填写开始日期和结束日期时,开始日期不能晚于结束日期。
if (startDate != null
&& endDate != null
&& startDate.isAfter(endDate)) {
throw new ServiceException("发布开始日期不能晚于发布结束日期",
BAD_REQUEST);
}
}
/**
* 规范化必填文本。
*
* @param value 原始文本
* @param maxLength 最大允许长度
* @param fieldName 字段名称
* @return 去除首尾空白后的非空文本
*/
private static String requiredText(
String value, int maxLength, String fieldName) {
// 第一步:复用可选文本规则完成空白和长度处理。
String normalized =
optionalText(value, maxLength, fieldName);
// 第二步:空文本不满足必填字段要求。
if (normalized == null) {
throw new ServiceException(fieldName + "不能为空",
BAD_REQUEST);
}
// 第三步:返回通过非空校验的文本。
return normalized;
}
/**
* 规范化可选文本。
*
* @param value 原始文本
* @param maxLength 最大允许长度
* @param fieldName 字段名称
* @return 去除首尾空白后的文本;空文本返回 null
*/
private static String optionalText(
String value, int maxLength, String fieldName) {
// 第一步:未填写文本时直接返回 null。
if (value == null) {
return null;
}
// 第二步:去除文本首尾空白。
String normalized = value.trim();
// 第三步:只包含空白的文本按照未填写处理。
if (normalized.isEmpty()) {
return null;
}
// 第四步:校验规范化后的文本长度。
if (normalized.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过"
+ maxLength + "个字符",
BAD_REQUEST);
}
// 第五步:返回规范化文本。
return normalized;
}
/**
* 将可空 Long 转换为非负基础数值。
*
* @param value 数据库统计值
* @return 非负 long 数值
*/
private static long number(Long value) {
// 空值或异常负值统一按照0处理。
return value == null
? 0L
: Math.max(0L, value.longValue());
}
/**
* 私有化构造方法,防止无状态工具类被实例化。
*/
private NoticeAnnouncementUtil() {
}
}
@@ -0,0 +1,74 @@
package com.roomroot.jwgl.utils;
/**
* 通知日志管理常量工具类。
*
* <p>集中维护通知日志分页、文本和发送状态使用的固定值,
* 避免业务实现类重复声明业务常量。</p>
*/
public final class NotificationLogConstants {
/**
* 通知日志分页允许的最大单页记录数。
*/
public static final int MAX_PAGE_SIZE = 100;
/**
* 通知标题的最大长度。
*/
public static final int TITLE_MAX_LENGTH = 100;
/**
* 通知类别的最大长度。
*/
public static final int CATEGORY_MAX_LENGTH = 50;
/**
* 通知发送状态的最大长度。
*/
public static final int SEND_STATUS_MAX_LENGTH = 20;
/**
* 通知日志业务编号的最大长度。
*/
public static final int NOTIFICATION_ID_MAX_LENGTH = 50;
/**
* 接收人查询关键字的最大长度。
*/
public static final int RECIPIENT_KEYWORD_MAX_LENGTH = 100;
/**
* 通知日志导出时每批查询的记录数。
*/
public static final int EXPORT_BATCH_SIZE = MAX_PAGE_SIZE;
/**
* 全部接收对象均接收成功时使用的状态。
*/
public static final String SEND_STATUS_SUCCESS =
"发送成功";
/**
* 部分接收对象接收成功时使用的状态。
*/
public static final String SEND_STATUS_PARTIAL =
"部分成功";
/**
* 没有接收对象接收成功时使用的状态。
*/
public static final String SEND_STATUS_FAILED =
"发送失败";
/**
* 阅读率保留的小数位数。
*/
public static final int READ_RATE_SCALE = 2;
/**
* 私有化构造方法,防止常量工具类被实例化。
*/
private NotificationLogConstants() {
}
}
@@ -0,0 +1,920 @@
package com.roomroot.jwgl.utils;
import com.roomroot.jwgl.dto.notificationlog.NotificationLogExportDTO;
import com.roomroot.jwgl.dto.notificationlog.NotificationLogIdDTO;
import com.roomroot.jwgl.dto.notificationlog.NotificationLogQueryDTO;
import com.roomroot.jwgl.dto.notificationlog.NotificationLogRecipientQueryDTO;
import com.roomroot.jwgl.unit.BusinessException;
import com.roomroot.jwgl.vo.notificationlog.NotificationLogDetailVO;
import com.roomroot.jwgl.vo.notificationlog.NotificationLogStatisticsVO;
import com.roomroot.jwgl.vo.notificationlog.NotificationLogVO;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static com.roomroot.jwgl.utils.BasicManagementConstants.BAD_REQUEST;
import static com.roomroot.jwgl.utils.BasicManagementConstants.NOT_FOUND;
import static com.roomroot.jwgl.utils.NotificationLogConstants.*;
/**
* 通知日志管理无状态辅助工具类。
*
* <p>集中处理查询 DTO 复制、文本规范化、时间范围校验和统计字段补全,
* 使通知日志业务实现类只保留查询流程编排。</p>
*/
public final class NotificationLogUtil {
/**
* 通知日志 Excel 文件中的固定列标题。
*/
private static final String[] NOTIFICATION_LOG_EXPORT_HEADERS = {
"通知编号",
"发送时间",
"通知标题",
"通知类别",
"公告状态",
"发送状态",
"接收对象数",
"成功接收数",
"发送失败数",
"已读人数",
"未读人数",
"阅读率",
"累计打开次数"
};
/**
* 通知日志 Excel 文件中各列的显示宽度。
*/
private static final int[] NOTIFICATION_LOG_EXPORT_COLUMN_WIDTHS = {
38, 20, 32, 18, 16, 16, 14,
14, 14, 14, 14, 14, 16
};
/**
* Excel 单个工作表除标题外允许写入的最大数据行数。
*/
private static final int EXCEL_MAX_DATA_ROW_COUNT = 1048575;
/**
* 通知日志导出文件名使用的时间格式。
*/
private static final DateTimeFormatter
NOTIFICATION_LOG_EXPORT_FILE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/**
* 通知日志导出单元格使用的时间格式。
*/
private static final DateTimeFormatter
NOTIFICATION_LOG_EXPORT_CELL_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 工具类不允许创建实例。
*/
private NotificationLogUtil() {
}
/**
* 复制、规范化并校验通知发送历史分页查询参数。
*
* @param dto 页面提交的查询参数
* @return 可直接用于数据库查询的独立参数对象
*/
public static NotificationLogQueryDTO normalizeQuery(
NotificationLogQueryDTO dto) {
// 第一步:兼容业务层直接传入 null,并使用 DTO 默认分页参数。
NotificationLogQueryDTO source =
dto == null ? new NotificationLogQueryDTO() : dto;
// 第二步:校验分页参数和发送时间范围。
validatePage(source.getPageNum(), source.getPageSize());
validateTimeRange(
source.getSendStartTime(),
source.getSendEndTime());
// 第三步:创建独立查询对象,避免修改调用方持有的原始 DTO。
NotificationLogQueryDTO query =
new NotificationLogQueryDTO();
// 第四步:复制已经校验通过的分页和时间条件。
query.setPageNum(source.getPageNum());
query.setPageSize(source.getPageSize());
query.setSendStartTime(source.getSendStartTime());
query.setSendEndTime(source.getSendEndTime());
// 第五步:规范化标题、类别和发送状态筛选条件。
query.setTitle(optionalText(
source.getTitle(),
TITLE_MAX_LENGTH,
"通知标题"));
query.setCategory(optionalText(
source.getCategory(),
CATEGORY_MAX_LENGTH,
"通知类别"));
query.setSendStatus(normalizeSendStatus(
source.getSendStatus()));
// 第六步:返回完成校验和规范化的独立查询对象。
return query;
}
/**
* 规范化并校验通知日志编号参数。
*
* @param dto 页面提交的通知日志编号
* @return 去除首尾空白后的有效通知编号
*/
public static String normalizeId(
NotificationLogIdDTO dto) {
// 第一步:业务层被直接调用时,拒绝空编号参数对象。
if (dto == null) {
throw new BusinessException(
BAD_REQUEST, "通知日志编号不能为空");
}
// 第二步:复用必填文本规则完成去空白和长度校验。
return requiredText(
dto.getId(),
NOTIFICATION_ID_MAX_LENGTH,
"通知日志编号");
}
/**
* 复制、规范化并校验接收对象及阅读明细分页查询参数。
*
* @param dto 页面提交的明细查询参数
* @return 可直接用于数据库查询的独立参数对象
*/
public static NotificationLogRecipientQueryDTO
normalizeRecipientQuery(
NotificationLogRecipientQueryDTO dto) {
// 第一步:业务层被直接调用时,拒绝空查询参数对象。
if (dto == null) {
throw new BusinessException(
BAD_REQUEST, "接收明细查询参数不能为空");
}
// 第二步:校验通知编号和分页参数。
String id = requiredText(
dto.getId(),
NOTIFICATION_ID_MAX_LENGTH,
"通知日志编号");
validatePage(dto.getPageNum(), dto.getPageSize());
/*
* 第三步:已阅读必然表示已经接收,
* 因此拒绝“接收失败且已读”的无效组合条件。
*/
if (Boolean.FALSE.equals(dto.getReceived())
&& Boolean.TRUE.equals(dto.getRead())) {
throw new BusinessException(
BAD_REQUEST,
"接收失败的对象不能筛选为已阅读");
}
// 第四步:创建独立查询对象,避免修改控制器接收的原始 DTO。
NotificationLogRecipientQueryDTO query =
new NotificationLogRecipientQueryDTO();
// 第五步:写入已经完成校验和规范化的查询条件。
query.setId(id);
query.setPageNum(dto.getPageNum());
query.setPageSize(dto.getPageSize());
query.setKeyword(optionalText(
dto.getKeyword(),
RECIPIENT_KEYWORD_MAX_LENGTH,
"接收人关键字"));
query.setReceived(dto.getReceived());
query.setRead(dto.getRead());
// 第六步:返回仅供本次数据库查询使用的独立参数对象。
return query;
}
/**
* 将导出或统计条件转换为统一通知日志查询参数。
*
* @param dto 页面提交的导出筛选条件
* @return 使用固定分页参数且完成规范化的查询对象
*/
public static NotificationLogQueryDTO normalizeExportQuery(
NotificationLogExportDTO dto) {
// 第一步:兼容业务层直接传入 null,将其视为未设置筛选条件。
NotificationLogExportDTO source =
dto == null ? new NotificationLogExportDTO() : dto;
// 第二步:创建列表查询对象,只复制导出允许使用的业务筛选条件。
NotificationLogQueryDTO query =
new NotificationLogQueryDTO();
query.setTitle(source.getTitle());
query.setCategory(source.getCategory());
query.setSendStartTime(source.getSendStartTime());
query.setSendEndTime(source.getSendEndTime());
query.setSendStatus(source.getSendStatus());
/*
* 第三步:导出和统计不接收客户端分页参数,
* 此处使用列表接口允许的固定最大批次。
*/
query.setPageNum(Integer.valueOf(1));
query.setPageSize(Integer.valueOf(
NotificationLogConstants.EXPORT_BATCH_SIZE));
// 第四步:复用列表参数规则完成文本、时间和发送状态校验。
return normalizeQuery(query);
}
/**
* 补全当前页全部通知日志的发送和阅读统计字段。
*
* @param records Mapper 返回的当前页日志
* @return 字段完整且不包含 null 元素的通知日志列表
*/
public static List<NotificationLogVO> completeRecords(
List<NotificationLogVO> records) {
// 第一步:数据库未返回记录时统一使用不可变空集合。
if (records == null || records.isEmpty()) {
return Collections.emptyList();
}
// 第二步:逐条补全数量、发送状态和阅读率。
List<NotificationLogVO> completed =
new ArrayList<>(records.size());
for (NotificationLogVO record : records) {
if (record == null) {
continue;
}
completeRecord(record);
completed.add(record);
}
// 第三步:返回完成业务统计补全的当前页记录。
return completed;
}
/**
* 校验通知发送日志存在并补全详情统计字段。
*
* @param detail Mapper 返回的通知日志详情
* @return 完成发送和阅读统计补全的详情
*/
public static NotificationLogDetailVO completeDetail(
NotificationLogDetailVO detail) {
// 第一步:没有查询到实际发送记录时返回明确的不存在错误。
if (detail == null) {
throw new BusinessException(
NOT_FOUND, "通知发送日志不存在");
}
// 第二步:复用列表单条记录规则补全发送失败数、未读数和阅读率。
completeRecord(detail);
// 第三步:返回字段完整的通知发送日志详情。
return detail;
}
/**
* 补全通知日志汇总统计中的派生数量和阅读率。
*
* @param statistics Mapper 返回的基础汇总结果
* @return 不包含空数量且派生字段完整的汇总统计
*/
public static NotificationLogStatisticsVO completeStatistics(
NotificationLogStatisticsVO statistics) {
// 第一步:数据库没有返回统计对象时创建全零结果。
NotificationLogStatisticsVO result =
statistics == null
? new NotificationLogStatisticsVO()
: statistics;
// 第二步:把通知数量和三类发送结果统一转换为非负数。
result.setNotificationCount(Long.valueOf(nonNegative(
result.getNotificationCount())));
result.setSuccessNotificationCount(Long.valueOf(nonNegative(
result.getSuccessNotificationCount())));
result.setPartialNotificationCount(Long.valueOf(nonNegative(
result.getPartialNotificationCount())));
result.setFailedNotificationCount(Long.valueOf(nonNegative(
result.getFailedNotificationCount())));
// 第三步:规范化接收对象、成功接收和已阅读总数。
long recipientCount =
nonNegative(result.getRecipientCount());
long receivedCount =
Math.min(
recipientCount,
nonNegative(result.getReceivedCount()));
long readerCount =
Math.min(
recipientCount,
nonNegative(result.getReaderCount()));
result.setRecipientCount(Long.valueOf(recipientCount));
result.setReceivedCount(Long.valueOf(receivedCount));
result.setReaderCount(Long.valueOf(readerCount));
// 第四步:根据总人数计算发送失败人数和未阅读人数。
result.setFailedCount(Long.valueOf(
recipientCount - receivedCount));
result.setUnreadCount(Long.valueOf(
recipientCount - readerCount));
// 第五步:根据全部接收对象和阅读人数计算汇总阅读率。
result.setReadRate(calculateReadRate(
recipientCount, readerCount));
// 第六步:返回完成全部统计补全的结果。
return result;
}
/**
* 将通知日志列表生成 Excel 文件并写入 HTTP 响应。
*
* @param response 当前 HTTP 响应
* @param records 待导出的通知日志
*/
public static void writeNotificationLogExcel(
HttpServletResponse response,
List<NotificationLogVO> records) {
// 第一步:没有响应对象时无法向客户端输出导出文件。
if (response == null) {
throw new BusinessException(
"通知日志导出响应不能为空");
}
// 第二步:空数据集合统一转换为空列表,仍允许下载只有标题的文件。
List<NotificationLogVO> exportRecords =
records == null
? Collections.<NotificationLogVO>emptyList()
: records;
// 第三步:提前校验 Excel 单表最大数据行数。
if (exportRecords.size() > EXCEL_MAX_DATA_ROW_COUNT) {
throw new BusinessException(
"导出数据超过Excel单表最大行数");
}
/*
* 第四步:先在内存中完整生成文件。
* 生成失败时 HTTP 响应尚未写入,可以继续返回统一异常结果。
*/
byte[] fileContent =
createNotificationLogWorkbook(exportRecords);
// 第五步:使用服务端当前时间生成唯一下载文件名。
String fileName =
"通知日志_"
+ LocalDateTime.now().format(
NOTIFICATION_LOG_EXPORT_FILE_TIME_FORMATTER)
+ ".xlsx";
try {
// 第六步:按照 UTF-8 编码中文文件名并规范化空格编码。
String encodedFileName =
URLEncoder.encode(fileName, "UTF-8")
.replace("+", "%20");
// 第七步:设置 xlsx 响应类型、下载文件名和文件长度。
response.setCharacterEncoding("UTF-8");
response.setContentType(
"application/vnd.openxmlformats-officedocument"
+ ".spreadsheetml.sheet");
response.setHeader(
"Content-Disposition",
"attachment; filename*=UTF-8''"
+ encodedFileName);
response.setContentLength(fileContent.length);
// 第八步:将完整文件内容写入响应并立即刷新。
ServletOutputStream outputStream =
response.getOutputStream();
outputStream.write(fileContent);
outputStream.flush();
} catch (IOException exception) {
// 第九步:保留响应写入失败的原始异常原因。
throw new IllegalStateException(
"通知日志导出文件写入失败",
exception);
}
}
/**
* 在内存中创建通知日志 Excel 工作簿。
*
* @param records 待导出的通知日志
* @return 完整的 xlsx 文件字节
*/
private static byte[] createNotificationLogWorkbook(
List<NotificationLogVO> records) {
/*
* 第一步:创建 xlsx 工作簿和内存输出流。
* try-with-resources 保证生成成功或失败后都能释放资源。
*/
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream =
new ByteArrayOutputStream()) {
// 第二步:创建通知日志工作表并冻结标题行。
Sheet sheet = workbook.createSheet("通知日志");
sheet.createFreezePane(0, 1);
// 第三步:创建并复用标题和正文单元格样式。
CellStyle headerStyle =
createNotificationLogHeaderStyle(workbook);
CellStyle bodyStyle =
createNotificationLogBodyStyle(workbook);
// 第四步:按照固定列顺序写入标题行。
Row headerRow = sheet.createRow(0);
for (int columnIndex = 0;
columnIndex
< NOTIFICATION_LOG_EXPORT_HEADERS.length;
columnIndex++) {
Cell cell = headerRow.createCell(columnIndex);
cell.setCellValue(
NOTIFICATION_LOG_EXPORT_HEADERS[columnIndex]);
cell.setCellStyle(headerStyle);
}
// 第五步:按照业务层返回顺序逐行写入通知日志。
int rowIndex = 1;
for (NotificationLogVO record : records) {
// 忽略异常空记录,避免单条无效对象中断全部导出。
if (record == null) {
continue;
}
Row row = sheet.createRow(rowIndex++);
writeNotificationLogRow(row, record, bodyStyle);
}
// 第六步:按照预设字符宽度设置全部导出列。
for (int columnIndex = 0;
columnIndex
< NOTIFICATION_LOG_EXPORT_COLUMN_WIDTHS.length;
columnIndex++) {
sheet.setColumnWidth(
columnIndex,
NOTIFICATION_LOG_EXPORT_COLUMN_WIDTHS[columnIndex]
* 256);
}
// 第七步:为标题行增加自动筛选,方便下载后继续筛选。
sheet.setAutoFilter(
new CellRangeAddress(
0,
0,
0,
NOTIFICATION_LOG_EXPORT_HEADERS.length - 1));
// 第八步:将工作簿完整写入内存并返回独立字节数组。
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
// 第九步:工作簿生成失败时保留原始异常原因。
throw new IllegalStateException(
"通知日志Excel文件生成失败",
exception);
}
}
/**
* 将一条通知日志写入 Excel 正文行。
*
* @param row 当前 Excel 行
* @param record 当前通知日志
* @param bodyStyle 正文单元格样式
*/
private static void writeNotificationLogRow(
Row row,
NotificationLogVO record,
CellStyle bodyStyle) {
// 第一步:写入通知自身的编号、发送时间、标题和类别。
setNotificationLogCell(
row, 0, record.getId(), bodyStyle);
setNotificationLogCell(
row,
1,
formatNotificationLogTime(record.getSendTime()),
bodyStyle);
setNotificationLogCell(
row, 2, record.getTitle(), bodyStyle);
setNotificationLogCell(
row, 3, record.getCategory(), bodyStyle);
// 第二步:写入公告当前状态和本次综合发送状态。
setNotificationLogCell(
row, 4, record.getNoticeStatus(), bodyStyle);
setNotificationLogCell(
row, 5, record.getSendStatus(), bodyStyle);
// 第三步:写入接收对象、成功接收和发送失败数量。
setNotificationLogCell(
row, 6, record.getRecipientCount(), bodyStyle);
setNotificationLogCell(
row, 7, record.getReceivedCount(), bodyStyle);
setNotificationLogCell(
row, 8, record.getFailedCount(), bodyStyle);
// 第四步:写入已读、未读、阅读率和累计打开次数。
setNotificationLogCell(
row, 9, record.getReaderCount(), bodyStyle);
setNotificationLogCell(
row, 10, record.getUnreadCount(), bodyStyle);
setNotificationLogCell(
row,
11,
record.getReadRate() == null
? null
: record.getReadRate().toPlainString() + "%",
bodyStyle);
setNotificationLogCell(
row, 12, record.getReadCount(), bodyStyle);
}
/**
* 创建通知日志 Excel 标题样式。
*
* @param workbook 当前工作簿
* @return 可复用的标题样式
*/
private static CellStyle createNotificationLogHeaderStyle(
Workbook workbook) {
// 第一步:创建加粗标题字体。
Font font = workbook.createFont();
font.setBold(true);
// 第二步:创建居中、浅灰背景和细边框的标题样式。
CellStyle style = workbook.createCellStyle();
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setFillForegroundColor(
IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
setNotificationLogCellBorders(style);
// 第三步:返回供全部标题单元格复用的样式。
return style;
}
/**
* 创建通知日志 Excel 正文样式。
*
* @param workbook 当前工作簿
* @return 可复用的正文样式
*/
private static CellStyle createNotificationLogBodyStyle(
Workbook workbook) {
// 第一步:创建垂直居中且允许长文本换行的正文样式。
CellStyle style = workbook.createCellStyle();
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(true);
// 第二步:为正文单元格设置统一细边框。
setNotificationLogCellBorders(style);
// 第三步:返回供全部正文单元格复用的样式。
return style;
}
/**
* 为通知日志 Excel 样式设置统一细边框。
*
* @param style 待设置的单元格样式
*/
private static void setNotificationLogCellBorders(
CellStyle style) {
// 统一设置上下左右细边框,保证导出表格边界清晰。
style.setBorderTop(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
}
/**
* 写入经过安全处理的 Excel 文本单元格。
*
* @param row 当前 Excel 行
* @param columnIndex 当前列下标
* @param value 原始单元格值
* @param style 正文样式
*/
private static void setNotificationLogCell(
Row row,
int columnIndex,
Object value,
CellStyle style) {
// 第一步:创建目标单元格。
Cell cell = row.createCell(columnIndex);
// 第二步:空值写为空文本,其余值先转换为字符串。
String text = value == null ? "" : String.valueOf(value);
// 第三步:写入经过公式注入防护的安全文本。
cell.setCellValue(toSafeExcelText(text));
// 第四步:应用统一正文样式。
cell.setCellStyle(style);
}
/**
* 将可能触发 Excel 公式解析的文本转换为普通文本。
*
* @param value 原始文本
* @return 可安全写入 Excel 的文本
*/
private static String toSafeExcelText(
String value) {
// 第一步:空文本无需进行公式起始字符判断。
if (value == null || value.isEmpty()) {
return "";
}
// 第二步:识别 Excel 可能作为公式解析的起始字符。
char firstCharacter = value.charAt(0);
if (firstCharacter == '='
|| firstCharacter == '+'
|| firstCharacter == '-'
|| firstCharacter == '@'
|| firstCharacter == '\t'
|| firstCharacter == '\r'
|| firstCharacter == '\n') {
// 第三步:增加单引号前缀,强制 Excel 按普通文本显示。
return "'" + value;
}
// 第四步:普通文本保持原值。
return value;
}
/**
* 将通知发送时间格式化为 Excel 展示文本。
*
* @param value 原始发送时间
* @return 固定格式时间;空值返回空文本
*/
private static String formatNotificationLogTime(
LocalDateTime value) {
// 空时间统一写为空文本。
return value == null
? ""
: value.format(
NOTIFICATION_LOG_EXPORT_CELL_TIME_FORMATTER);
}
/**
* 校验分页参数。
*
* @param pageNum 当前页码
* @param pageSize 每页记录数
*/
private static void validatePage(
Integer pageNum,
Integer pageSize) {
// 第一步:页码必须从1开始。
if (pageNum == null || pageNum.intValue() < 1) {
throw new BusinessException(
BAD_REQUEST, "页码最小为1");
}
// 第二步:每页记录数必须处于系统允许范围。
if (pageSize == null
|| pageSize.intValue() < 1
|| pageSize.intValue() > MAX_PAGE_SIZE) {
throw new BusinessException(
BAD_REQUEST, "每页大小必须在1到100之间");
}
}
/**
* 校验发送时间范围。
*
* @param startTime 发送开始时间
* @param endTime 发送结束时间
*/
private static void validateTimeRange(
LocalDateTime startTime,
LocalDateTime endTime) {
// 开始时间和结束时间同时存在时,开始时间不能晚于结束时间。
if (startTime != null
&& endTime != null
&& startTime.isAfter(endTime)) {
throw new BusinessException(
BAD_REQUEST,
"发送开始时间不能晚于发送结束时间");
}
}
/**
* 规范化可选文本条件。
*
* @param value 原始文本
* @param maxLength 最大长度
* @param fieldName 字段名称
* @return 去除首尾空白后的文本;空文本返回 null
*/
private static String optionalText(
String value,
int maxLength,
String fieldName) {
// 第一步:null 文本表示不启用当前筛选条件。
if (value == null) {
return null;
}
// 第二步:去除首尾空白,空字符串同样表示不启用筛选。
String normalized = value.trim();
if (normalized.isEmpty()) {
return null;
}
// 第三步:校验规范化后的真实文本长度。
if (normalized.length() > maxLength) {
throw new BusinessException(
BAD_REQUEST,
fieldName + "不能超过"
+ maxLength + "个字符");
}
// 第四步:返回可直接用于查询的规范化文本。
return normalized;
}
/**
* 规范化必填文本并校验最大长度。
*
* @param value 原始文本
* @param maxLength 最大长度
* @param fieldName 字段名称
* @return 去除首尾空白后的有效文本
*/
private static String requiredText(
String value,
int maxLength,
String fieldName) {
// 第一步:复用可选文本规则完成去空白和长度校验。
String normalized =
optionalText(value, maxLength, fieldName);
// 第二步:规范化后没有有效内容时返回必填参数错误。
if (normalized == null) {
throw new BusinessException(
BAD_REQUEST, fieldName + "不能为空");
}
// 第三步:返回可用于数据库查询的有效文本。
return normalized;
}
/**
* 规范化并校验发送状态。
*
* @param sendStatus 页面提交的发送状态
* @return 规范化后的发送状态;未选择时返回 null
*/
private static String normalizeSendStatus(
String sendStatus) {
// 第一步:去除无效空白并识别未选择状态的情况。
String normalized = optionalText(
sendStatus,
NotificationLogConstants.SEND_STATUS_MAX_LENGTH,
"发送状态");
if (normalized == null) {
return null;
}
// 第二步:只允许查询通知日志定义的三种发送状态。
if (!SEND_STATUS_SUCCESS.equals(normalized)
&& !SEND_STATUS_PARTIAL.equals(normalized)
&& !SEND_STATUS_FAILED.equals(normalized)) {
throw new BusinessException(
BAD_REQUEST,
"发送状态必须为发送成功、部分成功或发送失败");
}
// 第三步:返回通过白名单校验的发送状态。
return normalized;
}
/**
* 补全一条通知日志的统计结果。
*
* @param record 待补全的通知日志
*/
private static void completeRecord(
NotificationLogVO record) {
// 第一步:把数据库可能返回的空数量统一转换为非负数。
long recipientCount =
nonNegative(record.getRecipientCount());
long receivedCount =
Math.min(
recipientCount,
nonNegative(record.getReceivedCount()));
long readerCount =
Math.min(
recipientCount,
nonNegative(record.getReaderCount()));
// 第二步:写回规范化后的基础统计数量。
record.setRecipientCount(
Long.valueOf(recipientCount));
record.setReceivedCount(
Long.valueOf(receivedCount));
record.setReaderCount(
Long.valueOf(readerCount));
// 第三步:根据接收和阅读人数计算失败人数及未读人数。
record.setFailedCount(
Long.valueOf(
recipientCount - receivedCount));
record.setUnreadCount(
Long.valueOf(
recipientCount - readerCount));
// 第四步:根据接收情况确定本次通知的综合发送状态。
record.setSendStatus(resolveSendStatus(
recipientCount,
receivedCount));
// 第五步:按照接收对象总数计算阅读率,避免除零异常。
record.setReadRate(calculateReadRate(
recipientCount,
readerCount));
}
/**
* 根据接收总数和成功接收数确定发送状态。
*
* @param recipientCount 接收对象总数
* @param receivedCount 成功接收人数
* @return 发送成功、部分成功或发送失败
*/
private static String resolveSendStatus(
long recipientCount,
long receivedCount) {
// 没有任何有效接收或成功接收记录时视为发送失败。
if (recipientCount == 0L || receivedCount == 0L) {
return SEND_STATUS_FAILED;
}
// 全部接收对象均接收成功时视为发送成功。
if (receivedCount >= recipientCount) {
return SEND_STATUS_SUCCESS;
}
// 其余情况表示只有部分接收对象接收成功。
return SEND_STATUS_PARTIAL;
}
/**
* 计算阅读率百分比。
*
* @param recipientCount 接收对象总数
* @param readerCount 已阅读人数
* @return 保留两位小数的阅读率
*/
private static BigDecimal calculateReadRate(
long recipientCount,
long readerCount) {
// 没有接收对象时阅读率固定为0。
if (recipientCount == 0L) {
return BigDecimal.ZERO.setScale(
READ_RATE_SCALE,
RoundingMode.HALF_UP);
}
// 使用高精度十进制计算百分比并统一保留两位小数。
return BigDecimal.valueOf(readerCount)
.multiply(BigDecimal.valueOf(100L))
.divide(
BigDecimal.valueOf(recipientCount),
READ_RATE_SCALE,
RoundingMode.HALF_UP);
}
/**
* 把可能为空或小于零的数据库数量转换为非负数。
*
* @param value 数据库返回数量
* @return 非负数量
*/
private static long nonNegative(Long value) {
// 空值和负值统一按照0处理。
return value == null || value.longValue() < 0L
? 0L
: value.longValue();
}
}
@@ -0,0 +1,90 @@
package com.roomroot.jwgl.utils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.roomroot.jwgl.unit.PageResult;
import java.util.Collections;
import java.util.List;
/**
* 项目统一分页结果转换工具类。
*
* <p>集中处理 Mapper 返回空分页对象、分页记录集合为 null 以及
* MyBatis-Plus 分页对象转换为项目 {@link PageResult} 的通用逻辑。</p>
*/
public final class PageResultUtil {
/**
* 工具类不允许创建实例。
*/
private PageResultUtil() {
}
/**
* 将 Mapper 返回的 MyBatis-Plus 分页对象转换为项目统一分页结果。
*
* @param selectedPage Mapper 返回的分页对象
* @param defaultPage Mapper 返回 null 时使用的默认分页对象
* @param pageNum 当前页码
* @param pageSize 每页记录数
* @param <T> 分页记录类型
* @return 项目统一分页结果
*/
public static <T> PageResult<T> fromPage(
Page<T> selectedPage,
Page<T> defaultPage,
Integer pageNum,
Integer pageSize) {
// 第一步:优先使用 Mapper 返回结果,为 null 时回退到调用方创建的分页对象。
Page<T> resultPage =
selectedPage == null ? defaultPage : selectedPage;
// 第二步:读取分页记录,空分页对象或空记录集合统一按照空集合处理。
List<T> records = resultPage == null
? Collections.<T>emptyList()
: resultPage.getRecords();
// 第三步:使用通用记录组装方法返回项目统一分页结构。
return fromRecords(
resultPage,
records,
pageNum,
pageSize);
}
/**
* 使用已经完成业务转换的记录集合组装项目统一分页结果。
*
* <p>适用于 Mapper 分页记录还需要补充字段或转换为 VO 的场景,
* 分页总数继续取自原始 MyBatis-Plus 分页对象。</p>
*
* @param resultPage 提供分页总数的 MyBatis-Plus 分页对象
* @param records 已经完成业务处理的当前页记录
* @param pageNum 当前页码
* @param pageSize 每页记录数
* @param <T> 对外返回的分页记录类型
* @return 项目统一分页结果
*/
public static <T> PageResult<T> fromRecords(
Page<?> resultPage,
List<T> records,
Integer pageNum,
Integer pageSize) {
// 第一步:将 null 记录集合统一转换为不可变空集合,保证返回结构稳定。
List<T> safeRecords = records == null
? Collections.<T>emptyList()
: records;
// 第二步:分页对象为空时使用零条记录,避免公共转换过程产生空指针。
Long total = resultPage == null
? Long.valueOf(0L)
: Long.valueOf(resultPage.getTotal());
// 第三步:创建并返回项目统一分页结果。
return new PageResult<>(
safeRecords,
total,
pageNum,
pageSize);
}
}
@@ -0,0 +1,71 @@
package com.roomroot.jwgl.utils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class SupportConfirmUtil {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private SupportConfirmUtil() {
}
public static boolean isConfirmed(String remark) {
return remark != null && remark.contains("\"confirmed\":true");
}
public static String buildConfirmJson(String qrrbh, String qrbz) {
String confirmAt = LocalDateTime.now().format(DATE_TIME_FORMATTER);
StringBuilder sb = new StringBuilder();
sb.append("{\"confirmed\":true,");
sb.append("\"confirmedBy\":\"").append(qrrbh).append("\",");
sb.append("\"confirmedAt\":\"").append(confirmAt).append("\",");
sb.append("\"confirmRemark\":\"");
if (qrbz != null) {
sb.append(qrbz.replace("\"", "\\\""));
}
sb.append("\"}");
return sb.toString();
}
public static String mergeRemark(String originalRemark, String newRemark) {
if (originalRemark == null || originalRemark.trim().isEmpty()) {
return newRemark;
}
if (newRemark == null || newRemark.trim().isEmpty()) {
return originalRemark;
}
if (newRemark.contains("\"confirmed\":true")) {
return originalRemark + " | " + newRemark;
}
if (originalRemark.contains("\"confirmed\":true")) {
int splitIndex = originalRemark.indexOf(" | {");
if (splitIndex > 0) {
return newRemark + originalRemark.substring(splitIndex);
}
return newRemark + " | " + originalRemark;
}
return newRemark;
}
public static String clearConfirmJson(String remark) {
if (remark == null || remark.trim().isEmpty()) {
return "";
}
int splitIndex = remark.indexOf(" | {");
if (splitIndex > 0) {
return remark.substring(0, splitIndex);
}
if (remark.contains("\"confirmed\":true")) {
return "";
}
return remark;
}
}
@@ -0,0 +1,40 @@
package com.roomroot.jwgl.utils;
import java.util.UUID;
/**
* UUID生成工具类
*/
public class UuidUtil {
/**
* 原生UUID 带横线 36位
* 示例:550e8400-e29b-41d4-a716-446655440000
*/
public static String getOriginalUUID() {
return UUID.randomUUID().toString();
}
/**
* 去掉横线,小写 32位(最常用)
* 示例:550e8400e29b41d4a716446655440000
*/
public static String getUUID() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 去掉横线,大写 32位
*/
public static String getUpperUUID() {
return UUID.randomUUID().toString().replace("-", "").toUpperCase();
}
/**
* 自定义前缀 + UUID
* @param prefix 前缀
*/
public static String getUuidWithPrefix(String prefix) {
return prefix + getUUID();
}
}