generated from Java-2025Fall/final-vibevault-template
Initial commit
This commit is contained in:
commit
6b6f5c4dd6
390
.gitea/workflows/autograde.yml
Normal file
390
.gitea/workflows/autograde.yml
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
name: autograde-final-vibevault
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'submit' # 仍然允许标签触发
|
||||||
|
- 'submit-*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 检查是否应该触发 CI(仅在 commit message 包含 “完成作业” 时执行)
|
||||||
|
check-trigger:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
outputs:
|
||||||
|
should_run: ${{ steps.check.outputs.trigger }}
|
||||||
|
steps:
|
||||||
|
- name: Check commit message for trigger keyword
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
COMMIT_MSG="${{ github.event.head_commit.message || '' }}"
|
||||||
|
echo "Commit message: $COMMIT_MSG"
|
||||||
|
if echo "$COMMIT_MSG" | grep -q "完成作业"; then
|
||||||
|
echo "trigger=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ Commit contains \"完成作业\",即将执行评分"
|
||||||
|
else
|
||||||
|
echo "trigger=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "⛔ 只有包含“完成作业”的提交才会执行自动评分" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
grade:
|
||||||
|
needs: check-trigger
|
||||||
|
if: needs.check-trigger.outputs.should_run == 'true'
|
||||||
|
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.aliyun.com|g' "$f" || true
|
||||||
|
sed -i -E 's|https?://security.debian.org|http://mirrors.aliyun.com/debian-security|g' "$f" || true
|
||||||
|
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.aliyun.com|g' "$f" || true
|
||||||
|
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.aliyun.com|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 \
|
||||||
|
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
|
||||||
|
fonts-noto-cjk fonts-wqy-microhei
|
||||||
|
pip3 install --break-system-packages python-dotenv requests markdown weasyprint -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com
|
||||||
|
# 刷新字体缓存
|
||||||
|
fc-cache -f -v > /dev/null 2>&1 || true
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
- name: Configure Gradle mirror (Aliyun)
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
cat > ~/.gradle/init.gradle << 'EOF'
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
mavenLocal()
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/spring' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/spring-plugin' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "✅ Gradle configured to use Aliyun mirror"
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory ${{ github.workspace }}
|
||||||
|
git init
|
||||||
|
# Use token for authentication (required for private repos)
|
||||||
|
REPO_URL="${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
AUTH_URL=$(echo "$REPO_URL" | sed "s|://|://${GITHUB_TOKEN}@|")
|
||||||
|
git remote add origin "$AUTH_URL"
|
||||||
|
git fetch --depth=1 origin ${{ github.sha }}
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Fix permissions
|
||||||
|
run: chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||||
|
|
||||||
|
- name: Fetch hidden tests and grading scripts
|
||||||
|
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 not set!"
|
||||||
|
echo "Cannot fetch grading scripts - aborting."
|
||||||
|
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
|
||||||
|
|
||||||
|
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 tests and grading scripts 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 grading scripts (from tests repo, cannot be modified by students) =====
|
||||||
|
if [ -d "_priv_tests/autograde" ]; then
|
||||||
|
# Remove any local .autograde (prevent student tampering)
|
||||||
|
rm -rf .autograde
|
||||||
|
mkdir -p .autograde
|
||||||
|
cp _priv_tests/autograde/*.py .autograde/
|
||||||
|
cp _priv_tests/autograde/*.sh .autograde/ 2>/dev/null || true
|
||||||
|
echo "✅ Grading scripts copied from tests repo"
|
||||||
|
else
|
||||||
|
echo "❌ No autograde directory in tests repo!"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -rf _priv_tests
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
run: |
|
||||||
|
# LLM env vars are injected by Runner config (LLM_API_KEY, LLM_API_URL, LLM_MODEL)
|
||||||
|
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 }}
|
||||||
|
run: |
|
||||||
|
# LLM env vars are injected by Runner config (LLM_API_KEY, LLM_API_URL, LLM_MODEL)
|
||||||
|
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: Generate PDF report
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -f final_grade.json ]; then
|
||||||
|
# 读取学生信息文件(如果存在)
|
||||||
|
STUDENT_ID=""
|
||||||
|
STUDENT_NAME=""
|
||||||
|
CLASS_NAME=""
|
||||||
|
|
||||||
|
if [ -f .student_info.json ]; then
|
||||||
|
STUDENT_ID=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('student_id',''))" 2>/dev/null || echo "")
|
||||||
|
STUDENT_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('name',''))" 2>/dev/null || echo "")
|
||||||
|
CLASS_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('class_name',''))" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 如果没有学生信息文件,从仓库名提取学号
|
||||||
|
if [ -z "$STUDENT_ID" ]; then
|
||||||
|
STUDENT_ID=$(echo "$REPO" | sed -n 's/.*-stu[_-]\?\(st\)\?\([0-9]*\)$/\2/p')
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 ./.autograde/generate_pdf_report.py \
|
||||||
|
--report REPORT.md \
|
||||||
|
--frontend FRONTEND.md \
|
||||||
|
--grade final_grade.json \
|
||||||
|
--images images \
|
||||||
|
--out grade_report.pdf \
|
||||||
|
--student-id "$STUDENT_ID" \
|
||||||
|
--student-name "$STUDENT_NAME" \
|
||||||
|
--class-name "$CLASS_NAME" \
|
||||||
|
--commit-sha "$COMMIT_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload report to student repo
|
||||||
|
if: env.RUNNER_METADATA_TOKEN != ''
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
# 上传 PDF 或 Markdown 报告到学生仓库
|
||||||
|
REPORT_FILE=""
|
||||||
|
if [ -f grade_report.pdf ]; then
|
||||||
|
REPORT_FILE="grade_report.pdf"
|
||||||
|
elif [ -f grade_report.md ]; then
|
||||||
|
REPORT_FILE="grade_report.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REPORT_FILE" ]; then
|
||||||
|
# 使用内部地址
|
||||||
|
API_URL="http://gitea:3000/api/v1"
|
||||||
|
SHORT_SHA=$(echo "$COMMIT_SHA" | cut -c1-7)
|
||||||
|
DEST_PATH="reports/grade_report_${SHORT_SHA}.${REPORT_FILE##*.}"
|
||||||
|
|
||||||
|
# Base64 编码并保存到临时文件(避免命令行参数过长)
|
||||||
|
CONTENT=$(base64 -w 0 "$REPORT_FILE")
|
||||||
|
|
||||||
|
# 创建请求 JSON 文件
|
||||||
|
cat > /tmp/upload_request.json << EOF
|
||||||
|
{"message": "Add grade report for $SHORT_SHA", "content": "$CONTENT"}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 先尝试 POST 创建新文件
|
||||||
|
RESULT=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||||
|
-d @/tmp/upload_request.json)
|
||||||
|
|
||||||
|
if echo "$RESULT" | grep -q '"content"'; then
|
||||||
|
echo "✅ Report uploaded to $DEST_PATH"
|
||||||
|
else
|
||||||
|
# POST 失败,可能文件已存在,尝试获取 SHA 并 PUT 更新
|
||||||
|
echo "POST failed, trying PUT with SHA..."
|
||||||
|
SHA=$(curl -s -H "Authorization: token $TOKEN" \
|
||||||
|
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('sha','') if isinstance(d,dict) and 'sha' in d else '')" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$SHA" ]; then
|
||||||
|
# 创建更新请求 JSON 文件
|
||||||
|
cat > /tmp/upload_request.json << EOF
|
||||||
|
{"message": "Update grade report for $SHORT_SHA", "content": "$CONTENT", "sha": "$SHA"}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RESULT=$(curl -s -X PUT -H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||||
|
-d @/tmp/upload_request.json)
|
||||||
|
|
||||||
|
if echo "$RESULT" | grep -q '"content"'; then
|
||||||
|
echo "✅ Report updated at $DEST_PATH"
|
||||||
|
else
|
||||||
|
echo "⚠️ Failed to update report: $RESULT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ Could not get file SHA, upload failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
rm -f /tmp/upload_request.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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_TOKEN != ''
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
# 使用当前组织的 course-metadata 仓库,而不是 Runner 配置中的硬编码值
|
||||||
|
METADATA_REPO: ${{ github.repository_owner }}/course-metadata
|
||||||
|
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||||
|
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||||
|
STUDENT_REPO: ${{ github.repository }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
run: |
|
||||||
|
if [ -f metadata.json ]; then
|
||||||
|
python3 ./.autograde/upload_metadata.py \
|
||||||
|
--metadata-file metadata.json \
|
||||||
|
--metadata-repo "${METADATA_REPO}" \
|
||||||
|
--branch "${METADATA_BRANCH:-main}" \
|
||||||
|
--student-repo "${STUDENT_REPO}" \
|
||||||
|
--run-id "${RUN_ID}" \
|
||||||
|
--commit-sha "${COMMIT_SHA}" \
|
||||||
|
--workflow grade \
|
||||||
|
--server-url "${SERVER_URL}" \
|
||||||
|
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||||
|
fi
|
||||||
|
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Application
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Test outputs (keep for local debugging)
|
||||||
|
# junit.xml
|
||||||
|
# grade.json
|
||||||
|
# summary.md
|
||||||
|
|
||||||
32
FRONTEND.md
Normal file
32
FRONTEND.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 前端开发反思报告
|
||||||
|
|
||||||
|
> 请参考 `FRONTEND_GUIDE.md` 了解写作要求。建议 600–1200 字。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 我的界面展示
|
||||||
|
|
||||||
|
<!--
|
||||||
|
截图使用说明:
|
||||||
|
1. 将截图保存到 images/ 目录下
|
||||||
|
2. 使用相对路径引用:
|
||||||
|
3. 建议 3-6 张截图,每张下方用 2-3 句话说明
|
||||||
|
|
||||||
|
示例:
|
||||||
|

|
||||||
|
这是歌单列表页面,展示了...
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2. 我遇到的最大挑战
|
||||||
|
|
||||||
|
<!-- 描述一个具体的前端问题、你的排查过程、以及最终如何解决 -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 3. 如果重新做一遍
|
||||||
|
|
||||||
|
<!-- 回顾你的前端实现,哪些地方可以做得更好? -->
|
||||||
|
|
||||||
|
|
||||||
79
FRONTEND_GUIDE.md
Normal file
79
FRONTEND_GUIDE.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# 前端开发反思报告 写作指南
|
||||||
|
|
||||||
|
> **字数**:600–1200 字
|
||||||
|
> **形式**:自由叙述,重点展示你的界面和思考
|
||||||
|
> **核心**:我们想看到**你的界面设计**和**开发过程中的思考**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 写作要求
|
||||||
|
|
||||||
|
请围绕以下 **三个问题** 展开:
|
||||||
|
|
||||||
|
### 1. 展示你的界面(约 5 分)
|
||||||
|
|
||||||
|
**必须包含 3–6 张截图**,每张截图下方要有说明:
|
||||||
|
|
||||||
|
- 截图应覆盖主要功能界面(如歌单列表、创建/编辑、歌曲管理等)
|
||||||
|
- 每张截图下方用 2–3 句话说明:
|
||||||
|
- 这个界面是做什么的?
|
||||||
|
- 用户在这里可以完成哪些操作?
|
||||||
|
- 你在这个界面中设计了哪些交互细节?
|
||||||
|
|
||||||
|
> 💡 截图是前端报告的核心价值,让读者"看到"你的成果。说明要简洁但信息量充足。
|
||||||
|
|
||||||
|
**📁 截图保存位置**:
|
||||||
|
1. 将截图文件保存到仓库根目录下的 `images/` 文件夹
|
||||||
|
2. 支持 PNG、JPG、GIF 格式
|
||||||
|
3. 建议命名清晰,如 `playlist-list.png`、`create-form.png`
|
||||||
|
|
||||||
|
**示例格式**:
|
||||||
|
```markdown
|
||||||
|

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