diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b86763a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gradle +.idea +.kotlin +build +docs +logs +storage +.env +*.iml diff --git a/.gitignore b/.gitignore index ce491d0..5eabd98 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,6 @@ out/ .kotlin /.env /logs/ +/storage/ /docs/ /.ai/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b37115 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 04d6462..2f966d6 100644 --- a/README.md +++ b/README.md @@ -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 -U -d -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 强制下载,并设置响应安全头。 diff --git a/build.gradle.kts b/build.gradle.kts index 0c10238..050f21d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,7 @@ java { } repositories { + maven("https://maven.aliyun.com/repository/public") mavenCentral() } @@ -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") @@ -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 { diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..efe9c37 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/database/migrations/20260719_project_hub.sql b/database/migrations/20260719_project_hub.sql new file mode 100644 index 0000000..d97e0b2 --- /dev/null +++ b/database/migrations/20260719_project_hub.sql @@ -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 $$; diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a9db115..cf0bc06 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index bbf2903..5f70212 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,9 @@ +pluginManagement { + repositories { + maven("https://maven.aliyun.com/repository/gradle-plugin") + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = "NekoProjectBackend" diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt index 0a22068..0152f1a 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt @@ -26,6 +26,7 @@ import org.springframework.stereotype.Component class AdminUserSeeder( private val userService: UserService, private val env: Environment, + @Value("\${neko.admin.seed-enabled:true}") private val enabled: Boolean, @Value("\${neko.admin.username:admin}") private val username: String, @Value("\${neko.admin.password:password}") private val password: String, @Value("\${neko.admin.email:admin@nekobox.local}") private val email: String, @@ -35,19 +36,18 @@ class AdminUserSeeder( @Order(Ordered.HIGHEST_PRECEDENCE) @EventListener(ApplicationReadyEvent::class) fun seedAdmin() { - if (userService.findByUsername(username) != null) { - log.info("⚠ 管理员用户 [$username] 已存在,跳过初始化") + if (!enabled) { + log.info("⚠ 跳过管理员初始化(neko.admin.seed-enabled=false)") return } val isProd = env.activeProfiles.any { it.equals("prod", ignoreCase = true) } - // prod 守卫:拒绝空口令 / 弱默认口令,防 prod 误配后留下 admin/password 后门。 - // dev/test 放行默认口令,保证本地启动即可用 admin/password 登录开发。 - if (isProd && (password.isBlank() || password.equals(WEAK_DEFAULT_PASSWORD, ignoreCase = true))) { - log.error( - "✗ 跳过默认管理员初始化(prod):neko.admin.password 为空或弱默认值({})。" + - "请通过 NEKO_ADMIN_PASSWORD 环境变量设置强口令后再启动。", - WEAK_DEFAULT_PASSWORD, + if (isProd && isWeakProductionPassword(password)) { + throw IllegalStateException( + "生产环境 neko.admin.password 为空或使用默认弱口令,请通过 NEKO_ADMIN_PASSWORD 设置强口令", ) + } + if (userService.findByUsername(username) != null) { + log.info("⚠ 管理员用户 [$username] 已存在,跳过初始化") return } if (isProd) { @@ -60,11 +60,17 @@ class AdminUserSeeder( log.info("✓ 默认管理员用户 [$username] 初始化完成") } catch (e: Exception) { log.error("✗ 默认管理员用户 [$username] 初始化失败: ${e.message}", e) + throw e } } + private fun isWeakProductionPassword(value: String): Boolean { + val normalized = value.trim() + return normalized.isBlank() || WEAK_DEFAULT_PASSWORDS.any { it.equals(normalized, ignoreCase = true) } + } + private companion object { - // 与 @Value 默认值一致,用于弱口令守卫比对 - const val WEAK_DEFAULT_PASSWORD = "password" + // 与本地/历史默认值一致,用于生产弱默认口令守卫比对。 + val WEAK_DEFAULT_PASSWORDS = setOf("password", "NekoLocalRoot!2026") } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/JwtProductionConfigurationValidator.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/JwtProductionConfigurationValidator.kt new file mode 100644 index 0000000..3096ce6 --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/JwtProductionConfigurationValidator.kt @@ -0,0 +1,93 @@ +package `fun`.utf8.nekoprojectbackend.config + +import org.springframework.context.annotation.Profile +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.net.URI + +/** 生产 profile 的 JWT 配置硬校验,避免使用开发密钥或无效的令牌有效期启动服务。 */ +@Component +@Profile("prod") +class JwtProductionConfigurationValidator( + props: JwtProperties, + cookieProps: TokenCookieProperties, + fileProps: FileProperties, + @Value("\${neko.cors.allowed-origins}") allowedOrigins: String, +) { + init { + validate(props) + validateCookie(cookieProps) + validateOrigins(allowedOrigins) + validateFileBaseUrl(fileProps.baseUrl) + } + + private fun validateCookie(props: TokenCookieProperties) { + if (!props.secure || !props.httpOnly) { + throw IllegalStateException("生产环境 refresh Cookie 必须启用 Secure 和 HttpOnly") + } + val sameSite = props.sameSite.trim().lowercase() + if (sameSite !in setOf("lax", "strict", "none")) { + throw IllegalStateException("COOKIE_SAME_SITE 只能是 Lax、Strict 或 None") + } + if (sameSite == "none" && !props.secure) { + throw IllegalStateException("SameSite=None 时 refresh Cookie 必须启用 Secure") + } + } + + private fun validateOrigins(value: String) { + val origins = value.split(',').map { it.trim() }.filter { it.isNotEmpty() } + if (origins.isEmpty()) { + throw IllegalStateException("生产环境必须设置 CORS_ALLOWED_ORIGINS") + } + origins.forEach { origin -> + val uri = runCatching { URI(origin) }.getOrNull() + if (uri?.scheme != "https" || uri.host.isNullOrBlank() || + (!uri.path.isNullOrEmpty() && uri.path != "/") || uri.query != null || uri.fragment != null + ) { + throw IllegalStateException("生产环境 CORS 来源必须是 HTTPS origin:$origin") + } + } + } + + private fun validateFileBaseUrl(value: String) { + val uri = runCatching { URI(value.trim()) }.getOrNull() + val host = uri?.host?.lowercase() + if (uri?.scheme != "https" || host.isNullOrBlank() || + host in LOCAL_FILE_HOSTS || uri.userInfo != null || + (!uri.path.isNullOrEmpty() && uri.path != "/") || uri.query != null || uri.fragment != null + ) { + throw IllegalStateException("生产环境 FILE_BASE_URL 必须是外部可访问的 HTTPS origin") + } + } + + private fun validate(props: JwtProperties) { + val secret = props.secret.trim() + if (secret.isBlank()) { + throw IllegalStateException("生产环境必须设置 JWT_SECRET") + } + if (secret.toByteArray(StandardCharsets.UTF_8).size < MIN_SECRET_BYTES) { + throw IllegalStateException("生产环境 JWT_SECRET 至少需要 $MIN_SECRET_BYTES 字节") + } + if (INSECURE_SECRETS.any { it.equals(secret, ignoreCase = true) }) { + throw IllegalStateException("生产环境不能使用示例或开发用 JWT_SECRET") + } + if (props.issuer.isBlank()) { + throw IllegalStateException("生产环境 security.jwt.issuer 不能为空") + } + if (props.accessTokenTtlSeconds <= 0L || props.refreshTokenTtlSeconds <= 0L) { + throw IllegalStateException("生产环境 JWT 令牌有效期必须大于 0") + } + } + + private companion object { + const val MIN_SECRET_BYTES = 32 + val INSECURE_SECRETS = setOf( + "neko-backend-local-dev-secret-2026-change-me", + "replace-with-at-least-32-bytes-random-string", + "change-me", + "password", + ) + val LOCAL_FILE_HOSTS = setOf("localhost", "127.0.0.1", "0.0.0.0", "::1") + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/MailProperties.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/MailProperties.kt index f88c8ff..2c5b3cc 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/MailProperties.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/MailProperties.kt @@ -9,6 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties * - [codeLength] 验证码位数(默认 6 位纯数字) * - [sendIntervalSeconds] 同一邮箱两次发送的最小间隔(防轰炸,默认 60s) * - [dailyLimit] 同一邮箱每日发送上限(默认 10 次) + * - [maxAttempts] 单个验证码允许的错误尝试次数(默认 5 次) * - [from] 发件人地址,默认回退到 spring.mail.username * - [subjectPrefix] 邮件主题前缀 */ @@ -18,6 +19,7 @@ data class MailProperties( val codeLength: Int = 6, val sendIntervalSeconds: Long = 60L, val dailyLimit: Long = 10L, + val maxAttempts: Long = 5L, val from: String = "", val subjectPrefix: String = "NekoBackend", ) diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisClearUpConfig.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisClearUpConfig.kt index e8f453c..dd967d2 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisClearUpConfig.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisClearUpConfig.kt @@ -3,30 +3,31 @@ package `fun`.utf8.nekoprojectbackend.config import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener -import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.core.StringRedisTemplate import org.springframework.stereotype.Component +import org.slf4j.LoggerFactory -/** 应用启动时按配置清空 Redis(默认开启),用于本地/测试重置数据。 */ +/** 应用启动时按配置清空 Redis;默认关闭,仅供显式启用的本地数据重置。 */ @Component class RedisCleanupConfig( - private val redisTemplate: RedisTemplate, - @Value($$"${redis.clear-on-startup:true}") + private val redisTemplate: StringRedisTemplate, + @Value($$"${redis.clear-on-startup:false}") private val clearOnStartup: Boolean ) { + private val logger = LoggerFactory.getLogger(javaClass) + @EventListener(ApplicationReadyEvent::class) fun clearRedisOnStartup() { if (!clearOnStartup) { - println("⚠ 跳过清空Redis(配置未启用)") return } try { redisTemplate.connectionFactory?.connection?.serverCommands()?.flushDb() - println("✓ Redis已清空") + logger.info("Redis database cleared because redis.clear-on-startup is enabled") } catch (e: Exception) { - System.err.println("✗ 清空Redis失败: ${e.message}") - e.printStackTrace() + logger.error("Failed to clear Redis database", e) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisTemplateConfig.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisTemplateConfig.kt deleted file mode 100644 index ade9952..0000000 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisTemplateConfig.kt +++ /dev/null @@ -1,29 +0,0 @@ -package `fun`.utf8.nekoprojectbackend.config - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.data.redis.connection.RedisConnectionFactory -import org.springframework.data.redis.core.RedisTemplate -import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer - -/** - * 默认 RedisTemplate:key 与 value 均按 JSON 序列化(含非安全默认类型)。 - * 注意:key 也会被序列化为带引号的 JSON 串,按前缀(如 KEYS auth:token:*)扫描无法命中; - * token 白名单等需要可读 key 的场景请改用 StringRedisTemplate。 - */ -@Configuration -class RedisTemplateConfig { - @Bean - fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<*, *> { - - val redisTemplate: RedisTemplate<*, *> = RedisTemplate() - redisTemplate.connectionFactory = redisConnectionFactory - - val jsonRedisSerializer = GenericJacksonJsonRedisSerializer.builder() - .enableUnsafeDefaultTyping() - .build() - redisTemplate.defaultSerializer = jsonRedisSerializer //设置默认的Serialize,包含 keySerializer & valueSerializer - - return redisTemplate - } -} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminMindController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminMindController.kt index 7cd91c6..2243312 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminMindController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminMindController.kt @@ -5,6 +5,7 @@ import `fun`.utf8.nekoprojectbackend.security.LoginUser import `fun`.utf8.nekoprojectbackend.service.* import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -18,6 +19,15 @@ class AdminMindController( private val operationLogService: OperationLogService, private val builder: ResponseBuilder, ) { + @GetMapping("/{id}") + fun getById( + @AuthenticationPrincipal admin: LoginUser, + @PathVariable id: Int, + ): ResponseEntity { + accessService.requireSuperAdmin(admin) + return builder.ok().data(mindService.findById(id)).build() + } + @GetMapping fun list( @AuthenticationPrincipal admin: LoginUser, @@ -60,7 +70,7 @@ class AdminMindController( @PutMapping("/batch/status") fun batchStatus( @AuthenticationPrincipal admin: LoginUser, - @RequestBody request: AdminBatchStatusRequest, + @Valid @RequestBody request: AdminBatchStatusRequest, ): ResponseEntity { accessService.requireSuperAdmin(admin) val updateRequests = request.ids.map { diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemMaintenanceController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemMaintenanceController.kt index 3c49028..7efbd57 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemMaintenanceController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemMaintenanceController.kt @@ -3,13 +3,17 @@ package `fun`.utf8.nekoprojectbackend.controller import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationStatus import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentStatus import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateStatus +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory import `fun`.utf8.nekoprojectbackend.security.LoginUser import `fun`.utf8.nekoprojectbackend.service.* import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder +import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.http.MediaType import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile /** * 管理端「单个项目维护」接口(/api/admin/object-items/{id}/...):JWT 鉴权,无需项目控制密码。 @@ -23,6 +27,7 @@ class AdminObjectItemMaintenanceController( private val joinApplicationManagementService: JoinApplicationManagementService, private val objectItemUpdateManagementService: ObjectItemUpdateManagementService, private val objectItemCommentManagementService: ObjectItemCommentManagementService, + private val fileService: FileService, private val accessService: AccessService, private val operationLogService: OperationLogService, private val builder: ResponseBuilder, @@ -35,12 +40,35 @@ class AdminObjectItemMaintenanceController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @RequestParam(required = false) status: JoinApplicationStatus?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, ): ResponseEntity { accessService.ensureCanManage(admin, id) - val applications = joinApplicationManagementService.listByAdmin(id, status) + val applications: Any = if (page != null || size != null) { + joinApplicationManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + joinApplicationManagementService.listByAdmin(id, status) + } return builder.ok().data(applications).build() } + /** 管理员图片上传:文件记录绑定到项目,公开下载地址可直接用于封面或动态。 */ + @PostMapping("/{id}/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun uploadImage( + @AuthenticationPrincipal admin: LoginUser, + @PathVariable id: Int, + @RequestPart("file") file: MultipartFile, + ): ResponseEntity { + accessService.ensureCanManage(admin, id) + val result = fileService.upload(file, FileCategory.IMAGE, admin, id) + return builder.ok().data(result).build() + } + @PostMapping("/{id}/join-applications/{applicationId}/accept") fun acceptJoinApplication( @AuthenticationPrincipal admin: LoginUser, @@ -64,7 +92,7 @@ class AdminObjectItemMaintenanceController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @PathVariable applicationId: Int, - @RequestBody(required = false) request: JoinApplicationAdminRejectRequest?, + @Valid @RequestBody(required = false) request: JoinApplicationAdminRejectRequest?, ): ResponseEntity { accessService.ensureCanManage(admin, id) val application = joinApplicationManagementService.rejectByAdmin( @@ -89,9 +117,20 @@ class AdminObjectItemMaintenanceController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @RequestParam(required = false) status: ObjectItemUpdateStatus?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, ): ResponseEntity { accessService.ensureCanManage(admin, id) - val updates = objectItemUpdateManagementService.listByAdmin(id, status) + val updates: Any = if (page != null || size != null) { + objectItemUpdateManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + objectItemUpdateManagementService.listByAdmin(id, status) + } return builder.ok().data(updates).build() } @@ -99,7 +138,7 @@ class AdminObjectItemMaintenanceController( fun createUpdate( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, - @RequestBody request: ObjectItemUpdateManageCreateRequest, + @Valid @RequestBody request: ObjectItemUpdateManageCreateRequest, ): ResponseEntity { accessService.ensureCanManage(admin, id) val update = objectItemUpdateManagementService.createByAdmin(id, request) @@ -118,7 +157,7 @@ class AdminObjectItemMaintenanceController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @PathVariable updateId: Int, - @RequestBody request: ObjectItemUpdateManageUpdateRequest, + @Valid @RequestBody request: ObjectItemUpdateManageUpdateRequest, ): ResponseEntity { accessService.ensureCanManage(admin, id) val update = objectItemUpdateManagementService.updateByAdmin(id, updateId, request) @@ -157,9 +196,25 @@ class AdminObjectItemMaintenanceController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @RequestParam(required = false) status: ObjectItemCommentStatus?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, ): ResponseEntity { accessService.ensureCanManage(admin, id) - val comments = objectItemCommentManagementService.listByAdmin(id, status) + val comments: Any = if (page != null || size != null) { + objectItemCommentManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + objectItemCommentManagementService.listByAdmin(id, status) + } return builder.ok().data(comments).build() } + + private companion object { + private const val DEFAULT_SUBRESOURCE_PAGE = 0 + private const val DEFAULT_SUBRESOURCE_PAGE_SIZE = 100 + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemModerationController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemModerationController.kt index fd9b574..1ba4188 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemModerationController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemModerationController.kt @@ -9,6 +9,7 @@ import `fun`.utf8.nekoprojectbackend.service.ObjectItemUpdateManagementService import `fun`.utf8.nekoprojectbackend.service.OperationLogService import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -28,7 +29,7 @@ class AdminObjectItemModerationController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @PathVariable commentId: Int, - @RequestBody request: CommentStatusRequest, + @Valid @RequestBody request: CommentStatusRequest, ): ResponseEntity { accessService.ensureCanManage(admin, id) val comment = objectItemCommentManagementService.reviewByAdmin(id, commentId, request.status) @@ -47,7 +48,7 @@ class AdminObjectItemModerationController( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, @PathVariable updateId: Int, - @RequestBody request: UpdateStatusRequest, + @Valid @RequestBody request: UpdateStatusRequest, ): ResponseEntity { accessService.ensureCanManage(admin, id) val update = objectItemUpdateManagementService.reviewByAdmin(id, updateId, request.status) @@ -62,10 +63,10 @@ class AdminObjectItemModerationController( } data class CommentStatusRequest( - val status: ObjectItemCommentStatus = ObjectItemCommentStatus.APPROVED, + val status: ObjectItemCommentStatus, ) data class UpdateStatusRequest( - val status: ObjectItemUpdateStatus = ObjectItemUpdateStatus.APPROVED, + val status: ObjectItemUpdateStatus, ) } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt index 00500d2..f2cf9f7 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt @@ -5,12 +5,17 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Status import `fun`.utf8.nekoprojectbackend.handlder.ForbiddenException import `fun`.utf8.nekoprojectbackend.handlder.UserNotFoundException import `fun`.utf8.nekoprojectbackend.security.LoginUser +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.service.AccessService import `fun`.utf8.nekoprojectbackend.service.OperationLogService import `fun`.utf8.nekoprojectbackend.service.TokenStore import `fun`.utf8.nekoprojectbackend.service.UserService import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder +import jakarta.validation.Validator +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.crypto.password.PasswordEncoder @@ -34,11 +39,18 @@ class AdminUserController( private val tokenStore: TokenStore, private val passwordEncoder: PasswordEncoder, private val builder: ResponseBuilder, + private val validator: Validator, ) { data class CreateUserRequest( + @field:NotBlank(message = "用户名不能为空") + @field:Size(max = 64, message = "用户名不能超过 64 个字符") val username: String, + @field:Size(min = 8, max = 72, message = "密码长度必须为 8 到 72 个字符") val password: String, + @field:NotBlank(message = "邮箱不能为空") + @field:Email(message = "邮箱格式不正确") + @field:Size(max = 128, message = "邮箱不能超过 128 个字符") val email: String, val role: Role = Role.USER, ) @@ -50,6 +62,7 @@ class AdminUserController( ): ResponseEntity { // 仅总管理可创建用户(基于角色判定,而非用户名字符串——后者在 neko.admin.username 改名后失效) accessService.requireSuperAdmin(admin) + validator.validate(req).firstOrNull()?.let { throw ParamErrorException(it.message) } val user = userService.createUser(req.username, req.password, req.email, req.role) operationLogService.record( operator = admin, diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/FileController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/FileController.kt index 9e93898..4fff5d1 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/FileController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/FileController.kt @@ -2,6 +2,7 @@ package `fun`.utf8.nekoprojectbackend.controller import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory import `fun`.utf8.nekoprojectbackend.security.LoginUser +import `fun`.utf8.nekoprojectbackend.service.AccessService import `fun`.utf8.nekoprojectbackend.service.FileService import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder @@ -18,16 +19,18 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/api/files") class FileController( private val fileService: FileService, + private val accessService: AccessService, private val builder: ResponseBuilder, ) { /** 上传:multipart/form-data,字段名 file,type 取 IMAGE|DOCUMENT。 */ - @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PostMapping(value = ["", "/upload"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun upload( @RequestPart("file") file: MultipartFile, @RequestParam type: FileCategory, @RequestParam(required = false) objectItemId: Int?, - @AuthenticationPrincipal user: LoginUser?, + @AuthenticationPrincipal user: LoginUser, ): ResponseEntity { + objectItemId?.let { accessService.ensureCanManage(user, it) } val result = fileService.upload(file, type, user, objectItemId) return builder.ok().data(result).build() } @@ -62,19 +65,19 @@ class FileController( // 标签加载封面图不受 Content-Disposition 影响,仍可正常显示。 val isSvg = record.mimeType?.equals("image/svg+xml", ignoreCase = true) == true || record.extension?.equals("svg", ignoreCase = true) == true - val disposition = if (inline && !isSvg) "inline" else "attachment" - val contentType = MediaType.parseMediaType(record.mimeType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE) + val isSafeInlineImage = record.category == FileCategory.IMAGE && + record.mimeType?.startsWith("image/", ignoreCase = true) == true && + !isSvg + val disposition = if (inline && isSafeInlineImage) "inline" else "attachment" + val contentType = runCatching { + MediaType.parseMediaType(record.mimeType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE) + }.getOrDefault(MediaType.APPLICATION_OCTET_STREAM) return ResponseEntity.ok() .contentType(contentType) .header(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(disposition, record.originalName)) .contentLength(record.size ?: -1) .header("X-Content-Type-Options", "nosniff") - .apply { - if (isSvg) { - // 即便被内联渲染,CSP 阻止脚本执行与外部资源引用 - header("Content-Security-Policy", "default-src 'none'") - } - } + .header("Content-Security-Policy", "default-src 'none'; sandbox") .body(InputStreamResource(stream)) } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/MindController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/MindController.kt index 9bd0fc9..6eb83ba 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/MindController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/MindController.kt @@ -1,14 +1,18 @@ package `fun`.utf8.nekoprojectbackend.controller import `fun`.utf8.nekoprojectbackend.datasource.jdbc.MindStatus +import `fun`.utf8.nekoprojectbackend.security.ClientRequestIdentity import `fun`.utf8.nekoprojectbackend.security.LoginUser import `fun`.utf8.nekoprojectbackend.service.* import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder +import jakarta.validation.Valid +import jakarta.servlet.http.HttpServletRequest import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.time.LocalDateTime +import java.time.Duration /** 想法公开接口(/api/project/minds)。 */ @RestController @@ -16,6 +20,8 @@ import java.time.LocalDateTime class MindController( private val mindService: MindService, private val accessService: AccessService, + private val rateLimiter: RateLimiter, + private val clientRequestIdentity: ClientRequestIdentity, private val builder: ResponseBuilder, ) { @@ -26,8 +32,15 @@ class MindController( } @PostMapping - fun save(@RequestBody request: MindSaveRequest): ResponseEntity { - val mind = mindService.save(request) + fun save( + @Valid @RequestBody request: MindSaveRequest, + servletRequest: HttpServletRequest, + ): ResponseEntity { + val clientIp = clientRequestIdentity.clientIp(servletRequest) + rateLimiter.consume("public-write-ip", clientIp, MAX_PUBLIC_WRITES_PER_HOUR, RATE_LIMIT_WINDOW) + rateLimiter.consume("public-idea-ip", clientIp, MAX_IDEA_SUBMISSIONS_PER_HOUR, RATE_LIMIT_WINDOW) + val saved = mindService.saveTracked(request) + val mind = saved.value data class Response( val id: Int?, @@ -38,6 +51,7 @@ class MindController( val mcId: String?, val createTime: LocalDateTime?, val updateTime: LocalDateTime?, + val trackingToken: String, ) val rs = Response( @@ -49,13 +63,34 @@ class MindController( mcId = mind.mcId, createTime = mind.createTime, updateTime = mind.updateTime, + trackingToken = saved.trackingToken, ) return builder.ok().data(rs).build() } + /** 游客凭提交成功时展示的一次性追踪码查询想法状态。 */ + @GetMapping("/{id}/status") + fun getTrackedStatus( + @PathVariable id: Int, + @RequestHeader(SUBMISSION_TRACKING_TOKEN_HEADER) trackingToken: String, + servletRequest: HttpServletRequest, + ): ResponseEntity { + rateLimiter.consume( + "tracking-status-ip", + clientRequestIdentity.clientIp(servletRequest), + MAX_TRACKING_READS_PER_HOUR, + RATE_LIMIT_WINDOW, + ) + return builder.ok().data(mindService.findTracked(id, trackingToken)).build() + } + @PostMapping("/batch") - fun saveBatch(@RequestBody request: MindBatchSaveRequest): ResponseEntity { + fun saveBatch( + @AuthenticationPrincipal admin: LoginUser, + @RequestBody request: MindBatchSaveRequest, + ): ResponseEntity { + accessService.requireSuperAdmin(admin) val minds = mindService.saveBatch(request.items) data class Response( @@ -87,7 +122,7 @@ class MindController( @GetMapping("/{id}") fun getById(@PathVariable id: Int): ResponseEntity { - val mind = mindService.findById(id) + val mind = mindService.findPublicById(id) data class Response( val id: Int?, @@ -116,7 +151,7 @@ class MindController( @GetMapping("/status/{status}") fun listByStatus(@PathVariable status: MindStatus): ResponseEntity { - val minds = mindService.findByStatus(status) + val minds = mindService.findPublicByStatus(status) data class Response( val id: Int?, @@ -147,7 +182,7 @@ class MindController( @GetMapping("/statuses") fun listByStatuses(@RequestParam statuses: List): ResponseEntity { - val minds = mindService.findByStatuses(statuses) + val minds = mindService.findPublicByStatuses(statuses) data class Response( val id: Int?, @@ -217,7 +252,7 @@ class MindController( ) val rs: Any = if (page != null || size != null) { - val vo = mindService.queryPage(request, page ?: 0, size ?: DEFAULT_PAGE_SIZE, sort ?: DEFAULT_SORT) + val vo = mindService.queryPublicPage(request, page ?: 0, size ?: DEFAULT_PAGE_SIZE, sort ?: DEFAULT_SORT) PageResponse( content = vo.content.map { Response( @@ -237,7 +272,7 @@ class MindController( size = vo.size, ) } else { - mindService.query(request).map { + mindService.queryPublic(request).map { Response( id = it.id, title = it.title, @@ -255,7 +290,11 @@ class MindController( } @PostMapping("/query") - fun query(@RequestBody request: MindQueryRequest): ResponseEntity { + fun query( + @AuthenticationPrincipal admin: LoginUser, + @Valid @RequestBody request: MindQueryRequest, + ): ResponseEntity { + accessService.requireSuperAdmin(admin) val minds = mindService.query(request) data class Response( @@ -289,7 +328,7 @@ class MindController( fun update( @AuthenticationPrincipal admin: LoginUser, @PathVariable id: Int, - @RequestBody request: MindUpdateRequest, + @Valid @RequestBody request: MindUpdateRequest, ): ResponseEntity { accessService.requireSuperAdmin(admin) val mind = mindService.update(id, request) @@ -322,7 +361,7 @@ class MindController( @PutMapping("/batch") fun updateBatch( @AuthenticationPrincipal admin: LoginUser, - @RequestBody request: MindBatchUpdateRequest, + @Valid @RequestBody request: MindBatchUpdateRequest, ): ResponseEntity { accessService.requireSuperAdmin(admin) val minds = mindService.updateBatch(request.items) @@ -378,7 +417,7 @@ class MindController( @DeleteMapping("/batch") fun deleteBatch( @AuthenticationPrincipal admin: LoginUser, - @RequestBody request: MindBatchDeleteRequest, + @Valid @RequestBody request: MindBatchDeleteRequest, ): ResponseEntity { accessService.requireSuperAdmin(admin) mindService.deleteBatch(request.ids) @@ -399,5 +438,9 @@ class MindController( private companion object { const val DEFAULT_PAGE_SIZE = 20 const val DEFAULT_SORT = "createTime,desc" + const val MAX_PUBLIC_WRITES_PER_HOUR = 80 + const val MAX_IDEA_SUBMISSIONS_PER_HOUR = 20 + const val MAX_TRACKING_READS_PER_HOUR = 120 + val RATE_LIMIT_WINDOW: Duration = Duration.ofHours(1) } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt index e14b071..7c591c5 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt @@ -48,6 +48,9 @@ class JoinApplication { @Column(name = "reject_reason", length = 255) var rejectReason: String? = null + @Column(name = "tracking_token_hash", length = 64) + var trackingTokenHash: String? = null + @Column(name = "create_time", nullable = false) var createTime: LocalDateTime? = null diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplicationRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplicationRepository.kt index ad311cb..f8b7016 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplicationRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplicationRepository.kt @@ -1,6 +1,12 @@ package `fun`.utf8.nekoprojectbackend.datasource.jdbc +import jakarta.persistence.LockModeType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository /** 加入申请数据访问层,提供按项目条目及状态查询。 */ @@ -8,5 +14,21 @@ import org.springframework.stereotype.Repository interface JoinApplicationRepository : JpaRepository { fun findByObjectItemId(objectItemId: Int): List + fun findByObjectItemId(objectItemId: Int, pageable: Pageable): Page + fun findByObjectItemIdAndStatus(objectItemId: Int, status: JoinApplicationStatus): List + + fun findByObjectItemIdAndStatus( + objectItemId: Int, + status: JoinApplicationStatus, + pageable: Pageable, + ): Page + + fun countByObjectItemId(objectItemId: Int): Long + + fun countByObjectItemIdAndStatus(objectItemId: Int, status: JoinApplicationStatus): Long + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select application from JoinApplication application where application.id = :id") + fun findByIdForUpdate(@Param("id") id: Int): JoinApplication? } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt index ca7a041..d156089 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt @@ -40,6 +40,9 @@ class Mind { @Column(name = "mc_id", length = 64) var mcId: String? = null + @Column(name = "tracking_token_hash", length = 64) + var trackingTokenHash: String? = null + @Column(name = "create_time", nullable = false) var createTime: LocalDateTime? = null diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/MindRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/MindRepository.kt index b79b2c1..f6a30ea 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/MindRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/MindRepository.kt @@ -1,11 +1,12 @@ package `fun`.utf8.nekoprojectbackend.datasource.jdbc import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.stereotype.Repository /** 想法表数据访问层,提供按状态 / MC ID / 标题 / 昵称等查询。 */ @Repository -interface MindRepository : JpaRepository { +interface MindRepository : JpaRepository, JpaSpecificationExecutor { fun findByStatus(status: MindStatus): List fun countByStatus(status: MindStatus): Long diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemCommentRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemCommentRepository.kt index 52ed36b..f52feb7 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemCommentRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemCommentRepository.kt @@ -1,6 +1,8 @@ package `fun`.utf8.nekoprojectbackend.datasource.jdbc import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository /** 项目评论数据访问层,提供按项目条目及状态查询。 */ @@ -8,7 +10,23 @@ import org.springframework.stereotype.Repository interface ObjectItemCommentRepository : JpaRepository { fun findByObjectItemId(objectItemId: Int): List + fun findByObjectItemId(objectItemId: Int, pageable: Pageable): Page + fun findByObjectItemIdAndStatus(objectItemId: Int, status: ObjectItemCommentStatus): List + fun findByObjectItemIdAndStatus( + objectItemId: Int, + status: ObjectItemCommentStatus, + pageable: Pageable, + ): Page + fun findByStatus(status: ObjectItemCommentStatus): List + + fun findByStatus(status: ObjectItemCommentStatus, pageable: Pageable): Page + + fun countByObjectItemId(objectItemId: Int): Long + + fun countByObjectItemIdAndStatus(objectItemId: Int, status: ObjectItemCommentStatus): Long + + fun countByStatus(status: ObjectItemCommentStatus): Long } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt index ea42c87..fa8092b 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt @@ -24,6 +24,8 @@ interface ObjectItemRepository : JpaRepository, JpaSpecificatio fun countByOwnerId(ownerId: Long): Long + fun countByOwnerIdAndStatusNot(ownerId: Long, status: ObjectItemStatus): Long + fun findByOwnerId(ownerId: Long): List /** 关联了指定标签的全部项目(供软删除标签时解除关联)。 */ diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemUpdateRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemUpdateRepository.kt index bbec430..8412a4d 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemUpdateRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemUpdateRepository.kt @@ -1,6 +1,8 @@ package `fun`.utf8.nekoprojectbackend.datasource.jdbc import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository /** 项目动态数据访问层,提供按项目条目及状态查询。 */ @@ -8,7 +10,23 @@ import org.springframework.stereotype.Repository interface ObjectItemUpdateRepository : JpaRepository { fun findByObjectItemId(objectItemId: Int): List + fun findByObjectItemId(objectItemId: Int, pageable: Pageable): Page + fun findByObjectItemIdAndStatus(objectItemId: Int, status: ObjectItemUpdateStatus): List + fun findByObjectItemIdAndStatus( + objectItemId: Int, + status: ObjectItemUpdateStatus, + pageable: Pageable, + ): Page + fun findByStatus(status: ObjectItemUpdateStatus): List + + fun findByStatus(status: ObjectItemUpdateStatus, pageable: Pageable): Page + + fun countByObjectItemId(objectItemId: Int): Long + + fun countByObjectItemIdAndStatus(objectItemId: Int, status: ObjectItemUpdateStatus): Long + + fun countByStatus(status: ObjectItemUpdateStatus): Long } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt index 69753f0..e49f40a 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt @@ -42,10 +42,18 @@ class ConflictException( message: String = "资源冲突" ) : BusinessException(HttpStatus.CONFLICT, message) +class ResourceConflictException( + message: String = "资源状态冲突" +) : BusinessException(HttpStatus.CONFLICT, message) + class ParamErrorException( message: String = "参数错误" ) : BusinessException(HttpStatus.BAD_REQUEST, message) +class TooManyRequestsException( + message: String = "操作过于频繁,请稍后重试" +) : BusinessException(HttpStatus.TOO_MANY_REQUESTS, message) + class VerificationCodeInvalidException( message: String = "验证码错误或已过期" ) : BusinessException(HttpStatus.BAD_REQUEST, message) diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/GlobalExceptionHandler.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/GlobalExceptionHandler.kt index 25b5be9..78ae6cc 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/GlobalExceptionHandler.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/GlobalExceptionHandler.kt @@ -5,16 +5,23 @@ import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory import org.springframework.core.annotation.Order +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.security.access.AccessDeniedException +import org.springframework.web.HttpMediaTypeNotSupportedException import org.springframework.web.HttpRequestMethodNotSupportedException import org.springframework.web.bind.MissingRequestHeaderException import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.servlet.NoHandlerFoundException import org.springframework.web.servlet.resource.NoResourceFoundException +import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.multipart.support.MissingServletRequestPartException /** 全局异常处理:将各类异常转换为统一 [Response] 响应。 */ @@ -32,16 +39,44 @@ class GlobalExceptionHandler { } @ExceptionHandler(AccessDeniedException::class) - fun onAccessDeniedException(ex: AccessDeniedException): ResponseEntity { + fun onAccessDeniedException(): ResponseEntity { return builder.forbidden() - .message(ex.message ?: "禁止访问") + .message("禁止访问") .build() } @ExceptionHandler(HttpRequestMethodNotSupportedException::class) fun onHttpRequestMethodNotSupportedException(ex: HttpRequestMethodNotSupportedException): ResponseEntity { + return builder.status(HttpStatus.METHOD_NOT_ALLOWED) + .message("请求方法 ${ex.method} 不受支持") + .build() + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun onHttpMessageNotReadableException(): ResponseEntity { + return builder.badRequest() + .message("请求体格式错误") + .build() + } + + @ExceptionHandler(MissingServletRequestPartException::class) + fun onMissingServletRequestPartException(ex: MissingServletRequestPartException): ResponseEntity { return builder.badRequest() - .message("Method \"${ex.method}\" is not supported on this endpoint.") + .message("Required request part \"${ex.requestPartName}\" is not provided!") + .build() + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException::class) + fun onHttpMediaTypeNotSupportedException(): ResponseEntity { + return builder.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .message("不支持的请求内容类型") + .build() + } + + @ExceptionHandler(MaxUploadSizeExceededException::class) + fun onMaxUploadSizeExceededException(): ResponseEntity { + return builder.status(HttpStatus.PAYLOAD_TOO_LARGE) + .message("上传文件超过大小限制") .build() } @@ -67,7 +102,24 @@ class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun onMethodArgumentTypeMismatchException(ex: MethodArgumentTypeMismatchException): ResponseEntity { return builder.badRequest() - .message("Parameter \"${ex.parameter.parameterName}\" type mismatch. Expected ${ex.requiredType}.") + .message("参数 \"${ex.parameter.parameterName}\" 格式错误") + .build() + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun onMethodArgumentNotValidException(ex: MethodArgumentNotValidException): ResponseEntity { + val message = ex.bindingResult.fieldErrors.firstOrNull()?.defaultMessage + ?: "请求参数校验失败" + return builder.badRequest() + .message(message) + .build() + } + + @ExceptionHandler(DataIntegrityViolationException::class) + fun onDataIntegrityViolationException(ex: DataIntegrityViolationException): ResponseEntity { + log.warn("Data integrity conflict while processing request", ex) + return builder.status(HttpStatus.CONFLICT) + .message("数据冲突,请检查是否重复提交") .build() } @@ -75,7 +127,7 @@ class GlobalExceptionHandler { fun onIllegalArgumentException(ex: IllegalArgumentException?): ResponseEntity { log.warn("Illegal argument access happened: ", ex) return builder.badRequest() - .message(ex?.message ?: "Invalid argument.") + .message("请求参数不合法") .build() } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ClientRequestIdentity.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ClientRequestIdentity.kt new file mode 100644 index 0000000..f4aa11a --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ClientRequestIdentity.kt @@ -0,0 +1,32 @@ +package `fun`.utf8.nekoprojectbackend.security + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +/** Resolves a stable client address without trusting forwarding headers by default. */ +@Component +class ClientRequestIdentity( + @Value("\${neko.security.trusted-proxy:false}") private val trustedProxy: Boolean, +) { + fun clientIp(request: HttpServletRequest): String { + if (trustedProxy) { + request.getHeader("X-Forwarded-For") + ?.substringBefore(',') + ?.let(::normalize) + ?.let { return it } + request.getHeader("X-Real-IP") + ?.let(::normalize) + ?.let { return it } + } + return normalize(request.remoteAddr) ?: "unknown" + } + + private fun normalize(value: String?): String? = value + ?.trim() + ?.takeIf { it.isNotEmpty() && it.length <= MAX_ADDRESS_LENGTH && it.none(Char::isISOControl) } + + private companion object { + const val MAX_ADDRESS_LENGTH = 128 + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JsonAccessDeniedHandler.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JsonAccessDeniedHandler.kt index 6fa7aa2..f3c094c 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JsonAccessDeniedHandler.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JsonAccessDeniedHandler.kt @@ -26,7 +26,7 @@ class JsonAccessDeniedHandler( resp.contentType = MediaType.APPLICATION_JSON_VALUE resp.characterEncoding = Charsets.UTF_8.name() resp.writer.write( - objectMapper.writeValueAsString(Response(status, ex.message ?: "禁止访问", emptyMap())) + objectMapper.writeValueAsString(Response(status, "禁止访问", emptyMap())) ) } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt index 4e87acc..6f8e7da 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt @@ -45,6 +45,10 @@ class JwtAuthenticationFilter( req.setAttribute(AUTH_ERROR_ATTR, TokenInvalidException("Token 已失效")) return } + if (claims.get(CLAIM_TYPE, String::class.java) != TYPE_ACCESS) { + req.setAttribute(AUTH_ERROR_ATTR, TokenInvalidException("非访问令牌")) + return + } val principal = toPrincipal(claims) SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken.authenticated(principal, null, principal.authorities) @@ -59,7 +63,8 @@ class JwtAuthenticationFilter( private fun toPrincipal(claims: Claims): LoginUser { val roleName = claims.get(CLAIM_ROLE, String::class.java) - val role = runCatching { roleName?.let { Role.valueOf(it) } }.getOrNull() ?: Role.USER + val role = runCatching { roleName?.let { Role.valueOf(it) } }.getOrNull() + ?: throw TokenInvalidException("Token 角色无效") return LoginUser( id = claims.subject.toLong(), username = claims.get(CLAIM_USERNAME, String::class.java), @@ -74,5 +79,7 @@ class JwtAuthenticationFilter( private const val BEARER_PREFIX = "Bearer " private const val CLAIM_USERNAME = "username" private const val CLAIM_ROLE = "role" + private const val CLAIM_TYPE = "type" + private const val TYPE_ACCESS = "access" } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ProjectControlRequestRateLimitFilter.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ProjectControlRequestRateLimitFilter.kt new file mode 100644 index 0000000..e04a1f1 --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/ProjectControlRequestRateLimitFilter.kt @@ -0,0 +1,75 @@ +package `fun`.utf8.nekoprojectbackend.security + +import `fun`.utf8.nekoprojectbackend.service.RateLimiter +import `fun`.utf8.nekoprojectbackend.handlder.TooManyRequestsException +import `fun`.utf8.nekoprojectbackend.shared.Response +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.http.MediaType +import tools.jackson.databind.ObjectMapper +import java.time.Duration + +/** Bounds password-protected project-management requests before BCrypt verification. */ +@Component +class ProjectControlRequestRateLimitFilter( + private val rateLimiter: RateLimiter, + private val clientRequestIdentity: ClientRequestIdentity, + private val objectMapper: ObjectMapper, +) : OncePerRequestFilter() { + override fun shouldNotFilter(request: HttpServletRequest): Boolean = + !requestPath(request).startsWith(PROJECT_CONTROL_PREFIX) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + val clientIp = clientRequestIdentity.clientIp(request) + rateLimiter.consume("project-control-ip", clientIp, MAX_REQUESTS_PER_IP, WINDOW) + + projectId(requestPath(request))?.let { projectId -> + rateLimiter.consume( + "project-control-project-ip", + "$clientIp:$projectId", + MAX_REQUESTS_PER_PROJECT_AND_IP, + WINDOW, + ) + } + } catch (ex: TooManyRequestsException) { + response.status = ex.code + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write( + objectMapper.writeValueAsString(Response(ex.code, ex.message, emptyMap())), + ) + return + } + filterChain.doFilter(request, response) + } + + private fun projectId(path: String): Int? = path + .removePrefix(PROJECT_CONTROL_PREFIX) + .substringBefore('/') + .toIntOrNull() + ?.takeIf { it > 0 } + + private fun requestPath(request: HttpServletRequest): String { + val contextPath = request.contextPath.orEmpty() + return if (contextPath.isNotEmpty() && request.requestURI.startsWith(contextPath)) { + request.requestURI.removePrefix(contextPath) + } else { + request.requestURI + } + } + + private companion object { + const val PROJECT_CONTROL_PREFIX = "/api/admin/project/object-items/" + const val MAX_REQUESTS_PER_IP = 300 + const val MAX_REQUESTS_PER_PROJECT_AND_IP = 120 + val WINDOW: Duration = Duration.ofHours(1) + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/RefreshRequestOriginFilter.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/RefreshRequestOriginFilter.kt new file mode 100644 index 0000000..5cbce2c --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/RefreshRequestOriginFilter.kt @@ -0,0 +1,70 @@ +package `fun`.utf8.nekoprojectbackend.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.access.AccessDeniedException +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.net.URI + +/** Rejects cross-site refresh attempts because the refresh credential is carried by a Cookie. */ +@Component +class RefreshRequestOriginFilter( + private val accessDeniedHandler: JsonAccessDeniedHandler, + @Value("\${neko.cors.allowed-origins}") allowedOrigins: String, +) : OncePerRequestFilter() { + private val allowedOrigins = allowedOrigins.split(',') + .map { it.trim().removeSuffix("/") } + .filter { it.isNotEmpty() } + .toSet() + + override fun shouldNotFilter(request: HttpServletRequest): Boolean = + request.method != "POST" || requestPath(request) != REFRESH_PATH + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + if (!hasAllowedBrowserSource(request)) { + accessDeniedHandler.handle(request, response, AccessDeniedException("刷新请求来源不受信任")) + return + } + filterChain.doFilter(request, response) + } + + private fun hasAllowedBrowserSource(request: HttpServletRequest): Boolean { + val fetchSite = request.getHeader("Sec-Fetch-Site") + if (fetchSite.equals("cross-site", ignoreCase = true)) return false + + val origin = request.getHeader("Origin")?.trim()?.removeSuffix("/") + if (origin != null) return origin != "null" && origin in allowedOrigins + + val refererOrigin = request.getHeader("Referer")?.let(::originOf) + return refererOrigin == null || refererOrigin in allowedOrigins + } + + private fun originOf(value: String): String? = runCatching { + val uri = URI(value) + if (uri.scheme == null || uri.host == null) return@runCatching null + val defaultPort = (uri.scheme.equals("http", true) && uri.port == 80) || + (uri.scheme.equals("https", true) && uri.port == 443) + "${uri.scheme.lowercase()}://${uri.host.lowercase()}" + + if (uri.port >= 0 && !defaultPort) ":${uri.port}" else "" + }.getOrNull() + + private fun requestPath(request: HttpServletRequest): String { + val contextPath = request.contextPath.orEmpty() + return if (contextPath.isNotEmpty() && request.requestURI.startsWith(contextPath)) { + request.requestURI.removePrefix(contextPath) + } else { + request.requestURI + } + } + + private companion object { + const val REFRESH_PATH = "/api/auth/refresh" + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AdminBatchStatusRequest.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AdminBatchStatusRequest.kt index 9ba1c1b..3642fdc 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AdminBatchStatusRequest.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AdminBatchStatusRequest.kt @@ -1,7 +1,10 @@ package `fun`.utf8.nekoprojectbackend.service +import jakarta.validation.constraints.Size + /** 管理端批量修改状态的请求:目标 ID 列表 + 目标状态(泛型)。 */ data class AdminBatchStatusRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") val ids: List = emptyList(), val status: T, ) diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt index 8b8ac84..7a2418e 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Duration import java.time.LocalDateTime +import java.nio.charset.StandardCharsets /** * 鉴权业务:统一 account 登录(用户名或邮箱)、邮箱验证登录、登出(白名单驱逐)、 @@ -102,6 +103,7 @@ class AuthService( /** 统一账号登录:account 支持用户名(大小写不敏感)或邮箱。先校验密码,再查状态(不暴露账号存在性)。 */ fun login(req: LoginRequest, userAgent: String, ip: String): LoginResponse { + rejectOversizedPassword(req.password) val key = req.account.trim() val accountKey = key.lowercase() // 限流:账号维度 + IP 维度双检查(设计 §5.2) @@ -128,22 +130,23 @@ class AuthService( /** 邮箱验证登录:校验邮箱验证码(绑定 email+UA)+ 密码 + 状态。 */ fun loginByEmail(req: EmailLoginRequest, userAgent: String, ip: String): LoginResponse { + rejectOversizedPassword(req.password) rateLimiter.ensureNotLocked(LOGIN_IP_NS, ip) + val user = userService.findByEmail(req.email) + if (user == null || !passwordEncoder.matches(req.password, user.password)) { + rateLimiter.recordFailAndCheckLock(LOGIN_IP_NS, ip, LOGIN_MAX_FAIL * 2, LOGIN_WINDOW, LOGIN_LOCK) + throw UsernameOrPasswordErrorException() + } + ensureLoginable(user) verificationCodeService.verifyAndConsume( VerificationCodeService.CodeContext( scene = VerificationCodeService.Scene.EMAIL_LOGIN, - email = req.email, + email = req.email.trim().lowercase(), userId = null, userAgent = userAgent, ), req.emailCode, ) - val user = userService.findByEmail(req.email) - if (user == null || !passwordEncoder.matches(req.password, user.password)) { - rateLimiter.recordFailAndCheckLock(LOGIN_IP_NS, ip, LOGIN_MAX_FAIL * 2, LOGIN_WINDOW, LOGIN_LOCK) - throw UsernameOrPasswordErrorException() - } - ensureLoginable(user) rateLimiter.clearFails(LOGIN_IP_NS, ip) user.lastLoginAt = LocalDateTime.now() userService.save(user) @@ -201,7 +204,11 @@ class AuthService( /** 解析刷新令牌 jti 并服务端删除;过期 / 无效则静默跳过——登出不应因 cookie 中令牌失效而失败。 */ private fun revokeRefreshSafely(refreshToken: String, userId: Long) { - val jti = runCatching { jwtService.parse(refreshToken).id }.getOrNull() ?: return + val claims = runCatching { jwtService.parse(refreshToken) }.getOrNull() ?: return + if (claims.subject.toLongOrNull() != userId) { + return + } + val jti = claims.id ?: return tokenStore.revokeRefresh(jti, userId) } @@ -448,9 +455,19 @@ class AuthService( return LoginResponse(access.token, refresh.token, "Bearer", access.ttlSeconds, refresh.ttlSeconds) } + private fun rejectOversizedPassword(password: String) { + if (password.length > MAX_PASSWORD_INPUT_CHARS || + password.toByteArray(StandardCharsets.UTF_8).size > MAX_BCRYPT_INPUT_BYTES + ) { + throw UsernameOrPasswordErrorException() + } + } + private companion object { const val TYPE_REFRESH = "refresh" const val CLAIM_TYPE = "type" + const val MAX_PASSWORD_INPUT_CHARS = 64 + const val MAX_BCRYPT_INPUT_BYTES = 72 // 登录限流(设计 §5.2):账号 5 次 / 10min 触发锁定 15min;IP 阈值翻倍 const val LOGIN_USER_NS = "login:user" const val LOGIN_IP_NS = "login:ip" diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/FileService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/FileService.kt index dbd00af..f4a5bcd 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/FileService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/FileService.kt @@ -4,10 +4,12 @@ import `fun`.utf8.nekoprojectbackend.config.FileProperties import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileRecord import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileRecordRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role import `fun`.utf8.nekoprojectbackend.handlder.ForbiddenException import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import `fun`.utf8.nekoprojectbackend.handlder.UnauthorizedException import `fun`.utf8.nekoprojectbackend.security.LoginUser import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile import java.io.InputStream import java.time.LocalDateTime +import java.util.Locale import javax.imageio.ImageIO data class FileUploadResponse( @@ -41,6 +44,7 @@ data class FilePageVO( class FileService( private val storageService: StorageService, private val fileRecordRepository: FileRecordRepository, + private val objectItemRepository: ObjectItemRepository, private val properties: FileProperties, ) { @@ -53,30 +57,45 @@ class FileService( ): FileUploadResponse { if (file.isEmpty) throw ParamErrorException("文件为空") - val originalName = file.originalFilename ?: "unnamed" + val originalName = normalizeOriginalName(file.originalFilename) val extension = extractExtension(originalName) val policy = policyOf(category) + objectItemId?.let { id -> + if (id <= 0) { + throw ParamErrorException("项目条目 ID 必须大于 0") + } + if (!objectItemRepository.existsById(id)) { + throw ResourceNotFoundException("项目条目不存在") + } + } + validateExtension(extension, policy, category) validateSize(file.size, policy, category) - validateContent(file, category) + validateContent(file, category, extension) val storedName = storageService.store(file, extension) - val record = FileRecord().apply { - this.storedName = storedName - this.originalName = originalName - this.mimeType = file.contentType - this.size = file.size - this.category = category - this.extension = extension - this.uploaderId = user?.id - this.objectItemId = objectItemId - // 图片默认可公开读,文档默认私有 - this.publicRead = category == FileCategory.IMAGE && properties.publicReadImage - this.createTime = LocalDateTime.now() + try { + val record = FileRecord().apply { + this.storedName = storedName + this.originalName = originalName + this.mimeType = file.contentType + this.size = file.size + this.category = category + this.extension = extension + this.uploaderId = user?.id + this.objectItemId = objectItemId + // 图片默认可公开读,文档默认私有 + this.publicRead = category == FileCategory.IMAGE && properties.publicReadImage + this.createTime = LocalDateTime.now() + } + val saved = fileRecordRepository.save(record) + return saved.toUploadResponse() + } catch (ex: Exception) { + // 数据库写入失败时回收已落盘文件,避免磁盘与元数据逐渐分叉。 + storageService.delete(storedName) + throw ex } - val saved = fileRecordRepository.save(record) - return saved.toUploadResponse() } /** 列出当前用户上传的文件(按上传时间倒序分页)。 */ @@ -103,13 +122,11 @@ class FileService( record.category == FileCategory.IMAGE && properties.publicReadImage if (!publicOk) { - // 私有文件:须登录且为所有者或总管理,防止已登录的任意用户越权下载他人文档 + // 项目文件跟随当前项目归属;未绑定项目的文件才跟随最初上传者。 if (user == null) { - throw ForbiddenException("请登录后下载") + throw UnauthorizedException("请登录后下载") } - val isOwner = record.uploaderId == user.id - val isSuper = user.role == Role.SUPER_ADMIN - if (!isOwner && !isSuper) { + if (!canManage(record, user)) { throw ForbiddenException("无权下载该文件") } } @@ -121,19 +138,31 @@ class FileService( fun delete(storedName: String, user: LoginUser) { val record = fileRecordRepository.findByStoredName(storedName) ?: throw ResourceNotFoundException("文件不存在") - val isOwner = record.uploaderId == user.id - val isSuper = user.role == Role.SUPER_ADMIN - if (!isOwner && !isSuper) { + if (!canManage(record, user)) { throw ForbiddenException("无权删除该文件") } fileRecordRepository.delete(record) storageService.delete(storedName) } + private fun canManage(record: FileRecord, user: LoginUser): Boolean { + if (user.role == Role.SUPER_ADMIN) return true + val objectItemId = record.objectItemId + if (objectItemId == null) { + return record.uploaderId == user.id + } + return objectItemRepository.findById(objectItemId) + .map { it.ownerId == user.id } + .orElse(false) + } + private fun policyOf(category: FileCategory): FileProperties.TypePolicy = if (category == FileCategory.IMAGE) properties.image else properties.document private fun validateExtension(ext: String, policy: FileProperties.TypePolicy, category: FileCategory) { + if (category == FileCategory.IMAGE && ext in BLOCKED_IMAGE_EXTENSIONS) { + throw ParamErrorException("出于安全原因,不支持上传 SVG 图片,请转换为 PNG 或 WebP") + } if (policy.allowedExtensions.isNotEmpty() && ext !in policy.allowedExtensions) { throw ParamErrorException("不支持的${category}扩展名:$ext") } @@ -148,42 +177,84 @@ class FileService( /** * 内容真实性校验:防伪装扩展名(如 .exe 改名 .png)。 - * - 图片:用 ImageIO 试读,读不出(返回 null)即判定非真实图片; - * - MIME 一致性:file.contentType 若提供,需与声明的 category 大类匹配(图片 MIME 需以 image 开头)。 + * - 图片扩展名、MIME 和文件签名必须互相匹配; + * - JDK 原生支持的格式再用 ImageIO 完整试读,排除损坏文件。 * 仅扩展名校验不够——客户端可任意伪造文件名与 Content-Type。 */ - private fun validateContent(file: MultipartFile, category: FileCategory) { + private fun validateContent(file: MultipartFile, category: FileCategory, extension: String) { + if (category != FileCategory.IMAGE) return + val mime = file.contentType - if (!mime.isNullOrBlank()) { - val isImageMime = mime.startsWith("image/", ignoreCase = true) - if (category == FileCategory.IMAGE && !isImageMime) { - throw ParamErrorException("图片文件的 MIME 不是 image 类型:$mime") - } + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.ROOT) + ?.takeIf { it.isNotEmpty() } + ?: throw ParamErrorException("图片文件 MIME 不能为空") + val expectedMimes = IMAGE_MIMES_BY_EXTENSION[extension] + ?: throw ParamErrorException("无法安全校验该图片格式:$extension") + if (mime !in expectedMimes) { + throw ParamErrorException("图片扩展名 .$extension 与 MIME $mime 不匹配") } - if (category == FileCategory.IMAGE) { - val ext = extractExtension(file.originalFilename ?: "") - // ImageIO 只内置支持 jpg/png/gif/bmp;svg(XML)和 webp(需第三方插件) - // 不在支持范围内,对这两种格式跳过 ImageIO 试读,仅依赖 MIME 校验。 - if (ext in IMAGEIO_DECODABLE_EXTS) { - val decoded = try { - file.inputStream.use { ImageIO.read(it) } - } catch (e: Exception) { - null - } - if (decoded == null) { - throw ParamErrorException("文件不是有效的图片(内容无法解析),请确认文件完整性") - } + + val header = file.inputStream.use { it.readNBytes(IMAGE_SIGNATURE_BYTES) } + if (!matchesImageSignature(extension, header)) { + throw ParamErrorException("图片扩展名、MIME 与文件内容不匹配") + } + + if (extension in IMAGEIO_DECODABLE_EXTS) { + val decoded = try { + file.inputStream.use { ImageIO.read(it) } + } catch (_: Exception) { + null + } + if (decoded == null) { + throw ParamErrorException("文件不是有效的图片(内容无法解析),请确认文件完整性") } } } + private fun matchesImageSignature(extension: String, header: ByteArray): Boolean { + fun byteAt(index: Int): Int = header[index].toInt() and 0xff + fun startsWith(vararg expected: Int): Boolean = + header.size >= expected.size && expected.indices.all { byteAt(it) == expected[it] } + + return when (extension) { + "jpg", "jpeg" -> startsWith(0xff, 0xd8, 0xff) + "png" -> startsWith(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) + "gif" -> startsWith(0x47, 0x49, 0x46, 0x38, 0x37, 0x61) || + startsWith(0x47, 0x49, 0x46, 0x38, 0x39, 0x61) + "bmp" -> startsWith(0x42, 0x4d) + "webp" -> header.size >= 12 && + startsWith(0x52, 0x49, 0x46, 0x46) && + byteAt(8) == 0x57 && byteAt(9) == 0x45 && byteAt(10) == 0x42 && byteAt(11) == 0x50 + else -> false + } + } + private fun extractExtension(name: String): String { val idx = name.lastIndexOf('.') return if (idx >= 0) name.substring(idx + 1).lowercase() else "" } - private fun buildUrl(storedName: String): String = - "${properties.baseUrl.trimEnd('/')}/api/files/$storedName" + /** 只保留展示用基名,并拒绝控制字符和超长元数据。 */ + private fun normalizeOriginalName(name: String?): String { + val normalized = name?.trim().orEmpty() + .substringAfterLast('/') + .substringAfterLast('\\') + .ifBlank { "unnamed" } + if (normalized.any { it.code == 0 || it.code < 0x20 || it.code == 0x7f }) { + throw ParamErrorException("文件名包含非法控制字符") + } + if (normalized.length > MAX_ORIGINAL_NAME_LENGTH) { + throw ParamErrorException("文件名不能超过 $MAX_ORIGINAL_NAME_LENGTH 个字符") + } + return normalized + } + + private fun buildUrl(storedName: String, category: FileCategory?): String { + val base = "${properties.baseUrl.trimEnd('/')}/api/files/$storedName" + return if (category == FileCategory.IMAGE) "$base?inline=true" else base + } private fun FileRecord.toUploadResponse(): FileUploadResponse = FileUploadResponse( id = id, @@ -192,15 +263,28 @@ class FileService( mimeType = mimeType, size = size, category = category ?: FileCategory.DOCUMENT, - url = buildUrl(storedName ?: ""), + url = buildUrl(storedName ?: "", category), createTime = createTime, ) private companion object { const val DEFAULT_LIST_SIZE = 20 const val MAX_LIST_SIZE = 100 + const val MAX_ORIGINAL_NAME_LENGTH = 255 + + const val IMAGE_SIGNATURE_BYTES = 12 + val BLOCKED_IMAGE_EXTENSIONS = setOf("svg") + + val IMAGE_MIMES_BY_EXTENSION = mapOf( + "jpg" to setOf("image/jpeg"), + "jpeg" to setOf("image/jpeg"), + "png" to setOf("image/png"), + "gif" to setOf("image/gif"), + "webp" to setOf("image/webp"), + "bmp" to setOf("image/bmp", "image/x-bmp", "image/x-ms-bmp"), + ) - /** JDK ImageIO 原生可解码的图片格式;svg/webp 需第三方插件,跳过试读。 */ + /** JDK ImageIO 原生可解码的图片格式;WebP 由签名校验保护。 */ val IMAGEIO_DECODABLE_EXTS = setOf("jpg", "jpeg", "png", "gif", "bmp") } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ImageUrlPolicy.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ImageUrlPolicy.kt new file mode 100644 index 0000000..f082451 --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ImageUrlPolicy.kt @@ -0,0 +1,39 @@ +package `fun`.utf8.nekoprojectbackend.service + +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import java.net.URI + +/** Normalizes image addresses while rejecting browser-executable or ambiguous URL forms. */ +object ImageUrlPolicy { + fun normalize(value: String?, maxLength: Int, fieldName: String): String? { + val normalized = value?.trim()?.ifBlank { null } ?: return null + if (normalized.length > maxLength) { + throw ParamErrorException("$fieldName 不能超过 $maxLength 个字符") + } + if (normalized.any { it.isISOControl() } || '\\' in normalized) { + throw ParamErrorException("$fieldName 格式不正确") + } + if (normalized.startsWith("//")) { + throw ParamErrorException("$fieldName 不能使用省略协议的地址") + } + + // A single leading slash is a same-origin asset path. This keeps uploaded + // /api/files/... URLs and existing site assets deployable behind a proxy. + if (normalized.startsWith('/')) { + return normalized + } + + val uri = try { + URI(normalized) + } catch (_: Exception) { + throw ParamErrorException("$fieldName 格式不正确") + } + val scheme = uri.scheme?.lowercase() + if (scheme !in ALLOWED_SCHEMES || uri.host.isNullOrBlank() || uri.userInfo != null) { + throw ParamErrorException("$fieldName 仅支持本站路径或 HTTP(S) 地址") + } + return normalized + } + + private val ALLOWED_SCHEMES = setOf("http", "https") +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt index 29a54c6..f9d0705 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt @@ -5,6 +5,10 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,6 +17,14 @@ data class JoinApplicationAdminRejectRequest( val rejectReason: String? = null, ) +data class JoinApplicationPageVO( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, +) + /** * 加入申请管理业务:统一走 JWT 鉴权(项目 OWNER/MANAGER 或超管,由 AccessService.ensureCanManage 校验)。 * 接受申请时同事务创建 ACTIVE MEMBER(设计 §9.1)。 @@ -28,15 +40,81 @@ class JoinApplicationManagementService( objectItemId: Int, status: JoinApplicationStatus?, ): List { - val applications = if (status != null) { - joinApplicationRepository.findByObjectItemIdAndStatus(objectItemId, status) - } else { - joinApplicationRepository.findByObjectItemId(objectItemId) + val page = listByAdminPage(objectItemId, status, 0, MAX_UNPAGED_RESULTS) + if (page.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("加入申请超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") + } + return page.content + } + + @Transactional(readOnly = true) + fun listByAdminPage( + objectItemId: Int, + status: JoinApplicationStatus?, + page: Int, + size: Int, + ): JoinApplicationPageVO { + requirePositiveItemId(objectItemId) + return queryPage( + page = page, + size = size, + query = { pageable -> + if (status != null) { + joinApplicationRepository.findByObjectItemIdAndStatus(objectItemId, status, pageable) + } else { + joinApplicationRepository.findByObjectItemId(objectItemId, pageable) + } + }, + count = { + if (status != null) { + joinApplicationRepository.countByObjectItemIdAndStatus(objectItemId, status) + } else { + joinApplicationRepository.countByObjectItemId(objectItemId) + } + }, + ) + } + + private fun queryPage( + page: Int, + size: Int, + query: (Pageable) -> Page, + count: () -> Long, + ): JoinApplicationPageVO { + validatePageRequest(page, size) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = count() + return JoinApplicationPageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) } - return applications.asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + + val result = query(PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))) + return JoinApplicationPageVO( + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, + page = page, + size = size, + ) + } + + private fun validatePageRequest(page: Int, size: Int) { + if (page < 0) throw ParamErrorException("页码不能小于 0") + if (size <= 0) throw ParamErrorException("每页条数必须大于 0") + if (size > MAX_PAGE_SIZE) throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") + } + + private fun totalPages(totalElements: Long, size: Int): Int = + if (totalElements == 0L) 0 else (((totalElements - 1) / size) + 1) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + + private fun requirePositiveItemId(objectItemId: Int) { + if (objectItemId <= 0) throw ParamErrorException("项目条目 ID 必须大于 0") } /** 同意加入申请:JWT 鉴权(由控制器层保证),同事务创建 ACTIVE MEMBER(设计 §9.1)。 */ @@ -105,5 +183,7 @@ class JoinApplicationManagementService( private companion object { private const val MAX_REJECT_REASON_LENGTH = 255 + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt index a2f09b5..141a79e 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt @@ -3,7 +3,9 @@ package `fun`.utf8.nekoprojectbackend.service import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplication import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationStatus +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.NeedMemberItem import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus import `fun`.utf8.nekoprojectbackend.handlder.ForbiddenException import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException @@ -48,7 +50,9 @@ class JoinApplicationService( applicantUserId: Long? = null, ): JoinApplicationResponse { val resolvedItemId = requirePositiveItemId(objectItemId) - ensureObjectItemExists(resolvedItemId) + val item = objectItemRepository.findById(resolvedItemId) + .orElseThrow { ResourceNotFoundException("项目条目不存在") } + val requestedSkill = requireRecruitingSkill(resolvedItemId, item.status, item.needMembers, request.skill) // 同一用户对同一项目已有待处理申请则拒绝重复(设计 §9.1) if (applicantUserId != null) { @@ -80,8 +84,7 @@ class JoinApplicationService( "申请人联系方式不能超过 $MAX_CONTACT_LENGTH 个字符" ) it.reason = requireText(request.reason, "申请理由不能为空") - it.skill = - normalizeNullableText(request.skill, MAX_SKILL_LENGTH, "申请岗位不能超过 $MAX_SKILL_LENGTH 个字符") + it.skill = requestedSkill it.status = JoinApplicationStatus.PENDING } return joinApplicationRepository.save(entity).toResponse() @@ -105,10 +108,39 @@ class JoinApplicationService( return joinApplicationRepository.save(application).toResponse() } - private fun ensureObjectItemExists(objectItemId: Int) { - if (!objectItemRepository.existsById(objectItemId)) { + private fun requireRecruitingSkill( + objectItemId: Int, + status: ObjectItemStatus?, + needMembers: List?, + rawSkill: String?, + ): String { + if (status !in PUBLIC_STATUSES) { throw ResourceNotFoundException("项目条目不存在") } + if (status != ObjectItemStatus.RECRUITING) { + throw ParamErrorException("项目当前不在招募中") + } + val skill = requireText( + rawSkill.orEmpty(), + "申请岗位不能为空", + MAX_SKILL_LENGTH, + "申请岗位不能超过 $MAX_SKILL_LENGTH 个字符", + ) + val matchedNeed = needMembers.orEmpty() + .firstOrNull { it.skill?.trim()?.equals(skill, ignoreCase = true) == true } + ?: throw ParamErrorException("申请岗位不存在或未开放") + val capacity = matchedNeed.number ?: 0L + if (capacity <= 0) { + throw ParamErrorException("申请岗位暂无名额") + } + val accepted = joinApplicationRepository + .findByObjectItemIdAndStatus(objectItemId, JoinApplicationStatus.ACCEPTED) + .count { it.skill?.trim()?.equals(skill, ignoreCase = true) == true } + .toLong() + if (accepted >= capacity) { + throw ParamErrorException("申请岗位名额已满") + } + return matchedNeed.skill?.trim().orEmpty() } private fun requirePositiveItemId(objectItemId: Int): Int { diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt index ea2ee33..197155c 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt @@ -6,45 +6,71 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.MindRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.MindStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime data class MindSaveRequest( + @field:NotBlank(message = "想法标题不能为空") + @field:Size(max = 128, message = "想法标题不能超过 128 个字符") val title: String = "", + @field:Size(max = 64, message = "想法昵称不能超过 64 个字符") val nickName: String? = null, val status: MindStatus? = MindStatus.PENDING, + @field:NotBlank(message = "想法内容不能为空") + @field:Size(max = 10000, message = "想法内容不能超过 10000 个字符") val content: String? = null, + @field:Size(max = 64, message = "想法 Minecraft ID 不能超过 64 个字符") val mcId: String? = null, ) data class MindBatchSaveRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") + @field:Valid val items: List = emptyList(), ) data class MindUpdateRequest( val id: Int? = null, + @field:Size(max = 128, message = "想法标题不能超过 128 个字符") val title: String? = null, + @field:Size(max = 64, message = "想法昵称不能超过 64 个字符") val nickName: String? = null, val status: MindStatus? = null, + @field:Size(max = 10000, message = "想法内容不能超过 10000 个字符") val content: String? = null, + @field:Size(max = 64, message = "想法 Minecraft ID 不能超过 64 个字符") val mcId: String? = null, ) data class MindBatchUpdateRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") + @field:Valid val items: List = emptyList(), ) data class MindBatchDeleteRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") val ids: List = emptyList(), ) data class MindQueryRequest( + @field:Size(max = 100, message = "想法 ID 查询不能超过 100 个") val ids: List? = null, + @field:Size(max = 128, message = "想法标题查询不能超过 128 个字符") val title: String? = null, + @field:Size(max = 64, message = "想法昵称查询不能超过 64 个字符") val nickName: String? = null, val status: MindStatus? = null, + @field:Size(max = 4, message = "想法状态查询不能超过 4 个") val statuses: List? = null, + @field:Size(max = 64, message = "Minecraft ID 查询不能超过 64 个字符") val mcId: String? = null, ) @@ -85,6 +111,7 @@ enum class MindSortProperty(val alias: String) { @Service class MindService( private val mindRepository: MindRepository, + private val submissionTrackingService: SubmissionTrackingService, private val moderationProperties: ModerationProperties, ) { @@ -92,6 +119,16 @@ class MindService( fun save(request: MindSaveRequest): MindResponse = mindRepository.save(request.toEntity()).toResponse() + @Transactional + fun saveTracked(request: MindSaveRequest): TrackedSubmission { + val issued = submissionTrackingService.issue() + val entity = request.toEntity().also { it.trackingTokenHash = issued.hash } + return TrackedSubmission( + value = mindRepository.save(entity).toResponse(), + trackingToken = issued.token, + ) + } + @Transactional fun saveBatch(requests: List): List { validateBatchSize(requests, "批量保存想法不能为空") @@ -102,10 +139,39 @@ class MindService( @Transactional(readOnly = true) fun findById(id: Int): MindResponse = findMind(id).toResponse() + @Transactional(readOnly = true) + fun findPublicById(id: Int): MindResponse { + val mind = findMind(id) + if (mind.status != MindStatus.APPROVED) { + throw ResourceNotFoundException("想法不存在") + } + return mind.toResponse() + } + + @Transactional(readOnly = true) + fun findTracked(id: Int, trackingToken: String): MindResponse { + val mind = findMind(id) + if (!submissionTrackingService.matches(trackingToken, mind.trackingTokenHash)) { + throw ResourceNotFoundException("想法不存在或追踪码不正确") + } + return mind.toResponse() + } + @Transactional(readOnly = true) fun query(request: MindQueryRequest): List = - filterMinds(request).sortedWith(mindComparator(DEFAULT_SORT)) - .map { it.toResponse() } + mindRepository.findAll( + specification(request), + PageRequest.of(0, MAX_UNPAGED_RESULTS, toSpringSort(DEFAULT_SORT)), + ).let { result -> + if (result.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("匹配想法超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") + } + result.content.map { it.toResponse() } + } + + @Transactional(readOnly = true) + fun queryPublic(request: MindQueryRequest): List = + query(request.copy(status = MindStatus.APPROVED, statuses = null)) @Transactional(readOnly = true) fun queryPage(request: MindQueryRequest, page: Int, size: Int, sort: String): MindPageVO { @@ -113,34 +179,53 @@ class MindService( if (size <= 0) throw ParamErrorException("每页条数必须大于 0") if (size > MAX_PAGE_SIZE) throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") - val sorted = filterMinds(request).sortedWith(mindComparator(sort)) - val total = sorted.size.toLong() - val totalPages = if (total == 0L) 0 else ((total + size - 1) / size).toInt() - val fromIndex = minOf(page * size, sorted.size) - val toIndex = minOf(fromIndex + size, sorted.size) - val pageContent = sorted.subList(fromIndex, toIndex).map { it.toResponse() } + val specification = specification(request) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = mindRepository.count(specification) + return MindPageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) + } + val result = mindRepository.findAll( + specification, + PageRequest.of(page, size, toSpringSort(sort)), + ) return MindPageVO( - content = pageContent, - totalElements = total, - totalPages = totalPages, + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, page = page, size = size, ) } + @Transactional(readOnly = true) + fun queryPublicPage(request: MindQueryRequest, page: Int, size: Int, sort: String): MindPageVO = + queryPage(request.copy(status = MindStatus.APPROVED, statuses = null), page, size, sort) + @Transactional(readOnly = true) fun findByStatus(status: MindStatus): List = - mindRepository.findByStatus(status) - .sortedWith(mindComparator(DEFAULT_SORT)) - .map { it.toResponse() } + query(MindQueryRequest(status = status)) + + @Transactional(readOnly = true) + fun findPublicByStatus(status: MindStatus): List = + if (status == MindStatus.APPROVED) findByStatus(status) else emptyList() @Transactional(readOnly = true) fun findByStatuses(statuses: List): List { if (statuses.isEmpty()) throw ParamErrorException("状态列表不能为空") - return mindRepository.findByStatusIn(statuses) - .sortedWith(mindComparator(DEFAULT_SORT)) - .map { it.toResponse() } + return query(MindQueryRequest(statuses = statuses)) + } + + @Transactional(readOnly = true) + fun findPublicByStatuses(statuses: List): List { + if (statuses.isEmpty()) throw ParamErrorException("状态列表不能为空") + return if (MindStatus.APPROVED in statuses) findByStatus(MindStatus.APPROVED) else emptyList() } @Transactional(readOnly = true) @@ -194,48 +279,74 @@ class MindService( updateBatch(ids.map { MindUpdateRequest(id = it, status = MindStatus.DELETED) }) } - private fun filterMinds(request: MindQueryRequest): List { + private fun specification(request: MindQueryRequest): Specification { val ids = normalizeIds(request.ids) - val normalizedTitle = normalizeNullableText(request.title) - val normalizedNickName = normalizeNullableText(request.nickName) - val normalizedMcId = normalizeNullableText(request.mcId) + val normalizedTitle = normalizeNullableText( + request.title, + MAX_TITLE_LENGTH, + "想法标题查询不能超过 $MAX_TITLE_LENGTH 个字符", + ) + val normalizedNickName = normalizeNullableText( + request.nickName, + MAX_NICK_NAME_LENGTH, + "想法昵称查询不能超过 $MAX_NICK_NAME_LENGTH 个字符", + ) + val normalizedMcId = normalizeNullableText( + request.mcId, + MAX_MC_ID_LENGTH, + "Minecraft ID 查询不能超过 $MAX_MC_ID_LENGTH 个字符", + ) val requestedStatuses = normalizeStatuses(request.status, request.statuses) - val filterByStatus = requestedStatuses.isNotEmpty() - - val source = if (ids.isNullOrEmpty()) { - mindRepository.findAll() - } else { - mindRepository.findAllById(ids).toList() - } - - return source.asSequence() - .filter { normalizedTitle == null || it.title?.contains(normalizedTitle, ignoreCase = true) == true } - .filter { - normalizedNickName == null || it.nickName?.contains( - normalizedNickName, - ignoreCase = true - ) == true + return Specification { root, _, criteriaBuilder -> + val predicates = mutableListOf() + ids?.takeIf { it.isNotEmpty() }?.let { + predicates += root.get("id").`in`(it) + } + normalizedTitle?.let { + predicates += criteriaBuilder.like( + criteriaBuilder.lower(root.get("title")), + containsPattern(it), + LIKE_ESCAPE, + ) + } + normalizedNickName?.let { + predicates += criteriaBuilder.like( + criteriaBuilder.lower(root.get("nickName")), + containsPattern(it), + LIKE_ESCAPE, + ) } - .filter { !filterByStatus || it.status in requestedStatuses } - .filter { normalizedMcId == null || it.mcId?.equals(normalizedMcId, ignoreCase = true) == true } - .toList() + requestedStatuses.takeIf { it.isNotEmpty() }?.let { + predicates += root.get("status").`in`(it) + } + normalizedMcId?.let { + predicates += criteriaBuilder.equal( + criteriaBuilder.lower(root.get("mcId")), + it.lowercase(), + ) + } + criteriaBuilder.and(*predicates.toTypedArray()) + } } - private fun mindComparator(sort: String): Comparator { + private fun toSpringSort(sort: String): Sort { val (property, direction) = parseSort(sort) - val base: Comparator = when (property) { - MindSortProperty.ID -> compareBy { it.id ?: Int.MAX_VALUE } - MindSortProperty.CREATE_TIME -> compareBy { it.createTime ?: LocalDateTime.MIN } - MindSortProperty.UPDATE_TIME -> compareBy { it.updateTime ?: LocalDateTime.MIN } - } - return if (direction == SortDirection.DESC) base.reversed() else base + val springDirection = if (direction == SortDirection.DESC) Sort.Direction.DESC else Sort.Direction.ASC + return Sort.by(springDirection, property.alias) } + private fun totalPages(totalElements: Long, size: Int): Int = + if (totalElements == 0L) 0 else (((totalElements - 1) / size) + 1) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + private fun parseSort(sort: String): Pair { val parts = sort.split(",").map { it.trim() }.filter { it.isNotEmpty() } if (parts.isEmpty()) { return MindSortProperty.CREATE_TIME to SortDirection.DESC } + if (parts.size > 2) { + throw ParamErrorException("排序参数格式错误,应为 字段,方向") + } val property = MindSortProperty.from(parts[0]) ?: throw ParamErrorException("不支持的排序字段:${parts[0]},支持 id / createTime / updateTime") val direction = if (parts.size > 1) { @@ -252,7 +363,12 @@ class MindService( it.nickName = normalizeNullableText(nickName, MAX_NICK_NAME_LENGTH, "想法昵称不能超过 $MAX_NICK_NAME_LENGTH 个字符") it.status = if (moderationProperties.enabled) MindStatus.PENDING else MindStatus.APPROVED - it.content = requireText(content ?: "", "想法内容不能为空") + it.content = requireText( + content ?: "", + "想法内容不能为空", + MAX_CONTENT_LENGTH, + "想法内容不能超过 $MAX_CONTENT_LENGTH 个字符", + ) it.mcId = normalizeNullableText(mcId, MAX_MC_ID_LENGTH, "想法 Minecraft ID 不能超过 $MAX_MC_ID_LENGTH 个字符") } @@ -264,7 +380,14 @@ class MindService( nickName = normalizeNullableText(it, MAX_NICK_NAME_LENGTH, "想法昵称不能超过 $MAX_NICK_NAME_LENGTH 个字符") } request.status?.let { status = it } - request.content?.let { content = requireText(it, "想法内容不能为空") } + request.content?.let { + content = requireText( + it, + "想法内容不能为空", + MAX_CONTENT_LENGTH, + "想法内容不能超过 $MAX_CONTENT_LENGTH 个字符", + ) + } request.mcId?.let { mcId = normalizeNullableText(it, MAX_MC_ID_LENGTH, "想法 Minecraft ID 不能超过 $MAX_MC_ID_LENGTH 个字符") } @@ -281,13 +404,20 @@ class MindService( private fun normalizeIds(ids: List?): List? { if (ids == null) return null + if (ids.size > MAX_QUERY_ID_COUNT) { + throw ParamErrorException("想法 ID 查询不能超过 $MAX_QUERY_ID_COUNT 个") + } return ids.map { requirePositiveId(it) } .distinct() .takeIf { it.isNotEmpty() } } - private fun normalizeStatuses(status: MindStatus?, statuses: List?): Set = - (listOfNotNull(status) + statuses.orEmpty()).toSet() + private fun normalizeStatuses(status: MindStatus?, statuses: List?): Set { + if (statuses != null && statuses.size > MindStatus.entries.size) { + throw ParamErrorException("想法状态查询不能超过 ${MindStatus.entries.size} 个") + } + return (listOfNotNull(status) + statuses.orEmpty()).toSet() + } private fun validateBatchSize(items: List, emptyMessage: String) { if (items.isEmpty()) throw ParamErrorException(emptyMessage) @@ -314,6 +444,14 @@ class MindService( return normalized } + private fun containsPattern(value: String): String { + val escaped = value.lowercase() + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + return "%$escaped%" + } + private fun Mind.toResponse(): MindResponse = MindResponse( id = id, title = title, @@ -329,8 +467,12 @@ class MindService( private const val DEFAULT_SORT = "createTime,desc" private const val MAX_BATCH_SIZE = 100 private const val MAX_TITLE_LENGTH = 128 + private const val MAX_CONTENT_LENGTH = 10_000 private const val MAX_NICK_NAME_LENGTH = 64 private const val MAX_MC_ID_LENGTH = 64 - private const val MAX_PAGE_SIZE = 1024 + private const val MAX_QUERY_ID_COUNT = 100 + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 + private const val LIKE_ESCAPE = '\\' } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt index 0bb1eb5..c1c8309 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt @@ -5,9 +5,21 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +data class ObjectItemCommentPageVO( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, +) + /** 项目评论管理业务:统一 JWT 鉴权(项目 OWNER/MANAGER 或超管,由 AccessService.ensureCanManage 校验)。 */ @Service class ObjectItemCommentManagementService( @@ -19,15 +31,81 @@ class ObjectItemCommentManagementService( objectItemId: Int, status: ObjectItemCommentStatus?, ): List { - val comments = if (status != null) { - objectItemCommentRepository.findByObjectItemIdAndStatus(objectItemId, status) - } else { - objectItemCommentRepository.findByObjectItemId(objectItemId) + val page = listByAdminPage(objectItemId, status, 0, MAX_UNPAGED_RESULTS) + if (page.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("项目评论超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") } - return comments.asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + return page.content + } + + @Transactional(readOnly = true) + fun listByAdminPage( + objectItemId: Int, + status: ObjectItemCommentStatus?, + page: Int, + size: Int, + ): ObjectItemCommentPageVO { + requirePositiveItemId(objectItemId) + return queryPage( + page = page, + size = size, + query = { pageable -> + if (status != null) { + objectItemCommentRepository.findByObjectItemIdAndStatus(objectItemId, status, pageable) + } else { + objectItemCommentRepository.findByObjectItemId(objectItemId, pageable) + } + }, + count = { + if (status != null) { + objectItemCommentRepository.countByObjectItemIdAndStatus(objectItemId, status) + } else { + objectItemCommentRepository.countByObjectItemId(objectItemId) + } + }, + ) + } + + private fun queryPage( + page: Int, + size: Int, + query: (Pageable) -> Page, + count: () -> Long, + ): ObjectItemCommentPageVO { + validatePageRequest(page, size) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = count() + return ObjectItemCommentPageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) + } + + val result = query(PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))) + return ObjectItemCommentPageVO( + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, + page = page, + size = size, + ) + } + + private fun validatePageRequest(page: Int, size: Int) { + if (page < 0) throw ParamErrorException("页码不能小于 0") + if (size <= 0) throw ParamErrorException("每页条数必须大于 0") + if (size > MAX_PAGE_SIZE) throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") + } + + private fun totalPages(totalElements: Long, size: Int): Int = + if (totalElements == 0L) 0 else (((totalElements - 1) / size) + 1) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + + private fun requirePositiveItemId(objectItemId: Int) { + if (objectItemId <= 0) throw ParamErrorException("项目条目 ID 必须大于 0") } @Transactional @@ -74,4 +152,9 @@ class ObjectItemCommentManagementService( updateTime = updateTime, ) } + + private companion object { + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt index 37e48c3..82a90bd 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt @@ -37,13 +37,15 @@ class ObjectItemCommentService( @Transactional(readOnly = true) fun findByObjectItem( objectItemId: Int, - status: ObjectItemCommentStatus? + @Suppress("UNUSED_PARAMETER") status: ObjectItemCommentStatus?, ): List { val resolvedItemId = requirePositiveItemId(objectItemId) - val effectiveStatus = status ?: ObjectItemCommentStatus.APPROVED - ensureObjectItemExists(resolvedItemId) + ensurePubliclyAvailable(resolvedItemId) - return objectItemCommentRepository.findByObjectItemIdAndStatus(resolvedItemId, effectiveStatus) + return objectItemCommentRepository.findByObjectItemIdAndStatus( + resolvedItemId, + ObjectItemCommentStatus.APPROVED, + ) .asSequence() .sortedBy { it.id ?: Int.MAX_VALUE } .map { it.toResponse() } @@ -62,7 +64,7 @@ class ObjectItemCommentService( @Transactional fun create(objectItemId: Int, request: ObjectItemCommentSaveRequest): ObjectItemCommentResponse { val resolvedItemId = requirePositiveItemId(objectItemId) - ensureObjectItemExists(resolvedItemId) + ensurePubliclyAvailable(resolvedItemId) val entity = ObjectItemComment().also { it.objectItemId = resolvedItemId @@ -78,8 +80,10 @@ class ObjectItemCommentService( return objectItemCommentRepository.save(entity).toResponse() } - private fun ensureObjectItemExists(objectItemId: Int) { - if (!objectItemRepository.existsById(objectItemId)) { + private fun ensurePubliclyAvailable(objectItemId: Int) { + val item = objectItemRepository.findById(objectItemId) + .orElseThrow { ResourceNotFoundException("项目条目不存在") } + if (item.status !in PUBLIC_STATUSES) { throw ResourceNotFoundException("项目条目不存在") } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt index 48a4f53..238efda 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt @@ -183,6 +183,15 @@ class ObjectItemService( return item.toResponse() } + /** Ensures a child resource is attached to a project visible on the public site. */ + @Transactional(readOnly = true) + fun ensurePubliclyAvailable(id: Int) { + val item = findObjectItem(id) + if (item.status !in PUBLIC_STATUSES) { + throw ResourceNotFoundException("项目条目不存在") + } + } + @Transactional(readOnly = true) fun query(request: ObjectItemQueryRequest): List { val spec = buildSpecification(request) @@ -292,7 +301,7 @@ class ObjectItemService( .orElseThrow { ResourceNotFoundException("用户不存在") } // 重新分配给同一人不重复计入上限 val alreadyOwned = item.ownerId == ownerId - if (!alreadyOwned && objectItemRepository.countByOwnerId(ownerId) >= maxPerManager) { + if (!alreadyOwned && countActiveOwnedProjects(ownerId) >= maxPerManager) { throw ParamErrorException("该用户名下项目已达上限 $maxPerManager") } } @@ -306,7 +315,7 @@ class ObjectItemService( */ @Transactional fun saveOwned(request: ObjectItemSaveRequest, ownerId: Long, status: ObjectItemStatus): ObjectItemResponse { - if (objectItemRepository.countByOwnerId(ownerId) >= maxPerManager) { + if (countActiveOwnedProjects(ownerId) >= maxPerManager) { throw ParamErrorException("名下项目已达上限 $maxPerManager") } val entity = request.toEntity() @@ -315,6 +324,9 @@ class ObjectItemService( return objectItemRepository.save(entity).toResponse() } + private fun countActiveOwnedProjects(ownerId: Long): Long = + objectItemRepository.countByOwnerIdAndStatusNot(ownerId, ObjectItemStatus.DELETED) + private fun ObjectItemSaveRequest.toEntity(): ObjectItem { return ObjectItem().also { it.title = @@ -339,10 +351,10 @@ class ObjectItemService( MAX_CONTACT_INFORMATION_LENGTH, "联系方式不能超过 $MAX_CONTACT_INFORMATION_LENGTH 个字符", ) - it.coverImageUrl = normalizeNullableText( + it.coverImageUrl = ImageUrlPolicy.normalize( coverImageUrl, MAX_COVER_IMAGE_URL_LENGTH, - "封面图地址不能超过 $MAX_COVER_IMAGE_URL_LENGTH 个字符", + "封面图地址", ) } } @@ -382,10 +394,10 @@ class ObjectItemService( ) } request.coverImageUrl?.let { - coverImageUrl = normalizeNullableText( + coverImageUrl = ImageUrlPolicy.normalize( it, MAX_COVER_IMAGE_URL_LENGTH, - "封面图地址不能超过 $MAX_COVER_IMAGE_URL_LENGTH 个字符" + "封面图地址", ) } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt index b621814..6e5d052 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt @@ -5,6 +5,10 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -33,15 +37,81 @@ class ObjectItemUpdateManagementService( objectItemId: Int, status: ObjectItemUpdateStatus?, ): List { - val updates = if (status != null) { - objectItemUpdateRepository.findByObjectItemIdAndStatus(objectItemId, status) - } else { - objectItemUpdateRepository.findByObjectItemId(objectItemId) + val page = listByAdminPage(objectItemId, status, 0, MAX_UNPAGED_RESULTS) + if (page.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("项目动态超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") } - return updates.asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + return page.content + } + + @Transactional(readOnly = true) + fun listByAdminPage( + objectItemId: Int, + status: ObjectItemUpdateStatus?, + page: Int, + size: Int, + ): ObjectItemUpdatePageVO { + requirePositiveItemId(objectItemId) + return queryPage( + page = page, + size = size, + query = { pageable -> + if (status != null) { + objectItemUpdateRepository.findByObjectItemIdAndStatus(objectItemId, status, pageable) + } else { + objectItemUpdateRepository.findByObjectItemId(objectItemId, pageable) + } + }, + count = { + if (status != null) { + objectItemUpdateRepository.countByObjectItemIdAndStatus(objectItemId, status) + } else { + objectItemUpdateRepository.countByObjectItemId(objectItemId) + } + }, + ) + } + + private fun queryPage( + page: Int, + size: Int, + query: (Pageable) -> Page, + count: () -> Long, + ): ObjectItemUpdatePageVO { + validatePageRequest(page, size) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = count() + return ObjectItemUpdatePageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) + } + + val result = query(PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))) + return ObjectItemUpdatePageVO( + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, + page = page, + size = size, + ) + } + + private fun validatePageRequest(page: Int, size: Int) { + if (page < 0) throw ParamErrorException("页码不能小于 0") + if (size <= 0) throw ParamErrorException("每页条数必须大于 0") + if (size > MAX_PAGE_SIZE) throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") + } + + private fun totalPages(totalElements: Long, size: Int): Int = + if (totalElements == 0L) 0 else (((totalElements - 1) / size) + 1) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + + private fun requirePositiveItemId(objectItemId: Int) { + if (objectItemId <= 0) throw ParamErrorException("项目条目 ID 必须大于 0") } @Transactional @@ -183,5 +253,7 @@ class ObjectItemUpdateManagementService( private companion object { private const val MAX_TITLE_LENGTH = 128 private const val MAX_IMAGE_URL_LENGTH = 512 + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt index 6ab39f7..d9eff47 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt @@ -1,11 +1,13 @@ package `fun`.utf8.nekoprojectbackend.service -import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdate import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateRepository import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException -import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -21,41 +23,120 @@ data class ObjectItemUpdateResponse( val updateTime: LocalDateTime?, ) +data class ObjectItemUpdatePageVO( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, +) + /** 项目动态业务:动态查询(默认仅返回已通过)。 */ @Service class ObjectItemUpdateService( - private val objectItemRepository: ObjectItemRepository, + private val objectItemService: ObjectItemService, private val objectItemUpdateRepository: ObjectItemUpdateRepository, ) { @Transactional(readOnly = true) - fun findByObjectItem(objectItemId: Int, status: ObjectItemUpdateStatus?): List { + fun findByObjectItem( + objectItemId: Int, + @Suppress("UNUSED_PARAMETER") status: ObjectItemUpdateStatus? = null, + ): List { + val page = findByObjectItemPage(objectItemId, 0, MAX_UNPAGED_RESULTS) + if (page.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("项目动态超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") + } + return page.content + } + + @Transactional(readOnly = true) + fun findByObjectItemPage( + objectItemId: Int, + page: Int, + size: Int, + ): ObjectItemUpdatePageVO { val resolvedItemId = requirePositiveItemId(objectItemId) - val effectiveStatus = status ?: ObjectItemUpdateStatus.APPROVED - ensureObjectItemExists(resolvedItemId) - - return objectItemUpdateRepository.findByObjectItemIdAndStatus(resolvedItemId, effectiveStatus) - .asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + objectItemService.ensurePubliclyAvailable(resolvedItemId) + + return queryPage( + page = page, + size = size, + query = { pageable -> + objectItemUpdateRepository.findByObjectItemIdAndStatus( + resolvedItemId, + ObjectItemUpdateStatus.APPROVED, + pageable, + ) + }, + count = { + objectItemUpdateRepository.countByObjectItemIdAndStatus( + resolvedItemId, + ObjectItemUpdateStatus.APPROVED, + ) + }, + ) } @Transactional(readOnly = true) fun findApproved(): List { - return objectItemUpdateRepository.findByStatus(ObjectItemUpdateStatus.APPROVED) - .asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + val page = findApprovedPage(0, MAX_UNPAGED_RESULTS) + if (page.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("项目动态超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") + } + return page.content + } + + @Transactional(readOnly = true) + fun findApprovedPage(page: Int, size: Int): ObjectItemUpdatePageVO { + return queryPage( + page = page, + size = size, + query = { pageable -> + objectItemUpdateRepository.findByStatus(ObjectItemUpdateStatus.APPROVED, pageable) + }, + count = { objectItemUpdateRepository.countByStatus(ObjectItemUpdateStatus.APPROVED) }, + ) } - private fun ensureObjectItemExists(objectItemId: Int) { - if (!objectItemRepository.existsById(objectItemId)) { - throw ResourceNotFoundException("项目条目不存在") + private fun queryPage( + page: Int, + size: Int, + query: (Pageable) -> Page, + count: () -> Long, + ): ObjectItemUpdatePageVO { + validatePageRequest(page, size) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = count() + return ObjectItemUpdatePageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) } + + val result = query(PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))) + return ObjectItemUpdatePageVO( + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, + page = page, + size = size, + ) } + private fun validatePageRequest(page: Int, size: Int) { + if (page < 0) throw ParamErrorException("页码不能小于 0") + if (size <= 0) throw ParamErrorException("每页条数必须大于 0") + if (size > MAX_PAGE_SIZE) throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") + } + + private fun totalPages(totalElements: Long, size: Int): Int = + if (totalElements == 0L) 0 else (((totalElements - 1) / size) + 1) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + private fun requirePositiveItemId(objectItemId: Int): Int { if (objectItemId <= 0) { throw ParamErrorException("项目条目 ID 必须大于 0") @@ -75,4 +156,9 @@ class ObjectItemUpdateService( updateTime = updateTime, ) } + + private companion object { + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt index de6d7f6..29bb9c7 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt @@ -229,10 +229,25 @@ class OperationLogService( append(',').append('"').append(key).append("\":").append(value) } - private fun escape(s: String): String = s - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") + private fun escape(s: String): String = buildString(s.length) { + s.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + else -> { + if (ch.code < 0x20) { + append("\\u") + append(ch.code.toString(16).padStart(4, '0')) + } else { + append(ch) + } + } + } + } + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ProjectControlPasswordPolicy.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ProjectControlPasswordPolicy.kt new file mode 100644 index 0000000..83ed7ee --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ProjectControlPasswordPolicy.kt @@ -0,0 +1,36 @@ +package `fun`.utf8.nekoprojectbackend.service + +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException + +/** 项目控制密码在 BCrypt 编码前的统一业务校验。 */ +internal object ProjectControlPasswordPolicy { + private const val MIN_LENGTH = 6 + private const val MAX_BCRYPT_PASSWORD_BYTES = 72 + + fun normalizeOptional(value: String?): String? { + val normalized = value?.trim() + if (normalized.isNullOrBlank()) { + return null + } + validate(normalized) + return normalized + } + + fun normalizeRequired(value: String?): String { + val normalized = value?.trim().orEmpty() + if (normalized.isBlank()) { + throw ParamErrorException("新控制密码不能为空") + } + validate(normalized) + return normalized + } + + private fun validate(value: String) { + if (value.length < MIN_LENGTH) { + throw ParamErrorException("项目控制密码至少需要 $MIN_LENGTH 位") + } + if (value.toByteArray(Charsets.UTF_8).size > MAX_BCRYPT_PASSWORD_BYTES) { + throw ParamErrorException("项目控制密码的 UTF-8 长度不能超过 $MAX_BCRYPT_PASSWORD_BYTES 字节") + } + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt index f2bec4a..b35eee6 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt @@ -1,61 +1,116 @@ package `fun`.utf8.nekoprojectbackend.service -import `fun`.utf8.nekoprojectbackend.handlder.BusinessException +import `fun`.utf8.nekoprojectbackend.handlder.TooManyRequestsException +import org.springframework.beans.factory.annotation.Value import org.springframework.data.redis.core.StringRedisTemplate -import org.springframework.http.HttpStatus +import org.springframework.data.redis.core.script.DefaultRedisScript import org.springframework.stereotype.Service +import java.security.MessageDigest import java.time.Duration -/** - * 限流组件(基于 Redis,设计 §5.2 / §12):滑动失败计数 + 临时锁定。 - * - * - [recordFailAndCheckLock]:累计失败次数,达阈值写入 lock key 并返回锁定; - * - [ensureNotLocked]:检查是否处于锁定,锁定则抛 429; - * - [clearFails]:成功后清零计数。 - * - * 临时锁定存 Redis(区别于管理员封禁),不暴露具体账号是否存在。 - */ +/** Redis-backed fixed-window limiter for login failures and anonymous writes. */ @Service class RateLimiter( private val redis: StringRedisTemplate, + @Value("\${neko.security.rate-limit.enabled:true}") private val enabled: Boolean, ) { - /** 命中锁定抛 429,message 不含账号名,仅提示剩余秒数。 */ + fun consume(namespace: String, identity: String, limit: Int, window: Duration) { + if (!enabled || identity.isBlank()) return + require(limit > 0 && !window.isNegative && !window.isZero) + + val key = counterKey("request", namespace, identity) + val count = increment(key, window) + if (count > limit) { + throw TooManyRequestsException(retryMessage(key)) + } + } + fun ensureNotLocked(namespace: String, identity: String) { - val ttl = redis.getExpire(lockKey(namespace, identity)) + if (!enabled || identity.isBlank()) return + val key = lockKey(namespace, identity) + val ttl = redis.getExpire(key) if (ttl != null && ttl > 0) { - throw BusinessException(HttpStatus.TOO_MANY_REQUESTS, "操作过于频繁,请 ${ttl}s 后重试") + throw TooManyRequestsException("操作过于频繁,请 $ttl 秒后重试") } } - /** - * 记录一次失败并判断是否触发锁定。 - * @return true 表示本次失败触发了锁定(调用方通常仍抛业务异常)。 - */ + /** Returns true when this failure reaches the threshold and creates a temporary lock. */ + fun recordFailure( + namespace: String, + identity: String, + maxFailures: Int, + window: Duration, + lockDuration: Duration, + ): Boolean { + if (!enabled || identity.isBlank()) return false + require(maxFailures > 0 && !window.isNegative && !window.isZero) + require(!lockDuration.isNegative && !lockDuration.isZero) + + val failureKey = counterKey("failure", namespace, identity) + if (increment(failureKey, window) < maxFailures) return false + + redis.opsForValue().set(lockKey(namespace, identity), "1", lockDuration) + redis.delete(failureKey) + return true + } + + fun clearFailures(namespace: String, identity: String) { + if (!enabled || identity.isBlank()) return + redis.delete(counterKey("failure", namespace, identity)) + } + fun recordFailAndCheckLock( namespace: String, identity: String, maxFail: Int, windowSeconds: Long, lockSeconds: Long, - ): Boolean { - val key = failKey(namespace, identity) - val count = redis.opsForValue().increment(key) ?: 1L - if (count == 1L) { - redis.expire(key, Duration.ofSeconds(windowSeconds)) - } - if (count >= maxFail) { - redis.opsForValue().set(lockKey(namespace, identity), "1", Duration.ofSeconds(lockSeconds)) - redis.delete(key) - return true + ): Boolean = recordFailure( + namespace, + identity, + maxFail, + Duration.ofSeconds(windowSeconds), + Duration.ofSeconds(lockSeconds), + ) + + fun clearFails(namespace: String, identity: String) = clearFailures(namespace, identity) + + private fun increment(key: String, window: Duration): Long = + redis.execute(INCREMENT_SCRIPT, listOf(key), window.seconds.toString()) ?: 1L + + private fun retryMessage(key: String): String { + val ttl = redis.getExpire(key) + return if (ttl != null && ttl > 0) { + "操作过于频繁,请 $ttl 秒后重试" + } else { + "操作过于频繁,请稍后重试" } - return false } - /** 成功后清零失败计数。 */ - fun clearFails(namespace: String, identity: String) { - redis.delete(failKey(namespace, identity)) - } + private fun counterKey(type: String, namespace: String, identity: String): String = + "rate:$type:${safeNamespace(namespace)}:${digest(identity)}" + + private fun lockKey(namespace: String, identity: String): String = + "rate:lock:${safeNamespace(namespace)}:${digest(identity)}" + + private fun safeNamespace(value: String): String = value.filter { it.isLetterOrDigit() || it in "-_" }.take(48) - private fun failKey(namespace: String, identity: String) = "rate:fail:$namespace:$identity" - private fun lockKey(namespace: String, identity: String) = "rate:lock:$namespace:$identity" + private fun digest(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.trim().lowercase().toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private companion object { + val INCREMENT_SCRIPT = DefaultRedisScript().apply { + setScriptText( + """ + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return count + """.trimIndent(), + ) + resultType = Long::class.java + } + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/StorageService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/StorageService.kt index fc6d134..db14ef4 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/StorageService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/StorageService.kt @@ -37,8 +37,14 @@ class StorageService( val target = resolveAndGuard(relative) Files.createDirectories(target.parent) - file.inputStream.use { input -> - Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING) + try { + file.inputStream.use { input -> + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING) + } + } catch (ex: Exception) { + // 复制中断时也清掉可能留下的半文件,避免后续被误当成完整资源读取。 + runCatching { Files.deleteIfExists(target) } + throw ex } return relative } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/SubmissionTrackingService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/SubmissionTrackingService.kt new file mode 100644 index 0000000..4421a57 --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/SubmissionTrackingService.kt @@ -0,0 +1,48 @@ +package `fun`.utf8.nekoprojectbackend.service + +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +const val SUBMISSION_TRACKING_TOKEN_HEADER = "X-Submission-Tracking-Token" + +data class IssuedTrackingToken( + val token: String, + val hash: String, +) + +data class TrackedSubmission( + val value: T, + val trackingToken: String, +) + +/** Issues high-entropy anonymous tracking tokens and stores only their SHA-256 digest. */ +@Service +class SubmissionTrackingService { + private val secureRandom = SecureRandom() + + fun issue(): IssuedTrackingToken { + val bytes = ByteArray(TOKEN_BYTES) + secureRandom.nextBytes(bytes) + val token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + return IssuedTrackingToken(token = token, hash = hash(token)) + } + + fun matches(token: String, storedHash: String?): Boolean { + if (token.isBlank() || storedHash.isNullOrBlank()) return false + return MessageDigest.isEqual( + hash(token).toByteArray(StandardCharsets.US_ASCII), + storedHash.toByteArray(StandardCharsets.US_ASCII), + ) + } + + private fun hash(token: String): String = MessageDigest.getInstance("SHA-256") + .digest(token.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private companion object { + const val TOKEN_BYTES = 32 + } +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt index 3a057c4..095dfe4 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt @@ -34,6 +34,7 @@ class TokenStore( val value = "$userId|$ua|$safeIp|${System.currentTimeMillis()}" redis.opsForValue().set(accessKey(jti), value, ttl) redis.opsForSet().add(sessionIndexKey(userId), jti) + redis.expire(sessionIndexKey(userId), ttl) } /** 列出用户当前全部有效会话(access 仍在白名单中的)。 */ @@ -85,6 +86,7 @@ class TokenStore( redis.opsForValue().set(refreshKey(jti), userId.toString(), ttl) // 建立用户→refresh 索引,供改密码 / 找回密码时批量吊销该用户全部刷新令牌 redis.opsForSet().add(refreshIndexKey(userId), jti) + redis.expire(refreshIndexKey(userId), ttl) } /** 一次性消费刷新令牌:原子地取出并删除;不存在返回 null。顺带从用户索引移除,防集合膨胀。 */ diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt index 85b4a3c..9aecaef 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt @@ -93,6 +93,12 @@ class UserService( } // 大小写不敏感查重(按归一化列),邮箱同样归一化后查重 + PasswordPolicy.validate( + password = password, + username = normalizedUsername, + emailPrefix = normalizedEmail.substringBefore('@'), + ) + if (userRepository.findByUsernameLower(UsernamePolicy.normalizeKey(normalizedUsername)) != null) { throw UserAlreadyExistsException("用户名已存在") } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 660e41e..d0334f9 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -3,6 +3,8 @@ # 这里只放「非机密」的生产级硬约束;密钥(JWT_SECRET / DB / Redis 凭证)仍走环境变量,绝不写进仓库。 spring: + jmx: + enabled: false jpa: hibernate: # 生产用 update:账户系统改造引入大量新表 / 新列(project_member、users 生命周期字段、 @@ -19,13 +21,51 @@ spring: # 避免每次重启清空线上缓存 / 刷新令牌 / 限流计数。 clear-on-startup: false +security: + jwt: + # 生产不允许回退到 application.yaml 的开发密钥;缺少环境变量时直接启动失败。 + secret: ${JWT_SECRET} + +# 生产环境不得在应用重启时清空 Redis;否则全部登录会话、验证码和限流状态都会被抹除。 +redis: + clear-on-startup: false + logging: level: root: info org.hibernate.SQL: warn org.hibernate.type.descriptor.sql.BasicBinder: warn +management: + endpoint: + health: + show-components: never + show-details: never + endpoints: + web: + exposure: + include: health,info + jmx: + exposure: + exclude: "*" + neko: + cors: + # 生产必须显式提供逗号分隔的 HTTPS 前端来源,禁止使用通配符。 + allowed-origins: ${CORS_ALLOWED_ORIGINS} + admin: + # 首次建管理员时可临时设 true;密码不安全会直接拒绝启动。 + seed-enabled: ${ADMIN_SEED_ENABLED:false} + # 生产默认管理员凭证仍走环境变量,但 seeder 会拒绝用弱默认值(password)初始化超管。 + username: ${NEKO_ADMIN_USERNAME:admin} + password: ${NEKO_ADMIN_PASSWORD:} + email: ${NEKO_ADMIN_EMAIL:admin@nekobox.local} + seed: + # 生产环境禁止写入演示账号和演示项目。 + enabled: false + file: + # 生产必须显式填写对外 HTTPS 地址,避免上传后把示例域名写入数据库。 + base-url: ${FILE_BASE_URL} security: cookie: # 生产硬约束:refresh cookie 必须 HTTPS 传输 + 防 JS 读取。 @@ -36,10 +76,3 @@ neko: # 可信反代开关(设计 §5.2 修正):仅部署在可信反向代理后才采信 X-Forwarded-For / X-Real-IP。 # 未开启时 clientIp 只用 remoteAddr,防客户端伪造 XFF 绕过 IP 维度限流。 trusted-proxy: ${NEKO_TRUSTED_PROXY:false} - admin: - # 生产默认管理员凭证仍走环境变量,但 seeder 会拒绝用弱默认值(password)初始化超管 - # (见 AdminUserSeeder 守卫)。这里显式占位,便于运维知晓字段名。 - username: ${NEKO_ADMIN_USERNAME:admin} - password: ${NEKO_ADMIN_PASSWORD:} - email: ${NEKO_ADMIN_EMAIL:admin@nekobox.local} - diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2eab8ce..9c387f8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -58,7 +58,13 @@ security: # ───────────────────────── 项目自定义业务配置(neko.*) ───────────────────────── neko: + cors: + # 允许携带凭证访问 API 的前端来源,逗号分隔;禁止使用 *。 + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000} security: + rate-limit: + # 测试环境可关闭;生产应保持开启,Redis 不可用时请求会失败而不是绕过限制。 + enabled: ${RATE_LIMIT_ENABLED:true} cookie: # 刷新令牌 Cookie 名 name: ${COOKIE_NAME:nekobox_refresh} @@ -85,6 +91,8 @@ neko: send-interval-seconds: ${MAIL_VERIFICATION_SEND_INTERVAL:60} # 单邮箱每日发送上限(次) daily-limit: ${MAIL_VERIFICATION_DAILY_LIMIT:10} + # 单个验证码允许输错的最大次数;达到后立即作废 + max-attempts: ${MAIL_VERIFICATION_MAX_ATTEMPTS:5} # 发件人地址;留空则回退到 MAIL_USERNAME from: ${MAIL_FROM:${MAIL_USERNAME:}} # 验证码邮件主题前缀 @@ -96,7 +104,7 @@ neko: base-url: ${FILE_BASE_URL:http://localhost:8080} image: # 图片允许的扩展名(逗号分隔) - allowed-extensions: ${FILE_IMAGE_ALLOWED_EXT:jpg,jpeg,png,gif,webp,bmp,svg} + allowed-extensions: ${FILE_IMAGE_ALLOWED_EXT:jpg,jpeg,png,gif,webp,bmp} # 单张图片大小上限(MB) max-size-mb: ${FILE_IMAGE_MAX_MB:10} document: @@ -109,10 +117,13 @@ neko: # 私有下载签名 token 的有效期(秒):300 = 5min download-token-ttl-seconds: ${FILE_DOWNLOAD_TOKEN_TTL:300} admin: + # 是否在启动时创建初始管理员;生产 profile 默认关闭。 + seed-enabled: ${ADMIN_SEED_ENABLED:true} # 初始管理员用户名(首次启动由 AdminUserSeeder 写入) username: ${ADMIN_USERNAME:admin} # 初始管理员密码 - password: ${ADMIN_PASSWORD:password} + # 仅本地开发的初始密码;生产 profile 默认禁用 seed,若显式启用必须通过生产校验。 + password: ${ADMIN_PASSWORD:NekoLocalRoot!2026} # 初始管理员邮箱 email: ${ADMIN_EMAIL:admin@nekobox.local} project: @@ -275,3 +286,7 @@ logging: file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n" # 控制台日志格式(带 ANSI 颜色) console: "%clr(%d{yyyy-MM-dd HH:mm:ss}){faint} %clr([%thread]){cyan} %clr(%-5level){} %clr(%logger{50}){cyan} - %clr(%msg%n){magenta}" + +# 默认保留 Redis 中的登录会话、验证码与限流状态;仅本地需要重置时显式设为 true。 +redis: + clear-on-startup: ${REDIS_CLEAR_ON_STARTUP:false} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/AdminUserSeederTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/AdminUserSeederTest.kt new file mode 100644 index 0000000..9a8b636 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/AdminUserSeederTest.kt @@ -0,0 +1,57 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.AdminUserSeeder +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role +import `fun`.utf8.nekoprojectbackend.service.UserService +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.springframework.core.env.Environment +import kotlin.test.assertFailsWith + +class AdminUserSeederTest { + private val userService = mock(UserService::class.java) + private val environment = mock(Environment::class.java) + + @Test + fun `weak production seed password fails before creating an account`() { + `when`(environment.activeProfiles).thenReturn(arrayOf("prod")) + val seeder = seeder(password = "NekoLocalRoot!2026") + + assertFailsWith { seeder.seedAdmin() } + + verifyNoInteractions(userService) + } + + @Test + fun `disabled seed skips account lookup`() { + val seeder = seeder(password = "strong-password", enabled = false) + + seeder.seedAdmin() + + verifyNoInteractions(userService) + } + + @Test + fun `enabled seed does not swallow account creation failures`() { + `when`(environment.activeProfiles).thenReturn(emptyArray()) + `when`(userService.findByUsername("admin")).thenReturn(null) + doThrow(IllegalStateException("database unavailable")) + .`when`(userService) + .createUser("admin", "strong-password", "admin@example.test", Role.SUPER_ADMIN) + val seeder = seeder(password = "strong-password") + + assertFailsWith { seeder.seedAdmin() } + } + + private fun seeder(password: String, enabled: Boolean = true) = AdminUserSeeder( + userService = userService, + env = environment, + enabled = enabled, + username = "admin", + password = password, + email = "admin@example.test", + ) +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/AuthServiceSecurityTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/AuthServiceSecurityTest.kt new file mode 100644 index 0000000..c18076c --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/AuthServiceSecurityTest.kt @@ -0,0 +1,153 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.JwtProperties +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Status +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.User +import `fun`.utf8.nekoprojectbackend.handlder.UsernameOrPasswordErrorException +import `fun`.utf8.nekoprojectbackend.service.AuthService +import `fun`.utf8.nekoprojectbackend.service.JwtService +import `fun`.utf8.nekoprojectbackend.service.MailService +import `fun`.utf8.nekoprojectbackend.service.RateLimiter +import `fun`.utf8.nekoprojectbackend.service.TokenStore +import `fun`.utf8.nekoprojectbackend.service.UserService +import `fun`.utf8.nekoprojectbackend.service.VerificationCodeService +import java.time.LocalDateTime +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.springframework.security.crypto.password.PasswordEncoder +import kotlin.test.assertFailsWith + +class AuthServiceSecurityTest { + private val userService = mock(UserService::class.java) + private val passwordEncoder = mock(PasswordEncoder::class.java) + private val jwtService = mock(JwtService::class.java) + private val tokenStore = mock(TokenStore::class.java) + private val verificationCodeService = mock(VerificationCodeService::class.java) + private val mailService = mock(MailService::class.java) + private val rateLimiter = mock(RateLimiter::class.java) + private val service = AuthService( + userService, + passwordEncoder, + jwtService, + tokenStore, + verificationCodeService, + mailService, + rateLimiter, + JwtProperties(secret = "test-secret-that-is-long-enough-for-hs256"), + ) + + @Test + fun `wrong password does not consume email login code`() { + val user = user(id = 13, email = "email-login@example.test") + `when`(userService.findByEmail(user.email)).thenReturn(user) + `when`(passwordEncoder.matches("wrong-password", user.password)).thenReturn(false) + + assertFailsWith { + service.loginByEmail( + AuthService.EmailLoginRequest( + account = user.username, + password = "wrong-password", + email = user.email, + emailCode = "123456", + ), + "test-agent", + "203.0.113.10", + ) + } + + verifyNoInteractions(verificationCodeService) + } + + @Test + fun `password change verification is bound to the current user`() { + val user = user(id = 14, email = "change-password@example.test") + `when`(userService.findById(user.id!!)).thenReturn(user) + `when`(passwordEncoder.matches("old-password", user.password)).thenReturn(true) + `when`(passwordEncoder.encode("Str0ng!Pass")).thenReturn("encoded-new-password") + + service.changePassword( + user.id!!, + AuthService.ChangePasswordRequest( + oldPassword = "old-password", + newPassword = "Str0ng!Pass", + confirmPassword = "Str0ng!Pass", + emailCode = "123456", + ), + "test-agent", + ) + + verify(verificationCodeService).verifyAndConsume( + VerificationCodeService.CodeContext( + scene = VerificationCodeService.Scene.CHANGE_PASSWORD, + email = user.email, + userId = user.id!!, + userAgent = "test-agent", + ), + "123456", + ) + verify(tokenStore).invalidateAllSessions(user.id!!) + verify(mailService).sendSecurityNotice(user.email, "PASSWORD_CHANGED") + } + + @Test + fun `anonymous verification code requests do not enumerate email accounts`() { + val email = "unknown@example.test" + `when`(userService.findByEmail(email)).thenReturn(null) + + service.sendVerificationCode( + AuthService.SendCodeRequest( + email = email, + scene = VerificationCodeService.Scene.RESET_PASSWORD, + ), + "test-agent", + ) + + verify(verificationCodeService).checkAndRecordSend(email) + verifyNoInteractions(mailService) + } + + @Test + fun `logout does not revoke a refresh token owned by another user`() { + val jwt = JwtService(JwtProperties(secret = "test-secret-that-is-long-enough-for-hs256")) + val issued = jwt.issueRefreshToken(99, "other-user", Role.USER.name, 600) + `when`(jwtService.parse(issued.token)).thenReturn(jwt.parse(issued.token)) + + service.logout(jti = "access-jti", userId = 14, refreshToken = issued.token) + + verify(tokenStore).invalidateAccess("access-jti", 14) + verify(tokenStore, never()).revokeRefresh(issued.jti, 14) + } + + @Test + fun `oversized login password is rejected before bcrypt matching`() { + assertFailsWith { + service.login( + AuthService.LoginRequest( + account = "member-12", + password = "中".repeat(25), + ), + "test-agent", + "203.0.113.10", + ) + } + + verifyNoInteractions(userService, passwordEncoder) + } + + private fun user(id: Long, email: String) = User().apply { + this.id = id + this.username = "member-$id" + this.usernameLower = this.username.lowercase() + this.password = "encoded-password" + this.email = email + this.nickname = "member" + this.status = Status.ACTIVE + this.role = Role.USER + this.emailVerifiedAt = LocalDateTime.now() + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/FileStorageContractTests.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/FileStorageContractTests.kt new file mode 100644 index 0000000..6f8c6c7 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/FileStorageContractTests.kt @@ -0,0 +1,145 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.FileProperties +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileRecordRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.service.FileService +import `fun`.utf8.nekoprojectbackend.service.StorageService +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import java.nio.file.Files +import javax.imageio.ImageIO +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockMultipartFile +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class FileStorageContractTests { + + @Test + fun `upload removes the stored file when metadata persistence fails`() { + val root = Files.createTempDirectory("neko-file-upload-test") + try { + val properties = FileProperties( + storagePath = root.toString(), + image = FileProperties.TypePolicy(allowedExtensions = listOf("png"), maxSizeMb = 1), + ) + val storage = StorageService(properties) + val service = FileService( + storageService = storage, + fileRecordRepository = throwingRepository(FileRecordRepository::class.java), + objectItemRepository = throwingRepository(ObjectItemRepository::class.java), + properties = properties, + ) + + assertFailsWith { + service.upload( + MockMultipartFile( + "file", + "cover.png", + "image/png", + pngBytes(), + ), + `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory.IMAGE, + user = null, + ) + } + + Files.walk(root).use { paths -> + assertFalse(paths.anyMatch { Files.isRegularFile(it) }) + } + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `svg upload is rejected even when configuration includes svg`() { + val root = Files.createTempDirectory("neko-svg-upload-test") + try { + val service = fileService(root, listOf("svg")) + + assertFailsWith { + service.upload( + MockMultipartFile( + "file", + "cover.svg", + "image/svg+xml", + "".toByteArray(), + ), + `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory.IMAGE, + user = null, + ) + } + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `image extension mime and signature must agree`() { + val root = Files.createTempDirectory("neko-image-signature-test") + try { + val service = fileService(root, listOf("jpg")) + + assertFailsWith { + service.upload( + MockMultipartFile("file", "cover.jpg", "image/jpeg", pngBytes()), + `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory.IMAGE, + user = null, + ) + } + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `webp upload requires riff webp signature`() { + val root = Files.createTempDirectory("neko-webp-signature-test") + try { + val service = fileService(root, listOf("webp")) + + assertFailsWith { + service.upload( + MockMultipartFile("file", "cover.webp", "image/webp", "not-webp".toByteArray()), + `fun`.utf8.nekoprojectbackend.datasource.jdbc.FileCategory.IMAGE, + user = null, + ) + } + } finally { + root.toFile().deleteRecursively() + } + } + + private fun fileService(root: java.nio.file.Path, allowedExtensions: List): FileService { + val properties = FileProperties( + storagePath = root.toString(), + image = FileProperties.TypePolicy(allowedExtensions = allowedExtensions, maxSizeMb = 1), + ) + return FileService( + storageService = StorageService(properties), + fileRecordRepository = throwingRepository(FileRecordRepository::class.java), + objectItemRepository = throwingRepository(ObjectItemRepository::class.java), + properties = properties, + ) + } + + private fun pngBytes(): ByteArray { + return ByteArrayOutputStream().use { output -> + ImageIO.write(BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), "png", output) + output.toByteArray() + } + } + + @Suppress("UNCHECKED_CAST") + private fun throwingRepository(type: Class): T { + val handler = InvocationHandler { _, _, _ -> + throw IllegalStateException("database unavailable") + } + return Proxy.newProxyInstance(type.classLoader, arrayOf(type), handler) as T + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtAuthenticationFilterTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtAuthenticationFilterTest.kt new file mode 100644 index 0000000..5022a1b --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtAuthenticationFilterTest.kt @@ -0,0 +1,75 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.JwtProperties +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role +import `fun`.utf8.nekoprojectbackend.handlder.TokenInvalidException +import `fun`.utf8.nekoprojectbackend.security.JwtAuthenticationFilter +import `fun`.utf8.nekoprojectbackend.security.LoginUser +import `fun`.utf8.nekoprojectbackend.service.JwtService +import `fun`.utf8.nekoprojectbackend.service.TokenStore +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class JwtAuthenticationFilterTest { + private val jwtService = JwtService( + JwtProperties(secret = "test-secret-that-is-long-enough-for-hs256"), + ) + private val tokenStore = mock(TokenStore::class.java) + private val filter = JwtAuthenticationFilter(jwtService, tokenStore) + + @AfterEach + fun clearSecurityContext() { + SecurityContextHolder.clearContext() + } + + @Test + fun `valid access token creates the expected principal`() { + val issued = jwtService.issueAccessToken(7, "manager", Role.PROJECT_MANAGER.name, 60) + `when`(tokenStore.isAccessValid(issued.jti)).thenReturn(true) + + val request = authenticatedRequest(issued.token) + filter.doFilter(request, MockHttpServletResponse(), MockFilterChain()) + + val principal = SecurityContextHolder.getContext().authentication?.principal + assertIs(principal) + assertEquals(7, principal.id) + assertEquals(Role.PROJECT_MANAGER, principal.role) + } + + @Test + fun `refresh token cannot authenticate an api request`() { + val issued = jwtService.issueRefreshToken(7, "manager", Role.PROJECT_MANAGER.name, 60) + `when`(tokenStore.isAccessValid(issued.jti)).thenReturn(true) + + val request = authenticatedRequest(issued.token) + filter.doFilter(request, MockHttpServletResponse(), MockFilterChain()) + + assertNull(SecurityContextHolder.getContext().authentication) + assertIs(request.getAttribute(JwtAuthenticationFilter.AUTH_ERROR_ATTR)) + } + + @Test + fun `unknown role is rejected instead of becoming a project manager`() { + val issued = jwtService.issueAccessToken(7, "manager", "UNKNOWN_ROLE", 60) + `when`(tokenStore.isAccessValid(issued.jti)).thenReturn(true) + + val request = authenticatedRequest(issued.token) + filter.doFilter(request, MockHttpServletResponse(), MockFilterChain()) + + assertNull(SecurityContextHolder.getContext().authentication) + assertIs(request.getAttribute(JwtAuthenticationFilter.AUTH_ERROR_ATTR)) + } + + private fun authenticatedRequest(token: String) = MockHttpServletRequest().apply { + addHeader("Authorization", "Bearer $token") + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtProductionConfigurationValidatorTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtProductionConfigurationValidatorTest.kt new file mode 100644 index 0000000..41b8473 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/JwtProductionConfigurationValidatorTest.kt @@ -0,0 +1,114 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.JwtProductionConfigurationValidator +import `fun`.utf8.nekoprojectbackend.config.JwtProperties +import `fun`.utf8.nekoprojectbackend.config.FileProperties +import `fun`.utf8.nekoprojectbackend.config.TokenCookieProperties +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.jupiter.api.Test + +class JwtProductionConfigurationValidatorTest { + + @Test + fun `production validator accepts a strong secret and positive ttls`() { + assertConstructs( + JwtProperties( + secret = "01234567890123456789012345678901", + issuer = "NekoBackend", + accessTokenTtlSeconds = 7200, + refreshTokenTtlSeconds = 604800, + ), + ) + } + + @Test + fun `production validator rejects the development secret`() { + val exception = assertFailsWith { + JwtProductionConfigurationValidator( + JwtProperties(secret = "neko-backend-local-dev-secret-2026-change-me"), + secureCookie(), + secureFile(), + "https://project.example.test", + ) + } + + assertNotNull(exception.message) + } + + @Test + fun `production validator rejects a short secret`() { + assertFailsWith { + validator(JwtProperties(secret = "too-short")) + } + } + + @Test + fun `production validator rejects invalid token ttls`() { + assertFailsWith { + JwtProductionConfigurationValidator( + JwtProperties( + secret = "01234567890123456789012345678901", + accessTokenTtlSeconds = 0, + ), + secureCookie(), + secureFile(), + "https://project.example.test", + ) + } + } + + @Test + fun `production validator accepts case insensitive same site`() { + assertNull( + runCatching { + validator( + JwtProperties(secret = "01234567890123456789012345678901"), + secureCookie(sameSite = "strict"), + ) + }.exceptionOrNull(), + ) + } + + @Test + fun `production validator rejects non https cors origin`() { + assertFailsWith { + validator( + JwtProperties(secret = "01234567890123456789012345678901"), + allowedOrigins = "http://project.example.test", + ) + } + } + + @Test + fun `production validator rejects a local or non https file base url`() { + listOf("http://api.example.test", "https://localhost:8080", "https://api.example.test/files").forEach { + assertFailsWith { + validator( + JwtProperties(secret = "01234567890123456789012345678901"), + fileProps = FileProperties(baseUrl = it), + ) + } + } + } + + private fun assertConstructs(props: JwtProperties) { + assertNull(runCatching { validator(props) }.exceptionOrNull()) + } + + private fun validator( + props: JwtProperties, + cookieProps: TokenCookieProperties = secureCookie(), + fileProps: FileProperties = secureFile(), + allowedOrigins: String = "https://project.example.test", + ) = JwtProductionConfigurationValidator(props, cookieProps, fileProps, allowedOrigins) + + private fun secureCookie(sameSite: String = "Lax") = TokenCookieProperties( + secure = true, + httpOnly = true, + sameSite = sameSite, + ) + + private fun secureFile() = FileProperties(baseUrl = "https://api.example.test") +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/NekoProjectBackendApplicationTests.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/NekoProjectBackendApplicationTests.kt index f5e190e..ff35dc0 100644 --- a/src/test/kotlin/fun/utf8/nekoprojectbackend/NekoProjectBackendApplicationTests.kt +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/NekoProjectBackendApplicationTests.kt @@ -2,9 +2,11 @@ package `fun`.utf8.nekoprojectbackend import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles /** Spring Boot 上下文加载冒烟测试。 */ @SpringBootTest +@ActiveProfiles("test") class NekoProjectBackendApplicationTests { @Test diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectBusinessRulesTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectBusinessRulesTest.kt new file mode 100644 index 0000000..b5069e1 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectBusinessRulesTest.kt @@ -0,0 +1,206 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.ModerationProperties +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.AuditLogRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplication +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationStatus +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.NeedMemberItem +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItem +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemComment +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentStatus +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.TagRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.UserRepository +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import `fun`.utf8.nekoprojectbackend.service.JoinApplicationSaveRequest +import `fun`.utf8.nekoprojectbackend.service.JoinApplicationService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemCommentSaveRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemCommentService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemSaveRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemService +import `fun`.utf8.nekoprojectbackend.service.OperationLogService +import `fun`.utf8.nekoprojectbackend.service.TagService +import java.nio.file.Files +import java.util.Optional +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito + +class ProjectBusinessRulesTest { + + @Test + fun `join applications require a public recruiting project`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val joinApplicationRepository = Mockito.mock(JoinApplicationRepository::class.java) + val service = JoinApplicationService(objectItemRepository, joinApplicationRepository) + Mockito.`when`(objectItemRepository.findById(1)).thenReturn( + Optional.of(project(status = ObjectItemStatus.PREPARING)), + ) + + assertThrows { + service.create(1, joinRequest(skill = "建筑")) + } + + Mockito.verify(joinApplicationRepository, Mockito.never()).save(Mockito.any(JoinApplication::class.java)) + } + + @Test + fun `join applications reject hidden projects as not found`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val joinApplicationRepository = Mockito.mock(JoinApplicationRepository::class.java) + val service = JoinApplicationService(objectItemRepository, joinApplicationRepository) + Mockito.`when`(objectItemRepository.findById(1)).thenReturn( + Optional.of(project(status = ObjectItemStatus.PENDING)), + ) + + assertThrows { + service.create(1, joinRequest(skill = "建筑")) + } + + Mockito.verify(joinApplicationRepository, Mockito.never()).save(Mockito.any(JoinApplication::class.java)) + } + + @Test + fun `join applications require an open role with remaining slots`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val joinApplicationRepository = Mockito.mock(JoinApplicationRepository::class.java) + val service = JoinApplicationService(objectItemRepository, joinApplicationRepository) + Mockito.`when`(objectItemRepository.findById(1)).thenReturn( + Optional.of(project(status = ObjectItemStatus.RECRUITING, skill = "建筑", slots = 1)), + ) + Mockito.`when`( + joinApplicationRepository.findByObjectItemIdAndStatus(1, JoinApplicationStatus.ACCEPTED), + ).thenReturn(listOf(JoinApplication().apply { skill = "建筑" })) + + assertThrows { + service.create(1, joinRequest(skill = "建筑")) + } + + Mockito.verify(joinApplicationRepository, Mockito.never()).save(Mockito.any(JoinApplication::class.java)) + } + + @Test + fun `hidden projects reject public comments`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val commentRepository = Mockito.mock(ObjectItemCommentRepository::class.java) + val service = ObjectItemCommentService( + objectItemRepository, + commentRepository, + ModerationProperties(enabled = false), + ) + Mockito.`when`(objectItemRepository.findById(2)).thenReturn( + Optional.of(project(id = 2, status = ObjectItemStatus.REJECTED)), + ) + + assertThrows { + service.create(2, ObjectItemCommentSaveRequest(nickName = "Alice", content = "hi")) + } + + Mockito.verify(commentRepository, Mockito.never()).save(Mockito.any(ObjectItemComment::class.java)) + } + + @Test + fun `soft deleted projects do not consume the owner project quota`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val service = objectItemService(objectItemRepository, maxPerManager = 1) + Mockito.`when`( + objectItemRepository.countByOwnerIdAndStatusNot(7, ObjectItemStatus.DELETED), + ).thenReturn(0) + Mockito.`when`(objectItemRepository.save(Mockito.any(ObjectItem::class.java))).thenAnswer { + it.getArgument(0).apply { id = 44 } + } + + val saved = service.saveOwned(ObjectItemSaveRequest(title = "Fresh project"), 7, ObjectItemStatus.PENDING) + + assertEquals(44, saved.id) + Mockito.verify(objectItemRepository).countByOwnerIdAndStatusNot(7, ObjectItemStatus.DELETED) + } + + @Test + fun `unsafe cover image urls are rejected before persistence`() { + val objectItemRepository = Mockito.mock(ObjectItemRepository::class.java) + val service = objectItemService(objectItemRepository) + Mockito.`when`( + objectItemRepository.countByOwnerIdAndStatusNot(7, ObjectItemStatus.DELETED), + ).thenReturn(0) + + assertThrows { + service.saveOwned( + ObjectItemSaveRequest(title = "Unsafe image", coverImageUrl = "javascript:alert(1)"), + 7, + ObjectItemStatus.PENDING, + ) + } + + Mockito.verify(objectItemRepository, Mockito.never()).save(Mockito.any(ObjectItem::class.java)) + } + + @Test + fun `operation log escapes all json control characters`() { + val path = Files.createTempDirectory("neko-operation-log-test").resolve("operation.log") + val service = OperationLogService( + auditLogRepository = Mockito.mock(AuditLogRepository::class.java), + logPath = path.toString(), + maxSizeMb = 1, + maxArchives = 3, + enabled = true, + trustForwarded = false, + ) + service.init() + + service.record( + action = "TEST", + targetType = "PROJECT", + description = "line\u0001break\bend", + ) + service.shutdown() + + val line = Files.readString(path) + assertTrue(line.contains("\\u0001")) + assertTrue(line.contains("\\b")) + assertFalse(line.contains('\u0001')) + } + + private fun objectItemService( + objectItemRepository: ObjectItemRepository, + maxPerManager: Long = 10, + ): ObjectItemService { + val userRepository = Mockito.mock(UserRepository::class.java) + val tagRepository = Mockito.mock(TagRepository::class.java) + val tagService = TagService(tagRepository, objectItemRepository) + return ObjectItemService(objectItemRepository, userRepository, tagService, maxPerManager) + } + + private fun joinRequest(skill: String?) = JoinApplicationSaveRequest( + nickName = "Alice", + mcId = "AliceMC", + contact = "alice@example.test", + reason = "I want to join", + skill = skill, + ) + + private fun project( + id: Int = 1, + status: ObjectItemStatus, + skill: String = "建筑", + slots: Long = 2, + ) = ObjectItem().apply { + this.id = id + this.title = "Project $id" + this.status = status + this.needMembers = mutableListOf( + NeedMemberItem().apply { + this.skill = skill + this.number = slots + this.context = "build" + }, + ) + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/RateLimitResponseTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/RateLimitResponseTest.kt new file mode 100644 index 0000000..1bce652 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/RateLimitResponseTest.kt @@ -0,0 +1,61 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.handlder.GlobalExceptionHandler +import `fun`.utf8.nekoprojectbackend.handlder.TooManyRequestsException +import `fun`.utf8.nekoprojectbackend.security.ClientRequestIdentity +import `fun`.utf8.nekoprojectbackend.security.ProjectControlRequestRateLimitFilter +import `fun`.utf8.nekoprojectbackend.service.RateLimiter +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import tools.jackson.databind.json.JsonMapper +import java.time.Duration +import jakarta.servlet.FilterChain +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RateLimitResponseTest { + @Test + fun `business rate limit exception uses the unified 429 response`() { + val response = GlobalExceptionHandler().onBusinessException( + TooManyRequestsException("操作过于频繁,请稍后重试"), + ) + + assertEquals(429, response.statusCode.value()) + assertEquals(429, response.body?.status) + assertEquals("操作过于频繁,请稍后重试", response.body?.message) + } + + @Test + fun `project control filter returns the unified 429 json response`() { + val rateLimiter = mock(RateLimiter::class.java) + doThrow(TooManyRequestsException("操作过于频繁,请 60 秒后重试")) + .`when`(rateLimiter) + .consume("project-control-ip", "203.0.113.10", 300, Duration.ofHours(1)) + + val filter = ProjectControlRequestRateLimitFilter( + rateLimiter, + ClientRequestIdentity(false), + JsonMapper.builder().build(), + ) + val path = "/api/admin/project/object-items/42/verify" + val request = MockHttpServletRequest("POST", path).apply { + servletPath = path + remoteAddr = "203.0.113.10" + } + val response = MockHttpServletResponse() + val chain = mock(FilterChain::class.java) + + filter.doFilter(request, response, chain) + + assertEquals(429, response.status) + assertTrue(response.contentType.orEmpty().startsWith("application/json")) + assertContains(response.contentAsString, "\"status\":429") + assertContains(response.contentAsString, "\"message\":\"操作过于频繁,请 60 秒后重试\"") + verifyNoInteractions(chain) + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/SubmissionTrackingServiceTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/SubmissionTrackingServiceTest.kt new file mode 100644 index 0000000..d0ff57f --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/SubmissionTrackingServiceTest.kt @@ -0,0 +1,18 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.service.SubmissionTrackingService +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SubmissionTrackingServiceTest { + @Test + fun `issued token matches only its own digest`() { + val service = SubmissionTrackingService() + val issued = service.issue() + + assertTrue(service.matches(issued.token, issued.hash)) + assertFalse(service.matches("wrong-token", issued.hash)) + assertFalse(service.matches(issued.token, null)) + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/TokenStoreTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/TokenStoreTest.kt new file mode 100644 index 0000000..667d44f --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/TokenStoreTest.kt @@ -0,0 +1,43 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.service.TokenStore +import org.mockito.ArgumentMatchers.eq +import org.mockito.ArgumentMatchers.startsWith +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.data.redis.core.SetOperations +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ValueOperations +import java.time.Duration + +class TokenStoreTest { + private val redis = mock(StringRedisTemplate::class.java) + @Suppress("UNCHECKED_CAST") + private val valueOperations = mock(ValueOperations::class.java) as ValueOperations + @Suppress("UNCHECKED_CAST") + private val setOperations = mock(SetOperations::class.java) as SetOperations + private val store = TokenStore(redis) + + @Test + fun `user token indexes receive a ttl when tokens are saved`() { + `when`(redis.opsForValue()).thenReturn(valueOperations) + `when`(redis.opsForSet()).thenReturn(setOperations) + + val ttl = Duration.ofSeconds(90) + store.saveAccess("access-jti", 7, "test-agent", "127.0.0.1", ttl) + store.saveRefresh("refresh-jti", 7, ttl) + + verify(valueOperations).set( + eq("auth:token:access-jti"), + startsWith("7|test-agent|127.0.0.1|"), + eq(ttl), + ) + verify(valueOperations).set(eq("auth:refresh:refresh-jti"), eq("7"), eq(ttl)) + verify(setOperations).add("auth:user:7:sessions", "access-jti") + verify(setOperations).add("auth:user:7:refreshes", "refresh-jti") + verify(redis).expire("auth:user:7:sessions", ttl) + verify(redis).expire("auth:user:7:refreshes", ttl) + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/UserServiceValidationTests.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/UserServiceValidationTests.kt new file mode 100644 index 0000000..d41bd42 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/UserServiceValidationTests.kt @@ -0,0 +1,46 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.service.UserService +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.annotation.Transactional +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class UserServiceValidationTests @Autowired constructor( + private val userService: UserService, +) { + @Test + fun `user creation validates account fields in the service layer`() { + assertFailsWith { + userService.createUser("", "Strong!234", "member@example.test") + } + assertFailsWith { + userService.createUser("member", "12345", "member@example.test") + } + assertFailsWith { + userService.createUser("member", "Strong!234", "not-an-email") + } + assertFailsWith { + userService.createUser("member", "猫".repeat(25), "member@example.test") + } + } + + @Test + fun `email is normalized and looked up without case sensitivity`() { + val created = userService.createUser( + username = "case-email-user", + password = "Strong!234", + email = " Case.User@Example.Test ", + ) + + assertEquals("case.user@example.test", created.email) + assertEquals(created.id, userService.findByEmail("CASE.USER@example.test")?.id) + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/VerificationCodeServiceTest.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/VerificationCodeServiceTest.kt new file mode 100644 index 0000000..5b67ded --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/VerificationCodeServiceTest.kt @@ -0,0 +1,52 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.config.MailProperties +import `fun`.utf8.nekoprojectbackend.handlder.VerificationCodeInvalidException +import `fun`.utf8.nekoprojectbackend.service.VerificationCodeService +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.data.redis.core.ValueOperations +import org.springframework.data.redis.core.StringRedisTemplate +import kotlin.test.assertFailsWith + +class VerificationCodeServiceTest { + private val redis = mock(StringRedisTemplate::class.java) + @Suppress("UNCHECKED_CAST") + private val valueOperations = mock(ValueOperations::class.java) as ValueOperations + private val service = VerificationCodeService(redis, MailProperties()) + private val context = VerificationCodeService.CodeContext( + scene = VerificationCodeService.Scene.REGISTER, + email = "member@example.test", + userId = null, + userAgent = "test-agent", + ) + + @Test + fun `matching verification code is consumed and clears error counter`() { + `when`(redis.opsForValue()).thenReturn(valueOperations) + `when`(valueOperations.get(anyString())).thenReturn("123456") + + service.verifyAndConsume(context, " 123456 ") + + verify(valueOperations).get(anyString()) + verify(redis, times(2)).delete(anyString()) + } + + @Test + fun `missing or mismatched verification code is rejected`() { + `when`(redis.opsForValue()).thenReturn(valueOperations) + `when`(valueOperations.get(anyString())).thenReturn("123456") + `when`(valueOperations.increment(anyString())).thenReturn(1L) + + assertFailsWith { + service.verifyAndConsume(context, "000000") + } + + verify(redis, never()).delete(anyString()) + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..5418c8a --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,26 @@ +spring: + datasource: + url: jdbc:h2:mem:neko_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: "" + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + data: + redis: + host: localhost + port: 6379 + +neko: + security: + rate-limit: + enabled: false + admin: + seed-enabled: false + seed: + enabled: false + +redis: + clear-on-startup: false