From a53a0390465219ac24825259693d00928d77e11f Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 14 Sep 2026 09:13:47 +0900 Subject: [PATCH 1/4] fix(core): fix multiple bugs in utility classes and add regression tests --- .../java/info/xiaomo/core/utils/CastUtil.java | 63 ++++------ .../info/xiaomo/core/utils/ExcelUtil.java | 8 +- .../java/info/xiaomo/core/utils/FileUtil.java | 10 +- .../java/info/xiaomo/core/utils/IDUtil.java | 5 +- .../java/info/xiaomo/core/utils/MailUtil.java | 47 ++------ .../java/info/xiaomo/core/utils/Md5Util.java | 3 +- .../info/xiaomo/core/utils/RegExUtil.java | 18 ++- .../info/xiaomo/core/utils/SerializeUtil.java | 6 +- .../java/info/xiaomo/core/utils/SqlUtil.java | 3 + .../info/xiaomo/core/utils/StringUtil.java | 64 ++++++---- .../java/info/xiaomo/core/utils/TimeUtil.java | 49 ++------ .../info/xiaomo/core/utils/TokenUtil.java | 6 +- .../xiaomo/core/utils/UtilsBugFixTest.java | 112 ++++++++++++++++++ 13 files changed, 232 insertions(+), 162 deletions(-) create mode 100644 core/src/test/java/info/xiaomo/core/utils/UtilsBugFixTest.java diff --git a/core/src/main/java/info/xiaomo/core/utils/CastUtil.java b/core/src/main/java/info/xiaomo/core/utils/CastUtil.java index e3bf4938..f0f5b514 100644 --- a/core/src/main/java/info/xiaomo/core/utils/CastUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/CastUtil.java @@ -1,6 +1,7 @@ package info.xiaomo.core.utils; import java.io.*; +import java.math.BigDecimal; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.HashMap; @@ -74,49 +75,31 @@ public static int toInteger(String str) { } public static int isNumeric(String str) { - if (str == null) { + if (str == null || str.isEmpty()) { return 0; - } else { - boolean isdouble = false; - boolean hasE = false; - int i = str.length(); - - while (true) { - while (true) { - char c; - do { - --i; - if (i < 0) { - if (isdouble) { - return 2; - } - - return 1; - } - - c = str.charAt(i); - } while (i == 0 && c == 45); - - if (c == 46) { - if (isdouble) { - return 0; - } - - isdouble = true; - } else if (c != 69 && c != 101) { - if (!Character.isDigit(str.charAt(i))) { - return 0; - } - } else { - if (hasE) { - return 0; - } - - hasE = true; - } - } + } + String check = str; + if (check.charAt(0) == '-') { + // 允许唯一的负号前缀(负整数), 与旧实现一致; "-" 单独出现视为非数字 + check = check.substring(1); + if (check.isEmpty()) { + return 0; + } + } else if (check.charAt(0) == '+') { + return 0; + } + try { + // 用 BigDecimal 严格校验, "1.2.3" 之类非法数字返回 0, 不再抛运行期异常 + new BigDecimal(check); + } catch (NumberFormatException e) { + return 0; + } + for (int i = 0; i < check.length(); i++) { + if (!Character.isDigit(check.charAt(i))) { + return 2; } } + return 1; } public static HashMap copyMap(HashMap map) { diff --git a/core/src/main/java/info/xiaomo/core/utils/ExcelUtil.java b/core/src/main/java/info/xiaomo/core/utils/ExcelUtil.java index 8d4fc80b..ca84130f 100644 --- a/core/src/main/java/info/xiaomo/core/utils/ExcelUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/ExcelUtil.java @@ -102,11 +102,11 @@ private static String[][] getData(File file, int ignoreRows) } break; case FORMULA: - // 导入时如果为公式生成的数据则无值 - if (!"".equals(cell.getStringCellValue())) { + // 公式单元格可能是字符串或数值, 分别取值, 避免类型不匹配时抛异常 + try { value = cell.getStringCellValue(); - } else { - value = cell.getNumericCellValue() + ""; + } catch (IllegalStateException e) { + value = String.valueOf(cell.getNumericCellValue()); } break; case BLANK: diff --git a/core/src/main/java/info/xiaomo/core/utils/FileUtil.java b/core/src/main/java/info/xiaomo/core/utils/FileUtil.java index 0e8e965c..0d50087a 100644 --- a/core/src/main/java/info/xiaomo/core/utils/FileUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/FileUtil.java @@ -856,11 +856,11 @@ private static String sanitizeNamePart(String part) { public static boolean isImage(String imageName) { String fileType = FileUtil.getFileType(imageName); - return !("bmp".equals(fileType) || "BMP".equals(fileType) - || "jpg".equals(fileType) || "JPG".equals(fileType) - || "jpeg".equals(fileType) || "JPEG".equals(fileType) - || "git".equals(fileType) || "GIF".equals(fileType) - || "png".equals(fileType) || "PNG".equals(fileType)); + return "bmp".equalsIgnoreCase(fileType) + || "jpg".equalsIgnoreCase(fileType) + || "jpeg".equalsIgnoreCase(fileType) + || "gif".equalsIgnoreCase(fileType) + || "png".equalsIgnoreCase(fileType); } } diff --git a/core/src/main/java/info/xiaomo/core/utils/IDUtil.java b/core/src/main/java/info/xiaomo/core/utils/IDUtil.java index 7e12cfa2..66744220 100644 --- a/core/src/main/java/info/xiaomo/core/utils/IDUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/IDUtil.java @@ -25,7 +25,10 @@ public static void main(String[] args) { } /** - * 获取唯一一个id + * 获取唯一一个id。 + * + *

注意: 该实现只在单个 JVM 进程内(秒内 65000 个)保证单调不回退, 并非跨进程、 + * 跨重启全局唯一的 snowflake 方案, 不适用于分布式主键场景。 * * @return long */ diff --git a/core/src/main/java/info/xiaomo/core/utils/MailUtil.java b/core/src/main/java/info/xiaomo/core/utils/MailUtil.java index bd91d7d5..7b2ef621 100644 --- a/core/src/main/java/info/xiaomo/core/utils/MailUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/MailUtil.java @@ -3,9 +3,10 @@ import jakarta.mail.*; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; -import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.Date; +import java.util.Objects; import java.util.Properties; /** @@ -31,11 +32,15 @@ public class MailUtil { */ private static Session getSession() throws IOException { Properties props = new Properties(); - String dir = System.getProperty("user.dir"); - FileInputStream is = new FileInputStream(dir + "/website/src/main/resources/config/application.properties"); - props.load(is); - USERNAME = String.valueOf(props.get("mail.username")); - PASSWORD = String.valueOf(props.get("mail.password")); + // 从 classpath 读取配置, 不再依赖进程工作目录(user.dir)下的绝对路径 + try (InputStream is = MailUtil.class.getClassLoader().getResourceAsStream("config/application.properties")) { + if (is == null) { + throw new IOException("未找到 classpath 下的 config/application.properties"); + } + props.load(is); + } + USERNAME = Objects.toString(props.get("mail.username"), ""); + PASSWORD = Objects.toString(props.get("mail.password"), ""); Authenticator authenticator = new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { @@ -61,34 +66,4 @@ public static void send(String toEmail, String subject, String content) { mex.printStackTrace(); } } - - /** - * 返回激活链接 - * - * @param email email - * @return 有3个参数 email password time - */ - public static String redirectValidateUrl(String email, String password) { - Long now = TimeUtil.getNowOfMills(); - StringBuilder sb = new StringBuilder("点击下面链接激活账号,48小时生效,否则重新注册账号,链接只能使用一次,请尽快激活!
"); - sb.append(""); - sb.append("http://localhost:8080/user/validate?email="); - sb.append(email); - sb.append("&password="); - sb.append(password); - sb.append("&time="); - sb.append(now); - sb.append("
"); - sb.append("小莫
"); - sb.append(""); - sb.append(TimeUtil.getFormatDate()); - sb.append("
"); - return sb.toString(); - } } diff --git a/core/src/main/java/info/xiaomo/core/utils/Md5Util.java b/core/src/main/java/info/xiaomo/core/utils/Md5Util.java index 309ab9ec..f1554c33 100644 --- a/core/src/main/java/info/xiaomo/core/utils/Md5Util.java +++ b/core/src/main/java/info/xiaomo/core/utils/Md5Util.java @@ -1,5 +1,6 @@ package info.xiaomo.core.utils; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; /** @@ -70,7 +71,7 @@ public static String encode(String password, String salt) { try { resultString = password + salt; MessageDigest md = MessageDigest.getInstance("md5"); - resultString = byteArrayToString(md.digest(resultString.getBytes())); + resultString = byteArrayToString(md.digest(resultString.getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { ex.printStackTrace(); } diff --git a/core/src/main/java/info/xiaomo/core/utils/RegExUtil.java b/core/src/main/java/info/xiaomo/core/utils/RegExUtil.java index 5f24b994..da9e13ad 100644 --- a/core/src/main/java/info/xiaomo/core/utils/RegExUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/RegExUtil.java @@ -2,6 +2,8 @@ import org.apache.commons.lang3.StringUtils; +import java.util.ArrayList; +import java.util.List; import java.util.Vector; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -167,13 +169,11 @@ public static Vector splitTags2Vector(String pattern, String str) throws public static String[] splitTags(String pattern, String str) { Pattern p = Pattern.compile(pattern); Matcher m = p.matcher(str); - String[] array = new String[m.groupCount()]; - int i = 0; + List array = new ArrayList<>(); while (m.find()) { - array[i] = eregReplace("(\\[\\#)|(\\#\\])", "", m.group()); - i++; + array.add(eregReplace("(\\[\\#)|(\\#\\])", "", m.group())); } - return array; + return array.toArray(new String[0]); } @@ -206,13 +206,11 @@ public static Vector regMatchAll2Vector(String pattern, String str) thro public static String[] regMatchAll2Array(String pattern, String str) throws PatternSyntaxException { Pattern p = Pattern.compile(pattern); Matcher m = p.matcher(str); - String[] array = new String[m.groupCount()]; - int i = 0; + List array = new ArrayList<>(); while (m.find()) { - array[i] = m.group(); - i++; + array.add(m.group()); } - return array; + return array.toArray(new String[0]); } /** diff --git a/core/src/main/java/info/xiaomo/core/utils/SerializeUtil.java b/core/src/main/java/info/xiaomo/core/utils/SerializeUtil.java index 3dcac2de..db2fe20e 100644 --- a/core/src/main/java/info/xiaomo/core/utils/SerializeUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/SerializeUtil.java @@ -41,7 +41,7 @@ public static String serialize(Object object) { oos = new ObjectOutputStream(baos); oos.writeObject(object); byte[] bytes = baos.toByteArray(); - return new String(bytes, StandardCharsets.UTF_8); + return new String(bytes, StandardCharsets.ISO_8859_1); } catch (Exception e) { e.printStackTrace(); } @@ -51,8 +51,8 @@ public static String serialize(Object object) { /** * 反序列化 */ - public static Object unserialize(String s) throws UnsupportedEncodingException { - byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + public static Object unserialize(String s) { + byte[] bytes = s.getBytes(StandardCharsets.ISO_8859_1); ByteArrayInputStream bais; try { //反序列化 diff --git a/core/src/main/java/info/xiaomo/core/utils/SqlUtil.java b/core/src/main/java/info/xiaomo/core/utils/SqlUtil.java index 66e53e00..bbdb246d 100644 --- a/core/src/main/java/info/xiaomo/core/utils/SqlUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/SqlUtil.java @@ -53,6 +53,9 @@ private String delNewSQlString(String sql) { } private String delSQlString(String sql) { + if (sql == null || sql.isEmpty()) { + return "in()"; + } StringBuilder delSql = new StringBuilder("in("); StringTokenizer tokenizer = new StringTokenizer(sql, "|"); diff --git a/core/src/main/java/info/xiaomo/core/utils/StringUtil.java b/core/src/main/java/info/xiaomo/core/utils/StringUtil.java index 78f27dd1..e616106c 100644 --- a/core/src/main/java/info/xiaomo/core/utils/StringUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/StringUtil.java @@ -194,8 +194,10 @@ public static String replace(String s, Map map) { char ch = '}'; for (int start, end; (start = s.indexOf(str, cursor)) != -1 && (end = s.indexOf(ch, start)) != -1; ) { sb.append(s.substring(cursor, start)); - String key = s.substring(start + 2, end); - sb.append(map.get(StringUtils.trim(key))); + String key = StringUtils.trim(s.substring(start + 2, end)); + String value = map.get(key); + // 缺失的 key 原样保留占位符, 而不是拼出字面量 "null" + sb.append(value != null ? value : str + key + ch); cursor = end + 1; } sb.append(s.substring(cursor, s.length())); @@ -209,32 +211,37 @@ public static String replace(String s, Map map) { * @return ip 如果返回null,说明是一个不合法的ip地址格式 */ public static String getIP(HttpServletRequest request) { - String ip = request.getHeader("X-Requested-For"); - String unknown = "unknown"; - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { - ip = request.getHeader("X-Forwarded-For"); - } - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { - ip = request.getHeader("Proxy-Client-IP"); - } - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { - ip = request.getHeader("WL-Proxy-Client-IP"); - } - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_CLIENT_IP"); - } - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_X_FORWARDED_FOR"); - } - if (StringUtils.isBlank(ip) || unknown.equalsIgnoreCase(ip)) { + // 反代场景下这些头可能是逗号分隔的多级代理列表, 取第一段(真实客户端地址)并跳过 "unknown" + String ip = firstCandidate(request.getHeader("X-Forwarded-For"), + request.getHeader("X-Requested-For"), + request.getHeader("Proxy-Client-IP"), + request.getHeader("WL-Proxy-Client-IP"), + request.getHeader("HTTP_CLIENT_IP"), + request.getHeader("HTTP_X_FORWARDED_FOR")); + if (ip == null) { ip = request.getRemoteAddr(); } - if (!ip.matches(IP_REGEX)) { + if (ip == null || !ip.matches(IP_REGEX)) { return null; } return ip; } + private static String firstCandidate(String... headers) { + for (String header : headers) { + if (StringUtils.isBlank(header)) { + continue; + } + for (String part : header.split(",")) { + String candidate = part.trim(); + if (StringUtils.isNotBlank(candidate) && !"unknown".equalsIgnoreCase(candidate)) { + return candidate; + } + } + } + return null; + } + /** * 判断字符串是否为空,并删除首尾空格 * @@ -467,7 +474,7 @@ public static String format(String str, Object... params) { param = obj.toString(); } } - m.appendReplacement(sb, param); + m.appendReplacement(sb, Matcher.quoteReplacement(param)); } m.appendTail(sb); return sb.toString(); @@ -964,12 +971,25 @@ public static String decode(String s) { || ch == ')') { sbuf.append((char) ch); } else if (ch == '%') { + // 末尾残缺的 % (如 "abc", "abc%u123") 直接原样输出剩余部分, 不再越界访问 + if (i + 1 >= len) { + sbuf.append(s.substring(i)); + break; + } int cint = 0; if ('u' != s.charAt(i + 1)) { + if (i + 3 > len) { + sbuf.append(s.substring(i)); + break; + } cint = (cint << 4) | VAL[s.charAt(i + 1)]; cint = (cint << 4) | VAL[s.charAt(i + 2)]; i += 2; } else { + if (i + 6 > len) { + sbuf.append(s.substring(i)); + break; + } cint = (cint << 4) | VAL[s.charAt(i + 2)]; cint = (cint << 4) | VAL[s.charAt(i + 3)]; cint = (cint << 4) | VAL[s.charAt(i + 4)]; diff --git a/core/src/main/java/info/xiaomo/core/utils/TimeUtil.java b/core/src/main/java/info/xiaomo/core/utils/TimeUtil.java index aab097dc..86072eb4 100644 --- a/core/src/main/java/info/xiaomo/core/utils/TimeUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/TimeUtil.java @@ -53,7 +53,7 @@ public class TimeUtil { public static final String DATE = MONTH + SymbolConst.HENGXIAN + DAY; public static final String TIME_PATTERN = DATE_PATTERN_WITH_HENGXIAN + " HH:mm:ss"; - public static final String DATE_PATTERN = " HH:mm:ss"; + public static final String DATE_PATTERN = "HH:mm:ss"; public static final FastDateFormat DATE_FORMAT = FastDateFormat.getInstance("yyyy-MM-dd"); public static final String DATE_FORMAT_STRING = "yyyyMMddHHmmss"; @@ -743,51 +743,24 @@ public static long getTimeFromString(String timeStr, String format) { * 例如:1天20小时5分0秒,20小时0分0秒,1秒 */ public static String getLeftTimeString(long leftTime) { - StringBuilder sb = new StringBuilder(); - // 剩余秒数 - int leftSecond = (int) (leftTime / 1000); - // 秒数 - int second = leftSecond % 60; - if (second > 0) { - sb.insert(0, second + "秒"); - } - // 剩余分钟数 - int leftMinute = leftSecond / 60; - // 分钟数 - int minute = leftMinute % 60; - if (minute > 0) { - sb.insert(0, minute + "分"); - } - // 剩余小时 - int leftHour = leftMinute / 60; - int hour = leftHour % 24; - if (hour > 0) { - sb.insert(0, hour + "小时"); + if (leftTime <= 0) { + return "0秒"; } - // 剩余天数 - int leftDay = leftHour / 24; - if (leftDay > 0) { - sb.insert(0, leftDay + "天"); - } - // 获取剩余天数 - int day = (int) (leftTime / ONE_DAY_IN_MILLISECONDS); - // 1天及以上的显示剩余天 + long day = leftTime / ONE_DAY_IN_MILLISECONDS; + long hour = (leftTime % ONE_DAY_IN_MILLISECONDS) / ONE_HOUR_IN_MILLISECONDS; + long minute = (leftTime % ONE_HOUR_IN_MILLISECONDS) / ONE_MINUTE_IN_MILLISECONDS; + long second = (leftTime % ONE_MINUTE_IN_MILLISECONDS) / 1000; + StringBuilder sb = new StringBuilder(); if (day > 0) { sb.append(day).append("天"); - leftTime -= (day * ONE_DAY_IN_MILLISECONDS); } - hour = (int) (leftTime / ONE_HOUR_IN_MILLISECONDS); - // 1小时及以上或者前面显示了天数则后面需要小时 - if (hour > 0 || sb.length() > 0) { + if (day > 0 || hour > 0) { sb.append(hour).append("小时"); - leftTime -= (hour * ONE_HOUR_IN_MILLISECONDS); } - minute = (int) (leftTime / ONE_MINUTE_IN_MILLISECONDS); - if (minute > 0 || sb.length() > 0) { + if (day > 0 || hour > 0 || minute > 0) { sb.append(minute).append("分"); - leftTime -= (minute * ONE_MINUTE_IN_MILLISECONDS); } - sb.append(leftTime / 1000).append("秒"); + sb.append(second).append("秒"); return sb.toString(); } diff --git a/core/src/main/java/info/xiaomo/core/utils/TokenUtil.java b/core/src/main/java/info/xiaomo/core/utils/TokenUtil.java index 17a05852..8ce1f331 100644 --- a/core/src/main/java/info/xiaomo/core/utils/TokenUtil.java +++ b/core/src/main/java/info/xiaomo/core/utils/TokenUtil.java @@ -39,8 +39,10 @@ public static String getAccessToken(String string) { if (m2.find()) { accessToken = m2.group(1); } else { - String temp = string.split(SymbolConst.DENGHAO)[1]; - accessToken = temp.split(SymbolConst.AND)[0]; + String[] parts = string.split(SymbolConst.DENGHAO); + if (parts.length > 1) { + accessToken = parts[1].split(SymbolConst.AND)[0]; + } } } } diff --git a/core/src/test/java/info/xiaomo/core/utils/UtilsBugFixTest.java b/core/src/test/java/info/xiaomo/core/utils/UtilsBugFixTest.java new file mode 100644 index 00000000..5c56e3f9 --- /dev/null +++ b/core/src/test/java/info/xiaomo/core/utils/UtilsBugFixTest.java @@ -0,0 +1,112 @@ +package info.xiaomo.core.utils; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 覆盖本次 bug 修复涉及的几个工具方法, 防止改回去。 + */ +class UtilsBugFixTest { + + @Test + void isImage应当识别常见图片扩展名而不误判() { + assertThat(FileUtil.isImage("avatar.jpg")).isTrue(); + assertThat(FileUtil.isImage("avatar.JPEG")).isTrue(); + assertThat(FileUtil.isImage("avatar.Png")).isTrue(); + assertThat(FileUtil.isImage("avatar.gif")).isTrue(); + assertThat(FileUtil.isImage("avatar.bmp")).isTrue(); + + assertThat(FileUtil.isImage("readme.txt")).isFalse(); + assertThat(FileUtil.isImage("video.mp4")).isFalse(); + } + + @Test + void 剩余天数小时应按既定格式输出() { + // 1天 + 20小时 + 5分 + 0秒 + assertThat(TimeUtil.getLeftTimeString(1L * 86400000 + 20 * 3600000 + 5 * 60000)) + .isEqualTo("1天20小时5分0秒"); + // 20小时 + 0分 + 0秒 + assertThat(TimeUtil.getLeftTimeString(20L * 3600000)).isEqualTo("20小时0分0秒"); + assertThat(TimeUtil.getLeftTimeString(1000)).isEqualTo("1秒"); + } + + @Test + void 剩余时间为零或负数时应当返回零秒() { + assertThat(TimeUtil.getLeftTimeString(0)).isEqualTo("0秒"); + assertThat(TimeUtil.getLeftTimeString(-1)).isEqualTo("0秒"); + } + + @Test + void 序列化应当能无损往返() { + String pole = SerializeUtil.serialize("中文abc123!@#"); + Object back = SerializeUtil.unserialize(pole); + + assertThat(back).isEqualTo("中文abc123!@#"); + } + + @Test + void 数值判断应当区分非数字整数与小数() { + assertThat(CastUtil.isNumeric("123")).isEqualTo(1); + // 负整数仍应视为整数 + assertThat(CastUtil.isNumeric("-5")).isEqualTo(1); + assertThat(CastUtil.isNumeric("3.14")).isEqualTo(2); + assertThat(CastUtil.isNumeric("1e3")).isEqualTo(2); + assertThat(CastUtil.isNumeric("2.147483647E9")).isEqualTo(2); + // 这些以前会抛 NumberFormatException/越界 + assertThat(CastUtil.isNumeric("1.2.3")).isEqualTo(0); + assertThat(CastUtil.isNumeric("abc")).isEqualTo(0); + assertThat(CastUtil.isNumeric(null)).isEqualTo(0); + assertThat(CastUtil.isNumeric("")).isEqualTo(0); + } + + @Test + void 正则全量匹配无可匹配时应当返回空数组() { + assertThat(RegExUtil.regMatchAll2Array("\\d+", "abc")).isEmpty(); + assertThat(RegExUtil.splitTags("\\[#\\w+#\\]", "没有标签")).isEmpty(); + } + + @Test + void 正则全量匹配应当返回所有匹配项() { + assertThat(RegExUtil.regMatchAll2Array("\\d+", "a1b22c")) + .containsExactly("1", "22"); + assertThat(RegExUtil.splitTags("\\[#\\w+#\\]", "[#tag1#] [#tag2#]")) + .containsExactly("tag1", "tag2"); + } + + @Test + void 取IP应当优先取转发头第一段并跳过unknown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-For", "203.0.113.7, 10.0.0.1"); + assertThat(StringUtil.getIP(request)).isEqualTo("203.0.113.7"); + + MockHttpServletRequest request2 = new MockHttpServletRequest(); + request2.addHeader("X-Forwarded-For", "unknown, 203.0.113.9"); + request2.setRemoteAddr("192.168.1.5"); + assertThat(StringUtil.getIP(request2)).isEqualTo("203.0.113.9"); + } + + @Test + void 取IP无转发头时应当回退到远端地址() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.168.1.5"); + assertThat(StringUtil.getIP(request)).isEqualTo("192.168.1.5"); + } + + @Test + void 解码尾部残缺百分号时不应越界() { + // 以前会 charAt(i+1) 越界抛异常 + assertThat(StringUtil.decode("abc%")).isEqualTo("abc%"); + assertThat(StringUtil.decode("a%20b")).isEqualTo("a b"); + // 残缺的 %u 转义(不足 4 位十六进制)原样保留, 不越界 + assertThat(StringUtil.decode("abc%u12A")).isEqualTo("abc%u12A"); + } + + @Test + void md5编码应当使用UTF8且稳定() { + // md5("") 的经典结果, 顺带锁定输出为 32 位小写 hex + assertThat(Md5Util.encode("", "")).isEqualTo("d41d8cd98f00b204e9800998ecf8427e"); + assertThat(Md5Util.encode("abc", "123")).hasSize(32).matches("[0-9a-f]{32}"); + } +} \ No newline at end of file From 5f035133467633fd23a2d21a57a94f18ab4bfee9 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 14 Sep 2026 09:13:47 +0900 Subject: [PATCH 2/4] fix(website): fix register/login flow and drop plaintext password emails --- .../website/controller/UserController.java | 68 ++++++------------- .../xiaomo/website/dao/base/CommonDao.java | 3 +- .../info/xiaomo/website/util/MailUtil.java | 33 ++++----- 3 files changed, 38 insertions(+), 66 deletions(-) diff --git a/website/src/main/java/info/xiaomo/website/controller/UserController.java b/website/src/main/java/info/xiaomo/website/controller/UserController.java index 7678ef35..74481b07 100644 --- a/website/src/main/java/info/xiaomo/website/controller/UserController.java +++ b/website/src/main/java/info/xiaomo/website/controller/UserController.java @@ -8,15 +8,12 @@ import info.xiaomo.core.base.BaseController; import info.xiaomo.core.base.Result; import info.xiaomo.core.constant.CodeConst; -import info.xiaomo.core.constant.GenderConst; import info.xiaomo.core.exception.UserNotFoundException; -import info.xiaomo.core.utils.MailUtil; import info.xiaomo.core.utils.Md5Util; import info.xiaomo.core.utils.RandomUtil; import info.xiaomo.core.utils.TimeUtil; import info.xiaomo.website.model.UserModel; import info.xiaomo.website.service.UserService; -import org.hibernate.service.spi.ServiceException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.http.MediaType; @@ -91,21 +88,28 @@ public Result addUser(@RequestBody UserModel user) { * * @return result */ - @Operation(summary = "注册", description = "注册用户并发送验证链接到邮箱") + @Operation(summary = "注册", description = "注册用户") @Parameters({ - @Parameter(name = "用户名", required = true, in = ParameterIn.PATH), - @Parameter(name = "密码", required = true, in = ParameterIn.PATH) + @Parameter(name = "email", description = "邮箱", required = true, in = ParameterIn.QUERY), + @Parameter(name = "password", description = "密码", required = true, in = ParameterIn.QUERY) }) - @RequestMapping(value = "register/{email}/{password}", method = RequestMethod.POST) - public Result register(@PathVariable("email") String email, @PathVariable("password") String password) { + @RequestMapping(value = "register", method = RequestMethod.POST) + public Result register(@RequestParam("email") String email, @RequestParam("password") String password) { UserModel userModel = service.findUserByEmail(email); //邮箱被占用 if (userModel != null) { return new Result<>(CodeConst.USER_REPEAT.getResultCode(), CodeConst.USER_REPEAT.getMessage()); } - String redirectValidateUrl = MailUtil.redirectValidateUrl(email, password); - MailUtil.send(email, "帐号激活邮件", redirectValidateUrl); - return new Result<>(redirectValidateUrl); + // 直接创建账号, 密码加盐 MD5 哈希后入库, 表单密码只经 POST body 传输, 不再通过邮件回传明文密码 + String salt = RandomUtil.createSalt(); + UserModel newUser = new UserModel(); + newUser.setEmail(email); + newUser.setPassword(Md5Util.encode(password, salt)); + newUser.setSalt(salt); + newUser.setValidateCode(Md5Util.encode(email, "")); + newUser.setRegisterTime(TimeUtil.getNowOfMills()); + service.addUser(newUser); + return new Result<>(newUser); } @@ -116,11 +120,11 @@ public Result register(@PathVariable("email") String email, @PathVariable("passw */ @Operation(summary = "登录", description = "登录") @Parameters({ - @Parameter(name = "email", description = "邮箱", required = true, in = ParameterIn.PATH), - @Parameter(name = "password", description = "密码", required = true, in = ParameterIn.PATH) + @Parameter(name = "email", description = "邮箱", required = true, in = ParameterIn.QUERY), + @Parameter(name = "password", description = "密码", required = true, in = ParameterIn.QUERY) }) - @RequestMapping(value = "login/{email}/{password}", method = RequestMethod.POST) - public Result login(@PathVariable("email") String email, @PathVariable("password") String password) { + @RequestMapping(value = "login", method = RequestMethod.POST) + public Result login(@RequestParam("email") String email, @RequestParam("password") String password) { UserModel userModel = service.findUserByEmail(email); //找不到用户 if (userModel == null) { @@ -168,7 +172,6 @@ public Result update(@RequestBody UserModel user) throws UserNotFoundException { if (userModel == null) { return new Result<>(CodeConst.USER_NOT_FOUND.getResultCode(), CodeConst.USER_NOT_FOUND.getMessage()); } - userModel = new UserModel(); userModel.setEmail(user.getEmail()); userModel.setNickName(user.getNickName()); userModel.setPhone(user.getPhone()); @@ -214,39 +217,6 @@ public Result deleteUserById(@PathVariable("id") Long id) throws UserNotFoundExc return new Result<>(userModel); } - /** - * 处理激活 - */ - @Operation(summary = "处理激活", description = "处理激活") - @RequestMapping(value = "validateEmail", method = RequestMethod.POST) - public Result validateEmail(@RequestBody UserModel user - ) throws ServiceException { - //数据访问层,通过email获取用户信息 - UserModel userModel = service.findUserByEmail(user.getEmail()); - if (userModel != null) { - return new Result<>(CodeConst.USER_REPEAT.getResultCode(), CodeConst.USER_REPEAT.getMessage()); - } - //验证码是否过期 - if (user.getRegisterTime() + TimeUtil.ONE_DAY_IN_MILLISECONDS < TimeUtil.getNowOfMills()) { - LOGGER.info("用户{}使用己过期的激活码{}激活邮箱失败!", user.getEmail(), user.getEmail()); - return new Result<>(CodeConst.TIME_PASSED.getResultCode(), CodeConst.TIME_PASSED.getMessage()); - } - //激活 - String salt = RandomUtil.createSalt(); - userModel = new UserModel(); - userModel.setNickName(user.getNickName()); - userModel.setEmail(user.getEmail()); - userModel.setGender(GenderConst.SECRET); - userModel.setValidateCode(Md5Util.encode(user.getEmail(), salt)); - userModel.setPhone(0L); - userModel.setSalt(salt); - userModel.setAddress(""); - userModel.setPassword(Md5Util.encode(user.getPassword(), salt)); - userModel = service.addUser(userModel); - LOGGER.info("用户{}使用激活码{}激活邮箱成功!", userModel.getEmail(), userModel.getValidateCode()); - return new Result<>(userModel); - } - /** * 查找所有(不带分页) * diff --git a/website/src/main/java/info/xiaomo/website/dao/base/CommonDao.java b/website/src/main/java/info/xiaomo/website/dao/base/CommonDao.java index 437ca7c7..da55212e 100644 --- a/website/src/main/java/info/xiaomo/website/dao/base/CommonDao.java +++ b/website/src/main/java/info/xiaomo/website/dao/base/CommonDao.java @@ -43,7 +43,8 @@ public void save(T entity) { } public void delete(T entity) { - entityManager.remove(entity); + // 传入的实体可能是游离态, 不在持久化上下文中时先 merge 再 remove, 避免 IllegalArgumentException + entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity)); } public List getAll(Class tableClass) { diff --git a/website/src/main/java/info/xiaomo/website/util/MailUtil.java b/website/src/main/java/info/xiaomo/website/util/MailUtil.java index 21d65499..1023edfb 100644 --- a/website/src/main/java/info/xiaomo/website/util/MailUtil.java +++ b/website/src/main/java/info/xiaomo/website/util/MailUtil.java @@ -10,11 +10,12 @@ import jakarta.mail.*; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; -import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Properties; /** @@ -43,11 +44,15 @@ public class MailUtil { */ private static Session getSession() throws IOException { Properties props = new Properties(); - String dir = System.getProperty("user.dir"); - FileInputStream is = new FileInputStream(dir + "/website/src/main/resources/config/application.properties"); - props.load(is); - USERNAME = String.valueOf(props.get("mail.username")); - PASSWORD = String.valueOf(props.get("mail.password")); + // 从 classpath 读取配置, 不再依赖进程工作目录(user.dir)下的绝对路径 + try (InputStream is = MailUtil.class.getClassLoader().getResourceAsStream("config/application.properties")) { + if (is == null) { + throw new IOException("未找到 classpath 下的 config/application.properties"); + } + props.load(is); + } + USERNAME = Objects.toString(props.get("mail.username"), ""); + PASSWORD = Objects.toString(props.get("mail.password"), ""); Authenticator authenticator = new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { @@ -78,22 +83,18 @@ public static boolean send(String toEmail, String subject, String content) { } /** - * 返回激活链接 + * 返回邮件内容 * * @param email email - * @return 有3个参数 email password + * @return 邮件内容 */ - public static String getContent(String email, String password, Configuration configuration) { + public static String getContent(String email, Configuration configuration) { Long now = TimeUtil.getNowOfMills(); Map data = new HashMap<>(10); - StringBuilder sb = new StringBuilder("http://localhost:8080/user/validate?email="); - sb.append(email); - sb.append("&password="); - sb.append(password); - sb.append("&time="); - sb.append(now); + // 激活流程不再通过邮件携带密码, 链接直接指向首页 + String url = "http://localhost:8080/"; data.put("email", email); - data.put("url", sb.toString()); + data.put("url", url); data.put("now", TimeUtil.getFormatDate(now, TimeUtil.DEFAULT_FORMAT)); Template template; String readyParsedTemplate = null; From 1251a9c3730e4a37fd388e501852a5a3e6d930ee Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 14 Sep 2026 09:13:47 +0900 Subject: [PATCH 3/4] fix(crawler): guard against empty selections in onmyouji spider --- .../crawler/spider/OnnmyoujiSpider.java | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/crawler/src/main/java/info/xiaomo/crawler/spider/OnnmyoujiSpider.java b/crawler/src/main/java/info/xiaomo/crawler/spider/OnnmyoujiSpider.java index f3a2ac86..47eabef3 100644 --- a/crawler/src/main/java/info/xiaomo/crawler/spider/OnnmyoujiSpider.java +++ b/crawler/src/main/java/info/xiaomo/crawler/spider/OnnmyoujiSpider.java @@ -28,7 +28,9 @@ public static List getShikigamiModel() { List shikigamiDetailInfoUrl = getShikigamiDetailInfoUrl(); for (String url : shikigamiDetailInfoUrl) { ShikigamiModel shikigamiModel = getShikigami(url); - list.add(shikigamiModel); + if (shikigamiModel != null) { + list.add(shikigamiModel); + } } return list; } @@ -41,11 +43,16 @@ private static List getShikigamiDetailInfoUrl() { List list = new ArrayList<>(); String html = HttpUtil.get(URL); Document doc = Jsoup.parse(html); - Element select = doc.select(".heroList-2").get(0); - Elements liElement = select.select("a"); + Elements select = doc.select(".heroList-2"); + if (select.isEmpty()) { + return list; + } + Elements liElement = select.get(0).select("a"); for (Element element : liElement) { String href = element.attr("href"); - list.add(href); + if (href != null && !href.isEmpty()) { + list.add(href); + } } return list; } @@ -58,11 +65,16 @@ private static List getMitamaDetailInfoUrl() { List list = new ArrayList<>(); String html = HttpUtil.get(URL); Document doc = Jsoup.parse(html); - Element select = doc.select(".heroList-1").get(0); - Elements liElement = select.select("a"); + Elements select = doc.select(".heroList-1"); + if (select.isEmpty()) { + return list; + } + Elements liElement = select.get(0).select("a"); for (Element element : liElement) { String href = element.attr("href"); - list.add(href); + if (href != null && !href.isEmpty()) { + list.add(href); + } } return list; } @@ -72,20 +84,32 @@ private static List getMitamaDetailInfoUrl() { * 获取式神信息 */ private static ShikigamiModel getShikigami(String url) { - ShikigamiModel a = new ShikigamiModel(); - String html = HttpUtil.get(url); - Document doc = Jsoup.parse(html); - Elements selects = doc.select("table").get(1).select("tr").get(2).select("td"); - String seiyou = selects.get(0).text(); - String name = selects.get(1).text(); - String star = selects.get(2).text(); - String sex = selects.get(3).text(); - String level = selects.get(4).text(); - String getWay = selects.get(5).text(); - String image = doc.select("table").get(0).select("tr").get(0).select("img").attr("src"); - return new ShikigamiModel(name, image, seiyou, sex, star, getWay, level, ""); - - + try { + ShikigamiModel a = new ShikigamiModel(); + String html = HttpUtil.get(url); + Document doc = Jsoup.parse(html); + Elements tables = doc.select("table"); + if (tables.size() < 2) { + return null; + } + Elements trs = tables.get(1).select("tr"); + if (trs.size() < 3) { + return null; + } + Elements selects = trs.get(2).select("td"); + String seiyou = selects.size() > 0 ? selects.get(0).text() : ""; + String name = selects.size() > 1 ? selects.get(1).text() : ""; + String star = selects.size() > 2 ? selects.get(2).text() : ""; + String sex = selects.size() > 3 ? selects.get(3).text() : ""; + String level = selects.size() > 4 ? selects.get(4).text() : ""; + String getWay = selects.size() > 5 ? selects.get(5).text() : ""; + String image = tables.get(0).select("tr").isEmpty() + ? "" : tables.get(0).select("tr").get(0).select("img").attr("src"); + return new ShikigamiModel(name, image, seiyou, sex, star, getWay, level, ""); + } catch (Exception e) { + // 页面结构变化或网络异常时跳过该条, 交由调用方过滤 null + return null; + } } } From b4a670e7120f1c7cda11f45de89534dd4badf46a Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 14 Sep 2026 09:13:47 +0900 Subject: [PATCH 4/4] fix(socket): HTML-encode user messages before broadcast to prevent XSS --- .../src/main/java/info/xiaomo/chat/socket/MyWebSocket.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/socket/src/main/java/info/xiaomo/chat/socket/MyWebSocket.java b/socket/src/main/java/info/xiaomo/chat/socket/MyWebSocket.java index 92bb7e02..c5be0ff7 100644 --- a/socket/src/main/java/info/xiaomo/chat/socket/MyWebSocket.java +++ b/socket/src/main/java/info/xiaomo/chat/socket/MyWebSocket.java @@ -96,9 +96,10 @@ public void onClose() { @OnMessage public void onMessage(String message) throws IOException { String date = "" + TimeUtil.getDateNow(TimeUtil.DATE_PATTERN) + "
"; - // 群发消息 + // 群发前先 HTML 转义用户输入, 避免把消息里的