Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.gradle
.idea
.kotlin
build
docs
logs
storage
.env
*.iml
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ out/
.kotlin
/.env
/logs/
/storage/
/docs/
/.ai/
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk-noble AS build

WORKDIR /workspace
COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
RUN chmod +x ./gradlew
COPY src ./src
RUN --mount=type=cache,target=/root/.gradle \
for attempt in 1 2 3; do \
./gradlew bootJar --no-daemon && exit 0; \
echo "Gradle build attempt ${attempt} failed; retrying in 10 seconds"; \
sleep 10; \
done; \
exit 1

FROM eclipse-temurin:25-jre-noble

WORKDIR /app
RUN addgroup --system --gid 10001 neko \
&& adduser --system --uid 10001 --ingroup neko neko \
&& mkdir -p /app/storage /app/logs \
&& chown -R neko:neko /app
COPY --from=build /workspace/build/libs/*.jar /app/app.jar

USER neko
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
149 changes: 90 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,89 +1,120 @@
# Minecraft Club Summer Project Hub
# NekoProjectBackend

这是猫娘社 / Minecraft 社团暑假项目站。访客可以浏览公开项目、查看想法墙、投稿项目、提交想法、给项目留言和申请加入项目。当前版本是 Nuxt/Vue 前端,包含从 `nekoFrontend` 接入的登录页面和登录校验中间件
猫娘社 Minecraft 夏令营项目站的后端服务。项目方可以通过项目控制密码维护自己的项目;总管理和项目管理账号通过 JWT 管理项目、想法、评论、动态与申请

## 技术栈

- Nuxt 4
- Vue 3
- TypeScript
- Tailwind CSS
- Naive UI
- 本地开发默认使用 `server/api` 读写 `data/` 里的 JSON 文件
- Kotlin 2.3.21
- Spring Boot 4.1.0
- Spring WebMVC + Jetty
- Spring Data JPA / PostgreSQL
- Spring Data Redis
- Spring Security + JWT
- 本地磁盘文件存储(可替换为对象存储实现)

## 本地运行
## 环境要求

```bash
npm install
npm run dev
- 推荐:Docker Desktop(包含 Docker Compose)
- 手动运行时:JDK 25、PostgreSQL、Redis

## 首次本地运行(推荐)

Docker 会自动准备 JDK 25、PostgreSQL、Redis 和开发配置:

```powershell
docker compose up --build -d
docker compose ps
```

默认访问
等待 `postgres` 和 `redis` 显示 `healthy`,并确认后端日志出现启动完成信息

```text
http://localhost:3000/projects
```powershell
docker compose logs -f backend
```

## 常用页面
默认 API 地址为 `http://localhost:8080`,健康检查为 `http://localhost:8080/actuator/health`。停止服务使用:

- `/projects`:网站首页
- `/projects/groud`:全部公开项目
- `/submit`:投稿项目 / 提交想法
- `/ideas`:想法墙
- `/projects/:id`:项目详情、评论、加入申请
- `/login`:nekoFrontend 登录页
- `/register`:nekoFrontend 注册页
- `/user-center`:登录后的用户中心
- `/pve-users`:PVE 用户管理页
- `/virtual-machines`:虚拟机管理页
```powershell
docker compose down
```

只有明确需要连同本地开发数据一起清空时才使用 `docker compose down -v`。

公开项目站页面默认不强制登录,方便外部同学直接访问。
## 手动本地运行

## 环境变量
开发配置从项目根目录的 `.env` 读取。请先复制示例文件并替换数据库、Redis、JWT 和邮件配置:

```powershell
Copy-Item .env.example .env
```

复制 `.env.example` 为 `.env`
本地默认使用 `ddl-auto=create`,只适合没有需要保留的数据的开发数据库

```env
NUXT_PUBLIC_API_BASE=/api
NUXT_PUBLIC_AUTH_CHECK_ENABLED=false
LOCAL_DATA_DIR=./data
```powershell
.\gradlew.bat bootRun
```

说明:
默认 API 地址:`http://localhost:8080`。

- `NUXT_PUBLIC_API_BASE`:前端 API 根路径,本项目默认使用 Nuxt 自带的 `/api`。
- `NUXT_PUBLIC_AUTH_CHECK_ENABLED`:是否启用登录拦截。设为 `false` 时公开项目站可直接访问。
- `LOCAL_DATA_DIR`:本地 JSON 数据目录,默认是 `./data`。
常用验证命令:

## 数据与安全
```powershell
.\gradlew.bat clean test bootJar --no-daemon
```

测试使用 H2,不需要本地 PostgreSQL 或 Redis;应用本身运行时仍需要这两个服务。

本地数据保存在 `data/`,包括项目、想法、申请、评论、动态和操作记录。这个目录已写入 `.gitignore`,不会上传到 GitHub。
## 生产部署

不要提交这些内容
生产环境必须设置 `SPRING_PROFILES_ACTIVE=prod`。生产 profile 会

- `.env`
- `.env.local`
- `data/`
- `node_modules/`
- `.nuxt/`
- `.output/`
- `.next/`
- `_local-only/`
- 将 Hibernate DDL 策略固定为 `validate`;
- 关闭演示数据 Seeder 和默认管理员自动创建;
- 隐藏健康检查详情;
- 强制 refresh Cookie 使用 HTTPS 和 HttpOnly;
- 要求使用明确的 CORS 来源,不允许 `*`。
- 启动时拒绝缺失、过短或示例占位的 `JWT_SECRET`,并校验令牌有效期。

## 部署到 Vercel
至少配置以下变量:

1. 把代码推送到 GitHub。
2. 在 Vercel 导入仓库。
3. Framework Preset 选择 Nuxt,通常 Vercel 会自动识别。
4. 按需添加环境变量。
5. 部署后访问 `/projects`。
```text
SPRING_PROFILES_ACTIVE=prod
DB_URL=jdbc:postgresql://host:5432/database
DB_USERNAME=...
DB_PASSWORD=...
REDIS_HOST=...
REDIS_PORT=6379
REDIS_PASSWORD=...
JWT_SECRET=至少 32 字节的随机值
CORS_ALLOWED_ORIGINS=https://你的前端域名
FILE_BASE_URL=https://你的后端域名
FILE_STORAGE_PATH=/绝对路径/storage
ADMIN_SEED_ENABLED=false
SEED_ENABLED=false
```

如果你希望公开项目站不需要登录,生产环境也要设置
现有数据库在切换生产 profile 前,先执行 [database/migrations/20260719_project_hub.sql](database/migrations/20260719_project_hub.sql)。脚本是幂等的,补充项目封面、项目进度、申请拒绝原因和匿名追踪码字段,并创建相应索引。项目当前没有自动执行迁移工具,因此需要由部署方手动执行 SQL,例如

```env
NUXT_PUBLIC_AUTH_CHECK_ENABLED=false
```powershell
psql -h <host> -U <username> -d <database> -f database/migrations/20260719_project_hub.sql
```

## 本地归档
文件存储目录和审计日志目录必须由运行服务的用户创建并授予写权限。生产环境不要把 `.env`、`storage/`、`logs/` 或真实数据库数据放入 Git。

## 主要接口

- `/api/auth/**`:注册、登录、刷新令牌、注销和密码管理
- `/api/project/object-items/**`:公开项目、评论和加入申请
- `/api/project/minds/**`:公开想法和匿名投稿状态查询
- `/api/admin/object-items/**`、`/api/admin/minds/**`:JWT 管理接口
- `/api/admin/project/object-items/**`:项目方控制密码管理接口
- `/api/files/**`:文件上传、公开图片读取和私有文件下载

匿名投稿成功后返回一次性追踪码。查询状态时使用请求头 `X-Submission-Tracking-Token`;项目方管理接口使用 `X-Project-Control-Password` 请求头的读取、删除和图片上传接口,避免敏感值进入 URL。

## 安全约定

旧版 Next.js 代码和暂时未使用的素材已放到 `_local-only/unused-before-github-20260704/`。该目录不会上传到 GitHub,以后需要时可以从本机找回。
- 新项目控制密码使用 BCrypt 保存,历史明文密码仅用于兼容校验。
- 公开接口只返回已公开项目和 `APPROVED` 想法、评论、动态。
- 生产环境 PostgreSQL 使用手工迁移脚本和 `ddl-auto=validate`,禁止用 `create` 或 `update`。
- 新上传拒绝 SVG;历史 SVG 强制下载,并设置响应安全头。
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ java {
}

repositories {
maven("https://maven.aliyun.com/repository/public")
mavenCentral()
}

Expand All @@ -27,6 +28,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")

implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
Expand All @@ -51,6 +53,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("com.h2database:h2")
}

kotlin {
Expand Down
65 changes: 65 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: neko-project-backend

services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: nekoBackend
POSTGRES_USER: nekoBackend
POSTGRES_PASSWORD: neko-local-postgres
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nekoBackend -d nekoBackend"]
interval: 5s
timeout: 5s
retries: 20

redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--requirepass", "neko-local-redis"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a neko-local-redis ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 20

backend:
build:
context: .
ports:
- "${BACKEND_PORT:-8080}:8080"
environment:
SERVER_PORT: 8080
DB_URL: jdbc:postgresql://postgres:5432/nekoBackend
DB_USERNAME: nekoBackend
DB_PASSWORD: neko-local-postgres
DB_HIBERNATE_DDL_AUTO: update
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: neko-local-redis
REDIS_DATABASE: 4
JWT_SECRET: neko-local-docker-jwt-secret-change-before-production
CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}"
MANAGEMENT_HEALTH_MAIL_ENABLED: "false"
FILE_BASE_URL: "${FILE_BASE_URL:-http://localhost:8080}"
FILE_STORAGE_PATH: /app/storage
ADMIN_SEED_ENABLED: "true"
SEED_ENABLED: "true"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- file-storage:/app/storage
- audit-logs:/app/logs
restart: unless-stopped

volumes:
postgres-data:
redis-data:
file-storage:
audit-logs:
36 changes: 36 additions & 0 deletions database/migrations/20260719_project_hub.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Apply once before starting with SPRING_PROFILES_ACTIVE=prod.
-- The backend intentionally uses ddl-auto=validate in production.

ALTER TABLE object_item
ADD COLUMN IF NOT EXISTS cover_image_url VARCHAR(512);

ALTER TABLE object_item
ADD COLUMN IF NOT EXISTS progress INTEGER NOT NULL DEFAULT 0;

ALTER TABLE join_application
ADD COLUMN IF NOT EXISTS reject_reason VARCHAR(255);

ALTER TABLE mind
ADD COLUMN IF NOT EXISTS tracking_token_hash VARCHAR(64);

ALTER TABLE join_application
ADD COLUMN IF NOT EXISTS tracking_token_hash VARCHAR(64);

CREATE INDEX IF NOT EXISTS idx_mind_tracking_token_hash
ON mind (tracking_token_hash);

CREATE INDEX IF NOT EXISTS idx_join_application_tracking_token_hash
ON join_application (tracking_token_hash);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'object_item_progress_range'
) THEN
ALTER TABLE object_item
ADD CONSTRAINT object_item_progress_range
CHECK (progress >= 0 AND progress <= 100);
END IF;
END $$;
4 changes: 2 additions & 2 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=60000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
Expand Down
8 changes: 8 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
gradlePluginPortal()
mavenCentral()
}
}

rootProject.name = "NekoProjectBackend"
Loading