SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
/**
|
||||
* Application-level exception carrying a non-zero response code.
|
||||
* Thrown by services/controllers and translated to an ApiResp by the
|
||||
* global exception handler.
|
||||
*/
|
||||
public class ApiException extends RuntimeException {
|
||||
|
||||
private final int code;
|
||||
|
||||
public ApiException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public ApiException(String message) {
|
||||
this(1, message);
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
/**
|
||||
* Uniform API response envelope.
|
||||
*
|
||||
* code == 0 means success; any non-zero code indicates an error and message
|
||||
* carries the human-readable reason. data holds the payload (null on error).
|
||||
*/
|
||||
public record ApiResp<T>(int code, String message, T data) {
|
||||
|
||||
public static <T> ApiResp<T> ok(T data) {
|
||||
return new ApiResp<>(0, "ok", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResp<T> ok() {
|
||||
return new ApiResp<>(0, "ok", null);
|
||||
}
|
||||
|
||||
public static <T> ApiResp<T> error(int code, String message) {
|
||||
return new ApiResp<>(code, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 申报域外部政务平台凭据的「可逆加密」工具(创新研发中心·申报服务部,需求 §2 账号密码加密存储)。
|
||||
*
|
||||
* 政务平台登录口令必须能取回供真实登录代填,故不能用单向哈希(与 {@link PasswordUtil} 区别);
|
||||
* 这里用「每条随机盐 + 与盐派生的密钥流做 XOR + Base64 编码」做可逆加密落库,避免明文入库与撞库。
|
||||
* 落库串格式:{@code v1:<base64(salt)>:<base64(xor密文)>},前缀 {@code v1:} 用于识别已加密串避免二次加密。
|
||||
*
|
||||
* 说明:演示环境用对称流加密满足「加密存储/不回明文」的合规口径;生产可替换为 KMS/AES-GCM,
|
||||
* 解密入口受角色门禁(控制器层),列表/详情默认只回掩码而非明文。
|
||||
*/
|
||||
public final class DeclCrypto {
|
||||
|
||||
private static final String PREFIX = "v1:";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
/** 与盐混合的固定基密钥(演示口径;生产应来自外部密钥管理)。 */
|
||||
private static final byte[] BASE_KEY = "kaidi-decl-portal-key-2026".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private DeclCrypto() {
|
||||
}
|
||||
|
||||
/** 是否已是本工具加密后的密文串(避免对已加密值二次加密)。 */
|
||||
public static boolean isEncrypted(String s) {
|
||||
return s != null && s.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/** 加密:明文 → {@code v1:<saltB64>:<cipherB64>}。null/空 → 原样返回(无口令可存)。 */
|
||||
public static String encrypt(String plain) {
|
||||
if (plain == null || plain.isEmpty()) {
|
||||
return plain;
|
||||
}
|
||||
if (isEncrypted(plain)) {
|
||||
return plain; // 已加密,幂等
|
||||
}
|
||||
byte[] salt = new byte[12];
|
||||
RANDOM.nextBytes(salt);
|
||||
byte[] data = plain.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] cipher = xor(data, salt);
|
||||
return PREFIX + b64(salt) + ":" + b64(cipher);
|
||||
}
|
||||
|
||||
/** 解密:{@code v1:<saltB64>:<cipherB64>} → 明文。非本格式串原样返回(兼容历史明文)。 */
|
||||
public static String decrypt(String stored) {
|
||||
if (stored == null || !isEncrypted(stored)) {
|
||||
return stored;
|
||||
}
|
||||
try {
|
||||
String[] parts = stored.substring(PREFIX.length()).split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
return "";
|
||||
}
|
||||
byte[] salt = Base64.getDecoder().decode(parts[0]);
|
||||
byte[] cipher = Base64.getDecoder().decode(parts[1]);
|
||||
byte[] data = xor(cipher, salt);
|
||||
return new String(data, StandardCharsets.UTF_8);
|
||||
} catch (RuntimeException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 掩码:用于列表/详情展示,不回明文(如 已设置 ●●●●●●)。 */
|
||||
public static String mask(String stored) {
|
||||
if (stored == null || stored.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "已设置 ●●●●●●";
|
||||
}
|
||||
|
||||
/** 用盐与基密钥派生密钥流做按位异或(流加密)。 */
|
||||
private static byte[] xor(byte[] data, byte[] salt) {
|
||||
byte[] out = new byte[data.length];
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
byte k = (byte) (BASE_KEY[i % BASE_KEY.length] ^ salt[i % salt.length] ^ (i * 31));
|
||||
out[i] = (byte) (data[i] ^ k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String b64(byte[] b) {
|
||||
return Base64.getEncoder().encodeToString(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.ConcurrencyFailureException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.transaction.UnexpectedRollbackException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.multipart.MultipartException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Translates exceptions into the uniform {@link ApiResp} envelope so that every
|
||||
* error response also carries {code != 0, message}. HTTP status stays 200 for
|
||||
* application errors (envelope code conveys the failure) except where a more
|
||||
* specific status is meaningful.
|
||||
*
|
||||
* 横切健壮:框架层异常(请求方法不支持 / 路径不存在 / 路径参数类型不符 / 请求体坏 JSON /
|
||||
* 缺必填参数)一律映射成对应的 4xx + 友好 message,**绝不把原始 Java/SQL 异常文案回吐前端**
|
||||
* (避免信息泄露 + 误导性的 500)。原始异常仅写服务端日志,便于排障。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleNotFound(NotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResp.error(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleApi(ApiException ex) {
|
||||
return ResponseEntity.ok(ApiResp.error(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(GlobalExceptionHandler::formatFieldError)
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.ok(ApiResp.error(400, msg.isEmpty() ? "validation failed" : msg));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.ok(ApiResp.error(400, ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求方法不支持(如对集合端点发 DELETE)。原来落到通用 500 并回吐
|
||||
* "Request method 'DELETE' is not supported"。现统一映射成 405 + 友好提示。
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) {
|
||||
log.warn("method not supported: {} {}", ex.getMethod(), ex.getSupportedHttpMethods());
|
||||
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
.body(ApiResp.error(405, "请求方法不被支持"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径不存在。Spring Boot 3.2 / Spring 6.1 对未匹配路径抛
|
||||
* {@link NoResourceFoundException}("No static resource ..."),旧版本抛
|
||||
* {@link NoHandlerFoundException}。两者都收口成 404 + 友好提示,不回吐路径细节文案。
|
||||
*/
|
||||
@ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class})
|
||||
public ResponseEntity<ApiResp<Void>> handleNoHandler(Exception ex) {
|
||||
log.warn("no handler / resource: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResp.error(404, "请求的资源不存在"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径/查询参数类型不符(如 /cost-centers/abc 把 abc 喂给 Long id)。原来落到 500 并
|
||||
* 回吐 "Failed to convert value of type ... For input string: \"abc\""。现映射成 400。
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
log.warn("argument type mismatch on '{}': {}", ex.getName(), ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResp.error(400, "参数「" + ex.getName() + "」格式不正确"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体不可读 / 坏 JSON。原来落到 500 并回吐 Jackson 内部解析细节
|
||||
* ("JSON parse error: Unexpected character ...")。现映射成 400 + 通用提示。
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleNotReadable(HttpMessageNotReadableException ex) {
|
||||
log.warn("unreadable request body: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResp.error(400, "请求体格式不正确(无法解析的 JSON)"));
|
||||
}
|
||||
|
||||
/** 缺少必填查询参数。统一 400 + 指明参数名,不回吐底层文案。 */
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleMissingParam(MissingServletRequestParameterException ex) {
|
||||
log.warn("missing request parameter: {}", ex.getParameterName());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResp.error(400, "缺少必填参数「" + ex.getParameterName() + "」"));
|
||||
}
|
||||
|
||||
/** 缺少必填的 multipart 分片(如上传缺 file 字段)。属非法入参,统一 400 而非 500。 */
|
||||
@ExceptionHandler(MissingServletRequestPartException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleMissingPart(MissingServletRequestPartException ex) {
|
||||
log.warn("missing request part: {}", ex.getRequestPartName());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResp.error(400, "缺少必填上传内容「" + ex.getRequestPartName() + "」"));
|
||||
}
|
||||
|
||||
/** 非法/非 multipart 的上传请求(如对上传口发了非 multipart 请求)。同属非法入参,统一 400 而非 500。 */
|
||||
@ExceptionHandler(MultipartException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleMultipart(MultipartException ex) {
|
||||
log.warn("invalid multipart request: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResp.error(400, "上传请求格式不正确:需以 multipart/form-data 提交文件"));
|
||||
}
|
||||
|
||||
/** 请求体 Content-Type 不被支持。统一 415 + 友好提示。 */
|
||||
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleMediaType(HttpMediaTypeNotSupportedException ex) {
|
||||
log.warn("unsupported media type: {}", ex.getContentType());
|
||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
.body(ApiResp.error(415, "不支持的请求内容类型"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库约束冲突(唯一键 / NOT NULL / 外键)。原来落到通用 500 并可能回吐 SQL/表名。
|
||||
* 现统一映射成 409 + 通用提示,且原始 SQL 异常仅写日志——绝不外泄表结构。
|
||||
* 典型来源:并发写撞唯一约束、必填字段缺失。
|
||||
*/
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleDataIntegrity(DataIntegrityViolationException ex) {
|
||||
log.warn("data integrity violation: {}",
|
||||
ex.getMostSpecificCause() != null ? ex.getMostSpecificCause().getMessage() : ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResp.error(409, "操作与现有数据冲突或违反完整性约束,请核对后重试"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发/回滚冲突。典型:同一事项被并发办结,落后的事务被标记 rollback-only 或撞唯一约束。
|
||||
* 此前会透成不明 500;现收口成 409 + 可理解提示(数据一致性已由约束/守卫保证,无脏写)。
|
||||
*/
|
||||
@ExceptionHandler({UnexpectedRollbackException.class, ConcurrencyFailureException.class})
|
||||
public ResponseEntity<ApiResp<Void>> handleConcurrency(Exception ex) {
|
||||
log.warn("concurrent / rollback conflict: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResp.error(409, "操作冲突:该事项可能正在被处理或已处理,请刷新后重试"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hibernate/SQLite 下,唯一键 / NOT NULL / 外键等约束违反常被包成 {@link JpaSystemException}
|
||||
* 而非 Spring 的 DataIntegrityViolationException(后者对 SQLite 几乎命中不到)。这里按根因
|
||||
* 文案识别 SQLITE_CONSTRAINT,将约束类失败收口成 409,其余 JPA 系统异常仍回 500——
|
||||
* 两种情况都只给通用提示,绝不外泄 SQL / 表名 / 栈。
|
||||
*/
|
||||
@ExceptionHandler(JpaSystemException.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleJpaSystem(JpaSystemException ex) {
|
||||
String root = ex.getMostSpecificCause() != null ? ex.getMostSpecificCause().getMessage() : ex.getMessage();
|
||||
String r = root == null ? "" : root.toUpperCase();
|
||||
boolean constraint = r.contains("CONSTRAINT") || r.contains("UNIQUE") || r.contains("NOT NULL");
|
||||
log.warn("jpa system exception (constraint={}): {}", constraint, root);
|
||||
if (constraint) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResp.error(409, "操作与现有数据冲突或违反完整性约束,请核对后重试"));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResp.error(500, "服务器内部错误,请稍后重试"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底:任何未被上面专门处理的异常都回 500,但**只给通用友好提示**,绝不回吐
|
||||
* 原始异常 getMessage()(可能含 Java 栈细节 / SQL / 表名)。原始异常写日志便于排障。
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResp<Void>> handleGeneric(Exception ex) {
|
||||
log.error("unhandled exception", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResp.error(500, "服务器内部错误,请稍后重试"));
|
||||
}
|
||||
|
||||
private static String formatFieldError(FieldError fe) {
|
||||
return fe.getField() + ": " + fe.getDefaultMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.safety.Safelist;
|
||||
|
||||
/**
|
||||
* 服务端 HTML 白名单净化:用于用户可控的富文本正文(讨论/协作文档/博客/动态/公告/会议纪要/评论/回帖),
|
||||
* 在写入时清除存储型 XSS 向量,使其即便被前端以 v-html 渲染也不会执行脚本。
|
||||
*
|
||||
* <p>实现采用 jsoup 的 {@link Safelist}(白名单)而非正则黑名单:jsoup 会像浏览器一样把输入解析成
|
||||
* DOM(这一步会解码 HTML 实体,如 {@code javascript:} → {@code javascript:}),再仅保留白名单内的
|
||||
* 标签/属性,并对 a[href]、img[src] 等按白名单协议(http/https/mailto/ftp)校验——非白名单协议
|
||||
* ({@code javascript:}/{@code vbscript:}/{@code data:})、所有 {@code on*} 事件处理器、
|
||||
* {@code <script>/<svg>/<iframe>/<object>} 等危险元素一律被剥离。
|
||||
*
|
||||
* <p>这从根上消除了"正则只匹配字面 javascript: 而被 :/: 等实体编码绕过"的整类问题
|
||||
* (正则黑名单是军备竞赛,jsoup 的解析-再-白名单序列化才是稳健解)。前端仍建议对 v-html 再加一层
|
||||
* DOMPurify 作为纵深防御。
|
||||
*/
|
||||
public final class HtmlSanitizer {
|
||||
|
||||
private HtmlSanitizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本白名单:jsoup {@code relaxed}(允许 p/div/span/a/img/ul/table 等常见排版标签,
|
||||
* a[href] 仅 http/https/mailto/ftp、img[src] 仅 http/https;自动剥离所有事件处理器与危险元素)。
|
||||
* 额外允许 a[target] 以兼容"新窗口打开"的链接。
|
||||
*/
|
||||
private static final Safelist SAFELIST = Safelist.relaxed().addAttributes("a", "target");
|
||||
|
||||
/** 紧凑输出:不美化(prettyPrint=false),尽量保留原正文结构。 */
|
||||
private static final Document.OutputSettings OUTPUT =
|
||||
new Document.OutputSettings().prettyPrint(false);
|
||||
|
||||
/**
|
||||
* 净化一段可能含 HTML 的用户正文;null/空 原样返回。
|
||||
*/
|
||||
public static String sanitize(String html) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return html;
|
||||
}
|
||||
// baseUri 传空串即可;协议白名单仍生效。jsoup.clean 解析(解码实体)→按白名单过滤→再序列化。
|
||||
return Jsoup.clean(html, "", SAFELIST, OUTPUT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 金额工具(统一标度与空值处理)。
|
||||
*
|
||||
* 所有"钱"字段一律用 {@link BigDecimal} 而非 double,避免二进制浮点累加误差导致
|
||||
* 对账(已付=合同额、分包累计、发票回写)判等不可靠。本工具把请求层传来的装箱
|
||||
* {@code Double}(前端 JSON number 反序列化结果)安全收口成标度 2、HALF_UP 的
|
||||
* BigDecimal,并提供 null 安全的加减比较,供各控制器/服务复用。
|
||||
*/
|
||||
public final class Money {
|
||||
|
||||
/** 金额标准标度:分(2 位小数)。 */
|
||||
public static final int SCALE = 2;
|
||||
|
||||
public static final BigDecimal ZERO = scaled(BigDecimal.ZERO);
|
||||
|
||||
private Money() {
|
||||
}
|
||||
|
||||
/** 装箱 Double(可空)→ 标度 2 的 BigDecimal;null 视为 0。非有限数(NaN/Infinity)→干净 400。 */
|
||||
public static BigDecimal of(Double v) {
|
||||
if (v == null) {
|
||||
return ZERO;
|
||||
}
|
||||
requireFinite(v);
|
||||
return scaled(BigDecimal.valueOf(v));
|
||||
}
|
||||
|
||||
/** double → 标度 2 的 BigDecimal。非有限数(NaN/Infinity)→干净 400。 */
|
||||
public static BigDecimal of(double v) {
|
||||
requireFinite(v);
|
||||
return scaled(BigDecimal.valueOf(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额必须是有限数值:拒绝 NaN/Infinity/-Infinity(JSON 里的 "NaN"/"Infinity"/1e400 等)。
|
||||
* 统一在金额收口处给出干净的 400 中文提示,杜绝把 {@code BigDecimal.valueOf(非有限)} 的底层
|
||||
* NumberFormatException 原文("Character I is neither a decimal digit...")外泄给前端。
|
||||
*/
|
||||
private static void requireFinite(double v) {
|
||||
if (!Double.isFinite(v)) {
|
||||
throw new ApiException(400, "金额必须是有限数值");
|
||||
}
|
||||
}
|
||||
|
||||
/** BigDecimal(可空)→ 规整标度;null 视为 0。供已是 BigDecimal 的表达式统一收口。 */
|
||||
public static BigDecimal of(BigDecimal v) {
|
||||
return nz(v);
|
||||
}
|
||||
|
||||
/** null 安全:null → 0,否则规整标度。 */
|
||||
public static BigDecimal nz(BigDecimal v) {
|
||||
return v == null ? ZERO : scaled(v);
|
||||
}
|
||||
|
||||
public static BigDecimal add(BigDecimal a, BigDecimal b) {
|
||||
return scaled(nz(a).add(nz(b)));
|
||||
}
|
||||
|
||||
public static BigDecimal sub(BigDecimal a, BigDecimal b) {
|
||||
return scaled(nz(a).subtract(nz(b)));
|
||||
}
|
||||
|
||||
/** a 是否 > b(null 当 0)。 */
|
||||
public static boolean gt(BigDecimal a, BigDecimal b) {
|
||||
return nz(a).compareTo(nz(b)) > 0;
|
||||
}
|
||||
|
||||
/** a 是否 <= 0(null 当 0)。 */
|
||||
public static boolean lte0(BigDecimal a) {
|
||||
return nz(a).compareTo(ZERO) <= 0;
|
||||
}
|
||||
|
||||
private static BigDecimal scaled(BigDecimal v) {
|
||||
return v.setScale(SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 金额文本解析收口(BigDecimal-native)。
|
||||
*
|
||||
* <p>历史上 {@code TriggerRuleEngine.parseNumber} / {@code WorkflowService.parseNumber} /
|
||||
* {@code MobileApprovalController.parseNumber} 三处各自手写了一份"剥噪声→{@code Double.parseDouble}"
|
||||
* 的金额解析,返回裸 {@code double}:自动联动据此生成付款单/发票,金额下游与台账留痕里就会冒出
|
||||
* {@code 5.07}、{@code 9.9999999997} 这类二进制浮点伪值(如 5000万经 JSON 序列化为 "5.0E7",或
|
||||
* 表单 "10.00" 累加后抖动)。本工具把"解析"这一步直接落到 {@link BigDecimal},杜绝中途经过 double:
|
||||
* 用 {@link BigDecimal#BigDecimal(String)}(字符串构造,精确)而非 {@code Double.parseDouble},
|
||||
* 再统一 {@code setScale(2, HALF_UP)} 规整成"分"标度,使解析结果与 {@link Money} 完全同口径。</p>
|
||||
*
|
||||
* <p>容噪规则与原三处保持一致:剥掉 ¥/¥/元/逗号/空白等装饰字符,<b>保留</b> 数字、小数点、正负号
|
||||
* 与科学计数标记 {@code e/E}(大额金额经 JSON 常序列化为 "5.0E7",剥掉 E 会把它错解析成 5.07,
|
||||
* 量级灾难);末尾带「万」整体 ×10000。无法解析时返回 {@code null}(业务侧据此走"未解析到金额→跳过")。</p>
|
||||
*/
|
||||
public final class MoneyParser {
|
||||
|
||||
private static final BigDecimal TEN_THOUSAND = new BigDecimal("10000");
|
||||
|
||||
private MoneyParser() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析金额文本 → 标度 2、HALF_UP 的 {@link BigDecimal};解析失败返回 {@code null}。
|
||||
*
|
||||
* <p>关键:用 {@code new BigDecimal(cleaned)}(字符串精确构造,支持科学计数法),<b>绝不</b>
|
||||
* 走 {@code Double.parseDouble},故 "5.0E7"→50000000.00、"10.00"→10.00 全程无浮点抖动。</p>
|
||||
*/
|
||||
public static BigDecimal parse(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String s = raw.trim();
|
||||
if (s.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean wan = s.contains("万");
|
||||
// 保留 0-9 . e E + - ,剥掉其余(¥ 元 万 千分位逗号 空白…)。务必保留 e/E:科学计数法大额。
|
||||
String cleaned = s.replaceAll("[^0-9.eE+-]", "");
|
||||
if (cleaned.isEmpty() || "+".equals(cleaned) || "-".equals(cleaned) || ".".equals(cleaned)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
BigDecimal v = new BigDecimal(cleaned);
|
||||
if (wan) {
|
||||
v = v.multiply(TEN_THOUSAND);
|
||||
}
|
||||
return v.setScale(Money.SCALE, RoundingMode.HALF_UP);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 {@link #parse(String)},但解析失败回退为 {@link Money#ZERO}(标度 2 的 0.00)。
|
||||
* 适合"展示/排序用金额,缺失即按 0 处理且不抛错"的场景(如移动待办列表金额列)。
|
||||
*/
|
||||
public static BigDecimal parseOrZero(String raw) {
|
||||
BigDecimal v = parse(raw);
|
||||
return v == null ? Money.ZERO : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
/**
|
||||
* Thrown when a requested resource does not exist. Maps to response code 404.
|
||||
*/
|
||||
public class NotFoundException extends ApiException {
|
||||
|
||||
public NotFoundException(String message) {
|
||||
super(404, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
/**
|
||||
* Password hashing helper — salted PBKDF2-HMAC-SHA256 (JDK built-in, no extra
|
||||
* dependency). Each hash embeds a random per-user salt and an iteration count,
|
||||
* stored as {@code pbkdf2$<iterations>$<saltB64>$<hashB64>}. Verification is
|
||||
* constant-time. This defeats rainbow-table / precomputation attacks that plain
|
||||
* unsalted SHA-256 is vulnerable to.
|
||||
*
|
||||
* Legacy unsalted SHA-256 hex hashes are still accepted by {@link #matches} for
|
||||
* backward compatibility, so old records keep working until re-hashed.
|
||||
*/
|
||||
public final class PasswordUtil {
|
||||
|
||||
private static final int ITERATIONS = 120_000;
|
||||
private static final int KEY_LENGTH = 256; // bits
|
||||
private static final int SALT_BYTES = 16;
|
||||
/** 口令长度上限:防超长口令触发 PBKDF2 CPU 放大(DoS)。正常口令远短于此。 */
|
||||
private static final int MAX_PASSWORD_LEN = 200;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private PasswordUtil() {
|
||||
}
|
||||
|
||||
/** Produce a salted PBKDF2 hash string: {@code pbkdf2$<iter>$<saltB64>$<hashB64>}. */
|
||||
public static String hash(String raw) {
|
||||
if (raw != null && raw.length() > MAX_PASSWORD_LEN) {
|
||||
throw new IllegalArgumentException("password too long");
|
||||
}
|
||||
byte[] salt = new byte[SALT_BYTES];
|
||||
RANDOM.nextBytes(salt);
|
||||
byte[] dk = pbkdf2(raw, salt, ITERATIONS);
|
||||
return "pbkdf2$" + ITERATIONS + "$"
|
||||
+ Base64.getEncoder().encodeToString(salt) + "$"
|
||||
+ Base64.getEncoder().encodeToString(dk);
|
||||
}
|
||||
|
||||
/** Verify a raw password against a stored hash (PBKDF2 or legacy SHA-256). */
|
||||
public static boolean matches(String raw, String stored) {
|
||||
if (raw == null || stored == null || stored.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
// 超长口令直接判否:既挡 PBKDF2 DoS,又不影响任何正常口令。
|
||||
if (raw.length() > MAX_PASSWORD_LEN) {
|
||||
return false;
|
||||
}
|
||||
if (stored.startsWith("pbkdf2$")) {
|
||||
String[] parts = stored.split("\\$");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int iterations = Integer.parseInt(parts[1]);
|
||||
byte[] salt = Base64.getDecoder().decode(parts[2]);
|
||||
byte[] expected = Base64.getDecoder().decode(parts[3]);
|
||||
byte[] actual = pbkdf2(raw, salt, iterations);
|
||||
return MessageDigest.isEqual(expected, actual);
|
||||
} catch (RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// legacy unsalted SHA-256 hex fallback (constant-time compare)
|
||||
return MessageDigest.isEqual(
|
||||
stored.getBytes(StandardCharsets.UTF_8),
|
||||
legacySha256Hex(raw).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static byte[] pbkdf2(String raw, byte[] salt, int iterations) {
|
||||
try {
|
||||
PBEKeySpec spec = new PBEKeySpec(raw.toCharArray(), salt, iterations, KEY_LENGTH);
|
||||
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
return skf.generateSecret(spec).getEncoded();
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalStateException("PBKDF2 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String legacySha256Hex(String raw) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user