Initial commit
This commit is contained in:
commit
d0a4992cd6
115
.autograde/aggregate_final_grade.py
Normal file
115
.autograde/aggregate_final_grade.py
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
期末大作业成绩汇总脚本
|
||||
|
||||
汇总编程测试分数 + REPORT.md 分数 + FRONTEND.md 分数
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_json(filepath, default=None):
|
||||
"""安全加载 JSON 文件"""
|
||||
if not os.path.exists(filepath):
|
||||
return default or {}
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading {filepath}: {e}", file=sys.stderr)
|
||||
return default or {}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Aggregate final project grades")
|
||||
parser.add_argument("--programming", required=True, help="Programming test grade JSON")
|
||||
parser.add_argument("--report", required=True, help="REPORT.md LLM grade JSON")
|
||||
parser.add_argument("--frontend", required=True, help="FRONTEND.md LLM grade JSON")
|
||||
parser.add_argument("--out", default="final_grade.json", help="Output JSON file")
|
||||
parser.add_argument("--summary", default="final_summary.md", help="Output summary markdown")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载各部分成绩
|
||||
prog_grade = load_json(args.programming, {"total_score": 0, "max_score": 80})
|
||||
report_grade = load_json(args.report, {"total": 0})
|
||||
frontend_grade = load_json(args.frontend, {"total": 0})
|
||||
|
||||
# 提取分数
|
||||
prog_score = prog_grade.get("total_score", 0)
|
||||
prog_max = prog_grade.get("max_score", 80)
|
||||
|
||||
report_score = report_grade.get("total", 0)
|
||||
report_max = 10 # REPORT.md 满分 10 分
|
||||
|
||||
frontend_score = frontend_grade.get("total", 0)
|
||||
frontend_max = 10 # FRONTEND.md 满分 10 分
|
||||
|
||||
# 计算总分
|
||||
total_score = prog_score + report_score + frontend_score
|
||||
total_max = prog_max + report_max + frontend_max
|
||||
|
||||
# 构建最终成绩数据
|
||||
final_grade = {
|
||||
"total_score": round(total_score, 2),
|
||||
"max_score": total_max,
|
||||
"breakdown": {
|
||||
"programming": {
|
||||
"score": round(prog_score, 2),
|
||||
"max_score": prog_max,
|
||||
"groups": prog_grade.get("groups", {})
|
||||
},
|
||||
"report": {
|
||||
"score": round(report_score, 2),
|
||||
"max_score": report_max,
|
||||
"flags": report_grade.get("flags", [])
|
||||
},
|
||||
"frontend": {
|
||||
"score": round(frontend_score, 2),
|
||||
"max_score": frontend_max,
|
||||
"flags": frontend_grade.get("flags", [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 保存 final_grade.json
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(final_grade, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成 summary.md
|
||||
with open(args.summary, "w", encoding="utf-8") as f:
|
||||
f.write("# 期末大作业成绩报告\n\n")
|
||||
f.write(f"## 总分:{total_score:.2f} / {total_max}\n\n")
|
||||
|
||||
f.write("## 分项成绩\n\n")
|
||||
f.write("| 项目 | 得分 | 满分 | 备注 |\n")
|
||||
f.write("|------|------|------|------|\n")
|
||||
f.write(f"| 编程测试 | {prog_score:.2f} | {prog_max} | Core + Advanced + Challenge |\n")
|
||||
f.write(f"| REPORT.md | {report_score:.2f} | {report_max} | 后端与系统设计报告 |\n")
|
||||
f.write(f"| FRONTEND.md | {frontend_score:.2f} | {frontend_max} | 前端界面与交互设计报告 |\n")
|
||||
|
||||
# 编程测试详情
|
||||
if prog_grade.get("groups"):
|
||||
f.write("\n### 编程测试详情\n\n")
|
||||
f.write("| 分组 | 通过 | 总数 | 得分 | 满分 |\n")
|
||||
f.write("|------|------|------|------|------|\n")
|
||||
for group_name, group_info in prog_grade["groups"].items():
|
||||
f.write(f"| {group_name} | {group_info.get('passed', 0)} | "
|
||||
f"{group_info.get('total', 0)} | {group_info.get('score', 0):.2f} | "
|
||||
f"{group_info.get('max_score', 0)} |\n")
|
||||
|
||||
# 标记
|
||||
all_flags = report_grade.get("flags", []) + frontend_grade.get("flags", [])
|
||||
if all_flags:
|
||||
f.write("\n### 标记\n\n")
|
||||
for flag in set(all_flags):
|
||||
f.write(f"- {flag}\n")
|
||||
|
||||
print(f"Final grade: {total_score:.2f}/{total_max}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
121
.autograde/aggregate_llm_grades.py
Normal file
121
.autograde/aggregate_llm_grades.py
Normal file
@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
聚合多个 LLM 评分结果
|
||||
"""
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_grade(filepath):
|
||||
"""加载单个评分文件"""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: {filepath} not found")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing {filepath}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def aggregate_grades(input_files, output_file, summary_file):
|
||||
"""聚合多个评分文件"""
|
||||
grades = []
|
||||
total_score = 0
|
||||
max_score = 0
|
||||
need_review_count = 0
|
||||
|
||||
for input_file in input_files:
|
||||
grade = load_grade(input_file)
|
||||
if grade:
|
||||
grades.append(grade)
|
||||
# 支持两种格式:'total' (llm_grade.py) 或 'score' (旧格式)
|
||||
score = grade.get('total', grade.get('score', 0))
|
||||
total_score += score
|
||||
# 默认每题 10 分
|
||||
max_score += grade.get('max_score', 10)
|
||||
# 检查是否需要审核
|
||||
if 'need_review' in grade.get('flags', []) or grade.get('need_review', False):
|
||||
need_review_count += 1
|
||||
|
||||
# 计算总分
|
||||
final_score = total_score if max_score > 0 else 0
|
||||
final_max_score = max_score
|
||||
|
||||
# 生成汇总结果
|
||||
result = {
|
||||
'total_score': final_score,
|
||||
'max_score': final_max_score,
|
||||
'questions': len(grades),
|
||||
'need_review': need_review_count > 0,
|
||||
'details': grades
|
||||
}
|
||||
|
||||
# 保存 JSON
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# 生成 Markdown 摘要
|
||||
summary_lines = [
|
||||
'# LLM 简答题评分汇总',
|
||||
'',
|
||||
f'**总分**: {final_score:.1f} / {final_max_score:.1f}',
|
||||
f'**题目数**: {len(grades)}',
|
||||
f'**需要人工审核**: {"是" if result["need_review"] else "否"}',
|
||||
'',
|
||||
'## 各题详情',
|
||||
''
|
||||
]
|
||||
|
||||
for i, grade in enumerate(grades, 1):
|
||||
q_name = grade.get('question', f'Q{i}')
|
||||
# 支持两种格式:'total' (llm_grade.py) 或 'score' (旧格式)
|
||||
score = grade.get('total', grade.get('score', 0))
|
||||
max_q_score = grade.get('max_score', 10)
|
||||
# 检查是否需要审核
|
||||
need_review = 'need_review' in grade.get('flags', []) or grade.get('need_review', False)
|
||||
confidence = grade.get('confidence', 1.0)
|
||||
|
||||
summary_lines.append(f'### SA{i}')
|
||||
summary_lines.append(f'- **得分**: {score:.2f} / {max_q_score:.1f}')
|
||||
summary_lines.append(f'- **置信度**: {confidence:.2f}')
|
||||
if need_review:
|
||||
summary_lines.append('- ⚠️ **需要人工审核**')
|
||||
|
||||
# 显示分项评分
|
||||
if 'criteria' in grade:
|
||||
summary_lines.append('- **分项**:')
|
||||
for criterion in grade['criteria']:
|
||||
crit_id = criterion.get('id', '')
|
||||
crit_score = criterion.get('score', 0)
|
||||
crit_reason = criterion.get('reason', '')
|
||||
summary_lines.append(f' - {crit_id}: {crit_score:.1f} - {crit_reason}')
|
||||
|
||||
summary_lines.append('')
|
||||
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(summary_lines))
|
||||
|
||||
print(f"✅ Aggregated {len(grades)} grades")
|
||||
print(f" Total: {final_score:.1f} / {final_max_score:.1f}")
|
||||
print(f" Output: {output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Aggregate LLM grading results')
|
||||
parser.add_argument('--inputs', nargs='+', required=True,
|
||||
help='Input grade JSON files')
|
||||
parser.add_argument('--out', required=True,
|
||||
help='Output aggregated JSON file')
|
||||
parser.add_argument('--summary', required=True,
|
||||
help='Output summary Markdown file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
aggregate_grades(args.inputs, args.out, args.summary)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
282
.autograde/create_minimal_metadata.py
Normal file
282
.autograde/create_minimal_metadata.py
Normal file
@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建完整的成绩元数据文件
|
||||
|
||||
从 grade.json 或 llm_grade.json 生成 metadata.json
|
||||
包含所有详细信息:未通过的测试、各题详情等
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def extract_student_id():
|
||||
"""从环境变量或仓库名中提取学生 ID"""
|
||||
# 优先从环境变量获取
|
||||
student_id = os.getenv("STUDENT_ID")
|
||||
if student_id:
|
||||
return student_id
|
||||
|
||||
# 从仓库名提取(格式:hw1-stu_sit001)
|
||||
repo = os.getenv("REPO", "")
|
||||
if repo:
|
||||
# 匹配 hw1-stu_xxxxx 格式
|
||||
match = re.search(r'hw\d+-stu[_-]?([^/]+)', repo)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def extract_assignment_id():
|
||||
"""从环境变量或仓库名中提取作业 ID"""
|
||||
# 优先从环境变量获取
|
||||
assignment_id = os.getenv("ASSIGNMENT_ID")
|
||||
if assignment_id:
|
||||
return assignment_id
|
||||
|
||||
# 从仓库名提取(格式:hw1-stu_sit001 或 hw1-template)
|
||||
repo = os.getenv("REPO", "")
|
||||
if repo:
|
||||
# 尝试匹配 hwX-stu_ 或 hwX-template
|
||||
match = re.search(r'(hw\d+)-(?:stu|template)', repo)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# 如果只是 hwX 格式
|
||||
match = re.search(r'(hw\d+)$', repo)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return "hw1" # 默认回退
|
||||
|
||||
def create_grade_metadata(grade_file='grade.json'):
|
||||
"""从 grade.json 创建元数据,包含所有详细信息"""
|
||||
try:
|
||||
with open(grade_file, 'r') as f:
|
||||
grade_data = json.load(f)
|
||||
|
||||
assignment_id = extract_assignment_id()
|
||||
student_id = extract_student_id()
|
||||
language = os.getenv("LANGUAGE", "python")
|
||||
|
||||
# 提取所有相关信息
|
||||
final_score = grade_data.get("final_score", grade_data.get("score", 0))
|
||||
base_score = grade_data.get("base_score", final_score)
|
||||
penalty = grade_data.get("penalty", 0)
|
||||
passed = grade_data.get("passed", 0)
|
||||
total = grade_data.get("total", 0)
|
||||
fails = grade_data.get("fails", [])
|
||||
max_score = grade_data.get("max_score", 100)
|
||||
test_framework = grade_data.get("test_framework", "pytest")
|
||||
coverage = grade_data.get("coverage")
|
||||
raw_score = grade_data.get("raw_score")
|
||||
|
||||
# 动态生成 type 字段
|
||||
type_map = {
|
||||
"python": "programming_python",
|
||||
"java": "programming_java",
|
||||
"r": "programming_r"
|
||||
}
|
||||
component_type = type_map.get(language, f"programming_{language}")
|
||||
|
||||
component = {
|
||||
"type": component_type,
|
||||
"language": language,
|
||||
"score": round(final_score, 2),
|
||||
"max_score": max_score,
|
||||
"details": {
|
||||
"passed": passed,
|
||||
"total": total,
|
||||
"base_score": round(base_score, 2),
|
||||
"penalty": round(penalty, 2),
|
||||
"coverage": round(coverage, 2) if coverage else None,
|
||||
"raw_score": round(raw_score, 2) if raw_score else None,
|
||||
"failed_tests": fails,
|
||||
"test_framework": test_framework
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
"version": "1.0",
|
||||
"assignment": assignment_id,
|
||||
"student_id": student_id,
|
||||
"components": [component],
|
||||
"total_score": round(final_score, 2),
|
||||
"total_max_score": max_score,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"generator": "gitea-autograde"
|
||||
}
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error creating grade metadata: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def create_llm_metadata(llm_grade_file='artifacts/llm_grade.json'):
|
||||
"""从 llm_grade.json 创建元数据,包含所有详细信息"""
|
||||
try:
|
||||
with open(llm_grade_file, 'r') as f:
|
||||
llm_data = json.load(f)
|
||||
|
||||
assignment_id = extract_assignment_id()
|
||||
student_id = extract_student_id()
|
||||
|
||||
# 提取聚合后的信息
|
||||
total_score = llm_data.get("total_score", llm_data.get("total", 0))
|
||||
max_score = llm_data.get("max_score", 30)
|
||||
need_review = llm_data.get("need_review", False)
|
||||
questions_data = llm_data.get("details", llm_data.get("questions", []))
|
||||
|
||||
# 构建各题详情
|
||||
question_details = []
|
||||
for i, q_data in enumerate(questions_data, 1):
|
||||
q_score = q_data.get("total", q_data.get("score", 0))
|
||||
q_max = q_data.get("max_score", 10)
|
||||
q_confidence = q_data.get("confidence", 1.0)
|
||||
q_flags = q_data.get("flags", [])
|
||||
q_need_review = "need_review" in q_flags or q_data.get("need_review", False)
|
||||
q_criteria = q_data.get("criteria", [])
|
||||
|
||||
# 规范化 criteria 格式
|
||||
formatted_criteria = []
|
||||
for crit in q_criteria:
|
||||
formatted_criteria.append({
|
||||
"id": crit.get("id", ""),
|
||||
"score": round(float(crit.get("score", 0)), 2),
|
||||
"reason": crit.get("reason", "")
|
||||
})
|
||||
|
||||
question_detail = {
|
||||
"question_id": f"SA{i}",
|
||||
"question_name": q_data.get("question", f"SA{i}"),
|
||||
"score": round(float(q_score), 2),
|
||||
"max_score": q_max,
|
||||
"confidence": round(float(q_confidence), 2),
|
||||
"need_review": q_need_review,
|
||||
"flags": q_flags,
|
||||
"criteria": formatted_criteria
|
||||
}
|
||||
question_details.append(question_detail)
|
||||
|
||||
component = {
|
||||
"type": "llm_essay",
|
||||
"score": round(float(total_score), 2),
|
||||
"max_score": max_score,
|
||||
"details": {
|
||||
"questions": len(question_details),
|
||||
"need_review": need_review,
|
||||
"question_details": question_details
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
"version": "1.0",
|
||||
"assignment": assignment_id,
|
||||
"student_id": student_id,
|
||||
"components": [component],
|
||||
"total_score": round(float(total_score), 2),
|
||||
"total_max_score": max_score,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"generator": "gitea-autograde"
|
||||
}
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error creating LLM metadata: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def create_objective_metadata(objective_file='objective_grade.json'):
|
||||
"""从 objective_grade.json 创建元数据"""
|
||||
try:
|
||||
with open(objective_file, 'r', encoding='utf-8') as f:
|
||||
objective_data = json.load(f)
|
||||
|
||||
assignment_id = extract_assignment_id()
|
||||
student_id = extract_student_id()
|
||||
|
||||
total_score = objective_data.get("score", 0)
|
||||
max_score = objective_data.get("max_score", 0)
|
||||
components = objective_data.get("components", [])
|
||||
|
||||
formatted_components = []
|
||||
for comp in components:
|
||||
comp_type = comp.get("type", "objective")
|
||||
formatted_components.append({
|
||||
"type": f"objective_{comp_type}",
|
||||
"score": comp.get("score", 0),
|
||||
"max_score": comp.get("max_score", 0),
|
||||
"details": comp.get("details", {})
|
||||
})
|
||||
|
||||
if not formatted_components:
|
||||
formatted_components.append({
|
||||
"type": "objective_total",
|
||||
"score": total_score,
|
||||
"max_score": max_score,
|
||||
"details": {}
|
||||
})
|
||||
|
||||
metadata = {
|
||||
"version": "1.0",
|
||||
"assignment": assignment_id,
|
||||
"student_id": student_id,
|
||||
"components": formatted_components,
|
||||
"total_score": total_score,
|
||||
"total_max_score": max_score,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"generator": "gitea-autograde"
|
||||
}
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error creating objective metadata: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 检查命令行参数或环境变量
|
||||
grade_type = os.getenv("GRADE_TYPE", "programming").lower()
|
||||
grade_file_override = os.getenv("GRADE_FILE")
|
||||
|
||||
if grade_type == "llm":
|
||||
# LLM 成绩
|
||||
llm_file = grade_file_override or "artifacts/llm_grade.json"
|
||||
if os.path.exists(llm_file):
|
||||
metadata = create_llm_metadata(llm_file)
|
||||
elif os.path.exists("llm_grade.json"):
|
||||
metadata = create_llm_metadata("llm_grade.json")
|
||||
else:
|
||||
print(f"Error: {llm_file} not found", file=sys.stderr)
|
||||
metadata = {}
|
||||
elif grade_type == "objective":
|
||||
objective_file = grade_file_override or "objective_grade.json"
|
||||
if os.path.exists(objective_file):
|
||||
metadata = create_objective_metadata(objective_file)
|
||||
else:
|
||||
print(f"Error: {objective_file} not found", file=sys.stderr)
|
||||
metadata = {}
|
||||
else:
|
||||
# 编程成绩
|
||||
grade_file = grade_file_override or "grade.json"
|
||||
if os.path.exists(grade_file):
|
||||
metadata = create_grade_metadata(grade_file)
|
||||
else:
|
||||
print(f"Error: {grade_file} not found", file=sys.stderr)
|
||||
metadata = {}
|
||||
|
||||
# 输出到 stdout
|
||||
print(json.dumps(metadata, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
187
.autograde/grade.py
Normal file
187
.autograde/grade.py
Normal file
@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
编程题评分脚本
|
||||
|
||||
解析 JUnit XML 报告,计算分数,考虑迟交扣分,生成 grade.json 和 summary.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量(支持从 .env 文件或环境变量读取)
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def commit_ts():
|
||||
"""获取最后一次提交的时间戳(Unix 时间戳)"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "log", "-1", "--format=%ct"],
|
||||
stderr=subprocess.DEVNULL
|
||||
).decode().strip()
|
||||
return int(out)
|
||||
except Exception:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def parse_junit(junit_path):
|
||||
"""
|
||||
解析 JUnit XML 报告
|
||||
|
||||
Returns
|
||||
-------
|
||||
passed : int
|
||||
通过的测试数
|
||||
total : int
|
||||
总测试数
|
||||
fails : list
|
||||
失败的测试名称列表
|
||||
"""
|
||||
if not os.path.exists(junit_path):
|
||||
return (0, 0, [])
|
||||
|
||||
try:
|
||||
root = ET.parse(junit_path).getroot()
|
||||
total = 0
|
||||
passed = 0
|
||||
fails = []
|
||||
|
||||
for testsuite in root.iter("testsuite"):
|
||||
for testcase in testsuite.iter("testcase"):
|
||||
total += 1
|
||||
# 检查是否有 failure、error 或 skipped 子元素
|
||||
if list(testcase):
|
||||
classname = testcase.get("classname", "")
|
||||
name = testcase.get("name", "")
|
||||
full_name = f"{classname}.{name}" if classname else name
|
||||
fails.append(full_name)
|
||||
else:
|
||||
passed += 1
|
||||
|
||||
return (passed, total, fails)
|
||||
except Exception as e:
|
||||
print(f"Error parsing JUnit XML: {e}", file=sys.stderr)
|
||||
return (0, 0, [])
|
||||
|
||||
|
||||
def calculate_late_penalty(deadline_str):
|
||||
"""
|
||||
计算迟交扣分
|
||||
|
||||
Parameters
|
||||
----------
|
||||
deadline_str : str
|
||||
ISO 格式的截止时间(如 "2025-03-15T23:59:59+08:00")
|
||||
|
||||
Returns
|
||||
-------
|
||||
penalty : float
|
||||
扣分数(0-30)
|
||||
"""
|
||||
if not deadline_str:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
# 解析截止时间(支持多种格式)
|
||||
deadline_str = deadline_str.strip()
|
||||
# 移除时区信息(简化处理)
|
||||
if '+' in deadline_str:
|
||||
deadline_str = deadline_str.split('+')[0]
|
||||
elif 'Z' in deadline_str:
|
||||
deadline_str = deadline_str.replace('Z', '')
|
||||
|
||||
# 解析时间
|
||||
if 'T' in deadline_str:
|
||||
dl = time.mktime(time.strptime(deadline_str[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||
else:
|
||||
dl = time.mktime(time.strptime(deadline_str[:19], "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
commit_time = commit_ts()
|
||||
late_sec = max(0, commit_time - dl)
|
||||
days = late_sec / 86400
|
||||
|
||||
# 扣分规则:第一天 10 分,之后每天 5 分,最多 30 分
|
||||
if days > 0:
|
||||
penalty = min(30.0, 10.0 + 5.0 * days)
|
||||
else:
|
||||
penalty = 0.0
|
||||
|
||||
return round(penalty, 2)
|
||||
except Exception as e:
|
||||
print(f"Error calculating late penalty: {e}", file=sys.stderr)
|
||||
return 0.0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Grade programming assignments from JUnit XML")
|
||||
parser.add_argument("--junit", required=True, help="Path to JUnit XML file")
|
||||
parser.add_argument("--out", default="grade.json", help="Output JSON file")
|
||||
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
|
||||
parser.add_argument("--bonus", default=None, help="Optional bonus file (e.g., lintr.rds)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 解析 JUnit XML
|
||||
passed, total, fails = parse_junit(args.junit)
|
||||
|
||||
# 计算基础分数
|
||||
if total > 0:
|
||||
base_score = 100.0 * (passed / total)
|
||||
else:
|
||||
base_score = 0.0
|
||||
|
||||
# 计算迟交扣分
|
||||
deadline = os.getenv("DEADLINE", "")
|
||||
penalty = calculate_late_penalty(deadline)
|
||||
|
||||
# 最终分数
|
||||
final_score = max(0.0, round(base_score - penalty, 2))
|
||||
|
||||
# 生成 grade.json
|
||||
grade_data = {
|
||||
"score": final_score,
|
||||
"base_score": round(base_score, 2),
|
||||
"penalty": penalty,
|
||||
"passed": passed,
|
||||
"total": total,
|
||||
"fails": fails,
|
||||
"timestamp": int(time.time())
|
||||
}
|
||||
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(grade_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成 summary.md
|
||||
with open(args.summary, "w", encoding="utf-8") as f:
|
||||
f.write("# 成绩报告\n\n")
|
||||
f.write(f"- **通过用例**:{passed}/{total}\n")
|
||||
f.write(f"- **原始分**:{base_score:.2f}/100\n")
|
||||
if penalty > 0:
|
||||
f.write(f"- **迟交扣分**:-{penalty:.2f}\n")
|
||||
f.write(f"- **最终分**:**{final_score:.2f}/100**\n\n")
|
||||
|
||||
if fails:
|
||||
f.write("## 未通过的测试\n\n")
|
||||
for fail in fails:
|
||||
f.write(f"- {fail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if deadline:
|
||||
f.write(f"## 截止时间\n\n")
|
||||
f.write(f"- 截止时间:{deadline}\n")
|
||||
commit_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(commit_ts()))
|
||||
f.write(f"- 提交时间:{commit_time_str}\n")
|
||||
|
||||
print(f"Grading complete: {final_score:.2f}/100 ({passed}/{total} tests passed)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
207
.autograde/grade_grouped.py
Normal file
207
.autograde/grade_grouped.py
Normal file
@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
分组编程题评分脚本
|
||||
|
||||
解析 JUnit XML 报告,按测试分组(Core/Advanced/Challenge)计算加权分数
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from glob import glob
|
||||
|
||||
|
||||
def parse_junit_files(junit_dir):
|
||||
"""
|
||||
解析目录下所有 JUnit XML 报告
|
||||
|
||||
Returns
|
||||
-------
|
||||
results : list of dict
|
||||
每个测试的结果,包含 classname, name, passed
|
||||
"""
|
||||
results = []
|
||||
|
||||
xml_files = glob(os.path.join(junit_dir, "TEST-*.xml"))
|
||||
if not xml_files:
|
||||
xml_files = glob(os.path.join(junit_dir, "*.xml"))
|
||||
|
||||
for xml_file in xml_files:
|
||||
try:
|
||||
root = ET.parse(xml_file).getroot()
|
||||
|
||||
for testsuite in root.iter("testsuite"):
|
||||
for testcase in testsuite.iter("testcase"):
|
||||
classname = testcase.get("classname", "")
|
||||
name = testcase.get("name", "")
|
||||
|
||||
# 检查是否有 failure、error 或 skipped 子元素
|
||||
failed = any(testcase.iter("failure")) or any(testcase.iter("error"))
|
||||
skipped = any(testcase.iter("skipped"))
|
||||
|
||||
results.append({
|
||||
"classname": classname,
|
||||
"name": name,
|
||||
"passed": not failed and not skipped,
|
||||
"skipped": skipped
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing {xml_file}: {e}", file=sys.stderr)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_groups_config(groups_file):
|
||||
"""加载测试分组配置"""
|
||||
if not os.path.exists(groups_file):
|
||||
# 默认配置
|
||||
return {
|
||||
"groups": {
|
||||
"core": {"pattern": ".*core.*", "weight": 0.75, "max_score": 60},
|
||||
"advanced": {"pattern": ".*advanced.*", "weight": 0.125, "max_score": 10},
|
||||
"challenge": {"pattern": ".*challenge.*", "weight": 0.125, "max_score": 10}
|
||||
},
|
||||
"fallback_group": "core"
|
||||
}
|
||||
|
||||
with open(groups_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def categorize_test(classname, groups_config):
|
||||
"""根据 classname 将测试分类到对应的组"""
|
||||
for group_name, group_info in groups_config.get("groups", {}).items():
|
||||
pattern = group_info.get("pattern", "")
|
||||
if re.search(pattern, classname, re.IGNORECASE):
|
||||
return group_name
|
||||
|
||||
return groups_config.get("fallback_group", "core")
|
||||
|
||||
|
||||
def calculate_grouped_score(test_results, groups_config):
|
||||
"""
|
||||
按分组计算加权分数
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
包含各组得分和总分的字典
|
||||
"""
|
||||
groups = groups_config.get("groups", {})
|
||||
|
||||
# 初始化各组统计
|
||||
group_stats = {}
|
||||
for group_name, group_info in groups.items():
|
||||
group_stats[group_name] = {
|
||||
"passed": 0,
|
||||
"total": 0,
|
||||
"max_score": group_info.get("max_score", 10),
|
||||
"weight": group_info.get("weight", 0.1),
|
||||
"tests": []
|
||||
}
|
||||
|
||||
# 分类并统计测试结果
|
||||
for test in test_results:
|
||||
group = categorize_test(test["classname"], groups_config)
|
||||
if group not in group_stats:
|
||||
group = groups_config.get("fallback_group", "core")
|
||||
|
||||
group_stats[group]["total"] += 1
|
||||
if test["passed"]:
|
||||
group_stats[group]["passed"] += 1
|
||||
else:
|
||||
group_stats[group]["tests"].append(f"{test['classname']}.{test['name']}")
|
||||
|
||||
# 计算各组得分
|
||||
total_score = 0
|
||||
group_scores = {}
|
||||
|
||||
for group_name, stats in group_stats.items():
|
||||
if stats["total"] > 0:
|
||||
pass_rate = stats["passed"] / stats["total"]
|
||||
group_score = pass_rate * stats["max_score"]
|
||||
else:
|
||||
group_score = 0
|
||||
|
||||
group_scores[group_name] = {
|
||||
"passed": stats["passed"],
|
||||
"total": stats["total"],
|
||||
"max_score": stats["max_score"],
|
||||
"score": round(group_score, 2),
|
||||
"failed_tests": stats["tests"][:10] # 只保留前 10 个失败测试
|
||||
}
|
||||
|
||||
total_score += group_score
|
||||
|
||||
return {
|
||||
"total_score": round(total_score, 2),
|
||||
"max_score": 80, # 编程测试总分 80 分(Core 60 + Advanced 10 + Challenge 10)
|
||||
"groups": group_scores
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Grade programming assignments with test groups")
|
||||
parser.add_argument("--junit-dir", required=True, help="Directory containing JUnit XML files")
|
||||
parser.add_argument("--groups", default="test_groups.json", help="Test groups configuration file")
|
||||
parser.add_argument("--out", default="grade.json", help="Output JSON file")
|
||||
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 解析测试结果
|
||||
test_results = parse_junit_files(args.junit_dir)
|
||||
|
||||
if not test_results:
|
||||
print("Warning: No test results found", file=sys.stderr)
|
||||
grade_data = {
|
||||
"total_score": 0,
|
||||
"max_score": 80,
|
||||
"groups": {},
|
||||
"error": "No test results found"
|
||||
}
|
||||
else:
|
||||
# 加载分组配置
|
||||
groups_config = load_groups_config(args.groups)
|
||||
|
||||
# 计算分组分数
|
||||
grade_data = calculate_grouped_score(test_results, groups_config)
|
||||
|
||||
# 保存 grade.json
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(grade_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成 summary.md
|
||||
with open(args.summary, "w", encoding="utf-8") as f:
|
||||
f.write("# 编程测试成绩报告\n\n")
|
||||
f.write(f"**总分:{grade_data['total_score']:.2f} / {grade_data['max_score']}**\n\n")
|
||||
|
||||
f.write("## 分组得分\n\n")
|
||||
f.write("| 分组 | 通过 | 总数 | 得分 | 满分 |\n")
|
||||
f.write("|------|------|------|------|------|\n")
|
||||
|
||||
for group_name, group_info in grade_data.get("groups", {}).items():
|
||||
f.write(f"| {group_name} | {group_info['passed']} | {group_info['total']} | "
|
||||
f"{group_info['score']:.2f} | {group_info['max_score']} |\n")
|
||||
|
||||
# 列出失败的测试
|
||||
all_failed = []
|
||||
for group_name, group_info in grade_data.get("groups", {}).items():
|
||||
all_failed.extend(group_info.get("failed_tests", []))
|
||||
|
||||
if all_failed:
|
||||
f.write("\n## 未通过的测试\n\n")
|
||||
for test in all_failed[:20]: # 最多显示 20 个
|
||||
f.write(f"- {test}\n")
|
||||
if len(all_failed) > 20:
|
||||
f.write(f"\n... 还有 {len(all_failed) - 20} 个未通过的测试\n")
|
||||
|
||||
print(f"Grading complete: {grade_data['total_score']:.2f}/{grade_data['max_score']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
217
.autograde/llm_grade.py
Normal file
217
.autograde/llm_grade.py
Normal file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LLM 简答题评分脚本
|
||||
|
||||
调用 LLM API,按评分量表对简答题进行评分,输出 JSON 格式结果
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import requests
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量(支持从 .env 文件或环境变量读取)
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""读取文件内容"""
|
||||
if os.path.exists(path):
|
||||
return open(path, 'r', encoding='utf-8').read()
|
||||
return ""
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = """你是严格且一致的助教,按提供的评分量表为学生的简答题评分。
|
||||
|
||||
- 只依据量表,不做主观延伸;允许多样表述。
|
||||
- 不输出任何解释性文本;只输出 JSON,包含:
|
||||
{{
|
||||
"total": number(0-10, 两位小数),
|
||||
"criteria": [
|
||||
{{"id":"accuracy","score":0-3,"reason":"要点式一句话"}},
|
||||
{{"id":"coverage","score":0-3,"reason":""}},
|
||||
{{"id":"clarity","score":0-3,"reason":""}}
|
||||
],
|
||||
"flags": [],
|
||||
"confidence": number(0-1)
|
||||
}}
|
||||
如果答案与题目无关,total=0,并加 flag "need_review"。
|
||||
|
||||
【题目】
|
||||
<<<{question}>>>
|
||||
|
||||
【评分量表】
|
||||
<<<{rubric}>>>
|
||||
|
||||
【学生答案】
|
||||
<<<{answer}>>>
|
||||
"""
|
||||
|
||||
|
||||
def call_llm(url, key, model, prompt):
|
||||
"""
|
||||
调用 LLM API
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url : str
|
||||
API 地址
|
||||
key : str
|
||||
API 密钥
|
||||
model : str
|
||||
模型名称
|
||||
prompt : str
|
||||
提示词
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
LLM 返回的 JSON 结果
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"top_p": 1,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"}
|
||||
}
|
||||
|
||||
try:
|
||||
# 设置超时:连接超时 10 秒,读取超时 60 秒
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=data,
|
||||
timeout=(10, 60)
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
content = result.get("choices", [{}])[0].get("message", {}).get("content", "{}")
|
||||
return json.loads(content)
|
||||
except requests.exceptions.Timeout as e:
|
||||
print(f"LLM API request timeout: {e}", file=sys.stderr)
|
||||
raise
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"LLM API HTTP error: {e} (status: {response.status_code})", file=sys.stderr)
|
||||
raise
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"LLM API request failed: {e}", file=sys.stderr)
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Failed to parse LLM response as JSON: {e}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Grade short answer questions using LLM")
|
||||
parser.add_argument("--question", required=True, help="Path to question file")
|
||||
parser.add_argument("--answer", required=True, help="Path to answer file")
|
||||
parser.add_argument("--rubric", required=True, help="Path to rubric JSON file")
|
||||
parser.add_argument("--out", default="grade.json", help="Output JSON file")
|
||||
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
|
||||
parser.add_argument("--model", default=os.getenv("LLM_MODEL", "deepseek-chat"))
|
||||
parser.add_argument("--api_url", default=os.getenv("LLM_API_URL", "https://api.deepseek.com/chat/completions"))
|
||||
parser.add_argument("--api_key", default=os.getenv("LLM_API_KEY", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
# 验证必需的配置
|
||||
if not args.api_key:
|
||||
print("Warning: LLM_API_KEY not set. LLM grading may fail.", file=sys.stderr)
|
||||
|
||||
# 读取文件
|
||||
question = read_file(args.question).strip()
|
||||
answer = read_file(args.answer).strip()
|
||||
rubric_text = read_file(args.rubric).strip()
|
||||
|
||||
if not question or not answer:
|
||||
print(f"Warning: Empty question or answer file", file=sys.stderr)
|
||||
resp = {
|
||||
"total": 0,
|
||||
"criteria": [],
|
||||
"flags": ["need_review", "empty_answer"],
|
||||
"confidence": 0.0
|
||||
}
|
||||
else:
|
||||
# 调用 LLM
|
||||
try:
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
question=question,
|
||||
rubric=rubric_text,
|
||||
answer=answer
|
||||
)
|
||||
resp = call_llm(args.api_url, args.api_key, args.model, prompt)
|
||||
except Exception as e:
|
||||
print(f"LLM grading failed: {e}", file=sys.stderr)
|
||||
resp = {
|
||||
"total": 0,
|
||||
"criteria": [],
|
||||
"flags": ["need_review", "llm_error"],
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
# 边界带自动送审
|
||||
try:
|
||||
rubric_data = json.loads(rubric_text)
|
||||
lo, hi = rubric_data.get("borderline_band", [None, None])
|
||||
total = float(resp.get("total", 0))
|
||||
flags = set(resp.get("flags", []))
|
||||
|
||||
if lo is not None and hi is not None and lo <= total <= hi:
|
||||
flags.add("need_review")
|
||||
|
||||
# 低置信度送审
|
||||
confidence = resp.get("confidence", 1.0)
|
||||
if confidence < 0.7:
|
||||
flags.add("need_review")
|
||||
|
||||
resp["flags"] = sorted(list(flags))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 保存 grade.json
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(resp, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成 summary.md
|
||||
try:
|
||||
rubric_data = json.loads(rubric_text)
|
||||
max_score = rubric_data.get("max_score", 10)
|
||||
except Exception:
|
||||
max_score = 10
|
||||
|
||||
lines = [
|
||||
f"# 简答题评分",
|
||||
f"",
|
||||
f"- **总分**:**{resp.get('total', 0):.2f} / {max_score}**",
|
||||
f"- **置信度**:{resp.get('confidence', 0):.2f}",
|
||||
f"- **标记**:{', '.join(resp.get('flags', [])) or '无'}",
|
||||
f"",
|
||||
f"## 分项评分"
|
||||
]
|
||||
|
||||
for criterion in resp.get("criteria", []):
|
||||
criterion_id = criterion.get("id", "")
|
||||
score = criterion.get("score", 0)
|
||||
reason = criterion.get("reason", "")
|
||||
lines.append(f"- **{criterion_id}**: {score} 分")
|
||||
if reason:
|
||||
lines.append(f" - {reason}")
|
||||
|
||||
with open(args.summary, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"LLM grading complete: {resp.get('total', 0):.2f}/{max_score}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
444
.autograde/objective_grade.py
Normal file
444
.autograde/objective_grade.py
Normal file
@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
选择题/判断题评分脚本
|
||||
|
||||
读取学生答案和标准答案,生成成绩 JSON 文件
|
||||
"""
|
||||
|
||||
import json
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_answers(answer_file):
|
||||
"""
|
||||
加载学生答案文件(支持 JSON 和简单文本格式)
|
||||
|
||||
JSON 格式示例:
|
||||
{
|
||||
"MC1": "A",
|
||||
"MC2": "B",
|
||||
"TF1": true,
|
||||
"TF2": false
|
||||
}
|
||||
|
||||
文本格式示例(每行一个答案):
|
||||
A
|
||||
B
|
||||
true
|
||||
false
|
||||
"""
|
||||
try:
|
||||
with open(answer_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# 尝试作为 JSON 加载
|
||||
if content.startswith('{'):
|
||||
return json.loads(content)
|
||||
|
||||
# 否则按行加载,忽略空行和注释
|
||||
lines = [line.strip() for line in content.split('\n') if line.strip() and not line.strip().startswith('#')]
|
||||
|
||||
# 转换为字典格式:{"MC1": answer, "MC2": answer, ...}
|
||||
answers = {}
|
||||
for i, line in enumerate(lines, 1):
|
||||
# 尝试识别题型
|
||||
if line.lower() in ('true', 'false', 't', 'f'):
|
||||
question_id = f"TF{len([k for k in answers if k.startswith('TF')])+1}"
|
||||
answers[question_id] = line.lower() in ('true', 't')
|
||||
else:
|
||||
question_id = f"MC{len([k for k in answers if k.startswith('MC')])+1}"
|
||||
answers[question_id] = line.upper()
|
||||
|
||||
return answers
|
||||
except Exception as e:
|
||||
print(f"Error loading answers: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def load_standard_answers(std_file):
|
||||
"""加载标准答案文件(JSON 格式)"""
|
||||
try:
|
||||
with open(std_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading standard answers: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def grade_multiple_choice(student_answers, standard_answers, question_texts=None):
|
||||
"""
|
||||
评选择题
|
||||
|
||||
Parameters
|
||||
----------
|
||||
student_answers : dict
|
||||
学生答案,格式 {"MC1": "A", "MC2": "B", ...}
|
||||
standard_answers : dict
|
||||
标准答案,格式 {"MC1": "A", "MC2": "B", ...}
|
||||
question_texts : dict, optional
|
||||
题目文本,格式 {"MC1": "题目文本", ...}
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
成绩数据
|
||||
"""
|
||||
questions = []
|
||||
correct_count = 0
|
||||
|
||||
for question_id, std_answer in standard_answers.items():
|
||||
if not question_id.startswith('MC'):
|
||||
continue
|
||||
|
||||
student_answer = student_answers.get(question_id, "")
|
||||
is_correct = str(student_answer).upper() == str(std_answer).upper()
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
score = 1
|
||||
else:
|
||||
score = 0
|
||||
|
||||
questions.append({
|
||||
"question_id": question_id,
|
||||
"question_text": question_texts.get(question_id, "") if question_texts else "",
|
||||
"correct_answer": str(std_answer).upper(),
|
||||
"student_answer": str(student_answer).upper(),
|
||||
"correct": is_correct,
|
||||
"score": score,
|
||||
"max_score": 1
|
||||
})
|
||||
|
||||
total_count = len(questions)
|
||||
|
||||
return {
|
||||
"type": "multiple_choice",
|
||||
"score": correct_count,
|
||||
"max_score": total_count,
|
||||
"details": {
|
||||
"correct": correct_count,
|
||||
"total": total_count,
|
||||
"questions": questions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def grade_true_false(student_answers, standard_answers, question_texts=None):
|
||||
"""
|
||||
评判断题
|
||||
|
||||
Parameters
|
||||
----------
|
||||
student_answers : dict
|
||||
学生答案,格式 {"TF1": true, "TF2": false, ...}
|
||||
standard_answers : dict
|
||||
标准答案,格式 {"TF1": true, "TF2": false, ...}
|
||||
question_texts : dict, optional
|
||||
题目文本
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
成绩数据
|
||||
"""
|
||||
questions = []
|
||||
correct_count = 0
|
||||
|
||||
for question_id, std_answer in standard_answers.items():
|
||||
if not question_id.startswith('TF'):
|
||||
continue
|
||||
|
||||
student_answer = student_answers.get(question_id, None)
|
||||
|
||||
# 规范化布尔值
|
||||
if isinstance(student_answer, str):
|
||||
student_answer = student_answer.lower() in ('true', 't', '1', 'yes')
|
||||
|
||||
is_correct = bool(student_answer) == bool(std_answer)
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
score = 1
|
||||
else:
|
||||
score = 0
|
||||
|
||||
questions.append({
|
||||
"question_id": question_id,
|
||||
"question_text": question_texts.get(question_id, "") if question_texts else "",
|
||||
"correct_answer": bool(std_answer),
|
||||
"student_answer": bool(student_answer) if student_answer is not None else None,
|
||||
"correct": is_correct,
|
||||
"score": score,
|
||||
"max_score": 1
|
||||
})
|
||||
|
||||
total_count = len(questions)
|
||||
|
||||
return {
|
||||
"type": "true_false",
|
||||
"score": correct_count,
|
||||
"max_score": total_count,
|
||||
"details": {
|
||||
"correct": correct_count,
|
||||
"total": total_count,
|
||||
"questions": questions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def grade_multiple_select(student_answers, standard_answers, question_texts=None):
|
||||
"""
|
||||
评多选题
|
||||
|
||||
Parameters
|
||||
----------
|
||||
student_answers : dict
|
||||
学生答案,格式 {"MS1": ["A", "B"], "MS2": ["C"], ...}
|
||||
standard_answers : dict
|
||||
标准答案,格式 {"MS1": ["A", "B"], "MS2": ["C"], ...}
|
||||
question_texts : dict, optional
|
||||
题目文本
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
成绩数据
|
||||
"""
|
||||
questions = []
|
||||
correct_count = 0
|
||||
|
||||
for question_id, std_answer in standard_answers.items():
|
||||
if not question_id.startswith('MS'):
|
||||
continue
|
||||
|
||||
student_answer = student_answers.get(question_id, [])
|
||||
|
||||
# 规范化答案(转为大写并排序)
|
||||
if isinstance(student_answer, str):
|
||||
student_answer = [student_answer]
|
||||
if not isinstance(student_answer, list):
|
||||
student_answer = []
|
||||
|
||||
std_set = set([str(a).upper() for a in std_answer])
|
||||
stu_set = set([str(a).upper() for a in student_answer])
|
||||
|
||||
is_correct = std_set == stu_set
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
score = 1
|
||||
else:
|
||||
score = 0
|
||||
|
||||
questions.append({
|
||||
"question_id": question_id,
|
||||
"question_text": question_texts.get(question_id, "") if question_texts else "",
|
||||
"correct_answer": sorted(list(std_set)),
|
||||
"student_answer": sorted(list(stu_set)) if stu_set else [],
|
||||
"correct": is_correct,
|
||||
"score": score,
|
||||
"max_score": 1
|
||||
})
|
||||
|
||||
total_count = len(questions)
|
||||
|
||||
return {
|
||||
"type": "multiple_select",
|
||||
"score": correct_count,
|
||||
"max_score": total_count,
|
||||
"details": {
|
||||
"correct": correct_count,
|
||||
"total": total_count,
|
||||
"questions": questions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def grade_fill_blank(student_answers, standard_answers, question_texts=None):
|
||||
"""
|
||||
评填空题
|
||||
|
||||
Parameters
|
||||
----------
|
||||
student_answers : dict
|
||||
学生答案,格式 {"FB1": "答案", "FB2": ["答案1", "答案2"], ...}
|
||||
standard_answers : dict
|
||||
标准答案,格式同上
|
||||
question_texts : dict, optional
|
||||
题目文本
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
成绩数据
|
||||
"""
|
||||
questions = []
|
||||
correct_count = 0
|
||||
|
||||
def normalize_answer(ans):
|
||||
"""规范化答案:去除空格、转小写"""
|
||||
if isinstance(ans, str):
|
||||
return ans.strip().lower()
|
||||
elif isinstance(ans, list):
|
||||
return [a.strip().lower() for a in ans]
|
||||
return ans
|
||||
|
||||
def compare_answers(student, standard):
|
||||
"""比较答案是否相等"""
|
||||
student_norm = normalize_answer(student)
|
||||
standard_norm = normalize_answer(standard)
|
||||
|
||||
if isinstance(standard_norm, list) and isinstance(student_norm, list):
|
||||
return student_norm == standard_norm
|
||||
elif isinstance(standard_norm, str) and isinstance(student_norm, str):
|
||||
return student_norm == standard_norm
|
||||
return False
|
||||
|
||||
for question_id, std_answer in standard_answers.items():
|
||||
if not question_id.startswith('FB'):
|
||||
continue
|
||||
|
||||
student_answer = student_answers.get(question_id, "")
|
||||
|
||||
is_correct = compare_answers(student_answer, std_answer)
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
score = 1
|
||||
else:
|
||||
score = 0
|
||||
|
||||
questions.append({
|
||||
"question_id": question_id,
|
||||
"question_text": question_texts.get(question_id, "") if question_texts else "",
|
||||
"correct_answer": std_answer,
|
||||
"student_answer": student_answer,
|
||||
"correct": is_correct,
|
||||
"score": score,
|
||||
"max_score": 1
|
||||
})
|
||||
|
||||
total_count = len(questions)
|
||||
|
||||
return {
|
||||
"type": "fill_blank",
|
||||
"score": correct_count,
|
||||
"max_score": total_count,
|
||||
"details": {
|
||||
"correct": correct_count,
|
||||
"total": total_count,
|
||||
"questions": questions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Grade objective questions")
|
||||
parser.add_argument("--answers", required=True, help="Student answers file (JSON or text)")
|
||||
parser.add_argument("--standard", required=True, help="Standard answers file (JSON)")
|
||||
parser.add_argument("--questions", help="Question texts file (JSON, optional)")
|
||||
parser.add_argument("--out", default="grade.json", help="Output grade JSON file")
|
||||
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
|
||||
parser.add_argument("--type", choices=['mc', 'tf', 'ms', 'fb', 'all'], default='all',
|
||||
help="Question type to grade")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载文件
|
||||
student_answers = load_answers(args.answers)
|
||||
standard_answers = load_standard_answers(args.standard)
|
||||
question_texts = None
|
||||
|
||||
if args.questions:
|
||||
try:
|
||||
with open(args.questions, 'r', encoding='utf-8') as f:
|
||||
question_texts = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load question texts: {e}", file=sys.stderr)
|
||||
|
||||
if not student_answers or not standard_answers:
|
||||
print("Error: Could not load answers", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 评分
|
||||
components = []
|
||||
total_score = 0
|
||||
total_max_score = 0
|
||||
|
||||
if args.type in ('mc', 'all'):
|
||||
mc_grade = grade_multiple_choice(student_answers, standard_answers, question_texts)
|
||||
if mc_grade['details']['total'] > 0:
|
||||
components.append(mc_grade)
|
||||
total_score += mc_grade['score']
|
||||
total_max_score += mc_grade['max_score']
|
||||
|
||||
if args.type in ('tf', 'all'):
|
||||
tf_grade = grade_true_false(student_answers, standard_answers, question_texts)
|
||||
if tf_grade['details']['total'] > 0:
|
||||
components.append(tf_grade)
|
||||
total_score += tf_grade['score']
|
||||
total_max_score += tf_grade['max_score']
|
||||
|
||||
if args.type in ('ms', 'all'):
|
||||
ms_grade = grade_multiple_select(student_answers, standard_answers, question_texts)
|
||||
if ms_grade['details']['total'] > 0:
|
||||
components.append(ms_grade)
|
||||
total_score += ms_grade['score']
|
||||
total_max_score += ms_grade['max_score']
|
||||
|
||||
if args.type in ('fb', 'all'):
|
||||
fb_grade = grade_fill_blank(student_answers, standard_answers, question_texts)
|
||||
if fb_grade['details']['total'] > 0:
|
||||
components.append(fb_grade)
|
||||
total_score += fb_grade['score']
|
||||
total_max_score += fb_grade['max_score']
|
||||
|
||||
# 生成 grade.json
|
||||
grade_data = {
|
||||
"score": total_score,
|
||||
"max_score": total_max_score,
|
||||
"components": components,
|
||||
"timestamp": int(__import__('time').time())
|
||||
}
|
||||
|
||||
with open(args.out, 'w', encoding='utf-8') as f:
|
||||
json.dump(grade_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成 summary.md
|
||||
summary_lines = [
|
||||
"# 客观题评分\n",
|
||||
f"- **总分**:{total_score} / {total_max_score}\n",
|
||||
f"- **组件数**:{len(components)}\n",
|
||||
""
|
||||
]
|
||||
|
||||
for comp in components:
|
||||
comp_type = comp['type']
|
||||
correct = comp['details']['correct']
|
||||
total = comp['details']['total']
|
||||
|
||||
type_names = {
|
||||
'multiple_choice': '选择题',
|
||||
'true_false': '判断题',
|
||||
'multiple_select': '多选题',
|
||||
'fill_blank': '填空题'
|
||||
}
|
||||
|
||||
type_name = type_names.get(comp_type, comp_type)
|
||||
summary_lines.append(f"## {type_name}\n")
|
||||
summary_lines.append(f"- **正确**:{correct} / {total}\n")
|
||||
summary_lines.append("")
|
||||
|
||||
with open(args.summary, 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(summary_lines))
|
||||
|
||||
print(f"Grading complete: {total_score}/{total_max_score}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
155
.autograde/post_comment.py
Normal file
155
.autograde/post_comment.py
Normal file
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
发送评论到 Gitea PR
|
||||
|
||||
从环境变量读取配置,发送评论到指定的 PR
|
||||
支持在 Markdown 评论中嵌入 JSON 数据,便于后续结构化提取
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def create_comment_with_metadata(summary, commit_sha, comment_type='grade', metadata=None):
|
||||
"""
|
||||
创建包含元数据的评论内容
|
||||
|
||||
Parameters
|
||||
----------
|
||||
summary : str
|
||||
人类可读的 Markdown 格式总结
|
||||
commit_sha : str
|
||||
提交 SHA
|
||||
comment_type : str
|
||||
评论类型 ('grade', 'llm', 'combined')
|
||||
metadata : dict, optional
|
||||
结构化的成绩数据,将嵌入为 JSON
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
完整的评论内容(Markdown + JSON)
|
||||
"""
|
||||
commit_short = commit_sha[:7] if commit_sha else 'unknown'
|
||||
|
||||
# 根据类型设置标题和图标
|
||||
if comment_type == 'llm':
|
||||
title = "🤖 LLM 简答题评分结果"
|
||||
footer = "*此评论由 Gitea Actions 自动生成(使用 DeepSeek API) | Commit: `{}`*"
|
||||
elif comment_type == 'combined':
|
||||
title = "📊 综合评分结果"
|
||||
footer = "*此评论由 Gitea Actions 自动生成 | Commit: `{}`*"
|
||||
else:
|
||||
title = "🤖 自动评分结果"
|
||||
footer = "*此评论由 Gitea Actions 自动生成 | Commit: `{}`*"
|
||||
|
||||
# 构建评论
|
||||
parts = [
|
||||
f"## {title}",
|
||||
"",
|
||||
summary,
|
||||
""
|
||||
]
|
||||
|
||||
# 如果提供了元数据,嵌入 JSON
|
||||
if metadata:
|
||||
# 确保元数据包含版本和时间戳
|
||||
if 'version' not in metadata:
|
||||
metadata['version'] = '1.0'
|
||||
if 'timestamp' not in metadata:
|
||||
metadata['timestamp'] = datetime.now().isoformat()
|
||||
|
||||
# 使用 Markdown 代码块嵌入 JSON(更可靠,Gitea 会保留)
|
||||
# 放在评论末尾,对学生不太显眼
|
||||
json_str = json.dumps(metadata, ensure_ascii=False, indent=2)
|
||||
parts.extend([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"<!-- GRADE_METADATA -->",
|
||||
"```json",
|
||||
json_str,
|
||||
"```",
|
||||
""
|
||||
])
|
||||
|
||||
parts.extend([
|
||||
footer.format(commit_short)
|
||||
])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
# 从环境变量读取配置
|
||||
api_url = os.environ.get('API_URL', '')
|
||||
repo = os.environ.get('REPO', '')
|
||||
pr_number = os.environ.get('PR_NUMBER', '')
|
||||
token = os.environ.get('GITEA_TOKEN', '')
|
||||
summary = os.environ.get('SUMMARY', '')
|
||||
commit_sha = os.environ.get('COMMIT_SHA', '')
|
||||
comment_type = os.environ.get('COMMENT_TYPE', 'grade')
|
||||
|
||||
# 可选:从环境变量读取 JSON 元数据
|
||||
metadata_str = os.environ.get('GRADE_METADATA', '')
|
||||
metadata = None
|
||||
if metadata_str:
|
||||
try:
|
||||
metadata = json.loads(metadata_str)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Warning: Failed to parse GRADE_METADATA: {e}", file=sys.stderr)
|
||||
|
||||
# 验证必需参数
|
||||
if not all([api_url, repo, pr_number, token, summary]):
|
||||
print("Error: Missing required environment variables", file=sys.stderr)
|
||||
print(f"API_URL: {api_url}", file=sys.stderr)
|
||||
print(f"REPO: {repo}", file=sys.stderr)
|
||||
print(f"PR_NUMBER: {pr_number}", file=sys.stderr)
|
||||
print(f"GITEA_TOKEN: {'set' if token else 'not set'}", file=sys.stderr)
|
||||
print(f"SUMMARY: {'set' if summary else 'not set'}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 构建评论内容(包含元数据)
|
||||
comment_body = create_comment_with_metadata(
|
||||
summary=summary,
|
||||
commit_sha=commit_sha,
|
||||
comment_type=comment_type,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# 构建 API URL
|
||||
comment_url = f"{api_url}/repos/{repo}/issues/{pr_number}/comments"
|
||||
|
||||
# 发送请求
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {"body": comment_body}
|
||||
|
||||
try:
|
||||
print(f"Posting comment to: {comment_url}")
|
||||
if metadata:
|
||||
print("✓ Comment includes structured metadata")
|
||||
response = requests.post(comment_url, headers=headers, json=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
print("✅ Comment posted successfully to PR")
|
||||
return 0
|
||||
except requests.exceptions.Timeout:
|
||||
print("⚠️ Request timeout", file=sys.stderr)
|
||||
return 1
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"⚠️ HTTP error: {e}", file=sys.stderr)
|
||||
print(f"Response: {response.text}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to post comment: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
164
.autograde/run_tests.py
Normal file
164
.autograde/run_tests.py
Normal file
@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
通用测试运行器 - 根据语言配置运行测试并生成 JUnit XML
|
||||
|
||||
支持的语言:
|
||||
- python: pytest
|
||||
- java: maven (mvn test)
|
||||
- r: testthat (通过 JUnit Reporter)
|
||||
|
||||
环境变量:
|
||||
- LANGUAGE: 编程语言 (python/java/r)
|
||||
- TEST_DIR: 测试目录路径
|
||||
- SOURCE_DIR: 源代码目录路径
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_python_tests(test_dir, output_xml, **kwargs):
|
||||
"""运行 Python pytest 测试"""
|
||||
cmd = [
|
||||
"pytest", test_dir,
|
||||
f"--junit-xml={output_xml}",
|
||||
"-v", "--tb=short"
|
||||
]
|
||||
|
||||
# 添加覆盖率选项(如果指定)
|
||||
source_dir = kwargs.get('source_dir')
|
||||
if source_dir:
|
||||
cmd.extend([
|
||||
f"--cov={source_dir}",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=json:coverage.json"
|
||||
])
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=False)
|
||||
return result
|
||||
|
||||
|
||||
def run_java_tests(test_dir, output_xml, **kwargs):
|
||||
"""运行 Java Maven 测试"""
|
||||
cmd = ["mvn", "test", "-B"]
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=False)
|
||||
|
||||
# Maven 自动生成 XML 在 target/surefire-reports/
|
||||
# 需要复制到指定的输出位置
|
||||
surefire_dir = Path("target/surefire-reports")
|
||||
if surefire_dir.exists():
|
||||
# 合并所有 TEST-*.xml 文件
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
xml_files = list(surefire_dir.glob("TEST-*.xml"))
|
||||
if xml_files:
|
||||
# 简单情况:只复制第一个(或合并)
|
||||
import shutil
|
||||
if len(xml_files) == 1:
|
||||
shutil.copy(xml_files[0], output_xml)
|
||||
else:
|
||||
# 合并多个 XML 文件(简化版本)
|
||||
root = ET.Element("testsuites")
|
||||
for xml_file in xml_files:
|
||||
tree = ET.parse(xml_file)
|
||||
root.append(tree.getroot())
|
||||
|
||||
tree = ET.ElementTree(root)
|
||||
tree.write(output_xml, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_r_tests(test_dir, output_xml, **kwargs):
|
||||
"""运行 R testthat 测试"""
|
||||
# R 脚本:使用 testthat 的 JUnitReporter
|
||||
# 注意:需要安装 testthat (>= 3.0.0)
|
||||
|
||||
r_script = f"""
|
||||
library(testthat)
|
||||
|
||||
# 配置 JUnit reporter
|
||||
reporter <- JunitReporter$new(file = '{output_xml}')
|
||||
|
||||
# 运行测试
|
||||
test_dir(
|
||||
path = '{test_dir}',
|
||||
reporter = reporter,
|
||||
stop_on_failure = FALSE
|
||||
)
|
||||
"""
|
||||
|
||||
# 将脚本写入临时文件
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.R', delete=False) as f:
|
||||
f.write(r_script)
|
||||
script_path = f.name
|
||||
|
||||
try:
|
||||
cmd = ["Rscript", script_path]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=False)
|
||||
return result
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(script_path):
|
||||
os.remove(script_path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="通用测试运行器 - 支持 Python/Java/R"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--language",
|
||||
required=True,
|
||||
choices=["python", "java", "r"],
|
||||
help="编程语言"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-dir",
|
||||
required=True,
|
||||
help="测试目录路径"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-xml",
|
||||
default="test-results.xml",
|
||||
help="JUnit XML 输出文件路径"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-dir",
|
||||
help="源代码目录(用于覆盖率)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 语言对应的运行器
|
||||
runners = {
|
||||
"python": run_python_tests,
|
||||
"java": run_java_tests,
|
||||
"r": run_r_tests,
|
||||
}
|
||||
|
||||
if args.language not in runners:
|
||||
print(f"❌ Unsupported language: {args.language}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 运行测试
|
||||
result = runners[args.language](
|
||||
args.test_dir,
|
||||
args.output_xml,
|
||||
source_dir=args.source_dir
|
||||
)
|
||||
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
95
.autograde/test_objective_grade.sh
Normal file
95
.autograde/test_objective_grade.sh
Normal file
@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# 测试客观题评分脚本
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 测试客观题评分脚本 ==="
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 测试 1: 使用 JSON 格式答案
|
||||
echo ""
|
||||
echo "测试 1: JSON 格式答案(全对)"
|
||||
python3 ./.autograde/objective_grade.py \
|
||||
--answers objective_questions/standard_answers.json \
|
||||
--standard objective_questions/standard_answers.json \
|
||||
--questions objective_questions/question_texts.json \
|
||||
--out test_grade1.json \
|
||||
--summary test_summary1.md \
|
||||
--type both
|
||||
|
||||
echo "分数:"
|
||||
python3 -c "import json; data=json.load(open('test_grade1.json')); print(f\"{data['score']}/{data['max_score']}\")"
|
||||
|
||||
echo ""
|
||||
echo "摘要:"
|
||||
cat test_summary1.md
|
||||
|
||||
# 测试 2: 使用部分错误的答案
|
||||
echo ""
|
||||
echo "测试 2: 部分错误答案"
|
||||
cat > test_answers2.json << 'EOF'
|
||||
{
|
||||
"MC1": "A",
|
||||
"MC2": "A",
|
||||
"MC3": "C",
|
||||
"MC4": "B",
|
||||
"MC5": "C",
|
||||
"TF1": true,
|
||||
"TF2": false,
|
||||
"TF3": true,
|
||||
"TF4": true,
|
||||
"TF5": false
|
||||
}
|
||||
EOF
|
||||
|
||||
python3 ./.autograde/objective_grade.py \
|
||||
--answers test_answers2.json \
|
||||
--standard objective_questions/standard_answers.json \
|
||||
--questions objective_questions/question_texts.json \
|
||||
--out test_grade2.json \
|
||||
--summary test_summary2.md \
|
||||
--type both
|
||||
|
||||
echo "分数:"
|
||||
python3 -c "import json; data=json.load(open('test_grade2.json')); print(f\"{data['score']}/{data['max_score']}\")"
|
||||
|
||||
echo ""
|
||||
echo "摘要:"
|
||||
cat test_summary2.md
|
||||
|
||||
# 测试 3: 只评选择题
|
||||
echo ""
|
||||
echo "测试 3: 只评选择题"
|
||||
python3 ./.autograde/objective_grade.py \
|
||||
--answers objective_questions/standard_answers.json \
|
||||
--standard objective_questions/standard_answers.json \
|
||||
--questions objective_questions/question_texts.json \
|
||||
--out test_grade3.json \
|
||||
--summary test_summary3.md \
|
||||
--type mc
|
||||
|
||||
echo "分数:"
|
||||
python3 -c "import json; data=json.load(open('test_grade3.json')); print(f\"{data['score']}/{data['max_score']}\")"
|
||||
|
||||
# 测试 4: 只评判断题
|
||||
echo ""
|
||||
echo "测试 4: 只评判断题"
|
||||
python3 ./.autograde/objective_grade.py \
|
||||
--answers objective_questions/standard_answers.json \
|
||||
--standard objective_questions/standard_answers.json \
|
||||
--questions objective_questions/question_texts.json \
|
||||
--out test_grade4.json \
|
||||
--summary test_summary4.md \
|
||||
--type tf
|
||||
|
||||
echo "分数:"
|
||||
python3 -c "import json; data=json.load(open('test_grade4.json')); print(f\"{data['score']}/{data['max_score']}\")"
|
||||
|
||||
# 清理测试文件
|
||||
rm -f test_grade*.json test_summary*.md test_answers*.json
|
||||
|
||||
echo ""
|
||||
echo "✅ 所有测试通过!"
|
||||
|
||||
|
||||
150
.autograde/upload_metadata.py
Normal file
150
.autograde/upload_metadata.py
Normal file
@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Upload metadata.json to teacher-only repository via Gitea API.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def detect_host(server_url: str, external_host: str | None) -> str:
|
||||
"""Detect the Gitea host to use for API calls.
|
||||
|
||||
If server_url uses internal name (like 'gitea'), use external_host instead.
|
||||
"""
|
||||
parsed = urlparse(server_url)
|
||||
raw_host = parsed.netloc or parsed.path.split("/")[0]
|
||||
host = raw_host
|
||||
if raw_host.lower().startswith("gitea"):
|
||||
if not external_host:
|
||||
raise ValueError(
|
||||
f"Server URL uses internal name '{raw_host}' but EXTERNAL_GITEA_HOST is not set. "
|
||||
"Please configure EXTERNAL_GITEA_HOST in .env and run sync_runner_config.sh"
|
||||
)
|
||||
host = external_host
|
||||
return host
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Upload metadata.json to course metadata repo")
|
||||
parser.add_argument("--metadata-file", required=True)
|
||||
parser.add_argument("--metadata-repo", required=True, help="owner/repo of metadata store")
|
||||
parser.add_argument("--branch", default="main")
|
||||
parser.add_argument("--student-repo", required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--commit-sha", required=True)
|
||||
parser.add_argument("--workflow", required=True, choices=["grade", "objective", "llm"])
|
||||
parser.add_argument("--server-url", required=True)
|
||||
parser.add_argument("--external-host")
|
||||
parser.add_argument("--assignment-id", help="Assignment ID (e.g., hw1)")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("METADATA_TOKEN")
|
||||
if not token:
|
||||
print("METADATA_TOKEN is not set", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
path = Path(args.metadata_file)
|
||||
if not path.is_file():
|
||||
print(f"metadata file not found: {path}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
try:
|
||||
owner, repo_name = args.metadata_repo.split("/", 1)
|
||||
except ValueError:
|
||||
print(f"Invalid metadata repo: {args.metadata_repo}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Extract student ID from student repo name
|
||||
# student repo format: hw1-stu_20250001 or hw1-stu_student1
|
||||
student_id = args.student_repo.split("/")[-1] # Get repo name
|
||||
|
||||
# Auto-detect assignment ID from student repo if not provided
|
||||
assignment_id = args.assignment_id
|
||||
if not assignment_id:
|
||||
# Try to extract from student_repo format: hw1-stu_xxx
|
||||
repo_name_part = args.student_repo.split("/")[-1]
|
||||
if "-stu_" in repo_name_part:
|
||||
assignment_id = repo_name_part.split("-stu_")[0]
|
||||
elif "-template" in repo_name_part:
|
||||
assignment_id = repo_name_part.split("-template")[0]
|
||||
elif "-tests" in repo_name_part:
|
||||
assignment_id = repo_name_part.split("-tests")[0]
|
||||
else:
|
||||
assignment_id = "unknown"
|
||||
|
||||
# New path structure: {assignment_id}/{student_id}/{workflow}_{run_id}_{sha}.json
|
||||
target_path = f"{assignment_id}/{student_id}/{args.workflow}_{args.run_id}_{args.commit_sha[:7]}.json"
|
||||
|
||||
host = detect_host(args.server_url, args.external_host)
|
||||
api_url = f"http://{host}/api/v1/repos/{owner}/{repo_name}/contents/{target_path}"
|
||||
message = f"Upload {args.workflow} metadata for {args.student_repo} {args.commit_sha}"
|
||||
|
||||
# Check if file exists to determine if we need to update (PUT) or create (POST)
|
||||
get_req = urllib.request.Request(
|
||||
api_url,
|
||||
headers={"Authorization": f"token {token}"},
|
||||
method="GET"
|
||||
)
|
||||
|
||||
sha = None
|
||||
try:
|
||||
with urllib.request.urlopen(get_req) as resp:
|
||||
existing_file = json.loads(resp.read().decode())
|
||||
sha = existing_file.get("sha")
|
||||
print(f"File exists, updating (sha: {sha})")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
print(f"Error checking file existence: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# File doesn't exist, proceed with creation
|
||||
|
||||
content = base64.b64encode(path.read_bytes()).decode()
|
||||
payload = {
|
||||
"content": content,
|
||||
"message": message,
|
||||
"branch": args.branch
|
||||
}
|
||||
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
|
||||
data = json.dumps(payload).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
api_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="PUT" if sha else "POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp_body = resp.read().decode()
|
||||
print(resp_body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"Metadata upload failed: {exc.status} {exc.reason}", file=sys.stderr)
|
||||
print(exc.read().decode(), file=sys.stderr)
|
||||
return 1
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"Metadata upload failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"✅ Metadata stored at {args.metadata_repo}:{target_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
216
.autograde/workflow_templates/README.md
Normal file
216
.autograde/workflow_templates/README.md
Normal file
@ -0,0 +1,216 @@
|
||||
# Workflow 模板
|
||||
|
||||
本目录包含不同编程语言的 Gitea Actions workflow 模板。
|
||||
|
||||
## 可用模板
|
||||
|
||||
| 文件 | 语言 | 容器 | 测试框架 |
|
||||
|------|------|------|----------|
|
||||
| `python.yml` | Python | python:3.11 | pytest |
|
||||
| `java.yml` | Java | maven:3.9-eclipse-temurin-17 | JUnit 5 |
|
||||
| `r.yml` | R | r-base:4.3 | testthat |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 选择模板
|
||||
|
||||
根据你的编程语言选择对应的模板:
|
||||
|
||||
```bash
|
||||
# 对于 Python 作业
|
||||
cp .autograde/workflow_templates/python.yml .gitea/workflows/grade.yml
|
||||
|
||||
# 对于 Java 作业
|
||||
cp .autograde/workflow_templates/java.yml .gitea/workflows/grade.yml
|
||||
|
||||
# 对于 R 作业
|
||||
cp .autograde/workflow_templates/r.yml .gitea/workflows/grade.yml
|
||||
```
|
||||
|
||||
### 2. 自定义配置
|
||||
|
||||
编辑 `.gitea/workflows/grade.yml` 根据需要修改:
|
||||
|
||||
- **容器版本**:修改 `container:` 字段
|
||||
- **超时时间**:修改 `timeout-minutes:`
|
||||
- **依赖安装**:修改 "Install dependencies" 步骤
|
||||
- **测试命令**:修改测试运行步骤
|
||||
|
||||
### 3. 配置 Secrets
|
||||
|
||||
确保在 Gitea 仓库设置中配置了以下 Secrets:
|
||||
|
||||
- `TESTS_TOKEN`:用于访问隐藏测试仓库的 token(可选)
|
||||
- `EXTERNAL_GITEA_HOST`:外部访问的 Gitea 地址(可选)
|
||||
|
||||
## Python 模板 (python.yml)
|
||||
|
||||
### 特点
|
||||
- 使用 `python:3.11` 容器
|
||||
- 自动安装 `requirements.txt` 中的依赖
|
||||
- 使用 `run_tests.py` 运行 pytest
|
||||
- 支持代码覆盖率
|
||||
|
||||
### 自定义选项
|
||||
```yaml
|
||||
# 修改 Python 版本
|
||||
container: python:3.10 # 或 python:3.9
|
||||
|
||||
# 添加额外的依赖
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install numpy pandas # 额外的包
|
||||
```
|
||||
|
||||
## Java 模板 (java.yml)
|
||||
|
||||
### 特点
|
||||
- 使用 `maven:3.9-eclipse-temurin-17` 容器
|
||||
- Maven 自动管理依赖(通过 `pom.xml`)
|
||||
- JUnit 5 测试框架
|
||||
- 自动提取 Surefire 报告
|
||||
|
||||
### 自定义选项
|
||||
```yaml
|
||||
# 修改 JDK 版本
|
||||
container: maven:3.9-eclipse-temurin-11 # Java 11
|
||||
container: maven:3.9-eclipse-temurin-21 # Java 21
|
||||
|
||||
# 自定义 Maven 命令
|
||||
run: |
|
||||
mvn clean test -B -DskipTests=false
|
||||
```
|
||||
|
||||
### Maven 配置提示
|
||||
|
||||
确保 `pom.xml` 中配置了 Surefire 插件:
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.2</version>
|
||||
<configuration>
|
||||
<reportsDirectory>${project.build.directory}/surefire-reports</reportsDirectory>
|
||||
</configuration>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
## R 模板 (r.yml)
|
||||
|
||||
### 特点
|
||||
- 使用 `r-base:4.3` 容器
|
||||
- 自动从 `DESCRIPTION` 安装依赖
|
||||
- testthat 测试框架
|
||||
- JUnitReporter 输出 XML
|
||||
|
||||
### 自定义选项
|
||||
```yaml
|
||||
# 修改 R 版本
|
||||
container: r-base:4.2 # 或其他版本
|
||||
|
||||
# 修改 CRAN 镜像
|
||||
run: |
|
||||
Rscript -e "install.packages('testthat', repos='https://cran.r-project.org/')"
|
||||
```
|
||||
|
||||
### R 项目结构要求
|
||||
|
||||
```
|
||||
project/
|
||||
├── DESCRIPTION # 包依赖定义
|
||||
├── R/ # R 源代码
|
||||
└── tests/
|
||||
└── testthat/ # testthat 测试
|
||||
```
|
||||
|
||||
## 通用 Workflow 流程
|
||||
|
||||
所有模板都遵循相同的流程:
|
||||
|
||||
1. **安装系统依赖**(git, rsync 等)
|
||||
2. **检出代码** - 克隆学生仓库
|
||||
3. **安装语言依赖** - 根据语言安装包
|
||||
4. **获取隐藏测试**(可选)- 从私有仓库获取
|
||||
5. **运行测试** - 生成 JUnit XML
|
||||
6. **评分** - 解析 XML,计算分数
|
||||
7. **生成元数据** - 创建 JSON metadata
|
||||
8. **发布评论** - 在 PR 中发布结果
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 添加代码质量检查
|
||||
|
||||
```yaml
|
||||
- name: Run linter
|
||||
run: |
|
||||
# Python: pylint, flake8
|
||||
pip install pylint
|
||||
pylint src/
|
||||
|
||||
# Java: checkstyle
|
||||
mvn checkstyle:check
|
||||
|
||||
# R: lintr
|
||||
Rscript -e "lintr::lint_package()"
|
||||
```
|
||||
|
||||
### 自定义评分规则
|
||||
|
||||
修改 `grade.py` 的调用参数:
|
||||
|
||||
```yaml
|
||||
- name: Grade
|
||||
run: |
|
||||
python3 ./.autograde/grade.py \
|
||||
--junit junit.xml \
|
||||
--out grade.json \
|
||||
--summary summary.md \
|
||||
--bonus bonus.json # 可选的加分项
|
||||
```
|
||||
|
||||
### 多个测试套件
|
||||
|
||||
```yaml
|
||||
- name: Run public tests
|
||||
run: |
|
||||
pytest tests_public/ --junit-xml=public.xml
|
||||
|
||||
- name: Run hidden tests
|
||||
run: |
|
||||
pytest tests_hidden/ --junit-xml=hidden.xml
|
||||
|
||||
- name: Merge test results
|
||||
run: |
|
||||
python3 ./.autograde/merge_junit.py public.xml hidden.xml -o junit.xml
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 测试无法运行
|
||||
- 检查测试目录路径是否正确
|
||||
- 确认依赖是否正确安装
|
||||
- 查看 Actions 日志中的错误信息
|
||||
|
||||
### JUnit XML 未生成
|
||||
- Python: 确保 pytest 命令包含 `--junit-xml`
|
||||
- Java: 检查 Surefire 插件配置
|
||||
- R: 确认 testthat >= 3.0.0
|
||||
|
||||
### 元数据为空
|
||||
- 检查 `grade.json` 是否生成
|
||||
- 确认 `LANGUAGE` 环境变量设置正确
|
||||
- 查看 `create_minimal_metadata.py` 的输出
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [运行测试脚本](../run_tests.py) - 通用测试运行器
|
||||
- [评分脚本](../grade.py) - JUnit XML 解析和评分
|
||||
- [元数据生成](../create_minimal_metadata.py) - JSON 元数据
|
||||
- [示例](../../examples/) - 各语言的完整示例
|
||||
|
||||
---
|
||||
|
||||
最后更新: 2025-11-13
|
||||
|
||||
200
.autograde/workflow_templates/java.yml
Normal file
200
.autograde/workflow_templates/java.yml
Normal file
@ -0,0 +1,200 @@
|
||||
name: autograde-java
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
java:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: gradle:9.0-jdk21
|
||||
options: --user root
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Install dependencies (CN mirror)
|
||||
run: |
|
||||
set -e
|
||||
# 替换 Debian/Ubuntu 源为腾讯云镜像
|
||||
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
|
||||
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
done
|
||||
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync
|
||||
pip3 install --break-system-packages python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Fix permissions
|
||||
run: |
|
||||
# Ensure workspace is owned by current user
|
||||
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||
|
||||
- name: Fetch hidden tests (if available)
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
|
||||
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
|
||||
|
||||
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
|
||||
echo "Warning: RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set, skipping private tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve Gitea Host
|
||||
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
|
||||
HOST="$EXTERNAL_GITEA_HOST"
|
||||
elif [ -n "$GITEA_ROOT_URL" ]; then
|
||||
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
|
||||
else
|
||||
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
echo "📥 Fetching private tests repository..."
|
||||
echo " Gitea host: $HOST"
|
||||
|
||||
# Infer organization and assignment ID from repository name
|
||||
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
|
||||
|
||||
# Extract assignment ID from repo name (e.g., hw2-stu_xxx -> hw2, hw2-template -> hw2)
|
||||
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
|
||||
elif echo "$REPO_NAME" | grep -q -- '-template'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
|
||||
else
|
||||
ASSIGNMENT_ID="hw1" # fallback
|
||||
fi
|
||||
|
||||
echo " Organization: $ORG"
|
||||
echo " Assignment ID: $ASSIGNMENT_ID"
|
||||
|
||||
# Clone private test repository
|
||||
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
|
||||
|
||||
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
|
||||
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify test directory exists
|
||||
if [ ! -d "_priv_tests/java" ]; then
|
||||
echo "❌ java/ directory not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "_priv_tests/java/src/test" ]; then
|
||||
echo "❌ java/src/test/ not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy tests to src/test/
|
||||
rsync -a _priv_tests/java/src/test/ src/test/
|
||||
echo "✅ Private tests copied: _priv_tests/java/src/test/ → src/test/"
|
||||
|
||||
- name: Run tests using Gradle
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LANGUAGE: java
|
||||
run: |
|
||||
gradle test --no-daemon || true
|
||||
|
||||
- name: Extract test results
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Find JUnit XML report
|
||||
XML_REPORT=$(find build/test-results/test -name "TEST-*.xml" | head -n 1)
|
||||
if [ -n "$XML_REPORT" ]; then
|
||||
cp "$XML_REPORT" junit.xml
|
||||
echo "✅ Found JUnit report: $XML_REPORT"
|
||||
else
|
||||
echo "⚠️ No JUnit report found!"
|
||||
touch junit.xml
|
||||
fi
|
||||
|
||||
- name: Grade
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LANGUAGE: java
|
||||
run: |
|
||||
python3 ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
|
||||
|
||||
- name: Prepare artifacts
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
|
||||
|
||||
- name: Create grade metadata
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LANGUAGE: java
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ ! -f grade.json ]; then
|
||||
echo "⚠️ grade.json not found, skipping metadata creation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 生成 JSON 元数据
|
||||
if [ -f ./.autograde/create_grade_metadata.py ]; then
|
||||
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_grade_metadata.py)"
|
||||
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
|
||||
export GRADE_TYPE=programming
|
||||
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
|
||||
else
|
||||
echo "⚠️ No metadata creation script found, skipping"
|
||||
echo "{}" > metadata.json
|
||||
fi
|
||||
|
||||
- name: Upload metadata (teacher only)
|
||||
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
env:
|
||||
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
|
||||
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||
STUDENT_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -e
|
||||
if [ ! -f metadata.json ]; then
|
||||
echo "No metadata.json found, skip uploading."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 ./.autograde/upload_metadata.py \
|
||||
--metadata-file metadata.json \
|
||||
--metadata-repo "${METADATA_REPO}" \
|
||||
--branch "${METADATA_BRANCH:-main}" \
|
||||
--student-repo "${STUDENT_REPO}" \
|
||||
--run-id "${RUN_ID}" \
|
||||
--commit-sha "${COMMIT_SHA}" \
|
||||
--workflow grade \
|
||||
--server-url "${SERVER_URL}" \
|
||||
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||
rm -f metadata.json
|
||||
206
.autograde/workflow_templates/python.yml
Normal file
206
.autograde/workflow_templates/python.yml
Normal file
@ -0,0 +1,206 @@
|
||||
name: autograde-python
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
python:
|
||||
runs-on: docker
|
||||
container: python:3.11
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Install dependencies (CN mirror)
|
||||
run: |
|
||||
set -e
|
||||
# 替换 Debian/Ubuntu 源为腾讯云镜像
|
||||
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
|
||||
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
done
|
||||
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Fix permissions
|
||||
run: |
|
||||
# Ensure workspace is owned by current user
|
||||
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||
|
||||
- name: Install Python deps
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# 使用腾讯云镜像源加速
|
||||
python -m pip install -U pip -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt -i https://mirrors.cloud.tencent.com/pypi/simple; fi
|
||||
if [ -f pyproject.toml ]; then pip install . -i https://mirrors.cloud.tencent.com/pypi/simple; fi
|
||||
pip install pytest pytest-cov junit-xml python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||
|
||||
- name: Fetch private tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
|
||||
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
|
||||
|
||||
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
|
||||
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN 未配置!"
|
||||
echo "测试必须从私有的 tests 仓库获取"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve Gitea Host
|
||||
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
|
||||
HOST="$EXTERNAL_GITEA_HOST"
|
||||
elif [ -n "$GITEA_ROOT_URL" ]; then
|
||||
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
|
||||
else
|
||||
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
echo "📥 Fetching private tests repository..."
|
||||
echo " Gitea host: $HOST"
|
||||
|
||||
# Infer organization and assignment ID from repository name
|
||||
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
|
||||
|
||||
# Extract assignment ID from repo name (e.g., hw1-stu_xxx -> hw1, hw1-template -> hw1)
|
||||
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
|
||||
elif echo "$REPO_NAME" | grep -q -- '-template'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
|
||||
else
|
||||
ASSIGNMENT_ID="hw1" # fallback
|
||||
fi
|
||||
|
||||
echo " Organization: $ORG"
|
||||
echo " Assignment ID: $ASSIGNMENT_ID"
|
||||
|
||||
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
|
||||
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
|
||||
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证测试目录存在
|
||||
if [ ! -d "_priv_tests/python" ]; then
|
||||
echo "❌ python/ directory not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "_priv_tests/python/tests" ]; then
|
||||
echo "❌ python/tests/ not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 复制测试到 tests/
|
||||
mkdir -p tests
|
||||
rsync -a _priv_tests/python/tests/ tests/
|
||||
echo "✅ Tests copied: _priv_tests/python/tests/ → tests/"
|
||||
|
||||
# 复制数据文件(如果存在)
|
||||
if [ -d "_priv_tests/python/data" ]; then
|
||||
mkdir -p tests/data
|
||||
rsync -a _priv_tests/python/data/ tests/data/
|
||||
echo "✅ Data files copied: _priv_tests/python/data/ → tests/data/"
|
||||
fi
|
||||
|
||||
# 验证测试文件
|
||||
if [ -z "$(find tests -name 'test_*.py' 2>/dev/null)" ]; then
|
||||
echo "❌ No test files found in tests/ directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Test suite ready:"
|
||||
find tests -name 'test_*.py'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# 设置随机种子
|
||||
export PYTHONHASHSEED=2025
|
||||
pytest -q --maxfail=0 --junitxml=junit.xml --tb=short || true
|
||||
|
||||
- name: Grade
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
|
||||
|
||||
- name: Prepare artifacts
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
|
||||
|
||||
- name: Create grade metadata
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ ! -f grade.json ]; then
|
||||
echo "⚠️ grade.json not found, skipping metadata creation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 生成 JSON 元数据
|
||||
if [ -f ./.autograde/create_grade_metadata.py ]; then
|
||||
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_grade_metadata.py)"
|
||||
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
|
||||
export GRADE_TYPE=programming
|
||||
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
|
||||
else
|
||||
echo "⚠️ No metadata creation script found, skipping"
|
||||
echo "{}" > metadata.json
|
||||
fi
|
||||
|
||||
- name: Upload metadata (teacher only)
|
||||
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
env:
|
||||
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
|
||||
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||
STUDENT_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -e
|
||||
if [ ! -f metadata.json ]; then
|
||||
echo "No metadata.json found, skip uploading."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python ./.autograde/upload_metadata.py \
|
||||
--metadata-file metadata.json \
|
||||
--metadata-repo "${METADATA_REPO}" \
|
||||
--branch "${METADATA_BRANCH:-main}" \
|
||||
--student-repo "${STUDENT_REPO}" \
|
||||
--run-id "${RUN_ID}" \
|
||||
--commit-sha "${COMMIT_SHA}" \
|
||||
--workflow grade \
|
||||
--server-url "${SERVER_URL}" \
|
||||
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||
rm -f metadata.json
|
||||
200
.autograde/workflow_templates/r.yml
Normal file
200
.autograde/workflow_templates/r.yml
Normal file
@ -0,0 +1,200 @@
|
||||
name: autograde-r
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
r:
|
||||
runs-on: docker
|
||||
container: r-base:4.3.1
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Install dependencies (CN mirror)
|
||||
run: |
|
||||
set -e
|
||||
# 替换 Debian/Ubuntu 源为腾讯云镜像
|
||||
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
|
||||
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
done
|
||||
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync libcurl4-openssl-dev libssl-dev libxml2-dev
|
||||
pip3 install --break-system-packages python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Configure CRAN Mirror
|
||||
run: |
|
||||
echo 'options(repos = c(CRAN = "https://mirrors.tuna.tsinghua.edu.cn/CRAN/"))' >> ~/.Rprofile
|
||||
|
||||
- name: Install R packages
|
||||
run: |
|
||||
Rscript -e 'install.packages(c("testthat", "covr", "xml2"))'
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Fix permissions
|
||||
run: |
|
||||
# Ensure workspace is owned by current user
|
||||
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||
|
||||
- name: Fetch private tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
|
||||
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
|
||||
|
||||
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
|
||||
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN 未配置!"
|
||||
echo "测试必须从私有的 tests 仓库获取"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve Gitea Host
|
||||
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
|
||||
HOST="$EXTERNAL_GITEA_HOST"
|
||||
elif [ -n "$GITEA_ROOT_URL" ]; then
|
||||
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
|
||||
else
|
||||
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
echo "📥 Fetching private tests repository..."
|
||||
echo " Gitea host: $HOST"
|
||||
|
||||
# Infer organization and assignment ID from repository name
|
||||
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
|
||||
|
||||
# Extract assignment ID from repo name (e.g., hw1-stu_xxx -> hw1, hw1-template -> hw1)
|
||||
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
|
||||
elif echo "$REPO_NAME" | grep -q -- '-template'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
|
||||
else
|
||||
ASSIGNMENT_ID="hw1" # fallback
|
||||
fi
|
||||
|
||||
echo " Organization: $ORG"
|
||||
echo " Assignment ID: $ASSIGNMENT_ID"
|
||||
|
||||
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
|
||||
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
|
||||
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证测试目录存在
|
||||
if [ ! -d "_priv_tests/r" ]; then
|
||||
echo "❌ r/ directory not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "_priv_tests/r/tests" ]; then
|
||||
echo "❌ r/tests/ not found in ${ASSIGNMENT_ID}-tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 复制测试到 tests/
|
||||
mkdir -p tests
|
||||
rsync -a _priv_tests/r/tests/ tests/
|
||||
echo "✅ Tests copied: _priv_tests/r/tests/ → tests/"
|
||||
|
||||
# 验证测试文件
|
||||
if [ -z "$(find tests -name 'test_*.R' 2>/dev/null)" ]; then
|
||||
echo "❌ No test files found in tests/ directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Test suite ready:"
|
||||
find tests -name 'test_*.R'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
Rscript -e 'library(testthat); test_dir("tests", reporter = JunitReporter$new(file = "junit.xml"))' || true
|
||||
|
||||
- name: Grade
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LANGUAGE: r
|
||||
run: |
|
||||
python3 ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
|
||||
|
||||
- name: Prepare artifacts
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
|
||||
|
||||
- name: Create grade metadata
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LANGUAGE: r
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ ! -f grade.json ]; then
|
||||
echo "⚠️ grade.json not found, skipping metadata creation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 生成 JSON 元数据
|
||||
if [ -f ./.autograde/create_grade_metadata.py ]; then
|
||||
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_grade_metadata.py)"
|
||||
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
|
||||
export GRADE_TYPE=programming
|
||||
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
|
||||
else
|
||||
echo "⚠️ No metadata creation script found, skipping"
|
||||
echo "{}" > metadata.json
|
||||
fi
|
||||
|
||||
- name: Upload metadata (teacher only)
|
||||
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
env:
|
||||
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
|
||||
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||
STUDENT_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -e
|
||||
if [ ! -f metadata.json ]; then
|
||||
echo "No metadata.json found, skip uploading."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 ./.autograde/upload_metadata.py \
|
||||
--metadata-file metadata.json \
|
||||
--metadata-repo "${METADATA_REPO}" \
|
||||
--branch "${METADATA_BRANCH:-main}" \
|
||||
--student-repo "${STUDENT_REPO}" \
|
||||
--run-id "${RUN_ID}" \
|
||||
--commit-sha "${COMMIT_SHA}" \
|
||||
--workflow grade \
|
||||
--server-url "${SERVER_URL}" \
|
||||
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||
rm -f metadata.json
|
||||
215
.gitea/workflows/autograde.yml
Normal file
215
.gitea/workflows/autograde.yml
Normal file
@ -0,0 +1,215 @@
|
||||
name: autograde-final-vibevault
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
grade:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: gradle:9.0-jdk21
|
||||
options: --user root
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Install dependencies (CN mirror)
|
||||
run: |
|
||||
set -e
|
||||
# 替换 Debian/Ubuntu 源为腾讯云镜像
|
||||
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
|
||||
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
|
||||
done
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync
|
||||
pip3 install --break-system-packages python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Fix permissions
|
||||
run: chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||
|
||||
- name: Fetch hidden tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
|
||||
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
|
||||
|
||||
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
|
||||
echo "Warning: RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set, skipping private tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve Gitea Host
|
||||
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
|
||||
HOST="$EXTERNAL_GITEA_HOST"
|
||||
elif [ -n "$GITEA_ROOT_URL" ]; then
|
||||
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
|
||||
else
|
||||
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
|
||||
|
||||
# Extract assignment ID
|
||||
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
|
||||
elif echo "$REPO_NAME" | grep -q -- '-template'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
|
||||
else
|
||||
ASSIGNMENT_ID="final-vibevault"
|
||||
fi
|
||||
|
||||
echo "📥 Fetching private tests from ${ORG}/${ASSIGNMENT_ID}-tests..."
|
||||
|
||||
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
|
||||
|
||||
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
|
||||
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy Java tests
|
||||
if [ -d "_priv_tests/java/src/test" ]; then
|
||||
rsync -a _priv_tests/java/src/test/ src/test/
|
||||
echo "✅ Private tests copied"
|
||||
fi
|
||||
|
||||
# Copy test_groups.json if exists
|
||||
if [ -f "_priv_tests/test_groups.json" ]; then
|
||||
cp _priv_tests/test_groups.json .
|
||||
echo "✅ test_groups.json copied"
|
||||
fi
|
||||
|
||||
# Copy LLM rubrics
|
||||
if [ -d "_priv_tests/llm" ]; then
|
||||
mkdir -p .llm_rubrics
|
||||
cp _priv_tests/llm/*.json .llm_rubrics/ 2>/dev/null || true
|
||||
echo "✅ LLM rubrics copied"
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
gradle test --no-daemon || true
|
||||
|
||||
# Collect all JUnit XML reports
|
||||
find build/test-results/test -name "TEST-*.xml" -exec cat {} \; > all_tests.xml 2>/dev/null || true
|
||||
|
||||
# Also try to get a single combined report
|
||||
if [ -f build/test-results/test/TEST-*.xml ]; then
|
||||
cp build/test-results/test/TEST-*.xml junit.xml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Grade programming tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Use extended grading script with group support
|
||||
python3 ./.autograde/grade_grouped.py \
|
||||
--junit-dir build/test-results/test \
|
||||
--groups test_groups.json \
|
||||
--out grade.json \
|
||||
--summary summary.md
|
||||
|
||||
- name: Grade REPORT.md
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_API_URL: ${{ secrets.LLM_API_URL }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
run: |
|
||||
if [ -f REPORT.md ] && [ -f .llm_rubrics/rubric_report.json ]; then
|
||||
python3 ./.autograde/llm_grade.py \
|
||||
--question "请评估这份后端与系统设计报告" \
|
||||
--answer REPORT.md \
|
||||
--rubric .llm_rubrics/rubric_report.json \
|
||||
--out report_grade.json \
|
||||
--summary report_summary.md
|
||||
echo "✅ REPORT.md graded"
|
||||
else
|
||||
echo '{"total": 0, "flags": ["missing_file"]}' > report_grade.json
|
||||
echo "⚠️ REPORT.md or rubric not found"
|
||||
fi
|
||||
|
||||
- name: Grade FRONTEND.md
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_API_URL: ${{ secrets.LLM_API_URL }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
run: |
|
||||
if [ -f FRONTEND.md ] && [ -f .llm_rubrics/rubric_frontend.json ]; then
|
||||
python3 ./.autograde/llm_grade.py \
|
||||
--question "请评估这份前端界面与交互设计报告" \
|
||||
--answer FRONTEND.md \
|
||||
--rubric .llm_rubrics/rubric_frontend.json \
|
||||
--out frontend_grade.json \
|
||||
--summary frontend_summary.md
|
||||
echo "✅ FRONTEND.md graded"
|
||||
else
|
||||
echo '{"total": 0, "flags": ["missing_file"]}' > frontend_grade.json
|
||||
echo "⚠️ FRONTEND.md or rubric not found"
|
||||
fi
|
||||
|
||||
- name: Aggregate grades
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python3 ./.autograde/aggregate_final_grade.py \
|
||||
--programming grade.json \
|
||||
--report report_grade.json \
|
||||
--frontend frontend_grade.json \
|
||||
--out final_grade.json \
|
||||
--summary final_summary.md
|
||||
|
||||
- name: Create metadata
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -f final_grade.json ]; then
|
||||
export GRADE_TYPE=final
|
||||
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
fi
|
||||
|
||||
- name: Upload metadata
|
||||
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
|
||||
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||
STUDENT_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
if [ -f metadata.json ]; then
|
||||
python3 ./.autograde/upload_metadata.py \
|
||||
--metadata-file metadata.json \
|
||||
--metadata-repo "${METADATA_REPO}" \
|
||||
--branch "${METADATA_BRANCH:-main}" \
|
||||
--student-repo "${STUDENT_REPO}" \
|
||||
--run-id "${RUN_ID}" \
|
||||
--commit-sha "${COMMIT_SHA}" \
|
||||
--workflow grade \
|
||||
--server-url "${SERVER_URL}" \
|
||||
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||
fi
|
||||
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Application
|
||||
*.log
|
||||
data/
|
||||
|
||||
# Test outputs (keep for local debugging)
|
||||
# junit.xml
|
||||
# grade.json
|
||||
# summary.md
|
||||
|
||||
69
FRONTEND.md
Normal file
69
FRONTEND.md
Normal file
@ -0,0 +1,69 @@
|
||||
# VibeVault 期末大作业报告:前端界面与交互设计
|
||||
|
||||
> 本报告专门用于说明你实现的前端界面与交互设计,建议 800–1500 字。请与 `REPORT.md` 一起提交。
|
||||
|
||||
## 1. 整体概览与使用流程
|
||||
|
||||
- 简要说明你的前端技术栈(例如:Vite + React + 某 UI 库等),以及项目目录的基本结构。
|
||||
- 描述一个典型用户从打开页面到完成核心任务的完整流程,例如:
|
||||
- 登录 → 查看歌单列表 → 新建歌单 → 向歌单添加歌曲 → 删除歌曲/歌单。
|
||||
- 用 2–3 句话概括你的前端设计目标(例如:强调简单易用、突出关键操作、减少页面跳转等)。
|
||||
|
||||
## 2. 关键界面截图与说明
|
||||
|
||||
请通过 Markdown 语法在本节插入若干张截图(建议 3–6 张),每张截图下方都要有对应说明。示例:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
建议覆盖以下场景(可按实际实现情况调整):
|
||||
|
||||
1. **播放列表总览页面**
|
||||
- 标明页面上最重要的区域(例如:导航栏、歌单列表、主要操作按钮)。
|
||||
- 说明用户在该界面可以完成哪些操作,这些操作对应了哪些后端 API(如 `GET /api/playlists`、`POST /api/playlists`)。
|
||||
|
||||
2. **创建/编辑歌单页面或对话框**
|
||||
- 说明表单字段设计(必填项、可选项)以及你如何做输入校验和错误提示。
|
||||
|
||||
3. **歌曲列表/详情页面**
|
||||
- 如有实现,说明歌曲列表如何展示、如何添加/删除歌曲。
|
||||
|
||||
4. **加载中 / 错误 / 空状态界面**
|
||||
- 至少展示一种状态;说明你是如何在 UI 层面向用户反馈"正在加载""出现错误""暂无数据"等信息。
|
||||
|
||||
> 要求:每张截图下方必须有简短文字说明,让不运行代码的读者也能理解界面意图与交互流程。
|
||||
|
||||
## 3. 组件划分与状态管理
|
||||
|
||||
- 描述你是如何划分前端组件层次的:
|
||||
- 哪些是页面级组件?哪些是可复用的通用组件(如按钮、卡片、列表项)?
|
||||
- 有无"容器组件 + 展示组件"的区分?
|
||||
- 说明状态(state)主要存放在哪里、如何流动:
|
||||
- 是否使用 React 内部 state、Context、Redux 或其他状态管理方案?
|
||||
- 如何避免不必要的重新渲染(如使用 `memo`、拆分组件等)?
|
||||
- 如果你有自定义的 hook 或 API 客户端封装,请简单介绍其职责与好处。
|
||||
|
||||
## 4. 与后端的对接与解耦
|
||||
|
||||
- 说明你是如何在前端代码中调用后端 API 的:
|
||||
- 是否集中封装在某个 `api.ts`/`client.ts` 文件?
|
||||
- 错误时如何处理(toast 提示、页面错误组件、控制台日志等)?
|
||||
- 解释你的环境配置方案:
|
||||
- 如使用 `.env` 或 Vite 的环境变量,将后端地址抽离为配置,而不是硬编码在组件内。
|
||||
- 如有 CORS 或认证(如携带 JWT)的处理,请在此简要说明请求头、拦截器等设计。
|
||||
|
||||
## 5. 交互细节与用户体验
|
||||
|
||||
- 列举 2–3 个你刻意设计的交互细节,例如:
|
||||
- 提交表单前按钮禁用 / loading 状态切换;
|
||||
- 删除操作前的确认对话框;
|
||||
- 成功/失败后的反馈提示(toast/snackbar/banner 等)。
|
||||
- 说明你在这些设计中考虑的用户体验因素(如误操作防护、反馈及时性、信息层级清晰度等)。
|
||||
|
||||
## 6. 自我评价与改进思路
|
||||
|
||||
- 回顾你的前端实现,简要评价:
|
||||
- 哪些部分你认为完成得比较满意?(例如结构清晰、交互自然等)
|
||||
- 哪些部分你觉得可以做得更好?(例如视觉设计、响应式布局、性能优化等)
|
||||
- 如果有更多时间,你会优先在哪些方面改进这个前端界面?为什么?
|
||||
247
README.md
Normal file
247
README.md
Normal file
@ -0,0 +1,247 @@
|
||||
# VibeVault 期末大作业
|
||||
|
||||
本大作业要求你在给定的起始代码基础上,完成一个**可运行、可测试、可说明**的 VibeVault 后端系统。
|
||||
|
||||
---
|
||||
|
||||
## 一、学习目标
|
||||
|
||||
- **掌握端到端后端工程能力**:从实体建模、REST API 设计到数据库与事务管理
|
||||
- **应用三层架构与设计原则**:通过 Controller / Service / Repository 分层
|
||||
- **构建自动化测试安全网**:使用 JUnit/AssertJ 等工具为核心业务编写测试
|
||||
- **理解并实现基础安全机制**:落地最小可用的认证与授权骨架
|
||||
- **学会与 AI 协同开发**:通过高质量 Prompt 使用大模型辅助开发
|
||||
|
||||
---
|
||||
|
||||
## 二、运行环境
|
||||
|
||||
- **JDK 21**
|
||||
- **Gradle**(使用仓库内 Wrapper:`./gradlew`)
|
||||
- **Spring Boot 3.4.x**
|
||||
|
||||
**本地常用命令**:
|
||||
```bash
|
||||
./gradlew test # 编译与测试
|
||||
./gradlew bootRun # 运行应用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、功能要求
|
||||
|
||||
### 🟢 Core 轨道(必做,约 60 分)
|
||||
|
||||
#### 1. 实体层(model 包)
|
||||
|
||||
完善 `User`、`Playlist`、`Song` 三个 JPA 实体类:
|
||||
|
||||
- **User**:用户实体,映射到 `users` 表
|
||||
- 用户名必须唯一且不能为空
|
||||
- 密码不能为空
|
||||
- 包含角色字段(默认 `ROLE_USER`)
|
||||
|
||||
- **Playlist**:歌单实体,映射到 `playlists` 表
|
||||
- 每个歌单属于一个用户(多对一)
|
||||
- 一个歌单包含多首歌曲(一对多)
|
||||
- 删除歌单时应级联删除其中的歌曲
|
||||
- 实现 `addSong()` 和 `removeSong()` 方法维护双向关系
|
||||
|
||||
- **Song**:歌曲实体,映射到 `songs` 表
|
||||
- 每首歌曲属于一个歌单(多对一)
|
||||
|
||||
#### 2. 仓库层(repository 包)
|
||||
|
||||
- **UserRepository**:提供根据用户名查找用户、检查用户名是否存在的方法
|
||||
- **PlaylistRepository**:继承 JpaRepository 即可
|
||||
|
||||
#### 3. 服务层(service 包)
|
||||
|
||||
实现 `PlaylistServiceImpl` 中的所有方法:
|
||||
|
||||
- 获取所有歌单
|
||||
- 根据 ID 获取歌单(不存在时抛出 `ResourceNotFoundException`)
|
||||
- 创建新歌单
|
||||
- 向歌单添加歌曲
|
||||
- 从歌单移除歌曲
|
||||
- 删除歌单
|
||||
|
||||
#### 4. 控制器层(controller 包)
|
||||
|
||||
**PlaylistController** - 歌单 REST API:
|
||||
|
||||
| 端点 | 说明 | 状态码 |
|
||||
|------|------|--------|
|
||||
| `GET /api/playlists` | 获取所有歌单 | 200 |
|
||||
| `GET /api/playlists/{id}` | 获取指定歌单 | 200 / 404 |
|
||||
| `POST /api/playlists` | 创建新歌单 | 201 |
|
||||
| `POST /api/playlists/{id}/songs` | 添加歌曲 | 201 |
|
||||
| `DELETE /api/playlists/{playlistId}/songs/{songId}` | 移除歌曲 | 204 |
|
||||
| `DELETE /api/playlists/{id}` | 删除歌单 | 204 |
|
||||
|
||||
**AuthController** - 认证 API:
|
||||
|
||||
| 端点 | 说明 | 状态码 |
|
||||
|------|------|--------|
|
||||
| `POST /api/auth/register` | 用户注册 | 201 / 409(用户名已存在) |
|
||||
| `POST /api/auth/login` | 用户登录 | 200(返回 JWT)/ 401 |
|
||||
|
||||
#### 5. 安全配置(security 和 config 包)
|
||||
|
||||
- 配置公开接口:`/api/auth/**`、`GET /api/playlists`、`GET /api/playlists/{id}`
|
||||
- 其他接口需要认证
|
||||
- 未认证访问受保护资源返回 **401 Unauthorized**
|
||||
- 实现 JWT 生成、验证和过滤器
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Advanced 轨道(进阶,约 10 分)
|
||||
|
||||
#### 1. 事务与一致性
|
||||
|
||||
- 确保 Service 层的写操作都有事务支持
|
||||
- 批量操作保证原子性
|
||||
|
||||
#### 2. 高级查询
|
||||
|
||||
在 Repository 和 Service 层添加:
|
||||
|
||||
- 按所有者查询歌单
|
||||
- 按名称模糊搜索歌单
|
||||
- 复制歌单功能
|
||||
|
||||
对应的 Controller 端点:
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/playlists/search?keyword=xxx` | 搜索歌单 |
|
||||
| `POST /api/playlists/{id}/copy?newName=xxx` | 复制歌单 |
|
||||
|
||||
#### 3. 统一异常处理
|
||||
|
||||
使用 `@RestControllerAdvice` 实现全局异常处理:
|
||||
|
||||
- `ResourceNotFoundException` → 404
|
||||
- `UnauthorizedException` → 403
|
||||
- 其他异常 → 合适的状态码
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Challenge 轨道(挑战,约 10 分)
|
||||
|
||||
#### 1. 所有权检查
|
||||
|
||||
- 只有歌单所有者可以修改/删除自己的歌单
|
||||
- 非所有者操作他人歌单返回 **403 Forbidden**
|
||||
|
||||
#### 2. 角色权限
|
||||
|
||||
- 支持用户角色(`ROLE_USER`、`ROLE_ADMIN`)
|
||||
- 管理员可以删除任何用户的歌单
|
||||
- 普通用户只能删除自己的歌单
|
||||
|
||||
---
|
||||
|
||||
## 四、报告要求(约 20 分)
|
||||
|
||||
### REPORT.md(10 分)
|
||||
|
||||
撰写后端与系统设计报告,建议 1500–3000 字,包括:
|
||||
|
||||
- 系统架构与数据建模
|
||||
- 业务逻辑与事务
|
||||
- 安全与访问控制
|
||||
- 测试策略与质量保障
|
||||
- AI 协同开发反思
|
||||
- 总结与自我评估
|
||||
|
||||
### FRONTEND.md(10 分)
|
||||
|
||||
撰写前端界面与交互设计报告,建议 800–1500 字,包括:
|
||||
|
||||
- 前端技术栈与使用流程
|
||||
- 关键界面截图与说明(3–6 张)
|
||||
- 组件划分与状态管理
|
||||
- 与后端的对接方式
|
||||
- 交互细节与用户体验
|
||||
- 自我评价与改进思路
|
||||
|
||||
---
|
||||
|
||||
## 五、评分构成
|
||||
|
||||
| 项目 | 分值 | 说明 |
|
||||
|------|------|------|
|
||||
| Core 测试 | 60 分 | 实体、Service、Controller、基础安全 |
|
||||
| Advanced 测试 | 10 分 | 事务、高级查询、统一异常处理 |
|
||||
| Challenge 测试 | 10 分 | 所有权检查、角色权限 |
|
||||
| REPORT.md | 10 分 | LLM 自动评分 |
|
||||
| FRONTEND.md | 10 分 | LLM 自动评分 |
|
||||
|
||||
> ⚠️ **通过本地公开测试 ≠ 拿满分**。隐藏测试会检查更多边界条件。
|
||||
|
||||
---
|
||||
|
||||
## 六、提交流程
|
||||
|
||||
1. 克隆仓库到本地
|
||||
2. 完成代码开发
|
||||
3. 运行 `./gradlew test` 确保公开测试通过
|
||||
4. 完成 `REPORT.md` 和 `FRONTEND.md`
|
||||
5. `git add / commit / push` 到 `main` 分支
|
||||
6. 在 Gitea Actions 页面查看评分结果
|
||||
|
||||
---
|
||||
|
||||
## 七、学术诚信
|
||||
|
||||
- ❌ 禁止直接复制他人代码或报告
|
||||
- ✅ 允许使用 AI 辅助,但必须**完全理解**生成的代码
|
||||
- ⚠️ 教师可能通过口头问答或现场演示抽查
|
||||
|
||||
---
|
||||
|
||||
## 八、建议节奏
|
||||
|
||||
- **第 1 周**:完成实体建模(JPA 注解)、Repository、Playlist.addSong/removeSong
|
||||
- **第 2 周**:完成 Service 层、Controller 层、认证接口
|
||||
- **第 3 周**:完成 Security 配置、Advanced 和 Challenge 任务
|
||||
- **截止前**:完成报告,最后一轮自测
|
||||
|
||||
---
|
||||
|
||||
## 九、代码结构说明
|
||||
|
||||
```
|
||||
src/main/java/com/vibevault/
|
||||
├── VibeVaultApplication.java # 启动类(已完成)
|
||||
├── config/
|
||||
│ └── SecurityConfig.java # 安全配置(待实现)
|
||||
├── controller/
|
||||
│ ├── AuthController.java # 认证控制器(待实现)
|
||||
│ └── PlaylistController.java # 歌单控制器(待实现)
|
||||
├── dto/
|
||||
│ ├── PlaylistDTO.java # 歌单响应 DTO(已完成)
|
||||
│ ├── PlaylistCreateDTO.java # 创建歌单请求 DTO(已完成)
|
||||
│ ├── SongDTO.java # 歌曲响应 DTO(已完成)
|
||||
│ └── SongCreateDTO.java # 添加歌曲请求 DTO(已完成)
|
||||
├── exception/
|
||||
│ ├── GlobalExceptionHandler.java # 全局异常处理(待实现)
|
||||
│ ├── ResourceNotFoundException.java # 资源不存在异常(已完成)
|
||||
│ └── UnauthorizedException.java # 未授权异常(已完成)
|
||||
├── model/
|
||||
│ ├── User.java # 用户实体(待添加 JPA 注解)
|
||||
│ ├── Playlist.java # 歌单实体(待添加 JPA 注解)
|
||||
│ └── Song.java # 歌曲实体(待添加 JPA 注解)
|
||||
├── repository/
|
||||
│ ├── UserRepository.java # 用户仓库(待添加查询方法)
|
||||
│ └── PlaylistRepository.java # 歌单仓库(待添加查询方法)
|
||||
├── security/
|
||||
│ ├── JwtService.java # JWT 服务(待实现)
|
||||
│ └── JwtAuthenticationFilter.java # JWT 过滤器(待实现)
|
||||
└── service/
|
||||
├── PlaylistService.java # 歌单服务接口(已完成)
|
||||
└── PlaylistServiceImpl.java # 歌单服务实现(待实现)
|
||||
```
|
||||
|
||||
祝你顺利完成!记住:**理解永远比"跑通一次"更重要。**
|
||||
46
REPORT.md
Normal file
46
REPORT.md
Normal file
@ -0,0 +1,46 @@
|
||||
# VibeVault 期末大作业报告:后端与系统设计
|
||||
|
||||
> 请使用中文完成报告,建议 1500–3000 字。你可以使用任意 Markdown 编辑工具。
|
||||
|
||||
## 1. 系统概览与架构设计
|
||||
|
||||
- 简要描述你的系统功能(例如:歌单管理、歌曲管理、用户管理等)。
|
||||
- 画出或文字说明你的分层结构(Controller / Service / Repository),并说明每一层的主要职责。
|
||||
- 解释你如何在代码中体现"高内聚、低耦合"和"依赖倒置"。
|
||||
|
||||
## 2. 数据建模与持久化
|
||||
|
||||
- 描述你的核心实体(如 `User`, `Playlist`, `Song`)及它们之间的关系(如 One-to-Many, Many-to-One)。
|
||||
- 解释你选择的主键策略(如 `@GeneratedValue`)和约束(如唯一性、非空约束)的设计理由。
|
||||
- 如果你对数据库结构做了扩展或修改,请在此详细说明。
|
||||
|
||||
## 3. 业务逻辑与事务
|
||||
|
||||
- 选择 1–2 个你认为最关键的业务用例(例如"批量向歌单添加歌曲并保持原子性"),描述其业务流程。
|
||||
- 说明你在这些用例中如何使用事务(如 `@Transactional`),以及这样设计的原因。
|
||||
- 如果你实现了高级查询(如模糊搜索、复制歌单),请说明实现方式与性能考虑。
|
||||
|
||||
## 4. 安全与访问控制
|
||||
|
||||
- 描述你的认证方案(如 JWT),以及如何将用户身份传递给业务层。
|
||||
- 说明你的授权策略:谁可以删除/修改谁的歌单?你是如何在代码中实现"所有权检查"的?
|
||||
- 如果你实现了更多安全特性(如密码加密、角色/权限),请在此展开。
|
||||
|
||||
## 5. 测试策略与质量保障
|
||||
|
||||
- 概述你为本项目编写的测试类型(单元测试、集成测试等)及覆盖的主要场景。
|
||||
- 列举 1–2 个你通过测试发现并修复的典型 bug,说明问题成因和修复思路。
|
||||
- 反思:如果以后需要继续扩展本项目,你认为当前的测试体系是否足以支撑频繁重构?为什么?
|
||||
|
||||
## 6. AI 协同开发反思
|
||||
|
||||
- 具体描述 2–3 个你在项目中使用大语言模型(如 ChatGPT、Cursor 等)协助开发的案例:
|
||||
- 你给出了什么样的 Prompt?
|
||||
- 得到的回答中哪些部分是直接采用的?哪些部分你进行了修改或重写?
|
||||
- 分析:在本项目中,AI 对你最大的帮助是什么?最容易"误导"你的地方又是什么?
|
||||
- 总结:你会如何在未来的工程实践中规范、有效地使用 AI 作为编码伙伴?
|
||||
|
||||
## 7. 总结与自我评估
|
||||
|
||||
- 总结你在本项目中最重要的三点收获(可以是技术、思维方式或协作方式)。
|
||||
- 按照课程要求的大作业评分维度(功能、架构、测试、安全、文档),给自己一个客观的自评分,并简要说明理由。
|
||||
54
build.gradle.kts
Normal file
54
build.gradle.kts
Normal file
@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.springframework.boot") version "3.4.7"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
application
|
||||
}
|
||||
|
||||
group = "com.vibevault"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot Starters
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
|
||||
// Database
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
runtimeOnly("com.h2database:h2") // For testing
|
||||
|
||||
// JWT (Optional - for Challenge track)
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
|
||||
|
||||
// Testing
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("com.vibevault.VibeVaultApplication")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
// Generate XML reports for grading
|
||||
reports {
|
||||
junitXml.required.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@ -0,0 +1,5 @@
|
||||
# Gradle settings
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Executable file
251
gradlew
vendored
Executable file
@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
2
settings.gradle.kts
Normal file
2
settings.gradle.kts
Normal file
@ -0,0 +1,2 @@
|
||||
rootProject.name = "vibevault-final"
|
||||
|
||||
13
src/main/java/com/vibevault/VibeVaultApplication.java
Normal file
13
src/main/java/com/vibevault/VibeVaultApplication.java
Normal file
@ -0,0 +1,13 @@
|
||||
package com.vibevault;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class VibeVaultApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VibeVaultApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
57
src/main/java/com/vibevault/config/SecurityConfig.java
Normal file
57
src/main/java/com/vibevault/config/SecurityConfig.java
Normal file
@ -0,0 +1,57 @@
|
||||
package com.vibevault.config;
|
||||
|
||||
import com.vibevault.security.JwtAuthenticationFilter;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Spring Security 配置
|
||||
*
|
||||
* 需要实现:
|
||||
* - 公开接口无需认证:/api/auth/**, GET /api/playlists, GET /api/playlists/{id}
|
||||
* - 其他接口需要认证
|
||||
* - 未认证访问受保护资源返回 401(不是 403)
|
||||
* - 配置 JWT 过滤器
|
||||
* - 禁用 CSRF(REST API 通常不需要)
|
||||
* - 使用无状态会话
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
// TODO: 配置安全规则
|
||||
// 提示:
|
||||
// - 使用 http.authorizeHttpRequests() 配置路径权限
|
||||
// - 使用 http.csrf(csrf -> csrf.disable()) 禁用 CSRF
|
||||
// - 使用 http.sessionManagement() 配置无状态会话
|
||||
// - 使用 http.exceptionHandling() 配置 401 响应
|
||||
// - 使用 http.addFilterBefore() 添加 JWT 过滤器
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
62
src/main/java/com/vibevault/controller/AuthController.java
Normal file
62
src/main/java/com/vibevault/controller/AuthController.java
Normal file
@ -0,0 +1,62 @@
|
||||
package com.vibevault.controller;
|
||||
|
||||
import com.vibevault.model.User;
|
||||
import com.vibevault.repository.UserRepository;
|
||||
import com.vibevault.security.JwtService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* 认证控制器
|
||||
*
|
||||
* 需要实现以下端点:
|
||||
* - POST /api/auth/register - 用户注册
|
||||
* - 检查用户名是否已存在(已存在返回 409 Conflict)
|
||||
* - 密码需要加密存储
|
||||
* - 成功返回 201
|
||||
*
|
||||
* - POST /api/auth/login - 用户登录
|
||||
* - 验证用户名和密码
|
||||
* - 验证失败返回 401 Unauthorized
|
||||
* - 验证成功返回 JWT token
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
// TODO: 实现 POST /api/auth/register (状态码 201)
|
||||
|
||||
// TODO: 实现 POST /api/auth/login
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册请求 DTO
|
||||
*/
|
||||
record RegisterRequest(String username, String password) {}
|
||||
|
||||
/**
|
||||
* 注册响应 DTO
|
||||
*/
|
||||
record RegisterResponse(String message, String username) {}
|
||||
|
||||
/**
|
||||
* 登录请求 DTO
|
||||
*/
|
||||
record LoginRequest(String username, String password) {}
|
||||
|
||||
/**
|
||||
* 登录响应 DTO
|
||||
*/
|
||||
record LoginResponse(String token, String username) {}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.vibevault.controller;
|
||||
|
||||
import com.vibevault.dto.PlaylistCreateDTO;
|
||||
import com.vibevault.dto.PlaylistDTO;
|
||||
import com.vibevault.dto.SongCreateDTO;
|
||||
import com.vibevault.service.PlaylistService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 歌单 REST 控制器
|
||||
*
|
||||
* 需要实现以下端点:
|
||||
* - GET /api/playlists - 获取所有歌单(公开)
|
||||
* - GET /api/playlists/{id} - 获取指定歌单(公开)
|
||||
* - POST /api/playlists - 创建歌单(需认证)
|
||||
* - POST /api/playlists/{id}/songs - 添加歌曲(需认证)
|
||||
* - DELETE /api/playlists/{playlistId}/songs/{songId} - 移除歌曲(需认证)
|
||||
* - DELETE /api/playlists/{id} - 删除歌单(需认证)
|
||||
*
|
||||
* [Advanced] 额外端点:
|
||||
* - GET /api/playlists/search?keyword=xxx - 搜索歌单
|
||||
* - POST /api/playlists/{id}/copy?newName=xxx - 复制歌单
|
||||
*
|
||||
* 提示:
|
||||
* - 使用 Authentication 参数获取当前用户名:authentication.getName()
|
||||
* - 使用 @ResponseStatus 设置正确的 HTTP 状态码
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/playlists")
|
||||
public class PlaylistController {
|
||||
|
||||
private final PlaylistService playlistService;
|
||||
|
||||
public PlaylistController(PlaylistService playlistService) {
|
||||
this.playlistService = playlistService;
|
||||
}
|
||||
|
||||
// TODO: 实现 GET /api/playlists
|
||||
|
||||
// TODO: 实现 GET /api/playlists/{id}
|
||||
|
||||
// TODO: 实现 POST /api/playlists (状态码 201)
|
||||
|
||||
// TODO: 实现 POST /api/playlists/{id}/songs (状态码 201)
|
||||
|
||||
// TODO: 实现 DELETE /api/playlists/{playlistId}/songs/{songId} (状态码 204)
|
||||
|
||||
// TODO: 实现 DELETE /api/playlists/{id} (状态码 204)
|
||||
|
||||
// TODO [Advanced]: 实现 GET /api/playlists/search?keyword=xxx
|
||||
|
||||
// TODO [Advanced]: 实现 POST /api/playlists/{id}/copy?newName=xxx (状态码 201)
|
||||
}
|
||||
11
src/main/java/com/vibevault/dto/PlaylistCreateDTO.java
Normal file
11
src/main/java/com/vibevault/dto/PlaylistCreateDTO.java
Normal file
@ -0,0 +1,11 @@
|
||||
package com.vibevault.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record PlaylistCreateDTO(
|
||||
@NotBlank(message = "Playlist name is required")
|
||||
@Size(min = 1, max = 100, message = "Playlist name must be between 1 and 100 characters")
|
||||
String name
|
||||
) {
|
||||
}
|
||||
11
src/main/java/com/vibevault/dto/PlaylistDTO.java
Normal file
11
src/main/java/com/vibevault/dto/PlaylistDTO.java
Normal file
@ -0,0 +1,11 @@
|
||||
package com.vibevault.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PlaylistDTO(
|
||||
Long id,
|
||||
String name,
|
||||
String ownerUsername,
|
||||
List<SongDTO> songs
|
||||
) {
|
||||
}
|
||||
16
src/main/java/com/vibevault/dto/SongCreateDTO.java
Normal file
16
src/main/java/com/vibevault/dto/SongCreateDTO.java
Normal file
@ -0,0 +1,16 @@
|
||||
package com.vibevault.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
|
||||
public record SongCreateDTO(
|
||||
@NotBlank(message = "Song title is required")
|
||||
String title,
|
||||
|
||||
@NotBlank(message = "Artist name is required")
|
||||
String artist,
|
||||
|
||||
@PositiveOrZero(message = "Duration must be non-negative")
|
||||
int durationInSeconds
|
||||
) {
|
||||
}
|
||||
9
src/main/java/com/vibevault/dto/SongDTO.java
Normal file
9
src/main/java/com/vibevault/dto/SongDTO.java
Normal file
@ -0,0 +1,9 @@
|
||||
package com.vibevault.dto;
|
||||
|
||||
public record SongDTO(
|
||||
Long id,
|
||||
String title,
|
||||
String artist,
|
||||
int durationInSeconds
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.vibevault.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 全局异常处理器
|
||||
*
|
||||
* 需要实现:
|
||||
* - 捕获 ResourceNotFoundException 并返回 404 状态码
|
||||
* - 捕获 UnauthorizedException 并返回 403 状态码
|
||||
* - 捕获 ResponseStatusException 并返回对应状态码
|
||||
* - [Advanced] 统一处理其他异常,返回合适的错误响应格式
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
// TODO: 实现 ResourceNotFoundException 处理器 (返回 404)
|
||||
|
||||
// TODO: 实现 UnauthorizedException 处理器 (返回 403)
|
||||
|
||||
// TODO: 实现 ResponseStatusException 处理器
|
||||
|
||||
// TODO [Advanced]: 实现通用异常处理器
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.vibevault.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ResourceNotFoundException(String resourceName, Long id) {
|
||||
super(String.format("%s not found with id: %d", resourceName, id));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.vibevault.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
72
src/main/java/com/vibevault/model/Playlist.java
Normal file
72
src/main/java/com/vibevault/model/Playlist.java
Normal file
@ -0,0 +1,72 @@
|
||||
package com.vibevault.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 歌单实体类
|
||||
*
|
||||
* 需要实现:
|
||||
* - 将此类映射为数据库表 "playlists"
|
||||
* - id 作为自增主键
|
||||
* - name 不能为空
|
||||
* - 每个歌单属于一个用户(多对一关系)
|
||||
* - 一个歌单包含多首歌曲(一对多关系)
|
||||
* - 删除歌单时应级联删除其中的歌曲
|
||||
*/
|
||||
public class Playlist {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
private User owner;
|
||||
|
||||
private List<Song> songs = new ArrayList<>();
|
||||
|
||||
protected Playlist() {
|
||||
}
|
||||
|
||||
public Playlist(String name, User owner) {
|
||||
this.name = name;
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public User getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public List<Song> getSongs() {
|
||||
return Collections.unmodifiableList(songs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向歌单添加歌曲
|
||||
* 提示:需要维护双向关系
|
||||
*/
|
||||
public void addSong(Song song) {
|
||||
// TODO: 实现添加歌曲逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 从歌单移除歌曲
|
||||
* 提示:需要维护双向关系
|
||||
*/
|
||||
public void removeSong(Song song) {
|
||||
// TODO: 实现移除歌曲逻辑
|
||||
}
|
||||
}
|
||||
57
src/main/java/com/vibevault/model/Song.java
Normal file
57
src/main/java/com/vibevault/model/Song.java
Normal file
@ -0,0 +1,57 @@
|
||||
package com.vibevault.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 歌曲实体类
|
||||
*
|
||||
* 需要实现:
|
||||
* - 将此类映射为数据库表 "songs"
|
||||
* - id 作为自增主键
|
||||
* - 每首歌曲属于一个歌单(多对一关系)
|
||||
*/
|
||||
public class Song {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
|
||||
private String artist;
|
||||
|
||||
private int durationInSeconds;
|
||||
|
||||
private Playlist playlist;
|
||||
|
||||
public Song() {
|
||||
}
|
||||
|
||||
public Song(String title, String artist, int durationInSeconds) {
|
||||
this.title = title;
|
||||
this.artist = artist;
|
||||
this.durationInSeconds = durationInSeconds;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getArtist() {
|
||||
return artist;
|
||||
}
|
||||
|
||||
public int getDurationInSeconds() {
|
||||
return durationInSeconds;
|
||||
}
|
||||
|
||||
public Playlist getPlaylist() {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public void setPlaylist(Playlist playlist) {
|
||||
this.playlist = playlist;
|
||||
}
|
||||
}
|
||||
59
src/main/java/com/vibevault/model/User.java
Normal file
59
src/main/java/com/vibevault/model/User.java
Normal file
@ -0,0 +1,59 @@
|
||||
package com.vibevault.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 用户实体类
|
||||
*
|
||||
* 需要实现:
|
||||
* - 将此类映射为数据库表 "users"
|
||||
* - id 作为自增主键
|
||||
* - username 必须唯一且不能为空
|
||||
* - password 不能为空
|
||||
* - [Challenge] 支持用户角色(如 ROLE_USER, ROLE_ADMIN)
|
||||
*/
|
||||
public class User {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String username;
|
||||
|
||||
private String password;
|
||||
|
||||
// [Challenge] 用户角色,默认为 ROLE_USER
|
||||
private String role = "ROLE_USER";
|
||||
|
||||
protected User() {
|
||||
}
|
||||
|
||||
public User(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public User(String username, String password, String role) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.vibevault.repository;
|
||||
|
||||
import com.vibevault.model.Playlist;
|
||||
import com.vibevault.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 歌单仓库接口
|
||||
*
|
||||
* 基础功能由 JpaRepository 提供
|
||||
*
|
||||
* [Advanced] 需要添加:
|
||||
* - 按所有者查询歌单
|
||||
* - 按名称模糊搜索歌单
|
||||
*/
|
||||
@Repository
|
||||
public interface PlaylistRepository extends JpaRepository<Playlist, Long> {
|
||||
// TODO [Advanced]: 添加高级查询方法
|
||||
}
|
||||
19
src/main/java/com/vibevault/repository/UserRepository.java
Normal file
19
src/main/java/com/vibevault/repository/UserRepository.java
Normal file
@ -0,0 +1,19 @@
|
||||
package com.vibevault.repository;
|
||||
|
||||
import com.vibevault.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户仓库接口
|
||||
*
|
||||
* 需要实现:
|
||||
* - 根据用户名查找用户
|
||||
* - 检查用户名是否已存在
|
||||
*/
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
// TODO: 添加必要的查询方法
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.vibevault.security;
|
||||
|
||||
import com.vibevault.model.User;
|
||||
import com.vibevault.repository.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JWT 认证过滤器
|
||||
*
|
||||
* 需要实现:
|
||||
* - 从请求头中提取 Authorization: Bearer <token>
|
||||
* - 验证 token 有效性
|
||||
* - 如果有效,将用户信息设置到 SecurityContext 中
|
||||
* - [Challenge] 从数据库中读取用户角色并设置到 Authentication 中
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
// TODO: 实现 JWT 认证逻辑
|
||||
// 1. 从请求头获取 Authorization
|
||||
// 2. 检查是否以 "Bearer " 开头
|
||||
// 3. 提取 token 并验证
|
||||
// 4. 如果有效,创建 Authentication 并设置到 SecurityContextHolder
|
||||
//
|
||||
// 提示:
|
||||
// - 使用 request.getHeader("Authorization") 获取头
|
||||
// - 使用 jwtService.extractUsername() 和 jwtService.isTokenValid()
|
||||
// - 使用 UsernamePasswordAuthenticationToken 创建认证对象
|
||||
// - 使用 SecurityContextHolder.getContext().setAuthentication() 设置
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
61
src/main/java/com/vibevault/security/JwtService.java
Normal file
61
src/main/java/com/vibevault/security/JwtService.java
Normal file
@ -0,0 +1,61 @@
|
||||
package com.vibevault.security;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 服务
|
||||
*
|
||||
* 需要实现:
|
||||
* - 生成 JWT token(包含用户名)
|
||||
* - 从 token 中提取用户名
|
||||
* - 验证 token 是否有效(未过期、签名正确)
|
||||
*/
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
@Value("${jwt.secret:your-secret-key-here-should-be-at-least-256-bits-long-for-hs256}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private long expiration;
|
||||
|
||||
/**
|
||||
* 为用户生成 JWT token
|
||||
*/
|
||||
public String generateToken(String username) {
|
||||
// TODO: 实现 token 生成
|
||||
// 提示:使用 Jwts.builder()
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 token 中提取用户名
|
||||
*/
|
||||
public String extractUsername(String token) {
|
||||
// TODO: 实现用户名提取
|
||||
// 提示:使用 Jwts.parser()
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token 是否有效
|
||||
*/
|
||||
public boolean isTokenValid(String token, String username) {
|
||||
// TODO: 实现 token 验证
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签名密钥
|
||||
*/
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
66
src/main/java/com/vibevault/service/PlaylistService.java
Normal file
66
src/main/java/com/vibevault/service/PlaylistService.java
Normal file
@ -0,0 +1,66 @@
|
||||
package com.vibevault.service;
|
||||
|
||||
import com.vibevault.dto.PlaylistDTO;
|
||||
import com.vibevault.dto.SongCreateDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 歌单服务接口
|
||||
* 定义歌单相关的业务操作
|
||||
*/
|
||||
public interface PlaylistService {
|
||||
|
||||
/**
|
||||
* 获取所有歌单
|
||||
*/
|
||||
List<PlaylistDTO> getAllPlaylists();
|
||||
|
||||
/**
|
||||
* 根据 ID 获取歌单
|
||||
* @throws com.vibevault.exception.ResourceNotFoundException 如果歌单不存在
|
||||
*/
|
||||
PlaylistDTO getPlaylistById(Long id);
|
||||
|
||||
/**
|
||||
* 创建新歌单
|
||||
* @param name 歌单名称
|
||||
* @param ownerUsername 所有者用户名
|
||||
*/
|
||||
PlaylistDTO createPlaylist(String name, String ownerUsername);
|
||||
|
||||
/**
|
||||
* 向歌单添加歌曲
|
||||
* @param playlistId 歌单 ID
|
||||
* @param song 歌曲信息
|
||||
* @param username 当前用户名(用于权限检查)
|
||||
*/
|
||||
PlaylistDTO addSongToPlaylist(Long playlistId, SongCreateDTO song, String username);
|
||||
|
||||
/**
|
||||
* 从歌单移除歌曲
|
||||
* @param playlistId 歌单 ID
|
||||
* @param songId 歌曲 ID
|
||||
* @param username 当前用户名(用于权限检查)
|
||||
*/
|
||||
void removeSongFromPlaylist(Long playlistId, Long songId, String username);
|
||||
|
||||
/**
|
||||
* 删除歌单
|
||||
* @param playlistId 歌单 ID
|
||||
* @param username 当前用户名(用于权限检查)
|
||||
*/
|
||||
void deletePlaylist(Long playlistId, String username);
|
||||
|
||||
// ========== Advanced 方法(选做)==========
|
||||
|
||||
/**
|
||||
* [Advanced] 按关键字搜索歌单
|
||||
*/
|
||||
List<PlaylistDTO> searchPlaylists(String keyword);
|
||||
|
||||
/**
|
||||
* [Advanced] 复制歌单
|
||||
*/
|
||||
PlaylistDTO copyPlaylist(Long playlistId, String newName, String username);
|
||||
}
|
||||
122
src/main/java/com/vibevault/service/PlaylistServiceImpl.java
Normal file
122
src/main/java/com/vibevault/service/PlaylistServiceImpl.java
Normal file
@ -0,0 +1,122 @@
|
||||
package com.vibevault.service;
|
||||
|
||||
import com.vibevault.dto.PlaylistDTO;
|
||||
import com.vibevault.dto.SongCreateDTO;
|
||||
import com.vibevault.dto.SongDTO;
|
||||
import com.vibevault.exception.ResourceNotFoundException;
|
||||
import com.vibevault.exception.UnauthorizedException;
|
||||
import com.vibevault.model.Playlist;
|
||||
import com.vibevault.model.Song;
|
||||
import com.vibevault.model.User;
|
||||
import com.vibevault.repository.PlaylistRepository;
|
||||
import com.vibevault.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 歌单服务实现
|
||||
*
|
||||
* 需要实现:
|
||||
* - 所有 PlaylistService 接口中定义的方法
|
||||
* - 将实体转换为 DTO 返回给调用者
|
||||
* - 资源不存在时抛出 ResourceNotFoundException
|
||||
* - [Challenge] 检查用户是否有权限操作歌单(所有权检查)
|
||||
*/
|
||||
@Service
|
||||
public class PlaylistServiceImpl implements PlaylistService {
|
||||
|
||||
private final PlaylistRepository playlistRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public PlaylistServiceImpl(PlaylistRepository playlistRepository, UserRepository userRepository) {
|
||||
this.playlistRepository = playlistRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlaylistDTO> getAllPlaylists() {
|
||||
// TODO: 实现获取所有歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlaylistDTO getPlaylistById(Long id) {
|
||||
// TODO: 实现根据 ID 获取歌单,不存在时抛出 ResourceNotFoundException
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PlaylistDTO createPlaylist(String name, String ownerUsername) {
|
||||
// TODO: 实现创建歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PlaylistDTO addSongToPlaylist(Long playlistId, SongCreateDTO song, String username) {
|
||||
// TODO: 实现添加歌曲到歌单
|
||||
// [Challenge] 需要检查用户是否有权限操作此歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void removeSongFromPlaylist(Long playlistId, Long songId, String username) {
|
||||
// TODO: 实现从歌单移除歌曲
|
||||
// [Challenge] 需要检查用户是否有权限操作此歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deletePlaylist(Long playlistId, String username) {
|
||||
// TODO: 实现删除歌单
|
||||
// [Challenge] 需要检查用户是否有权限操作此歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
// ========== Advanced 方法 ==========
|
||||
|
||||
@Override
|
||||
public List<PlaylistDTO> searchPlaylists(String keyword) {
|
||||
// TODO [Advanced]: 实现按关键字搜索歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PlaylistDTO copyPlaylist(Long playlistId, String newName, String username) {
|
||||
// TODO [Advanced]: 实现复制歌单
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 将 Playlist 实体转换为 DTO
|
||||
*/
|
||||
private PlaylistDTO toDTO(Playlist playlist) {
|
||||
// TODO: 实现实体到 DTO 的转换
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Song 实体转换为 DTO
|
||||
*/
|
||||
private SongDTO toSongDTO(Song song) {
|
||||
// TODO: 实现实体到 DTO 的转换
|
||||
throw new UnsupportedOperationException("待实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* [Challenge] 检查用户是否有权限操作指定歌单
|
||||
* 规则:歌单所有者或管理员可以操作
|
||||
*/
|
||||
private void checkPermission(Playlist playlist, String username) {
|
||||
// TODO [Challenge]: 实现权限检查
|
||||
// 如果无权限,抛出 UnauthorizedException
|
||||
}
|
||||
}
|
||||
13
src/main/resources/application-test.properties
Normal file
13
src/main/resources/application-test.properties
Normal file
@ -0,0 +1,13 @@
|
||||
# Test Configuration - Uses H2 in-memory database
|
||||
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
|
||||
|
||||
# Disable security for some tests (can be overridden per test)
|
||||
# spring.security.enabled=false
|
||||
|
||||
31
src/main/resources/application.properties
Normal file
31
src/main/resources/application.properties
Normal file
@ -0,0 +1,31 @@
|
||||
# VibeVault Application Configuration
|
||||
spring.application.name=vibevault
|
||||
|
||||
# Database Configuration
|
||||
# 开发/测试时使用 H2 内存数据库(每次重启清空数据)
|
||||
spring.datasource.url=jdbc:h2:mem:vibevault;DB_CLOSE_DELAY=-1
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
|
||||
# 如需使用 PostgreSQL,注释上面的 H2 配置,取消下面的注释
|
||||
# spring.datasource.url=jdbc:postgresql://localhost:5432/vibevault
|
||||
# spring.datasource.username=postgres
|
||||
# spring.datasource.password=postgres
|
||||
# spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
# JPA / Hibernate
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.format_sql=true
|
||||
|
||||
# H2 Console (访问 http://localhost:8080/h2-console 查看数据库)
|
||||
spring.h2.console.enabled=true
|
||||
spring.h2.console.path=/h2-console
|
||||
|
||||
# JWT Configuration
|
||||
jwt.secret=your-secret-key-here-should-be-at-least-256-bits-long-for-hs256
|
||||
jwt.expiration=86400000
|
||||
|
||||
# Server
|
||||
server.port=8080
|
||||
120
src/test/java/com/vibevault/PublicPlaylistServiceTest.java
Normal file
120
src/test/java/com/vibevault/PublicPlaylistServiceTest.java
Normal file
@ -0,0 +1,120 @@
|
||||
package com.vibevault;
|
||||
|
||||
import com.vibevault.dto.PlaylistDTO;
|
||||
import com.vibevault.dto.SongCreateDTO;
|
||||
import com.vibevault.model.Playlist;
|
||||
import com.vibevault.model.User;
|
||||
import com.vibevault.repository.PlaylistRepository;
|
||||
import com.vibevault.repository.UserRepository;
|
||||
import com.vibevault.service.PlaylistService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 公开 PlaylistService 集成测试
|
||||
*
|
||||
* 这些测试需要启动 Spring 上下文,用于验证 Service 层的基本功能。
|
||||
* 注意:隐藏测试会检查更多边界条件!
|
||||
*
|
||||
* 提示:这些测试需要你先完成以下工作才能运行:
|
||||
* 1. 为实体类添加 JPA 注解
|
||||
* 2. 实现 Repository 方法
|
||||
* 3. 实现 PlaylistServiceImpl 中的方法
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class PublicPlaylistServiceTest {
|
||||
|
||||
@Autowired
|
||||
private PlaylistService playlistService;
|
||||
|
||||
@Autowired
|
||||
private PlaylistRepository playlistRepository;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUser = new User("testuser", passwordEncoder.encode("password123"));
|
||||
userRepository.save(testUser);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAllPlaylists 应该返回所有歌单")
|
||||
void getAllPlaylists_shouldReturnAllPlaylists() {
|
||||
// Given
|
||||
Playlist playlist1 = new Playlist("My Favorites", testUser);
|
||||
Playlist playlist2 = new Playlist("Workout Mix", testUser);
|
||||
playlistRepository.save(playlist1);
|
||||
playlistRepository.save(playlist2);
|
||||
|
||||
// When
|
||||
List<PlaylistDTO> playlists = playlistService.getAllPlaylists();
|
||||
|
||||
// Then
|
||||
assertNotNull(playlists);
|
||||
assertTrue(playlists.size() >= 2, "Should return at least 2 playlists");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPlaylistById 应该返回正确的歌单")
|
||||
void getPlaylistById_shouldReturnCorrectPlaylist() {
|
||||
// Given
|
||||
Playlist playlist = new Playlist("Test Playlist", testUser);
|
||||
playlist = playlistRepository.save(playlist);
|
||||
|
||||
// When
|
||||
PlaylistDTO result = playlistService.getPlaylistById(playlist.getId());
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertEquals("Test Playlist", result.name());
|
||||
assertEquals("testuser", result.ownerUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createPlaylist 应该创建新歌单")
|
||||
void createPlaylist_shouldCreateNewPlaylist() {
|
||||
// When
|
||||
PlaylistDTO result = playlistService.createPlaylist("New Playlist", "testuser");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.id());
|
||||
assertEquals("New Playlist", result.name());
|
||||
assertEquals("testuser", result.ownerUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addSongToPlaylist 应该向歌单添加歌曲")
|
||||
void addSongToPlaylist_shouldAddSong() {
|
||||
// Given
|
||||
PlaylistDTO playlist = playlistService.createPlaylist("My Playlist", "testuser");
|
||||
SongCreateDTO song = new SongCreateDTO("Test Song", "Test Artist", 180);
|
||||
|
||||
// When
|
||||
playlistService.addSongToPlaylist(playlist.id(), song, "testuser");
|
||||
|
||||
// Then
|
||||
PlaylistDTO updated = playlistService.getPlaylistById(playlist.id());
|
||||
assertEquals(1, updated.songs().size());
|
||||
assertEquals("Test Song", updated.songs().get(0).title());
|
||||
}
|
||||
}
|
||||
106
src/test/java/com/vibevault/PublicSmokeTest.java
Normal file
106
src/test/java/com/vibevault/PublicSmokeTest.java
Normal file
@ -0,0 +1,106 @@
|
||||
package com.vibevault;
|
||||
|
||||
import com.vibevault.dto.PlaylistDTO;
|
||||
import com.vibevault.dto.SongDTO;
|
||||
import com.vibevault.model.Playlist;
|
||||
import com.vibevault.model.Song;
|
||||
import com.vibevault.model.User;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 公开冒烟测试
|
||||
*
|
||||
* 这些测试对学生可见,用于本地自检。
|
||||
* 通过这些测试不代表能拿满分,还有更多隐藏测试。
|
||||
*
|
||||
* 注意:这些测试不需要启动 Spring 上下文,可以快速运行。
|
||||
*/
|
||||
class PublicSmokeTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("User 实体应该能正确创建")
|
||||
void user_shouldBeCreatedCorrectly() {
|
||||
User user = new User("testuser", "password123");
|
||||
|
||||
assertEquals("testuser", user.getUsername());
|
||||
assertEquals("password123", user.getPassword());
|
||||
assertEquals("ROLE_USER", user.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("User 实体应该支持自定义角色")
|
||||
void user_shouldSupportCustomRole() {
|
||||
User admin = new User("admin", "password123", "ROLE_ADMIN");
|
||||
|
||||
assertEquals("admin", admin.getUsername());
|
||||
assertEquals("ROLE_ADMIN", admin.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Playlist 实体应该能正确创建")
|
||||
void playlist_shouldBeCreatedCorrectly() {
|
||||
User owner = new User("testuser", "password123");
|
||||
Playlist playlist = new Playlist("My Favorites", owner);
|
||||
|
||||
assertEquals("My Favorites", playlist.getName());
|
||||
assertEquals(owner, playlist.getOwner());
|
||||
assertNotNull(playlist.getSongs());
|
||||
assertTrue(playlist.getSongs().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Song 实体应该能正确创建")
|
||||
void song_shouldBeCreatedCorrectly() {
|
||||
Song song = new Song("Test Song", "Test Artist", 180);
|
||||
|
||||
assertEquals("Test Song", song.getTitle());
|
||||
assertEquals("Test Artist", song.getArtist());
|
||||
assertEquals(180, song.getDurationInSeconds());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PlaylistDTO 应该正确存储数据")
|
||||
void playlistDTO_shouldStoreDataCorrectly() {
|
||||
SongDTO song = new SongDTO(1L, "Test Song", "Test Artist", 180);
|
||||
PlaylistDTO playlist = new PlaylistDTO(1L, "My Favorites", "testuser", List.of(song));
|
||||
|
||||
assertEquals(1L, playlist.id());
|
||||
assertEquals("My Favorites", playlist.name());
|
||||
assertEquals("testuser", playlist.ownerUsername());
|
||||
assertEquals(1, playlist.songs().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Playlist.addSong 应该添加歌曲到歌单")
|
||||
void playlist_addSong_shouldAddSongToPlaylist() {
|
||||
User owner = new User("testuser", "password123");
|
||||
Playlist playlist = new Playlist("My Favorites", owner);
|
||||
Song song = new Song("Test Song", "Test Artist", 180);
|
||||
|
||||
playlist.addSong(song);
|
||||
|
||||
// 这个测试会失败直到你实现 addSong 方法
|
||||
assertEquals(1, playlist.getSongs().size(), "歌单应该包含 1 首歌曲");
|
||||
assertEquals(playlist, song.getPlaylist(), "歌曲的 playlist 应该指向当前歌单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Playlist.removeSong 应该从歌单移除歌曲")
|
||||
void playlist_removeSong_shouldRemoveSongFromPlaylist() {
|
||||
User owner = new User("testuser", "password123");
|
||||
Playlist playlist = new Playlist("My Favorites", owner);
|
||||
Song song = new Song("Test Song", "Test Artist", 180);
|
||||
|
||||
playlist.addSong(song);
|
||||
playlist.removeSong(song);
|
||||
|
||||
// 这个测试会失败直到你实现 addSong 和 removeSong 方法
|
||||
assertTrue(playlist.getSongs().isEmpty(), "歌单应该为空");
|
||||
assertNull(song.getPlaylist(), "歌曲的 playlist 应该为 null");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user