完成作业
Some checks failed
autograde-final-vibevault / check-trigger (push) Successful in 3s
autograde-final-vibevault / grade (push) Failing after 39s

This commit is contained in:
1234 2025-12-23 19:17:44 +08:00
commit 208e089093
72 changed files with 3143 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

89
DATABASE_CONNECTION.md Normal file
View File

@ -0,0 +1,89 @@
# VibeVault 数据库连接配置
## 远程数据库连接信息
### 生产环境配置
- **数据库类型**: PostgreSQL
- **主机地址**: 49.234.193.192
- **端口**: 5432
- **数据库名**: vibevault
- **用户名**: postgres
- **密码**: postgres
### 配置文件说明
#### 1. 开发环境 (application.properties)
默认使用H2内存数据库适合本地开发和测试
```properties
# 开发/测试时使用 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
```
#### 2. 生产环境 (application-prod.properties)
使用远程PostgreSQL数据库
```properties
# 生产环境使用 PostgreSQL 远程数据库
spring.datasource.url=jdbc:postgresql://49.234.193.192:5432/vibevault
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
```
### 环境切换
#### 方式1: 启动参数
```bash
# 开发环境 (默认)
./gradlew bootRun
# 生产环境
./gradlew bootRun --args='--spring.profiles.active=prod'
```
#### 方式2: 环境变量
```bash
# 设置环境变量
set SPRING_PROFILES_ACTIVE=prod
./gradlew bootRun
```
#### 方式3: 系统属性
```bash
# 通过系统属性
./gradlew bootRun -Dspring.profiles.active=prod
```
### 数据库连接测试
项目包含数据库连接测试类 `DatabaseConnectionTest`,可以验证与远程数据库的连接:
```bash
# 运行数据库连接测试
./gradlew test --tests DatabaseConnectionTest
```
### 注意事项
1. **网络连接**: 确保可以访问远程数据库服务器 `49.234.193.192:5432`
2. **防火墙**: 检查本地防火墙设置确保5432端口未被阻止
3. **数据库权限**: 确保数据库用户具有必要的读写权限
4. **连接池**: Spring Boot会自动配置Hikari连接池
### 故障排除
如果连接失败,请检查:
- 网络连接是否正常
- 数据库服务是否正在运行
- 用户名和密码是否正确
- 数据库是否存在
### 支持的数据库操作
- ✅ 自动建表 (spring.jpa.hibernate.ddl-auto=update)
- ✅ 事务管理
- ✅ 连接池
- ✅ JPA/Hibernate支持
- ✅ 数据库迁移 (如果需要)

55
FRONTEND.md Normal file
View File

