package jnpf.limsService.support;
|
|
import java.io.IOException;
|
import java.nio.charset.StandardCharsets;
|
import java.nio.file.Files;
|
import java.nio.file.Path;
|
import java.nio.file.Paths;
|
import java.util.stream.Stream;
|
|
/** SIGN_USED 业务入口、模块语义和折叠接线的源码级回归探针。 */
|
public final class LimsSignUsageWiringProbe {
|
|
private static int passed;
|
private static int failed;
|
|
public static void main(String[] args) throws Exception {
|
String qingyandanController = read("jnpf-lims/jnpf-lims-controller/src/main/java/"
|
+ "jnpf/limsController/LimsQingyandanController.java");
|
String quyangController = read("jnpf-lims/jnpf-lims-controller/src/main/java/"
|
+ "jnpf/limsController/LimsQuyangController.java");
|
String jieyangController = read("jnpf-lims/jnpf-lims-controller/src/main/java/"
|
+ "jnpf/limsController/LimsJieyangController.java");
|
String huanjingController = read("jnpf-lims/jnpf-lims-controller/src/main/java/"
|
+ "jnpf/limsController/LimsHuanjingJianceController.java");
|
String qingyandanService = read("jnpf-lims/jnpf-lims-biz/src/main/java/"
|
+ "jnpf/limsService/impl/LimsQingyandanServiceImpl.java");
|
String signService = read("jnpf-lims/jnpf-lims-biz/src/main/java/"
|
+ "jnpf/limsService/impl/LimsSignServiceImpl.java");
|
String signOpType = read("jnpf-lims/jnpf-lims-entity/src/main/java/"
|
+ "jnpf/limsEntity/SignOpType.java");
|
String qingyandanEntity = read("jnpf-lims/jnpf-lims-entity/src/main/java/"
|
+ "jnpf/limsEntity/LimsQingyandanEntity.java");
|
|
// 2026-07-31 retarget(原为 checkConsumption("请验单提交", qingyandanController,
|
// "QINGYANDAN_SUBMIT")):旧断言查「submit 之后调用了 recordSignUsage」,
|
// 而 D8 明确表单路径**停写事件、保留原子认领**。语义变了,判据跟着变;
|
// 认领这件事**必须继续有人守**(防重放护栏,删了同一签名可反复使用),故是 retarget 不是删除。
|
checkQingyandanSubmitClaim(qingyandanController);
|
checkQingyandanFreshSign(qingyandanController);
|
checkCancelConsumption(qingyandanController);
|
checkConsumption("取样", quyangController, "QUYANG_FETCH");
|
checkConsumption("接样", jieyangController, "JIEYANG_RECEIVE");
|
checkHuanjingFailureBoundary(huanjingController, "public R<String> quyangJilu(",
|
"limsHuanjingJianceService.quyangJilu", "HUANJING_QUYANG");
|
checkHuanjingFailureBoundary(huanjingController, "public R<String> terminateQuyangJilu(",
|
"limsHuanjingJianceService.terminateQuyangJilu", "HUANJING_QUYANG_TERMINATE");
|
|
// ┌─ 2026-07-31 删除,留痕如下(不是有人删断言换绿)────────────────────────────┐
|
// │ 曾经断言:check("请验单提交 Layer 2 与消费事件共事务", │
|
// │ annotatedBusinessAction(qingyandanService, │
|
// │ "action = \"SUBMIT\"", "public void submit("), │
|
// │ "submit 缺少 @AuditLog"); │
|
// │ 即:LimsQingyandanServiceImpl.submit() 必须带 @AuditLog(action="SUBMIT")。│
|
// │ │
|
// │ 为什么被取代:spec D7 已把两处 SUBMIT 埋点整块删除——客户关注的是他自己触发 │
|
// │ 的动作(表单的新增/修改),那已由层 0 记录;submit() 是表单保存之后的后端 │
|
// │ 内部编排,再记一条「请验单 提交」只会让列表出现用户没做过的动作行。 │
|
// │ 所以这条断言的**前提**(submit 有层 2 事件)不复存在,不是覆盖面丢失。 │
|
// │ │
|
// │ 谁接替:tasks/lims-sign-usage-checks.sh G 组「SUBMIT 埋点已删(恰好 0 处)」,│
|
// │ 同一个位置从「必须在」倒转成「必须不在」,且做过双向变异验证(加回来也红)。 │
|
// └────────────────────────────────────────────────────────────────────────────┘
|
check("取样 Layer 2 与消费事件共事务",
|
annotatedBusinessAction(qingyandanService, "action = \"FETCH\"", "public void fetch("),
|
"fetch 缺少 @AuditLog");
|
check("接样 Layer 2 与消费事件共事务",
|
annotatedBusinessAction(qingyandanService, "action = \"RECEIVE\"", "public void jieyangReceive("),
|
"jieyangReceive 缺少 @AuditLog");
|
check("请验单撤回使用稳定签名操作码",
|
signOpType.contains("QINGYANDAN_CANCEL(\"qingyandan_cancel\", \"请验单撤回\")"),
|
"缺少 QINGYANDAN_CANCEL 稳定 code");
|
checkCancelAffectedGuard(qingyandanService);
|
checkQingyandanEntityContract(qingyandanEntity);
|
check("SIGN_USED 写入 bizModule",
|
signService.contains(".bizModule(group.getBizModule())"),
|
"AuditEventDTO builder 未写 bizModule");
|
String publishSignUsed = methodBody(signService, "private void publishSignUsedEvents(");
|
check("SIGN_USED 将签名 note 写入 reason",
|
publishSignUsed.contains(".reason(sign.getNote())"),
|
"SIGN_USED AuditEventDTO 未把 lims_sign.note 映射到 reason");
|
checkSignClaimGuard(signService);
|
check("全部签名使用目标显式声明 bizModule", allTargetsDeclareBizModule(),
|
"存在未声明 bizModule 的 LimsSignUsageTarget.builder() 块");
|
|
System.out.println("=== SIGN_USED 接线断言:" + passed + " 通过 / " + failed + " 失败 ===");
|
System.exit(failed == 0 ? 0 : 1);
|
}
|
|
/**
|
* 取样 / 接样仍走本方法,语义未变(成功后仍应写 SIGN_USED),2026-07-31 <b>一字未动</b>。
|
* 请验单提交已 retarget 到 {@link #checkQingyandanSubmitClaim}。
|
*/
|
private static void checkConsumption(String label, String source, String opType) {
|
check(label + "校验签名", source.contains("SignOpType." + opType), opType + " 未接入");
|
int operation = source.indexOf("SignOpType." + opType);
|
int record = source.indexOf("recordSignUsage", operation);
|
check(label + "成功后记录 SIGN_USED", operation >= 0 && record > operation,
|
opType + " 后未调用 recordSignUsage");
|
}
|
|
/**
|
* 请验单提交:D8 之后<b>只做原子认领、不再写 SIGN_USED</b>。
|
*
|
* <p>取代原来的 {@code checkConsumption("请验单提交", ...)} 调用。除语义外还修掉了那边的
|
* <b>范围缺陷</b>:{@code source.indexOf("recordSignUsage", operation)} 是从某位置往后
|
* <b>在整个文件里</b>找,命中的其实是私有方法体内那行 {@code limsSignService.recordSignUsage(...)},
|
* 与 submit 端点没有任何绑定关系——把 submit 里的调用整行删掉,那条断言照样绿。
|
* 本方法把搜索范围<b>夹在 submit 端点方法体内</b>,改坏 submit 才会红。
|
*
|
* <p>守的是<b>认领</b>不是事件:认领是「签名单次消费」的防重放护栏(ed4d908),
|
* 删掉等于同一个签名可以反复使用。D8 停的只是事件。
|
*
|
* <p>⚠️ 取样({@code QUYANG_FETCH})/ 接样({@code JIEYANG_RECEIVE})语义未变,
|
* 仍走 {@link #checkConsumption} 且一字未动。它们沿用的整文件 indexOf 有同样的范围缺陷,
|
* 但本期不在范围内,已记入 Task 8 报告待处理——不在这里顺手改,是为了让
|
* 「取样 / 接样没被改到」在 diff 上一眼可见。
|
*/
|
private static void checkQingyandanSubmitClaim(String source) {
|
String block = methodBody(source, "public R<String> qingyandanSubmit(");
|
// 判据写成**完整调用**而不是光一个 SignOpType.QINGYANDAN_SUBMIT:
|
// 认领那行也带着同一个枚举名,只查枚举名的话,把校验改成传 null 照样绿(变异 N3 实测)。
|
int verify = block.indexOf(
|
"requireQingyandanSign(qingyandan, formData, SignOpType.QINGYANDAN_SUBMIT)");
|
int business = block.indexOf("limsQingyandanService.submit(", verify);
|
int claim = block.indexOf("claimQingyandanSignUsageOnly(", business);
|
check("请验单提交按 QINGYANDAN_SUBMIT 校验签名", verify >= 0,
|
"submit 端点未按 QINGYANDAN_SUBMIT 校验签名");
|
// 顺序也钉死:认领必须在业务写库**之后**。提前认领 = 业务失败时签名已被消费,
|
// 正是 ed4d908 修掉的那个 bug。
|
check("请验单提交校验签名后执行、业务成功后才原子认领(不再写 SIGN_USED)",
|
verify >= 0 && business > verify && claim > business,
|
"submit 端点体内未在业务成功后调用 claimQingyandanSignUsageOnly"
|
+ " —— 防重放护栏丢失或顺序颠倒(ed4d908 回归)");
|
check("请验单提交不再走发事件路径",
|
!block.isEmpty()
|
&& !block.contains("recordQingyandanSignUsage(")
|
&& !block.contains("limsSignService.recordSignUsage("),
|
"submit 端点仍写 SIGN_USED,与层 0 据签名字段补写的签名事件重复(D8)");
|
}
|
|
/** 按方法签名夹出方法体(到同缩进的收尾大括号);取不到时返回空串,让断言按红处理。 */
|
private static String methodBody(String source, String signature) {
|
int start = source.indexOf(signature);
|
if (start < 0) {
|
return "";
|
}
|
int end = source.indexOf("\n }", start);
|
return end > start ? source.substring(start, end) : "";
|
}
|
|
private static void checkCancelConsumption(String source) {
|
int cancelCase = source.indexOf("case \"cancel\":");
|
int operation = source.indexOf("SignOpType.QINGYANDAN_CANCEL", cancelCase);
|
int cancel = source.indexOf("limsQingyandanService.cancel", operation);
|
int record = source.indexOf("recordQingyandanSignUsage", cancel);
|
check("请验单撤回从请求读取签名",
|
source.contains("params.get(\"biz_sign\")") && source.contains("params.get(\"id\")"),
|
"撤回未读取 biz_sign 或兼容 id");
|
check("请验单撤回校验签名后执行且成功后记录 SIGN_USED",
|
cancelCase >= 0 && operation > cancelCase && cancel > operation && record > cancel,
|
"撤回的签名校验、业务更新、SIGN_USED 顺序错误");
|
}
|
|
private static void checkQingyandanFreshSign(String source) {
|
int start = source.indexOf("private LimsSignEntity requireQingyandanSign(");
|
int end = source.indexOf("private LimsSignEntity requireQingyandanActionSign(", start);
|
String block = start >= 0 && end > start ? source.substring(start, end) : "";
|
check("请验单提交和编辑必须携带本次签名",
|
block.contains("request.getBizSign()") && !block.contains("persisted.getBizSign()"),
|
"请求缺签名时仍会复用业务表中的历史 biz_sign");
|
}
|
|
private static void checkHuanjingFailureBoundary(String source,
|
String method,
|
String businessCall,
|
String opType) {
|
int methodAt = source.indexOf(method);
|
int nextMethod = source.indexOf("\n public ", methodAt + method.length());
|
String block = methodAt >= 0
|
? source.substring(methodAt, nextMethod > methodAt ? nextMethod : source.length())
|
: "";
|
int validation = block.indexOf("limsSignService.requireValidSign(");
|
int catchAt = block.indexOf("catch (DataException e)", validation);
|
int catchClose = block.indexOf("\n }", catchAt);
|
int business = block.indexOf(businessCall, catchClose);
|
int record = block.indexOf("recordHuanjingSignUsage", business);
|
check("环境监测 " + opType + " 只捕获签名校验异常",
|
validation >= 0 && catchAt > validation && catchClose > catchAt
|
&& business > catchClose && record > business,
|
"业务更新或 SIGN_USED 仍在 DataException 捕获范围内");
|
}
|
|
private static void checkCancelAffectedGuard(String source) {
|
int start = source.indexOf("public boolean cancel(");
|
int end = source.indexOf("\n @Override", start);
|
String block = start >= 0 && end > start ? source.substring(start, end) : "";
|
int conditionalUpdate = block.indexOf("update.eq(\"biz_status\", BizStatus.CONFIRMED.getCode())");
|
int zeroReturn = block.indexOf("return false;");
|
int softDelete = block.indexOf("softDeleteJianyanXiangRecords");
|
check("请验单撤回按 confirmed 状态原子更新",
|
block.contains("activeRowUpdate(qingyandan.getId())") && conditionalUpdate >= 0,
|
"cancel 缺少状态或未删除条件");
|
check("affected=0 不执行撤回副作用",
|
zeroReturn > conditionalUpdate && softDelete > zeroReturn,
|
"cancel 在确认更新命中前执行了软删除");
|
check("affected=0 不生成业务事件",
|
source.substring(Math.max(0, source.lastIndexOf("@AuditLog(", start)), start)
|
.contains("#result ? #qingyandan.jianyanLiushuihao : null"),
|
"cancel 的 @AuditLog 未按返回值跳过 0 行事件");
|
}
|
|
private static void checkQingyandanEntityContract(String entity) {
|
String[] fields = {"qingyan_fenlei", "zhouqi", "zhouqi_danwei"};
|
for (String field : fields) {
|
check("实体包含请验字段 " + field,
|
entity.contains("@TableField(\"" + field + "\")"),
|
field + " 的实体契约不完整");
|
}
|
}
|
|
private static void checkSignClaimGuard(String source) {
|
int requireStart = source.indexOf("public LimsSignEntity requireValidSign(");
|
int recordStart = source.indexOf("public void recordSignUsage(", requireStart);
|
String requireBlock = requireStart >= 0 && recordStart > requireStart
|
? source.substring(requireStart, recordStart) : "";
|
int claim = source.indexOf("claimSignUsage(sign, opType, usageJson)", recordStart);
|
int event = source.indexOf("auditClientProvider.ifAvailable", recordStart);
|
check("签名校验不提前写库认领",
|
!requireBlock.contains("updateById(") && !requireBlock.contains("this.update("),
|
"requireValidSign 仍会在业务成功前写签名");
|
check("已消费签名不可跨业务周期复用",
|
requireBlock.contains("sign.getExtraJson()")
|
&& requireBlock.contains("电子签名已被使用,请重新签名")
|
&& source.contains("w.isNull(\"extra_json\").or().eq(\"extra_json\", \"\")")
|
&& !source.contains(".or().eq(\"extra_json\", usageJson)"),
|
"extra_json 已有值时仍可能再次消费同一签名");
|
check("签名成功后先原子认领再记录事件",
|
claim > recordStart && event > claim
|
&& source.contains("w.isNull(\"op_type\")")
|
&& source.contains("w.isNull(\"data_id\")")
|
&& source.contains("w.isNull(\"extra_json\")"),
|
"SIGN_USED 缺少认领冲突条件或事件顺序错误");
|
check("签名认领失败标记事务回滚",
|
source.contains("currentTransactionStatus().setRollbackOnly()")
|
&& source.contains("throw signUsageFailure("),
|
"controller 捕获 DataException 时事务可能继续提交");
|
}
|
|
private static boolean annotatedBusinessAction(String source, String action, String method) {
|
int methodAt = source.indexOf(method);
|
int actionAt = source.lastIndexOf(action, methodAt);
|
int annotationAt = source.lastIndexOf("@AuditLog(", methodAt);
|
return methodAt >= 0 && annotationAt >= 0 && actionAt > annotationAt
|
&& methodAt - annotationAt < 600;
|
}
|
|
private static boolean allTargetsDeclareBizModule() throws IOException {
|
Path root = Paths.get("jnpf-lims");
|
try (Stream<Path> files = Files.walk(root)) {
|
return files.filter(path -> path.toString().contains("/src/main/java/")
|
&& path.toString().endsWith(".java"))
|
.allMatch(LimsSignUsageWiringProbe::targetBlocksDeclareBizModule);
|
}
|
}
|
|
private static boolean targetBlocksDeclareBizModule(Path path) {
|
try {
|
String source = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
int from = 0;
|
while (true) {
|
int builder = source.indexOf("LimsSignUsageTarget.builder()", from);
|
if (builder < 0) {
|
return true;
|
}
|
int build = source.indexOf(".build()", builder);
|
if (build < 0 || !source.substring(builder, build).contains(".bizModule(")) {
|
return false;
|
}
|
from = build + 8;
|
}
|
} catch (IOException e) {
|
throw new IllegalStateException("读取源码失败: " + path, e);
|
}
|
}
|
|
private static String read(String path) throws IOException {
|
return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8);
|
}
|
|
private static void check(String name, boolean ok, String detail) {
|
if (ok) {
|
passed++;
|
System.out.println(" [PASS] " + name);
|
} else {
|
failed++;
|
System.out.println(" [FAIL] " + name + " -- " + detail);
|
}
|
}
|
|
private LimsSignUsageWiringProbe() {
|
}
|
}
|