feat: requirements analysis, submission checks (stability stress test), AI inspection pipeline

This commit is contained in:
hyh
2026-08-31 13:47:29 +08:00
parent a9bbe2f9b4
commit eaffeb58f5
17 changed files with 1218 additions and 8 deletions
+30 -1
View File
@@ -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 = "项目交付检测"
+272
View File
@@ -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
+54
View File
@@ -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)
+133
View File
@@ -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 [],
}
+46
View File
@@ -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
View File
@@ -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})"
+20
View File
@@ -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"]
+81
View File
@@ -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
View File
@@ -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())
+15
View File
@@ -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
View File
@@ -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)