task-121: N+1 扫描审计(循环内单查扫描器 + 10 条测试 + 报告快照,不改生产代码)
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user