Initial commit

This commit is contained in:
2311061215 2025-12-15 13:47:16 +08:00
commit 6b6f5c4dd6
39 changed files with 2382 additions and 0 deletions

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

32
FRONTEND.md Normal file
View File

@ -0,0 +1,32 @@
# 前端开发反思报告
> 请参考 `FRONTEND_GUIDE.md` 了解写作要求。建议 6001200 字。
---
## 1. 我的界面展示
<!--
截图使用说明:
1. 将截图保存到 images/ 目录下
2. 使用相对路径引用:![描述](images/你的截图.png)
3. 建议 3-6 张截图,每张下方用 2-3 句话说明
示例:
![歌单列表页面](images/playlists.png)
这是歌单列表页面,展示了...
-->
## 2. 我遇到的最大挑战
<!-- 描述一个具体的前端问题、你的排查过程、以及最终如何解决 -->
## 3. 如果重新做一遍
<!-- 回顾你的前端实现,哪些地方可以做得更好? -->

79
FRONTEND_GUIDE.md Normal file
View File

@ -0,0 +1,79 @@
# 前端开发反思报告 写作指南
> **字数**6001200 字
> **形式**:自由叙述,重点展示你的界面和思考
> **核心**:我们想看到**你的界面设计**和**开发过程中的思考**
---
## 写作要求
请围绕以下 **三个问题** 展开:
### 1. 展示你的界面(约 5 分)
**必须包含 36 张截图**,每张截图下方要有说明:
- 截图应覆盖主要功能界面(如歌单列表、创建/编辑、歌曲管理等)
- 每张截图下方用 23 句话说明:
- 这个界面是做什么的?
- 用户在这里可以完成哪些操作?
- 你在这个界面中设计了哪些交互细节?
> 💡 截图是前端报告的核心价值,让读者"看到"你的成果。说明要简洁但信息量充足。
**📁 截图保存位置**
1. 将截图文件保存到仓库根目录下的 `images/` 文件夹
2. 支持 PNG、JPG、GIF 格式
3. 建议命名清晰,如 `playlist-list.png`、`create-form.png`
**示例格式**
```markdown
![歌单列表页面](images/playlists.png)
这是歌单列表页面,用户可以查看所有歌单。我设计了...
```
### 2. 你在前端开发中遇到的最大挑战是什么?(约 3 分)
- 描述一个具体的前端问题如状态管理混乱、组件通信困难、API 对接错误等)
- 说明你的排查和解决过程
- 从中学到了什么?
> 💡 好的回答会展现真实的前端开发痛点,而不是"一切顺利"。
### 3. 如果重新做一遍,你会如何改进?(约 2 分)
- 回顾你的前端实现,哪些地方可以做得更好?
- 如果时间充裕,你会优先改进什么?(如性能优化、响应式设计、代码组织等)
> 💡 诚实地承认不足并提出改进思路,这比假装完美更有价值。
---
## 评分说明
| 维度 | 分值 | 评分标准 |
|------|------|----------|
| 界面展示 | 5 分 | 截图数量充足(3-6张)、说明清晰、能展现主要功能 |
| 问题解决 | 3 分 | 问题描述具体、解决过程真实、有明确的学习收获 |
| 反思改进 | 2 分 | 能识别不足、提出合理的改进思路 |
---
## 不需要写的内容
- ❌ 技术栈的详细罗列("我用了 React + Vite..."
- ❌ 组件结构的详细说明(代码本身会说话)
- ❌ API 调用的详细代码(除非是为了说明某个问题)
- ❌ "我学到了很多"之类的空话
---
## 示例片段
> **不好的写法**
> "我使用了 React 18组件分为页面组件和通用组件状态管理使用了 Context..."
>
> **好的写法**
> "在实现歌单列表时,我最初把所有数据都放在组件内部 state导致每次刷新都要重新请求。后来我把数据提升到 Context不仅减少了重复请求还能在多个组件间共享状态。这让我理解了状态提升的重要性..."

252
README.md Normal file
View 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` 文件中撰写后端开发反思报告,建议 **8001500 字**,围绕以下三个问题展开:
1. **问题解决4 分)**:你遇到的最大挑战是什么?你是如何解决的?
2. **反思深度3 分)**:如果重新做一遍,你会有什么不同的设计决策?
3. **AI 使用3 分)**:你如何使用 AI 辅助开发?有什么经验教训?
> ⚠️ 我们想听到的是**你的思考**,而非代码的复述。
### FRONTEND.md - 前端开发反思报告10 分)
`FRONTEND.md` 文件中撰写前端开发反思报告,建议 **6001200 字**,围绕以下三个问题展开:
1. **界面展示5 分)**:提供 36 张截图展示你的界面,每张配简要说明
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
View File

@ -0,0 +1,23 @@
# 后端开发反思报告
> 请参考 `REPORT_GUIDE.md` 了解写作要求。建议 8001500 字。
---
## 1. 我遇到的最大挑战
<!-- 描述一个具体的问题、你的排查过程、以及最终如何解决 -->
## 2. 如果重新做一遍
<!-- 回顾你的设计决策,哪些地方可以做得更好? -->
## 3. AI 协同开发经验
<!-- 举 1-2 个具体例子,分享 AI 帮助你和误导你的经历 -->

64
REPORT_GUIDE.md Normal file
View File

@ -0,0 +1,64 @@
# 后端开发反思报告 写作指南
> **字数**8001500 字
> **形式**:自由叙述,不必拘泥于固定格式
> **核心**:我们想听到的是**你的思考**,而非代码的复述
---
## 写作要求
请围绕以下 **三个问题** 展开你的反思:
### 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
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 Normal 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

0
images/.gitkeep Normal file
View File

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