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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -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&#58;} → {@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: 而被 &#58;/&#x3a; 等实体编码绕过"的整类问题
* (正则黑名单是军备竞赛,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 的 BigDecimalnull 视为 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/-InfinityJSON 里的 "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 是否 > bnull 当 0)。 */
public static boolean gt(BigDecimal a, BigDecimal b) {
return nz(a).compareTo(nz(b)) > 0;
}
/** a 是否 <= 0null 当 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);
}
}
}