From 586abb2a840935f6e7438ab809e34e4a72e47ee6 Mon Sep 17 00:00:00 2001 From: SH-ZMD Date: Tue, 11 Aug 2026 14:02:22 +0800 Subject: [PATCH 1/2] fix: harden project hub backend --- .env.example | 20 +- .gitignore | 1 + README.md | 131 +-- build.gradle.kts | 2 + database/migrations/20260719_project_hub.sql | 36 + gradle/wrapper/gradle-wrapper.properties | 4 +- .../config/AdminUserSeeder.kt | 30 +- .../JwtProductionConfigurationValidator.kt | 93 +++ .../config/MailProperties.kt | 2 + .../config/RedisClearUpConfig.kt | 19 +- .../config/RedisTemplateConfig.kt | 29 - .../config/SecurityConfig.kt | 31 +- .../controller/AdminMindController.kt | 12 +- .../controller/AdminObjectController.kt | 119 ++- .../controller/AdminObjectItemController.kt | 33 +- .../AdminObjectItemMaintenanceController.kt | 67 +- .../AdminObjectItemModerationController.kt | 9 +- .../controller/AdminUserController.kt | 13 + .../controller/AuthController.kt | 38 +- .../controller/FileController.kt | 23 +- .../controller/MindController.kt | 67 +- .../controller/ObjectItemController.kt | 207 +++-- .../datasource/jdbc/JoinApplication.kt | 3 + .../jdbc/JoinApplicationRepository.kt | 22 + .../datasource/jdbc/Mind.kt | 3 + .../datasource/jdbc/MindRepository.kt | 3 +- .../datasource/jdbc/ObjectItem.kt | 4 + .../jdbc/ObjectItemCommentRepository.kt | 18 + .../datasource/jdbc/ObjectItemRepository.kt | 7 +- .../jdbc/ObjectItemUpdateRepository.kt | 18 + .../datasource/jdbc/UserRepository.kt | 6 +- .../handlder/BusinessException.kt | 8 + .../handlder/GlobalExceptionHandler.kt | 62 +- .../security/ClientRequestIdentity.kt | 32 + .../security/JsonAccessDeniedHandler.kt | 2 +- .../security/JwtAuthenticationFilter.kt | 22 +- .../nekoprojectbackend/security/LoginUser.kt | 2 +- .../ProjectControlRequestRateLimitFilter.kt | 75 ++ .../security/RefreshRequestOriginFilter.kt | 70 ++ .../service/AccessService.kt | 40 +- .../service/AdminBatchStatusRequest.kt | 3 + .../nekoprojectbackend/service/AuthService.kt | 253 ++++-- .../nekoprojectbackend/service/FileService.kt | 182 +++-- .../service/ImageUrlPolicy.kt | 39 + .../JoinApplicationManagementService.kt | 126 ++- .../service/JoinApplicationService.kt | 86 +- .../nekoprojectbackend/service/MindService.kt | 247 ++++-- .../ObjectItemCommentManagementService.kt | 110 ++- .../service/ObjectItemCommentService.kt | 141 +++- .../service/ObjectItemManagementService.kt | 66 +- .../service/ObjectItemService.kt | 428 +++++++--- .../ObjectItemUpdateManagementService.kt | 161 +++- .../service/ObjectItemUpdateService.kt | 123 ++- .../service/OperationLogService.kt | 62 +- .../service/PasswordPolicy.kt | 55 ++ .../service/ProjectControlPasswordPolicy.kt | 36 + .../nekoprojectbackend/service/RateLimiter.kt | 100 +++ .../service/StorageService.kt | 10 +- .../service/SubmissionTrackingService.kt | 48 ++ .../nekoprojectbackend/service/TokenStore.kt | 17 +- .../nekoprojectbackend/service/UserService.kt | 67 +- .../service/VerificationCodeService.kt | 57 +- src/main/resources/application-prod.yaml | 36 + src/main/resources/application.yaml | 21 +- .../nekoprojectbackend/AdminUserSeederTest.kt | 50 ++ .../AuthServiceSecurityTest.kt | 198 +++++ .../FileStorageContractTests.kt | 145 ++++ .../JwtAuthenticationFilterTest.kt | 75 ++ ...JwtProductionConfigurationValidatorTest.kt | 114 +++ .../NekoProjectBackendApplicationTests.kt | 2 + .../ProjectHubSecurityContractTests.kt | 755 ++++++++++++++++++ .../ProjectHubWorkflowTests.kt | 358 +++++++++ .../RateLimitResponseTest.kt | 61 ++ .../SubmissionTrackingServiceTest.kt | 18 + .../utf8/nekoprojectbackend/TokenStoreTest.kt | 39 + .../UserServiceValidationTests.kt | 46 ++ .../VerificationCodeServiceTest.kt | 65 ++ src/test/resources/application-test.yaml | 26 + 78 files changed, 5112 insertions(+), 697 deletions(-) create mode 100644 database/migrations/20260719_project_hub.sql create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/config/JwtProductionConfigurationValidator.kt delete mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/config/RedisTemplateConfig.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/security/ClientRequestIdentity.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/security/ProjectControlRequestRateLimitFilter.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/security/RefreshRequestOriginFilter.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/service/ImageUrlPolicy.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/service/PasswordPolicy.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/service/ProjectControlPasswordPolicy.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt create mode 100644 src/main/kotlin/fun/utf8/nekoprojectbackend/service/SubmissionTrackingService.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/AdminUserSeederTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/AuthServiceSecurityTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/FileStorageContractTests.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/JwtAuthenticationFilterTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/JwtProductionConfigurationValidatorTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubSecurityContractTests.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubWorkflowTests.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/RateLimitResponseTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/SubmissionTrackingServiceTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/TokenStoreTest.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/UserServiceValidationTests.kt create mode 100644 src/test/kotlin/fun/utf8/nekoprojectbackend/VerificationCodeServiceTest.kt create mode 100644 src/test/resources/application-test.yaml diff --git a/.env.example b/.env.example index de7eb38..9748e6f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ # ───────────────────────── 服务器 ───────────────────────── SERVER_PORT=8080 +# 允许访问 API 的前端来源,逗号分隔;生产必须填写实际 HTTPS 域名,禁止使用 * +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +# 仅当后端只接受可信反代连接时设 true,使限流读取 X-Forwarded-For +TRUSTED_PROXY=false +# 登录、公开投稿和项目控制密码接口的 Redis 限流开关,生产保持 true +RATE_LIMIT_ENABLED=true + # ───────────────────────── Web 服务器(Jetty) ───────────────────────── # 线程池:min/max 决定并发处理上限;acceptors/selectors 一般保持 -1(自动按核数) JETTY_THREADS_ACCEPTORS=-1 @@ -36,8 +43,8 @@ REDIS_PORT=6379 REDIS_PASSWORD=change-me REDIS_DATABASE=4 REDIS_TIMEOUT=10000ms -# 应用启动时是否 FLUSHDB 清空 Redis(默认开启,本地/测试重置数据用;生产务必设 false) -REDIS_CLEAR_ON_STARTUP=true +# 应用启动时是否 FLUSHDB 清空 Redis;默认关闭,仅在本地确需重置时临时设 true +REDIS_CLEAR_ON_STARTUP=false # ───────────────────────── JWT ───────────────────────── JWT_SECRET=replace-with-at-least-32-bytes-random-string @@ -76,17 +83,20 @@ MAIL_VERIFICATION_CODE_LENGTH=6 MAIL_VERIFICATION_SEND_INTERVAL=60 # 单邮箱每日发送上限 MAIL_VERIFICATION_DAILY_LIMIT=10 +# 单个验证码最多允许输错次数,达到后立即作废 +MAIL_VERIFICATION_MAX_ATTEMPTS=5 MAIL_VERIFICATION_SUBJECT_PREFIX=NekoBackend # ───────────────────────── 文件上传/下载 ───────────────────────── FILE_STORAGE_PATH=./storage +# 生产环境必须填写后端公开 HTTPS 域名,例如 https://api.project.doh.ink FILE_BASE_URL=http://localhost:8080 # 图片是否允许匿名(无 JWT)读取 FILE_PUBLIC_READ_IMAGE=true # 私有下载签名 token 有效期(秒) FILE_DOWNLOAD_TOKEN_TTL=300 # 各类型允许的扩展名(逗号分隔)与单文件大小上限(MB) -FILE_IMAGE_ALLOWED_EXT=jpg,jpeg,png,gif,webp,bmp,svg +FILE_IMAGE_ALLOWED_EXT=jpg,jpeg,png,gif,webp,bmp FILE_IMAGE_MAX_MB=10 FILE_DOC_ALLOWED_EXT=pdf,doc,docx,xls,xlsx,ppt,pptx,txt,md,csv,zip FILE_DOC_MAX_MB=20 @@ -95,8 +105,10 @@ FILE_MAX_SIZE=20MB FILE_MAX_REQUEST_SIZE=20MB # ───────────────────────── 初始管理员(AdminUserSeeder) ───────────────────────── +ADMIN_SEED_ENABLED=true ADMIN_USERNAME=admin -ADMIN_PASSWORD=password +# 仅本地开发示例。生产环境默认关闭 ADMIN_SEED_ENABLED;若显式开启,务必替换为独立强密码。 +ADMIN_PASSWORD=NekoLocalRoot!2026 ADMIN_EMAIL=admin@nekobox.local # ───────────────────────── 邀请码 ───────────────────────── 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/README.md b/README.md index 04d6462..104144c 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,98 @@ -# 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 -``` +- JDK 25 +- PostgreSQL +- Redis -默认访问: +开发配置从项目根目录的 `.env` 读取。请先复制示例文件并替换数据库、Redis、JWT 和邮件配置: -```text -http://localhost:3000/projects +```powershell +Copy-Item .env.example .env ``` -## 常用页面 +## 本地运行 -- `/projects`:网站首页 -- `/projects/groud`:全部公开项目 -- `/submit`:投稿项目 / 提交想法 -- `/ideas`:想法墙 -- `/projects/:id`:项目详情、评论、加入申请 -- `/login`:nekoFrontend 登录页 -- `/register`:nekoFrontend 注册页 -- `/user-center`:登录后的用户中心 -- `/pve-users`:PVE 用户管理页 -- `/virtual-machines`:虚拟机管理页 +本地默认使用 `ddl-auto=create`,只适合没有需要保留的数据的开发数据库: -公开项目站页面默认不强制登录,方便外部同学直接访问。 +```powershell +.\gradlew.bat bootRun +``` -## 环境变量 +默认 API 地址:`http://localhost:8080`。 -复制 `.env.example` 为 `.env`: +常用验证命令: -```env -NUXT_PUBLIC_API_BASE=/api -NUXT_PUBLIC_AUTH_CHECK_ENABLED=false -LOCAL_DATA_DIR=./data +```powershell +.\gradlew.bat clean test bootJar --no-daemon ``` -说明: +测试使用 H2,不需要本地 PostgreSQL 或 Redis;应用本身运行时仍需要这两个服务。 -- `NUXT_PUBLIC_API_BASE`:前端 API 根路径,本项目默认使用 Nuxt 自带的 `/api`。 -- `NUXT_PUBLIC_AUTH_CHECK_ENABLED`:是否启用登录拦截。设为 `false` 时公开项目站可直接访问。 -- `LOCAL_DATA_DIR`:本地 JSON 数据目录,默认是 `./data`。 +## 生产部署 -## 数据与安全 +生产环境必须设置 `SPRING_PROFILES_ACTIVE=prod`。生产 profile 会: -本地数据保存在 `data/`,包括项目、想法、申请、评论、动态和操作记录。这个目录已写入 `.gitignore`,不会上传到 GitHub。 +- 将 Hibernate DDL 策略固定为 `validate`; +- 关闭演示数据 Seeder 和默认管理员自动创建; +- 隐藏健康检查详情; +- 强制 refresh Cookie 使用 HTTPS 和 HttpOnly; +- 要求使用明确的 CORS 来源,不允许 `*`。 +- 启动时拒绝缺失、过短或示例占位的 `JWT_SECRET`,并校验令牌有效期。 -不要提交这些内容: +至少配置以下变量: -- `.env` -- `.env.local` -- `data/` -- `node_modules/` -- `.nuxt/` -- `.output/` -- `.next/` -- `_local-only/` +```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 +``` -## 部署到 Vercel +现有数据库在切换生产 profile 前,先执行 [database/migrations/20260719_project_hub.sql](database/migrations/20260719_project_hub.sql)。脚本是幂等的,补充项目封面、项目进度、申请拒绝原因和匿名追踪码字段,并创建相应索引。项目当前没有自动执行迁移工具,因此需要由部署方手动执行 SQL,例如: -1. 把代码推送到 GitHub。 -2. 在 Vercel 导入仓库。 -3. Framework Preset 选择 Nuxt,通常 Vercel 会自动识别。 -4. 按需添加环境变量。 -5. 部署后访问 `/projects`。 +```powershell +psql -h -U -d -f database/migrations/20260719_project_hub.sql +``` -如果你希望公开项目站不需要登录,生产环境也要设置: +文件存储目录和审计日志目录必须由运行服务的用户创建并授予写权限。生产环境不要把 `.env`、`storage/`、`logs/` 或真实数据库数据放入 Git。 -```env -NUXT_PUBLIC_AUTH_CHECK_ENABLED=false -``` +## 主要接口 + +- `/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..1231186 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,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 +52,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/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/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt index a602cfc..a22bb04 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/AdminUserSeeder.kt @@ -8,6 +8,8 @@ import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.core.env.Environment +import org.springframework.core.env.Profiles import org.springframework.stereotype.Component /** @@ -21,8 +23,10 @@ import org.springframework.stereotype.Component @Component class AdminUserSeeder( private val userService: UserService, + private val environment: 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.password:NekoLocalRoot!2026}") private val password: String, @Value("\${neko.admin.email:admin@nekobox.local}") private val email: String, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -30,15 +34,27 @@ class AdminUserSeeder( @Order(Ordered.HIGHEST_PRECEDENCE) @EventListener(ApplicationReadyEvent::class) fun seedAdmin() { + if (!enabled) { + log.info("⚠ 跳过初始管理员创建(neko.admin.seed-enabled=false)") + return + } + if (environment.acceptsProfiles(Profiles.of("prod")) && + (password == DEFAULT_PASSWORD || password.length < MIN_PRODUCTION_PASSWORD_LENGTH) + ) { + throw IllegalStateException( + "生产环境启用初始管理员创建时,ADMIN_PASSWORD 必须至少 $MIN_PRODUCTION_PASSWORD_LENGTH 位且不能使用默认值", + ) + } if (userService.findByUsername(username) != null) { log.info("⚠ 管理员用户 [$username] 已存在,跳过初始化") return } - try { - userService.createUser(username = username, password = password, email = email, role = Role.SUPER_ADMIN) - log.info("✓ 默认管理员用户 [$username] 初始化完成") - } catch (e: Exception) { - log.error("✗ 默认管理员用户 [$username] 初始化失败: ${e.message}", e) - } + userService.createUser(username = username, password = password, email = email, role = Role.SUPER_ADMIN) + log.info("✓ 默认管理员用户 [$username] 初始化完成") + } + + private companion object { + const val DEFAULT_PASSWORD = "NekoLocalRoot!2026" + const val MIN_PRODUCTION_PASSWORD_LENGTH = 12 } } 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/config/SecurityConfig.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/SecurityConfig.kt index d39e277..6caaaec 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/config/SecurityConfig.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/config/SecurityConfig.kt @@ -3,6 +3,9 @@ package `fun`.utf8.nekoprojectbackend.config import `fun`.utf8.nekoprojectbackend.security.JsonAccessDeniedHandler import `fun`.utf8.nekoprojectbackend.security.JsonAuthEntryPoint import `fun`.utf8.nekoprojectbackend.security.JwtAuthenticationFilter +import `fun`.utf8.nekoprojectbackend.security.ProjectControlRequestRateLimitFilter +import `fun`.utf8.nekoprojectbackend.security.RefreshRequestOriginFilter +import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -12,6 +15,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.authentication.logout.LogoutFilter +import org.springframework.web.filter.CorsFilter import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource @@ -39,6 +44,9 @@ class SecurityConfig( private val jwtFilter: JwtAuthenticationFilter, private val authEntryPoint: JsonAuthEntryPoint, private val accessDeniedHandler: JsonAccessDeniedHandler, + private val refreshRequestOriginFilter: RefreshRequestOriginFilter, + private val projectControlRequestRateLimitFilter: ProjectControlRequestRateLimitFilter, + @Value("\${neko.cors.allowed-origins}") private val corsAllowedOrigins: String, ) { @Bean @@ -84,19 +92,32 @@ class SecurityConfig( it.authenticationEntryPoint(authEntryPoint) it.accessDeniedHandler(accessDeniedHandler) } + .addFilterAfter(refreshRequestOriginFilter, CorsFilter::class.java) + .addFilterAfter(projectControlRequestRateLimitFilter, LogoutFilter::class.java) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) return http.build() } @Bean fun corsConfigurationSource(): CorsConfigurationSource { + val origins = corsAllowedOrigins.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + require(origins.isNotEmpty()) { "neko.cors.allowed-origins 不能为空" } + require(origins.none { '*' in it }) { "CORS 允许来源不能包含通配符" } + val config = CorsConfiguration().apply { - // origin 走回显:allowedOriginPatterns 支持带凭证,浏览器会收到具体 origin(而非字面 *) - allowedOriginPatterns = listOf("*") - // 方法 / 请求头必须显式列举:allowCredentials=true 时,浏览器不接受通配 *, - // 否则 preflight 会以 "field content-type is not allowed by Access-Control-Allow-Headers" 拦截 + allowedOrigins = origins allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") - allowedHeaders = listOf("Authorization", "Content-Type", "Accept", "X-Requested-With") + allowedHeaders = listOf( + "Authorization", + "Content-Type", + "Accept", + "X-Requested-With", + "X-Project-Control-Password", + "X-Submission-Tracking-Token", + ) allowCredentials = true maxAge = 3600 } 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/AdminObjectController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectController.kt index 33db4e2..1bbc4d6 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectController.kt @@ -3,11 +3,18 @@ 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.service.FileService 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.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile + +const val PROJECT_CONTROL_PASSWORD_HEADER = "X-Project-Control-Password" /** 项目条目管理接口(/api/admin/project/object-items):凭控制密码管理项目、评论、动态、申请。 */ @RestController @@ -17,12 +24,13 @@ class AdminObjectController( private val joinApplicationManagementService: JoinApplicationManagementService, private val objectItemUpdateManagementService: ObjectItemUpdateManagementService, private val objectItemCommentManagementService: ObjectItemCommentManagementService, + private val fileService: FileService, private val builder: ResponseBuilder, ) { @PostMapping("/{id}/verify") fun verify( @PathVariable id: Int, - @RequestBody request: ObjectItemManageVerifyRequest, + @Valid @RequestBody request: ObjectItemManageVerifyRequest, ): ResponseEntity { val item = objectItemManagementService.verify(id, request) return builder.ok().data(item).build() @@ -31,16 +39,28 @@ class AdminObjectController( @PutMapping("/{id}") fun update( @PathVariable id: Int, - @RequestBody request: ObjectItemManageUpdateRequest, + @Valid @RequestBody request: ObjectItemManageUpdateRequest, ): ResponseEntity { val item = objectItemManagementService.update(id, request) return builder.ok().data(item).build() } + /** 项目方图片上传:先用控制密码验证项目,再把文件挂到项目上。 */ + @PostMapping("/{id}/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun uploadImage( + @PathVariable id: Int, + @RequestPart("file") file: MultipartFile, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, + ): ResponseEntity { + objectItemManagementService.verify(id, ObjectItemManageVerifyRequest(controlPassword)) + val result = fileService.upload(file, FileCategory.IMAGE, null, id) + return builder.ok().data(result).build() + } + @PatchMapping("/{id}/password") fun changePassword( @PathVariable id: Int, - @RequestBody request: ObjectItemPasswordChangeRequest, + @Valid @RequestBody request: ObjectItemPasswordChangeRequest, ): ResponseEntity { val item = objectItemManagementService.changePassword(id, request) return builder.ok().data(item).build() @@ -49,7 +69,7 @@ class AdminObjectController( @DeleteMapping("/{id}") fun delete( @PathVariable id: Int, - @RequestBody request: ObjectItemManageVerifyRequest, + @Valid @RequestBody request: ObjectItemManageVerifyRequest, ): ResponseEntity { val item = objectItemManagementService.delete(id, request) return builder.ok().data(item).build() @@ -59,13 +79,25 @@ class AdminObjectController( fun listJoinApplications( @PathVariable id: Int, @RequestParam(required = false) status: JoinApplicationStatus?, - @RequestParam controlPassword: String, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, ): ResponseEntity { - val applications = joinApplicationManagementService.list( - id, - status, - ObjectItemManageVerifyRequest(controlPassword), - ) + val applications: Any = if (page != null || size != null) { + objectItemManagementService.verify(id, ObjectItemManageVerifyRequest(controlPassword)) + joinApplicationManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + joinApplicationManagementService.list( + id, + status, + ObjectItemManageVerifyRequest(controlPassword), + ) + } return builder.ok().data(applications).build() } @@ -73,7 +105,7 @@ class AdminObjectController( fun acceptJoinApplication( @PathVariable id: Int, @PathVariable applicationId: Int, - @RequestBody request: ObjectItemManageVerifyRequest, + @Valid @RequestBody request: ObjectItemManageVerifyRequest, ): ResponseEntity { val application = joinApplicationManagementService.accept(id, applicationId, request) return builder.ok().data(application).build() @@ -83,7 +115,7 @@ class AdminObjectController( fun rejectJoinApplication( @PathVariable id: Int, @PathVariable applicationId: Int, - @RequestBody request: JoinApplicationRejectRequest, + @Valid @RequestBody request: JoinApplicationRejectRequest, ): ResponseEntity { val application = joinApplicationManagementService.reject(id, applicationId, request) return builder.ok().data(application).build() @@ -93,20 +125,32 @@ class AdminObjectController( fun listUpdates( @PathVariable id: Int, @RequestParam(required = false) status: ObjectItemUpdateStatus?, - @RequestParam controlPassword: String, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, ): ResponseEntity { - val updates = objectItemUpdateManagementService.list( - id, - status, - ObjectItemManageVerifyRequest(controlPassword), - ) + val updates: Any = if (page != null || size != null) { + objectItemManagementService.verify(id, ObjectItemManageVerifyRequest(controlPassword)) + objectItemUpdateManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + objectItemUpdateManagementService.list( + id, + status, + ObjectItemManageVerifyRequest(controlPassword), + ) + } return builder.ok().data(updates).build() } @PostMapping("/{id}/updates") fun createUpdate( @PathVariable id: Int, - @RequestBody request: ObjectItemUpdateManageCreateRequest, + @Valid @RequestBody request: ObjectItemUpdateManageCreateRequest, ): ResponseEntity { val update = objectItemUpdateManagementService.create(id, request) return builder.ok().data(update).build() @@ -116,7 +160,7 @@ class AdminObjectController( fun updateUpdate( @PathVariable id: Int, @PathVariable updateId: Int, - @RequestBody request: ObjectItemUpdateManageUpdateRequest, + @Valid @RequestBody request: ObjectItemUpdateManageUpdateRequest, ): ResponseEntity { val update = objectItemUpdateManagementService.update(id, updateId, request) return builder.ok().data(update).build() @@ -126,7 +170,7 @@ class AdminObjectController( fun deleteUpdate( @PathVariable id: Int, @PathVariable updateId: Int, - @RequestParam controlPassword: String, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, ): ResponseEntity { objectItemUpdateManagementService.delete( id, @@ -140,13 +184,25 @@ class AdminObjectController( fun listComments( @PathVariable id: Int, @RequestParam(required = false) status: ObjectItemCommentStatus?, - @RequestParam controlPassword: String, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, ): ResponseEntity { - val comments = objectItemCommentManagementService.list( - id, - status, - ObjectItemManageVerifyRequest(controlPassword), - ) + val comments: Any = if (page != null || size != null) { + objectItemManagementService.verify(id, ObjectItemManageVerifyRequest(controlPassword)) + objectItemCommentManagementService.listByAdminPage( + id, + status, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, + ) + } else { + objectItemCommentManagementService.list( + id, + status, + ObjectItemManageVerifyRequest(controlPassword), + ) + } return builder.ok().data(comments).build() } @@ -154,7 +210,7 @@ class AdminObjectController( fun reviewComment( @PathVariable id: Int, @PathVariable commentId: Int, - @RequestBody request: ObjectItemCommentManageStatusRequest, + @Valid @RequestBody request: ObjectItemCommentManageStatusRequest, ): ResponseEntity { val comment = objectItemCommentManagementService.review(id, commentId, request) return builder.ok().data(comment).build() @@ -164,7 +220,7 @@ class AdminObjectController( fun deleteComment( @PathVariable id: Int, @PathVariable commentId: Int, - @RequestParam controlPassword: String, + @RequestHeader(PROJECT_CONTROL_PASSWORD_HEADER) controlPassword: String, ): ResponseEntity { objectItemCommentManagementService.delete( id, @@ -173,4 +229,9 @@ class AdminObjectController( ) return builder.ok().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/AdminObjectItemController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemController.kt index bf421b5..27bc9f3 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminObjectItemController.kt @@ -6,6 +6,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.* @@ -23,6 +24,15 @@ class AdminObjectItemController( private val operationLogService: OperationLogService, private val builder: ResponseBuilder, ) { + @GetMapping("/{id}") + fun getById( + @AuthenticationPrincipal admin: LoginUser, + @PathVariable id: Int, + ): ResponseEntity { + accessService.ensureCanManage(admin, id) + return builder.ok().data(objectItemService.findById(id)).build() + } + @GetMapping fun list( @AuthenticationPrincipal admin: LoginUser, @@ -70,13 +80,14 @@ class AdminObjectItemController( @PostMapping fun create( @AuthenticationPrincipal admin: LoginUser, - @RequestBody request: ObjectItemSaveRequest, + @Valid @RequestBody request: ObjectItemSaveRequest, ): ResponseEntity { - val status = if (admin.role == Role.SUPER_ADMIN) { + val requestedStatus = if (admin.role == Role.SUPER_ADMIN) { request.status ?: ObjectItemStatus.RECRUITING } else { ObjectItemStatus.PENDING } + val status = if (requestedStatus == ObjectItemStatus.APPROVED) ObjectItemStatus.PREPARING else requestedStatus val item = objectItemService.saveOwned(request, admin.id, status) operationLogService.record( operator = admin, @@ -91,11 +102,19 @@ class AdminObjectItemController( @PutMapping("/batch/status") fun batchStatus( @AuthenticationPrincipal admin: LoginUser, - @RequestBody request: AdminBatchStatusRequest, + @Valid @RequestBody request: AdminBatchStatusRequest, ): ResponseEntity { - request.ids.forEach { accessService.ensureCanManage(admin, it) } + request.ids.forEach { + val current = accessService.ensureCanManage(admin, it) + accessService.ensureCanSetProjectStatus(admin, current.status, request.status) + } + val effectiveStatus = if (request.status == ObjectItemStatus.APPROVED) { + ObjectItemStatus.PREPARING + } else { + request.status + } val updateRequests = request.ids.map { - ObjectItemUpdateRequest(id = it, status = request.status) + ObjectItemUpdateRequest(id = it, status = effectiveStatus) } objectItemService.updateBatch(updateRequests) operationLogService.record( @@ -103,7 +122,7 @@ class AdminObjectItemController( action = "PROJECT_STATUS_BATCH", targetType = "PROJECT", targetId = request.ids, - description = "批量改项目状态为 ${request.status}:$request.ids", + description = "批量改项目状态为 $effectiveStatus:$request.ids", ) data class BatchResult( @@ -115,7 +134,7 @@ class AdminObjectItemController( val rs = BatchResult( updated = true, ids = request.ids, - status = request.status, + status = effectiveStatus, ) return builder.ok().data(rs).build() } 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 8fb8187..6aaba3c 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AdminUserController.kt @@ -3,11 +3,16 @@ package `fun`.utf8.nekoprojectbackend.controller import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Status 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.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.web.bind.annotation.* @@ -27,11 +32,18 @@ class AdminUserController( private val accessService: AccessService, private val operationLogService: OperationLogService, 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.PROJECT_MANAGER, ) @@ -43,6 +55,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/AuthController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AuthController.kt index 52d2baa..076f5b9 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AuthController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/AuthController.kt @@ -2,6 +2,7 @@ package `fun`.utf8.nekoprojectbackend.controller import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Role import `fun`.utf8.nekoprojectbackend.handlder.TokenInvalidException +import `fun`.utf8.nekoprojectbackend.security.ClientRequestIdentity import `fun`.utf8.nekoprojectbackend.security.LoginUser import `fun`.utf8.nekoprojectbackend.security.RefreshCookie import `fun`.utf8.nekoprojectbackend.service.AuthService @@ -9,6 +10,7 @@ import `fun`.utf8.nekoprojectbackend.service.OperationLogService import `fun`.utf8.nekoprojectbackend.shared.Response import `fun`.utf8.nekoprojectbackend.shared.ResponseBuilder import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -21,12 +23,16 @@ class AuthController( private val refreshCookie: RefreshCookie, private val builder: ResponseBuilder, private val operationLogService: OperationLogService, + private val clientRequestIdentity: ClientRequestIdentity, ) { @PostMapping("/login") - fun login(@RequestBody req: AuthService.LoginRequest): ResponseEntity { + fun login( + @Valid @RequestBody req: AuthService.LoginRequest, + request: HttpServletRequest, + ): ResponseEntity { val result = try { - authService.login(req) + authService.login(req, clientRequestIdentity.clientIp(request)) } catch (e: Exception) { operationLogService.record( action = "LOGIN", @@ -59,12 +65,12 @@ class AuthController( /** 邮箱验证登录:邮箱 + 密码 + 邮箱验证码。 */ @PostMapping("/login/email") fun loginByEmail( - @RequestBody req: AuthService.EmailLoginRequest, + @Valid @RequestBody req: AuthService.EmailLoginRequest, request: HttpServletRequest, ): ResponseEntity { val userAgent = request.getHeader("User-Agent") ?: "" val result = try { - authService.loginByEmail(req, userAgent) + authService.loginByEmail(req, userAgent, clientRequestIdentity.clientIp(request)) } catch (e: Exception) { operationLogService.record( action = "EMAIL_LOGIN", @@ -119,12 +125,12 @@ class AuthController( @PostMapping("/register/manager") fun registerManager( - @RequestBody req: AuthService.RegisterManagerRequest, + @Valid @RequestBody req: AuthService.RegisterManagerRequest, request: HttpServletRequest, ): ResponseEntity { val userAgent = request.getHeader("User-Agent") ?: "" val result = try { - authService.registerManager(req, userAgent) + authService.registerManager(req, userAgent, clientRequestIdentity.clientIp(request)) } catch (e: Exception) { operationLogService.record( action = "PM_REGISTER", @@ -154,22 +160,28 @@ class AuthController( return builder.ok().data(rs).build() } - /** 发送邮箱验证码:公开接口,按 scene+email(+userId) 绑定 UserAgent 存 Redis。 */ + /** 发送邮箱验证码:公开接口;匿名场景按邮箱绑定,改密场景由服务端绑定当前用户。 */ @PostMapping("/verification-code") fun sendVerificationCode( - @RequestBody req: AuthService.SendCodeRequest, + @AuthenticationPrincipal user: LoginUser?, + @Valid @RequestBody req: AuthService.SendCodeRequest, request: HttpServletRequest, ): ResponseEntity { val userAgent = request.getHeader("User-Agent") ?: "" - authService.sendVerificationCode(req, userAgent) - return builder.ok().message("验证码已发送,请查收邮件").build() + authService.sendVerificationCode( + req, + userAgent, + user?.id, + clientRequestIdentity.clientIp(request), + ) + return builder.ok().message("如果邮箱可用于此操作,验证码将发送至该邮箱").build() } /** 修改密码(已登录):需旧密码 + 邮箱验证码确认。 */ @PostMapping("/change-password") fun changePassword( @AuthenticationPrincipal user: LoginUser, - @RequestBody req: AuthService.ChangePasswordRequest, + @Valid @RequestBody req: AuthService.ChangePasswordRequest, request: HttpServletRequest, ): ResponseEntity { val userAgent = request.getHeader("User-Agent") ?: "" @@ -184,11 +196,11 @@ class AuthController( /** 找回密码(匿名):凭邮箱验证码重置密码。 */ @PostMapping("/reset-password") fun resetPassword( - @RequestBody req: AuthService.ResetPasswordRequest, + @Valid @RequestBody req: AuthService.ResetPasswordRequest, request: HttpServletRequest, ): ResponseEntity { val userAgent = request.getHeader("User-Agent") ?: "" - authService.resetPassword(req, userAgent) + authService.resetPassword(req, userAgent, clientRequestIdentity.clientIp(request)) operationLogService.record(action = "RESET_PASSWORD", operatorName = req.email, description = "找回密码") return builder.ok().message("密码已重置,请用新密码登录").build() } 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/controller/ObjectItemController.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/ObjectItemController.kt index a548e4f..befa7d3 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/ObjectItemController.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/controller/ObjectItemController.kt @@ -4,14 +4,19 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.JoinApplicationStatus import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemCommentStatus import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemUpdateStatus +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +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/object-items):增删改查、评论、动态、加入申请。 */ @RestController @@ -23,6 +28,8 @@ class ObjectItemController( private val joinApplicationService: JoinApplicationService, private val accessService: AccessService, private val operationLogService: OperationLogService, + private val rateLimiter: RateLimiter, + private val clientRequestIdentity: ClientRequestIdentity, private val builder: ResponseBuilder, ) { @@ -32,9 +39,23 @@ class ObjectItemController( return builder.ok().data(count).build() } + @GetMapping("/count/public") + fun countPublic(): ResponseEntity { + val count = objectItemService.countPublic() + return builder.ok().data(count).build() + } + @PostMapping - fun save(@RequestBody request: ObjectItemSaveRequest): ResponseEntity { - val item = objectItemService.save(request) + fun save( + @Valid @RequestBody request: ObjectItemSaveRequest, + servletRequest: HttpServletRequest, + ): ResponseEntity { + limitAnonymousWrite(servletRequest, "project", MAX_PROJECT_SUBMISSIONS_PER_HOUR) + val item = objectItemService.save( + request.copy( + controlPassword = ProjectControlPasswordPolicy.normalizeRequired(request.controlPassword), + ), + ) operationLogService.record( action = "PROJECT_CREATE", targetType = "PROJECT", @@ -55,7 +76,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, - val hasControlPassword: Boolean, + val progress: Int, ) val rs = Response( @@ -71,14 +92,18 @@ class ObjectItemController( leaderMcId = item.leaderMcId, contactInformation = item.contactInformation, coverImageUrl = item.coverImageUrl, - hasControlPassword = item.hasControlPassword, + progress = item.progress, ) return builder.ok().data(rs).build() } @PostMapping("/batch") - fun saveBatch(@RequestBody request: ObjectItemBatchSaveRequest): ResponseEntity { + fun saveBatch( + @AuthenticationPrincipal admin: LoginUser, + @RequestBody request: ObjectItemBatchSaveRequest, + ): ResponseEntity { + accessService.requireSuperAdmin(admin) val items = objectItemService.saveBatch(request.items) data class Response( @@ -94,6 +119,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, + val progress: Int, val hasControlPassword: Boolean, ) @@ -111,6 +137,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, + progress = it.progress, hasControlPassword = it.hasControlPassword, ) } @@ -120,7 +147,7 @@ class ObjectItemController( @GetMapping("/{id}") fun getById(@PathVariable id: Int): ResponseEntity { - val item = objectItemService.findById(id) + val item = objectItemService.findPublicById(id) data class Response( val id: Int?, @@ -135,7 +162,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, - val hasControlPassword: Boolean, + val progress: Int, ) val rs = Response( @@ -151,7 +178,7 @@ class ObjectItemController( leaderMcId = item.leaderMcId, contactInformation = item.contactInformation, coverImageUrl = item.coverImageUrl, - hasControlPassword = item.hasControlPassword, + progress = item.progress, ) return builder.ok().data(rs).build() @@ -159,7 +186,7 @@ class ObjectItemController( @GetMapping("/status/{status}") fun listByStatus(@PathVariable status: ObjectItemStatus): ResponseEntity { - val items = objectItemService.findByStatus(status) + val items = objectItemService.findPublicByStatus(status) data class Response( val id: Int?, @@ -174,7 +201,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, - val hasControlPassword: Boolean, + val progress: Int, ) val rs = items.map { @@ -191,7 +218,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, - hasControlPassword = it.hasControlPassword, + progress = it.progress, ) } @@ -236,7 +263,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, - val hasControlPassword: Boolean, + val progress: Int, ) data class PageResponse( @@ -248,7 +275,12 @@ class ObjectItemController( ) val rs: Any = if (page != null || size != null) { - val vo = objectItemService.queryPage(request, page ?: 0, size ?: DEFAULT_PAGE_SIZE, sort ?: DEFAULT_SORT) + val vo = objectItemService.queryPublicPage( + request, + page ?: 0, + size ?: DEFAULT_PAGE_SIZE, + sort ?: DEFAULT_SORT, + ) PageResponse( content = vo.content.map { Response( @@ -264,7 +296,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, - hasControlPassword = it.hasControlPassword, + progress = it.progress, ) }, totalElements = vo.totalElements, @@ -273,7 +305,7 @@ class ObjectItemController( size = vo.size, ) } else { - objectItemService.query(request).map { + objectItemService.queryPublic(request).map { Response( id = it.id, title = it.title, @@ -287,7 +319,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, - hasControlPassword = it.hasControlPassword, + progress = it.progress, ) } } @@ -296,7 +328,11 @@ class ObjectItemController( } @PostMapping("/query") - fun query(@RequestBody request: ObjectItemQueryRequest): ResponseEntity { + fun query( + @AuthenticationPrincipal admin: LoginUser, + @Valid @RequestBody request: ObjectItemQueryRequest, + ): ResponseEntity { + accessService.requireSuperAdmin(admin) val items = objectItemService.query(request) data class Response( @@ -312,6 +348,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, + val progress: Int, val hasControlPassword: Boolean, ) @@ -329,6 +366,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, + progress = it.progress, hasControlPassword = it.hasControlPassword, ) } @@ -340,10 +378,12 @@ class ObjectItemController( fun update( @AuthenticationPrincipal user: LoginUser, @PathVariable id: Int, - @RequestBody request: ObjectItemUpdateRequest, + @Valid @RequestBody request: ObjectItemUpdateRequest, ): ResponseEntity { - accessService.ensureCanManage(user, id) - val item = objectItemService.update(id, request) + val current = accessService.ensureCanManage(user, id) + accessService.ensureCanSetProjectStatus(user, current.status, request.status) + // 控制密码只属于项目方自服务接口;JWT 管理更新不得借此改写它。 + val item = objectItemService.update(id, request.copy(controlPassword = null)) operationLogService.record( action = "PROJECT_UPDATE", targetType = "PROJECT", @@ -364,6 +404,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, + val progress: Int, val hasControlPassword: Boolean, ) @@ -380,6 +421,7 @@ class ObjectItemController( leaderMcId = item.leaderMcId, contactInformation = item.contactInformation, coverImageUrl = item.coverImageUrl, + progress = item.progress, hasControlPassword = item.hasControlPassword, ) @@ -389,10 +431,15 @@ class ObjectItemController( @PutMapping("/batch") fun updateBatch( @AuthenticationPrincipal user: LoginUser, - @RequestBody request: ObjectItemBatchUpdateRequest, + @Valid @RequestBody request: ObjectItemBatchUpdateRequest, ): ResponseEntity { - request.items.forEach { it.id?.let { id -> accessService.ensureCanManage(user, id) } } - val items = objectItemService.updateBatch(request.items) + request.items.forEach { + val id = it.id ?: throw ParamErrorException("批量更新时项目条目 ID 不能为空") + val current = accessService.ensureCanManage(user, id) + accessService.ensureCanSetProjectStatus(user, current.status, it.status) + } + // 控制密码只属于项目方自服务接口;JWT 管理更新不得借此改写它。 + val items = objectItemService.updateBatch(request.items.map { it.copy(controlPassword = null) }) data class Response( val id: Int?, @@ -407,6 +454,7 @@ class ObjectItemController( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, + val progress: Int, val hasControlPassword: Boolean, ) @@ -424,6 +472,7 @@ class ObjectItemController( leaderMcId = it.leaderMcId, contactInformation = it.contactInformation, coverImageUrl = it.coverImageUrl, + progress = it.progress, hasControlPassword = it.hasControlPassword, ) } @@ -434,8 +483,9 @@ class ObjectItemController( @DeleteMapping("/batch") fun deleteBatch( @AuthenticationPrincipal user: LoginUser, - @RequestBody request: ObjectItemBatchDeleteRequest, + @Valid @RequestBody request: ObjectItemBatchDeleteRequest, ): ResponseEntity { + accessService.requireSuperAdmin(user) request.ids.forEach { accessService.ensureCanManage(user, it) } objectItemService.deleteBatch(request.ids) operationLogService.record( @@ -461,10 +511,9 @@ class ObjectItemController( @GetMapping("/{id}/updates") fun listUpdates( @PathVariable id: Int, - @RequestParam(required = false) status: ObjectItemUpdateStatus?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, ): ResponseEntity { - val updates = objectItemUpdateService.findByObjectItem(id, status) - data class Response( val id: Int?, val objectItemId: Int?, @@ -476,17 +525,25 @@ class ObjectItemController( val updateTime: LocalDateTime?, ) - val rs = updates.map { - Response( - id = it.id, - objectItemId = it.objectItemId, - title = it.title, - content = it.content, - imageUrl = it.imageUrl, - status = it.status, - createTime = it.createTime, - updateTime = it.updateTime, + val rs: Any = if (page != null || size != null) { + objectItemUpdateService.findByObjectItemPage( + id, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, ) + } else { + objectItemUpdateService.findByObjectItem(id).map { + Response( + id = it.id, + objectItemId = it.objectItemId, + title = it.title, + content = it.content, + imageUrl = it.imageUrl, + status = it.status, + createTime = it.createTime, + updateTime = it.updateTime, + ) + } } return builder.ok().data(rs).build() @@ -495,10 +552,9 @@ class ObjectItemController( @GetMapping("/{id}/comments") fun listComments( @PathVariable id: Int, - @RequestParam(required = false) status: ObjectItemCommentStatus?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, ): ResponseEntity { - val comments = objectItemCommentService.findByObjectItem(id, status) - data class Response( val id: Int?, val objectItemId: Int?, @@ -509,16 +565,24 @@ class ObjectItemController( val updateTime: LocalDateTime?, ) - val rs = comments.map { - Response( - id = it.id, - objectItemId = it.objectItemId, - nickName = it.nickName, - content = it.content, - status = it.status, - createTime = it.createTime, - updateTime = it.updateTime, + val rs: Any = if (page != null || size != null) { + objectItemCommentService.findByObjectItemPage( + id, + page ?: DEFAULT_SUBRESOURCE_PAGE, + size ?: DEFAULT_SUBRESOURCE_PAGE_SIZE, ) + } else { + objectItemCommentService.findByObjectItem(id).map { + Response( + id = it.id, + objectItemId = it.objectItemId, + nickName = it.nickName, + content = it.content, + status = it.status, + createTime = it.createTime, + updateTime = it.updateTime, + ) + } } return builder.ok().data(rs).build() @@ -527,8 +591,10 @@ class ObjectItemController( @PostMapping("/{id}/comments") fun createComment( @PathVariable id: Int, - @RequestBody request: ObjectItemCommentSaveRequest, + @Valid @RequestBody request: ObjectItemCommentSaveRequest, + servletRequest: HttpServletRequest, ): ResponseEntity { + limitAnonymousWrite(servletRequest, "comment", MAX_COMMENT_SUBMISSIONS_PER_HOUR) val comment = objectItemCommentService.create(id, request) data class Response( @@ -557,9 +623,12 @@ class ObjectItemController( @PostMapping("/{id}/join-applications") fun createJoinApplication( @PathVariable id: Int, - @RequestBody request: JoinApplicationSaveRequest, + @Valid @RequestBody request: JoinApplicationSaveRequest, + servletRequest: HttpServletRequest, ): ResponseEntity { - val application = joinApplicationService.create(id, request) + limitAnonymousWrite(servletRequest, "join", MAX_JOIN_SUBMISSIONS_PER_HOUR) + val saved = joinApplicationService.createTracked(id, request) + val application = saved.value data class Response( val id: Int?, @@ -573,6 +642,7 @@ class ObjectItemController( val rejectReason: String?, val createTime: LocalDateTime?, val updateTime: LocalDateTime?, + val trackingToken: String, ) val rs = Response( @@ -587,13 +657,46 @@ class ObjectItemController( rejectReason = application.rejectReason, createTime = application.createTime, updateTime = application.updateTime, + trackingToken = saved.trackingToken, ) return builder.ok().data(rs).build() } + /** 游客凭提交成功时展示的一次性追踪码查询加入申请状态。 */ + @GetMapping("/{id}/join-applications/{applicationId}/status") + fun getTrackedJoinApplicationStatus( + @PathVariable id: Int, + @PathVariable applicationId: 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, + ) + val application = joinApplicationService.findTracked(id, applicationId, trackingToken) + return builder.ok().data(application).build() + } + + private fun limitAnonymousWrite(request: HttpServletRequest, type: String, limit: Int) { + val clientIp = clientRequestIdentity.clientIp(request) + rateLimiter.consume("public-write-ip", clientIp, MAX_PUBLIC_WRITES_PER_HOUR, RATE_LIMIT_WINDOW) + rateLimiter.consume("public-$type-ip", clientIp, limit, RATE_LIMIT_WINDOW) + } + private companion object { const val DEFAULT_PAGE_SIZE = 20 + const val DEFAULT_SUBRESOURCE_PAGE = 0 + const val DEFAULT_SUBRESOURCE_PAGE_SIZE = 100 const val DEFAULT_SORT = "id,desc" + const val MAX_PUBLIC_WRITES_PER_HOUR = 80 + const val MAX_PROJECT_SUBMISSIONS_PER_HOUR = 10 + const val MAX_COMMENT_SUBMISSIONS_PER_HOUR = 40 + const val MAX_JOIN_SUBMISSIONS_PER_HOUR = 15 + 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 7473e7c..94b4f9c 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/JoinApplication.kt @@ -44,6 +44,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 5b203ed..1947af1 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/Mind.kt @@ -36,6 +36,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/ObjectItem.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItem.kt index 6206d3d..5b1422f 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItem.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItem.kt @@ -63,6 +63,10 @@ class ObjectItem { @Column(name = "cover_image_url", length = 512) var coverImageUrl: String? = null + /** 项目完成进度,取值 0-100。 */ + @Column(name = "progress", nullable = false) + var progress: Int = 0 + @Column(name = "control_password", length = 255) var controlPassword: String? = null 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 66c20f1..346e976 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/ObjectItemRepository.kt @@ -1,22 +1,25 @@ 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 ObjectItemRepository : JpaRepository { +interface ObjectItemRepository : JpaRepository, JpaSpecificationExecutor { fun findByStatus(status: ObjectItemStatus): List fun countByStatus(status: ObjectItemStatus): Long + fun countByStatusIn(statuses: Collection): Long + fun findByType(type: String): List fun findByLeaderMcId(leaderMcId: String): List fun findByTitleContainingIgnoreCase(title: String): List - 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/datasource/jdbc/UserRepository.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/UserRepository.kt index 0478943..7a4cb8e 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/UserRepository.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/datasource/jdbc/UserRepository.kt @@ -7,9 +7,11 @@ import org.springframework.stereotype.Repository @Repository interface UserRepository : JpaRepository { fun findByUsername(username: String): User? - fun findByEmail(email: String): User? + fun findByEmailIgnoreCase(email: String): User? fun findByRole(role: Role): List /** 角色范围内的全部账号:用于列出可归属项目的账号(项目管理 + 总管理)。 */ fun findByRoleIn(roles: Collection): List -} \ No newline at end of file + + fun findByRoleInAndStatus(roles: Collection, status: Status): List +} diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt index 15c3e62..48b2284 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/handlder/BusinessException.kt @@ -38,10 +38,18 @@ class ResourceNotFoundException( message: String = "资源不存在" ) : BusinessException(HttpStatus.NOT_FOUND, 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 562b383..6ade09f 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/JwtAuthenticationFilter.kt @@ -40,6 +40,10 @@ class JwtAuthenticationFilter( private fun authenticate(token: String, req: HttpServletRequest) { try { val claims = jwtService.parse(token) + if (claims.get(CLAIM_TYPE, String::class.java) != TYPE_ACCESS) { + req.setAttribute(AUTH_ERROR_ATTR, TokenInvalidException("非访问令牌")) + return + } val jti = claims.id if (jti == null || !tokenStore.isAccessValid(jti)) { req.setAttribute(AUTH_ERROR_ATTR, TokenInvalidException("Token 已失效")) @@ -59,12 +63,20 @@ 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.PROJECT_MANAGER + ?: throw TokenInvalidException("令牌缺少角色") + val role = runCatching { Role.valueOf(roleName) } + .getOrElse { throw TokenInvalidException("令牌角色无效") } + val userId = claims.subject?.toLongOrNull() + ?: throw TokenInvalidException("令牌用户无效") + val username = claims.get(CLAIM_USERNAME, String::class.java) + ?.takeIf { it.isNotBlank() } + ?: throw TokenInvalidException("令牌缺少用户名") + val jti = claims.id ?: throw TokenInvalidException("令牌缺少标识") return LoginUser( - id = claims.subject.toLong(), - username = claims.get(CLAIM_USERNAME, String::class.java), + id = userId, + username = username, role = role, - jti = claims.id, + jti = jti, ) } @@ -74,5 +86,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/LoginUser.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/LoginUser.kt index 7e6fd87..e4645c5 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/security/LoginUser.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/security/LoginUser.kt @@ -30,6 +30,6 @@ data class LoginUser( companion object { fun of(user: User, jti: String) = - LoginUser(user.id!!, user.username, user.role ?: Role.PROJECT_MANAGER, jti) + LoginUser(user.id!!, user.username, requireNotNull(user.role) { "用户缺少角色" }, jti) } } 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/AccessService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AccessService.kt index 4fbe805..224dd14 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AccessService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AccessService.kt @@ -1,8 +1,11 @@ package `fun`.utf8.nekoprojectbackend.service +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItem import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus 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.security.LoginUser import org.springframework.stereotype.Service @@ -28,17 +31,46 @@ class AccessService( } /** 校验当前用户能否管理指定项目:总管理放行;项目管理仅限 ownerId 等于自身 id 的名下项目。 */ - fun ensureCanManage(user: LoginUser, objectItemId: Int) { - if (user.role == Role.SUPER_ADMIN) return - val ownerId = objectItemRepository.findById(objectItemId) + fun ensureCanManage(user: LoginUser, objectItemId: Int): ObjectItem { + if (objectItemId <= 0) { + throw ParamErrorException("项目条目 ID 必须大于 0") + } + val item = objectItemRepository.findById(objectItemId) .orElseThrow { ResourceNotFoundException("项目条目不存在") } - .ownerId + if (user.role == Role.SUPER_ADMIN) return item + val ownerId = item.ownerId if (ownerId != user.id) { throw ForbiddenException("无权管理该项目") } + return item + } + + /** 项目管理只能在已通过审核的项目上维护运营阶段。 */ + fun ensureCanSetProjectStatus( + user: LoginUser, + currentStatus: ObjectItemStatus?, + targetStatus: ObjectItemStatus?, + ) { + if (user.role == Role.SUPER_ADMIN || targetStatus == null) return + if (targetStatus !in PROJECT_MANAGER_EDITABLE_STATUSES) { + throw ForbiddenException("项目管理只能修改运营状态:筹备中、招募中、进行中或已暂停") + } + if (currentStatus !in PROJECT_MANAGER_EDITABLE_SOURCE_STATUSES) { + throw ForbiddenException("项目尚未通过审核,不能进入运营状态") + } } /** 列表场景的归属过滤:总管理返回 null(不限),项目管理返回自身 id。 */ fun ownerIdScope(user: LoginUser): Long? = if (user.role == Role.SUPER_ADMIN) null else user.id + + private companion object { + val PROJECT_MANAGER_EDITABLE_STATUSES = setOf( + ObjectItemStatus.PREPARING, + ObjectItemStatus.RECRUITING, + ObjectItemStatus.IN_PROGRESS, + ObjectItemStatus.PAUSED, + ) + val PROJECT_MANAGER_EDITABLE_SOURCE_STATUSES = PROJECT_MANAGER_EDITABLE_STATUSES + ObjectItemStatus.APPROVED + } } 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 5175e5d..f5cf8db 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/AuthService.kt @@ -6,6 +6,9 @@ import `fun`.utf8.nekoprojectbackend.datasource.jdbc.Status import `fun`.utf8.nekoprojectbackend.datasource.jdbc.User import `fun`.utf8.nekoprojectbackend.handlder.* import jakarta.transaction.Transactional +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import java.time.Duration @@ -27,10 +30,18 @@ class AuthService( private val inviteCodeService: InviteCodeService, private val verificationCodeService: VerificationCodeService, private val mailService: MailService, + private val rateLimiter: RateLimiter, private val props: JwtProperties, ) { - data class LoginRequest(val username: String, val password: String) + data class LoginRequest( + @field:NotBlank(message = "用户名不能为空") + @field:Size(max = 64, message = "用户名不能超过 64 个字符") + val username: String, + @field:NotBlank(message = "密码不能为空") + @field:Size(max = 72, message = "密码不能超过 72 个字符") + val password: String, + ) data class LoginResponse( val accessToken: String, val refreshToken: String, @@ -40,10 +51,20 @@ class AuthService( ) data class RegisterManagerRequest( + @field:NotBlank(message = "邀请码不能为空") + @field:Size(max = 128, message = "邀请码不能超过 128 个字符") val inviteCode: String, + @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, + @field:NotBlank(message = "验证码不能为空") + @field:Size(max = 16, message = "验证码格式错误") val emailCode: String, ) @@ -55,63 +76,98 @@ class AuthService( /** 邮箱+密码登录请求:username 可为用户名或邮箱,需额外校验邮箱验证码。 */ data class EmailLoginRequest( + @field:NotBlank(message = "账号不能为空") + @field:Size(max = 128, message = "账号不能超过 128 个字符") val account: String, + @field:NotBlank(message = "密码不能为空") + @field:Size(max = 72, message = "密码不能超过 72 个字符") val password: String, + @field:NotBlank(message = "邮箱不能为空") + @field:Email(message = "邮箱格式不正确") + @field:Size(max = 128, message = "邮箱不能超过 128 个字符") val email: String, + @field:NotBlank(message = "验证码不能为空") + @field:Size(max = 16, message = "验证码格式错误") val emailCode: String, ) data class SendCodeRequest( + @field:NotBlank(message = "邮箱不能为空") + @field:Email(message = "邮箱格式不正确") + @field:Size(max = 128, message = "邮箱不能超过 128 个字符") val email: String, val scene: VerificationCodeService.Scene, - val userId: Long? = null, ) data class ChangePasswordRequest( + @field:NotBlank(message = "旧密码不能为空") + @field:Size(max = 72, message = "旧密码不能超过 72 个字符") val oldPassword: String, + @field:Size(min = 8, max = 72, message = "新密码长度必须为 8 到 72 个字符") val newPassword: String, + @field:NotBlank(message = "邮箱不能为空") + @field:Email(message = "邮箱格式不正确") + @field:Size(max = 128, message = "邮箱不能超过 128 个字符") val email: String, + @field:NotBlank(message = "验证码不能为空") + @field:Size(max = 16, message = "验证码格式错误") val emailCode: String, ) data class ResetPasswordRequest( + @field:NotBlank(message = "邮箱不能为空") + @field:Email(message = "邮箱格式不正确") + @field:Size(max = 128, message = "邮箱不能超过 128 个字符") val email: String, + @field:NotBlank(message = "验证码不能为空") + @field:Size(max = 16, message = "验证码格式错误") val emailCode: String, + @field:Size(min = 8, max = 72, message = "新密码长度必须为 8 到 72 个字符") val newPassword: String, ) - fun login(req: LoginRequest): LoginResponse { + fun login(req: LoginRequest, clientIp: String = ""): LoginResponse { + val accountIdentity = req.username.trim().lowercase() + ensureLoginAllowed(accountIdentity, clientIp) val user = userService.findByUsername(req.username) - ?: throw UsernameOrPasswordErrorException() - if (user.status == Status.BANNED) throw UserDisabledException() - if (!passwordEncoder.matches(req.password, user.password)) { - throw UsernameOrPasswordErrorException() + ?: rejectLogin(accountIdentity, clientIp, UsernameOrPasswordErrorException()) + if (user.status == Status.BANNED) { + rejectLogin(accountIdentity, clientIp, UserDisabledException()) + } + if (!matchesPassword(req.password, user.password)) { + rejectLogin(accountIdentity, clientIp, UsernameOrPasswordErrorException()) } + clearLoginFailures(accountIdentity, clientIp) return issueTokens(user) } /** 邮箱验证登录:校验邮箱验证码(绑定 email+UA)+ 账号归属 + 密码。 */ - fun loginByEmail(req: EmailLoginRequest, userAgent: String): LoginResponse { - // 校验验证码(匿名场景,按 email+UA 绑定) + fun loginByEmail(req: EmailLoginRequest, userAgent: String, clientIp: String = ""): LoginResponse { + val emailIdentity = userService.normalizeEmail(req.email) + ensureLoginAllowed(emailIdentity, clientIp) + val user = userService.findByEmail(emailIdentity) + ?: rejectLogin(emailIdentity, clientIp, UsernameOrPasswordErrorException()) + if (user.status == Status.BANNED) { + rejectLogin(emailIdentity, clientIp, UserDisabledException()) + } + // account 必须与该邮箱归属账号的用户名一致,防止用他人邮箱验证码登录任意账号 + if (user.username != req.account.trim()) { + rejectLogin(emailIdentity, clientIp, UsernameOrPasswordErrorException()) + } + if (!matchesPassword(req.password, user.password)) { + rejectLogin(emailIdentity, clientIp, UsernameOrPasswordErrorException()) + } + // 账号与密码通过后再消费验证码,避免输错密码导致一次性验证码无故作废。 verificationCodeService.verifyAndConsume( VerificationCodeService.CodeContext( scene = VerificationCodeService.Scene.EMAIL_LOGIN, - email = req.email, + email = user.email, userId = null, userAgent = userAgent, ), req.emailCode, ) - val user = userService.findByEmail(req.email) - ?: throw UsernameOrPasswordErrorException() - if (user.status == Status.BANNED) throw UserDisabledException() - // account 必须与该邮箱归属账号的用户名一致,防止用他人邮箱验证码登录任意账号 - if (user.username != req.account.trim()) { - throw UsernameOrPasswordErrorException() - } - if (!passwordEncoder.matches(req.password, user.password)) { - throw UsernameOrPasswordErrorException() - } + clearLoginFailures(emailIdentity, clientIp) return issueTokens(user) } @@ -123,7 +179,10 @@ 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.get(CLAIM_TYPE, String::class.java) != TYPE_REFRESH) return + if (claims.subject?.toLongOrNull() != userId) return + val jti = claims.id ?: return tokenStore.revokeRefresh(jti, userId) } @@ -133,7 +192,7 @@ class AuthService( throw TokenInvalidException("非刷新令牌") } val jti = claims.id ?: throw TokenInvalidException() - val userId = claims.subject.toLong() + val userId = claims.subject?.toLongOrNull() ?: throw TokenInvalidException() val storedUserId = tokenStore.consumeRefresh(jti) ?: throw TokenInvalidException("刷新令牌已失效") if (storedUserId != userId) throw TokenInvalidException() @@ -146,38 +205,48 @@ class AuthService( } /** 发送验证码:校验场景前置条件 + 限流,生成后发邮件。 */ - fun sendVerificationCode(req: SendCodeRequest, userAgent: String) { - val email = req.email.trim() - if (email.isBlank()) throw ParamErrorException("邮箱不能为空") - // 场景前置校验:找回密码要求邮箱已注册;注册要求邮箱未注册 + fun sendVerificationCode( + req: SendCodeRequest, + userAgent: String, + authenticatedUserId: Long? = null, + clientIp: String = "", + ) { + val email = userService.normalizeEmail(req.email) + rateLimiter.consume("verification-code-ip", clientIp, MAX_CODE_REQUESTS_PER_IP, CODE_REQUEST_WINDOW) + val contextUserId: Long? + val shouldSend: Boolean when (req.scene) { VerificationCodeService.Scene.REGISTER -> { - if (userService.findByEmail(email) != null) { - throw ParamErrorException("该邮箱已注册") - } + contextUserId = null + shouldSend = userService.findByEmail(email) == null } VerificationCodeService.Scene.RESET_PASSWORD, VerificationCodeService.Scene.EMAIL_LOGIN -> { - if (userService.findByEmail(email) == null) { - throw UserNotFoundException("该邮箱未注册") - } + contextUserId = null + shouldSend = userService.findByEmail(email) != null } VerificationCodeService.Scene.CHANGE_PASSWORD -> { - // 改密码确认:校验邮箱归属某真实账号(防对未注册邮箱发码刷量)。 - // 验证码 key 只绑 email+UA(与改密码时一致),不引入 userId—— - // 发码接口是匿名的,传来的 userId 无法核验归属,强绑会导致发码/校验两处 key 不一致。 - if (userService.findByEmail(email) == null) { - throw UserNotFoundException("该邮箱未注册") + val userId = authenticatedUserId + ?: throw UnauthorizedException("修改密码验证码需要先登录") + val user = userService.findById(userId) + ?: throw UnauthorizedException("当前登录用户不存在") + if (user.status == Status.BANNED) throw UserDisabledException() + if (!user.email.equals(email, ignoreCase = true)) { + throw ParamErrorException("邮箱与当前账号不匹配") } + contextUserId = userId + shouldSend = true } } verificationCodeService.checkAndRecordSend(email) + // 匿名场景统一返回成功,避免通过发码接口探测邮箱是否已注册。 + if (!shouldSend) return val ctx = VerificationCodeService.CodeContext( scene = req.scene, email = email, - userId = null, + userId = contextUserId, userAgent = userAgent, ) val code = verificationCodeService.generate(ctx) @@ -186,26 +255,31 @@ class AuthService( /** 项目管理凭一次性邀请码注册:先校验邮箱验证码,再建号 + 原子消费邀请码;消费失败则回滚。 */ @Transactional - fun registerManager(req: RegisterManagerRequest, userAgent: String): RegisterManagerResponse { - // 校验注册验证码(匿名场景,按 email+UA 绑定) + fun registerManager( + req: RegisterManagerRequest, + userAgent: String, + clientIp: String = "", + ): RegisterManagerResponse { + rateLimiter.consume("manager-register-ip", clientIp, MAX_REGISTRATIONS_PER_IP, REGISTRATION_WINDOW) + val user = userService.createUser(req.username, req.password, req.email, Role.PROJECT_MANAGER) + if (!inviteCodeService.consume(req.inviteCode, user.id!!)) { + // 邀请码无效 / 已用 / 已过期:同一事务回滚,不留下无邀请码的用户 + throw ParamErrorException("邀请码无效或已过期") + } + // 放在本地字段校验、建号与邀请码消费之后:前置步骤失败时不会白白消耗验证码。 verificationCodeService.verifyAndConsume( VerificationCodeService.CodeContext( scene = VerificationCodeService.Scene.REGISTER, - email = req.email, + email = user.email, userId = null, userAgent = userAgent, ), req.emailCode, ) - val user = userService.createUser(req.username, req.password, req.email, Role.PROJECT_MANAGER) - if (!inviteCodeService.consume(req.inviteCode, user.id!!)) { - // 邀请码无效 / 已用 / 已过期:同一事务回滚,不留下无邀请码的用户 - throw ParamErrorException("邀请码无效或已过期") - } return RegisterManagerResponse( id = user.id!!, username = user.username, - role = user.role ?: Role.PROJECT_MANAGER, + role = requireNotNull(user.role) { "新建用户缺少角色" }, ) } @@ -213,48 +287,82 @@ class AuthService( @Transactional fun changePassword(userId: Long, req: ChangePasswordRequest, userAgent: String) { val user = userService.findById(userId) ?: throw UserNotFoundException() - if (!passwordEncoder.matches(req.oldPassword, user.password)) { + if (!matchesPassword(req.oldPassword, user.password)) { throw UsernameOrPasswordErrorException() } + val email = userService.normalizeEmail(req.email) + if (!user.email.equals(email, ignoreCase = true)) { + throw ParamErrorException("邮箱与当前账号不匹配") + } + userService.validatePassword(req.newPassword) verificationCodeService.verifyAndConsume( VerificationCodeService.CodeContext( scene = VerificationCodeService.Scene.CHANGE_PASSWORD, - email = req.email, - userId = null, + email = email, + userId = userId, userAgent = userAgent, ), req.emailCode, ) - if (req.newPassword.isBlank()) throw ParamErrorException("新密码不能为空") - user.password = passwordEncoder.encode(req.newPassword) - ?: throw IllegalStateException("Password encoding failed.") - userService.save(user) + userService.updatePassword(user, req.newPassword) // 改密码后踢掉所有旧会话,强制重新登录 tokenStore.invalidateAllSessions(userId) } /** 找回密码(匿名):凭邮箱验证码重置密码。 */ @Transactional - fun resetPassword(req: ResetPasswordRequest, userAgent: String) { + fun resetPassword(req: ResetPasswordRequest, userAgent: String, clientIp: String = "") { + val email = userService.normalizeEmail(req.email) + rateLimiter.consume("password-reset-ip", clientIp, MAX_RESETS_PER_IP, PASSWORD_RESET_WINDOW) + rateLimiter.consume("password-reset-email", email, MAX_RESETS_PER_EMAIL, PASSWORD_RESET_WINDOW) + userService.validatePassword(req.newPassword) verificationCodeService.verifyAndConsume( VerificationCodeService.CodeContext( scene = VerificationCodeService.Scene.RESET_PASSWORD, - email = req.email, + email = email, userId = null, userAgent = userAgent, ), req.emailCode, ) - val user = userService.findByEmail(req.email) ?: throw UserNotFoundException() - if (req.newPassword.isBlank()) throw ParamErrorException("新密码不能为空") - user.password = passwordEncoder.encode(req.newPassword) - ?: throw IllegalStateException("Password encoding failed.") - userService.save(user) + val user = userService.findByEmail(email) ?: throw VerificationCodeInvalidException() + userService.updatePassword(user, req.newPassword) tokenStore.invalidateAllSessions(user.id!!) } + private fun ensureLoginAllowed(accountIdentity: String, clientIp: String) { + rateLimiter.ensureNotLocked(LOGIN_ACCOUNT_NAMESPACE, accountIdentity) + rateLimiter.ensureNotLocked(LOGIN_IP_NAMESPACE, clientIp) + } + + private fun rejectLogin(accountIdentity: String, clientIp: String, exception: BusinessException): Nothing { + val accountLocked = rateLimiter.recordFailure( + LOGIN_ACCOUNT_NAMESPACE, + accountIdentity, + MAX_LOGIN_FAILURES_PER_ACCOUNT, + LOGIN_FAILURE_WINDOW, + LOGIN_LOCK_DURATION, + ) + val ipLocked = rateLimiter.recordFailure( + LOGIN_IP_NAMESPACE, + clientIp, + MAX_LOGIN_FAILURES_PER_IP, + LOGIN_FAILURE_WINDOW, + LOGIN_LOCK_DURATION, + ) + if (accountLocked || ipLocked) { + throw TooManyRequestsException("登录失败次数过多,请稍后重试") + } + throw exception + } + + private fun clearLoginFailures(accountIdentity: String, clientIp: String) { + rateLimiter.clearFailures(LOGIN_ACCOUNT_NAMESPACE, accountIdentity) + rateLimiter.clearFailures(LOGIN_IP_NAMESPACE, clientIp) + } + private fun issueTokens(user: User): LoginResponse { - val role = (user.role ?: Role.PROJECT_MANAGER).name + val role = requireNotNull(user.role) { "用户缺少角色" }.name val userId = user.id!! val access = jwtService.issueAccessToken(userId, user.username, role, props.accessTokenTtlSeconds) val refresh = jwtService.issueRefreshToken(userId, user.username, role, props.refreshTokenTtlSeconds) @@ -263,8 +371,29 @@ class AuthService( return LoginResponse(access.token, refresh.token, "Bearer", access.ttlSeconds, refresh.ttlSeconds) } + private fun matchesPassword(rawPassword: String, encodedPassword: String): Boolean { + if (rawPassword.toByteArray(Charsets.UTF_8).size > MAX_BCRYPT_PASSWORD_BYTES) { + return false + } + return passwordEncoder.matches(rawPassword, encodedPassword) + } + private companion object { const val TYPE_REFRESH = "refresh" const val CLAIM_TYPE = "type" + const val MAX_BCRYPT_PASSWORD_BYTES = 72 + const val LOGIN_ACCOUNT_NAMESPACE = "login-account" + const val LOGIN_IP_NAMESPACE = "login-ip" + const val MAX_LOGIN_FAILURES_PER_ACCOUNT = 5 + const val MAX_LOGIN_FAILURES_PER_IP = 30 + const val MAX_CODE_REQUESTS_PER_IP = 30 + const val MAX_REGISTRATIONS_PER_IP = 10 + const val MAX_RESETS_PER_IP = 20 + const val MAX_RESETS_PER_EMAIL = 5 + val LOGIN_FAILURE_WINDOW: Duration = Duration.ofMinutes(15) + val LOGIN_LOCK_DURATION: Duration = Duration.ofMinutes(15) + val CODE_REQUEST_WINDOW: Duration = Duration.ofHours(1) + val REGISTRATION_WINDOW: Duration = Duration.ofHours(1) + val PASSWORD_RESET_WINDOW: Duration = Duration.ofHours(1) } } 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 1a94417..b8776cf 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationManagementService.kt @@ -4,20 +4,39 @@ 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.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.handlder.ResourceConflictException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +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 JoinApplicationRejectRequest( + @field:NotBlank(message = "项目控制密码不能为空") + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", + @field:Size(max = 255, message = "拒绝理由不能超过 255 个字符") val rejectReason: String? = null, ) /** 管理员拒绝加入申请请求体:JWT 鉴权下无需项目控制密码,仅携带可选拒绝理由。 */ data class JoinApplicationAdminRejectRequest( + @field:Size(max = 255, message = "拒绝理由不能超过 255 个字符") val rejectReason: String? = null, ) +data class JoinApplicationPageVO( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, +) + /** 加入申请管理业务:凭项目控制密码查看/接受/拒绝申请,或管理员(JWT)直接处理。 */ @Service class JoinApplicationManagementService( @@ -41,15 +60,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") } @Transactional @@ -68,8 +153,10 @@ class JoinApplicationManagementService( objectItemId: Int, applicationId: Int, ): JoinApplicationResponse { - val application = loadApplication(applicationId, objectItemId) + val application = loadApplicationForUpdate(applicationId, objectItemId) + ensureProcessable(application) application.status = JoinApplicationStatus.ACCEPTED + application.rejectReason = null return joinApplicationRepository.save(application).toResponse() } @@ -90,7 +177,8 @@ class JoinApplicationManagementService( applicationId: Int, rejectReason: String?, ): JoinApplicationResponse { - val application = loadApplication(applicationId, objectItemId) + val application = loadApplicationForUpdate(applicationId, objectItemId) + ensureProcessable(application) application.status = JoinApplicationStatus.REJECTED application.rejectReason = normalizeRejectReason(rejectReason) return joinApplicationRepository.save(application).toResponse() @@ -108,18 +196,24 @@ class JoinApplicationManagementService( objectItemManagementService.verify(objectItemId, request) } - private fun loadApplication(applicationId: Int, objectItemId: Int): JoinApplication { + private fun loadApplicationForUpdate(applicationId: Int, objectItemId: Int): JoinApplication { if (applicationId <= 0) { throw ParamErrorException("加入申请 ID 必须大于 0") } - val application = joinApplicationRepository.findById(applicationId) - .orElseThrow { ResourceNotFoundException("加入申请不存在") } + val application = joinApplicationRepository.findByIdForUpdate(applicationId) + ?: throw ResourceNotFoundException("加入申请不存在") if (application.objectItemId != objectItemId) { throw ResourceNotFoundException("加入申请不存在") } return application } + private fun ensureProcessable(application: JoinApplication) { + if (application.status !in PROCESSABLE_STATUSES) { + throw ResourceConflictException("加入申请已处理,不能重复修改结果") + } + } + private fun JoinApplication.toResponse(): JoinApplicationResponse { return JoinApplicationResponse( id = id, @@ -138,5 +232,11 @@ 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 + private val PROCESSABLE_STATUSES = setOf( + JoinApplicationStatus.PENDING, + JoinApplicationStatus.CONTACTED, + ) } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt index 4a4e1fe..d5de182 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/JoinApplicationService.kt @@ -3,18 +3,30 @@ 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.ObjectItemRepository +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.handlder.ResourceConflictException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime data class JoinApplicationSaveRequest( + @field:NotBlank(message = "申请人昵称不能为空") + @field:Size(max = 64, message = "申请人昵称不能超过 64 个字符") val nickName: String = "", + @field:NotBlank(message = "申请人 Minecraft ID 不能为空") + @field:Size(max = 64, message = "申请人 Minecraft ID 不能超过 64 个字符") val mcId: String = "", + @field:NotBlank(message = "申请人联系方式不能为空") + @field:Size(max = 255, message = "申请人联系方式不能超过 255 个字符") val contact: String = "", + @field:NotBlank(message = "申请理由不能为空") + @field:Size(max = 4000, message = "申请理由不能超过 4000 个字符") val reason: String = "", + @field:Size(max = 64, message = "申请岗位不能超过 64 个字符") val skill: String? = null, ) @@ -35,14 +47,52 @@ data class JoinApplicationResponse( /** 加入申请业务:用户提交入组申请并校验昵称/MC ID/联系方式/理由等字段。 */ @Service class JoinApplicationService( - private val objectItemRepository: ObjectItemRepository, + private val objectItemService: ObjectItemService, private val joinApplicationRepository: JoinApplicationRepository, + private val submissionTrackingService: SubmissionTrackingService, ) { @Transactional fun create(objectItemId: Int, request: JoinApplicationSaveRequest): JoinApplicationResponse { + return createEntity(objectItemId, request, trackingTokenHash = null).toResponse() + } + + @Transactional + fun createTracked( + objectItemId: Int, + request: JoinApplicationSaveRequest, + ): TrackedSubmission { + val issued = submissionTrackingService.issue() + val entity = createEntity(objectItemId, request, issued.hash) + return TrackedSubmission( + value = entity.toResponse(), + trackingToken = issued.token, + ) + } + + @Transactional(readOnly = true) + fun findTracked(objectItemId: Int, applicationId: Int, trackingToken: String): JoinApplicationResponse { val resolvedItemId = requirePositiveItemId(objectItemId) - ensureObjectItemExists(resolvedItemId) + if (applicationId <= 0) { + throw ParamErrorException("加入申请 ID 必须大于 0") + } + val application = joinApplicationRepository.findById(applicationId) + .orElseThrow { ResourceNotFoundException("加入申请不存在或追踪码不正确") } + if (application.objectItemId != resolvedItemId || + !submissionTrackingService.matches(trackingToken, application.trackingTokenHash) + ) { + throw ResourceNotFoundException("加入申请不存在或追踪码不正确") + } + return application.toResponse() + } + + private fun createEntity( + objectItemId: Int, + request: JoinApplicationSaveRequest, + trackingTokenHash: String?, + ): JoinApplication { + val resolvedItemId = requirePositiveItemId(objectItemId) + val selectedSkill = resolveRecruitmentSkill(resolvedItemId, request.skill) val entity = JoinApplication().also { it.objectItemId = resolvedItemId @@ -64,18 +114,33 @@ class JoinApplicationService( MAX_CONTACT_LENGTH, "申请人联系方式不能超过 $MAX_CONTACT_LENGTH 个字符" ) - it.reason = requireText(request.reason, "申请理由不能为空") - it.skill = - normalizeNullableText(request.skill, MAX_SKILL_LENGTH, "申请岗位不能超过 $MAX_SKILL_LENGTH 个字符") + it.reason = requireText( + request.reason, + "申请理由不能为空", + MAX_REASON_LENGTH, + "申请理由不能超过 $MAX_REASON_LENGTH 个字符", + ) + it.skill = selectedSkill it.status = JoinApplicationStatus.PENDING + it.trackingTokenHash = trackingTokenHash } - return joinApplicationRepository.save(entity).toResponse() + return joinApplicationRepository.save(entity) } - private fun ensureObjectItemExists(objectItemId: Int) { - if (!objectItemRepository.existsById(objectItemId)) { - throw ResourceNotFoundException("项目条目不存在") + private fun resolveRecruitmentSkill(objectItemId: Int, requestedSkill: String?): String { + val project = objectItemService.findPublicById(objectItemId) + if (project.status != ObjectItemStatus.RECRUITING) { + throw ResourceConflictException("项目当前未开放招募") } + val skill = normalizeNullableText( + requestedSkill, + MAX_SKILL_LENGTH, + "申请岗位不能超过 $MAX_SKILL_LENGTH 个字符", + ) ?: throw ParamErrorException("请选择申请岗位") + val need = project.needMembers.firstOrNull { + (it.number ?: 0) > 0 && it.skill?.trim()?.equals(skill, ignoreCase = true) == true + } ?: throw ParamErrorException("申请岗位不在项目当前招募需求中") + return need.skill?.trim().orEmpty() } private fun requirePositiveItemId(objectItemId: Int): Int { @@ -133,6 +198,7 @@ class JoinApplicationService( private const val MAX_NICK_NAME_LENGTH = 64 private const val MAX_MC_ID_LENGTH = 64 private const val MAX_CONTACT_LENGTH = 255 + private const val MAX_REASON_LENGTH = 4_000 private const val MAX_SKILL_LENGTH = 64 } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt index ea2c284..5feecbb 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/MindService.kt @@ -5,45 +5,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, ) @@ -84,12 +110,23 @@ enum class MindSortProperty(val alias: String) { @Service class MindService( private val mindRepository: MindRepository, + private val submissionTrackingService: SubmissionTrackingService, ) { @Transactional 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, "批量保存想法不能为空") @@ -100,10 +137,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 { @@ -111,34 +177,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) @@ -192,48 +277,77 @@ 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, + ) + } + requestedStatuses.takeIf { it.isNotEmpty() }?.let { + predicates += root.get("status").`in`(it) + } + normalizedMcId?.let { + predicates += criteriaBuilder.equal( + criteriaBuilder.lower(root.get("mcId")), + it.lowercase(), + ) } - .filter { !filterByStatus || it.status in requestedStatuses } - .filter { normalizedMcId == null || it.mcId?.equals(normalizedMcId, ignoreCase = true) == true } - .toList() + 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) { @@ -250,7 +364,12 @@ class MindService( it.nickName = normalizeNullableText(nickName, MAX_NICK_NAME_LENGTH, "想法昵称不能超过 $MAX_NICK_NAME_LENGTH 个字符") it.status = MindStatus.PENDING - 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 个字符") } @@ -262,7 +381,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 个字符") } @@ -279,13 +405,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) @@ -312,6 +445,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, @@ -327,8 +468,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 6790ece..c6a7f4c 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentManagementService.kt @@ -5,12 +5,20 @@ 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 jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +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 ObjectItemCommentManageStatusRequest( + @field:NotBlank(message = "项目控制密码不能为空") + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", - val status: ObjectItemCommentStatus = ObjectItemCommentStatus.APPROVED, + val status: ObjectItemCommentStatus, ) /** 项目评论管理业务:凭项目控制密码查看/审核/删除评论,或管理员直接审核状态。 */ @@ -36,15 +44,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 @@ -54,6 +128,7 @@ class ObjectItemCommentManagementService( request: ObjectItemCommentManageStatusRequest, ): ObjectItemCommentResponse { verifyProject(objectItemId, request.toVerifyRequest()) + ensureModerationStatus(request.status) val comment = loadComment(commentId, objectItemId) comment.status = request.status return objectItemCommentRepository.save(comment).toResponse() @@ -65,6 +140,7 @@ class ObjectItemCommentManagementService( commentId: Int, status: ObjectItemCommentStatus, ): ObjectItemCommentResponse { + ensureModerationStatus(status) val comment = loadComment(commentId, objectItemId) comment.status = status return objectItemCommentRepository.save(comment).toResponse() @@ -86,6 +162,12 @@ class ObjectItemCommentManagementService( objectItemManagementService.verify(objectItemId, request) } + private fun ensureModerationStatus(status: ObjectItemCommentStatus) { + if (status !in MODERATION_STATUSES) { + throw ParamErrorException("评论审核状态只能是 APPROVED、REJECTED 或 DELETED") + } + } + private fun loadComment(commentId: Int, objectItemId: Int): ObjectItemComment { if (commentId <= 0) { throw ParamErrorException("项目评论 ID 必须大于 0") @@ -112,4 +194,14 @@ class ObjectItemCommentManagementService( updateTime = updateTime, ) } + + private companion object { + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 + private val MODERATION_STATUSES = setOf( + ObjectItemCommentStatus.APPROVED, + ObjectItemCommentStatus.REJECTED, + ObjectItemCommentStatus.DELETED, + ) + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt index 7fcc750..7c06469 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemCommentService.kt @@ -3,15 +3,23 @@ package `fun`.utf8.nekoprojectbackend.service 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.handlder.ParamErrorException -import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +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 data class ObjectItemCommentSaveRequest( + @field:NotBlank(message = "评论者昵称不能为空") + @field:Size(max = 64, message = "评论者昵称不能超过 64 个字符") val nickName: String = "", + @field:NotBlank(message = "评论内容不能为空") + @field:Size(max = 2000, message = "评论内容不能超过 2000 个字符") val content: String = "", ) @@ -25,42 +33,123 @@ data class ObjectItemCommentResponse( val updateTime: LocalDateTime?, ) +data class ObjectItemCommentPageVO( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, +) + /** 项目评论业务:公开评论的创建与查询(查询默认仅返回已通过)。 */ @Service class ObjectItemCommentService( - private val objectItemRepository: ObjectItemRepository, + private val objectItemService: ObjectItemService, private val objectItemCommentRepository: ObjectItemCommentRepository, ) { @Transactional(readOnly = true) fun findByObjectItem( objectItemId: Int, - status: ObjectItemCommentStatus? ): 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, + ): ObjectItemCommentPageVO { val resolvedItemId = requirePositiveItemId(objectItemId) - val effectiveStatus = status ?: ObjectItemCommentStatus.APPROVED - ensureObjectItemExists(resolvedItemId) - - return objectItemCommentRepository.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 -> + objectItemCommentRepository.findByObjectItemIdAndStatus( + resolvedItemId, + ObjectItemCommentStatus.APPROVED, + pageable, + ) + }, + count = { + objectItemCommentRepository.countByObjectItemIdAndStatus( + resolvedItemId, + ObjectItemCommentStatus.APPROVED, + ) + }, + ) } @Transactional(readOnly = true) fun findApproved(): List { - return objectItemCommentRepository.findByStatus(ObjectItemCommentStatus.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): ObjectItemCommentPageVO { + return queryPage( + page = page, + size = size, + query = { pageable -> + objectItemCommentRepository.findByStatus(ObjectItemCommentStatus.APPROVED, pageable) + }, + count = { objectItemCommentRepository.countByStatus(ObjectItemCommentStatus.APPROVED) }, + ) } + 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() + @Transactional fun create(objectItemId: Int, request: ObjectItemCommentSaveRequest): ObjectItemCommentResponse { val resolvedItemId = requirePositiveItemId(objectItemId) - ensureObjectItemExists(resolvedItemId) + objectItemService.ensurePubliclyAvailable(resolvedItemId) val entity = ObjectItemComment().also { it.objectItemId = resolvedItemId @@ -70,18 +159,17 @@ class ObjectItemCommentService( MAX_NICK_NAME_LENGTH, "评论者昵称不能超过 $MAX_NICK_NAME_LENGTH 个字符", ) - it.content = requireText(request.content, "评论内容不能为空") + it.content = requireText( + request.content, + "评论内容不能为空", + MAX_CONTENT_LENGTH, + "评论内容不能超过 $MAX_CONTENT_LENGTH 个字符", + ) it.status = ObjectItemCommentStatus.PENDING } return objectItemCommentRepository.save(entity).toResponse() } - private fun ensureObjectItemExists(objectItemId: Int) { - if (!objectItemRepository.existsById(objectItemId)) { - throw ResourceNotFoundException("项目条目不存在") - } - } - private fun requirePositiveItemId(objectItemId: Int): Int { if (objectItemId <= 0) { throw ParamErrorException("项目条目 ID 必须大于 0") @@ -119,5 +207,8 @@ class ObjectItemCommentService( private companion object { private const val MAX_NICK_NAME_LENGTH = 64 + private const val MAX_CONTENT_LENGTH = 2_000 + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemManagementService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemManagementService.kt index 2e2359a..c857e93 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemManagementService.kt @@ -6,30 +6,58 @@ 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 +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional data class ObjectItemManageVerifyRequest( + @field:NotBlank(message = "项目控制密码不能为空") + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", ) data class ObjectItemManageUpdateRequest( + @field:NotBlank(message = "项目控制密码不能为空") + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", + @field:Size(max = 128, message = "项目标题不能超过 128 个字符") val title: String? = null, + @field:Size(max = 64, message = "项目类型不能超过 64 个字符") val type: String? = null, + @field:Size(max = 255, message = "项目简介不能超过 255 个字符") val introduction: String? = null, + @field:Size(max = 20_000, message = "项目描述不能超过 20000 个字符") val description: String? = null, val status: ObjectItemStatus? = null, + @field:Size(max = 64, message = "项目负责人不能超过 64 个字符") val leader: String? = null, + @field:Size(max = 100, message = "项目招募需求不能超过 100 条") + @field:Valid val needMembers: List? = null, + @field:Size(max = 12, message = "项目标签不能超过 12 个") val tags: List? = null, + @field:Size(max = 64, message = "负责人 Minecraft ID 不能超过 64 个字符") val leaderMcId: String? = null, + @field:Size(max = 255, message = "联系方式不能超过 255 个字符") val contactInformation: String? = null, + @field:Size(max = 512, message = "封面图地址不能超过 512 个字符") + val coverImageUrl: String? = null, + @field:Min(value = 0, message = "项目进度不能小于 0") + @field:Max(value = 100, message = "项目进度不能大于 100") + val progress: Int? = null, ) data class ObjectItemPasswordChangeRequest( + @field:NotBlank(message = "项目控制密码不能为空") + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", + @field:NotBlank(message = "新控制密码不能为空") + @field:Size(min = 6, max = 72, message = "新控制密码长度必须为 6 到 72 个字符") val newControlPassword: String? = null, ) @@ -51,7 +79,8 @@ class ObjectItemManagementService( @Transactional fun update(id: Int, request: ObjectItemManageUpdateRequest): ObjectItemResponse { - loadAndVerify(id, request.controlPassword) + val item = loadAndVerify(id, request.controlPassword) + ensureOwnerEditableStatus(item.status, request.status) return objectItemService.update( id, ObjectItemUpdateRequest( @@ -66,6 +95,8 @@ class ObjectItemManagementService( tags = request.tags, leaderMcId = request.leaderMcId, contactInformation = request.contactInformation, + coverImageUrl = request.coverImageUrl, + progress = request.progress, ), ) } @@ -101,6 +132,9 @@ class ObjectItemManagementService( if (rawPassword.isNullOrBlank() || storedPassword.isNullOrBlank()) { return false } + if (rawPassword.toByteArray(Charsets.UTF_8).size > MAX_BCRYPT_PASSWORD_BYTES) { + return false + } if (storedPassword.startsWith(BCRYPT_PREFIX)) { return passwordEncoder.matches(rawPassword, storedPassword) } @@ -108,14 +142,7 @@ class ObjectItemManagementService( } private fun requireNewPassword(newPassword: String?): String { - val normalized = newPassword?.trim().orEmpty() - if (normalized.isBlank()) { - throw ParamErrorException("新控制密码不能为空") - } - if (normalized.length > MAX_CONTROL_PASSWORD_LENGTH) { - throw ParamErrorException("项目控制密码不能超过 $MAX_CONTROL_PASSWORD_LENGTH 个字符") - } - return normalized + return ProjectControlPasswordPolicy.normalizeRequired(newPassword) } private fun requirePositiveId(id: Int): Int { @@ -125,6 +152,17 @@ class ObjectItemManagementService( return id } + /** 项目方只能在已通过审核的项目上维护运营阶段。 */ + private fun ensureOwnerEditableStatus(currentStatus: ObjectItemStatus?, targetStatus: ObjectItemStatus?) { + if (targetStatus == null) return + if (targetStatus !in OWNER_EDITABLE_STATUSES) { + throw ForbiddenException("项目方只能修改运营状态:筹备中、招募中、进行中或已暂停") + } + if (currentStatus !in OWNER_EDITABLE_SOURCE_STATUSES) { + throw ForbiddenException("项目尚未通过审核,不能进入运营状态") + } + } + /** 常量时间字符串比较,避免明文密码校验时的时序侧信道。 */ private fun constantTimeEquals(a: String, b: String): Boolean { if (a.length != b.length) { @@ -157,6 +195,7 @@ class ObjectItemManagementService( leaderMcId = leaderMcId, contactInformation = contactInformation, coverImageUrl = coverImageUrl, + progress = progress, ownerId = ownerId, hasControlPassword = !controlPassword.isNullOrBlank(), ) @@ -164,6 +203,13 @@ class ObjectItemManagementService( private companion object { private const val BCRYPT_PREFIX = "\$2" - private const val MAX_CONTROL_PASSWORD_LENGTH = 255 + private const val MAX_BCRYPT_PASSWORD_BYTES = 72 + private val OWNER_EDITABLE_STATUSES = setOf( + ObjectItemStatus.PREPARING, + ObjectItemStatus.RECRUITING, + ObjectItemStatus.IN_PROGRESS, + ObjectItemStatus.PAUSED, + ) + private val OWNER_EDITABLE_SOURCE_STATUSES = OWNER_EDITABLE_STATUSES + ObjectItemStatus.APPROVED } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt index fedb13c..90e692e 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemService.kt @@ -3,14 +3,28 @@ package `fun`.utf8.nekoprojectbackend.service import `fun`.utf8.nekoprojectbackend.datasource.jdbc.* import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.PositiveOrZero +import jakarta.validation.constraints.Size import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.* data class NeedMemberItemRequest( + @field:NotBlank(message = "项目招募技能不能为空") + @field:Size(max = 64, message = "项目招募技能不能超过 64 个字符") val skill: String = "", + @field:PositiveOrZero(message = "项目招募人数不能小于 0") val number: Long? = null, + @field:Size(max = 255, message = "项目招募说明不能超过 255 个字符") val context: String? = null, ) @@ -21,58 +35,102 @@ data class NeedMemberItemResponse( ) data class ObjectItemSaveRequest( + @field:NotBlank(message = "项目标题不能为空") + @field:Size(max = 128, message = "项目标题不能超过 128 个字符") val title: String = "", + @field:NotBlank(message = "项目类型不能为空") + @field:Size(max = 64, message = "项目类型不能超过 64 个字符") val type: String = "", + @field:Size(max = 255, message = "项目简介不能超过 255 个字符") val introduction: String? = null, + @field:Size(max = 20_000, message = "项目描述不能超过 20000 个字符") val description: String? = null, // 未指定时为 null:公开投稿由 toEntity() 固化为 PENDING;管理员创建时由 controller 按角色兜底 // (总管理默认 RECRUITING、项目管理强制 PENDING)。 - var status: ObjectItemStatus? = null, + val status: ObjectItemStatus? = null, + @field:Size(max = 64, message = "项目负责人不能超过 64 个字符") val leader: String? = null, + @field:Size(max = 100, message = "项目招募需求不能超过 100 条") + @field:Valid val needMembers: List? = null, + @field:Size(max = 12, message = "项目标签不能超过 12 个") val tags: List? = null, + @field:Size(max = 64, message = "负责人 Minecraft ID 不能超过 64 个字符") val leaderMcId: String? = null, + @field:Size(max = 255, message = "联系方式不能超过 255 个字符") val contactInformation: String? = null, + @field:Size(max = 512, message = "封面图地址不能超过 512 个字符") val coverImageUrl: String? = null, + @field:Min(value = 0, message = "项目进度不能小于 0") + @field:Max(value = 100, message = "项目进度不能大于 100") + val progress: Int? = 0, + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String? = null, ) data class ObjectItemBatchSaveRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") + @field:Valid val items: List = emptyList(), ) data class ObjectItemUpdateRequest( val id: Int? = null, + @field:Size(max = 128, message = "项目标题不能超过 128 个字符") val title: String? = null, + @field:Size(max = 64, message = "项目类型不能超过 64 个字符") val type: String? = null, + @field:Size(max = 255, message = "项目简介不能超过 255 个字符") val introduction: String? = null, + @field:Size(max = 20_000, message = "项目描述不能超过 20000 个字符") val description: String? = null, val status: ObjectItemStatus? = null, + @field:Size(max = 64, message = "项目负责人不能超过 64 个字符") val leader: String? = null, + @field:Size(max = 100, message = "项目招募需求不能超过 100 条") + @field:Valid val needMembers: List? = null, + @field:Size(max = 12, message = "项目标签不能超过 12 个") val tags: List? = null, + @field:Size(max = 64, message = "负责人 Minecraft ID 不能超过 64 个字符") val leaderMcId: String? = null, + @field:Size(max = 255, message = "联系方式不能超过 255 个字符") val contactInformation: String? = null, + @field:Size(max = 512, message = "封面图地址不能超过 512 个字符") val coverImageUrl: String? = null, + @field:Min(value = 0, message = "项目进度不能小于 0") + @field:Max(value = 100, message = "项目进度不能大于 100") + val progress: Int? = null, + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String? = null, ) data class ObjectItemBatchUpdateRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") + @field:Valid val items: List = emptyList(), ) data class ObjectItemBatchDeleteRequest( + @field:Size(min = 1, max = 100, message = "批量操作数量必须为 1 到 100 条") val ids: List = emptyList(), ) data class ObjectItemQueryRequest( + @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 type: String? = null, val status: ObjectItemStatus? = null, + @field:Size(max = 8, message = "项目状态查询不能超过 8 个") val statuses: List? = null, + @field:Size(max = 64, message = "项目负责人查询不能超过 64 个字符") val leader: String? = null, + @field:Size(max = 64, message = "Minecraft ID 查询不能超过 64 个字符") val leaderMcId: String? = null, + @field:Size(max = 12, message = "项目标签查询不能超过 12 个") val tags: List? = null, val ownerId: Long? = null, ) @@ -90,6 +148,7 @@ data class ObjectItemResponse( val leaderMcId: String?, val contactInformation: String?, val coverImageUrl: String?, + val progress: Int, val ownerId: Long?, val hasControlPassword: Boolean, ) @@ -128,13 +187,12 @@ enum class SortDirection { class ObjectItemService( private val objectItemRepository: ObjectItemRepository, private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, @Value("\${neko.project.max-per-manager:10}") private val maxPerManager: Long, ) { @Transactional fun save(request: ObjectItemSaveRequest): ObjectItemResponse { - // 注意:toEntity() 会把 status 固化为 PENDING,此处的 RECRUITING 赋值实际不生效(疑似遗留,建议确认)。 - request.status = ObjectItemStatus.RECRUITING val entity = request.toEntity() return objectItemRepository.save(entity).toResponse() } @@ -152,90 +210,214 @@ class ObjectItemService( return item.toResponse() } + /** 公开详情只返回已经进入公开运营状态的项目;其他状态统一按不存在处理。 */ + @Transactional(readOnly = true) + fun findPublicById(id: Int): ObjectItemResponse { + val item = findObjectItem(id) + ensurePublicStatus(item) + return item.toResponse() + } + + /** 供评论、动态和加入申请等公开子资源复用同一项目可见性判定。 */ + @Transactional(readOnly = true) + fun ensurePubliclyAvailable(id: Int) { + ensurePublicStatus(findObjectItem(id)) + } + @Transactional(readOnly = true) fun query(request: ObjectItemQueryRequest): List { - return filterObjectItems(request) - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + val result = objectItemRepository.findAll( + specification(request), + PageRequest.of(0, MAX_UNPAGED_RESULTS, Sort.by(Sort.Direction.ASC, "id")), + ) + if (result.totalElements > MAX_UNPAGED_RESULTS) { + throw ParamErrorException("匹配项目超过 $MAX_UNPAGED_RESULTS 条,请使用分页查询") + } + return result.content.map { it.toResponse() } + } + + @Transactional(readOnly = true) + fun queryPublic(request: ObjectItemQueryRequest): List { + val publicRequest = publicQueryRequest(request) ?: return emptyList() + return query(publicRequest) } @Transactional(readOnly = true) fun queryPage(request: ObjectItemQueryRequest, page: Int, size: Int, sort: String): ObjectItemPageVO { - if (page < 0) { - throw ParamErrorException("页码不能小于 0") - } - if (size <= 0) { - throw ParamErrorException("每页条数必须大于 0") - } - if (size > MAX_PAGE_SIZE) { - throw ParamErrorException("每页条数不能超过 $MAX_PAGE_SIZE 条") - } + validatePageRequest(page, size) val (property, direction) = parseSort(sort) - - val sorted = filterObjectItems(request).let { all -> - val comparator: Comparator = when (property) { - ObjectItemSortProperty.ID -> compareBy { it.id ?: Int.MAX_VALUE } - } - if (direction == SortDirection.DESC) all.sortedWith(comparator.reversed()) else all.sortedWith(comparator) + val springDirection = if (direction == SortDirection.DESC) Sort.Direction.DESC else Sort.Direction.ASC + val specification = specification(request) + if (page.toLong() * size > Int.MAX_VALUE) { + val totalElements = objectItemRepository.count(specification) + return ObjectItemPageVO( + content = emptyList(), + totalElements = totalElements, + totalPages = totalPages(totalElements, size), + page = page, + size = size, + ) } - - 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 result = objectItemRepository.findAll( + specification, + PageRequest.of(page, size, Sort.by(springDirection, property.alias)), + ) return ObjectItemPageVO( - content = pageContent, - totalElements = total, - totalPages = totalPages, + content = result.content.map { it.toResponse() }, + totalElements = result.totalElements, + totalPages = result.totalPages, page = page, size = size, ) } - private fun filterObjectItems(request: ObjectItemQueryRequest): List { + @Transactional(readOnly = true) + fun queryPublicPage( + request: ObjectItemQueryRequest, + page: Int, + size: Int, + sort: String, + ): ObjectItemPageVO { + val publicRequest = publicQueryRequest(request) + if (publicRequest == null) { + validatePageRequest(page, size) + parseSort(sort) + return ObjectItemPageVO( + content = emptyList(), + totalElements = 0, + totalPages = 0, + page = page, + size = size, + ) + } + return queryPage(publicRequest, page, size, sort) + } + + private fun specification(request: ObjectItemQueryRequest): Specification { val ids = normalizeIds(request.ids) - val normalizedTitle = normalizeNullableText(request.title) - val normalizedType = normalizeNullableText(request.type) - val normalizedLeader = normalizeNullableText(request.leader) - val normalizedLeaderMcId = normalizeNullableText(request.leaderMcId) + val normalizedTitle = normalizeNullableText( + request.title, + MAX_TITLE_LENGTH, + "项目标题查询不能超过 $MAX_TITLE_LENGTH 个字符", + ) + val normalizedType = normalizeNullableText( + request.type, + MAX_TYPE_LENGTH, + "项目类型查询不能超过 $MAX_TYPE_LENGTH 个字符", + ) + val normalizedLeader = normalizeNullableText( + request.leader, + MAX_LEADER_LENGTH, + "项目负责人查询不能超过 $MAX_LEADER_LENGTH 个字符", + ) + val normalizedLeaderMcId = normalizeNullableText( + request.leaderMcId, + MAX_LEADER_MC_ID_LENGTH, + "Minecraft ID 查询不能超过 $MAX_LEADER_MC_ID_LENGTH 个字符", + ) val normalizedTags = cleanTags(request.tags) val requestedStatuses = normalizeStatuses(request.status, request.statuses) - val filterByStatus = requestedStatuses.isNotEmpty() + if (request.ownerId != null && request.ownerId <= 0) { + throw ParamErrorException("项目归属用户 ID 必须大于 0") + } - val source = if (ids.isNullOrEmpty()) { - objectItemRepository.findAll() - } else { - objectItemRepository.findAllById(ids).toList() - } - - return source.asSequence() - .filter { normalizedTitle == null || it.title?.contains(normalizedTitle, ignoreCase = true) == true } - .filter { normalizedType == null || it.type?.equals(normalizedType, ignoreCase = true) == true } - .filter { !filterByStatus || it.status in requestedStatuses } - .filter { normalizedLeader == null || it.leader?.contains(normalizedLeader, ignoreCase = true) == true } - .filter { - normalizedLeaderMcId == null || it.leaderMcId?.equals( - normalizedLeaderMcId, - ignoreCase = true - ) == true + return Specification { root, query, criteriaBuilder -> + val predicates = mutableListOf() + ids?.takeIf { it.isNotEmpty() }?.let { + predicates += root.get("id").`in`(it) } - .filter { normalizedTags.isEmpty() || it.containsAllTags(normalizedTags) } - .filter { request.ownerId == null || it.ownerId == request.ownerId } - .toList() + normalizedTitle?.let { + predicates += criteriaBuilder.like( + criteriaBuilder.lower(root.get("title")), + containsPattern(it), + LIKE_ESCAPE, + ) + } + normalizedType?.let { + predicates += criteriaBuilder.equal(criteriaBuilder.lower(root.get("type")), it.lowercase()) + } + requestedStatuses.takeIf { it.isNotEmpty() }?.let { + predicates += root.get("status").`in`(it) + } + normalizedLeader?.let { + predicates += criteriaBuilder.like( + criteriaBuilder.lower(root.get("leader")), + containsPattern(it), + LIKE_ESCAPE, + ) + } + normalizedLeaderMcId?.let { + predicates += criteriaBuilder.equal( + criteriaBuilder.lower(root.get("leaderMcId")), + it.lowercase(), + ) + } + normalizedTags.forEach { tag -> + val subquery = query.subquery(Int::class.java) + val tagRoot = subquery.from(ObjectItem::class.java) + val tagJoin = tagRoot.join("tags") + subquery.select(tagRoot.get("id")) + .where( + criteriaBuilder.equal(tagRoot.get("id"), root.get("id")), + criteriaBuilder.equal(criteriaBuilder.lower(tagJoin), tag.lowercase()), + ) + predicates += criteriaBuilder.exists(subquery) + } + request.ownerId?.let { + predicates += criteriaBuilder.equal(root.get("ownerId"), it) + } + criteriaBuilder.and(*predicates.toTypedArray()) + } } private fun normalizeStatuses(status: ObjectItemStatus?, statuses: List?): Set { + if (statuses != null && statuses.size > ObjectItemStatus.entries.size) { + throw ParamErrorException("项目状态查询不能超过 ${ObjectItemStatus.entries.size} 个") + } return (listOfNotNull(status) + statuses.orEmpty()).toSet() } + private fun publicQueryRequest(request: ObjectItemQueryRequest): ObjectItemQueryRequest? { + val requestedStatuses = normalizeStatuses(request.status, request.statuses) + val effectiveStatuses = if (requestedStatuses.isEmpty()) { + PUBLIC_STATUSES + } else { + requestedStatuses.intersect(PUBLIC_STATUSES) + } + if (effectiveStatuses.isEmpty()) { + return null + } + return request.copy(status = null, statuses = effectiveStatuses.toList()) + } + + 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 parseSort(sort: String): Pair { val parts = sort.split(",").map { it.trim() }.filter { it.isNotEmpty() } if (parts.isEmpty()) { return ObjectItemSortProperty.ID to SortDirection.DESC } + if (parts.size > 2) { + throw ParamErrorException("排序参数格式错误,应为 字段,方向") + } val property = ObjectItemSortProperty.from(parts[0]) ?: throw ParamErrorException("不支持的排序字段:${parts[0]},支持 id") val direction = if (parts.size > 1) { @@ -249,11 +431,15 @@ class ObjectItemService( @Transactional(readOnly = true) fun findByStatus(status: ObjectItemStatus): List { - return objectItemRepository.findByStatus(status) - .asSequence() - .sortedBy { it.id ?: Int.MAX_VALUE } - .map { it.toResponse() } - .toList() + return query(ObjectItemQueryRequest(status = status)) + } + + @Transactional(readOnly = true) + fun findPublicByStatus(status: ObjectItemStatus): List { + if (status !in PUBLIC_STATUSES) { + return emptyList() + } + return findByStatus(status) } @Transactional(readOnly = true) @@ -261,6 +447,11 @@ class ObjectItemService( return objectItemRepository.countByStatus(ObjectItemStatus.IN_PROGRESS) } + @Transactional(readOnly = true) + fun countPublic(): Long { + return objectItemRepository.countByStatusIn(PUBLIC_STATUSES) + } + @Transactional fun update(id: Int, request: ObjectItemUpdateRequest): ObjectItemResponse { val objectItem = findObjectItem(id) @@ -315,11 +506,17 @@ class ObjectItemService( fun assignOwner(id: Int, ownerId: Long?): ObjectItemResponse { val item = findObjectItem(id) if (ownerId != null) { - userRepository.findById(ownerId) + if (ownerId <= 0) { + throw ParamErrorException("项目归属用户 ID 必须大于 0") + } + val owner = userRepository.findById(ownerId) .orElseThrow { ResourceNotFoundException("用户不存在") } + if (owner.status != Status.ACTIVE || owner.role !in ASSIGNABLE_OWNER_ROLES) { + throw ParamErrorException("项目只能分配给状态正常的项目管理或总管理账号") + } // 重新分配给同一人不重复计入上限 val alreadyOwned = item.ownerId == ownerId - if (!alreadyOwned && objectItemRepository.countByOwnerId(ownerId) >= maxPerManager) { + if (!alreadyOwned && countOwnedProjects(ownerId) >= maxPerManager) { throw ParamErrorException("该用户名下项目已达上限 $maxPerManager") } } @@ -333,15 +530,18 @@ class ObjectItemService( */ @Transactional fun saveOwned(request: ObjectItemSaveRequest, ownerId: Long, status: ObjectItemStatus): ObjectItemResponse { - if (objectItemRepository.countByOwnerId(ownerId) >= maxPerManager) { + if (countOwnedProjects(ownerId) >= maxPerManager) { throw ParamErrorException("名下项目已达上限 $maxPerManager") } val entity = request.toEntity() - entity.status = status + entity.status = normalizeStatus(status) entity.ownerId = ownerId return objectItemRepository.save(entity).toResponse() } + private fun countOwnedProjects(ownerId: Long): Long = + objectItemRepository.countByOwnerIdAndStatusNot(ownerId, ObjectItemStatus.DELETED) + private fun ObjectItemSaveRequest.toEntity(): ObjectItem { return ObjectItem().also { it.title = @@ -352,7 +552,11 @@ class ObjectItemService( MAX_INTRODUCTION_LENGTH, "项目简介不能超过 $MAX_INTRODUCTION_LENGTH 个字符" ) - it.description = normalizeNullableText(description) + it.description = normalizeNullableText( + description, + MAX_DESCRIPTION_LENGTH, + "项目描述不能超过 $MAX_DESCRIPTION_LENGTH 个字符", + ) it.status = ObjectItemStatus.PENDING it.leader = normalizeNullableText(leader, MAX_LEADER_LENGTH, "项目负责人不能超过 $MAX_LEADER_LENGTH 个字符") it.needMembers = cleanNeedMembers(needMembers).toMutableList() @@ -367,16 +571,13 @@ 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 个字符", - ) - it.controlPassword = normalizeNullableText( - controlPassword, - MAX_CONTROL_PASSWORD_LENGTH, - "项目控制密码不能超过 $MAX_CONTROL_PASSWORD_LENGTH 个字符" + "封面图地址", ) + it.progress = normalizeProgress(progress) + it.controlPassword = encodeControlPassword(controlPassword) } } @@ -391,8 +592,14 @@ class ObjectItemService( introduction = normalizeNullableText(it, MAX_INTRODUCTION_LENGTH, "项目简介不能超过 $MAX_INTRODUCTION_LENGTH 个字符") } - request.description?.let { description = normalizeNullableText(it) } - request.status?.let { status = it } + request.description?.let { + description = normalizeNullableText( + it, + MAX_DESCRIPTION_LENGTH, + "项目描述不能超过 $MAX_DESCRIPTION_LENGTH 个字符", + ) + } + request.status?.let { status = normalizeStatus(it) } request.leader?.let { leader = normalizeNullableText(it, MAX_LEADER_LENGTH, "项目负责人不能超过 $MAX_LEADER_LENGTH 个字符") } @@ -413,18 +620,26 @@ class ObjectItemService( ) } request.coverImageUrl?.let { - coverImageUrl = normalizeNullableText( + coverImageUrl = ImageUrlPolicy.normalize( it, MAX_COVER_IMAGE_URL_LENGTH, - "封面图地址不能超过 $MAX_COVER_IMAGE_URL_LENGTH 个字符" + "封面图地址", ) } + request.progress?.let { progress = normalizeProgress(it) } request.controlPassword?.let { - controlPassword = normalizeNullableText( - it, - MAX_CONTROL_PASSWORD_LENGTH, - "项目控制密码不能超过 $MAX_CONTROL_PASSWORD_LENGTH 个字符" - ) + controlPassword = encodeControlPassword(it) + } + } + + private fun encodeControlPassword(value: String?): String? { + val normalized = ProjectControlPasswordPolicy.normalizeOptional(value) + return normalized?.let(passwordEncoder::encode) + } + + private fun ensurePublicStatus(item: ObjectItem) { + if (item.status !in PUBLIC_STATUSES) { + throw ResourceNotFoundException("项目条目不存在") } } @@ -444,6 +659,9 @@ class ObjectItemService( 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() } @@ -486,6 +704,17 @@ class ObjectItemService( return normalized } + private fun normalizeProgress(value: Int?): Int { + val progress = value ?: 0 + if (progress !in 0..100) { + throw ParamErrorException("项目进度必须在 0 到 100 之间") + } + return progress + } + + private fun normalizeStatus(value: ObjectItemStatus): ObjectItemStatus = + if (value == ObjectItemStatus.APPROVED) ObjectItemStatus.PREPARING else value + private fun cleanNeedMembers(items: List?): List { val normalizedItems = items.orEmpty() if (normalizedItems.size > MAX_NEED_MEMBER_SIZE) { @@ -516,16 +745,21 @@ class ObjectItemService( } private fun cleanTags(tags: List?): List { - return tags.orEmpty() + val source = tags.orEmpty() + if (source.size > MAX_TAG_COUNT) { + throw ParamErrorException("项目标签不能超过 $MAX_TAG_COUNT 个") + } + return source .mapNotNull { normalizeNullableText(it, MAX_TAG_LENGTH, "项目标签不能超过 $MAX_TAG_LENGTH 个字符") } .distinctBy { it.lowercase(Locale.ROOT) } } - private fun ObjectItem.containsAllTags(requiredTags: List): Boolean { - val existingTags = tags.orEmpty() - return requiredTags.all { requiredTag -> - existingTags.any { it.equals(requiredTag, ignoreCase = true) } - } + private fun containsPattern(value: String): String { + val escaped = value.lowercase(Locale.ROOT) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + return "%$escaped%" } private fun ObjectItem.toResponse(): ObjectItemResponse { @@ -542,6 +776,7 @@ class ObjectItemService( leaderMcId = leaderMcId, contactInformation = contactInformation, coverImageUrl = coverImageUrl, + progress = progress, ownerId = ownerId, hasControlPassword = !controlPassword.isNullOrBlank(), ) @@ -556,10 +791,20 @@ class ObjectItemService( } private companion object { + private val PUBLIC_STATUSES = setOf( + ObjectItemStatus.PREPARING, + ObjectItemStatus.RECRUITING, + ObjectItemStatus.IN_PROGRESS, + ObjectItemStatus.PAUSED, + // Keep legacy approved rows visible until they are migrated. + ObjectItemStatus.APPROVED, + ) + private val ASSIGNABLE_OWNER_ROLES = setOf(Role.PROJECT_MANAGER, Role.SUPER_ADMIN) private const val MAX_BATCH_SIZE = 100 private const val MAX_TITLE_LENGTH = 128 private const val MAX_TYPE_LENGTH = 64 private const val MAX_INTRODUCTION_LENGTH = 255 + private const val MAX_DESCRIPTION_LENGTH = 20_000 private const val MAX_LEADER_LENGTH = 64 private const val MAX_NEED_MEMBER_SIZE = 100 private const val MAX_NEED_MEMBER_SKILL_LENGTH = 64 @@ -567,8 +812,11 @@ class ObjectItemService( private const val MAX_LEADER_MC_ID_LENGTH = 64 private const val MAX_CONTACT_INFORMATION_LENGTH = 255 private const val MAX_COVER_IMAGE_URL_LENGTH = 512 - private const val MAX_CONTROL_PASSWORD_LENGTH = 255 private const val MAX_TAG_LENGTH = 32 - private const val MAX_PAGE_SIZE = 1024 + private const val MAX_TAG_COUNT = 12 + 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/ObjectItemUpdateManagementService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt index 3f54e09..720f313 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateManagementService.kt @@ -3,23 +3,40 @@ package `fun`.utf8.nekoprojectbackend.service 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.ForbiddenException import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +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 ObjectItemUpdateManageCreateRequest( + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", + @field:NotBlank(message = "动态标题不能为空") + @field:Size(max = 128, message = "动态标题不能超过 128 个字符") val title: String = "", + @field:NotBlank(message = "动态内容不能为空") + @field:Size(max = 10_000, message = "动态内容不能超过 10000 个字符") val content: String = "", + @field:Size(max = 512, message = "动态图片 URL 不能超过 512 个字符") val imageUrl: String? = null, val status: ObjectItemUpdateStatus? = ObjectItemUpdateStatus.PENDING, ) data class ObjectItemUpdateManageUpdateRequest( + @field:Size(max = 72, message = "项目控制密码不能超过 72 个字符") val controlPassword: String = "", + @field:Size(max = 128, message = "动态标题不能超过 128 个字符") val title: String? = null, + @field:Size(max = 10_000, message = "动态内容不能超过 10000 个字符") val content: String? = null, + @field:Size(max = 512, message = "动态图片 URL 不能超过 512 个字符") val imageUrl: String? = null, val status: ObjectItemUpdateStatus? = null, ) @@ -47,15 +64,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 @@ -64,7 +147,7 @@ class ObjectItemUpdateManagementService( request: ObjectItemUpdateManageCreateRequest, ): ObjectItemUpdateResponse { verifyProject(objectItemId, request.toVerifyRequest()) - return createByAdmin(objectItemId, request) + return createByAdmin(objectItemId, request.copy(status = ObjectItemUpdateStatus.APPROVED)) } /** 管理员发布项目动态:JWT 鉴权,无需项目控制密码。 */ @@ -81,13 +164,18 @@ class ObjectItemUpdateManagementService( MAX_TITLE_LENGTH, "动态标题不能超过 $MAX_TITLE_LENGTH 个字符", ) - it.content = requireText(request.content, "动态内容不能为空") - it.imageUrl = normalizeNullableText( + it.content = requireText( + request.content, + "动态内容不能为空", + MAX_CONTENT_LENGTH, + "动态内容不能超过 $MAX_CONTENT_LENGTH 个字符", + ) + it.imageUrl = ImageUrlPolicy.normalize( request.imageUrl, MAX_IMAGE_URL_LENGTH, - "动态图片 URL 不能超过 $MAX_IMAGE_URL_LENGTH 个字符", + "动态图片 URL", ) - it.status = request.status ?: ObjectItemUpdateStatus.PENDING + it.status = ObjectItemUpdateStatus.APPROVED } return objectItemUpdateRepository.save(entity).toResponse() } @@ -99,6 +187,9 @@ class ObjectItemUpdateManagementService( request: ObjectItemUpdateManageUpdateRequest, ): ObjectItemUpdateResponse { verifyProject(objectItemId, request.toVerifyRequest()) + if (request.status != null) { + throw ForbiddenException("项目方不能修改动态审核状态") + } return updateByAdmin(objectItemId, updateId, request) } @@ -109,6 +200,9 @@ class ObjectItemUpdateManagementService( updateId: Int, request: ObjectItemUpdateManageUpdateRequest, ): ObjectItemUpdateResponse { + if (request.status != null) { + throw ParamErrorException("动态审核状态请使用专用审核接口修改") + } val update = loadUpdate(updateId, objectItemId) applyUpdateFields(update, request) return objectItemUpdateRepository.save(update).toResponse() @@ -120,6 +214,7 @@ class ObjectItemUpdateManagementService( updateId: Int, status: ObjectItemUpdateStatus, ): ObjectItemUpdateResponse { + ensureModerationStatus(status) val update = loadUpdate(updateId, objectItemId) update.status = status return objectItemUpdateRepository.save(update).toResponse() @@ -156,21 +251,33 @@ class ObjectItemUpdateManagementService( "动态标题不能超过 $MAX_TITLE_LENGTH 个字符", ) } - request.content?.let { update.content = requireText(it, "动态内容不能为空") } + request.content?.let { + update.content = requireText( + it, + "动态内容不能为空", + MAX_CONTENT_LENGTH, + "动态内容不能超过 $MAX_CONTENT_LENGTH 个字符", + ) + } request.imageUrl?.let { - update.imageUrl = normalizeNullableText( + update.imageUrl = ImageUrlPolicy.normalize( it, MAX_IMAGE_URL_LENGTH, - "动态图片 URL 不能超过 $MAX_IMAGE_URL_LENGTH 个字符", + "动态图片 URL", ) } - request.status?.let { update.status = it } } private fun verifyProject(objectItemId: Int, request: ObjectItemManageVerifyRequest) { objectItemManagementService.verify(objectItemId, request) } + private fun ensureModerationStatus(status: ObjectItemUpdateStatus) { + if (status !in MODERATION_STATUSES) { + throw ParamErrorException("动态审核状态只能是 APPROVED、REJECTED 或 DELETED") + } + } + private fun loadUpdate(updateId: Int, objectItemId: Int): ObjectItemUpdate { if (updateId <= 0) { throw ParamErrorException("项目动态 ID 必须大于 0") @@ -210,18 +317,6 @@ class ObjectItemUpdateManagementService( return normalized } - private fun normalizeNullableText(value: String?): String? { - return value?.trim()?.ifBlank { null } - } - - private fun normalizeNullableText(value: String?, maxLength: Int, tooLongMessage: String): String? { - val normalized = normalizeNullableText(value) - if (normalized != null && normalized.length > maxLength) { - throw ParamErrorException(tooLongMessage) - } - return normalized - } - private fun ObjectItemUpdate.toResponse(): ObjectItemUpdateResponse { return ObjectItemUpdateResponse( id = id, @@ -237,6 +332,14 @@ class ObjectItemUpdateManagementService( private companion object { private const val MAX_TITLE_LENGTH = 128 + private const val MAX_CONTENT_LENGTH = 10_000 private const val MAX_IMAGE_URL_LENGTH = 512 + private const val MAX_UNPAGED_RESULTS = 500 + private const val MAX_PAGE_SIZE = 500 + private val MODERATION_STATUSES = setOf( + ObjectItemUpdateStatus.APPROVED, + ObjectItemUpdateStatus.REJECTED, + ObjectItemUpdateStatus.DELETED, + ) } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/ObjectItemUpdateService.kt index 6ab39f7..2dcac3d 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,117 @@ 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): 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 +153,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 5ae1d34..343d84b 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/OperationLogService.kt @@ -9,6 +9,7 @@ import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Service import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.ServletRequestAttributes +import tools.jackson.databind.ObjectMapper import java.io.File import java.nio.charset.StandardCharsets import java.nio.file.Files @@ -33,6 +34,7 @@ import java.util.concurrent.TimeUnit */ @Service class OperationLogService( + private val objectMapper: ObjectMapper, @Value("\${neko.audit.log-path:./logs/operation.log}") private val logPath: String, @Value("\${neko.audit.max-size-mb:50}") private val maxSizeMb: Long, @Value("\${neko.audit.max-archives:30}") private val maxArchives: Int, @@ -78,7 +80,12 @@ class OperationLogService( // 上下文必须在请求线程同步抓取(异步线程上 ThreadLocal 已失效) val (opId, opName, opRole) = resolveOperator(operatorId, operatorName, operatorRole) val ip = currentIp() - val line = buildJson(opId, opName, opRole, action, targetType, targetId, description, ip, success, error) + val line = runCatching { + buildJson(opId, opName, opRole, action, targetType, targetId, description, ip, success, error) + }.getOrElse { + appLog.error("序列化操作日志失败: ${it.message}", it) + return + } writer.execute { runCatching { appendLine(line) } .onFailure { appLog.error("写入操作日志失败: ${it.message}", it) } @@ -167,43 +174,20 @@ class OperationLogService( action: String, targetType: String?, targetId: Any?, description: String, ip: String?, success: Boolean, error: String?, ): String { - val sb = StringBuilder(256) - sb.append("{\"time\":\"").append(escape(Instant.now().toString())).append('"') - sb.raw("operatorId", operatorId) - sb.str("operatorName", operatorName) - sb.str("operatorRole", operatorRole) - sb.str("action", action) - sb.str("targetType", targetType) - sb.str("targetId", targetId?.toString()) - sb.str("description", description) - sb.str("ip", ip) - sb.bool("success", success) - sb.str("error", error) - sb.append('}') - return sb.toString() - } - - private fun StringBuilder.str(key: String, value: String?) { - append(',').append('"').append(key).append("\":") - if (value == null) { - append("null") - } else { - append('"').append(escape(value)).append('"') - } - } - - private fun StringBuilder.raw(key: String, value: Any?) { - append(',').append('"').append(key).append("\":").append(value?.toString() ?: "null") - } - - private fun StringBuilder.bool(key: String, value: Boolean) { - append(',').append('"').append(key).append("\":").append(value) + return objectMapper.writeValueAsString( + linkedMapOf( + "time" to Instant.now().toString(), + "operatorId" to operatorId, + "operatorName" to operatorName, + "operatorRole" to operatorRole, + "action" to action, + "targetType" to targetType, + "targetId" to targetId?.toString(), + "description" to description, + "ip" to ip, + "success" to success, + "error" to error, + ), + ) } - - private fun escape(s: String): String = s - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/PasswordPolicy.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/PasswordPolicy.kt new file mode 100644 index 0000000..400fb9b --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/PasswordPolicy.kt @@ -0,0 +1,55 @@ +package `fun`.utf8.nekoprojectbackend.service + +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException + +/** Password rules shared by account creation, password change and reset. */ +object PasswordPolicy { + fun validate(password: String, username: String? = null, email: String? = null) { + if (password.length < MIN_LENGTH) { + throw ParamErrorException("密码至少需要 $MIN_LENGTH 位") + } + if (password.toByteArray(Charsets.UTF_8).size > MAX_BCRYPT_BYTES) { + throw ParamErrorException("密码的 UTF-8 长度不能超过 $MAX_BCRYPT_BYTES 字节") + } + + val classes = listOf(LOWER, UPPER, DIGIT, SPECIAL).count { it.containsMatchIn(password) } + if (classes < MIN_CHARACTER_CLASSES) { + throw ParamErrorException("密码需包含大写字母、小写字母、数字、特殊字符中的至少三类") + } + + val lowered = password.lowercase() + if (lowered in WEAK_PASSWORDS) { + throw ParamErrorException("密码过于常见,请更换") + } + username?.trim()?.takeIf { it.length >= 3 }?.let { + if (password.contains(it, ignoreCase = true)) { + throw ParamErrorException("密码不能包含用户名") + } + } + email?.substringBefore('@')?.trim()?.takeIf { it.length >= 3 }?.let { + if (password.contains(it, ignoreCase = true)) { + throw ParamErrorException("密码不能包含邮箱前缀") + } + } + } + + private const val MIN_LENGTH = 8 + private const val MAX_BCRYPT_BYTES = 72 + private const val MIN_CHARACTER_CLASSES = 3 + private val LOWER = Regex("[a-z]") + private val UPPER = Regex("[A-Z]") + private val DIGIT = Regex("\\d") + private val SPECIAL = Regex("[^A-Za-z0-9]") + private val WEAK_PASSWORDS = setOf( + "12345678", + "123456789", + "password", + "password1", + "qwerty123", + "abc12345", + "admin123", + "11111111", + "00000000", + "nekobox123", + ) +} 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 new file mode 100644 index 0000000..ca93bda --- /dev/null +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/RateLimiter.kt @@ -0,0 +1,100 @@ +package `fun`.utf8.nekoprojectbackend.service + +import `fun`.utf8.nekoprojectbackend.handlder.TooManyRequestsException +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.DefaultRedisScript +import org.springframework.stereotype.Service +import java.security.MessageDigest +import java.time.Duration + +/** 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, +) { + 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) { + if (!enabled || identity.isBlank()) return + val key = lockKey(namespace, identity) + val ttl = redis.getExpire(key) + if (ttl != null && ttl > 0) { + throw TooManyRequestsException("操作过于频繁,请 $ttl 秒后重试") + } + } + + /** 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)) + } + + 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 { + "操作过于频繁,请稍后重试" + } + } + + 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 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 049f5b4..bad1a69 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/TokenStore.kt @@ -20,7 +20,9 @@ class TokenStore( fun saveAccess(jti: String, userId: Long, ttl: Duration) { redis.opsForValue().set(accessKey(jti), userId.toString(), ttl) - redis.opsForSet().add(sessionIndexKey(userId), jti) + val indexKey = sessionIndexKey(userId) + redis.opsForSet().add(indexKey, jti) + extendIndexTtl(indexKey, ttl) } fun isAccessValid(jti: String): Boolean = redis.hasKey(accessKey(jti)) @@ -47,7 +49,9 @@ class TokenStore( fun saveRefresh(jti: String, userId: Long, ttl: Duration) { redis.opsForValue().set(refreshKey(jti), userId.toString(), ttl) // 建立用户→refresh 索引,供改密码 / 找回密码时批量吊销该用户全部刷新令牌 - redis.opsForSet().add(refreshIndexKey(userId), jti) + val indexKey = refreshIndexKey(userId) + redis.opsForSet().add(indexKey, jti) + extendIndexTtl(indexKey, ttl) } /** 一次性消费刷新令牌:原子地取出并删除;不存在返回 null。顺带从用户索引移除,防集合膨胀。 */ @@ -64,6 +68,15 @@ class TokenStore( redis.opsForSet().remove(refreshIndexKey(userId), jti) } + /** 索引至少存活到其中最长令牌过期,避免无 TTL 集合在 Redis 中永久累积。 */ + private fun extendIndexTtl(key: String, ttl: Duration) { + val requestedSeconds = ttl.seconds.coerceAtLeast(1L) + val remainingSeconds = redis.getExpire(key) + if (remainingSeconds < requestedSeconds) { + redis.expire(key, Duration.ofSeconds(requestedSeconds)) + } + } + private fun accessKey(jti: String) = "auth:token:$jti" private fun refreshKey(jti: String) = "auth:refresh:$jti" private fun sessionIndexKey(userId: Long) = "auth:user:$userId:sessions" diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt index e8a0d30..f69dcda 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/UserService.kt @@ -4,6 +4,7 @@ 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.datasource.jdbc.UserRepository +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException import `fun`.utf8.nekoprojectbackend.handlder.UserAlreadyExistsException import jakarta.transaction.Transactional import org.springframework.security.crypto.password.PasswordEncoder @@ -17,20 +18,21 @@ class UserService( ) { fun findByUsername(username: String): User? { - return userRepository.findByUsername(username) + return userRepository.findByUsername(username.trim()) } fun findByEmail(email: String): User? { - return userRepository.findByEmail(email) + return userRepository.findByEmailIgnoreCase(normalizeEmail(email)) } fun findByRole(role: Role): List { return userRepository.findByRole(role) } - /** 可归属项目的全部账号:项目管理 + 总管理(总管理也可创建并管理自有项目)。 */ + /** 可归属项目的正常账号:项目管理 + 总管理(总管理也可创建并管理自有项目)。 */ fun findAssignableOwners(): List { - return userRepository.findByRoleIn(listOf(Role.PROJECT_MANAGER, Role.SUPER_ADMIN)) + return userRepository.findByRoleInAndStatus(ASSIGNABLE_ROLES, Status.ACTIVE) + .sortedBy { it.username.lowercase() } } /** 批量取用户名(供邀请码历史等场景把用户 ID 解析为可读名称)。 */ @@ -57,20 +59,22 @@ class UserService( role: Role = Role.PROJECT_MANAGER, ): User { val normalizedUsername = username.trim() - val normalizedEmail = email.trim() + val normalizedEmail = normalizeEmail(email) + + validateUsername(normalizedUsername) + validatePassword(password, normalizedUsername, normalizedEmail) if (userRepository.findByUsername(normalizedUsername) != null) { throw UserAlreadyExistsException("用户名已存在") } - if (userRepository.findByEmail(normalizedEmail) != null) { + if (userRepository.findByEmailIgnoreCase(normalizedEmail) != null) { throw UserAlreadyExistsException("邮箱已存在") } return userRepository.save( User( username = normalizedUsername, - password = passwordEncoder.encode(password) - ?: throw IllegalStateException("Password encoding failed."), + password = encodePassword(password), email = normalizedEmail, nickname = normalizedUsername, status = Status.ACTIVE, @@ -78,4 +82,51 @@ class UserService( ) ) } + + fun normalizeEmail(email: String): String { + val normalized = email.trim().lowercase() + if (normalized.isBlank()) { + throw ParamErrorException("邮箱不能为空") + } + if (normalized.length > MAX_EMAIL_LENGTH || !EMAIL_PATTERN.matches(normalized)) { + throw ParamErrorException("邮箱格式不正确") + } + return normalized + } + + fun validatePassword(password: String, username: String? = null, email: String? = null) = + PasswordPolicy.validate(password, username, email) + + @Transactional + fun updatePassword(user: User, password: String): User { + validatePassword(password, user.username, user.email) + user.password = passwordEncoder.encode(password) + ?: throw IllegalStateException("Password encoding failed.") + return userRepository.save(user) + } + + private fun validateUsername(username: String) { + if (username.isBlank()) { + throw ParamErrorException("用户名不能为空") + } + if (username.length > MAX_USERNAME_LENGTH) { + throw ParamErrorException("用户名不能超过 $MAX_USERNAME_LENGTH 个字符") + } + if (username.any { it.isWhitespace() || it.isISOControl() }) { + throw ParamErrorException("用户名不能包含空白或控制字符") + } + } + + private fun encodePassword(password: String): String { + validatePassword(password) + return passwordEncoder.encode(password) + ?: throw IllegalStateException("Password encoding failed.") + } + + private companion object { + val ASSIGNABLE_ROLES = listOf(Role.PROJECT_MANAGER, Role.SUPER_ADMIN) + const val MAX_USERNAME_LENGTH = 64 + const val MAX_EMAIL_LENGTH = 128 + val EMAIL_PATTERN = Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$") + } } diff --git a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/VerificationCodeService.kt b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/VerificationCodeService.kt index c69ef41..2f544ad 100644 --- a/src/main/kotlin/fun/utf8/nekoprojectbackend/service/VerificationCodeService.kt +++ b/src/main/kotlin/fun/utf8/nekoprojectbackend/service/VerificationCodeService.kt @@ -4,6 +4,7 @@ import `fun`.utf8.nekoprojectbackend.config.MailProperties import `fun`.utf8.nekoprojectbackend.handlder.BusinessException import `fun`.utf8.nekoprojectbackend.handlder.VerificationCodeInvalidException import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.DefaultRedisScript import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import java.security.MessageDigest @@ -51,7 +52,12 @@ class VerificationCodeService( */ fun generate(ctx: CodeContext): String { val code = randomCode(props.codeLength) - redis.opsForValue().set(codeKey(ctx), code, Duration.ofSeconds(props.ttlSeconds)) + redis.execute( + STORE_CODE_SCRIPT, + listOf(codeKey(ctx), attemptKey(ctx)), + code, + props.ttlSeconds.coerceAtLeast(1L).toString(), + ) return code } @@ -60,12 +66,15 @@ class VerificationCodeService( */ fun verifyAndConsume(ctx: CodeContext, input: String) { val key = codeKey(ctx) - val stored = redis.opsForValue().get(key) - if (stored == null || stored != input.trim()) { + val consumed = redis.execute( + VERIFY_AND_CONSUME_SCRIPT, + listOf(key, attemptKey(ctx)), + input.trim(), + props.maxAttempts.coerceAtLeast(1L).toString(), + ) + if (consumed != 1L) { throw VerificationCodeInvalidException() } - // 原子消费:校验通过即删除,防止验证码被多次复用 - redis.delete(key) } /** @@ -106,6 +115,8 @@ class VerificationCodeService( private fun dailyKey(email: String) = "verify:daily:email:$email:${LocalDate.now()}" + private fun attemptKey(ctx: CodeContext) = "${codeKey(ctx)}:attempts" + /** UserAgent 取 SHA-256 前 16 位,既可区分终端又避免 key 过长。 */ private fun uaHash(userAgent: String): String { val digest = MessageDigest.getInstance("SHA-256") @@ -128,4 +139,40 @@ class VerificationCodeService( val endOfDay = java.time.LocalTime.MAX return Duration.between(now, endOfDay).seconds + 1 } + + private companion object { + val STORE_CODE_SCRIPT = DefaultRedisScript( + """ + redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) + redis.call('DEL', KEYS[2]) + return 1 + """.trimIndent(), + Long::class.java, + ) + + val VERIFY_AND_CONSUME_SCRIPT = DefaultRedisScript( + """ + local stored = redis.call('GET', KEYS[1]) + if not stored then + redis.call('DEL', KEYS[2]) + return 0 + end + if stored == ARGV[1] then + redis.call('DEL', KEYS[1], KEYS[2]) + return 1 + end + + local attempts = redis.call('INCR', KEYS[2]) + local ttl = redis.call('TTL', KEYS[1]) + if ttl > 0 then + redis.call('EXPIRE', KEYS[2], ttl) + end + if attempts >= tonumber(ARGV[2]) then + redis.call('DEL', KEYS[1], KEYS[2]) + end + return 0 + """.trimIndent(), + Long::class.java, + ) + } } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9276b73..0a4a3b2 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: # 生产严禁 create/update:create 会每次启动丢表重建(数据全没),update 会自动改表结构导致 schema 漂移。 @@ -13,13 +15,47 @@ spring: hibernate: format_sql: 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} + seed: + # 生产环境禁止写入演示账号和演示项目。 + enabled: false + file: + # 生产必须显式填写对外 HTTPS 地址,避免上传后把示例域名写入数据库。 + base-url: ${FILE_BASE_URL} security: cookie: # 生产硬约束:refresh cookie 必须 HTTPS 传输 + 防 JS 读取。 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d9e328a..6d4c27e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -58,7 +58,15 @@ security: # ───────────────────────── 项目自定义业务配置(neko.*) ───────────────────────── neko: + cors: + # 允许携带凭证访问 API 的前端来源,逗号分隔;禁止使用 *。 + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000} security: + # 仅在后端只接受可信反向代理连接时开启;开启后限流会读取 X-Forwarded-For。 + trusted-proxy: ${TRUSTED_PROXY:false} + rate-limit: + # 测试环境可关闭;生产应保持开启,Redis 不可用时请求会失败而不是绕过限制。 + enabled: ${RATE_LIMIT_ENABLED:true} cookie: # 刷新令牌 Cookie 名 name: ${COOKIE_NAME:nekobox_refresh} @@ -82,6 +90,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:}} # 验证码邮件主题前缀 @@ -93,7 +103,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: @@ -106,10 +116,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} invite: @@ -267,3 +280,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..a5f1235 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/AdminUserSeederTest.kt @@ -0,0 +1,50 @@ +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.ArgumentMatchers.any +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 org.springframework.core.env.Profiles +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.acceptsProfiles(any(Profiles::class.java))).thenReturn(true) + val seeder = seeder(password = "NekoLocalRoot!2026") + + assertFailsWith { seeder.seedAdmin() } + + verifyNoInteractions(userService) + } + + @Test + fun `enabled seed does not swallow account creation failures`() { + `when`(environment.acceptsProfiles(any(Profiles::class.java))).thenReturn(false) + `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) = AdminUserSeeder( + userService = userService, + environment = environment, + enabled = true, + 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..a2cb73e --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/AuthServiceSecurityTest.kt @@ -0,0 +1,198 @@ +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.ParamErrorException +import `fun`.utf8.nekoprojectbackend.handlder.UsernameOrPasswordErrorException +import `fun`.utf8.nekoprojectbackend.service.AuthService +import `fun`.utf8.nekoprojectbackend.service.InviteCodeService +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 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 inviteCodeService = mock(InviteCodeService::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, + inviteCodeService, + verificationCodeService, + mailService, + rateLimiter, + JwtProperties(secret = "test-secret-that-is-long-enough-for-hs256"), + ) + + @Test + fun `invalid invite does not consume the email verification code`() { + val user = user(id = 9, email = "member@example.test") + val request = AuthService.RegisterManagerRequest( + inviteCode = "invalid-invite", + username = "member", + password = "Strong!234", + email = user.email, + emailCode = "123456", + ) + `when`( + userService.createUser("member", "Strong!234", user.email, Role.PROJECT_MANAGER), + ).thenReturn(user) + `when`(inviteCodeService.consume("invalid-invite", 9)).thenReturn(false) + + assertFailsWith { + service.registerManager(request, "test-agent") + } + + verifyNoInteractions(verificationCodeService) + } + + @Test + fun `password change requires the current accounts own email`() { + val user = user(id = 11, email = "owner@example.test") + `when`(userService.findById(11)).thenReturn(user) + `when`(passwordEncoder.matches("old-password", user.password)).thenReturn(true) + `when`(userService.normalizeEmail("other@example.test")).thenReturn("other@example.test") + + assertFailsWith { + service.changePassword( + 11, + AuthService.ChangePasswordRequest( + oldPassword = "old-password", + newPassword = "Strong!234", + email = "other@example.test", + emailCode = "123456", + ), + "test-agent", + ) + } + + verifyNoInteractions(verificationCodeService) + } + + @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", + ) + } + + 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`(userService.normalizeEmail(user.email)).thenReturn(user.email) + `when`(passwordEncoder.matches("old-password", user.password)).thenReturn(true) + + service.changePassword( + user.id!!, + AuthService.ChangePasswordRequest( + oldPassword = "old-password", + newPassword = "Strong!234", + email = user.email, + emailCode = "123456", + ), + "test-agent", + ) + + verify(verificationCodeService).verifyAndConsume( + VerificationCodeService.CodeContext( + scene = VerificationCodeService.Scene.CHANGE_PASSWORD, + email = user.email, + userId = user.id!!, + userAgent = "test-agent", + ), + "123456", + ) + } + + @Test + fun `anonymous verification code requests do not enumerate email accounts`() { + val email = "unknown@example.test" + `when`(userService.normalizeEmail(email)).thenReturn(email) + `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.PROJECT_MANAGER.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`() { + val user = user(id = 12, email = "owner@example.test") + `when`(userService.findByUsername("member-12")).thenReturn(user) + + assertFailsWith { + service.login( + AuthService.LoginRequest( + username = "member-12", + password = "中".repeat(25), + ), + ) + } + + verifyNoInteractions(passwordEncoder) + } + + private fun user(id: Long, email: String) = User( + id = id, + username = "member-$id", + password = "encoded-password", + email = email, + nickname = "member", + status = Status.ACTIVE, + role = Role.PROJECT_MANAGER, + ) +} 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/ProjectHubSecurityContractTests.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubSecurityContractTests.kt new file mode 100644 index 0000000..a7c2e12 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubSecurityContractTests.kt @@ -0,0 +1,755 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.controller.PROJECT_CONTROL_PASSWORD_HEADER +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.* +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.security.LoginUser +import `fun`.utf8.nekoprojectbackend.service.* +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ProjectHubSecurityContractTests @Autowired constructor( + private val mockMvc: MockMvc, + private val objectItemService: ObjectItemService, + private val objectItemRepository: ObjectItemRepository, + private val joinApplicationService: JoinApplicationService, + private val userRepository: UserRepository, + private val userService: UserService, + private val mindService: MindService, + private val commentRepository: ObjectItemCommentRepository, + private val updateRepository: ObjectItemUpdateRepository, + private val fileRecordRepository: FileRecordRepository, + private val passwordEncoder: PasswordEncoder, + private val fileService: FileService, + private val storageService: StorageService, +) { + + @Test + fun `anonymous detail and list endpoints do not expose review records`() { + val pendingProject = objectItemService.save(projectRequest("Pending project")) + val pendingMind = mindService.save( + MindSaveRequest(title = "Pending idea", content = "Private until approved"), + ) + + mockMvc.perform(get("/api/project/object-items/{id}", pendingProject.id)) + .andExpect(status().isNotFound) + mockMvc.perform(get("/api/project/object-items").param("status", "PENDING")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(0)) + + mockMvc.perform(get("/api/project/minds/{id}", pendingMind.id)) + .andExpect(status().isNotFound) + mockMvc.perform(get("/api/project/minds").param("status", "PENDING")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(0)) + } + + @Test + fun `public project responses do not expose control password presence`() { + val project = publishProject("Public response contract", "public-secret") + + mockMvc.perform(get("/api/project/object-items/{id}", project.id)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.hasControlPassword").doesNotExist()) + + mockMvc.perform( + get("/api/project/object-items") + .param("ids", project.id.toString()), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].hasControlPassword").doesNotExist()) + + mockMvc.perform(get("/api/project/object-items/status/{status}", ObjectItemStatus.RECRUITING)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].hasControlPassword").doesNotExist()) + } + + @Test + fun `invalid public request bodies use the unified bad request response`() { + mockMvc.perform( + post("/api/project/object-items") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"","type":"BUILD"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + } + + @Test + fun `anonymous project submissions require a control password`() { + mockMvc.perform( + post("/api/project/object-items") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Project without password","type":"BUILD","leader":"Owner"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + + mockMvc.perform( + post("/api/project/object-items") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Project with short password","type":"BUILD","leader":"Owner","controlPassword":"12345"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + } + + @Test + fun `admin dynamic edits cannot change status outside the review endpoint`() { + val admin = LoginUser(1, "admin", Role.SUPER_ADMIN, "admin-jti") + val project = publishProject("Dynamic status contract") + + mockMvc.perform( + post("/api/admin/object-items/{id}/updates", project.id) + .with(user(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"title":"Admin update","content":"Published content","status":"REJECTED"}""", + ), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + + val updateId = updateRepository.findByObjectItemId(project.id!!).single().id!! + mockMvc.perform( + put("/api/admin/object-items/{id}/updates/{updateId}", project.id, updateId) + .with(user(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"Edited","status":"REJECTED"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/admin/object-items/{id}/updates/{updateId}/status", + project.id, + updateId, + ) + .with(user(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"status":"PENDING"}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + + assertEquals( + ObjectItemUpdateStatus.APPROVED, + updateRepository.findById(updateId).orElseThrow().status, + ) + } + + @Test + fun `processed join applications cannot be handled twice`() { + val admin = LoginUser(1, "admin", Role.SUPER_ADMIN, "admin-jti") + val project = publishProject("Join conflict contract") + val application = joinApplicationService.create( + project.id!!, + JoinApplicationSaveRequest( + nickName = "Visitor", + mcId = "visitor", + contact = "contact", + reason = "I can help", + skill = "Builder", + ), + ) + + val endpoint = "/api/admin/object-items/${project.id}/join-applications/${application.id!!}/accept" + mockMvc.perform(post(endpoint).with(user(admin))) + .andExpect(status().isOk) + + mockMvc.perform(post(endpoint).with(user(admin))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.status").value(409)) + } + + @Test + fun `admin detail endpoints can read records hidden from public routes`() { + val pendingProject = objectItemService.save(projectRequest("Admin project")) + val pendingMind = mindService.save( + MindSaveRequest(title = "Admin idea", content = "Needs review"), + ) + val admin = LoginUser(1, "admin", Role.SUPER_ADMIN, "test-jti") + + mockMvc.perform( + get("/api/admin/object-items/{id}", pendingProject.id).with(user(admin)), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.id").value(pendingProject.id)) + + mockMvc.perform( + get("/api/admin/minds/{id}", pendingMind.id).with(user(admin)), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.id").value(pendingMind.id)) + } + + @Test + fun `admin detail endpoints preserve authentication and ownership failures`() { + val project = publishProject("Ownership protected detail") + val owner = LoginUser(101, "project-owner", Role.PROJECT_MANAGER, "owner-jti") + val otherManager = LoginUser(102, "other-manager", Role.PROJECT_MANAGER, "other-jti") + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = owner.id + }.also(objectItemRepository::save) + + mockMvc.perform(get("/api/admin/object-items/{id}", project.id)) + .andExpect(status().isUnauthorized) + mockMvc.perform( + get("/api/admin/object-items/{id}", project.id).with(user(otherManager)), + ).andExpect(status().isForbidden) + mockMvc.perform( + get("/api/admin/object-items/{id}", project.id).with(user(owner)), + ).andExpect(status().isOk) + } + + @Test + fun `project managers cannot maintain or moderate another managers project`() { + val projectOwner = LoginUser(111, "first-project-owner", Role.PROJECT_MANAGER, "first-owner-jti") + val otherManager = LoginUser(112, "second-project-owner", Role.PROJECT_MANAGER, "second-owner-jti") + val project = publishProject("Cross project boundary") + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = projectOwner.id + }.also(objectItemRepository::save) + val comment = commentRepository.save(comment(project.id, "needs moderation", ObjectItemCommentStatus.PENDING)) + val update = updateRepository.save(update(project.id, "needs moderation", ObjectItemUpdateStatus.PENDING)) + + mockMvc.perform( + post("/api/admin/object-items/{id}/updates", project.id) + .with(user(otherManager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"forbidden","content":"forbidden"}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + put("/api/project/object-items/{id}", project.id) + .with(user(otherManager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"progress":50}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/admin/object-items/{id}/comments/{commentId}/status", + project.id, + comment.id, + ) + .with(user(otherManager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"status":"APPROVED"}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/admin/object-items/{id}/updates/{updateId}/status", + project.id, + update.id, + ) + .with(user(otherManager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"status":"APPROVED"}"""), + ).andExpect(status().isForbidden) + + assertEquals(ObjectItemCommentStatus.PENDING, commentRepository.findById(comment.id!!).orElseThrow().status) + assertEquals(ObjectItemUpdateStatus.PENDING, updateRepository.findById(update.id!!).orElseThrow().status) + } + + @Test + fun `super admin can manage and moderate any project`() { + val projectOwner = LoginUser(121, "managed-owner", Role.PROJECT_MANAGER, "managed-owner-jti") + val admin = LoginUser(1, "admin", Role.SUPER_ADMIN, "admin-jti") + val project = publishProject("Super admin boundary") + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = projectOwner.id + }.also(objectItemRepository::save) + val comment = commentRepository.save(comment(project.id, "review me", ObjectItemCommentStatus.PENDING)) + + mockMvc.perform( + get("/api/admin/object-items/{id}", project.id).with(user(admin)), + ).andExpect(status().isOk) + + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/admin/object-items/{id}/comments/{commentId}/status", + project.id, + comment.id, + ) + .with(user(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"status":"APPROVED"}"""), + ).andExpect(status().isOk) + + assertEquals(ObjectItemCommentStatus.APPROVED, commentRepository.findById(comment.id!!).orElseThrow().status) + } + + @Test + fun `idea management endpoints remain super admin only`() { + val manager = LoginUser(131, "idea-manager", Role.PROJECT_MANAGER, "idea-manager-jti") + val idea = mindService.save(MindSaveRequest(title = "Restricted idea", content = "Admin only")) + + mockMvc.perform(get("/api/admin/minds").with(user(manager))) + .andExpect(status().isForbidden) + mockMvc.perform(get("/api/admin/minds/{id}", idea.id).with(user(manager))) + .andExpect(status().isForbidden) + mockMvc.perform( + put("/api/admin/minds/batch/status") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${idea.id}],"status":"APPROVED"}"""), + ).andExpect(status().isForbidden) + mockMvc.perform( + put("/api/project/minds/{id}", idea.id) + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"title":"forbidden"}"""), + ).andExpect(status().isForbidden) + mockMvc.perform( + delete("/api/project/minds/{id}", idea.id).with(user(manager)), + ).andExpect(status().isForbidden) + + assertEquals("Restricted idea", mindService.findById(idea.id!!).title) + assertEquals(MindStatus.PENDING, mindService.findById(idea.id).status) + } + + @Test + fun `public child resources always return approved rows only`() { + val project = publishProject("Public child resources") + commentRepository.save(comment(project.id!!, "pending", ObjectItemCommentStatus.PENDING)) + commentRepository.save(comment(project.id, "approved", ObjectItemCommentStatus.APPROVED)) + updateRepository.save(update(project.id, "pending", ObjectItemUpdateStatus.PENDING)) + updateRepository.save(update(project.id, "approved", ObjectItemUpdateStatus.APPROVED)) + + mockMvc.perform( + get("/api/project/object-items/{id}/comments", project.id).param("status", "PENDING"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].status").value("APPROVED")) + + mockMvc.perform( + get("/api/project/object-items/{id}/updates", project.id).param("status", "REJECTED"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].status").value("APPROVED")) + } + + @Test + fun `hidden projects reject anonymous comments and join applications`() { + val project = objectItemService.save(projectRequest("Hidden target")) + + mockMvc.perform( + post("/api/project/object-items/{id}/comments", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"nickName":"visitor","content":"hello"}"""), + ).andExpect(status().isNotFound) + + mockMvc.perform( + post("/api/project/object-items/{id}/join-applications", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"nickName":"visitor","mcId":"visitor","contact":"qq","reason":"help"}""", + ), + ).andExpect(status().isNotFound) + } + + @Test + fun `control passwords are hashed and owner reads require the dedicated header`() { + val rawPassword = "owner-secret-123" + val project = objectItemService.save(projectRequest("Password project", rawPassword)) + val storedPassword = objectItemRepository.findById(project.id!!).orElseThrow().controlPassword.orEmpty() + + assertNotEquals(rawPassword, storedPassword) + assertTrue(passwordEncoder.matches(rawPassword, storedPassword)) + + mockMvc.perform( + get("/api/admin/project/object-items/{id}/updates", project.id) + .param("controlPassword", rawPassword), + ).andExpect(status().isBadRequest) + + mockMvc.perform( + get("/api/admin/project/object-items/{id}/updates", project.id) + .header(PROJECT_CONTROL_PASSWORD_HEADER, rawPassword), + ).andExpect(status().isOk) + + mockMvc.perform( + get("/api/admin/project/object-items/{id}/updates", project.id) + .header(PROJECT_CONTROL_PASSWORD_HEADER, "中".repeat(25)), + ).andExpect(status().isForbidden) + } + + @Test + fun `project owner cannot change review or deletion status through profile update`() { + val rawPassword = "owner-status-secret" + val project = objectItemService.save(projectRequest("Owner status project", rawPassword)) + + mockMvc.perform( + put("/api/admin/project/object-items/{id}", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"controlPassword":"$rawPassword","status":"APPROVED"}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + put("/api/admin/project/object-items/{id}", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"controlPassword":"$rawPassword","status":"DELETED"}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + put("/api/admin/project/object-items/{id}", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"controlPassword":"$rawPassword","status":"PREPARING"}"""), + ).andExpect(status().isForbidden) + + assertTrue( + objectItemRepository.findById(project.id!!).orElseThrow().status == ObjectItemStatus.PENDING, + ) + + objectItemService.update(project.id, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + mockMvc.perform( + put("/api/admin/project/object-items/{id}", project.id) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"controlPassword":"$rawPassword","status":"PAUSED"}"""), + ).andExpect(status().isOk) + assertEquals(ObjectItemStatus.PAUSED, objectItemRepository.findById(project.id).orElseThrow().status) + } + + @Test + fun `tracking token is accepted only from the dedicated header`() { + val tracked = mindService.saveTracked( + MindSaveRequest(title = "Tracked idea", content = "Private status"), + ) + + mockMvc.perform( + get("/api/project/minds/{id}/status", tracked.value.id) + .param("trackingToken", tracked.trackingToken), + ).andExpect(status().isBadRequest) + + mockMvc.perform( + get("/api/project/minds/{id}/status", tracked.value.id) + .header(SUBMISSION_TRACKING_TOKEN_HEADER, tracked.trackingToken), + ).andExpect(status().isOk) + } + + @Test + fun `cors preflight allows project control and tracking headers`() { + mockMvc.perform( + options("/api/project/minds/1/status") + .header(HttpHeaders.ORIGIN, "http://localhost:3000") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.GET.name()) + .header( + HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "x-project-control-password,x-submission-tracking-token", + ), + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:3000")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")) + .andExpect { result -> + val allowedHeaders = result.response + .getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS) + .orEmpty() + assertTrue(allowedHeaders.contains("x-project-control-password", ignoreCase = true)) + assertTrue(allowedHeaders.contains("x-submission-tracking-token", ignoreCase = true)) + } + } + + @Test + fun `refresh cookie endpoint rejects cross site sources`() { + mockMvc.perform( + post("/api/auth/refresh") + .header(HttpHeaders.ORIGIN, "http://localhost:3000") + .header("Sec-Fetch-Site", "cross-site"), + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.status").value(403)) + .andExpect(jsonPath("$.message").value("禁止访问")) + } + + @Test + fun `project managers cannot use global query or batch creation endpoints`() { + val manager = LoginUser(2, "manager", Role.PROJECT_MANAGER, "manager-jti") + + mockMvc.perform( + post("/api/project/object-items/query") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isForbidden) + + mockMvc.perform( + post("/api/project/minds/query") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isForbidden) + + mockMvc.perform( + post("/api/project/object-items/batch") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"items":[]}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + post("/api/project/minds/batch") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"items":[]}"""), + ).andExpect(status().isForbidden) + } + + @Test + fun `project managers cannot change review status through jwt project endpoints`() { + val manager = LoginUser(2, "manager", Role.PROJECT_MANAGER, "manager-jti") + val rawPassword = "manager-project-secret" + val project = objectItemService.save(projectRequest("Manager-owned project", rawPassword)) + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = manager.id + }.also(objectItemRepository::save) + + mockMvc.perform( + put("/api/project/object-items/{id}", project.id) + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"status":"DELETED","controlPassword":"attacker-password"}""", + ), + ).andExpect(status().isForbidden) + + mockMvc.perform( + put("/api/admin/object-items/batch/status") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${project.id}],"status":"REJECTED"}"""), + ).andExpect(status().isForbidden) + + mockMvc.perform( + put("/api/project/object-items/{id}", project.id) + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"status":"PREPARING","controlPassword":"attacker-password"}""", + ), + ).andExpect(status().isForbidden) + + objectItemService.update(project.id, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + mockMvc.perform( + put("/api/project/object-items/{id}", project.id) + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"status":"PAUSED","controlPassword":"attacker-password"}""", + ), + ).andExpect(status().isOk) + + val stored = objectItemRepository.findById(project.id).orElseThrow() + assertEquals(ObjectItemStatus.PAUSED, stored.status) + assertTrue(passwordEncoder.matches(rawPassword, stored.controlPassword.orEmpty())) + } + + @Test + fun `project managers cannot soft delete projects through the legacy jwt batch endpoint`() { + val manager = LoginUser(2, "manager", Role.PROJECT_MANAGER, "manager-jti") + val project = publishProject("Manager delete boundary") + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = manager.id + }.also(objectItemRepository::save) + + mockMvc.perform( + delete("/api/project/object-items/batch") + .with(user(manager)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${project.id}]}"""), + ).andExpect(status().isForbidden) + + assertEquals(ObjectItemStatus.RECRUITING, objectItemRepository.findById(project.id).orElseThrow().status) + } + + @Test + fun `banned accounts cannot receive projects or appear in the assignable owner list`() { + val banned = userRepository.save( + User( + username = "banned-owner", + password = checkNotNull(passwordEncoder.encode("password")), + email = "banned-owner@example.test", + status = Status.BANNED, + role = Role.PROJECT_MANAGER, + ), + ) + val project = objectItemService.save(projectRequest("Banned owner boundary")) + + assertFailsWith { + objectItemService.assignOwner(project.id!!, banned.id) + } + assertFalse(userService.findAssignableOwners().any { it.id == banned.id }) + assertEquals(null, objectItemRepository.findById(project.id!!).orElseThrow().ownerId) + } + + @Test + fun `admin maintenance endpoints return not found for a missing project`() { + val admin = LoginUser(1, "admin", Role.SUPER_ADMIN, "test-jti") + + mockMvc.perform( + get("/api/admin/object-items/{id}/updates", 999999).with(user(admin)), + ).andExpect(status().isNotFound) + } + + @Test + fun `svg downloads stay attachments even when inline preview is requested`() { + val svg = """safe""".toByteArray() + val storedName = storageService.store( + MockMultipartFile("file", "封面.svg", "image/svg+xml", svg), + "svg", + ) + val legacyRecord = fileRecordRepository.save( + FileRecord().apply { + this.storedName = storedName + this.originalName = "封面.svg" + this.mimeType = "image/svg+xml" + this.size = svg.size.toLong() + this.category = FileCategory.IMAGE + this.extension = "svg" + this.publicRead = true + this.createTime = LocalDateTime.now() + }, + ) + + try { + mockMvc.perform( + get("/api/files/{path}", storedName).param("inline", "true"), + ) + .andExpect(status().isOk) + .andExpect { result -> + val disposition = result.response + .getHeader(HttpHeaders.CONTENT_DISPOSITION) + .orEmpty() + assertTrue(disposition.startsWith("attachment;")) + assertTrue( + result.response.getHeader("X-Content-Type-Options").equals("nosniff", ignoreCase = true), + ) + } + } finally { + storageService.delete(storedName) + legacyRecord.id?.let(fileRecordRepository::deleteById) + } + } + + @Test + fun `private project files follow the current project owner after reassignment`() { + val firstOwner = LoginUser(21, "first-owner", Role.PROJECT_MANAGER, "first-owner-jti") + val nextOwner = LoginUser(22, "next-owner", Role.PROJECT_MANAGER, "next-owner-jti") + val project = publishProject("Private project files") + objectItemRepository.findById(project.id!!).orElseThrow().apply { + ownerId = firstOwner.id + }.also(objectItemRepository::save) + val uploaded = fileService.upload( + MockMultipartFile("file", "notes.txt", "text/plain", "private notes".toByteArray()), + FileCategory.DOCUMENT, + firstOwner, + project.id, + ) + + try { + mockMvc.perform(get("/api/files/{path}", uploaded.storedName)) + .andExpect(status().isUnauthorized) + mockMvc.perform(get("/api/files/{path}", uploaded.storedName).with(user(nextOwner))) + .andExpect(status().isForbidden) + mockMvc.perform(get("/api/files/{path}", uploaded.storedName).with(user(firstOwner))) + .andExpect(status().isOk) + + objectItemRepository.findById(project.id).orElseThrow().apply { + ownerId = nextOwner.id + }.also(objectItemRepository::save) + + mockMvc.perform(get("/api/files/{path}", uploaded.storedName).with(user(firstOwner))) + .andExpect(status().isForbidden) + mockMvc.perform(get("/api/files/{path}", uploaded.storedName).with(user(nextOwner))) + .andExpect(status().isOk) + mockMvc.perform(delete("/api/files/{path}", uploaded.storedName).with(user(firstOwner))) + .andExpect(status().isForbidden) + mockMvc.perform(delete("/api/files/{path}", uploaded.storedName).with(user(nextOwner))) + .andExpect(status().isOk) + + assertFalse(storageService.exists(uploaded.storedName)) + assertEquals(null, fileRecordRepository.findByStoredName(uploaded.storedName)) + } finally { + storageService.delete(uploaded.storedName) + uploaded.id?.let { id -> + if (fileRecordRepository.existsById(id)) fileRecordRepository.deleteById(id) + } + } + } + + @Test + fun `malformed json is reported as a bad request`() { + mockMvc.perform( + post("/api/project/minds") + .contentType(MediaType.APPLICATION_JSON) + .content("{"), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value(400)) + } + + private fun projectRequest(title: String, controlPassword: String? = null) = ObjectItemSaveRequest( + title = title, + type = "BUILD", + leader = "Owner", + needMembers = listOf(NeedMemberItemRequest(skill = "Builder", number = 2)), + controlPassword = controlPassword, + ) + + private fun publishProject(title: String, controlPassword: String? = null) = + objectItemService.save(projectRequest(title, controlPassword)).let { saved -> + objectItemService.update( + saved.id!!, + ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING), + ) + } + + private fun comment(projectId: Int, content: String, status: ObjectItemCommentStatus) = + ObjectItemComment().apply { + objectItemId = projectId + nickName = "visitor" + this.content = content + this.status = status + } + + private fun update(projectId: Int, title: String, status: ObjectItemUpdateStatus) = + ObjectItemUpdate().apply { + objectItemId = projectId + this.title = title + content = title + this.status = status + } +} diff --git a/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubWorkflowTests.kt b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubWorkflowTests.kt new file mode 100644 index 0000000..98d9d4f --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/ProjectHubWorkflowTests.kt @@ -0,0 +1,358 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.datasource.jdbc.ObjectItemStatus +import `fun`.utf8.nekoprojectbackend.handlder.ParamErrorException +import `fun`.utf8.nekoprojectbackend.handlder.ResourceConflictException +import `fun`.utf8.nekoprojectbackend.handlder.ResourceNotFoundException +import `fun`.utf8.nekoprojectbackend.service.JoinApplicationSaveRequest +import `fun`.utf8.nekoprojectbackend.service.JoinApplicationService +import `fun`.utf8.nekoprojectbackend.service.NeedMemberItemRequest +import `fun`.utf8.nekoprojectbackend.service.MindSaveRequest +import `fun`.utf8.nekoprojectbackend.service.MindService +import `fun`.utf8.nekoprojectbackend.service.MindQueryRequest +import `fun`.utf8.nekoprojectbackend.service.MindUpdateRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemQueryRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemSaveRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemCommentSaveRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemCommentService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemManagementService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemPasswordChangeRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemUpdateManageCreateRequest +import `fun`.utf8.nekoprojectbackend.service.ObjectItemUpdateManagementService +import `fun`.utf8.nekoprojectbackend.service.ObjectItemUpdateRequest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +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.assertTrue + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ProjectHubWorkflowTests @Autowired constructor( + private val objectItemService: ObjectItemService, + private val objectItemManagementService: ObjectItemManagementService, + private val mindService: MindService, + private val joinApplicationService: JoinApplicationService, + private val objectItemCommentService: ObjectItemCommentService, + private val objectItemUpdateManagementService: ObjectItemUpdateManagementService, +) { + + @Test + fun `legacy approved project becomes preparing and public count excludes review rows`() { + val project = objectItemService.save( + ObjectItemSaveRequest( + title = "Public project", + type = "BUILD", + leader = "Owner", + controlPassword = "secret", + ), + ) + + val preparing = objectItemService.update( + project.id!!, + ObjectItemUpdateRequest(status = ObjectItemStatus.APPROVED, progress = 20), + ) + + assertEquals(ObjectItemStatus.PREPARING, preparing.status) + assertEquals(20, preparing.progress) + assertEquals(1, objectItemService.countPublic()) + + assertThrows { + objectItemService.update( + project.id, + ObjectItemUpdateRequest(progress = 101), + ) + } + } + + @Test + fun `tracked idea can be read with its token but not with another token`() { + val tracked = mindService.saveTracked( + MindSaveRequest( + title = "Tracked idea", + content = "A useful idea", + nickName = "Visitor", + mcId = "visitor", + ), + ) + + val ideaId = tracked.value.id ?: error("tracked idea id was not assigned") + assertTrue(tracked.trackingToken.isNotBlank()) + assertEquals(ideaId, mindService.findTracked(ideaId, tracked.trackingToken).id) + assertThrows { + mindService.findTracked(ideaId, "wrong-token") + } + } + + @Test + fun `tracked join application can be read with its token`() { + val project = objectItemService.save( + ObjectItemSaveRequest( + title = "Joinable project", + type = "RPG", + leader = "Owner", + needMembers = listOf(NeedMemberItemRequest(skill = "Builder", number = 2)), + ), + ) + objectItemService.update( + project.id!!, + ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING), + ) + + val tracked = joinApplicationService.createTracked( + project.id, + JoinApplicationSaveRequest( + nickName = "Visitor", + mcId = "visitor", + contact = "contact", + reason = "I can help", + skill = "Builder", + ), + ) + + val applicationId = tracked.value.id ?: error("tracked application id was not assigned") + assertTrue(tracked.trackingToken.isNotBlank()) + assertEquals( + applicationId, + joinApplicationService.findTracked(project.id, applicationId, tracked.trackingToken).id, + ) + assertThrows { + joinApplicationService.findTracked(project.id, applicationId, "wrong-token") + } + } + + @Test + fun `very large page numbers return an empty page without integer overflow`() { + val projectPage = objectItemService.queryPublicPage( + request = `fun`.utf8.nekoprojectbackend.service.ObjectItemQueryRequest(), + page = Int.MAX_VALUE, + size = 500, + sort = "id,desc", + ) + val mindPage = mindService.queryPublicPage( + request = `fun`.utf8.nekoprojectbackend.service.MindQueryRequest(), + page = Int.MAX_VALUE, + size = 500, + sort = "createTime,desc", + ) + + assertTrue(projectPage.content.isEmpty()) + assertTrue(mindPage.content.isEmpty()) + } + + @Test + fun `project control passwords stay within the bcrypt input policy`() { + assertThrows { + objectItemService.save(projectRequest("Short password", "12345")) + } + assertThrows { + objectItemService.save(projectRequest("Long password", "a".repeat(73))) + } + + val project = objectItemService.save(projectRequest("Change password", "secret")) + assertThrows { + objectItemManagementService.changePassword( + project.id!!, + ObjectItemPasswordChangeRequest( + controlPassword = "secret", + newControlPassword = "中".repeat(25), + ), + ) + } + } + + @Test + fun `public submissions enforce bounded text and tag collections`() { + assertThrows { + objectItemService.save( + projectRequest("Oversized description").copy(description = "a".repeat(20_001)), + ) + } + assertThrows { + objectItemService.save( + projectRequest("Too many tags").copy(tags = (1..13).map { "tag-$it" }), + ) + } + assertThrows { + mindService.save(MindSaveRequest(title = "Large idea", content = "a".repeat(10_001))) + } + + val project = objectItemService.save(projectRequest("Bounded public input", "secret")) + objectItemService.update(project.id!!, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + + assertThrows { + joinApplicationService.create( + project.id, + JoinApplicationSaveRequest( + nickName = "Visitor", + mcId = "visitor", + contact = "contact", + reason = "a".repeat(4_001), + skill = "Builder", + ), + ) + } + assertThrows { + objectItemCommentService.create( + project.id, + ObjectItemCommentSaveRequest(nickName = "Visitor", content = "a".repeat(2_001)), + ) + } + assertThrows { + objectItemUpdateManagementService.create( + project.id, + ObjectItemUpdateManageCreateRequest( + controlPassword = "secret", + title = "Large update", + content = "a".repeat(10_001), + ), + ) + } + } + + @Test + fun `unsafe image urls are rejected while same origin paths remain valid`() { + listOf( + "javascript:alert(1)", + "data:image/svg+xml,", + "//untrusted.example.test/image.png", + ).forEachIndexed { index, url -> + assertThrows { + objectItemService.save( + projectRequest("Unsafe image $index").copy(coverImageUrl = url), + ) + } + } + + val project = objectItemService.save( + projectRequest("Safe image path", "secret").copy(coverImageUrl = "/api/files/cover.png"), + ) + assertEquals("/api/files/cover.png", project.coverImageUrl) + + objectItemService.update(project.id!!, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + assertThrows { + objectItemUpdateManagementService.create( + project.id, + ObjectItemUpdateManageCreateRequest( + controlPassword = "secret", + title = "Unsafe dynamic image", + content = "The image URL must not execute browser code.", + imageUrl = "vbscript:msgbox(1)", + ), + ) + } + } + + @Test + fun `query bounds and database paging remain enforced`() { + val projectIds = (1..2).map { index -> + objectItemService.save( + projectRequest("Paged project $index").copy(tags = listOf("Redstone")), + ).also { saved -> + objectItemService.update(saved.id!!, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + }.id!! + } + + val firstProjectPage = objectItemService.queryPublicPage( + ObjectItemQueryRequest(tags = listOf("rEdStOnE")), + page = 0, + size = 1, + sort = "id,asc", + ) + val secondProjectPage = objectItemService.queryPublicPage( + ObjectItemQueryRequest(tags = listOf("REDSTONE")), + page = 1, + size = 1, + sort = "id,asc", + ) + assertEquals(2, firstProjectPage.totalElements) + assertEquals(listOf(projectIds[0]), firstProjectPage.content.mapNotNull { it.id }) + assertEquals(listOf(projectIds[1]), secondProjectPage.content.mapNotNull { it.id }) + + (1..2).forEach { index -> + val idea = mindService.save( + MindSaveRequest(title = "Paged idea $index", content = "Searchable content $index"), + ) + mindService.update(idea.id!!, MindUpdateRequest(status = `fun`.utf8.nekoprojectbackend.datasource.jdbc.MindStatus.APPROVED)) + } + val ideaPage = mindService.queryPublicPage( + MindQueryRequest(title = "PAGED IDEA"), + page = 0, + size = 1, + sort = "createTime,desc", + ) + assertEquals(2, ideaPage.totalElements) + assertEquals(1, ideaPage.content.size) + + assertThrows { + objectItemService.queryPage(ObjectItemQueryRequest(ids = (1..101).toList()), 0, 20, "id,desc") + } + assertThrows { + mindService.queryPage(MindQueryRequest(title = "x".repeat(129)), 0, 20, "id,desc") + } + assertThrows { + objectItemService.queryPage(ObjectItemQueryRequest(), 0, 20, "id,asc,extra") + } + assertThrows { + mindService.queryPage(MindQueryRequest(), 0, 20, "id,asc,extra") + } + assertThrows { + objectItemService.queryPage(ObjectItemQueryRequest(), 0, 501, "id,desc") + } + } + + @Test + fun `join applications require recruiting status and a declared role`() { + val project = objectItemService.save(projectRequest("Recruitment rules")) + objectItemService.update(project.id!!, ObjectItemUpdateRequest(status = ObjectItemStatus.PREPARING)) + val request = JoinApplicationSaveRequest( + nickName = "Visitor", + mcId = "visitor", + contact = "contact", + reason = "I can help", + skill = "Builder", + ) + + assertThrows { + joinApplicationService.create(project.id, request) + } + + objectItemService.update(project.id, ObjectItemUpdateRequest(status = ObjectItemStatus.RECRUITING)) + assertThrows { + joinApplicationService.create(project.id, request.copy(skill = "Miner")) + } + } + + @Test + fun `soft deleted projects do not consume the manager project quota`() { + val ownerId = 9_999L + repeat(10) { index -> + val project = objectItemService.saveOwned( + projectRequest("Deleted project $index"), + ownerId, + ObjectItemStatus.PENDING, + ) + objectItemService.update(project.id!!, ObjectItemUpdateRequest(status = ObjectItemStatus.DELETED)) + } + + val activeProject = objectItemService.saveOwned( + projectRequest("Replacement project"), + ownerId, + ObjectItemStatus.PENDING, + ) + assertEquals(ownerId, activeProject.ownerId) + } + + private fun projectRequest(title: String, controlPassword: String? = null) = ObjectItemSaveRequest( + title = title, + type = "BUILD", + leader = "Owner", + needMembers = listOf(NeedMemberItemRequest(skill = "Builder", number = 2)), + controlPassword = controlPassword, + ) +} 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..433498a --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/TokenStoreTest.kt @@ -0,0 +1,39 @@ +package `fun`.utf8.nekoprojectbackend + +import `fun`.utf8.nekoprojectbackend.service.TokenStore +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) + `when`(redis.getExpire("auth:user:7:sessions")).thenReturn(0L) + `when`(redis.getExpire("auth:user:7:refreshes")).thenReturn(0L) + + val ttl = Duration.ofSeconds(90) + store.saveAccess("access-jti", 7, ttl) + store.saveRefresh("refresh-jti", 7, ttl) + + verify(valueOperations).set("auth:token:access-jti", "7", ttl) + verify(valueOperations).set("auth:refresh:refresh-jti", "7", 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..add4469 --- /dev/null +++ b/src/test/kotlin/fun/utf8/nekoprojectbackend/VerificationCodeServiceTest.kt @@ -0,0 +1,65 @@ +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.any +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.script.RedisScript +import kotlin.test.assertFailsWith + +class VerificationCodeServiceTest { + private val redis = mock(StringRedisTemplate::class.java) + 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 through one atomic redis operation`() { + `when`( + redis.execute( + any>(), + anyList(), + eq("123456"), + eq("5"), + ), + ).thenReturn(1L) + + service.verifyAndConsume(context, " 123456 ") + + verify(redis).execute( + any>(), + anyList(), + eq("123456"), + eq("5"), + ) + verify(redis, never()).delete(any()) + } + + @Test + fun `missing or mismatched verification code is rejected`() { + `when`( + redis.execute( + any>(), + anyList(), + eq("000000"), + eq("5"), + ), + ).thenReturn(0L) + + assertFailsWith { + service.verifyAndConsume(context, "000000") + } + } +} 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 From 358a6399d31e10619c3751986901628649c37cc2 Mon Sep 17 00:00:00 2001 From: SH-ZMD Date: Tue, 11 Aug 2026 20:49:54 +0800 Subject: [PATCH 2/2] build: add reproducible local Docker startup --- .dockerignore | 10 +++++++ Dockerfile | 28 +++++++++++++++++++ README.md | 32 ++++++++++++++++++---- build.gradle.kts | 1 + compose.yaml | 65 +++++++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 8 ++++++ 6 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml 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/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 104144c..2f966d6 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,33 @@ ## 环境要求 -- JDK 25 -- PostgreSQL -- Redis +- 推荐: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`,并确认后端日志出现启动完成信息: + +```powershell +docker compose logs -f backend +``` + +默认 API 地址为 `http://localhost:8080`,健康检查为 `http://localhost:8080/actuator/health`。停止服务使用: + +```powershell +docker compose down +``` + +只有明确需要连同本地开发数据一起清空时才使用 `docker compose down -v`。 + +## 手动本地运行 开发配置从项目根目录的 `.env` 读取。请先复制示例文件并替换数据库、Redis、JWT 和邮件配置: @@ -24,8 +48,6 @@ Copy-Item .env.example .env ``` -## 本地运行 - 本地默认使用 `ddl-auto=create`,只适合没有需要保留的数据的开发数据库: ```powershell diff --git a/build.gradle.kts b/build.gradle.kts index 1231186..050f21d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,7 @@ java { } repositories { + maven("https://maven.aliyun.com/repository/public") mavenCentral() } 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/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"