@ -0,0 +1,55 @@
# 前端开发反思报告
> 请参考 `FRONTEND_GUIDE.md` 了解写作要求。建议 6001200 字。
---
## 1. 我的界面展示
<!--
截图使用说明:
1. 将截图保存到 images/ 目录下
2. 使用相对路径引用:![描述](images/你的截图.png)
3. 建议 3-6 张截图,每张下方用 2-3 句话说明
示例:
![歌单列表页面](images/playlists.png)
这是歌单列表页面,展示了...
-->
![添加歌曲弹窗](images/song-add-modal.png)
这是歌单详情页的“添加歌曲”弹窗,用于向当前歌单中新增歌曲。用户可在此填写歌曲名称、歌手(必填项)及专辑(选填项),完成后点击“添加歌曲”按钮提交。我在设计时将必填项与选填项做了标注区分,同时在弹窗底部重复放置操作按钮,避免用户滚动后需返回顶部操作。
![歌单详情(无歌曲)页面](images/playlist-detail-empty.png)
这是歌单详情页面,用于展示指定歌单的基础信息和歌曲列表。用户可在此编辑歌单信息、返回歌单列表,或通过“添加歌曲”按钮向歌单中新增歌曲。我设计了空状态提示文案,当歌单无歌曲时引导用户点击按钮添加,同时将操作按钮放置在显眼位置,降低操作成本。
![创建歌单弹窗](images/playlist-create-modal.png)
这是创建歌单的弹窗界面,用于生成新的音乐歌单。用户需填写歌单名称(必填)和歌单描述(选填),完成后点击“创建歌单”按钮即可生成新的歌单。我在此处设计了输入项的功能标注,明确区分必填与选填内容,同时将“创建歌单”按钮做了视觉强化,让核心操作更醒目。
## 2. 我遇到的最大挑战
<!-- 描述一个具体的前端问题、你的排查过程、以及最终如何解决 -->
本次开发中,最大的挑战是跨模块的数据同步问题。在实现“添加歌曲后自动更新歌单卡片的歌曲数量”功能时,我发现歌曲管理模块和歌单列表模块是独立的,添加歌曲后,歌单卡片上的“歌曲数量”不会自动更新,需要手动刷新页面才能同步。
起初我尝试在“添加歌曲”的按钮点击事件里直接去修改歌单卡片的文本内容但这种方式需要遍历DOM找到对应的歌单卡片不仅代码繁琐还容易因为歌单名称重复导致修改错误。接着我又尝试用全局变量存储歌单数据但不同模块修改全局变量后其他模块无法感知到变化依然需要手动触发刷新。
最后我采用了自定义事件的方案在歌曲管理模块添加歌曲后触发一个自定义事件 songAdded 并携带当前歌单名称歌单列表模块监听这个事件收到事件后更新对应歌单卡片的歌曲数量。这种方式既避免了DOM操作的繁琐也实现了模块间的解耦。
这次经历让我明白前端模块间的通信要尽量避免直接操作DOM或共享全局变量利用自定义事件、状态管理等方式能让代码更易维护。
## 3. 如果重新做一遍
<!-- 回顾你的前端实现,哪些地方可以做得更好? -->
回顾这次实现,我会优先做两方面的改进:
第一是添加数据持久化。当前页面刷新后创建的歌单和添加的歌曲都会丢失重新开发时我会用 localStorage 存储歌单和歌曲数据页面加载时从本地存储读取数据确保用户操作不会因为刷新而丢失。
第二是优化代码组织。这次开发把所有JS代码写在同一个 <script> 

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 # 歌单服务实现(待实现)
```
祝你顺利完成!记住:**理解永远比"跑通一次"更重要。**

44
REPORT.md Normal file
View File

@ -0,0 +1,44 @@
# 后端开发反思报告
> 请参考 `REPORT_GUIDE.md` 了解写作要求。建议 8001500 字。
---
## 1. 我遇到的最大挑战
<!-- 描述一个具体的问题、你的排查过程、以及最终如何解决 -->
  本次开发中我遇到的最大挑战是JWT认证过滤器与实体类方法不匹配引发的代码标红及功能失效问题。在整合 JwtAuthenticationFilter  User 实体类时IDE持续提示 user.getRole() 方法不存在即便我已确认在 User 类中定义了 role 字段及对应的 getter 方法。
  起初我将排查重点放在代码书写层面反复核对方法名拼写与访问修饰符甚至重新编写 getRole() 方法但问题并未解决。随后我又尝试排查Lombok插件的生效情况同样无果。在走了诸多弯路后我调整排查思路先通过清除Java语言服务器缓存并重启IDE排除了IDE缓存干扰的可能接着将目光聚焦到实体类的实例化环节。经核查发现测试代码中调用了三参数的构造方法创建 User 对象但初始的 User 类仅编写了无参和两参数构造方法导致实例化后的对象未正确初始化 role 字段进而使IDE误判 getRole() 方法无效。
  找到问题根源后我在 User 类中补充了包含 username  password  role 的三参数构造方法同时确保 getRole() 方法的访问修饰符为 public 。修改完成后代码标红消失JWT过滤器也能正常提取用户角色并完成权限认证流程。
  此次问题排查让我深刻认识到后端开发中实体类的构造方法设计与业务代码的匹配度至关重要。很多时候代码报错的根源并非方法本身存在缺陷而是对象实例化时的参数缺失。此外IDE缓存问题是高频“隐形坑”遇到代码标红时应优先排除缓存干扰再开展代码逻辑的排查工作。
## 2. 如果重新做一遍
<!-- 回顾你的设计决策,哪些地方可以做得更好? -->
  回顾本次开发过程,若能重新设计系统,我会在数据模型设计与业务逻辑分层两个方面做出优化调整。
  其一优化用户角色的存储方式采用枚举类替代字符串类型。本次开发中用户角色直接以 String 类型存储取值为 ROLE_USER  ROLE_ADMIN 。这种设计存在明显弊端一方面字符串手写输入易出现拼写错误例如将 ROLE_ADMIN 误写为 ROLE_ADMINN 会直接导致权限判断逻辑失效另一方面角色类型缺乏统一管理机制新增角色时需要在多处代码中修改字符串常量维护成本较高。重新设计时我会定义 UserRole 枚举类封装 USER  ADMIN 两个枚举值 User 实体类中使用枚举类型映射角色字段。这种方式既能借助枚举的强类型特性避免拼写错误又能实现角色类型的统一管理提升代码的健壮性与可维护性。
  其二拆分歌单复制功能的代码逻辑提升代码复用性与可测试性。本次开发中我将歌单复制功能的所有逻辑包括原歌单查询、新歌单创建、歌曲复制等全部集中在 copyPlaylist 这一个方法内导致该方法代码行数过多可读性与可维护性较差。重新设计时我会遵循单一职责原则将该功能拆分为三个粒度更细的方法 getOriginalPlaylistById() 负责查询原歌单并处理资源不存在的异常 createNewPlaylist() 负责构建新歌单的基础信息 copySongsToNewPlaylist() 负责将原歌单的歌曲复制到新歌单中。这种分层设计让每个方法只承担一项具体职责不仅便于编写单元测试还能在其他需要复制歌曲的业务场景中复用 copySongsToNewPlaylist() 方法。
## 3. AI 协同开发经验
<!-- 举 1-2 个具体例子,分享 AI 帮助你和误导你的经历 -->
在本次开发过程中AI作为高效的辅助工具为我节省了大量查阅资料的时间但同时也暴露出一些局限性让我总结出一套更高效的AI使用策略。
第一个典型场景是生成JWT服务类的核心代码。开发初期我对JJWT库的API使用并不熟悉于是向AI提问“如何基于JJWT 0.11.x版本实现JWT的生成、解析与验证功能” AI快速返回了完整的 JwtService 代码包含 generateToken  extractUsername 等核心方法其代码框架完全符合JJWT 0.11.x版本的使用规范帮我省去了翻阅官方文档的时间成本。但深入核查后发现AI提供的代码存在细节疏漏 isTokenValid 方法仅验证了用户名的一致性未判断token是否过期且未处理token解析时的异常情况。对此我在AI代码的基础上补充了 extractExpiration 方法用于判断token有效期并通过 try-catch 块捕获解析异常确保方法在token无效时能稳定返回 false 
第二个典型场景是解决实体类构造方法不匹配的问题。当 User 类因构造方法缺失导致代码标红时我向AI描述了问题现象AI准确指出了核心问题——缺少三参数构造方法并给出了正确的代码补充方案。但同时AI附带了一个错误建议即使用 new User(username: "admin", password: "123") 这种命名参数的写法而这种语法属于Kotlin语言并不适用于Java开发。我及时识别了该错误未采纳此建议而是按照Java的标准语法补充了构造方法。
基于以上两次使用经验我总结出两点核心心得大势所趋不能不用AI而且AI确实能帮助我们提升效率但也不能全信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 层,不仅代码更清晰,还能单独写单元测试了。这让我真正理解了为什么要分层..."

View File

@ -0,0 +1,24 @@
# VibeVault Production Configuration
spring.application.name=vibevault
# Production Database Configuration (PostgreSQL)
spring.datasource.url=jdbc:postgresql://49.234.193.192:5432/vibevault
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA / Hibernate for Production
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
# JWT Configuration
jwt.secret=your-production-secret-key-here-should-be-at-least-256-bits-long-for-hs256
jwt.expiration=86400000
# Server
server.port=8080
# Disable H2 Console in production
spring.h2.console.enabled=false

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 远程数据库
spring.datasource.url=jdbc:postgresql://49.234.193.192: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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

55
build.gradle.kts Normal file
View File

@ -0,0 +1,55 @@
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")
implementation("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,100 @@
package com.vibevault.config;
import com.vibevault.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.core.userdetails.UserDetailsService;
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;
private final UserDetailsService userDetailsService;
// 构造注入过滤器和用户详情服务
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, UserDetailsService userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
// 配置密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 配置认证提供者
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
// 配置认证管理器
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
// 核心安全过滤链配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 禁用CSRFREST API不需要
.csrf(csrf -> csrf.disable())
// 2. 配置接口权限
.authorizeHttpRequests(auth -> auth
// 公开接口无需认证
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("GET", "/api/playlists").permitAll()
.requestMatchers("GET", "/api/playlists/{id}").permitAll()
// 其他接口需要认证
.anyRequest().authenticated()
)
// 3. 未认证返回401默认行为确保不被覆盖
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.getWriter().write("Unauthorized: " + authException.getMessage());
})
)
// 4. 使用无状态会话JWT不需要会话
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 5. 配置JWT过滤器在用户名密码过滤器之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@ -0,0 +1,96 @@
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;
}
// POST /api/auth/register - 用户注册 状态码 201
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public RegisterResponse register(@RequestBody RegisterRequest request) {
// 检查用户名是否已存在
if (userRepository.existsByUsername(request.username())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Username already exists");
}
// 创建用户并加密密码
User user = new User();
user.setUsername(request.username());
user.setPassword(passwordEncoder.encode(request.password()));
// 默认角色设置为普通用户根据你的User类调整
user.setRole("ROLE_USER");
userRepository.save(user);
return new RegisterResponse("User registered successfully", request.username());
}
// POST /api/auth/login - 用户登录
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
// 查询用户
User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"));
// 验证密码
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password");
}
// 生成JWT token
String token = jwtService.generateToken(user.getUsername());
return new LoginResponse(token, user.getUsername());
}
}
/**
* 注册请求 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,107 @@
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;
}
// GET /api/playlists - 获取所有歌单公开
@GetMapping
public List<PlaylistDTO> getAllPlaylists() {
return playlistService.getAllPlaylists();
}
// GET /api/playlists/{id} - 获取指定歌单公开
@GetMapping("/{id}")
public PlaylistDTO getPlaylistById(@PathVariable Long id) {
return playlistService.getPlaylistById(id);
}
// POST /api/playlists - 创建歌单需认证 状态码 201
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PlaylistDTO createPlaylist(@RequestBody PlaylistCreateDTO playlistCreateDTO,
Authentication authentication) {
String username = authentication.getName();
return playlistService.createPlaylist(playlistCreateDTO.name(), username);
}
// POST /api/playlists/{id}/songs - 添加歌曲需认证 状态码 201
@PostMapping("/{id}/songs")
@ResponseStatus(HttpStatus.CREATED)
public PlaylistDTO addSongToPlaylist(@PathVariable Long id,
@RequestBody SongCreateDTO songCreateDTO,
Authentication authentication) {
String username = authentication.getName();
return playlistService.addSongToPlaylist(id, songCreateDTO, username);
}
// DELETE /api/playlists/{playlistId}/songs/{songId} - 移除歌曲需认证 状态码 204
@DeleteMapping("/{playlistId}/songs/{songId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void removeSongFromPlaylist(@PathVariable Long playlistId,
@PathVariable Long songId,
Authentication authentication) {
String username = authentication.getName();
playlistService.removeSongFromPlaylist(playlistId, songId, username);
}
// DELETE /api/playlists/{id} - 删除歌单需认证 状态码 204
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletePlaylist(@PathVariable Long id,
Authentication authentication) {
String username = authentication.getName();
playlistService.deletePlaylist(id, username);
}
// [Advanced] GET /api/playlists/search?keyword=xxx - 搜索歌单
@GetMapping("/search")
public List<PlaylistDTO> searchPlaylists(@RequestParam String keyword) {
return playlistService.searchPlaylists(keyword);
}
// [Advanced] POST /api/playlists/{id}/copy?newName=xxx - 复制歌单 状态码 201
@PostMapping("/{id}/copy")
@ResponseStatus(HttpStatus.CREATED)
public PlaylistDTO copyPlaylist(@PathVariable Long id,
@RequestParam String newName,
Authentication authentication) {
String username = authentication.getName();
return playlistService.copyPlaylist(id, newName, username);
}
}

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,40 @@
package com.vibevault.dto;
public class SongDTO {
// 1. 补全字段定义之前截图里字段没写全
private String title;
private String artist;
private Integer durationInSeconds;
public SongDTO(Long id, String title2, String artist2, int durationInSeconds2) {
//TODO Auto-generated constructor stub
}
// 2. 补全getter方法语法要完整
public String getTitle() {
return title;
}
public String getArtist() {
return artist;
}
public Integer getDurationInSeconds() {
return durationInSeconds;
}
// 3. 补全setter方法如果需要接收前端参数必须写setter
public void setTitle(String title) {
this.title = title;
}
public void setArtist(String artist) {
this.artist = artist;
}
public void setDurationInSeconds(Integer durationInSeconds) {
this.durationInSeconds = durationInSeconds;
}
}

View File

@ -0,0 +1,65 @@
package com.vibevault.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
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.util.HashMap;
import java.util.Map;
/**
* 全局异常处理器
*
* 需要实现
* - 捕获 ResourceNotFoundException 并返回 404 状态码
* - 捕获 UnauthorizedException 并返回 403 状态码
* - 捕获 ResponseStatusException 并返回对应状态码
* - [Advanced] 统一处理其他异常返回合适的错误响应格式
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理 ResourceNotFoundException返回404
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException ex) {
Map<String, Object> error = buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
// 处理 UnauthorizedException返回403
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Map<String, Object>> handleUnauthorized(UnauthorizedException ex) {
Map<String, Object> error = buildErrorResponse(ex.getMessage(), HttpStatus.FORBIDDEN);
return new ResponseEntity<>(error, HttpStatus.FORBIDDEN);
}
// 处理 ResponseStatusException返回对应状态码
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException ex) {
Map<String, Object> error = buildErrorResponse(ex.getReason(), ex.getStatusCode());
return new ResponseEntity<>(error, ex.getStatusCode());
}
// [Advanced] 统一处理其他异常返回500
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGenericException(Exception ex) {
Map<String, Object> error = buildErrorResponse(
"An unexpected error occurred: " + ex.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
// 构建统一的错误响应格式
private Map<String, Object> buildErrorResponse(String message, HttpStatusCode status) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", java.time.LocalDateTime.now().toString());
error.put("status", status.value());
error.put("error", status instanceof HttpStatus ? ((HttpStatus) status).getReasonPhrase() : "Unknown Error");
error.put("message", message);
return error;
}
}

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,91 @@
package com.vibevault.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/**
* 歌单实体类
*
* 需要实现:
* - 将此类映射为数据库表 "playlists"
* - id 作为自增主键
* - name 不能为空
* - 每个歌单属于一个用户多对一关系
* - 一个歌单包含多首歌曲一对多关系
* - 删除歌单时应级联删除其中的歌曲
*/
@Entity // 实现将此类映射为数据库表
@Table(name = "playlists") // 实现指定表名为"playlists"
public class Playlist {
@Id // 实现标记id为主键
@GeneratedValue(strategy = GenerationType.IDENTITY) // 实现id作为自增主键
private Long id;
@Column(nullable = false) // 实现name不能为空
private String name;
// 实现每个歌单属于一个用户多对一关系
@ManyToOne(fetch = FetchType.LAZY, optional = false) // 多对一用户不能为空
@JoinColumn(name = "user_id") // 数据库中关联用户的字段名
private User owner;
// 实现一个歌单包含多首歌曲一对多关系 + 删除歌单时级联删除歌曲
@OneToMany(mappedBy = "playlist", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Song> songs = new ArrayList<>();
public Playlist() {
}
// 带参数的构造方法方便创建歌单
public Playlist(String name, User owner) {
this.name = name;
this.owner = owner;
}
// 维护双向关系添加歌曲到歌单
public void addSong(Song song) {
songs.add(song);
song.setPlaylist(this);
}
// 维护双向关系从歌单移除歌曲
public void removeSong(Song song) {
songs.remove(song);
song.setPlaylist(null);
}
// 必要的getter/setter方法
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public User getOwner() {
return owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
public List<Song> getSongs() {
return songs;
}
public void setSongs(List<Song> songs) {
this.songs = songs;
}
}

View File

@ -0,0 +1,91 @@
package com.vibevault.model;
import jakarta.persistence.*;
/**
* 歌曲实体类
*
* 需要实现:
* - 将此类映射为数据库表 "songs"
* - id 作为自增主键
* - 每首歌曲属于一个歌单多对一关系
*/
@Entity // 实现将此类映射为数据库表
@Table(name = "songs") // 实现指定表名为"songs"
public class Song {
@Id // 实现标记id为主键
@GeneratedValue(strategy = GenerationType.IDENTITY) // 实现id作为自增主键
private Long id;
private String title;
private String artist;
private int durationInSeconds;
// 实现每首歌曲属于一个歌单多对一关系
@ManyToOne(fetch = FetchType.LAZY, optional = false) // 多对一歌单不能为空
@JoinColumn(name = "playlist_id") // 数据库中关联歌单的字段名
private Playlist playlist;
// JPA必须的无参构造protected改为public方便测试
public Song() {
}
// 适配测试代码的构造方法不带Playlist参数
public Song(String title, String artist, int durationInSeconds) {
this.title = title;
this.artist = artist;
this.durationInSeconds = durationInSeconds;
}
// 业务用的构造方法带Playlist参数
public Song(String title, String artist, int durationInSeconds, Playlist playlist) {
this.title = title;
this.artist = artist;
this.durationInSeconds = durationInSeconds;
this.playlist = playlist;
}
// 必要的getter/setter方法
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public int getDurationInSeconds() {
return durationInSeconds;
}
public void setDurationInSeconds(int durationInSeconds) {
this.durationInSeconds = durationInSeconds;
}
public Playlist getPlaylist() {
return playlist;
}
public void setPlaylist(Playlist playlist) {
this.playlist = playlist;
}
}

View File

@ -0,0 +1,82 @@
package com.vibevault.model;
import jakarta.persistence.*;
/**
* 用户实体类
* 需要实现:
* - 将此类映射为数据库表 "users"
* - id 作为自增主键
* - username 必须唯一且不能为空
* - password 不能为空
* - [Challenge] 支持用户角色 ROLE_USER, ROLE_ADMIN
*/
@Entity // 实现将此类映射为数据库表
@Table(name = "users") // 实现指定表名为"users"
public class User {
@Id // 实现标记id为主键
@GeneratedValue(strategy = GenerationType.IDENTITY) // 实现id作为自增主键
private Long id;
@Column(nullable = false, unique = true) // 实现username必须唯一且不能为空
private String username;
@Column(nullable = false) // 实现password不能为空
private String password;
// [Challenge] 支持用户角色 ROLE_USER, ROLE_ADMIN
@Column(nullable = false) // 角色字段非空
private String role = "ROLE_USER"; // 默认角色为ROLE_USER
// 无参构造JPA必须
public User() {
}
// 2参数构造用户名+密码
public User(String username, String password) {
this.username = username;
this.password = password;
}
// 3参数构造适配测试代码用户名+密码+角色
public User(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
// 必须的getter/setter方法JPA操作实体需要
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@ -0,0 +1,26 @@
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> {
// 按所有者查询歌单
List<Playlist> findByOwner(User owner);
// 按名称模糊搜索歌单忽略大小写
List<Playlist> findByNameContainingIgnoreCase(String keyword);
}

View File

@ -0,0 +1,23 @@
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> {
// 根据用户名查找用户
Optional<User> findByUsername(String username);
// 检查用户名是否已存在
boolean existsByUsername(String username);
}

View File

@ -0,0 +1,94 @@
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 static final String BEARER_PREFIX = "Bearer ";
private static final String AUTHORIZATION_HEADER = "Authorization";
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 {
// 1. 从请求头获取 Authorization
final String authHeader = request.getHeader(AUTHORIZATION_HEADER);
final String jwt;
final String username;
// 2. 检查是否以 "Bearer " 开头
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// 3. 提取 token
jwt = authHeader.substring(BEARER_PREFIX.length());
username = jwtService.extractUsername(jwt); // 从token中提取用户名
// 4. 验证token有效性 + 安全上下文未被填充
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 从数据库查询用户
User user = userRepository.findByUsername(username)
.orElse(null); // 若用户不存在跳过认证
if (user != null && jwtService.isTokenValid(jwt, user)) {
// [Challenge] 读取用户角色并构造权限列表
List<SimpleGrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority(user.getRole())
);
// 创建认证对象
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
user,
null,
authorities
);
// 设置请求详情
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 将认证对象存入安全上下文
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
// 继续执行过滤器链
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,129 @@
package com.vibevault.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.vibevault.model.User;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 服务
*
* 提供 JWT 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; // 默认 24 小时
/**
* 为用户生成 JWT token
*/
public String generateToken(String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.compact();
}
/**
* token 中提取用户名
*/
public String extractUsername(String token) {
Claims claims = extractAllClaims(token);
return claims.getSubject();
}
/**
* 验证 token 是否有效
*/
public boolean isTokenValid(String token, User user) {
try {
String username = extractUsername(token);
return username.equals(user.getUsername()) && !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 验证 token 是否有效
*/
public boolean isTokenValid(String token) {
try {
extractAllClaims(token);
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 检查 token 是否过期
*/
private boolean isTokenExpired(String token) {
Date expirationDate = extractExpiration(token);
return expirationDate.before(new Date());
}
/**
* 提取 token 的过期时间
*/
private Date extractExpiration(String token) {
Claims claims = extractAllClaims(token);
return claims.getExpiration();
}
/**
* token 中提取所有声明
*/
private Claims extractAllClaims(String token) {
// 在JJWT 0.12.x版本中使用parser()方法
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 验证 token 签名和格式
*/
public boolean validateToken(String token) {
try {
// 在JJWT 0.12.x版本中使用parser()方法和verifyWith()来验证Token
Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException e) {
System.out.println("无效的 JWT 签名");
} catch (MalformedJwtException e) {
System.out.println("无效的 JWT token");
} catch (ExpiredJwtException e) {
System.out.println("JWT token 已过期");
} catch (IllegalArgumentException e) {
System.out.println("JWT token 为空或格式错误");
}
return false;
}
}

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,221 @@
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;
import java.util.stream.Collectors;
/**
* 歌单服务实现
*
* 需要实现
* - 所有 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() {
// 获取所有歌单并转换为DTO
return playlistRepository.findAll().stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Override
public PlaylistDTO getPlaylistById(Long id) {
// 根据ID查询歌单不存在则抛异常
Playlist playlist = playlistRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("歌单不存在ID: " + id));
return toDTO(playlist);
}
@Override
@Transactional
public PlaylistDTO createPlaylist(String name, String ownerUsername) {
// 1. 查询歌单所有者
User owner = userRepository.findByUsername(ownerUsername)
.orElseThrow(() -> new ResourceNotFoundException("用户不存在,用户名: " + ownerUsername));
// 2. 创建歌单实体
Playlist playlist = new Playlist();
playlist.setName(name);
playlist.setOwner(owner);
// 3. 保存歌单并返回DTO
Playlist savedPlaylist = playlistRepository.save(playlist);
return toDTO(savedPlaylist);
}
@Override
@Transactional
public PlaylistDTO addSongToPlaylist(Long playlistId, SongCreateDTO songDTO, String username) {
// 1. 查询歌单
Playlist playlist = playlistRepository.findById(playlistId)
.orElseThrow(() -> new ResourceNotFoundException("歌单不存在ID: " + playlistId));
// 2. 检查用户权限
checkPermission(playlist, username);
// 3. 创建歌曲实体
Song song = new Song();
// 对于record类型直接使用字段名访问而不是getter方法
song.setTitle(songDTO.title());
song.setArtist(songDTO.artist());
song.setDurationInSeconds(songDTO.durationInSeconds());
song.setPlaylist(playlist);
// 4. 维护双向关系并保存
playlist.getSongs().add(song);
Playlist updatedPlaylist = playlistRepository.save(playlist);
return toDTO(updatedPlaylist);
}
@Override
@Transactional
public void removeSongFromPlaylist(Long playlistId, Long songId, String username) {
// 1. 查询歌单
Playlist playlist = playlistRepository.findById(playlistId)
.orElseThrow(() -> new ResourceNotFoundException("歌单不存在ID: " + playlistId));
// 2. 检查用户权限
checkPermission(playlist, username);
// 3. 查询歌曲并移除
Song song = playlist.getSongs().stream()
.filter(s -> s.getId().equals(songId))
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("歌曲不存在ID: " + songId));
playlist.getSongs().remove(song);
playlistRepository.save(playlist);
}
@Override
@Transactional
public void deletePlaylist(Long playlistId, String username) {
// 1. 查询歌单
Playlist playlist = playlistRepository.findById(playlistId)
.orElseThrow(() -> new ResourceNotFoundException("歌单不存在ID: " + playlistId));
// 2. 检查用户权限
checkPermission(playlist, username);
// 3. 删除歌单
playlistRepository.delete(playlist);
}
// ========== Advanced 方法 ==========
@Override
public List<PlaylistDTO> searchPlaylists(String keyword) {
// 按名称模糊搜索歌单
return playlistRepository.findByNameContainingIgnoreCase(keyword).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Override
@Transactional
public PlaylistDTO copyPlaylist(Long playlistId, String newName, String username) {
// 1. 查询原歌单
Playlist original = playlistRepository.findById(playlistId)
.orElseThrow(() -> new ResourceNotFoundException("原歌单不存在ID: " + playlistId));
// 2. 查询目标用户新所有者
User newOwner = userRepository.findByUsername(username)
.orElseThrow(() -> new ResourceNotFoundException("用户不存在,用户名: " + username));
// 3. 创建新歌单
Playlist copy = new Playlist();
copy.setName(newName);
copy.setOwner(newOwner);
// 4. 复制歌曲
original.getSongs().forEach(song -> {
Song newSong = new Song();
newSong.setTitle(song.getTitle());
newSong.setArtist(song.getArtist());
newSong.setDurationInSeconds(song.getDurationInSeconds());
newSong.setPlaylist(copy);
copy.getSongs().add(newSong);
});
// 5. 保存并返回DTO
Playlist savedCopy = playlistRepository.save(copy);
return toDTO(savedCopy);
}
// ========== 辅助方法 ==========
/**
* Playlist 实体转换为 DTO
*/
private PlaylistDTO toDTO(Playlist playlist) {
List<SongDTO> songDTOs = playlist.getSongs().stream()
.map(this::toSongDTO)
.collect(Collectors.toList());
return new PlaylistDTO(
playlist.getId(),
playlist.getName(),
playlist.getOwner().getUsername(),
songDTOs
);
}
/**
* Song 实体转换为 DTO
*/
private SongDTO toSongDTO(Song song) {
return new SongDTO(
song.getId(),
song.getTitle(),
song.getArtist(),
song.getDurationInSeconds()
);
}
/**
* [Challenge] 检查用户是否有权限操作指定歌单
* 规则歌单所有者或管理员可以操作
*/
private void checkPermission(Playlist playlist, String username) {
// 查询当前操作的用户
User operator = userRepository.findByUsername(username)
.orElseThrow(() -> new ResourceNotFoundException("操作用户不存在,用户名: " + username));
// 权限规则所有者 管理员假设角色为"ROLE_ADMIN"
boolean isOwner = playlist.getOwner().getUsername().equals(username);
boolean isAdmin = "ROLE_ADMIN".equals(operator.getRole());
if (!isOwner && !isAdmin) {
throw new UnauthorizedException("无权限操作此歌单");
}
}
}
//...

View File

@ -0,0 +1,24 @@
# VibeVault Production Configuration
spring.application.name=vibevault
# Production Database Configuration (PostgreSQL)
spring.datasource.url=jdbc:postgresql://49.234.193.192:5432/vibevault
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA / Hibernate for Production
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
# JWT Configuration
jwt.secret=your-production-secret-key-here-should-be-at-least-256-bits-long-for-hs256
jwt.expiration=86400000
# Server
server.port=8080
# Disable H2 Console in production
spring.h2.console.enabled=false

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 远程数据库
spring.datasource.url=jdbc:postgresql://49.234.193.192: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,59 @@
package com.vibevault;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 数据库连接测试类
* 用于验证与远程PostgreSQL数据库的连接
*/
@SpringBootTest(properties = {
"spring.profiles.active=prod"
})
public class DatabaseConnectionTest {
@Autowired
private DataSource dataSource;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldConnectToDatabase() throws SQLException {
// 测试数据源是否已正确配置
assertThat(dataSource).isNotNull();
// 测试数据库连接
try (Connection connection = dataSource.getConnection()) {
assertThat(connection).isNotNull();
assertThat(connection.isValid(1000)).isTrue();
// 测试数据库类型
String databaseProductName = connection.getMetaData().getDatabaseProductName();
assertThat(databaseProductName).isEqualTo("PostgreSQL");
}
}
@Test
void shouldExecuteQuery() {
// 测试简单查询
Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
assertThat(result).isEqualTo(1);
}
@Test
void shouldCheckDatabaseVersion() {
// 检查PostgreSQL版本
String version = jdbcTemplate.queryForObject("SELECT version()", String.class);
assertThat(version).contains("PostgreSQL");
System.out.println("Database Version: " + version);
}
}

View File

@ -0,0 +1,121 @@
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).getTitle());
}
}

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