Initial commit

This commit is contained in:
sit002 2025-12-01 22:12:02 +08:00
commit d0a4992cd6
51 changed files with 5078 additions and 0 deletions

View 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()

View 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()

View 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
View 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
View 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
View 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()

View 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
View 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
View 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()

View 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 "✅ 所有测试通过!"

View 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())

View 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

View 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

View 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

View 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

View 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
View 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
View File

@ -0,0 +1,69 @@
# VibeVault 期末大作业报告:前端界面与交互设计
> 本报告专门用于说明你实现的前端界面与交互设计,建议 8001500 字。请与 `REPORT.md` 一起提交。
## 1. 整体概览与使用流程
- 简要说明你的前端技术栈例如Vite + React + 某 UI 库等),以及项目目录的基本结构。
- 描述一个典型用户从打开页面到完成核心任务的完整流程,例如:
- 登录 → 查看歌单列表 → 新建歌单 → 向歌单添加歌曲 → 删除歌曲/歌单。
- 用 23 句话概括你的前端设计目标(例如:强调简单易用、突出关键操作、减少页面跳转等)。
## 2. 关键界面截图与说明
请通过 Markdown 语法在本节插入若干张截图(建议 36 张),每张截图下方都要有对应说明。示例:
```markdown
![播放列表总览页面](images/playlists-overview.png)
```
建议覆盖以下场景(可按实际实现情况调整):
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. 交互细节与用户体验
- 列举 23 个你刻意设计的交互细节,例如:
- 提交表单前按钮禁用 / loading 状态切换;
- 删除操作前的确认对话框;
- 成功/失败后的反馈提示toast/snackbar/banner 等)。
- 说明你在这些设计中考虑的用户体验因素(如误操作防护、反馈及时性、信息层级清晰度等)。
## 6. 自我评价与改进思路
- 回顾你的前端实现,简要评价:
- 哪些部分你认为完成得比较满意?(例如结构清晰、交互自然等)
- 哪些部分你觉得可以做得更好?(例如视觉设计、响应式布局、性能优化等)
- 如果有更多时间,你会优先在哪些方面改进这个前端界面?为什么?

247
README.md Normal file
View 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.md10 分)
撰写后端与系统设计报告,建议 15003000 字,包括:
- 系统架构与数据建模
- 业务逻辑与事务
- 安全与访问控制
- 测试策略与质量保障
- AI 协同开发反思
- 总结与自我评估
### FRONTEND.md10 分)
撰写前端界面与交互设计报告,建议 8001500 字,包括:
- 前端技术栈与使用流程
- 关键界面截图与说明36 张)
- 组件划分与状态管理
- 与后端的对接方式
- 交互细节与用户体验
- 自我评价与改进思路
---
## 五、评分构成
| 项目 | 分值 | 说明 |
|------|------|------|
| 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
View File

@ -0,0 +1,46 @@
# VibeVault 期末大作业报告:后端与系统设计
> 请使用中文完成报告,建议 15003000 字。你可以使用任意 Markdown 编辑工具。
## 1. 系统概览与架构设计
- 简要描述你的系统功能(例如:歌单管理、歌曲管理、用户管理等)。
- 画出或文字说明你的分层结构Controller / Service / Repository并说明每一层的主要职责。
- 解释你如何在代码中体现"高内聚、低耦合"和"依赖倒置"。
## 2. 数据建模与持久化
- 描述你的核心实体(如 `User`, `Playlist`, `Song`)及它们之间的关系(如 One-to-Many, Many-to-One
- 解释你选择的主键策略(如 `@GeneratedValue`)和约束(如唯一性、非空约束)的设计理由。
- 如果你对数据库结构做了扩展或修改,请在此详细说明。
## 3. 业务逻辑与事务
- 选择 12 个你认为最关键的业务用例(例如"批量向歌单添加歌曲并保持原子性"),描述其业务流程。
- 说明你在这些用例中如何使用事务(如 `@Transactional`),以及这样设计的原因。
- 如果你实现了高级查询(如模糊搜索、复制歌单),请说明实现方式与性能考虑。
## 4. 安全与访问控制
- 描述你的认证方案(如 JWT以及如何将用户身份传递给业务层。
- 说明你的授权策略:谁可以删除/修改谁的歌单?你是如何在代码中实现"所有权检查"的?
- 如果你实现了更多安全特性(如密码加密、角色/权限),请在此展开。
## 5. 测试策略与质量保障
- 概述你为本项目编写的测试类型(单元测试、集成测试等)及覆盖的主要场景。
- 列举 12 个你通过测试发现并修复的典型 bug说明问题成因和修复思路。
- 反思:如果以后需要继续扩展本项目,你认为当前的测试体系是否足以支撑频繁重构?为什么?
## 6. AI 协同开发反思
- 具体描述 23 个你在项目中使用大语言模型(如 ChatGPT、Cursor 等)协助开发的案例:
- 你给出了什么样的 Prompt
- 得到的回答中哪些部分是直接采用的?哪些部分你进行了修改或重写?
- 分析在本项目中AI 对你最大的帮助是什么?最容易"误导"你的地方又是什么?
- 总结:你会如何在未来的工程实践中规范、有效地使用 AI 作为编码伙伴?
## 7. 总结与自我评估
- 总结你在本项目中最重要的三点收获(可以是技术、思维方式或协作方式)。
- 按照课程要求的大作业评分维度(功能、架构、测试、安全、文档),给自己一个客观的自评分,并简要说明理由。

54
build.gradle.kts Normal file
View 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
View 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

Binary file not shown.

View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
rootProject.name = "vibevault-final"

View 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);
}
}

View 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 过滤器
* - 禁用 CSRFREST 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();
}
}

View 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) {}

View File

@ -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)
}

View 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
) {
}

View 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
) {
}

View 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
) {
}

View File

@ -0,0 +1,9 @@
package com.vibevault.dto;
public record SongDTO(
Long id,
String title,
String artist,
int durationInSeconds
) {
}

View File

@ -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]: 实现通用异常处理器
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View 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: 实现移除歌曲逻辑
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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]: 添加高级查询方法
}

View 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: 添加必要的查询方法
}

View File

@ -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);
}
}

View 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));
}
}

View 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);
}

View 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
}
}

View 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

View 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

View 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());
}
}

View 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");
}
}