task-124: @Transactional 方法审计清单(147 方法/30 模块扫描器 + 13 条测试 + 报告快照,不改生产代码)
This commit is contained in:
@@ -0,0 +1,204 @@
|
|||||||
|
"""task-124 @Transactional 方法审计清单测试。
|
||||||
|
|
||||||
|
对应 plan 07 任务 124 的 8 条用例 + 2 条补强:
|
||||||
|
1. test_audit_covers_all_modules 覆盖全部业务模块
|
||||||
|
2. test_audit_classification 分类完整(必须/可移出)
|
||||||
|
3. test_audit_result_endpoints /result 相关方法都在清单
|
||||||
|
4. test_audit_python_facing Python 回调相关标记
|
||||||
|
5. test_audit_priority_order 按收益排序
|
||||||
|
6. test_audit_no_code_change 本任务零代码变更
|
||||||
|
7. test_audit_documented 文档产出
|
||||||
|
8. test_audit_repeatable 可重复执行
|
||||||
|
9. test_audit_detects_tx_method 识别 @Transactional 方法
|
||||||
|
10. test_audit_classifies_movable 事务内可移出段分类
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tx_audit import MODULES_ROOT, scan_modules, scan_text
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = SCRIPTS_DIR.parent.parent
|
||||||
|
DOC_PATH = REPO_ROOT / "backend-java" / "docs" / "transaction-boundary-audit.md"
|
||||||
|
|
||||||
|
FIXTURE_TX = """package com.example;
|
||||||
|
@Service
|
||||||
|
public class DemoService {
|
||||||
|
@Transactional
|
||||||
|
public boolean submitResult(Long taskId, List<RowVo> rows) {
|
||||||
|
List<ItemVo> items = rows.stream()
|
||||||
|
.map(row -> toVo(row))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
int updated = fileResultMapper.update(null, new LambdaUpdateWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getId, taskId)
|
||||||
|
.set(FileResultEntity::getStatus, "SUCCESS"));
|
||||||
|
log.info("submitted taskId={} count={}", taskId, items.size());
|
||||||
|
Files.deleteIfExists(Path.of("/tmp/task-" + taskId));
|
||||||
|
return updated > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void noTxMethod(Long id) {
|
||||||
|
fileTaskMapper.selectById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
FIXTURE_WRITE_ONLY = """package com.example;
|
||||||
|
@Service
|
||||||
|
public class DemoService {
|
||||||
|
@Transactional
|
||||||
|
public int markFailed(Long id) {
|
||||||
|
return fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
||||||
|
.eq(FileTaskEntity::getId, id)
|
||||||
|
.set(FileTaskEntity::getStatus, "FAILED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
FIXTURE_LOCK = """package com.example;
|
||||||
|
@Service
|
||||||
|
public class DemoService {
|
||||||
|
@Transactional
|
||||||
|
public void finalizeTask(Long taskId) {
|
||||||
|
TaskDistributedLockService.LockHandle handle = acquireTaskLock("MODULE", taskId);
|
||||||
|
if (handle == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (handle) {
|
||||||
|
taskMapper.update(null, new LambdaUpdateWrapper<TaskFileEntity>()
|
||||||
|
.eq(TaskFileEntity::getId, taskId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
FIXTURE_PLAIN = """package com.example;
|
||||||
|
@Service
|
||||||
|
public class DemoService {
|
||||||
|
public int sum(List<Integer> nums) {
|
||||||
|
return nums.stream().mapToInt(Integer::intValue).sum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TxAuditDetectTest(unittest.TestCase):
|
||||||
|
def test_audit_detects_tx_method(self):
|
||||||
|
findings = scan_text(FIXTURE_TX, "DemoService.java")
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
self.assertEqual(findings[0]["method"], "submitResult")
|
||||||
|
self.assertFalse(findings[0]["class_level"])
|
||||||
|
|
||||||
|
def test_audit_classifies_movable(self):
|
||||||
|
findings = scan_text(FIXTURE_TX, "DemoService.java")
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
movable = findings[0]["movable"]
|
||||||
|
self.assertIn("compute", movable)
|
||||||
|
self.assertIn("assembly", movable)
|
||||||
|
self.assertIn("log", movable)
|
||||||
|
self.assertIn("cleanup", movable)
|
||||||
|
self.assertIn("write", findings[0]["must_stay"])
|
||||||
|
self.assertGreater(findings[0]["priority"], 0)
|
||||||
|
|
||||||
|
def test_audit_no_tx_not_reported(self):
|
||||||
|
self.assertEqual(len(scan_text(FIXTURE_PLAIN, "Plain.java")), 0)
|
||||||
|
|
||||||
|
def test_audit_write_only_no_movable(self):
|
||||||
|
findings = scan_text(FIXTURE_WRITE_ONLY, "DemoService.java")
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
self.assertIn("write", findings[0]["must_stay"])
|
||||||
|
self.assertEqual(findings[0]["movable"], [])
|
||||||
|
|
||||||
|
def test_audit_lock_flagged_must_stay(self):
|
||||||
|
findings = scan_text(FIXTURE_LOCK, "DemoService.java")
|
||||||
|
self.assertEqual(len(findings), 1)
|
||||||
|
self.assertIn("lock", findings[0]["must_stay"])
|
||||||
|
|
||||||
|
|
||||||
|
class TxAuditCoverageTest(unittest.TestCase):
|
||||||
|
def test_audit_covers_all_modules(self):
|
||||||
|
report = scan_modules(MODULES_ROOT)
|
||||||
|
self.assertGreaterEqual(report.scanned_files, 600)
|
||||||
|
self.assertGreater(len(report.findings), 50)
|
||||||
|
modules = {f["module"] for f in report.findings}
|
||||||
|
self.assertGreaterEqual(len(modules), 15)
|
||||||
|
|
||||||
|
def test_audit_classification(self):
|
||||||
|
for finding in scan_modules(MODULES_ROOT).findings:
|
||||||
|
self.assertIn("must_stay", finding)
|
||||||
|
self.assertIn("movable", finding)
|
||||||
|
self.assertIsInstance(finding["must_stay"], list)
|
||||||
|
self.assertIsInstance(finding["movable"], list)
|
||||||
|
|
||||||
|
def test_audit_result_endpoints(self):
|
||||||
|
report = scan_modules(MODULES_ROOT)
|
||||||
|
result_methods = [
|
||||||
|
f
|
||||||
|
for f in report.findings
|
||||||
|
if "result" in f["method"].lower() or "submit" in f["method"].lower()
|
||||||
|
]
|
||||||
|
self.assertGreater(
|
||||||
|
len(result_methods), 0, "应找到 /result 相关 @Transactional 方法"
|
||||||
|
)
|
||||||
|
modules_with_result = {f["module"] for f in result_methods}
|
||||||
|
self.assertGreaterEqual(len(modules_with_result), 3)
|
||||||
|
|
||||||
|
def test_audit_python_facing(self):
|
||||||
|
report = scan_modules(MODULES_ROOT)
|
||||||
|
flagged = [f for f in report.findings if f["python_facing"]]
|
||||||
|
self.assertGreater(len(flagged), 0, "应标记 Python 回调相关方法")
|
||||||
|
for f in flagged:
|
||||||
|
name = f["method"].lower()
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
k in name
|
||||||
|
for k in (
|
||||||
|
"result",
|
||||||
|
"submit",
|
||||||
|
"upload",
|
||||||
|
"chunk",
|
||||||
|
"done",
|
||||||
|
"ack",
|
||||||
|
"report",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
f"python_facing 标记与命名不符: {f['method']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audit_priority_order(self):
|
||||||
|
findings = scan_modules(MODULES_ROOT).findings
|
||||||
|
priorities = [f["priority"] for f in findings]
|
||||||
|
self.assertEqual(priorities, sorted(priorities, reverse=True))
|
||||||
|
|
||||||
|
def test_audit_repeatable(self):
|
||||||
|
first = scan_modules(MODULES_ROOT).to_dict()
|
||||||
|
second = scan_modules(MODULES_ROOT).to_dict()
|
||||||
|
self.assertEqual(
|
||||||
|
json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audit_documented(self):
|
||||||
|
self.assertTrue(DOC_PATH.is_file(), f"audit doc missing: {DOC_PATH}")
|
||||||
|
text = DOC_PATH.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("事务", text)
|
||||||
|
self.assertIn("必须", text)
|
||||||
|
self.assertIn("可移出", text)
|
||||||
|
|
||||||
|
def test_audit_no_code_change(self):
|
||||||
|
changed = subprocess.run(
|
||||||
|
["git", "diff", "--name-only", "HEAD", "--", "backend-java/src"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
self.assertEqual(changed, "", f"生产代码被改动: {changed}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""task-124 @Transactional 方法审计清单工具。
|
||||||
|
|
||||||
|
对 backend-java modules 下的 Java 源码做机械化扫描:定位全部方法级
|
||||||
|
@Transactional 注解,按方法体内容分类事务内代码段——必须事务内(落库写/状态/锁)
|
||||||
|
与可移出(纯计算/DTO 组装/日志/临时文件清理),标记 Python 回调相关方法,
|
||||||
|
按可移出收益排序,产出审计清单供 plan 07 的任务 125-131 事务收缩参考。
|
||||||
|
本工具只读,不改任何代码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from n1_scan import _match_span, _strip_comments
|
||||||
|
|
||||||
|
MODULES_ROOT = (
|
||||||
|
Path(__file__).resolve().parent.parent / "src/main/java/com/nanri/aiimage/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
TX_RE = re.compile(r"@Transactional\b")
|
||||||
|
METHOD_RE = re.compile(
|
||||||
|
r"\s*(?:(?:public|protected|private)\s+)?"
|
||||||
|
r"(?:[\w<>,.?\[\] ]+[\s])?(\w+)\s*\("
|
||||||
|
)
|
||||||
|
PYTHON_FACING_RE = re.compile(r"(?i)(result|submit|upload|chunk|done|ack|report)")
|
||||||
|
|
||||||
|
WRITE_RE = re.compile(
|
||||||
|
r"\.(insert|update|deleteById|deleteBatchIds|removeById|removeByIds|remove\b|"
|
||||||
|
r"save|saveBatch|saveOrUpdate|updateById|insertOrUpdate|updateBatchById)\s*\("
|
||||||
|
)
|
||||||
|
LOCK_RE = re.compile(r"acquireTaskLock|\.tryLock\(|LockHandle|taskLockHandle")
|
||||||
|
CLEANUP_RE = re.compile(
|
||||||
|
r"Files\.(delete|deleteIfExists|move)|deleteIfExists\(|"
|
||||||
|
r"tempDir|tempFile|temp-dir|cleanupTemp|deleteTemp|临时文件"
|
||||||
|
)
|
||||||
|
COMPUTE_RE = re.compile(
|
||||||
|
r"\.stream\(\)|\.map\(|\.collect\(|\.filter\(|\.reduce\(|\.sorted\(|"
|
||||||
|
r"Collectors\.|computeIfAbsent|\.distinct\(|\.flatMap\(|\.peek\("
|
||||||
|
)
|
||||||
|
ASSEMBLY_RE = re.compile(
|
||||||
|
r"build\w*(Vo|VO|Dto|DTO)|toVo\(|toEntity\(|convert\w*\(|assemble\w*\(|"
|
||||||
|
r"new \w+(Vo|VO|Dto|DTO)\b"
|
||||||
|
)
|
||||||
|
LOG_RE = re.compile(r"log\.(info|warn|debug|error|trace)\s*\(")
|
||||||
|
|
||||||
|
|
||||||
|
def _match_body_span(text: str, open_pos: int) -> int | None:
|
||||||
|
"""从 { 出发,返回匹配的 } 偏移(含)。
|
||||||
|
|
||||||
|
圆括号深度同步跟踪:lambda/方法调用内的 { } 不计入方法体层级,
|
||||||
|
方法体的闭合 } 总是在圆括号外层(paren == 0)首次归零时命中。
|
||||||
|
"""
|
||||||
|
depth = 0
|
||||||
|
paren = 0
|
||||||
|
for i in range(open_pos, len(text)):
|
||||||
|
c = text[i]
|
||||||
|
if c == "(":
|
||||||
|
paren += 1
|
||||||
|
elif c == ")":
|
||||||
|
paren -= 1
|
||||||
|
elif c == "{" and paren == 0:
|
||||||
|
depth += 1
|
||||||
|
elif c == "}" and paren == 0:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _count_hits(pattern: re.Pattern[str], text: str) -> int:
|
||||||
|
return len(pattern.findall(text))
|
||||||
|
|
||||||
|
|
||||||
|
def scan_text(text: str, filename: str = "fixture.java") -> list[dict]:
|
||||||
|
"""扫描单份 Java 源码,返回 @Transactional 方法审计条目(按收益降序)。"""
|
||||||
|
stripped = _strip_comments(text)
|
||||||
|
findings: list[dict] = []
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
tx_match = TX_RE.search(stripped, pos)
|
||||||
|
if not tx_match:
|
||||||
|
break
|
||||||
|
anno_end = tx_match.end()
|
||||||
|
if stripped[anno_end : anno_end + 1] == "(":
|
||||||
|
close = _match_span(stripped, anno_end)
|
||||||
|
if close is None:
|
||||||
|
pos = tx_match.end()
|
||||||
|
continue
|
||||||
|
anno_end = close + 1
|
||||||
|
method_match = METHOD_RE.search(stripped, anno_end)
|
||||||
|
if not method_match:
|
||||||
|
pos = tx_match.end()
|
||||||
|
continue
|
||||||
|
method_name = method_match.group(1)
|
||||||
|
paren_pos = method_match.end() - 1
|
||||||
|
close_paren = _match_span(stripped, paren_pos)
|
||||||
|
if close_paren is None:
|
||||||
|
pos = method_match.end()
|
||||||
|
continue
|
||||||
|
body_start = close_paren + 1
|
||||||
|
while body_start < len(stripped) and stripped[body_start].isspace():
|
||||||
|
body_start += 1
|
||||||
|
if body_start >= len(stripped) or stripped[body_start] != "{":
|
||||||
|
# 抽象方法/接口声明无方法体,跳过
|
||||||
|
pos = method_match.end()
|
||||||
|
continue
|
||||||
|
body_end = _match_body_span(stripped, body_start)
|
||||||
|
if body_end is None:
|
||||||
|
pos = method_match.end()
|
||||||
|
continue
|
||||||
|
body = stripped[body_start + 1 : body_end]
|
||||||
|
|
||||||
|
must_stay: list[str] = []
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
if _count_hits(WRITE_RE, body) > 0:
|
||||||
|
must_stay.append("write")
|
||||||
|
counts["write"] = _count_hits(WRITE_RE, body)
|
||||||
|
if _count_hits(LOCK_RE, body) > 0:
|
||||||
|
must_stay.append("lock")
|
||||||
|
counts["lock"] = _count_hits(LOCK_RE, body)
|
||||||
|
|
||||||
|
movable: list[str] = []
|
||||||
|
for key, pattern in (
|
||||||
|
("compute", COMPUTE_RE),
|
||||||
|
("assembly", ASSEMBLY_RE),
|
||||||
|
("cleanup", CLEANUP_RE),
|
||||||
|
("log", LOG_RE),
|
||||||
|
):
|
||||||
|
hits = _count_hits(pattern, body)
|
||||||
|
counts[key] = hits
|
||||||
|
if hits > 0:
|
||||||
|
movable.append(key)
|
||||||
|
|
||||||
|
priority = sum(
|
||||||
|
counts.get(key, 0) for key in ("compute", "assembly", "cleanup", "log")
|
||||||
|
)
|
||||||
|
findings.append(
|
||||||
|
{
|
||||||
|
"file": filename,
|
||||||
|
"line": _line_of(text, tx_match.start()),
|
||||||
|
"method": method_name,
|
||||||
|
"signature": " ".join(
|
||||||
|
stripped[method_match.start() : close_paren].split()
|
||||||
|
)[:100],
|
||||||
|
"class_level": False,
|
||||||
|
"python_facing": bool(PYTHON_FACING_RE.search(method_name)),
|
||||||
|
"must_stay": must_stay,
|
||||||
|
"movable": movable,
|
||||||
|
"counts": counts,
|
||||||
|
"priority": priority,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pos = body_end + 1
|
||||||
|
|
||||||
|
findings.sort(key=lambda f: (-f["priority"], f["line"]))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _line_of(text: str, offset: int) -> int:
|
||||||
|
return text.count("\n", 0, offset) + 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
scanned_files: int = 0
|
||||||
|
modules: list[str] = field(default_factory=list)
|
||||||
|
findings: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"scanned_files": self.scanned_files,
|
||||||
|
"modules": self.modules,
|
||||||
|
"findings": self.findings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_modules(root: Path) -> Report:
|
||||||
|
"""扫描 modules 根目录下全部 Java 文件,产出 @Transactional 审计报告。"""
|
||||||
|
report = Report()
|
||||||
|
module_set: set[str] = set()
|
||||||
|
for java_file in sorted(root.rglob("*.java")):
|
||||||
|
report.scanned_files += 1
|
||||||
|
rel = java_file.relative_to(root)
|
||||||
|
module_set.add(rel.parts[0])
|
||||||
|
for finding in scan_text(java_file.read_text(encoding="utf-8"), str(rel)):
|
||||||
|
finding["module"] = rel.parts[0]
|
||||||
|
report.findings.append(finding)
|
||||||
|
report.findings.sort(key=lambda f: (-f["priority"], f["file"], f["line"]))
|
||||||
|
report.modules = sorted(module_set)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="@Transactional 方法审计")
|
||||||
|
parser.add_argument("--json", help="输出 JSON 报告路径(默认 stdout)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = scan_modules(MODULES_ROOT)
|
||||||
|
payload = report.to_dict()
|
||||||
|
if args.json:
|
||||||
|
Path(args.json).write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
print(
|
||||||
|
f"scanned {report.scanned_files} files, "
|
||||||
|
f"{len(report.findings)} @Transactional methods "
|
||||||
|
f"across {len(report.modules)} modules",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user