generated from Java-2025Fall/final-vibevault-template
完成作业
This commit is contained in:
commit
208e089093
390
.gitea/workflows/autograde.yml
Normal file
390
.gitea/workflows/autograde.yml
Normal file
@ -0,0 +1,390 @@
|
||||
name: autograde-final-vibevault
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'submit' # 仍然允许标签触发
|
||||
- 'submit-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# 检查是否应该触发 CI(仅在 commit message 包含 “完成作业” 时执行)
|
||||
check-trigger:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: alpine:latest
|
||||
outputs:
|
||||
should_run: ${{ steps.check.outputs.trigger }}
|
||||
steps:
|
||||
- name: Check commit message for trigger keyword
|
||||
id: check
|
||||
run: |
|
||||
COMMIT_MSG="${{ github.event.head_commit.message || '' }}"
|
||||
echo "Commit message: $COMMIT_MSG"
|
||||
if echo "$COMMIT_MSG" | grep -q "完成作业"; then
|
||||
echo "trigger=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Commit contains \"完成作业\",即将执行评分"
|
||||
else
|
||||
echo "trigger=false" >> $GITHUB_OUTPUT
|
||||
echo "⛔ 只有包含“完成作业”的提交才会执行自动评分" >&2
|
||||
fi
|
||||
|
||||
grade:
|
||||
needs: check-trigger
|
||||
if: needs.check-trigger.outputs.should_run == 'true'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: gradle:9.0-jdk21
|
||||
options: --user root
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Install dependencies (CN mirror)
|
||||
run: |
|
||||
set -e
|
||||
# 替换 Debian/Ubuntu 源为阿里云镜像
|
||||
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i -E 's|https?://deb.debian.org|http://mirrors.aliyun.com|g' "$f" || true
|
||||
sed -i -E 's|https?://security.debian.org|http://mirrors.aliyun.com/debian-security|g' "$f" || true
|
||||
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.aliyun.com|g' "$f" || true
|
||||
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.aliyun.com|g' "$f" || true
|
||||
done
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync \
|
||||
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
|
||||
fonts-noto-cjk fonts-wqy-microhei
|
||||
pip3 install --break-system-packages python-dotenv requests markdown weasyprint -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com
|
||||
# 刷新字体缓存
|
||||
fc-cache -f -v > /dev/null 2>&1 || true
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Configure Gradle mirror (Aliyun)
|
||||
run: |
|
||||
mkdir -p ~/.gradle
|
||||
cat > ~/.gradle/init.gradle << 'EOF'
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/spring' }
|
||||
maven { url 'https://maven.aliyun.com/repository/spring-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "✅ Gradle configured to use Aliyun mirror"
|
||||
|
||||
- name: Checkout code
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git config --global --add safe.directory ${{ github.workspace }}
|
||||
git init
|
||||
# Use token for authentication (required for private repos)
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}.git"
|
||||
AUTH_URL=$(echo "$REPO_URL" | sed "s|://|://${GITHUB_TOKEN}@|")
|
||||
git remote add origin "$AUTH_URL"
|
||||
git fetch --depth=1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Fix permissions
|
||||
run: chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
|
||||
|
||||
- name: Fetch hidden tests and grading scripts
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
|
||||
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
|
||||
|
||||
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
|
||||
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set!"
|
||||
echo "Cannot fetch grading scripts - aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve Gitea Host
|
||||
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
|
||||
HOST="$EXTERNAL_GITEA_HOST"
|
||||
elif [ -n "$GITEA_ROOT_URL" ]; then
|
||||
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
|
||||
else
|
||||
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
|
||||
|
||||
# Extract assignment ID
|
||||
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
|
||||
elif echo "$REPO_NAME" | grep -q -- '-template'; then
|
||||
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
|
||||
else
|
||||
ASSIGNMENT_ID="final-vibevault"
|
||||
fi
|
||||
|
||||
echo "📥 Fetching tests and grading scripts from ${ORG}/${ASSIGNMENT_ID}-tests..."
|
||||
|
||||
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
|
||||
|
||||
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
|
||||
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== Copy grading scripts (from tests repo, cannot be modified by students) =====
|
||||
if [ -d "_priv_tests/autograde" ]; then
|
||||
# Remove any local .autograde (prevent student tampering)
|
||||
rm -rf .autograde
|
||||
mkdir -p .autograde
|
||||
cp _priv_tests/autograde/*.py .autograde/
|
||||
cp _priv_tests/autograde/*.sh .autograde/ 2>/dev/null || true
|
||||
echo "✅ Grading scripts copied from tests repo"
|
||||
else
|
||||
echo "❌ No autograde directory in tests repo!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy Java tests
|
||||
if [ -d "_priv_tests/java/src/test" ]; then
|
||||
rsync -a _priv_tests/java/src/test/ src/test/
|
||||
echo "✅ Private tests copied"
|
||||
fi
|
||||
|
||||
# Copy test_groups.json if exists
|
||||
if [ -f "_priv_tests/test_groups.json" ]; then
|
||||
cp _priv_tests/test_groups.json .
|
||||
echo "✅ test_groups.json copied"
|
||||
fi
|
||||
|
||||
# Copy LLM rubrics
|
||||
if [ -d "_priv_tests/llm" ]; then
|
||||
mkdir -p .llm_rubrics
|
||||
cp _priv_tests/llm/*.json .llm_rubrics/ 2>/dev/null || true
|
||||
echo "✅ LLM rubrics copied"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf _priv_tests
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
gradle test --no-daemon || true
|
||||
|
||||
# Collect all JUnit XML reports
|
||||
find build/test-results/test -name "TEST-*.xml" -exec cat {} \; > all_tests.xml 2>/dev/null || true
|
||||
|
||||
# Also try to get a single combined report
|
||||
if [ -f build/test-results/test/TEST-*.xml ]; then
|
||||
cp build/test-results/test/TEST-*.xml junit.xml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Grade programming tests
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Use extended grading script with group support
|
||||
python3 ./.autograde/grade_grouped.py \
|
||||
--junit-dir build/test-results/test \
|
||||
--groups test_groups.json \
|
||||
--out grade.json \
|
||||
--summary summary.md
|
||||
|
||||
- name: Grade REPORT.md
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# LLM env vars are injected by Runner config (LLM_API_KEY, LLM_API_URL, LLM_MODEL)
|
||||
if [ -f REPORT.md ] && [ -f .llm_rubrics/rubric_report.json ]; then
|
||||
python3 ./.autograde/llm_grade.py \
|
||||
--question "请评估这份后端与系统设计报告" \
|
||||
--answer REPORT.md \
|
||||
--rubric .llm_rubrics/rubric_report.json \
|
||||
--out report_grade.json \
|
||||
--summary report_summary.md
|
||||
echo "✅ REPORT.md graded"
|
||||
else
|
||||
echo '{"total": 0, "flags": ["missing_file"]}' > report_grade.json
|
||||
echo "⚠️ REPORT.md or rubric not found"
|
||||
fi
|
||||
|
||||
- name: Grade FRONTEND.md
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# LLM env vars are injected by Runner config (LLM_API_KEY, LLM_API_URL, LLM_MODEL)
|
||||
if [ -f FRONTEND.md ] && [ -f .llm_rubrics/rubric_frontend.json ]; then
|
||||
python3 ./.autograde/llm_grade.py \
|
||||
--question "请评估这份前端界面与交互设计报告" \
|
||||
--answer FRONTEND.md \
|
||||
--rubric .llm_rubrics/rubric_frontend.json \
|
||||
--out frontend_grade.json \
|
||||
--summary frontend_summary.md
|
||||
echo "✅ FRONTEND.md graded"
|
||||
else
|
||||
echo '{"total": 0, "flags": ["missing_file"]}' > frontend_grade.json
|
||||
echo "⚠️ FRONTEND.md or rubric not found"
|
||||
fi
|
||||
|
||||
- name: Aggregate grades
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python3 ./.autograde/aggregate_final_grade.py \
|
||||
--programming grade.json \
|
||||
--report report_grade.json \
|
||||
--frontend frontend_grade.json \
|
||||
--out final_grade.json \
|
||||
--summary final_summary.md
|
||||
|
||||
- name: Generate PDF report
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -f final_grade.json ]; then
|
||||
# 读取学生信息文件(如果存在)
|
||||
STUDENT_ID=""
|
||||
STUDENT_NAME=""
|
||||
CLASS_NAME=""
|
||||
|
||||
if [ -f .student_info.json ]; then
|
||||
STUDENT_ID=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('student_id',''))" 2>/dev/null || echo "")
|
||||
STUDENT_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('name',''))" 2>/dev/null || echo "")
|
||||
CLASS_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('class_name',''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# 如果没有学生信息文件,从仓库名提取学号
|
||||
if [ -z "$STUDENT_ID" ]; then
|
||||
STUDENT_ID=$(echo "$REPO" | sed -n 's/.*-stu[_-]\?\(st\)\?\([0-9]*\)$/\2/p')
|
||||
fi
|
||||
|
||||
python3 ./.autograde/generate_pdf_report.py \
|
||||
--report REPORT.md \
|
||||
--frontend FRONTEND.md \
|
||||
--grade final_grade.json \
|
||||
--images images \
|
||||
--out grade_report.pdf \
|
||||
--student-id "$STUDENT_ID" \
|
||||
--student-name "$STUDENT_NAME" \
|
||||
--class-name "$CLASS_NAME" \
|
||||
--commit-sha "$COMMIT_SHA"
|
||||
fi
|
||||
|
||||
- name: Upload report to student repo
|
||||
if: env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# 上传 PDF 或 Markdown 报告到学生仓库
|
||||
REPORT_FILE=""
|
||||
if [ -f grade_report.pdf ]; then
|
||||
REPORT_FILE="grade_report.pdf"
|
||||
elif [ -f grade_report.md ]; then
|
||||
REPORT_FILE="grade_report.md"
|
||||
fi
|
||||
|
||||
if [ -n "$REPORT_FILE" ]; then
|
||||
# 使用内部地址
|
||||
API_URL="http://gitea:3000/api/v1"
|
||||
SHORT_SHA=$(echo "$COMMIT_SHA" | cut -c1-7)
|
||||
DEST_PATH="reports/grade_report_${SHORT_SHA}.${REPORT_FILE##*.}"
|
||||
|
||||
# Base64 编码并保存到临时文件(避免命令行参数过长)
|
||||
CONTENT=$(base64 -w 0 "$REPORT_FILE")
|
||||
|
||||
# 创建请求 JSON 文件
|
||||
cat > /tmp/upload_request.json << EOF
|
||||
{"message": "Add grade report for $SHORT_SHA", "content": "$CONTENT"}
|
||||
EOF
|
||||
|
||||
# 先尝试 POST 创建新文件
|
||||
RESULT=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||
-d @/tmp/upload_request.json)
|
||||
|
||||
if echo "$RESULT" | grep -q '"content"'; then
|
||||
echo "✅ Report uploaded to $DEST_PATH"
|
||||
else
|
||||
# POST 失败,可能文件已存在,尝试获取 SHA 并 PUT 更新
|
||||
echo "POST failed, trying PUT with SHA..."
|
||||
SHA=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('sha','') if isinstance(d,dict) and 'sha' in d else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$SHA" ]; then
|
||||
# 创建更新请求 JSON 文件
|
||||
cat > /tmp/upload_request.json << EOF
|
||||
{"message": "Update grade report for $SHORT_SHA", "content": "$CONTENT", "sha": "$SHA"}
|
||||
EOF
|
||||
|
||||
RESULT=$(curl -s -X PUT -H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_URL/repos/$REPO/contents/$DEST_PATH" \
|
||||
-d @/tmp/upload_request.json)
|
||||
|
||||
if echo "$RESULT" | grep -q '"content"'; then
|
||||
echo "✅ Report updated at $DEST_PATH"
|
||||
else
|
||||
echo "⚠️ Failed to update report: $RESULT"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Could not get file SHA, upload failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -f /tmp/upload_request.json
|
||||
fi
|
||||
|
||||
- name: Create metadata
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -f final_grade.json ]; then
|
||||
export GRADE_TYPE=final
|
||||
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
|
||||
fi
|
||||
|
||||
- name: Upload metadata
|
||||
if: env.RUNNER_METADATA_TOKEN != ''
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
# 使用当前组织的 course-metadata 仓库,而不是 Runner 配置中的硬编码值
|
||||
METADATA_REPO: ${{ github.repository_owner }}/course-metadata
|
||||
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
|
||||
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
|
||||
STUDENT_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
if [ -f metadata.json ]; then
|
||||
python3 ./.autograde/upload_metadata.py \
|
||||
--metadata-file metadata.json \
|
||||
--metadata-repo "${METADATA_REPO}" \
|
||||
--branch "${METADATA_BRANCH:-main}" \
|
||||
--student-repo "${STUDENT_REPO}" \
|
||||
--run-id "${RUN_ID}" \
|
||||
--commit-sha "${COMMIT_SHA}" \
|
||||
--workflow grade \
|
||||
--server-url "${SERVER_URL}" \
|
||||
--external-host "${EXTERNAL_GITEA_HOST}"
|
||||
fi
|
||||
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Application
|
||||
*.log
|
||||
data/
|
||||
|
||||
# Test outputs (keep for local debugging)
|
||||
# junit.xml
|
||||
# grade.json
|
||||
# summary.md
|
||||
|
||||
89
DATABASE_CONNECTION.md
Normal file
89
DATABASE_CONNECTION.md
Normal 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
55
FRONTEND.md
Normal file
@ -0,0 +1,55 @@
|
||||
# 前端开发反思报告
|
||||
|
||||
> 请参考 `FRONTEND_GUIDE.md` 了解写作要求。建议 600–1200 字。
|
||||
|
||||
---
|
||||
|
||||
## 1. 我的界面展示
|
||||
|
||||
<!--
|
||||
截图使用说明:
|
||||
1. 将截图保存到 images/ 目录下
|
||||
2. 使用相对路径引用:
|
||||
3. 建议 3-6 张截图,每张下方用 2-3 句话说明
|
||||
|
||||
示例:
|
||||

|
||||
这是歌单列表页面,展示了...
|
||||
-->
|
||||

|
||||
|
||||
这是歌单详情页的“添加歌曲”弹窗,用于向当前歌单中新增歌曲。用户可在此填写歌曲名称、歌手(必填项)及专辑(选填项),完成后点击“添加歌曲”按钮提交。我在设计时将必填项与选填项做了标注区分,同时在弹窗底部重复放置操作按钮,避免用户滚动后需返回顶部操作。
|
||||
|
||||

|
||||
|
||||
这是歌单详情页面,用于展示指定歌单的基础信息和歌曲列表。用户可在此编辑歌单信息、返回歌单列表,或通过“添加歌曲”按钮向歌单中新增歌曲。我设计了空状态提示文案,当歌单无歌曲时引导用户点击按钮添加,同时将操作按钮放置在显眼位置,降低操作成本。
|
||||
|
||||

|
||||
|
||||
这是创建歌单的弹窗界面,用于生成新的音乐歌单。用户需填写歌单名称(必填)和歌单描述(选填),完成后点击“创建歌单”按钮即可生成新的歌单。我在此处设计了输入项的功能标注,明确区分必填与选填内容,同时将“创建歌单”按钮做了视觉强化,让核心操作更醒目。
|
||||
|
||||
|
||||
## 2. 我遇到的最大挑战
|
||||
|
||||
<!-- 描述一个具体的前端问题、你的排查过程、以及最终如何解决 -->
|
||||
本次开发中,最大的挑战是跨模块的数据同步问题。在实现“添加歌曲后自动更新歌单卡片的歌曲数量”功能时,我发现歌曲管理模块和歌单列表模块是独立的,添加歌曲后,歌单卡片上的“歌曲数量”不会自动更新,需要手动刷新页面才能同步。
|
||||
|
||||
起初我尝试在“添加歌曲”的按钮点击事件里,直接去修改歌单卡片的文本内容,但这种方式需要遍历DOM找到对应的歌单卡片,不仅代码繁琐,还容易因为歌单名称重复导致修改错误。接着我又尝试用全局变量存储歌单数据,但不同模块修改全局变量后,其他模块无法感知到变化,依然需要手动触发刷新。
|
||||
|
||||
最后我采用了自定义事件的方案:在歌曲管理模块添加歌曲后,触发一个自定义事件(如 songAdded )并携带当前歌单名称;歌单列表模块监听这个事件,收到事件后更新对应歌单卡片的歌曲数量。这种方式既避免了DOM操作的繁琐,也实现了模块间的解耦。
|
||||
|
||||
这次经历让我明白:前端模块间的通信,要尽量避免直接操作DOM或共享全局变量,利用自定义事件、状态管理等方式,能让代码更易维护。
|
||||
|
||||
|
||||
## 3. 如果重新做一遍
|
||||
|
||||
<!-- 回顾你的前端实现,哪些地方可以做得更好? -->
|
||||
|
||||
回顾这次实现,我会优先做两方面的改进:
|
||||
|
||||
第一是添加数据持久化。当前页面刷新后,创建的歌单和添加的歌曲都会丢失,重新开发时我会用 localStorage 存储歌单和歌曲数据,页面加载时从本地存储读取数据,确保用户操作不会因为刷新而丢失。
|
||||
|
||||
第二是优化代码组织。这次开发把所有JS代码写在同一个 <script> 标签里,随着功能增加会变得臃肿。重新开发时,我会将歌单管理、歌曲管理的逻辑拆分为独立的函数模块,甚至封装为类,让代码结构更清晰,后续修改某个功能时不用在大段代码里查找。
|
||||
|
||||
|
||||
|
||||
79
FRONTEND_GUIDE.md
Normal file
79
FRONTEND_GUIDE.md
Normal file
@ -0,0 +1,79 @@
|
||||
# 前端开发反思报告 写作指南
|
||||
|
||||
> **字数**:600–1200 字
|
||||
> **形式**:自由叙述,重点展示你的界面和思考
|
||||
> **核心**:我们想看到**你的界面设计**和**开发过程中的思考**
|
||||
|
||||
---
|
||||
|
||||
## 写作要求
|
||||
|
||||
请围绕以下 **三个问题** 展开:
|
||||
|
||||
### 1. 展示你的界面(约 5 分)
|
||||
|
||||
**必须包含 3–6 张截图**,每张截图下方要有说明:
|
||||
|
||||
- 截图应覆盖主要功能界面(如歌单列表、创建/编辑、歌曲管理等)
|
||||
- 每张截图下方用 2–3 句话说明:
|
||||
- 这个界面是做什么的?
|
||||
- 用户在这里可以完成哪些操作?
|
||||
- 你在这个界面中设计了哪些交互细节?
|
||||
|
||||
> 💡 截图是前端报告的核心价值,让读者"看到"你的成果。说明要简洁但信息量充足。
|
||||
|
||||
**📁 截图保存位置**:
|
||||
1. 将截图文件保存到仓库根目录下的 `images/` 文件夹
|
||||
2. 支持 PNG、JPG、GIF 格式
|
||||
3. 建议命名清晰,如 `playlist-list.png`、`create-form.png`
|
||||
|
||||
**示例格式**:
|
||||
```markdown
|
||||

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