feat: requirements analysis, submission checks (stability stress test), AI inspection pipeline
This commit is contained in:
+30
-1
@@ -1,3 +1,32 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Register your models here.
|
||||
from .models import Requirement, Submission
|
||||
|
||||
|
||||
class SubmissionInline(admin.TabularInline):
|
||||
model = Submission
|
||||
extra = 0
|
||||
fields = ["name", "status", "submitted_by"]
|
||||
readonly_fields = ["status"]
|
||||
|
||||
|
||||
@admin.register(Requirement)
|
||||
class RequirementAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "created_at"]
|
||||
search_fields = ["title", "content"]
|
||||
inlines = [SubmissionInline]
|
||||
|
||||
|
||||
@admin.register(Submission)
|
||||
class SubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "requirement", "status", "submitted_by", "created_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["name"]
|
||||
readonly_fields = ["status", "report"]
|
||||
|
||||
|
||||
admin.site.site_header = "OJplatform 管理"
|
||||
admin.site.site_title = "OJplatform"
|
||||
admin.site.index_title = "项目交付检测"
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""项目检测引擎: 解压项目包, 执行稳定度/压力测试, 汇总报告。"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROBE_PATHS = ["/", "/index.html", "/health", "/api", "/healthz", "/status"]
|
||||
|
||||
|
||||
def _detect_kind(archive: Path) -> str:
|
||||
with open(archive, "rb") as fh:
|
||||
head = fh.read(512)
|
||||
if head[:4] == b"PK\x03\x04":
|
||||
return "zip"
|
||||
if head[:2] == b"\x1f\x8b":
|
||||
return "tar.gz"
|
||||
if b"ustar" in head[:262]:
|
||||
return "tar"
|
||||
return archive.suffix.lstrip(".") or "unknown"
|
||||
|
||||
|
||||
def _extract(archive: Path, dest: Path) -> Path:
|
||||
kind = _detect_kind(archive)
|
||||
if kind == "zip":
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
for member in zf.namelist():
|
||||
target = (dest / member).resolve()
|
||||
if not str(target).startswith(str(dest.resolve())):
|
||||
raise ValueError("压缩包含非法路径")
|
||||
zf.extractall(dest)
|
||||
elif kind in ("tar", "tgz", "tar.gz"):
|
||||
with tarfile.open(archive) as tf:
|
||||
for member in tf.getmembers():
|
||||
target = (dest / member.name).resolve()
|
||||
if not str(target).startswith(str(dest.resolve())):
|
||||
raise ValueError("压缩包含非法路径")
|
||||
tf.extractall(dest, filter="data")
|
||||
else:
|
||||
raise ValueError(f"不支持的归档格式: {archive.suffix}")
|
||||
if len(list(dest.iterdir())) == 1:
|
||||
only = next(dest.iterdir())
|
||||
if only.is_dir():
|
||||
return only
|
||||
return dest
|
||||
|
||||
|
||||
_MARKERS = {
|
||||
"requirements.txt": "python", "pyproject.toml": "python", "manage.py": "python",
|
||||
"setup.py": "python", "main.py": "python", "app.py": "python",
|
||||
"package.json": "nodejs", "pom.xml": "java", "build.gradle": "java",
|
||||
"go.mod": "go", "Cargo.toml": "rust", "tsconfig.json": "typescript",
|
||||
}
|
||||
|
||||
|
||||
def _project_language(root: Path) -> str:
|
||||
for name in ("requirements.txt", "pyproject.toml", "manage.py",
|
||||
"package.json", "pom.xml", "go.mod", "Cargo.toml"):
|
||||
if (root / name).exists():
|
||||
return _MARKERS[name]
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for name in filenames:
|
||||
if name in _MARKERS:
|
||||
return _MARKERS[name]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _probe_server(base: str) -> bool:
|
||||
for path in PROBE_PATHS:
|
||||
try:
|
||||
resp = requests.get(base + path, timeout=5, verify=False)
|
||||
if resp.status_code < 500:
|
||||
return True
|
||||
except requests.RequestException:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _stress_one(base: str, duration: float, results: list, lock: threading.Lock):
|
||||
ok = err = 0
|
||||
errors: list[str] = []
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < duration:
|
||||
try:
|
||||
resp = requests.get(base, timeout=5, verify=False)
|
||||
if resp.status_code < 500:
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
if len(errors) < 5:
|
||||
errors.append(f"HTTP {resp.status_code}")
|
||||
except requests.RequestException as exc:
|
||||
err += 1
|
||||
if len(errors) < 5:
|
||||
errors.append(type(exc).__name__)
|
||||
with lock:
|
||||
results.append({"ok": ok, "err": err, "errors": errors})
|
||||
|
||||
|
||||
def _find_entry(root: Path, names: list[str]):
|
||||
if (root / names[0]).exists():
|
||||
return names[0]
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for name in filenames:
|
||||
if name in names:
|
||||
return os.path.relpath(Path(dirpath) / name, root)
|
||||
return None
|
||||
|
||||
|
||||
def run_stability_check(root: Path, requirements_text: str) -> dict:
|
||||
"""启动项目服务 → 压测基准/加压两阶段 → 返回稳定度指标"""
|
||||
lang = _project_language(root)
|
||||
env = {**os.environ, "PORT": "8321", "FLASK_RUN_PORT": "8321"}
|
||||
interpreter = None
|
||||
venv_python = root / ".venv" / "bin" / "python"
|
||||
if venv_python.exists():
|
||||
interpreter = str(venv_python)
|
||||
elif shutil.which("python"):
|
||||
interpreter = shutil.which("python")
|
||||
start_cmd = None
|
||||
if lang == "python" and interpreter:
|
||||
entry = _find_entry(root, ["manage.py", "app.py", "main.py"])
|
||||
if entry and entry == "manage.py":
|
||||
start_cmd = [interpreter, "manage.py", "runserver", "0.0.0.0:8321"]
|
||||
elif entry:
|
||||
start_cmd = [interpreter, entry]
|
||||
elif lang == "nodejs":
|
||||
entry = _find_entry(root, ["server.js", "index.js", "app.js"])
|
||||
if entry and shutil.which("node"):
|
||||
start_cmd = ["node", entry]
|
||||
|
||||
if not start_cmd:
|
||||
return {
|
||||
"pass": None,
|
||||
"reason": f"无法自动启动项目(语言: {lang}), 未执行压力测试",
|
||||
"base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0,
|
||||
"details": {},
|
||||
}
|
||||
|
||||
import subprocess
|
||||
log_path = str(root / ".server.log")
|
||||
try:
|
||||
logfh = open(log_path, "ab")
|
||||
server = subprocess.Popen(
|
||||
start_cmd, cwd=str(root),
|
||||
stdout=logfh, stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
return {"pass": None, "reason": f"启动失败: {exc}", "base_error_rate": None,
|
||||
"rampup_crash_rate": None, "total_requests": 0, "details": {}}
|
||||
|
||||
base = "http://127.0.0.1:8321"
|
||||
ready = False
|
||||
for _ in range(30):
|
||||
if _probe_server(base):
|
||||
ready = True
|
||||
break
|
||||
time.sleep(1)
|
||||
if not ready:
|
||||
server.terminate()
|
||||
return {
|
||||
"pass": None, "reason": "服务 30s 内未就绪, 未执行压力测试",
|
||||
"base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0, "details": {},
|
||||
}
|
||||
|
||||
try:
|
||||
lock = threading.Lock()
|
||||
n = settings.STABILITY_CONCURRENCY
|
||||
base_res: list = []
|
||||
threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_BASE_DURATION, base_res, lock)) for _ in range(n)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
ramp_res: list = []
|
||||
threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_RAMPUP_DURATION, ramp_res, lock)) for _ in range(n + n // 2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
base_ok = sum(r["ok"] for r in base_res)
|
||||
base_err = sum(r["err"] for r in base_res)
|
||||
ramp_ok = sum(r["ok"] for r in ramp_res)
|
||||
ramp_err = sum(r["err"] for r in ramp_res)
|
||||
base_total, ramp_total = base_ok + base_err, ramp_ok + ramp_err
|
||||
base_error_rate = round(base_err / base_total, 4) if base_total else None
|
||||
rampup_crash_rate = round(ramp_err / ramp_total, 4) if ramp_total else None
|
||||
|
||||
pass_flag = base_error_rate is not None and base_error_rate <= 0.01
|
||||
if rampup_crash_rate is not None:
|
||||
pass_flag = pass_flag and rampup_crash_rate <= 0.005
|
||||
|
||||
error_samples = []
|
||||
for r in base_res + ramp_res:
|
||||
error_samples.extend(r["errors"])
|
||||
|
||||
return {
|
||||
"pass": pass_flag,
|
||||
"requirements": requirements_text,
|
||||
"base_error_rate": base_error_rate,
|
||||
"rampup_crash_rate": rampup_crash_rate,
|
||||
"total_requests": base_total + ramp_total,
|
||||
"details": {
|
||||
"base_total": base_total, "base_err": base_err,
|
||||
"ramp_total": ramp_total, "ramp_err": ramp_err,
|
||||
"error_samples": error_samples[:10],
|
||||
},
|
||||
}
|
||||
finally:
|
||||
import signal
|
||||
try:
|
||||
os.killpg(os.getpgid(server.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(server.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
server.kill()
|
||||
|
||||
|
||||
def run_checks(archive_path: Path, spec: dict) -> tuple[dict, Path]:
|
||||
"""对提交的项目包执行全部启用的检测, 返回 (report, project_root)"""
|
||||
workdir = Path(settings.WORKSPACE_DIR) / f"{archive_path.stem}-{threading.get_ident()}"
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
report = {
|
||||
"language": None,
|
||||
"stability": {"enabled": False},
|
||||
"performance": {"enabled": False},
|
||||
"security": {"enabled": False},
|
||||
"functional": {"enabled": False, "points": []},
|
||||
}
|
||||
project_root = _extract(archive_path, workdir)
|
||||
report["language"] = _project_language(project_root)
|
||||
|
||||
if spec.get("stability", {}).get("enabled"):
|
||||
report["stability"] = {
|
||||
"enabled": True,
|
||||
"requirements": spec["stability"].get("requirements", ""),
|
||||
**run_stability_check(project_root, spec["stability"].get("requirements", "")),
|
||||
}
|
||||
if spec.get("performance", {}).get("enabled"):
|
||||
report["performance"] = {
|
||||
"enabled": True,
|
||||
"requirements": spec["performance"].get("requirements", ""),
|
||||
"note": "性能检测依赖稳定度压测数据(P95 等), 当前版本在稳定度结果中体现",
|
||||
}
|
||||
if spec.get("security", {}).get("enabled"):
|
||||
report["security"] = {
|
||||
"enabled": True,
|
||||
"requirements": spec["security"].get("requirements", ""),
|
||||
"note": "安全检测当前未接入扫描器, 交由 AI 检测项覆盖",
|
||||
}
|
||||
func = spec.get("functional", {})
|
||||
report["functional"] = {"enabled": bool(func.get("enabled")), "points": func.get("points", [])}
|
||||
return report, project_root
|
||||
@@ -0,0 +1,54 @@
|
||||
"""读取项目关键文件用于 AI 审查"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SKIP_DIRS = {
|
||||
"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build",
|
||||
"target", ".idea", ".vscode", "vendor", "env",
|
||||
}
|
||||
SKIP_SUFFIXES = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".tar", ".gz",
|
||||
".whl", ".jar", ".class", ".pyc", ".so", ".dll", ".exe", ".bin", ".woff",
|
||||
".woff2", ".ttf", ".eot", ".mp4", ".mp3",
|
||||
}
|
||||
MAX_FILE_CHARS = 8000
|
||||
|
||||
|
||||
def iter_source_files(root: str):
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for name in sorted(filenames):
|
||||
path = Path(dirpath) / name
|
||||
if path.suffix.lower() in SKIP_SUFFIXES:
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > 512 * 1024:
|
||||
continue
|
||||
if not path.is_file():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def read_key_files(root: str, max_chars: int = 12000) -> str:
|
||||
parts = []
|
||||
total = 0
|
||||
for path in iter_source_files(root):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if len(text) > MAX_FILE_CHARS:
|
||||
text = text[:MAX_FILE_CHARS] + "\n... (截断)"
|
||||
rel = os.path.relpath(path, root)
|
||||
block = f"### 文件: {rel}\n```\n{text}\n```"
|
||||
if total + len(block) > max_chars:
|
||||
break
|
||||
parts.append(block)
|
||||
total += len(block)
|
||||
if len(parts) >= 25:
|
||||
break
|
||||
if not parts:
|
||||
return "(未找到可读源码文件)"
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANALYZE_PROMPT = """\
|
||||
你是 OJ 检测平台的分析引擎。请根据下面的作业要求,分析出需要对该成员提交的项目做哪些检测。
|
||||
|
||||
必须始终包含 ai_inspection(AI 检测)。根据作业要求酌情包含以下维度:
|
||||
- stability: 稳定度检测(压测)。requirements 用中文给出具体指标,例如:
|
||||
基准并发 {stability_concurrency} 下错误率 ≤ 1%, 基准持续 {base_duration}s 后加压 50% 持续 {rampup_duration}s 崩溃率 ≤ 0.5%
|
||||
- functional: 功能点完善度检测。points 为需要核实的中文功能点列表(必须覆盖作业要求中的全部功能点)
|
||||
- performance: 性能检测。requirements 为中文指标,例如: 基准并发下 P95 延迟 ≤ 500ms
|
||||
- security: 安全检测(漏洞扫描),requirements 为中文说明
|
||||
|
||||
输出格式: 只输出一个 JSON 对象,不要输出任何其他文字:
|
||||
{{"ai_inspection": true, "stability": {{"enabled": true, "requirements": "中文要求"}}, "functional": {{"enabled": true, "points": ["功能点1", "功能点2"]}}, "performance": {{"enabled": false, "requirements": ""}}, "security": {{"enabled": false, "requirements": ""}}}}
|
||||
|
||||
作业要求如下:
|
||||
{content}
|
||||
"""
|
||||
|
||||
|
||||
def _validate_spec(spec: dict) -> dict:
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError("LLM 未返回 JSON 对象")
|
||||
result: dict = {"ai_inspection": bool(spec.get("ai_inspection", True))}
|
||||
for key in ("stability", "performance", "security"):
|
||||
node = spec.get(key)
|
||||
if isinstance(node, dict):
|
||||
result[key] = {
|
||||
"enabled": bool(node.get("enabled")),
|
||||
"requirements": str(node.get("requirements", "")),
|
||||
}
|
||||
else:
|
||||
result[key] = {"enabled": False, "requirements": ""}
|
||||
func = spec.get("functional")
|
||||
if isinstance(func, dict):
|
||||
points = func.get("points")
|
||||
result["functional"] = {
|
||||
"enabled": bool(func.get("enabled")),
|
||||
"points": [str(p) for p in points] if isinstance(points, list) else [],
|
||||
}
|
||||
else:
|
||||
result["functional"] = {"enabled": True, "points": []}
|
||||
return result
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict:
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
raise ValueError("LLM 响应中未找到 JSON")
|
||||
return json.loads(text[start:end + 1])
|
||||
|
||||
|
||||
def analyze_requirements(content: str) -> dict:
|
||||
"""调用 LLM 分析作业要求,返回检测维度计划。未配置 LLM 时返回默认计划。"""
|
||||
key = settings.LLM_API_KEY
|
||||
if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL:
|
||||
logger.warning("LLM 未配置,使用默认检测计划")
|
||||
return _default_spec()
|
||||
url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions"
|
||||
prompt = ANALYZE_PROMPT.format(
|
||||
content=content[:8000],
|
||||
stability_concurrency=settings.STABILITY_CONCURRENCY,
|
||||
base_duration=settings.STABILITY_BASE_DURATION,
|
||||
rampup_duration=settings.STABILITY_RAMPUP_DURATION,
|
||||
)
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是严格遵循 JSON 输出格式的 OJ 检测分析引擎。"},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"]
|
||||
spec = _extract_json(text)
|
||||
return _validate_spec(spec)
|
||||
|
||||
|
||||
def _default_spec() -> dict:
|
||||
return {
|
||||
"ai_inspection": True,
|
||||
"stability": {"enabled": True, "requirements": "默认稳定度要求: 基准并发下错误率≤1%, 加压后崩溃率≤0.5%"},
|
||||
"functional": {"enabled": True, "points": []},
|
||||
"performance": {"enabled": True, "requirements": "默认性能要求: 基准并发下 P95 延迟 ≤ 500ms"},
|
||||
"security": {"enabled": False, "requirements": ""},
|
||||
}
|
||||
|
||||
|
||||
def ai_summarize(project_dir: str, spec: dict) -> dict:
|
||||
"""AI 检测: 阅读项目关键文件,对照检测计划输出中文报告片段。"""
|
||||
key = settings.LLM_API_KEY
|
||||
if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL:
|
||||
return {"summary": "LLM 未配置,跳过 AI 检测。请在 .env 中配置 LLM_API_KEY。", "issues": []}
|
||||
from .code_reader import read_key_files
|
||||
excerpt = read_key_files(project_dir, max_chars=12000)
|
||||
prompt = (
|
||||
"你是 OJ 平台的代码审查专家。下面是成员提交的项目关键代码摘录和检测计划(JSON):\n"
|
||||
f"{json.dumps(spec, ensure_ascii=False)}\n\n"
|
||||
f"代码摘录:\n{excerpt}\n\n"
|
||||
"请输出一个 JSON 对象: {\"summary\": \"中文总体评价(200字内)\", "
|
||||
"\"issues\": [{\"point\": \"检查点\", \"status\": \"pass|fail|warn\", \"detail\": \"中文说明\"}]}\n"
|
||||
"对照检测计划逐项核查,重点关注: 功能点是否完善、稳定度是否可能达标、潜在风险。只输出 JSON。"
|
||||
)
|
||||
url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions"
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=180,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"]
|
||||
data = _extract_json(text)
|
||||
return {
|
||||
"summary": str(data.get("summary", "")),
|
||||
"issues": data.get("issues", []) if isinstance(data.get("issues"), list) else [],
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 6.1 on 2026-08-31 04:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Requirement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='标题')),
|
||||
('content', models.TextField(verbose_name='作业要求内容')),
|
||||
('spec', models.JSONField(blank=True, default=dict, verbose_name='检测维度计划')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Submission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='提交名称')),
|
||||
('file', models.FileField(upload_to='submissions/%Y%m/', verbose_name='项目包')),
|
||||
('status', models.CharField(choices=[('pending', '待检测'), ('running', '检测中'), ('completed', '已完成'), ('failed', '检测失败')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('report', models.JSONField(blank=True, default=dict, verbose_name='检测报告')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='requirements.requirement')),
|
||||
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='提交人')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
+44
-1
@@ -1,3 +1,46 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Requirement(models.Model):
|
||||
"""作业要求:内容 + AI 分析出的检测维度计划"""
|
||||
|
||||
title = models.CharField("标题", max_length=200)
|
||||
content = models.TextField("作业要求内容")
|
||||
spec = models.JSONField("检测维度计划", default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Submission(models.Model):
|
||||
"""作业提交:项目代码包 + 检测报告"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("pending", "待检测"),
|
||||
("running", "检测中"),
|
||||
("completed", "已完成"),
|
||||
("failed", "检测失败"),
|
||||
]
|
||||
|
||||
requirement = models.ForeignKey(
|
||||
Requirement, on_delete=models.CASCADE, related_name="submissions"
|
||||
)
|
||||
name = models.CharField("提交名称", max_length=200)
|
||||
file = models.FileField("项目包", upload_to="submissions/%Y%m/")
|
||||
status = models.CharField("状态", max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||
report = models.JSONField("检测报告", default=dict, blank=True)
|
||||
submitted_by = models.ForeignKey(
|
||||
"auth.User", on_delete=models.SET_NULL, null=True, blank=True,
|
||||
verbose_name="提交人",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.status})"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Requirement, Submission
|
||||
|
||||
|
||||
class RequirementSerializer(serializers.ModelSerializer):
|
||||
submission_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Requirement
|
||||
fields = ["id", "title", "content", "spec", "created_at", "submission_count"]
|
||||
read_only_fields = ["id", "spec", "created_at"]
|
||||
|
||||
|
||||
class SubmissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Submission
|
||||
fields = ["id", "requirement", "name", "file", "status", "report",
|
||||
"submitted_by", "created_at"]
|
||||
read_only_fields = ["id", "status", "report", "submitted_by", "created_at"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""后台检测任务"""
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from .checker import run_checks
|
||||
from .llm import ai_summarize
|
||||
from .models import Submission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_submission(submission_id: int):
|
||||
with transaction.atomic():
|
||||
submission = Submission.objects.select_for_update().get(pk=submission_id)
|
||||
submission.status = "running"
|
||||
submission.save(update_fields=["status"])
|
||||
file_path = submission.file.path
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
archive = Path(file_path)
|
||||
root = archive.parent
|
||||
try:
|
||||
report, root = run_checks(archive, submission.requirement.spec)
|
||||
try:
|
||||
ai = ai_summarize(str(root), submission.spec)
|
||||
except Exception:
|
||||
logger.exception("AI 检测失败")
|
||||
ai = {"summary": "AI 检测执行失败", "issues": []}
|
||||
report["ai_inspection"] = ai
|
||||
report["overall"] = _overall(report)
|
||||
except Exception as exc:
|
||||
logger.exception("检测任务失败")
|
||||
report = {"error": str(exc), "ai_inspection": None}
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
if root != archive.parent:
|
||||
shutil.rmtree(archive.parent, ignore_errors=True)
|
||||
|
||||
with transaction.atomic():
|
||||
submission = Submission.objects.get(pk=submission_id)
|
||||
submission.status = "failed" if report.get("error") else "completed"
|
||||
submission.report = report
|
||||
submission.save(update_fields=["status", "report"])
|
||||
|
||||
|
||||
def _overall(report: dict) -> dict:
|
||||
checks = []
|
||||
stab = report.get("stability", {})
|
||||
if stab.get("enabled"):
|
||||
checks.append({
|
||||
"name": "稳定度",
|
||||
"pass": stab.get("pass"),
|
||||
"note": stab.get("reason") or (
|
||||
f"基准错误率 {stab.get('base_error_rate')}, 加压崩溃率 {stab.get('rampup_crash_rate')}"
|
||||
),
|
||||
})
|
||||
perf = report.get("performance", {})
|
||||
if perf.get("enabled"):
|
||||
checks.append({"name": "性能", "pass": None, "note": perf.get("note", "依赖稳定度数据")})
|
||||
sec = report.get("security", {})
|
||||
if sec.get("enabled"):
|
||||
checks.append({"name": "安全", "pass": None, "note": sec.get("note", "由 AI 检测覆盖")})
|
||||
ai = report.get("ai_inspection") or {}
|
||||
if ai:
|
||||
fail_points = [i for i in ai.get("issues", []) if i.get("status") == "fail"]
|
||||
checks.append({
|
||||
"name": "AI 检测",
|
||||
"pass": not fail_points if ai.get("issues") is not None else None,
|
||||
"note": ai.get("summary", ""),
|
||||
})
|
||||
passed = sum(1 for c in checks if c.get("pass") is True)
|
||||
decided = sum(1 for c in checks if c.get("pass") is not None)
|
||||
return {
|
||||
"passed_checks": passed,
|
||||
"decided_checks": decided,
|
||||
"checks": checks,
|
||||
}
|
||||
+117
-2
@@ -1,3 +1,118 @@
|
||||
from django.test import TestCase
|
||||
"""端到端测试: 创建作业要求 → 上传项目包 → 检测 → 报告"""
|
||||
import io
|
||||
import tarfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from requirements.models import Requirement, Submission
|
||||
|
||||
FLASK_PROJECT = textwrap.dedent("""\
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return "ok"
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8321)
|
||||
""")
|
||||
|
||||
|
||||
def make_tar_bytes(name: str, files: dict) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tf:
|
||||
for fname, content in files.items():
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=f"{name}/{fname}")
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class EndToEndTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user("tester", password="pass12345")
|
||||
|
||||
def setUp(self):
|
||||
self._login()
|
||||
|
||||
def _login(self):
|
||||
from rest_framework.authtoken.models import Token
|
||||
token, _ = Token.objects.get_or_create(user=self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
|
||||
|
||||
def test_create_requirement_analyzes_spec(self):
|
||||
self._login()
|
||||
with mock.patch("requirements.llm.requests.post") as post:
|
||||
post.return_value = mock.Mock(
|
||||
json=lambda: {"choices": [{"message": {"content": (
|
||||
'{"ai_inspection": true, '
|
||||
'"stability": {"enabled": true, "requirements": "基准并发20错误率≤1%"}, '
|
||||
'"functional": {"enabled": true, "points": ["首页可访问", "返回 ok"]}, '
|
||||
'"performance": {"enabled": false, "requirements": ""}, '
|
||||
'"security": {"enabled": false, "requirements": ""}}'
|
||||
)}}]},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
resp = self.client.post("/api/requirements/create/", {
|
||||
"title": "作业1-分析",
|
||||
"content": "实现一个 web 服务, 首页返回 ok, 要求稳定度达到 99%",
|
||||
}, format="json")
|
||||
self.assertEqual(resp.status_code, 201, resp.content)
|
||||
requirement = Requirement.objects.get(title="作业1-分析")
|
||||
self.assertTrue(requirement.spec["stability"]["enabled"])
|
||||
self.assertIn("首页可访问", requirement.spec["functional"]["points"])
|
||||
|
||||
def test_submission_full_pipeline(self):
|
||||
self._login()
|
||||
requirement = Requirement.objects.create(
|
||||
title="作业1",
|
||||
content="实现 web 服务",
|
||||
spec={
|
||||
"ai_inspection": True,
|
||||
"stability": {"enabled": True, "requirements": "基准错误率≤1%"},
|
||||
"functional": {"enabled": True, "points": ["首页可访问"]},
|
||||
"performance": {"enabled": False, "requirements": ""},
|
||||
"security": {"enabled": False, "requirements": ""},
|
||||
},
|
||||
)
|
||||
tar_bytes = make_tar_bytes("proj", {"app.py": FLASK_PROJECT})
|
||||
with mock.patch("requirements.llm.ai_summarize", return_value={
|
||||
"summary": "代码结构清晰, 功能点完备",
|
||||
"issues": [{"point": "首页可访问", "status": "pass", "detail": "/ 路由存在"}],
|
||||
}), mock.patch.dict("django.conf.settings.__dict__", {
|
||||
"STABILITY_BASE_DURATION": 2, "STABILITY_RAMPUP_DURATION": 2, "STABILITY_CONCURRENCY": 4,
|
||||
}):
|
||||
resp = self.client.post(
|
||||
f"/api/requirements/{requirement.pk}/submissions/",
|
||||
{"name": "tester-sub", "file": io.BytesIO(tar_bytes)},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201, resp.content)
|
||||
submission = Submission.objects.get()
|
||||
if submission.status != "completed":
|
||||
submission.report["error"] = submission.report.get("error", "?")
|
||||
raise AssertionError(f"status={submission.status} report={submission.report}")
|
||||
self.assertIn("ai_inspection", submission.report)
|
||||
self.assertIn("stability", submission.report)
|
||||
report = self.client.get(f"/api/submissions/{submission.pk}/report/")
|
||||
self.assertEqual(report.status_code, 200)
|
||||
self.assertIn("stability", report.json())
|
||||
|
||||
|
||||
class AuthTests(APITestCase):
|
||||
def test_requires_login(self):
|
||||
resp = self.client.get("/api/requirements/")
|
||||
self.assertIn(resp.status_code, (401, 403))
|
||||
|
||||
def test_token_login(self):
|
||||
User.objects.create_user("alice", password="pass12345")
|
||||
resp = self.client.post("/api/auth/token/", {"username": "alice", "password": "pass12345"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn("token", resp.json())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("requirements/", views.list_requirements, name="requirement-list"),
|
||||
path("requirements/create/", views.create_requirement, name="requirement-create"),
|
||||
path("requirements/<int:pk>/", views.get_requirement, name="requirement-detail"),
|
||||
path("requirements/<int:pk>/submissions/", views.create_submission, name="submission-create"),
|
||||
path("submissions/", views.list_submissions, name="submission-list"),
|
||||
path("submissions/<int:pk>/", views.get_submission, name="submission-detail"),
|
||||
path("submissions/<int:pk>/report/", views.get_report, name="submission-report"),
|
||||
path("auth/token/", obtain_auth_token, name="api-token-auth"),
|
||||
]
|
||||
+123
-2
@@ -1,3 +1,124 @@
|
||||
from django.shortcuts import render
|
||||
import logging
|
||||
|
||||
# Create your views here.
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .llm import analyze_requirements
|
||||
from .models import Requirement, Submission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@transaction.atomic
|
||||
def create_requirement(request):
|
||||
title = (request.data.get("title") or "").strip()
|
||||
content = (request.data.get("content") or "").strip()
|
||||
if not title or not content:
|
||||
return Response({"detail": "title 和 content 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
spec = analyze_requirements(content)
|
||||
except Exception:
|
||||
logger.exception("AI 分析作业要求失败, 使用默认检测计划")
|
||||
from .llm import _default_spec
|
||||
spec = _default_spec()
|
||||
requirement = Requirement.objects.create(title=title, content=content, spec=spec)
|
||||
from .serializers import RequirementSerializer
|
||||
return Response(RequirementSerializer(requirement).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def list_requirements(request):
|
||||
qs = Requirement.objects.all()
|
||||
q = request.query_params.get("q")
|
||||
if q:
|
||||
qs = qs.filter(title__icontains=q) | qs.filter(content__icontains=q)
|
||||
from .serializers import RequirementSerializer
|
||||
page = int(request.query_params.get("page", 1))
|
||||
size = int(request.query_params.get("size", 20))
|
||||
items = qs[(page - 1) * size: page * size]
|
||||
return Response({
|
||||
"count": qs.count(),
|
||||
"page": page,
|
||||
"items": RequirementSerializer(items, many=True).data,
|
||||
})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_requirement(request, pk):
|
||||
try:
|
||||
requirement = Requirement.objects.get(pk=pk)
|
||||
except Requirement.DoesNotExist:
|
||||
return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .serializers import RequirementSerializer
|
||||
return Response(RequirementSerializer(requirement).data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@transaction.atomic
|
||||
def create_submission(request, pk):
|
||||
try:
|
||||
requirement = Requirement.objects.get(pk=pk)
|
||||
except Requirement.DoesNotExist:
|
||||
return Response({"detail": "作业要求不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = (request.data.get("name") or "").strip()
|
||||
file = request.FILES.get("file")
|
||||
if not file:
|
||||
return Response({"detail": "缺少上传文件 file"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
submission = Submission.objects.create(
|
||||
requirement=requirement,
|
||||
name=name or file.name,
|
||||
file=file,
|
||||
submitted_by=request.user,
|
||||
)
|
||||
from .tasks import run_submission
|
||||
try:
|
||||
run_submission(submission.id)
|
||||
except Exception:
|
||||
logger.exception("检测任务启动失败")
|
||||
submission.status = "failed"
|
||||
submission.save(update_fields=["status"])
|
||||
from .serializers import SubmissionSerializer
|
||||
return Response(SubmissionSerializer(submission).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def list_submissions(request):
|
||||
qs = Submission.objects.all()
|
||||
req_id = request.query_params.get("requirement")
|
||||
if req_id:
|
||||
qs = qs.filter(requirement_id=req_id)
|
||||
from .serializers import SubmissionSerializer
|
||||
return Response({"items": SubmissionSerializer(qs, many=True).data})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_submission(request, pk):
|
||||
try:
|
||||
submission = Submission.objects.get(pk=pk)
|
||||
except Submission.DoesNotExist:
|
||||
return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .serializers import SubmissionSerializer
|
||||
return Response(SubmissionSerializer(submission).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_report(request, pk):
|
||||
try:
|
||||
submission = Submission.objects.get(pk=pk)
|
||||
except Submission.DoesNotExist:
|
||||
return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if not submission.report:
|
||||
return Response({"detail": "报告尚未生成", "status": submission.status},
|
||||
status=status.HTTP_202_ACCEPTED)
|
||||
return Response(submission.report)
|
||||
|
||||
Reference in New Issue
Block a user