From 29625f703cd2145ca6c212339c0df6ba00ae2ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 02:40:16 +0800 Subject: [PATCH] =?UTF-8?q?task-121:=20N+1=20=E6=89=AB=E6=8F=8F=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=EF=BC=88=E5=BE=AA=E7=8E=AF=E5=86=85=E5=8D=95=E6=9F=A5?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=99=A8=20+=2010=20=E6=9D=A1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20+=20=E6=8A=A5=E5=91=8A=E5=BF=AB=E7=85=A7=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=94=B9=E7=94=9F=E4=BA=A7=E4=BB=A3=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-java/scripts/n1_report.json | 317 +++++++++++++++++++++++++++ backend-java/scripts/n1_scan.py | 313 ++++++++++++++++++++++++++ backend-java/scripts/test_n1_scan.py | 199 +++++++++++++++++ 3 files changed, 829 insertions(+) create mode 100644 backend-java/scripts/n1_report.json create mode 100644 backend-java/scripts/n1_scan.py create mode 100644 backend-java/scripts/test_n1_scan.py diff --git a/backend-java/scripts/n1_report.json b/backend-java/scripts/n1_report.json new file mode 100644 index 00000000..519aae9c --- /dev/null +++ b/backend-java/scripts/n1_report.json @@ -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()", + "loop_type": "for", + "impact": "LOW", + "module": "appearancepatent" + }, + { + "file": "appearancepatent\\service\\AppearancePatentTaskService.java", + "line": 2112, + "statement": "List page = taskChunkMapper.selectList(query);", + "loop_type": "while", + "impact": "LOW", + "module": "appearancepatent" + }, + { + "file": "dedupe\\service\\DedupeTotalDataService.java", + "line": 279, + "statement": "List rows = dedupeTotalDataMapper.selectList(", + "loop_type": "while", + "impact": "LOW", + "module": "dedupe" + }, + { + "file": "deletebrand\\service\\DeleteBrandRunService.java", + "line": 1234, + "statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper()", + "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 rows = fileResultMapper.selectList(historyResultQuery()", + "loop_type": "for", + "impact": "LOW", + "module": "deletebrand" + }, + { + "file": "patroldelete\\service\\PatrolDeleteTaskService.java", + "line": 638, + "statement": "List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper()", + "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()", + "loop_type": "for", + "impact": "LOW", + "module": "pricetrack" + }, + { + "file": "pricetrack\\service\\PriceTrackTaskService.java", + "line": 418, + "statement": "List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper()", + "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()", + "loop_type": "for", + "impact": "LOW", + "module": "productrisk" + }, + { + "file": "queryasin\\service\\QueryAsinTaskService.java", + "line": 587, + "statement": "List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper()", + "loop_type": "for", + "impact": "LOW", + "module": "queryasin" + }, + { + "file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java", + "line": 1085, + "statement": "List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper()", + "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()", + "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()", + "loop_type": "while", + "impact": "LOW", + "module": "similarasin" + }, + { + "file": "similarasin\\service\\SimilarAsinTaskService.java", + "line": 1646, + "statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper()", + "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 chunks = taskChunkMapper.selectList(new LambdaQueryWrapper()", + "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 page = taskChunkMapper.selectList(query);", + "loop_type": "while", + "impact": "LOW", + "module": "similarasin" + }, + { + "file": "similarasin\\service\\SimilarAsinTaskService.java", + "line": 4437, + "statement": "List page = taskScopeStateMapper.selectList(query);", + "loop_type": "while", + "impact": "LOW", + "module": "similarasin" + }, + { + "file": "task\\service\\ModuleHistoryCleanupService.java", + "line": 131, + "statement": "List page = fileTaskMapper.selectList(new LambdaQueryWrapper()", + "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" + } + ] +} \ No newline at end of file diff --git a/backend-java/scripts/n1_scan.py b/backend-java/scripts/n1_scan.py new file mode 100644 index 00000000..8ca0ec7e --- /dev/null +++ b/backend-java/scripts/n1_scan.py @@ -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: + """影响分级:遍历查询结果集 → HIGH;forEach/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() diff --git a/backend-java/scripts/test_n1_scan.py b/backend-java/scripts/test_n1_scan.py new file mode 100644 index 00000000..557f30ef --- /dev/null +++ b/backend-java/scripts/test_n1_scan.py @@ -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 ids) { + for (Long id : ids) { + FileTaskEntity task = fileTaskMapper.selectById(id); + } + } +} +""" + +FIXTURE_QUERY_RESULT_LOOP = """package com.example; +public class DemoService { + public List listVos() { + List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<>()); + List 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 ids) { + ids.forEach(id -> fileTaskMapper.selectOne( + new LambdaQueryWrapper().eq(FileTaskEntity::getId, id))); + } +} +""" + +FIXTURE_BATCH_SAFE = """package com.example; +public class DemoService { + public List load(List ids) { + List 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 nums) { + int s = 0; + for (Integer n : nums) { + s += n; + } + return s; + } +} +""" + +FIXTURE_INDEX_LOOP = """package com.example; +public class DemoService { + public List listVos(List ids) { + List 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()