task-121: N+1 扫描审计(循环内单查扫描器 + 10 条测试 + 报告快照,不改生产代码)

This commit is contained in:
2026-09-02 02:40:16 +08:00
parent 5f4fcad2ef
commit 29625f703c
3 changed files with 829 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
{
"scanned_files": 640,
"modules": [
"admin",
"appearancepatent",
"auth",
"brand",
"collectdata",
"convert",
"debug",
"dedupe",
"deletebrand",
"digitalhuman",
"file",
"filetemplate",
"imagehistory",
"imagevideo",
"invalidasin",
"patroldelete",
"permission",
"pricetrack",
"productcategory",
"productrisk",
"publish",
"queryasin",
"shopdatacrawl",
"shopkey",
"shopmatch",
"similarasin",
"split",
"task",
"withdraw",
"ziniao"
],
"findings": [
{
"file": "deletebrand\\service\\DeleteBrandStaleTaskService.java",
"line": 209,
"statement": "FileTaskEntity refreshed = fileTaskMapper.selectById(task.getId());",
"loop_type": "for",
"impact": "HIGH",
"module": "deletebrand"
},
{
"file": "pricetrack\\service\\PriceTrackTaskService.java",
"line": 354,
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
"loop_type": "for",
"impact": "HIGH",
"module": "pricetrack"
},
{
"file": "productrisk\\service\\ProductRiskTaskService.java",
"line": 339,
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
"loop_type": "for",
"impact": "HIGH",
"module": "productrisk"
},
{
"file": "task\\service\\TaskFileJobService.java",
"line": 282,
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
"loop_type": "for",
"impact": "HIGH",
"module": "task"
},
{
"file": "task\\service\\TaskFileJobService.java",
"line": 303,
"statement": "TaskFileJobEntity exhausted = taskFileJobMapper.selectById(job.getId());",
"loop_type": "for",
"impact": "HIGH",
"module": "task"
},
{
"file": "task\\service\\TaskFileJobService.java",
"line": 315,
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
"loop_type": "for",
"impact": "HIGH",
"module": "task"
},
{
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
"line": 1094,
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "appearancepatent"
},
{
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
"line": 2112,
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
"loop_type": "while",
"impact": "LOW",
"module": "appearancepatent"
},
{
"file": "dedupe\\service\\DedupeTotalDataService.java",
"line": 279,
"statement": "List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(",
"loop_type": "while",
"impact": "LOW",
"module": "dedupe"
},
{
"file": "deletebrand\\service\\DeleteBrandRunService.java",
"line": 1234,
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "deletebrand"
},
{
"file": "deletebrand\\service\\DeleteBrandRunService.java",
"line": 1252,
"statement": "rows.addAll(fileResultMapper.selectList(historyResultQuery()",
"loop_type": "for",
"impact": "LOW",
"module": "deletebrand"
},
{
"file": "deletebrand\\service\\DeleteBrandRunService.java",
"line": 1268,
"statement": "List<FileResultEntity> rows = fileResultMapper.selectList(historyResultQuery()",
"loop_type": "for",
"impact": "LOW",
"module": "deletebrand"
},
{
"file": "patroldelete\\service\\PatrolDeleteTaskService.java",
"line": 638,
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "patroldelete"
},
{
"file": "permission\\service\\PermissionMenuService.java",
"line": 676,
"statement": "cursor = permissionMenuMapper.selectById(ancestorId);",
"loop_type": "while",
"impact": "LOW",
"module": "permission"
},
{
"file": "pricetrack\\service\\PriceTrackTaskService.java",
"line": 153,
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "pricetrack"
},
{
"file": "pricetrack\\service\\PriceTrackTaskService.java",
"line": 418,
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "pricetrack"
},
{
"file": "productcategory\\service\\ProductCategoryService.java",
"line": 465,
"statement": "ProductCategoryEntity parent = productCategoryMapper.selectById(cursor.getParentId());",
"loop_type": "while",
"impact": "LOW",
"module": "productcategory"
},
{
"file": "productcategory\\service\\ProductCategoryService.java",
"line": 481,
"statement": "cursor = cursor.getParentId() == null ? null : productCategoryMapper.selectById(cursor.getParentId());",
"loop_type": "while",
"impact": "LOW",
"module": "productcategory"
},
{
"file": "productcategory\\service\\ProductCategoryService.java",
"line": 518,
"statement": "ProductCategoryEntity row = productCategoryMapper.selectById(cursor);",
"loop_type": "while",
"impact": "LOW",
"module": "productcategory"
},
{
"file": "productrisk\\service\\ProductRiskTaskService.java",
"line": 144,
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "productrisk"
},
{
"file": "queryasin\\service\\QueryAsinTaskService.java",
"line": 587,
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "queryasin"
},
{
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
"line": 1085,
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "shopdatacrawl"
},
{
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
"line": 1940,
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
"loop_type": "for",
"impact": "LOW",
"module": "shopdatacrawl"
},
{
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
"line": 2497,
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
"loop_type": "for",
"impact": "LOW",
"module": "shopdatacrawl"
},
{
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
"line": 2575,
"statement": "FileTaskEntity task = fileTaskMapper.selectById(member.getTaskId());",
"loop_type": "for",
"impact": "LOW",
"module": "shopdatacrawl"
},
{
"file": "shopmatch\\service\\ShopMatchTaskService.java",
"line": 179,
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "shopmatch"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 1499,
"statement": "page = mapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskChunkEntity>()",
"loop_type": "while",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 1646,
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 2346,
"statement": "FileTaskEntity task = fileTaskMapper.selectById(taskId);",
"loop_type": "for",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 2356,
"statement": "List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()",
"loop_type": "for",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 2379,
"statement": "TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);",
"loop_type": "for",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 4364,
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
"loop_type": "while",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "similarasin\\service\\SimilarAsinTaskService.java",
"line": 4437,
"statement": "List<TaskScopeStateEntity> page = taskScopeStateMapper.selectList(query);",
"loop_type": "while",
"impact": "LOW",
"module": "similarasin"
},
{
"file": "task\\service\\ModuleHistoryCleanupService.java",
"line": 131,
"statement": "List<FileTaskEntity> page = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
"loop_type": "while",
"impact": "LOW",
"module": "task"
},
{
"file": "task\\service\\TaskFileJobService.java",
"line": 153,
"statement": "TaskFileJobEntity claim = taskFileJobMapper.selectById(candidate.getId());",
"loop_type": "for",
"impact": "LOW",
"module": "task"
}
]
}
+313
View File
@@ -0,0 +1,313 @@
"""task-121 N+1 扫描审计工具。
对 backend-java modules 下的 Java Service 源码做机械化扫描:
识别循环(for/while/forEach/map)内对 MyBatis-Plus 单行查询
selectById/selectOne/selectList/selectCount/selectObjs/selectMaps/getById/getOne
的调用,产出 N+1 候选清单(位置/对象/影响),供人工审计与 task-122 修复。
本工具只读,不改任何代码。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
IMPACT_ORDER = {HIGH: 0, MEDIUM: 1, LOW: 2}
MODULES_ROOT = (
Path(__file__).resolve().parent.parent / "src/main/java/com/nanri/aiimage/modules"
)
# 单行查询调用(selectBatchIds/selectByIds 天然批量,不匹配此正则)
QUERY_RE = re.compile(
r"\.(selectById|selectOne|selectList|selectCount|selectObjs|selectMaps|getById|getOne)\("
)
# 循环头:for/while 关键字 + 圆括号跨度,或 .forEach( / .map( 方法调用
LOOP_RE = re.compile(r"\b(for|while)\s*\(|\.(forEach|map)\s*\(")
# 返回集合的查询调用(循环遍历这类结果集 → 影响 HIGH)
COLLECTION_QUERY_RE = re.compile(
r"(selectList|selectObjs|selectMaps|selectBatchIds|selectByIds|\.list\()"
)
ASSIGN_RE = re.compile(r"(\w+)\s*=\s*([^;{}=]+);")
FIELD_RE = re.compile(r"\b[A-Za-z_]\w*\b")
def _strip_comments(text: str) -> str:
"""用空格替换注释(保留换行与偏移),简化后续扫描。"""
out = list(text)
i, n = 0, len(text)
while i < n:
if text.startswith("/*", i):
j = text.find("*/", i + 2)
j = n if j < 0 else j + 2
for k in range(i, j):
if text[k] != "\n":
out[k] = " "
i = j
elif text.startswith("//", i):
j = text.find("\n", i)
j = n if j < 0 else j
for k in range(i, j):
out[k] = " "
i = j
else:
i += 1
return "".join(out)
def _match_span(text: str, open_pos: int) -> int | None:
"""从 text[open_pos] 的 ( 出发,返回匹配的 ) 偏移(含),不匹配返回 None。"""
depth = 0
for i in range(open_pos, len(text)):
c = text[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i
return None
def _line_of(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
class _Frame:
__slots__ = ("body_depth", "expr", "loop_type", "start")
def __init__(self, start: int, body_depth: int, loop_type: str, expr: str):
self.start = start
self.body_depth = body_depth
self.loop_type = loop_type
self.expr = expr
def _query_results(stripped: str) -> set[str]:
"""收集被赋值给集合查询结果(selectList 等)的变量名。"""
names = set()
for lhs, rhs in ASSIGN_RE.findall(stripped):
if COLLECTION_QUERY_RE.search(rhs):
names.add(lhs)
return names
def _loop_headers(stripped: str) -> list[dict]:
"""预取全部循环头:(start, end, type, expr)start 为 ( 偏移,end 为匹配的 ) 偏移)。"""
headers: list[dict] = []
pos = 0
while True:
m = LOOP_RE.search(stripped, pos)
if not m:
break
paren_pos = m.end() - 1
if stripped[paren_pos] == "(":
close = _match_span(stripped, paren_pos)
if close is not None:
headers.append(
{
"start": paren_pos,
"end": close,
"type": m.group(1) or m.group(2),
"expr": stripped[paren_pos + 1 : close].strip(),
}
)
pos = m.end()
return headers
def _header_context(stripped: str, h: dict, offset: int) -> tuple[bool, str, str]:
"""查询落在循环头括号跨度内时的归属判定。
返回 (是否循环内, 循环类型, 循环表达式):
- lambda 箭头之后 → 循环内(无花括号 lambda 体)
- 增强 for 的 ` : ` 之后(迭代源 selectList 等)→ 集合装载,非循环内
- for/while 初始化或条件 → 视为循环内(条件逐次求值)
"""
expr = h["expr"]
if "->" in stripped[h["start"] : h["end"]]:
arrow = stripped.find("->", h["start"], h["end"])
if arrow != -1 and offset > arrow:
return True, h["type"], expr
return False, h["type"], expr
if " : " in expr:
colon = stripped.find(" : ", h["start"], h["end"])
if colon != -1 and offset > colon:
return False, h["type"], expr
return True, h["type"], expr
return True, h["type"], expr
def scan_text(text: str, filename: str = "fixture.java") -> list[dict]:
"""扫描单份 Java 源码,返回 N+1 候选清单(按影响降序、文件行号升序)。"""
stripped = _strip_comments(text)
query_results = _query_results(stripped)
headers = _loop_headers(stripped)
depth = 0
frames: list[_Frame] = []
single: tuple[str, str, int, int] | None = None # (loop_type, expr, start, end)
findings: list[dict] = []
def in_loop(offset: int) -> bool:
if single is not None and single[2] <= offset < single[3]:
return True
if any(f.start <= offset and depth >= f.body_depth for f in frames):
return True
for h in reversed(headers):
if h["start"] <= offset <= h["end"]:
return _header_context(stripped, h, offset)[0]
return False
def enclosing(offset: int) -> tuple[str, str]:
if single is not None and single[2] <= offset < single[3]:
return single[0], single[1]
for h in reversed(headers):
if h["start"] <= offset <= h["end"]:
in_loop, loop_type, expr = _header_context(stripped, h, offset)
if in_loop:
return loop_type, expr
for f in reversed(frames):
if f.start <= offset:
return f.loop_type, f.expr
return "loop", ""
i = 0
n = len(stripped)
while i < n:
c = stripped[i]
if c == "{":
depth += 1
i += 1
continue
if c == "}":
depth -= 1
if frames and depth == frames[-1].body_depth - 1:
frames.pop()
i += 1
continue
loop_match = LOOP_RE.match(stripped, i)
if loop_match:
loop_type = loop_match.group(1) or loop_match.group(2)
paren_pos = loop_match.end() - 1
close = (
_match_span(stripped, paren_pos) if stripped[paren_pos] == "(" else None
)
if close is not None:
expr = stripped[paren_pos + 1 : close].strip()
body_start = close + 1
while body_start < n and stripped[body_start].isspace():
body_start += 1
if body_start < n and stripped[body_start] == "{":
frames.append(_Frame(body_start + 1, depth + 1, loop_type, expr))
else:
end = stripped.find(";", body_start)
single = (loop_type, expr, body_start, n if end < 0 else end)
# 继续扫描括号跨度内容(无花括号 lambda 体的查询落在其中)
i = paren_pos + 1
continue
i = loop_match.end()
continue
query_match = QUERY_RE.match(stripped, i)
if query_match:
if in_loop(i):
line_start = stripped.rfind("\n", 0, i) + 1
line_end = stripped.find("\n", i)
line_end = n if line_end < 0 else line_end
statement = stripped[line_start:line_end].strip()
loop_type, expr = enclosing(i)
impact = _classify(loop_type, [expr], query_results)
findings.append(
{
"file": filename,
"line": _line_of(text, i),
"statement": statement,
"loop_type": loop_type,
"impact": impact,
}
)
i = query_match.end()
continue
i += 1
findings.sort(key=lambda f: (IMPACT_ORDER[f["impact"]], f["line"]))
return findings
def _classify(loop_type: str, exprs: list[str], query_results: set[str]) -> str:
"""影响分级:遍历查询结果集 → HIGHforEach/map lambda → MEDIUM;其余 → LOW。"""
for expr in exprs:
if expr and COLLECTION_QUERY_RE.search(expr):
return HIGH
if expr and any(t in query_results for t in FIELD_RE.findall(expr)):
return HIGH
if loop_type in ("forEach", "map"):
return MEDIUM
return LOW
@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 文件,产出 N+1 候选报告。"""
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: (IMPACT_ORDER[f["impact"]], f["file"], f["line"])
)
report.modules = sorted(module_set)
return report
def main() -> None:
parser = argparse.ArgumentParser(description="N+1 扫描审计")
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)} N+1 candidates "
f"across {len(report.modules)} modules",
file=sys.stderr,
)
if __name__ == "__main__":
main()
+199
View File
@@ -0,0 +1,199 @@
"""task-121 N+1 扫描审计测试。
对应 plan 06 任务 121 的 8 条用例 + 2 条补强:
1. test_scan_select_in_loop 识别循环内单查
2. test_scan_output_listed 清单产出(位置/对象/影响字段齐全)
3. test_scan_priority 按影响排序
4. test_scan_module_coverage 覆盖主要模块(真实代码扫描)
5. test_scan_false_positive_checked 排除误报(已批量/循环外)
6. test_scan_repeatable 可重复(同输入同输出)
7. test_scan_documented 审计文档产出
8. test_scan_no_edit 本任务不改生产代码
9. test_scan_lambda_brace_less 无花括号 lambda 内单查识别
10. test_scan_iterating_query_result 遍历查询结果集判 HIGH
"""
from __future__ import annotations
import json
import subprocess
import unittest
from pathlib import Path
from n1_scan import (
HIGH,
LOW,
MEDIUM,
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" / "n1-scan-audit.md"
FIXTURE_LOOP = """package com.example;
public class DemoService {
public void batchByIds(List<Long> ids) {
for (Long id : ids) {
FileTaskEntity task = fileTaskMapper.selectById(id);
}
}
}
"""
FIXTURE_QUERY_RESULT_LOOP = """package com.example;
public class DemoService {
public List<Vo> listVos() {
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<>());
List<Vo> vos = new ArrayList<>();
for (FileTaskEntity t : tasks) {
FileResultEntity r = fileResultMapper.selectById(t.getResultId());
vos.add(toVo(r));
}
return vos;
}
}
"""
FIXTURE_LAMBDA_BRACELESS = """package com.example;
public class DemoService {
public void touch(List<Long> ids) {
ids.forEach(id -> fileTaskMapper.selectOne(
new LambdaQueryWrapper<FileTaskEntity>().eq(FileTaskEntity::getId, id)));
}
}
"""
FIXTURE_BATCH_SAFE = """package com.example;
public class DemoService {
public List<FileTaskEntity> load(List<Long> ids) {
List<FileTaskEntity> tasks = new ArrayList<>();
for (Long id : ids) {
tasks.addAll(fileTaskMapper.selectBatchIds(Collections.singleton(id)));
}
for (Long id : ids) {
tasks.addAll(fileTaskMapper.selectByIds(Collections.singleton(id)));
}
return tasks;
}
}
"""
FIXTURE_NO_LOOP = """package com.example;
public class DemoService {
public FileTaskEntity one(Long id) {
return fileTaskMapper.selectById(id);
}
}
"""
FIXTURE_LOOP_NO_QUERY = """package com.example;
public class DemoService {
public int sum(List<Integer> nums) {
int s = 0;
for (Integer n : nums) {
s += n;
}
return s;
}
}
"""
FIXTURE_INDEX_LOOP = """package com.example;
public class DemoService {
public List<Vo> listVos(List<Long> ids) {
List<Vo> vos = new ArrayList<>();
for (int i = 0; i < ids.size(); i++) {
FileTaskEntity t = fileTaskMapper.selectById(ids.get(i));
vos.add(toVo(t));
}
return vos;
}
}
"""
FIXTURE_MIXED = (
FIXTURE_QUERY_RESULT_LOOP
+ "\n"
+ FIXTURE_LAMBDA_BRACELESS
+ "\n"
+ FIXTURE_INDEX_LOOP
)
class ScanDetectTest(unittest.TestCase):
def test_scan_select_in_loop(self):
findings = scan_text(FIXTURE_LOOP, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertIn("selectById", findings[0]["statement"])
self.assertEqual(findings[0]["loop_type"], "for")
self.assertEqual(findings[0]["line"], 5)
def test_scan_output_listed(self):
for finding in scan_text(FIXTURE_MIXED, "DemoService.java"):
for key in ("file", "line", "statement", "loop_type", "impact"):
self.assertIn(key, finding)
self.assertTrue(finding[key] is not None and finding[key] != "")
def test_scan_priority(self):
findings = scan_text(FIXTURE_MIXED, "DemoService.java")
order = {HIGH: 0, MEDIUM: 1, LOW: 2}
impacts = [order[f["impact"]] for f in findings]
self.assertEqual(impacts, sorted(impacts))
def test_scan_false_positive_checked(self):
self.assertEqual(len(scan_text(FIXTURE_BATCH_SAFE, "Batch.java")), 0)
self.assertEqual(len(scan_text(FIXTURE_NO_LOOP, "NoLoop.java")), 0)
self.assertEqual(len(scan_text(FIXTURE_LOOP_NO_QUERY, "NoQuery.java")), 0)
def test_scan_repeatable(self):
first = scan_text(FIXTURE_MIXED, "DemoService.java")
second = scan_text(FIXTURE_MIXED, "DemoService.java")
self.assertEqual(
json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)
)
def test_scan_lambda_brace_less(self):
findings = scan_text(FIXTURE_LAMBDA_BRACELESS, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["loop_type"], "forEach")
self.assertEqual(findings[0]["impact"], MEDIUM)
def test_scan_iterating_query_result(self):
findings = scan_text(FIXTURE_QUERY_RESULT_LOOP, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["impact"], HIGH)
class ScanCoverageTest(unittest.TestCase):
def test_scan_module_coverage(self):
report = scan_modules(MODULES_ROOT)
self.assertGreaterEqual(report.scanned_files, 600)
self.assertGreater(len(report.findings), 0)
modules = {f["module"] for f in report.findings}
self.assertGreaterEqual(len(modules), 3)
order = {HIGH: 0, MEDIUM: 1, LOW: 2}
impacts = [order[f["impact"]] for f in report.findings]
self.assertEqual(impacts, sorted(impacts))
def test_scan_documented(self):
self.assertTrue(DOC_PATH.is_file(), f"audit doc missing: {DOC_PATH}")
text = DOC_PATH.read_text(encoding="utf-8")
self.assertIn("N+1", text)
self.assertIn("影响", text)
def test_scan_no_edit(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()