Compare commits

21 Commits
main ... dev

Author SHA1 Message Date
yoyuzh
9af2d38e37 Eliminate admin N+1 queries and lazy-load app routes 2026-04-12 00:48:23 +08:00
yoyuzh
30a9bbc1e7 Refactor backend and frontend modules for architecture alignment 2026-04-12 00:32:21 +08:00
yoyuzh
f59515f5dd feat(admin): add blob share and task admin apis 2026-04-11 14:09:31 +08:00
yoyuzh
12005cc606 feat(front): 覆盖 front 并完善登录快传入口与中文文案 2026-04-10 01:09:06 +08:00
yoyuzh
99e00cd7f7 feat(portal): land files platform and frontend workspace refresh 2026-04-09 18:35:03 +08:00
yoyuzh
67cd0f6e6f docs: rewrite frontend construction spec 2026-04-09 18:34:20 +08:00
yoyuzh
3906a523fd refactor(files): reorganize backend package layout 2026-04-09 16:00:34 +08:00
yoyuzh
da576e0253 docs(front): broaden redesign scope 2026-04-09 01:09:46 +08:00
yoyuzh
7d6ceaf6d8 docs(front): add redesign handoff plans 2026-04-09 00:48:32 +08:00
yoyuzh
977eb60b17 feat(files): add v2 task and metadata workflows 2026-04-09 00:42:41 +08:00
yoyuzh
c5362ebe31 feat(admin): show storage policies 2026-04-08 21:54:22 +08:00
yoyuzh
3e67760712 feat(files): stamp entities with storage policy 2026-04-08 21:44:38 +08:00
yoyuzh
00b268c30f fix(storage): integrate s3 session provider 2026-04-08 21:38:49 +08:00
yoyuzh
19c296a212 添加storage 2026-04-08 21:11:18 +08:00
yoyuzh
6da0d196ee feat(files): add storage policy skeleton 2026-04-08 15:37:43 +08:00
yoyuzh
f582e600aa feat(files): expire stale upload sessions 2026-04-08 15:27:39 +08:00
yoyuzh
06a95bc489 feat(files): track v2 upload session parts 2026-04-08 15:22:52 +08:00
yoyuzh
35b0691188 feat(files): complete v2 upload sessions 2026-04-08 15:18:09 +08:00
yoyuzh
7ddef9bddb feat(files): add v2 upload session skeleton 2026-04-08 15:12:36 +08:00
yoyuzh
5802f396c5 feat(files): add file entity migration 2026-04-08 15:02:42 +08:00
yoyuzh
9d5fdd9ea3 feat(api): add v2 phase one skeleton 2026-04-08 14:28:01 +08:00
547 changed files with 45004 additions and 23385 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"codex@openai-codex": true
}
}

View File

@@ -1,5 +1,5 @@
name = "implementer"
description = "Code-writing agent. It makes focused changes in frontend, backend, scripts, or docs after planning/exploration are complete, and leaves broad verification to tester."
description = "Code-writing agent. It owns delegated feature implementation and focused changes in frontend, backend, scripts, or docs after planning/exploration are complete; broad or time-consuming edits should come here instead of being handled by orchestrator. It leaves broad verification to tester."
nickname_candidates = ["implementer", "impl", "builder"]
sandbox_mode = "workspace-write"
include_apply_patch_tool = true

View File

@@ -1,5 +1,5 @@
name = "orchestrator"
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis."
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis. It should not directly own feature implementation or broad code edits; only tiny alignment fixes such as imports, field/signature synchronization, or obvious one-line consistency repairs may be handled locally when delegating would be slower than the fix itself."
nickname_candidates = ["orchestrator", "lead", "coord"]
sandbox_mode = "read-only"
include_apply_patch_tool = false

View File

@@ -1,5 +1,5 @@
name = "tester"
description = "Verification-only agent. It runs lint, test, build, package, and type-check commands that already exist in this repo, reports failures, and does not edit source files."
description = "Verification-only agent. It runs only repository-backed verification commands that already exist in this repo, reports exact failures, and does not edit source files. Use the `multi-angle-verification` skill as the default verification workflow so command coverage, browser-flow checks, UI review, and coverage-gap reporting stay consistent. Android emulator or device simulation is out of scope for this agent and is handled manually by the user."
nickname_candidates = ["tester", "qa", "verify"]
sandbox_mode = "workspace-write"
include_apply_patch_tool = false

View File

@@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml"
nickname_candidates = ["implementer", "impl", "builder"]
[agents.tester]
description = "Runs repository-backed verification commands only."
description = "Runs repository-backed verification through the multi-angle-verification workflow, including browser and UI review when the task warrants it."
config_file = ".codex/agents/tester.toml"
nickname_candidates = ["tester", "qa", "verify"]

View File

@@ -0,0 +1,38 @@
{
"name": "my_site backend",
"image": "mcr.microsoft.com/devcontainers/java:1-17-bullseye",
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/backend",
"runArgs": [
"--init"
],
"mounts": [
"source=my-site-backend-maven-cache,target=/home/vscode/.m2,type=volume"
],
"containerEnv": {
"APP_JWT_SECRET": "devcontainer-local-jwt-secret-please-change-if-needed",
"SPRING_PROFILES_ACTIVE": "dev"
},
"forwardPorts": [
8080
],
"portsAttributes": {
"8080": {
"label": "Spring Boot backend",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"vscjava.vscode-java-pack",
"vscjava.vscode-lombok",
"vmware.vscode-spring-boot"
],
"settings": {
"java.import.maven.enabled": true,
"java.configuration.updateBuildConfiguration": "interactive"
}
}
}
}

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
backend/target/
data/
storage/
/storage/
/backend/storage/
node_modules/
output/
tmp/

View File

@@ -6,7 +6,8 @@ This repository is split across a Java backend, a Vite/React frontend, a small `
- Every new window / new session that starts work in this repository must read `memory.md`, `docs/architecture.md`, and `docs/api-reference.md` first before planning, coding, reviewing, or deploying.
- Treat `memory.md` as the current project memory and continuity handoff unless the user explicitly overrides it.
- Treat `docs/architecture.md` as the system-level source of truth for module boundaries and runtime structure.
- Treat `docs/architecture.md` as the project architecture document and source of truth for module boundaries and runtime structure.
- Do not edit `docs/architecture.md` during normal implementation, refactor, review, or handoff work. Only change it when the user explicitly asks to update the architecture document itself.
- Treat `docs/api-reference.md` as the quick reference for backend endpoints and auth/public access boundaries.
## Real project structure
@@ -117,7 +118,8 @@ Important:
### Project memory upkeep
- Every time a task causes a major project change, update `memory.md` and `docs/architecture.md` in the same turn before handing off. Major changes include architecture shifts, storage/provider migrations, auth or security model changes, deployment topology changes, and meaningful new product capabilities.
- Every time a task causes a major project change, update `memory.md` in the same turn before handing off.
- Do not update `docs/architecture.md` as part of routine implementation follow-up. That file is reserved for explicit architecture-document changes requested by the user.
## Repo-specific guardrails
@@ -129,4 +131,11 @@ Important:
- For frontend releases, prefer `node scripts/deploy-front-oss.mjs` over ad hoc `ossutil` or manual uploads.
- For backend releases, package from `backend/` and deploy the produced jar; do not commit `backend/target/` artifacts to git unless the user explicitly asks for that unusual workflow.
## Debugging Discipline
- When diagnosing environment or download issues, use short probes first: prefer `curl --max-time`, `mvn -q`, `apt-get update`, `mvn dependency:get`, or similar bounded checks before any full build or long download.
- Do not wait indefinitely on a stalled download or network command. If a command shows no progress within a short probe window, stop and inspect the active proxy, DNS, and mirror path before retrying.
- For WSL-based debugging, prefer the native WSL shell plus the current mirror/proxy settings already in place. If a download path is slow, verify whether the proxy path is actually faster before forcing direct access.
- If a package source is unstable, switch to a domestic mirror only after confirming whether the failure is in DNS, proxy routing, or the upstream mirror itself.
Directory-level `AGENTS.md` files in `backend/`, `front/`, and `docs/` add more specific rules and override this file where they are more specific.

View File

@@ -13,6 +13,26 @@
- Maven 3.9+
- 生产环境使用 MySQL 8.x 或 openGauss
## Dev Container
仓库根目录现在提供了后端专用的 `.devcontainer/devcontainer.json`
用途:
- 使用 JDK 17 + Maven 的开发容器打开后端
- 默认把工作目录定位到 `backend/`
- 默认转发 `8080`
- 默认注入一个本地开发用 `APP_JWT_SECRET`
- 默认启用 `SPRING_PROFILES_ACTIVE=dev`
进入容器后,仍按仓库已有命令运行:
```bash
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
如果你需要读取仓库根目录的 `.env`,它仍然和 `backend/` 一起挂载在同一个 workspace 下,可通过 `../.env` 访问。
## 启动
推荐先在仓库根目录准备并加载 `.env`

View File

@@ -0,0 +1,190 @@
2026-04-09 00:35:10.239730+08:00 jdbc[4]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "SF1_0.IS_RECYCLE_ROOT" not found; SQL statement:
select sf1_0.id,b1_0.id,b1_0.content_type,b1_0.created_at,b1_0.object_key,b1_0.size,sf1_0.content_type,sf1_0.created_at,sf1_0.deleted_at,sf1_0.is_directory,sf1_0.filename,sf1_0.storage_name,sf1_0.path,sf1_0.primary_entity_id,sf1_0.recycle_group_id,sf1_0.recycle_original_path,sf1_0.is_recycle_root,sf1_0.size,sf1_0.updated_at,sf1_0.user_id from portal_file sf1_0 left join portal_file_blob b1_0 on b1_0.id=sf1_0.blob_id where sf1_0.deleted_at is not null and sf1_0.deleted_at<? order by sf1_0.deleted_at [42122-224]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:514)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
at org.h2.message.DbException.get(DbException.java:223)
at org.h2.message.DbException.get(DbException.java:199)
at org.h2.expression.ExpressionColumn.getColumnException(ExpressionColumn.java:244)
at org.h2.expression.ExpressionColumn.optimizeOther(ExpressionColumn.java:226)
at org.h2.expression.ExpressionColumn.optimize(ExpressionColumn.java:213)
at org.h2.command.query.Select.optimizeExpressionsAndPreserveAliases(Select.java:1285)
at org.h2.command.query.Select.prepareExpressions(Select.java:1167)
at org.h2.command.query.Query.prepare(Query.java:218)
at org.h2.command.Parser.prepareCommand(Parser.java:489)
at org.h2.engine.SessionLocal.prepareLocal(SessionLocal.java:639)
at org.h2.engine.SessionLocal.prepareCommand(SessionLocal.java:559)
at org.h2.jdbc.JdbcConnection.prepareCommand(JdbcConnection.java:1166)
at org.h2.jdbc.JdbcPreparedStatement.<init>(JdbcPreparedStatement.java:93)
at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:316)
at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:328)
at com.zaxxer.hikari.pool.HikariProxyConnection.prepareStatement(HikariProxyConnection.java)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$5.doPrepare(StatementPreparerImpl.java:153)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$StatementPreparationTemplate.prepareStatement(StatementPreparerImpl.java:183)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl.prepareQueryStatement(StatementPreparerImpl.java:155)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.lambda$list$0(JdbcSelectExecutor.java:85)
at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:231)
at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:167)
at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.advanceNext(JdbcValuesResultSetImpl.java:265)
at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.processNext(JdbcValuesResultSetImpl.java:145)
at org.hibernate.sql.results.jdbc.internal.AbstractJdbcValues.next(AbstractJdbcValues.java:19)
at org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.next(RowProcessingStateStandardImpl.java:67)
at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:204)
at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:33)
at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:211)
at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:83)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:76)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:65)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$2(ConcreteSqmSelectQueryPlan.java:139)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:382)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:302)
at org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:526)
at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:423)
at org.hibernate.query.Query.getResultList(Query.java:120)
at org.springframework.data.jpa.repository.query.JpaQueryExecution$CollectionExecution.doExecute(JpaQueryExecution.java:130)
at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:93)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:152)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:140)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:169)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:148)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:70)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:136)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy2/jdk.proxy2.$Proxy162.findByDeletedAtBefore(Unknown Source)
at com.yoyuzh.files.FileService.pruneExpiredRecycleBinItems(FileService.java:292)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355)
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720)
at com.yoyuzh.files.FileService$$SpringCGLIB$$0.pruneExpiredRecycleBinItems(<generated>)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:130)
at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$2(ScheduledMethodRunnable.java:124)
at io.micrometer.observation.Observation.observe(Observation.java:499)
at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:124)
at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:358)
at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:305)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1570)
2026-04-09 00:35:10.299570+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "SF1_0.IS_RECYCLE_ROOT" not found; SQL statement:
select sf1_0.id,sf1_0.blob_id,sf1_0.content_type,sf1_0.created_at,sf1_0.deleted_at,sf1_0.is_directory,sf1_0.filename,sf1_0.storage_name,sf1_0.path,sf1_0.primary_entity_id,sf1_0.recycle_group_id,sf1_0.recycle_original_path,sf1_0.is_recycle_root,sf1_0.size,sf1_0.updated_at,sf1_0.user_id from portal_file sf1_0 where not(sf1_0.is_directory) and sf1_0.blob_id is null [42122-224]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:514)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
at org.h2.message.DbException.get(DbException.java:223)
at org.h2.message.DbException.get(DbException.java:199)
at org.h2.expression.ExpressionColumn.getColumnException(ExpressionColumn.java:244)
at org.h2.expression.ExpressionColumn.optimizeOther(ExpressionColumn.java:226)
at org.h2.expression.ExpressionColumn.optimize(ExpressionColumn.java:213)
at org.h2.command.query.Select.optimizeExpressionsAndPreserveAliases(Select.java:1285)
at org.h2.command.query.Select.prepareExpressions(Select.java:1167)
at org.h2.command.query.Query.prepare(Query.java:218)
at org.h2.command.Parser.prepareCommand(Parser.java:489)
at org.h2.engine.SessionLocal.prepareLocal(SessionLocal.java:639)
at org.h2.engine.SessionLocal.prepareCommand(SessionLocal.java:559)
at org.h2.jdbc.JdbcConnection.prepareCommand(JdbcConnection.java:1166)
at org.h2.jdbc.JdbcPreparedStatement.<init>(JdbcPreparedStatement.java:93)
at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:316)
at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:328)
at com.zaxxer.hikari.pool.HikariProxyConnection.prepareStatement(HikariProxyConnection.java)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$5.doPrepare(StatementPreparerImpl.java:153)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$StatementPreparationTemplate.prepareStatement(StatementPreparerImpl.java:183)
at org.hibernate.engine.jdbc.internal.StatementPreparerImpl.prepareQueryStatement(StatementPreparerImpl.java:155)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.lambda$list$0(JdbcSelectExecutor.java:85)
at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:231)
at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:167)
at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.advanceNext(JdbcValuesResultSetImpl.java:265)
at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.processNext(JdbcValuesResultSetImpl.java:145)
at org.hibernate.sql.results.jdbc.internal.AbstractJdbcValues.next(AbstractJdbcValues.java:19)
at org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.next(RowProcessingStateStandardImpl.java:67)
at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:204)
at org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:33)
at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:211)
at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:83)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:76)
at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:65)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$2(ConcreteSqmSelectQueryPlan.java:139)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:382)
at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:302)
at org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:526)
at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:423)
at org.hibernate.query.Query.getResultList(Query.java:120)
at org.springframework.data.jpa.repository.query.JpaQueryExecution$CollectionExecution.doExecute(JpaQueryExecution.java:130)
at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:93)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:152)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:140)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:169)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:148)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:70)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:136)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy2/jdk.proxy2.$Proxy162.findAllByDirectoryFalseAndBlobIsNull(Unknown Source)
at com.yoyuzh.files.FileBlobBackfillService.backfillMissingBlobs(FileBlobBackfillService.java:28)
at com.yoyuzh.files.FileBlobBackfillService.run(FileBlobBackfillService.java:23)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355)
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720)
at com.yoyuzh.files.FileBlobBackfillService$$SpringCGLIB$$0.run(<generated>)
at org.springframework.boot.SpringApplication.lambda$callRunner$5(SpringApplication.java:790)
at org.springframework.util.function.ThrowingConsumer$1.acceptWithException(ThrowingConsumer.java:82)
at org.springframework.util.function.ThrowingConsumer.accept(ThrowingConsumer.java:60)
at org.springframework.util.function.ThrowingConsumer$1.accept(ThrowingConsumer.java:86)
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:798)
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:789)
at org.springframework.boot.SpringApplication.lambda$callRunners$3(SpringApplication.java:774)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
at java.base/java.util.stream.SortedOps$SizedRefSortingSink.end(SortedOps.java:357)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:557)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:546)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:265)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:611)
at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:774)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:342)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
at com.yoyuzh.PortalBackendApplication.main(PortalBackendApplication.java:25)

View File

@@ -38,6 +38,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

View File

@@ -1,6 +1,7 @@
package com.yoyuzh;
import com.yoyuzh.config.AdminProperties;
import com.yoyuzh.config.AppRedisProperties;
import com.yoyuzh.config.AndroidReleaseProperties;
import com.yoyuzh.config.CorsProperties;
import com.yoyuzh.config.FileStorageProperties;
@@ -17,7 +18,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
CorsProperties.class,
AdminProperties.class,
RegistrationProperties.class,
AndroidReleaseProperties.class
AndroidReleaseProperties.class,
AppRedisProperties.class
})
public class PortalBackendApplication {

View File

@@ -1,27 +1,34 @@
package com.yoyuzh.admin;
import com.yoyuzh.config.AdminProperties;
import com.yoyuzh.auth.UserRole;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Objects;
@Component
public class AdminAccessEvaluator {
private final Set<String> adminUsernames;
public AdminAccessEvaluator(AdminProperties adminProperties) {
this.adminUsernames = adminProperties.getUsernames().stream()
.map(username -> username == null ? "" : username.trim())
.filter(username -> !username.isEmpty())
.collect(Collectors.toUnmodifiableSet());
public boolean isAdmin(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(this::toUserRole)
.filter(Objects::nonNull)
.anyMatch(UserRole::canAccessAdmin);
}
public boolean isAdmin(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& adminUsernames.contains(authentication.getName());
private UserRole toUserRole(String authority) {
if (authority == null || !authority.startsWith("ROLE_")) {
return null;
}
try {
return UserRole.valueOf(authority.substring("ROLE_".length()));
} catch (IllegalArgumentException ex) {
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
package com.yoyuzh.admin;
public enum AdminAuditAction {
UPDATE_REGISTRATION_INVITE_CODE,
ROTATE_REGISTRATION_INVITE_CODE,
UPDATE_OFFLINE_TRANSFER_STORAGE_LIMIT,
UPDATE_USER_ROLE,
UPDATE_USER_BANNED,
UPDATE_USER_PASSWORD,
RESET_USER_PASSWORD,
UPDATE_USER_STORAGE_QUOTA,
UPDATE_USER_MAX_UPLOAD_SIZE,
DELETE_SHARE,
DELETE_FILE,
CREATE_STORAGE_POLICY,
UPDATE_STORAGE_POLICY,
UPDATE_STORAGE_POLICY_STATUS,
CREATE_STORAGE_POLICY_MIGRATION_TASK
}

View File

@@ -0,0 +1,126 @@
package com.yoyuzh.admin;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_admin_audit_log")
public class AdminAuditLogEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "actor_user_id")
private Long actorUserId;
@Column(name = "actor_username", nullable = false, length = 100)
private String actorUsername;
@Column(name = "actor_authorities", nullable = false, length = 255)
private String actorAuthorities;
@Column(name = "action_type", nullable = false, length = 100)
private String actionType;
@Column(name = "target_type", nullable = false, length = 100)
private String targetType;
@Column(name = "target_id")
private Long targetId;
@Column(name = "summary", nullable = false, length = 255)
private String summary;
@Column(name = "details_json", nullable = false, columnDefinition = "TEXT")
private String detailsJson;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public Long getActorUserId() {
return actorUserId;
}
public void setActorUserId(Long actorUserId) {
this.actorUserId = actorUserId;
}
public String getActorUsername() {
return actorUsername;
}
public void setActorUsername(String actorUsername) {
this.actorUsername = actorUsername;
}
public String getActorAuthorities() {
return actorAuthorities;
}
public void setActorAuthorities(String actorAuthorities) {
this.actorAuthorities = actorAuthorities;
}
public String getActionType() {
return actionType;
}
public void setActionType(String actionType) {
this.actionType = actionType;
}
public String getTargetType() {
return targetType;
}
public void setTargetType(String targetType) {
this.targetType = targetType;
}
public Long getTargetId() {
return targetId;
}
public void setTargetId(Long targetId) {
this.targetId = targetId;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDetailsJson() {
return detailsJson;
}
public void setDetailsJson(String detailsJson) {
this.detailsJson = detailsJson;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,25 @@
package com.yoyuzh.admin;
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.Query;
import org.springframework.data.repository.query.Param;
public interface AdminAuditLogRepository extends JpaRepository<AdminAuditLogEntity, Long> {
@Query("""
select l from AdminAuditLogEntity l
where (:actorQuery = '' or lower(l.actorUsername) like lower(concat('%', :actorQuery, '%')))
and (:actionType = '' or l.actionType = :actionType)
and (:targetType = '' or l.targetType = :targetType)
and (:targetId is null or l.targetId = :targetId)
""")
Page<AdminAuditLogEntity> search(
@Param("actorQuery") String actorQuery,
@Param("actionType") String actionType,
@Param("targetType") String targetType,
@Param("targetId") Long targetId,
Pageable pageable
);
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.admin;
import java.time.LocalDateTime;
public record AdminAuditLogResponse(
Long id,
Long actorUserId,
String actorUsername,
String actorAuthorities,
String actionType,
String targetType,
Long targetId,
String summary,
String detailsJson,
LocalDateTime createdAt
) {
}

View File

@@ -0,0 +1,59 @@
package com.yoyuzh.admin;
import com.yoyuzh.common.PageResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AdminAuditQueryService {
private final AdminAuditLogRepository adminAuditLogRepository;
public PageResponse<AdminAuditLogResponse> listAuditLogs(int page,
int size,
String actorQuery,
String actionType,
String targetType,
Long targetId) {
Page<AdminAuditLogEntity> result = adminAuditLogRepository.search(
normalizeQuery(actorQuery),
normalizeQuery(actionType),
normalizeQuery(targetType),
targetId,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")
.and(Sort.by(Sort.Direction.DESC, "id")))
);
return new PageResponse<>(
result.getContent().stream().map(this::toResponse).toList(),
result.getTotalElements(),
page,
size
);
}
private AdminAuditLogResponse toResponse(AdminAuditLogEntity entity) {
return new AdminAuditLogResponse(
entity.getId(),
entity.getActorUserId(),
entity.getActorUsername(),
entity.getActorAuthorities(),
entity.getActionType(),
entity.getTargetType(),
entity.getTargetId(),
entity.getSummary(),
entity.getDetailsJson(),
entity.getCreatedAt()
);
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
}

View File

@@ -0,0 +1,73 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminAuditService {
private final AdminAuditLogRepository adminAuditLogRepository;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
public void record(AdminAuditAction action,
String targetType,
Long targetId,
String summary,
Map<String, Object> details) {
ActorSnapshot actor = resolveActorSnapshot();
AdminAuditLogEntity entity = new AdminAuditLogEntity();
entity.setActorUserId(actor.userId());
entity.setActorUsername(actor.username());
entity.setActorAuthorities(actor.authorities());
entity.setActionType(action.name());
entity.setTargetType(targetType);
entity.setTargetId(targetId);
entity.setSummary(summary);
entity.setDetailsJson(serializeDetails(details));
adminAuditLogRepository.save(entity);
}
private ActorSnapshot resolveActorSnapshot() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return new ActorSnapshot(null, "system", "");
}
String username = authentication.getName();
Long userId = StringUtils.hasText(username)
? userRepository.findByUsername(username).map(user -> user.getId()).orElse(null)
: null;
String authorities = authentication.getAuthorities() == null
? ""
: authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.sorted()
.collect(Collectors.joining(","));
if (!StringUtils.hasText(username)) {
return new ActorSnapshot(userId, "system", authorities);
}
return new ActorSnapshot(userId, username, authorities);
}
private String serializeDetails(Map<String, Object> details) {
try {
return objectMapper.writeValueAsString(details == null ? Map.of() : details);
} catch (JsonProcessingException ex) {
return "{}";
}
}
private record ActorSnapshot(Long userId, String username, String authorities) {
}
}

View File

@@ -0,0 +1,121 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.config.AppRedisProperties;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.config.JwtProperties;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AdminConfigSnapshotService {
private final RegistrationInviteService registrationInviteService;
private final AdminMetricsService adminMetricsService;
private final AppRedisProperties redisProperties;
private final FileStorageProperties fileStorageProperties;
private final JwtProperties jwtProperties;
private final Environment environment;
private final StoragePolicyService storagePolicyService;
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final FileEntityRepository fileEntityRepository;
public AdminSettingsResponse getSettings() {
return new AdminSettingsResponse(
new AdminSettingsResponse.SiteSection(false, false),
new AdminSettingsResponse.RegistrationSection(
true,
registrationInviteService.getCurrentInviteCode(),
List.of(UserRole.MODERATOR.name(), UserRole.ADMIN.name()),
true
),
new AdminSettingsResponse.UserSessionSection(
jwtProperties.getAccessExpirationSeconds(),
jwtProperties.getRefreshExpirationSeconds(),
redisProperties.isEnabled(),
redisProperties.getTtlBufferSeconds(),
false
),
new AdminSettingsResponse.TransferSection(
adminMetricsService.getOfflineTransferStorageLimitBytes(),
true
),
new AdminSettingsResponse.MediaProcessingSection(true, false, false, false),
new AdminSettingsResponse.QueueSection(
redisProperties.isEnabled() ? "redis" : "in-memory",
readLongProperty("app.redis.broker.media-meta.fixed-delay-ms", 3000L),
readLongProperty("app.redis.broker.media-meta.initial-delay-ms", 15000L),
false
),
new AdminSettingsResponse.AppearanceSection(false, false),
new AdminSettingsResponse.ServerSection(
normalizeStorageProvider(fileStorageProperties.getProvider()),
redisProperties.isEnabled(),
false
)
);
}
public AdminFilesystemResponse getFilesystem() {
StoragePolicy defaultPolicy = storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(defaultPolicy);
boolean directUpload = capabilities.directUpload();
return new AdminFilesystemResponse(
new AdminFilesystemResponse.OverviewSection(
normalizeStorageProvider(fileStorageProperties.getProvider()),
storedFileRepository.count(),
fileBlobRepository.count(),
fileEntityRepository.count()
),
AdminStoragePolicyResponses.from(storagePolicyService, defaultPolicy),
new AdminFilesystemResponse.UploadSection(
!directUpload,
directUpload && !capabilities.multipartUpload(),
directUpload && capabilities.multipartUpload(),
resolveEffectiveMaxFileSize(defaultPolicy, capabilities)
),
new AdminFilesystemResponse.MediaProcessingSection(true, capabilities.thumbnailNative()),
new AdminFilesystemResponse.CacheSection(
redisProperties.isEnabled() ? "redis" : "disabled",
redisProperties.getCache().getFilesListTtlSeconds(),
redisProperties.getCache().getDirectoryVersionTtlSeconds()
),
new AdminFilesystemResponse.WebdavSection(false)
);
}
private String normalizeStorageProvider(String provider) {
if (!StringUtils.hasText(provider)) {
return "local";
}
return provider.trim().toLowerCase();
}
private long resolveEffectiveMaxFileSize(StoragePolicy policy, StoragePolicyCapabilities capabilities) {
long effectiveMaxFileSize = fileStorageProperties.getMaxFileSize();
if (policy.getMaxSizeBytes() > 0) {
effectiveMaxFileSize = Math.min(effectiveMaxFileSize, policy.getMaxSizeBytes());
}
if (capabilities.maxObjectSize() > 0) {
effectiveMaxFileSize = Math.min(effectiveMaxFileSize, capabilities.maxObjectSize());
}
return effectiveMaxFileSize;
}
private long readLongProperty(String key, long defaultValue) {
return environment.getProperty(key, Long.class, defaultValue);
}
}

View File

@@ -1,10 +1,20 @@
package com.yoyuzh.admin;
import com.yoyuzh.api.v2.tasks.BackgroundTaskResponse;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -16,23 +26,55 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@PreAuthorize("@adminAccessEvaluator.isAdmin(authentication)")
public class AdminController {
private final AdminService adminService;
private final AdminInspectionQueryService adminInspectionQueryService;
private final AdminTaskQueryService adminTaskQueryService;
private final AdminStoragePolicyQueryService adminStoragePolicyQueryService;
private final AdminAuditQueryService adminAuditQueryService;
private final AdminResourceGovernanceService adminResourceGovernanceService;
private final AdminStorageGovernanceService adminStorageGovernanceService;
private final AdminConfigSnapshotService adminConfigSnapshotService;
private final AdminMutableSettingsService adminMutableSettingsService;
private final AdminUserGovernanceService adminUserGovernanceService;
private final CustomUserDetailsService userDetailsService;
@GetMapping("/summary")
public ApiResponse<AdminSummaryResponse> summary() {
return ApiResponse.success(adminService.getSummary());
return ApiResponse.success(adminInspectionQueryService.getSummary());
}
@GetMapping("/settings")
public ApiResponse<AdminSettingsResponse> settings() {
return ApiResponse.success(adminConfigSnapshotService.getSettings());
}
@PatchMapping("/settings/registration/invite-code")
public ApiResponse<AdminRegistrationInviteCodeResponse> updateRegistrationInviteCode(
@Valid @RequestBody AdminRegistrationInviteCodeUpdateRequest request) {
return ApiResponse.success(adminMutableSettingsService.updateRegistrationInviteCode(request.inviteCode()));
}
@PostMapping("/settings/registration/invite-code/rotate")
public ApiResponse<AdminRegistrationInviteCodeResponse> rotateRegistrationInviteCode() {
return ApiResponse.success(adminMutableSettingsService.rotateRegistrationInviteCode());
}
@GetMapping("/filesystem")
public ApiResponse<AdminFilesystemResponse> filesystem() {
return ApiResponse.success(adminConfigSnapshotService.getFilesystem());
}
@PatchMapping("/settings/offline-transfer-storage-limit")
public ApiResponse<AdminOfflineTransferStorageLimitResponse> updateOfflineTransferStorageLimit(
@Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) {
return ApiResponse.success(adminService.updateOfflineTransferStorageLimit(
return ApiResponse.success(adminMutableSettingsService.updateOfflineTransferStorageLimit(
request.offlineTransferStorageLimitBytes()
));
}
@@ -41,7 +83,7 @@ public class AdminController {
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String query) {
return ApiResponse.success(adminService.listUsers(page, size, query));
return ApiResponse.success(adminUserGovernanceService.listUsers(page, size, query));
}
@GetMapping("/files")
@@ -49,47 +91,155 @@ public class AdminController {
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String query,
@RequestParam(defaultValue = "") String ownerQuery) {
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
return ApiResponse.success(adminInspectionQueryService.listFiles(page, size, query, ownerQuery));
}
@GetMapping("/file-blobs")
public ApiResponse<PageResponse<AdminFileBlobResponse>> fileBlobs(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) Long storagePolicyId,
@RequestParam(defaultValue = "") String objectKey,
@RequestParam(required = false) FileEntityType entityType) {
return ApiResponse.success(adminInspectionQueryService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType));
}
@GetMapping("/shares")
public ApiResponse<PageResponse<AdminShareResponse>> shares(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(defaultValue = "") String fileName,
@RequestParam(defaultValue = "") String token,
@RequestParam(required = false) Boolean passwordProtected,
@RequestParam(required = false) Boolean expired) {
return ApiResponse.success(adminInspectionQueryService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired));
}
@DeleteMapping("/shares/{shareId}")
public ApiResponse<Void> deleteShare(@PathVariable Long shareId) {
adminResourceGovernanceService.deleteShare(shareId);
return ApiResponse.success();
}
@GetMapping("/tasks")
public ApiResponse<PageResponse<AdminTaskResponse>> tasks(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) BackgroundTaskType type,
@RequestParam(required = false) BackgroundTaskStatus status,
@RequestParam(required = false) BackgroundTaskFailureCategory failureCategory,
@RequestParam(required = false) AdminTaskLeaseState leaseState) {
return ApiResponse.success(adminTaskQueryService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState));
}
@GetMapping("/tasks/{taskId}")
public ApiResponse<AdminTaskResponse> task(@PathVariable Long taskId) {
return ApiResponse.success(adminTaskQueryService.getTask(taskId));
}
@GetMapping("/storage-policies")
public ApiResponse<List<AdminStoragePolicyResponse>> storagePolicies() {
return ApiResponse.success(adminStoragePolicyQueryService.listStoragePolicies());
}
@GetMapping("/audits")
public ApiResponse<PageResponse<AdminAuditLogResponse>> audits(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String actorQuery,
@RequestParam(defaultValue = "") String actionType,
@RequestParam(defaultValue = "") String targetType,
@RequestParam(required = false) Long targetId) {
return ApiResponse.success(adminAuditQueryService.listAuditLogs(
page,
size,
actorQuery,
actionType,
targetType,
targetId
));
}
@PostMapping("/storage-policies")
public ApiResponse<AdminStoragePolicyResponse> createStoragePolicy(
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminStorageGovernanceService.createStoragePolicy(request));
}
@PutMapping("/storage-policies/{policyId}")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicy(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicy(policyId, request));
}
@PatchMapping("/storage-policies/{policyId}/status")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicyStatus(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyStatusUpdateRequest request) {
return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicyStatus(policyId, request.enabled()));
}
@PostMapping("/storage-policies/migrations")
public ApiResponse<BackgroundTaskResponse> createStoragePolicyMigrationTask(
@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(toTaskResponse(adminStorageGovernanceService.createStoragePolicyMigrationTask(user, request)));
}
@DeleteMapping("/files/{fileId}")
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
adminService.deleteFile(fileId);
adminResourceGovernanceService.deleteFile(fileId);
return ApiResponse.success();
}
@PatchMapping("/users/{userId}/role")
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
return ApiResponse.success(adminService.updateUserRole(userId, request.role()));
return ApiResponse.success(adminUserGovernanceService.updateUserRole(userId, request.role()));
}
@PatchMapping("/users/{userId}/status")
public ApiResponse<AdminUserResponse> updateUserStatus(@PathVariable Long userId,
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
return ApiResponse.success(adminService.updateUserBanned(userId, request.banned()));
return ApiResponse.success(adminUserGovernanceService.updateUserBanned(userId, request.banned()));
}
@PutMapping("/users/{userId}/password")
public ApiResponse<AdminUserResponse> updateUserPassword(@PathVariable Long userId,
@Valid @RequestBody AdminUserPasswordUpdateRequest request) {
return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword()));
return ApiResponse.success(adminUserGovernanceService.updateUserPassword(userId, request.newPassword()));
}
@PatchMapping("/users/{userId}/storage-quota")
public ApiResponse<AdminUserResponse> updateUserStorageQuota(@PathVariable Long userId,
@Valid @RequestBody AdminUserStorageQuotaUpdateRequest request) {
return ApiResponse.success(adminService.updateUserStorageQuota(userId, request.storageQuotaBytes()));
return ApiResponse.success(adminUserGovernanceService.updateUserStorageQuota(userId, request.storageQuotaBytes()));
}
@PatchMapping("/users/{userId}/max-upload-size")
public ApiResponse<AdminUserResponse> updateUserMaxUploadSize(@PathVariable Long userId,
@Valid @RequestBody AdminUserMaxUploadSizeUpdateRequest request) {
return ApiResponse.success(adminService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes()));
return ApiResponse.success(adminUserGovernanceService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes()));
}
@PostMapping("/users/{userId}/password/reset")
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
return ApiResponse.success(adminService.resetUserPassword(userId));
return ApiResponse.success(adminUserGovernanceService.resetUserPassword(userId));
}
private BackgroundTaskResponse toTaskResponse(BackgroundTask task) {
return new BackgroundTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt()
);
}
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.core.FileEntityType;
import java.time.LocalDateTime;
public record AdminFileBlobResponse(
Long entityId,
Long blobId,
String objectKey,
FileEntityType entityType,
Long storagePolicyId,
Long size,
String contentType,
Integer referenceCount,
long linkedStoredFileCount,
long linkedOwnerCount,
String sampleOwnerUsername,
String sampleOwnerEmail,
Long createdByUserId,
String createdByUsername,
LocalDateTime createdAt,
LocalDateTime blobCreatedAt,
boolean blobMissing,
boolean orphanRisk,
boolean referenceMismatch
) {
}

View File

@@ -0,0 +1,45 @@
package com.yoyuzh.admin;
public record AdminFilesystemResponse(
OverviewSection overview,
AdminStoragePolicyResponse defaultPolicy,
UploadSection upload,
MediaProcessingSection mediaProcessing,
CacheSection cache,
WebdavSection webdav
) {
public record OverviewSection(
String storageProvider,
long totalFiles,
long totalBlobs,
long totalEntities
) {
}
public record UploadSection(
boolean proxyUpload,
boolean directSingleUpload,
boolean directMultipartUpload,
long effectiveMaxFileSizeBytes
) {
}
public record MediaProcessingSection(
boolean metadataExtractionEnabled,
boolean nativeThumbnailSupport
) {
}
public record CacheSection(
String backend,
long filesListTtlSeconds,
long directoryVersionTtlSeconds
) {
}
public record WebdavSection(
boolean enabled
) {
}
}

View File

@@ -0,0 +1,242 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntity;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminInspectionQueryService {
private final UserRepository userRepository;
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final RegistrationInviteService registrationInviteService;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final AdminMetricsService adminMetricsService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final FileShareLinkRepository fileShareLinkRepository;
public AdminSummaryResponse getSummary() {
AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot();
return new AdminSummaryResponse(
userRepository.count(),
storedFileRepository.count(),
fileBlobRepository.sumAllBlobSize(),
metrics.downloadTrafficBytes(),
metrics.requestCount(),
metrics.transferUsageBytes(),
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
metrics.offlineTransferStorageLimitBytes(),
metrics.dailyActiveUsers(),
metrics.requestTimeline(),
registrationInviteService.getCurrentInviteCode()
);
}
public PageResponse<AdminFileResponse> listFiles(int page, int size, String query, String ownerQuery) {
Page<StoredFile> result = storedFileRepository.searchAdminFiles(
normalizeQuery(query),
normalizeQuery(ownerQuery),
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username")
.and(Sort.by(Sort.Direction.DESC, "createdAt")))
);
List<AdminFileResponse> items = result.getContent().stream()
.map(this::toFileResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminFileBlobResponse> listFileBlobs(int page,
int size,
String userQuery,
Long storagePolicyId,
String objectKey,
FileEntityType entityType) {
Page<FileEntity> result = fileEntityRepository.searchAdminEntities(
normalizeQuery(userQuery),
storagePolicyId,
normalizeQuery(objectKey),
entityType,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<FileEntity> entities = result.getContent();
Map<String, FileBlob> blobsByObjectKey = loadBlobsByObjectKey(entities);
Map<Long, StoredFileEntityRepository.FileEntityLinkStatsProjection> linkStatsByEntityId = loadLinkStatsByEntityId(entities);
List<AdminFileBlobResponse> items = entities.stream()
.map(entity -> toFileBlobResponse(
entity,
blobsByObjectKey.get(entity.getObjectKey()),
linkStatsByEntityId.get(entity.getId())
))
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminShareResponse> listShares(int page,
int size,
String userQuery,
String fileName,
String token,
Boolean passwordProtected,
Boolean expired) {
Page<FileShareLink> result = fileShareLinkRepository.searchAdminShares(
normalizeQuery(userQuery),
normalizeQuery(fileName),
normalizeQuery(token),
passwordProtected,
expired,
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminShareResponse> items = result.getContent().stream()
.map(this::toAdminShareResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
private AdminFileResponse toFileResponse(StoredFile storedFile) {
User owner = storedFile.getUser();
return new AdminFileResponse(
storedFile.getId(),
storedFile.getFilename(),
storedFile.getPath(),
storedFile.getSize(),
storedFile.getContentType(),
storedFile.isDirectory(),
storedFile.getCreatedAt(),
owner.getId(),
owner.getUsername(),
owner.getEmail()
);
}
private AdminFileBlobResponse toFileBlobResponse(FileEntity entity,
FileBlob blob,
StoredFileEntityRepository.FileEntityLinkStatsProjection linkStats) {
long linkedStoredFileCount = linkStats == null || linkStats.getLinkedStoredFileCount() == null
? 0L
: linkStats.getLinkedStoredFileCount();
long linkedOwnerCount = linkStats == null || linkStats.getLinkedOwnerCount() == null
? 0L
: linkStats.getLinkedOwnerCount();
String sampleOwnerUsername = linkStats == null ? null : linkStats.getSampleOwnerUsername();
String sampleOwnerEmail = linkStats == null ? null : linkStats.getSampleOwnerEmail();
return new AdminFileBlobResponse(
entity.getId(),
blob == null ? null : blob.getId(),
entity.getObjectKey(),
entity.getEntityType(),
entity.getStoragePolicyId(),
entity.getSize(),
StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob == null ? null : blob.getContentType(),
entity.getReferenceCount(),
linkedStoredFileCount,
linkedOwnerCount,
sampleOwnerUsername,
sampleOwnerEmail,
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(),
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(),
entity.getCreatedAt(),
blob == null ? null : blob.getCreatedAt(),
blob == null,
linkedStoredFileCount == 0,
entity.getReferenceCount() == null || entity.getReferenceCount() != linkedStoredFileCount
);
}
private Map<String, FileBlob> loadBlobsByObjectKey(List<FileEntity> entities) {
Set<String> objectKeys = entities.stream()
.map(FileEntity::getObjectKey)
.filter(StringUtils::hasText)
.collect(Collectors.toSet());
if (objectKeys.isEmpty()) {
return Map.of();
}
return fileBlobRepository.findAllByObjectKeyIn(objectKeys).stream()
.collect(Collectors.toMap(
FileBlob::getObjectKey,
Function.identity(),
(left, right) -> left
));
}
private Map<Long, StoredFileEntityRepository.FileEntityLinkStatsProjection> loadLinkStatsByEntityId(List<FileEntity> entities) {
Set<Long> entityIds = entities.stream()
.map(FileEntity::getId)
.filter(id -> id != null)
.collect(Collectors.toSet());
if (entityIds.isEmpty()) {
return Collections.emptyMap();
}
return storedFileEntityRepository.findAdminLinkStatsByFileEntityIds(entityIds).stream()
.collect(Collectors.toMap(
StoredFileEntityRepository.FileEntityLinkStatsProjection::getFileEntityId,
Function.identity()
));
}
private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) {
StoredFile file = shareLink.getFile();
User owner = shareLink.getOwner();
boolean expired = shareLink.getExpiresAt() != null && shareLink.getExpiresAt().isBefore(LocalDateTime.now());
return new AdminShareResponse(
shareLink.getId(),
shareLink.getToken(),
shareLink.getShareNameOrDefault(),
shareLink.hasPassword(),
expired,
shareLink.getCreatedAt(),
shareLink.getExpiresAt(),
shareLink.getMaxDownloads(),
shareLink.getDownloadCountOrZero(),
shareLink.getViewCountOrZero(),
shareLink.isAllowImportEnabled(),
shareLink.isAllowDownloadEnabled(),
owner.getId(),
owner.getUsername(),
owner.getEmail(),
file.getId(),
file.getFilename(),
file.getPath(),
file.getContentType(),
file.getSize(),
file.isDirectory()
);
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
}

View File

@@ -0,0 +1,66 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.RegistrationInviteService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AdminMutableSettingsService {
private final RegistrationInviteService registrationInviteService;
private final AdminMetricsService adminMetricsService;
private final AdminAuditService adminAuditService;
@Transactional
public AdminRegistrationInviteCodeResponse updateRegistrationInviteCode(String inviteCode) {
String normalizedInviteCode = normalizeQuery(inviteCode);
String currentInviteCode = registrationInviteService.updateCurrentInviteCode(normalizedInviteCode);
adminAuditService.record(
AdminAuditAction.UPDATE_REGISTRATION_INVITE_CODE,
"SYSTEM_SETTING",
null,
"Updated registration invite code",
Map.of("inviteCodeLength", currentInviteCode.length())
);
return new AdminRegistrationInviteCodeResponse(currentInviteCode);
}
@Transactional
public AdminRegistrationInviteCodeResponse rotateRegistrationInviteCode() {
String currentInviteCode = registrationInviteService.rotateCurrentInviteCode();
adminAuditService.record(
AdminAuditAction.ROTATE_REGISTRATION_INVITE_CODE,
"SYSTEM_SETTING",
null,
"Rotated registration invite code",
Map.of("inviteCodeLength", currentInviteCode.length())
);
return new AdminRegistrationInviteCodeResponse(currentInviteCode);
}
@Transactional
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
AdminOfflineTransferStorageLimitResponse response = adminMetricsService.updateOfflineTransferStorageLimit(
offlineTransferStorageLimitBytes
);
adminAuditService.record(
AdminAuditAction.UPDATE_OFFLINE_TRANSFER_STORAGE_LIMIT,
"SYSTEM_SETTING",
null,
"Updated offline transfer storage limit",
Map.of("offlineTransferStorageLimitBytes", response.offlineTransferStorageLimitBytes())
);
return response;
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.admin;
public record AdminRegistrationInviteCodeResponse(
String currentInviteCode
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AdminRegistrationInviteCodeUpdateRequest(
@NotBlank(message = "邀请码不能为空")
@Size(max = 64, message = "邀请码长度不能超过 64 个字符")
String inviteCode
) {
}

View File

@@ -0,0 +1,60 @@
package com.yoyuzh.admin;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AdminResourceGovernanceService {
private final StoredFileRepository storedFileRepository;
private final FileService fileService;
private final FileShareLinkRepository fileShareLinkRepository;
private final AdminAuditService adminAuditService;
@Transactional
public void deleteShare(Long shareId) {
FileShareLink shareLink = fileShareLinkRepository.findById(shareId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "share not found"));
fileShareLinkRepository.delete(shareLink);
Map<String, Object> details = new LinkedHashMap<>();
details.put("token", shareLink.getToken());
adminAuditService.record(
AdminAuditAction.DELETE_SHARE,
"SHARE",
shareId,
"Deleted share link",
details
);
}
@Transactional
public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "file not found"));
fileService.delete(storedFile.getUser(), fileId);
Map<String, Object> details = new LinkedHashMap<>();
details.put("ownerUserId", storedFile.getUser().getId());
details.put("path", storedFile.getPath());
details.put("filename", storedFile.getFilename());
details.put("directory", storedFile.isDirectory());
adminAuditService.record(
AdminAuditAction.DELETE_FILE,
"FILE",
fileId,
"Deleted file",
details
);
}
}

View File

@@ -0,0 +1,73 @@
package com.yoyuzh.admin;
import java.util.List;
public record AdminSettingsResponse(
SiteSection site,
RegistrationSection registration,
UserSessionSection userSession,
TransferSection transfer,
MediaProcessingSection mediaProcessing,
QueueSection queue,
AppearanceSection appearance,
ServerSection server
) {
public record SiteSection(
boolean supported,
boolean writeSupported
) {
}
public record RegistrationSection(
boolean inviteCodeRequired,
String currentInviteCode,
List<String> managementRoles,
boolean writeSupported
) {
}
public record UserSessionSection(
long accessExpirationSeconds,
long refreshExpirationSeconds,
boolean tokenBlacklistEnabled,
long tokenBlacklistTtlBufferSeconds,
boolean writeSupported
) {
}
public record TransferSection(
long offlineTransferStorageLimitBytes,
boolean writeSupported
) {
}
public record MediaProcessingSection(
boolean metadataExtractionEnabled,
boolean thumbnailGenerationEnabled,
boolean videoPosterEnabled,
boolean writeSupported
) {
}
public record QueueSection(
String backend,
long mediaMetadataFixedDelayMs,
long mediaMetadataInitialDelayMs,
boolean writeSupported
) {
}
public record AppearanceSection(
boolean supported,
boolean writeSupported
) {
}
public record ServerSection(
String storageProvider,
boolean redisEnabled,
boolean writeSupported
) {
}
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import java.time.LocalDateTime;
public record AdminShareResponse(
Long id,
String token,
String shareName,
boolean passwordProtected,
boolean expired,
LocalDateTime createdAt,
LocalDateTime expiresAt,
Integer maxDownloads,
long downloadCount,
long viewCount,
boolean allowImport,
boolean allowDownload,
Long ownerId,
String ownerUsername,
String ownerEmail,
Long fileId,
String fileName,
String filePath,
String fileContentType,
long fileSize,
boolean directory
) {
}

View File

@@ -0,0 +1,199 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskCommandService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AdminStorageGovernanceService {
private final StoragePolicyRepository storagePolicyRepository;
private final StoragePolicyService storagePolicyService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final BackgroundTaskCommandService backgroundTaskCommandService;
private final AdminAuditService adminAuditService;
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
applyStoragePolicyUpsert(policy, request);
AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy));
adminAuditService.record(
AdminAuditAction.CREATE_STORAGE_POLICY,
"STORAGE_POLICY",
response.id(),
"Created storage policy",
Map.of(
"name", response.name(),
"type", response.type().name(),
"enabled", response.enabled()
)
);
return response;
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy));
adminAuditService.record(
AdminAuditAction.UPDATE_STORAGE_POLICY,
"STORAGE_POLICY",
policyId,
"Updated storage policy",
Map.of(
"name", response.name(),
"type", response.type().name(),
"enabled", response.enabled()
)
);
return response;
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
if (policy.isDefaultPolicy() && !enabled) {
throw new BusinessException(ErrorCode.UNKNOWN, "姒涙顓荤€涙ê鍋嶇粵鏍殣娑撳秷鍏橀崑婊呮暏");
}
policy.setEnabled(enabled);
AdminStoragePolicyResponse response = AdminStoragePolicyResponses.from(storagePolicyService, storagePolicyRepository.save(policy));
adminAuditService.record(
AdminAuditAction.UPDATE_STORAGE_POLICY_STATUS,
"STORAGE_POLICY",
policyId,
enabled ? "Enabled storage policy" : "Disabled storage policy",
Map.of("enabled", enabled)
);
return response;
}
@Transactional
public BackgroundTask createStoragePolicyMigrationTask(User user, AdminStoragePolicyMigrationCreateRequest request) {
StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId());
StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId());
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "濠ф劕鐡ㄩ崒銊х摜閻c儱鎷伴惄顔界垼鐎涙ê鍋嶇粵鏍殣娑撳秷鍏橀惄绋挎倱");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
Map<String, Object> state = new LinkedHashMap<>();
state.put("sourcePolicyId", sourcePolicy.getId());
state.put("sourcePolicyName", sourcePolicy.getName());
state.put("targetPolicyId", targetPolicy.getId());
state.put("targetPolicyName", targetPolicy.getName());
state.put("candidateEntityCount", candidateEntityCount);
state.put("candidateStoredFileCount", candidateStoredFileCount);
state.put("migrationPerformed", false);
state.put("migrationMode", "skeleton");
state.put("entityType", FileEntityType.VERSION.name());
state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data");
Map<String, Object> privateState = new LinkedHashMap<>(state);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
BackgroundTask task = backgroundTaskCommandService.createQueuedTask(
user,
BackgroundTaskType.STORAGE_POLICY_MIGRATION,
state,
privateState,
request.correlationId()
);
Map<String, Object> auditDetails = new LinkedHashMap<>();
auditDetails.put("sourcePolicyId", sourcePolicy.getId());
auditDetails.put("targetPolicyId", targetPolicy.getId());
auditDetails.put("correlationId", request.correlationId());
adminAuditService.record(
AdminAuditAction.CREATE_STORAGE_POLICY_MIGRATION_TASK,
"TASK",
task.getId(),
"Created storage policy migration task",
auditDetails
);
return task;
}
private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) {
if (policy.isDefaultPolicy() && !request.enabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "姒涙顓荤€涙ê鍋嶇粵鏍殣娑撳秷鍏橀崑婊呮暏");
}
validateStoragePolicyRequest(request);
policy.setName(request.name().trim());
policy.setType(request.type());
policy.setBucketName(normalizeNullable(request.bucketName()));
policy.setEndpoint(normalizeNullable(request.endpoint()));
policy.setRegion(normalizeNullable(request.region()));
policy.setPrivateBucket(request.privateBucket());
policy.setPrefix(normalizePrefix(request.prefix()));
policy.setCredentialMode(request.credentialMode());
policy.setMaxSizeBytes(request.maxSizeBytes());
policy.setCapabilitiesJson(storagePolicyService.writeCapabilities(request.capabilities()));
policy.setEnabled(request.enabled());
}
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "storage policy not found"));
}
private String normalizeNullable(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String normalizePrefix(String prefix) {
if (!StringUtils.hasText(prefix)) {
return "";
}
return prefix.trim();
}
private void validateStoragePolicyRequest(AdminStoragePolicyUpsertRequest request) {
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.LOCAL
&& request.credentialMode() != com.yoyuzh.files.policy.StoragePolicyCredentialMode.NONE) {
throw new BusinessException(ErrorCode.UNKNOWN, "閺堫剙婀寸€涙ê鍋嶇粵鏍殣韫囧懘銆忔担璺ㄦ暏 NONE 閸戭叀鐦夊Ο鈥崇础");
}
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE
&& !StringUtils.hasText(request.bucketName())) {
throw new BusinessException(ErrorCode.UNKNOWN, "S3 鐎涙ê鍋嶇粵鏍殣韫囧懘銆忛幓鎰返 bucketName");
}
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyMigrationCreateRequest(
@NotNull(message = "sourcePolicyId 不能为空")
Long sourcePolicyId,
@NotNull(message = "targetPolicyId 不能为空")
Long targetPolicyId,
String correlationId
) {
}

View File

@@ -0,0 +1,29 @@
package com.yoyuzh.admin;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AdminStoragePolicyQueryService {
private final StoragePolicyRepository storagePolicyRepository;
private final StoragePolicyService storagePolicyService;
@Cacheable(cacheNames = RedisCacheNames.STORAGE_POLICIES, key = "'all'")
public List<AdminStoragePolicyResponse> listStoragePolicies() {
return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy")
.and(Sort.by(Sort.Direction.DESC, "enabled"))
.and(Sort.by(Sort.Direction.ASC, "id")))
.stream()
.map(policy -> AdminStoragePolicyResponses.from(storagePolicyService, policy))
.toList();
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyType;
import java.time.LocalDateTime;
public record AdminStoragePolicyResponse(
Long id,
String name,
StoragePolicyType type,
String bucketName,
String endpoint,
String region,
boolean privateBucket,
String prefix,
StoragePolicyCredentialMode credentialMode,
long maxSizeBytes,
StoragePolicyCapabilities capabilities,
boolean enabled,
boolean defaultPolicy,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,30 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyService;
final class AdminStoragePolicyResponses {
private AdminStoragePolicyResponses() {
}
static AdminStoragePolicyResponse from(StoragePolicyService storagePolicyService, StoragePolicy policy) {
return new AdminStoragePolicyResponse(
policy.getId(),
policy.getName(),
policy.getType(),
policy.getBucketName(),
policy.getEndpoint(),
policy.getRegion(),
policy.isPrivateBucket(),
policy.getPrefix(),
policy.getCredentialMode(),
policy.getMaxSizeBytes(),
storagePolicyService.readCapabilities(policy),
policy.isEnabled(),
policy.isDefaultPolicy(),
policy.getCreatedAt(),
policy.getUpdatedAt()
);
}
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyStatusUpdateRequest(
@NotNull(message = "enabled 不能为空")
Boolean enabled
) {
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record AdminStoragePolicyUpsertRequest(
@NotBlank(message = "存储策略名称不能为空")
String name,
@NotNull(message = "存储策略类型不能为空")
StoragePolicyType type,
String bucketName,
String endpoint,
String region,
boolean privateBucket,
String prefix,
@NotNull(message = "凭证模式不能为空")
StoragePolicyCredentialMode credentialMode,
@Positive(message = "最大对象大小必须大于 0")
long maxSizeBytes,
@NotNull(message = "能力声明不能为空")
StoragePolicyCapabilities capabilities,
boolean enabled
) {
}

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.admin;
public enum AdminTaskLeaseState {
ACTIVE,
EXPIRED,
NONE
}

View File

@@ -0,0 +1,148 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminTaskQueryService {
private final BackgroundTaskRepository backgroundTaskRepository;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
public PageResponse<AdminTaskResponse> listTasks(int page,
int size,
String userQuery,
BackgroundTaskType type,
BackgroundTaskStatus status,
BackgroundTaskFailureCategory failureCategory,
AdminTaskLeaseState leaseState) {
String failureCategoryPattern = failureCategory == null
? null
: "\"failureCategory\":\"" + failureCategory.name() + "\"";
Page<BackgroundTask> result = backgroundTaskRepository.searchAdminTasks(
normalizeQuery(userQuery),
type,
status,
failureCategoryPattern,
leaseState == null ? null : leaseState.name(),
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
Map<Long, User> ownerById = userRepository.findAllById(result.getContent().stream()
.map(BackgroundTask::getUserId)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, user -> user));
return new PageResponse<>(
result.getContent().stream()
.map(task -> toAdminTaskResponse(task, ownerById.get(task.getUserId())))
.toList(),
result.getTotalElements(),
page,
size
);
}
public AdminTaskResponse getTask(Long taskId) {
BackgroundTask task = backgroundTaskRepository.findById(taskId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "task not found"));
User owner = userRepository.findById(task.getUserId()).orElse(null);
return toAdminTaskResponse(task, owner);
}
private AdminTaskResponse toAdminTaskResponse(BackgroundTask task, User owner) {
Map<String, Object> state = parseState(task.getPublicStateJson());
return new AdminTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
owner == null ? null : owner.getUsername(),
owner == null ? null : owner.getEmail(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getAttemptCount(),
task.getMaxAttempts(),
task.getNextRunAt(),
task.getLeaseOwner(),
task.getLeaseExpiresAt(),
task.getHeartbeatAt(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt(),
readStringState(state, "failureCategory"),
readBooleanState(state, "retryScheduled"),
readStringState(state, "workerOwner"),
resolveLeaseState(task)
);
}
private Map<String, Object> parseState(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
return Map.of();
}
}
private String readStringState(Map<String, Object> state, String key) {
Object value = state.get(key);
return value == null ? null : String.valueOf(value);
}
private Boolean readBooleanState(Map<String, Object> state, String key) {
Object value = state.get(key);
if (value instanceof Boolean boolValue) {
return boolValue;
}
if (value instanceof String stringValue) {
return Boolean.parseBoolean(stringValue);
}
return null;
}
private AdminTaskLeaseState resolveLeaseState(BackgroundTask task) {
if (!StringUtils.hasText(task.getLeaseOwner()) || task.getLeaseExpiresAt() == null) {
return AdminTaskLeaseState.NONE;
}
return task.getLeaseExpiresAt().isBefore(LocalDateTime.now())
? AdminTaskLeaseState.EXPIRED
: AdminTaskLeaseState.ACTIVE;
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
}

View File

@@ -0,0 +1,32 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import java.time.LocalDateTime;
public record AdminTaskResponse(
Long id,
BackgroundTaskType type,
BackgroundTaskStatus status,
Long userId,
String ownerUsername,
String ownerEmail,
String publicStateJson,
String correlationId,
String errorMessage,
Integer attemptCount,
Integer maxAttempts,
LocalDateTime nextRunAt,
String leaseOwner,
LocalDateTime leaseExpiresAt,
LocalDateTime heartbeatAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime finishedAt,
String failureCategory,
Boolean retryScheduled,
String workerOwner,
AdminTaskLeaseState leaseState
) {
}

View File

@@ -1,19 +1,16 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.AuthSessionPolicy;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.FileBlobRepository;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -23,133 +20,147 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminService {
public class AdminUserGovernanceService {
private final UserRepository userRepository;
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final RegistrationInviteService registrationInviteService;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final AdminMetricsService adminMetricsService;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final AuthSessionPolicy authSessionPolicy;
private final AdminAuditService adminAuditService;
private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() {
AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot();
return new AdminSummaryResponse(
userRepository.count(),
storedFileRepository.count(),
fileBlobRepository.sumAllBlobSize(),
metrics.downloadTrafficBytes(),
metrics.requestCount(),
metrics.transferUsageBytes(),
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
metrics.offlineTransferStorageLimitBytes(),
metrics.dailyActiveUsers(),
metrics.requestTimeline(),
registrationInviteService.getCurrentInviteCode()
);
}
public PageResponse<AdminUserResponse> listUsers(int page, int size, String query) {
Page<User> result = userRepository.searchByUsernameOrEmail(
normalizeQuery(query),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminUserResponse> items = result.getContent().stream()
.map(this::toUserResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminFileResponse> listFiles(int page, int size, String query, String ownerQuery) {
Page<StoredFile> result = storedFileRepository.searchAdminFiles(
normalizeQuery(query),
normalizeQuery(ownerQuery),
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username")
.and(Sort.by(Sort.Direction.DESC, "createdAt")))
List<User> users = result.getContent();
Map<Long, Long> usedStorageByUserId = loadUsedStorageByUserIds(users);
return new PageResponse<>(
users.stream()
.map(user -> toUserResponse(user, usedStorageByUserId.getOrDefault(user.getId(), 0L)))
.toList(),
result.getTotalElements(),
page,
size
);
List<AdminFileResponse> items = result.getContent().stream()
.map(this::toFileResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
@Transactional
public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
fileService.delete(storedFile.getUser(), fileId);
}
@Transactional
public AdminUserResponse updateUserRole(Long userId, UserRole role) {
User user = getRequiredUser(userId);
user.setRole(role);
return toUserResponse(userRepository.save(user));
AdminUserResponse response = toUserResponse(userRepository.save(user));
adminAuditService.record(
AdminAuditAction.UPDATE_USER_ROLE,
"USER",
userId,
"Updated user role",
Map.of("role", role.name())
);
return response;
}
@Transactional
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
User user = getRequiredUser(userId);
user.setBanned(banned);
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
authSessionPolicy.rotateAllActiveSessions(user);
refreshTokenService.revokeAllForUser(user.getId());
return toUserResponse(userRepository.save(user));
AdminUserResponse response = toUserResponse(userRepository.save(user));
adminAuditService.record(
AdminAuditAction.UPDATE_USER_BANNED,
"USER",
userId,
banned ? "Banned user" : "Unbanned user",
Map.of("banned", banned)
);
return response;
}
@Transactional
public AdminUserResponse updateUserPassword(Long userId, String newPassword) {
if (!PasswordPolicy.isStrong(newPassword)) {
throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
}
User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
refreshTokenService.revokeAllForUser(user.getId());
return toUserResponse(userRepository.save(user));
return updateUserPasswordInternal(userId, newPassword, AdminAuditAction.UPDATE_USER_PASSWORD);
}
@Transactional
public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) {
User user = getRequiredUser(userId);
user.setStorageQuotaBytes(storageQuotaBytes);
return toUserResponse(userRepository.save(user));
AdminUserResponse response = toUserResponse(userRepository.save(user));
adminAuditService.record(
AdminAuditAction.UPDATE_USER_STORAGE_QUOTA,
"USER",
userId,
"Updated user storage quota",
Map.of("storageQuotaBytes", storageQuotaBytes)
);
return response;
}
@Transactional
public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) {
User user = getRequiredUser(userId);
user.setMaxUploadSizeBytes(maxUploadSizeBytes);
return toUserResponse(userRepository.save(user));
AdminUserResponse response = toUserResponse(userRepository.save(user));
adminAuditService.record(
AdminAuditAction.UPDATE_USER_MAX_UPLOAD_SIZE,
"USER",
userId,
"Updated user max upload size",
Map.of("maxUploadSizeBytes", maxUploadSizeBytes)
);
return response;
}
@Transactional
public AdminPasswordResetResponse resetUserPassword(Long userId) {
String temporaryPassword = generateTemporaryPassword();
updateUserPassword(userId, temporaryPassword);
updateUserPasswordInternal(userId, temporaryPassword, AdminAuditAction.RESET_USER_PASSWORD);
return new AdminPasswordResetResponse(temporaryPassword);
}
@Transactional
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes);
private AdminUserResponse updateUserPasswordInternal(Long userId, String newPassword, AdminAuditAction action) {
if (!PasswordPolicy.isStrong(newPassword)) {
throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
}
User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword));
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
authSessionPolicy.rotateAllActiveSessions(user);
refreshTokenService.revokeAllForUser(user.getId());
AdminUserResponse response = toUserResponse(userRepository.save(user));
Map<String, Object> details = new LinkedHashMap<>();
details.put("passwordLength", newPassword.length());
details.put("temporaryPassword", action == AdminAuditAction.RESET_USER_PASSWORD);
adminAuditService.record(
action,
"USER",
userId,
action == AdminAuditAction.RESET_USER_PASSWORD
? "Reset user password"
: "Updated user password",
details
);
return response;
}
private AdminUserResponse toUserResponse(User user) {
long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
return toUserResponse(user, storedFileRepository.sumFileSizeByUserId(user.getId()));
}
private AdminUserResponse toUserResponse(User user, long usedStorageBytes) {
return new AdminUserResponse(
user.getId(),
user.getUsername(),
@@ -164,25 +175,24 @@ public class AdminService {
);
}
private AdminFileResponse toFileResponse(StoredFile storedFile) {
User owner = storedFile.getUser();
return new AdminFileResponse(
storedFile.getId(),
storedFile.getFilename(),
storedFile.getPath(),
storedFile.getSize(),
storedFile.getContentType(),
storedFile.isDirectory(),
storedFile.getCreatedAt(),
owner.getId(),
owner.getUsername(),
owner.getEmail()
);
private Map<Long, Long> loadUsedStorageByUserIds(List<User> users) {
Set<Long> userIds = users.stream()
.map(User::getId)
.filter(id -> id != null)
.collect(Collectors.toSet());
if (userIds.isEmpty()) {
return Map.of();
}
return storedFileRepository.sumFileSizeByUserIds(userIds).stream()
.collect(Collectors.toMap(
StoredFileRepository.UserStorageUsageProjection::getUserId,
projection -> projection.getUsedStorageBytes() == null ? 0L : projection.getUsedStorageBytes()
));
}
private User getRequiredUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found"));
}
private String normalizeQuery(String query) {

View File

@@ -0,0 +1,27 @@
package com.yoyuzh.api.v2;
import org.springframework.http.HttpStatus;
public enum ApiV2ErrorCode {
BAD_REQUEST(2400, HttpStatus.BAD_REQUEST),
NOT_LOGGED_IN(2401, HttpStatus.UNAUTHORIZED),
PERMISSION_DENIED(2403, HttpStatus.FORBIDDEN),
FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND),
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
private final int code;
private final HttpStatus httpStatus;
ApiV2ErrorCode(int code, HttpStatus httpStatus) {
this.code = code;
this.httpStatus = httpStatus;
}
public int getCode() {
return code;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}

View File

@@ -0,0 +1,15 @@
package com.yoyuzh.api.v2;
public class ApiV2Exception extends RuntimeException {
private final ApiV2ErrorCode errorCode;
public ApiV2Exception(ApiV2ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ApiV2ErrorCode getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,43 @@
package com.yoyuzh.api.v2;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackages = "com.yoyuzh.api.v2")
public class ApiV2ExceptionHandler {
@ExceptionHandler(ApiV2Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleApiV2Exception(ApiV2Exception ex) {
ApiV2ErrorCode errorCode = ex.getErrorCode();
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiV2Response<Void>> handleBusinessException(BusinessException ex) {
ApiV2ErrorCode errorCode = mapBusinessErrorCode(ex.getErrorCode());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
return ResponseEntity
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
.body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
}
private ApiV2ErrorCode mapBusinessErrorCode(ErrorCode errorCode) {
return switch (errorCode) {
case NOT_LOGGED_IN -> ApiV2ErrorCode.NOT_LOGGED_IN;
case PERMISSION_DENIED -> ApiV2ErrorCode.PERMISSION_DENIED;
case FILE_NOT_FOUND -> ApiV2ErrorCode.FILE_NOT_FOUND;
case UNKNOWN -> ApiV2ErrorCode.BAD_REQUEST;
};
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2;
public record ApiV2Response<T>(int code, String msg, T data) {
public static <T> ApiV2Response<T> success(T data) {
return new ApiV2Response<>(0, "success", data);
}
public static ApiV2Response<Void> error(ApiV2ErrorCode errorCode, String msg) {
return new ApiV2Response<>(errorCode.getCode(), msg, null);
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CreateUploadSessionV2Request(
@NotBlank String path,
@NotBlank String filename,
String contentType,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,31 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.events.FileEventService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequestMapping("/api/v2/files")
@RequiredArgsConstructor
public class FileEventsV2Controller {
private final FileEventService fileEventService;
private final CustomUserDetailsService userDetailsService;
@GetMapping(value = "/events", produces = "text/event-stream")
public SseEmitter events(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false, defaultValue = "/") String path,
@RequestHeader(value = "X-Yoyuzh-Client-Id", required = false) String clientId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return fileEventService.openStream(user, path, clientId);
}
}

View File

@@ -0,0 +1,71 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.search.FileSearchQuery;
import com.yoyuzh.files.search.FileSearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Locale;
@RestController
@RequestMapping("/api/v2/files")
@RequiredArgsConstructor
public class FileSearchV2Controller {
private final FileSearchService fileSearchService;
private final CustomUserDetailsService userDetailsService;
@GetMapping("/search")
public ApiV2Response<PageResponse<FileMetadataResponse>> search(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type,
@RequestParam(required = false) Long sizeGte,
@RequestParam(required = false) Long sizeLte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime createdGte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime createdLte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime updatedGte,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime updatedLte,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(fileSearchService.search(
user,
new FileSearchQuery(name, parseType(type), sizeGte, sizeLte, createdGte, createdLte, updatedGte, updatedLte, page, size)
));
}
private Boolean parseType(String type) {
if (!StringUtils.hasText(type) || "all".equalsIgnoreCase(type.trim())) {
return null;
}
return switch (type.trim().toLowerCase(Locale.ROOT)) {
case "file" -> false;
case "directory", "folder" -> true;
default -> throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件类型筛选只支持 file 或 directory");
};
}
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.api.v2.files;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record MarkUploadSessionPartV2Request(
@NotBlank String etag,
@Min(0) long size
) {
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2.files;
import java.util.Map;
public record PreparedUploadV2Response(
boolean direct,
String uploadUrl,
String method,
Map<String, String> headers,
String storageName
) {
}

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.api.v2.files;
import java.time.LocalDateTime;
public record UploadSessionRuntimeStateV2Response(
String phase,
long uploadedBytes,
int uploadedPartCount,
Integer progressPercent,
LocalDateTime lastUpdatedAt,
LocalDateTime expiresAt
) {
}

View File

@@ -0,0 +1,193 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.upload.UploadSession;
import com.yoyuzh.files.upload.UploadSessionRuntimeState;
import com.yoyuzh.files.upload.UploadSessionCreateCommand;
import com.yoyuzh.files.upload.UploadSessionUploadMode;
import com.yoyuzh.files.upload.UploadSessionPartCommand;
import com.yoyuzh.files.upload.UploadSessionService;
import com.yoyuzh.files.storage.PreparedUpload;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v2/files/upload-sessions")
@RequiredArgsConstructor
public class UploadSessionV2Controller {
private final UploadSessionService uploadSessionService;
private final CustomUserDetailsService userDetailsService;
@PostMapping
public ApiV2Response<UploadSessionV2Response> createSession(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateUploadSessionV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
UploadSession session = uploadSessionService.createSession(user, new UploadSessionCreateCommand(
request.path(),
request.filename(),
request.contentType(),
request.size()
));
return ApiV2Response.success(toResponse(session));
}
@GetMapping("/{sessionId}")
public ApiV2Response<UploadSessionV2Response> getSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId)));
}
@GetMapping("/{sessionId}/prepare")
public ApiV2Response<PreparedUploadV2Response> prepareUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, sessionId);
return ApiV2Response.success(new PreparedUploadV2Response(
preparedUpload.direct(),
preparedUpload.uploadUrl(),
preparedUpload.method(),
preparedUpload.headers(),
preparedUpload.storageName()
));
}
@DeleteMapping("/{sessionId}")
public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId)));
}
@PostMapping("/{sessionId}/complete")
public ApiV2Response<UploadSessionV2Response> completeSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
}
@PutMapping("/{sessionId}/parts/{partIndex}")
public ApiV2Response<UploadSessionV2Response> recordPart(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable int partIndex,
@Valid @RequestBody MarkUploadSessionPartV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
UploadSession session = uploadSessionService.recordUploadedPart(
user,
sessionId,
partIndex,
new UploadSessionPartCommand(request.etag(), request.size())
);
return ApiV2Response.success(toResponse(session));
}
@PostMapping("/{sessionId}/content")
public ApiV2Response<UploadSessionV2Response> uploadContent(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@RequestPart("file") MultipartFile file) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.uploadOwnedContent(user, sessionId, file)));
}
@GetMapping("/{sessionId}/parts/{partIndex}/prepare")
public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@PathVariable int partIndex) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedPartUpload(user, sessionId, partIndex);
return ApiV2Response.success(new PreparedUploadV2Response(
preparedUpload.direct(),
preparedUpload.uploadUrl(),
preparedUpload.method(),
preparedUpload.headers(),
preparedUpload.storageName()
));
}
private UploadSessionV2Response toResponse(UploadSession session) {
UploadSessionUploadMode uploadMode = uploadSessionService.resolveUploadMode(session);
if (uploadMode == null) {
uploadMode = session.getMultipartUploadId() != null
? UploadSessionUploadMode.DIRECT_MULTIPART
: UploadSessionUploadMode.PROXY;
}
return new UploadSessionV2Response(
session.getSessionId(),
session.getObjectKey(),
uploadMode != UploadSessionUploadMode.PROXY,
uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART,
uploadMode.name(),
session.getTargetPath(),
session.getFilename(),
session.getContentType(),
session.getSize(),
session.getStoragePolicyId(),
session.getStatus().name(),
session.getChunkSize(),
session.getChunkCount(),
session.getExpiresAt(),
session.getCreatedAt(),
session.getUpdatedAt(),
uploadSessionService.getRuntimeState(session.getSessionId())
.map(this::toRuntimeResponse)
.orElse(null),
toStrategyResponse(session.getSessionId(), uploadMode)
);
}
private UploadSessionRuntimeStateV2Response toRuntimeResponse(UploadSessionRuntimeState runtimeState) {
return new UploadSessionRuntimeStateV2Response(
runtimeState.phase(),
runtimeState.uploadedBytes(),
runtimeState.uploadedPartCount(),
runtimeState.progressPercent(),
runtimeState.lastUpdatedAt(),
runtimeState.expiresAt()
);
}
private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) {
String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId;
return switch (uploadMode) {
case PROXY -> new UploadSessionV2StrategyResponse(
null,
sessionBasePath + "/content",
null,
null,
sessionBasePath + "/complete",
"file"
);
case DIRECT_SINGLE -> new UploadSessionV2StrategyResponse(
sessionBasePath + "/prepare",
null,
null,
null,
sessionBasePath + "/complete",
null
);
case DIRECT_MULTIPART -> new UploadSessionV2StrategyResponse(
null,
null,
sessionBasePath + "/parts/{partIndex}/prepare",
sessionBasePath + "/parts/{partIndex}",
sessionBasePath + "/complete",
null
);
};
}
}

View File

@@ -0,0 +1,25 @@
package com.yoyuzh.api.v2.files;
import java.time.LocalDateTime;
public record UploadSessionV2Response(
String sessionId,
String objectKey,
boolean directUpload,
boolean multipartUpload,
String uploadMode,
String path,
String filename,
String contentType,
long size,
Long storagePolicyId,
String status,
long chunkSize,
int chunkCount,
LocalDateTime expiresAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
UploadSessionRuntimeStateV2Response runtime,
UploadSessionV2StrategyResponse strategy
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.api.v2.files;
public record UploadSessionV2StrategyResponse(
String prepareUrl,
String proxyContentUrl,
String partPrepareUrlTemplate,
String partRecordUrlTemplate,
String completeUrl,
String proxyFormField
) {
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
public record CreateShareV2Request(
@NotNull Long fileId,
String password,
LocalDateTime expiresAt,
@Min(1) Integer maxDownloads,
Boolean allowImport,
Boolean allowDownload,
String shareName
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.NotBlank;
public record ImportShareV2Request(
@NotBlank String path,
String password
) {
}

View File

@@ -0,0 +1,83 @@
package com.yoyuzh.api.v2.shares;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileMetadataResponse;
import com.yoyuzh.files.share.ShareV2Service;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/shares")
@RequiredArgsConstructor
public class ShareV2Controller {
private final ShareV2Service shareV2Service;
private final CustomUserDetailsService userDetailsService;
@PostMapping
public ApiV2Response<ShareV2Response> createShare(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateShareV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(shareV2Service.createShare(user, request));
}
@GetMapping("/{token}")
public ApiV2Response<ShareV2Response> getShare(@PathVariable String token) {
return ApiV2Response.success(shareV2Service.getShare(token));
}
@GetMapping(value = "/{token}", params = "download")
public ResponseEntity<?> downloadShare(@PathVariable String token,
@RequestParam(required = false) String password) {
return shareV2Service.downloadSharedFile(token, password);
}
@PostMapping("/{token}/verify-password")
public ApiV2Response<ShareV2Response> verifyPassword(@PathVariable String token,
@Valid @RequestBody VerifySharePasswordV2Request request) {
return ApiV2Response.success(shareV2Service.verifyPassword(token, request));
}
@PostMapping("/{token}/import")
public ApiV2Response<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String token,
@Valid @RequestBody ImportShareV2Request request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(shareV2Service.importSharedFile(user, token, request));
}
@GetMapping("/mine")
public ApiV2Response<PageResponse<ShareV2Response>> mine(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
var result = shareV2Service.listOwnedShares(user, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")));
return ApiV2Response.success(new PageResponse<>(result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()));
}
@DeleteMapping("/{id}")
public ApiV2Response<Void> deleteShare(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
shareV2Service.deleteOwnedShare(user, id);
return ApiV2Response.success(null);
}
}

View File

@@ -0,0 +1,23 @@
package com.yoyuzh.api.v2.shares;
import com.yoyuzh.files.core.FileMetadataResponse;
import java.time.LocalDateTime;
public record ShareV2Response(
Long id,
String token,
String shareName,
String ownerUsername,
boolean passwordRequired,
boolean passwordVerified,
boolean allowImport,
boolean allowDownload,
Integer maxDownloads,
long downloadCount,
long viewCount,
LocalDateTime expiresAt,
LocalDateTime createdAt,
FileMetadataResponse file
) {
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.api.v2.shares;
import jakarta.validation.constraints.NotBlank;
public record VerifySharePasswordV2Request(
@NotBlank String password
) {
}

View File

@@ -0,0 +1,16 @@
package com.yoyuzh.api.v2.site;
import com.yoyuzh.api.v2.ApiV2Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/site")
public class SiteV2Controller {
@GetMapping("/ping")
public ApiV2Response<SiteV2PingResponse> ping() {
return ApiV2Response.success(new SiteV2PingResponse("ok", "v2"));
}
}

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.api.v2.site;
public record SiteV2PingResponse(String status, String apiVersion) {
}

View File

@@ -0,0 +1,20 @@
package com.yoyuzh.api.v2.tasks;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import java.time.LocalDateTime;
public record BackgroundTaskResponse(
Long id,
BackgroundTaskType type,
BackgroundTaskStatus status,
Long userId,
String publicStateJson,
String correlationId,
String errorMessage,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime finishedAt
) {
}

View File

@@ -0,0 +1,119 @@
package com.yoyuzh.api.v2.tasks;
import com.yoyuzh.api.v2.ApiV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskCommandService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/tasks")
@RequiredArgsConstructor
public class BackgroundTaskV2Controller {
private final BackgroundTaskCommandService backgroundTaskCommandService;
private final CustomUserDetailsService userDetailsService;
@GetMapping
public ApiV2Response<PageResponse<BackgroundTaskResponse>> list(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
var result = backgroundTaskCommandService.listOwnedTasks(
user,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
return ApiV2Response.success(new PageResponse<>(
result.getContent().stream().map(this::toResponse).toList(),
result.getTotalElements(),
result.getNumber(),
result.getSize()
));
}
@GetMapping("/{id}")
public ApiV2Response<BackgroundTaskResponse> get(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskCommandService.getOwnedTask(user, id)));
}
@DeleteMapping("/{id}")
public ApiV2Response<BackgroundTaskResponse> cancel(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskCommandService.cancelOwnedTask(user, id)));
}
@PostMapping("/{id}/retry")
public ApiV2Response<BackgroundTaskResponse> retry(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskCommandService.retryOwnedTask(user, id)));
}
@PostMapping("/archive")
public ApiV2Response<BackgroundTaskResponse> createArchiveTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.ARCHIVE, request));
}
@PostMapping("/extract")
public ApiV2Response<BackgroundTaskResponse> createExtractTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.EXTRACT, request));
}
@PostMapping("/media-metadata")
public ApiV2Response<BackgroundTaskResponse> createMediaMetadataTask(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody CreateBackgroundTaskRequest request) {
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.MEDIA_META, request));
}
private BackgroundTaskResponse createTask(UserDetails userDetails,
BackgroundTaskType type,
CreateBackgroundTaskRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
BackgroundTask task = backgroundTaskCommandService.createQueuedFileTask(
user,
type,
request.fileId(),
request.path(),
request.correlationId()
);
return toResponse(task);
}
private BackgroundTaskResponse toResponse(BackgroundTask task) {
return new BackgroundTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt()
);
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.api.v2.tasks;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateBackgroundTaskRequest(
@NotNull Long fileId,
@NotBlank String path,
String correlationId
) {
}

View File

@@ -9,8 +9,8 @@ import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
import com.yoyuzh.auth.dto.UserProfileResponse;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.InitiateUploadResponse;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.upload.InitiateUploadResponse;
import com.yoyuzh.files.storage.FileContentStorage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -44,6 +44,8 @@ public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final AuthSessionPolicy authSessionPolicy;
private final FileService fileService;
private final FileContentStorage fileContentStorage;
private final RegistrationInviteService registrationInviteService;
@@ -115,13 +117,20 @@ public class AuthService {
}
final String finalCandidate = candidate;
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
UserRole desiredRole = resolveDevLoginRole(finalCandidate);
User user = userRepository.findByUsername(finalCandidate).map(existing -> {
if (existing.getRole() != desiredRole) {
existing.setRole(desiredRole);
return userRepository.save(existing);
}
return existing;
}).orElseGet(() -> {
User created = new User();
created.setUsername(finalCandidate);
created.setDisplayName(finalCandidate);
created.setEmail(finalCandidate + "@dev.local");
created.setPasswordHash(passwordEncoder.encode("1"));
created.setRole(UserRole.USER);
created.setRole(desiredRole);
created.setPreferredLanguage("zh-CN");
return userRepository.save(created);
});
@@ -291,6 +300,7 @@ public class AuthService {
}
private AuthResponse issueFreshTokens(User user, AuthClientType clientType) {
authTokenInvalidationService.revokeAccessTokensForUser(user.getId(), clientType);
refreshTokenService.revokeAllForUser(user.getId(), clientType);
return issueTokens(user, refreshTokenService.issueRefreshToken(user, clientType), clientType);
}
@@ -300,31 +310,20 @@ public class AuthService {
String accessToken = jwtTokenProvider.generateAccessToken(
sessionUser.getId(),
sessionUser.getUsername(),
getActiveSessionId(sessionUser, clientType),
authSessionPolicy.getActiveSessionId(sessionUser, clientType),
clientType
);
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
}
private User rotateActiveSession(User user, AuthClientType clientType) {
String nextSessionId = UUID.randomUUID().toString();
if (clientType == AuthClientType.MOBILE) {
user.setMobileActiveSessionId(nextSessionId);
} else {
user.setDesktopActiveSessionId(nextSessionId);
user.setActiveSessionId(nextSessionId);
}
authSessionPolicy.rotateActiveSession(user, clientType);
return userRepository.save(user);
}
private void rotateAllActiveSessions(User user) {
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
}
private String getActiveSessionId(User user, AuthClientType clientType) {
return clientType == AuthClientType.MOBILE ? user.getMobileActiveSessionId() : user.getDesktopActiveSessionId();
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
authSessionPolicy.rotateAllActiveSessions(user);
}
private String normalizeOptionalText(String value) {
@@ -335,6 +334,16 @@ public class AuthService {
return trimmed.isEmpty() ? null : trimmed;
}
private UserRole resolveDevLoginRole(String username) {
if ("admin".equalsIgnoreCase(username)) {
return UserRole.ADMIN;
}
if ("operator".equalsIgnoreCase(username) || "moderator".equalsIgnoreCase(username)) {
return UserRole.MODERATOR;
}
return UserRole.USER;
}
private String normalizePreferredLanguage(String preferredLanguage) {
if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) {
return "zh-CN";

View File

@@ -0,0 +1,35 @@
package com.yoyuzh.auth;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class AuthSessionPolicy {
public void rotateActiveSession(User user, AuthClientType clientType) {
String nextSessionId = nextSessionId();
if (clientType == AuthClientType.MOBILE) {
user.setMobileActiveSessionId(nextSessionId);
return;
}
user.setDesktopActiveSessionId(nextSessionId);
user.setActiveSessionId(nextSessionId);
}
public void rotateAllActiveSessions(User user) {
user.setActiveSessionId(nextSessionId());
user.setDesktopActiveSessionId(nextSessionId());
user.setMobileActiveSessionId(nextSessionId());
}
public String getActiveSessionId(User user, AuthClientType clientType) {
return clientType == AuthClientType.MOBILE
? user.getMobileActiveSessionId()
: user.getDesktopActiveSessionId();
}
private String nextSessionId() {
return UUID.randomUUID().toString();
}
}

View File

@@ -0,0 +1,108 @@
package com.yoyuzh.auth;
import com.yoyuzh.config.AppRedisProperties;
import com.yoyuzh.config.JwtProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.time.Instant;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class AuthTokenInvalidationService {
private final StringRedisTemplate redisTemplate;
private final AppRedisProperties redisProperties;
private final JwtProperties jwtProperties;
public AuthTokenInvalidationService(StringRedisTemplate redisTemplate,
AppRedisProperties redisProperties,
JwtProperties jwtProperties) {
this.redisTemplate = redisTemplate;
this.redisProperties = redisProperties;
this.jwtProperties = jwtProperties;
}
public void revokeAccessTokensForUser(Long userId) {
revokeAccessTokensForUser(userId, AuthClientType.DESKTOP);
revokeAccessTokensForUser(userId, AuthClientType.MOBILE);
}
public void revokeAccessTokensForUser(Long userId, AuthClientType clientType) {
if (userId == null || clientType == null) {
return;
}
redisTemplate.opsForValue().set(
buildAccessInvalidationKey(userId, clientType),
Long.toString(Instant.now().getEpochSecond()),
Duration.ofSeconds(jwtProperties.getAccessExpirationSeconds() + redisProperties.getTtlBufferSeconds())
);
}
public boolean isAccessTokenRevoked(Long userId, AuthClientType clientType, Instant issuedAt) {
if (userId == null || clientType == null || issuedAt == null) {
return false;
}
String rawValue = redisTemplate.opsForValue().get(buildAccessInvalidationKey(userId, clientType));
if (!StringUtils.hasText(rawValue)) {
return false;
}
long revokedBeforeEpochSecond = normalizeRevokedBefore(rawValue);
if (revokedBeforeEpochSecond <= 0L) {
return false;
}
return issuedAt.getEpochSecond() < revokedBeforeEpochSecond;
}
public void blacklistRefreshTokenHash(String tokenHash, Instant expiresAt) {
if (!StringUtils.hasText(tokenHash) || expiresAt == null) {
return;
}
Duration ttl = Duration.between(Instant.now(), expiresAt)
.plusSeconds(redisProperties.getTtlBufferSeconds());
if (ttl.isNegative() || ttl.isZero()) {
ttl = Duration.ofSeconds(redisProperties.getTtlBufferSeconds());
}
redisTemplate.opsForValue().set(buildRefreshTokenBlacklistKey(tokenHash), "1", ttl);
}
public boolean isRefreshTokenHashBlacklisted(String tokenHash) {
if (!StringUtils.hasText(tokenHash)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(buildRefreshTokenBlacklistKey(tokenHash)));
}
private String buildAccessInvalidationKey(Long userId, AuthClientType clientType) {
return buildAuthKey("access-revoked-before", userId.toString(), clientType.name());
}
private String buildRefreshTokenBlacklistKey(String tokenHash) {
return buildAuthKey("refresh-blacklist", tokenHash);
}
private long normalizeRevokedBefore(String rawValue) {
long parsed = Long.parseLong(rawValue);
if (parsed > 9_999_999_999L) {
return parsed / 1000L;
}
return parsed;
}
private String buildAuthKey(String... segments) {
StringBuilder builder = new StringBuilder();
builder.append(redisProperties.getKeyPrefix())
.append(':')
.append(redisProperties.getNamespaces().getAuth());
for (String segment : segments) {
if (!StringUtils.hasText(segment)) {
continue;
}
builder.append(':').append(segment.trim());
}
return builder.toString();
}
}

View File

@@ -1,7 +1,7 @@
package com.yoyuzh.auth;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;

View File

@@ -79,6 +79,11 @@ public class JwtTokenProvider {
return uid == null ? null : Long.parseLong(uid.toString());
}
public Instant getIssuedAt(String token) {
Date issuedAt = parseClaims(token).getIssuedAt();
return issuedAt == null ? null : issuedAt.toInstant();
}
public String getSessionId(String token) {
Object sessionId = parseClaims(token).get("sid");
return sessionId == null ? null : sessionId.toString();

View File

@@ -0,0 +1,37 @@
package com.yoyuzh.auth;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.time.Instant;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public class NoOpAuthTokenInvalidationService extends AuthTokenInvalidationService {
public NoOpAuthTokenInvalidationService() {
super(null, null, null);
}
@Override
public void revokeAccessTokensForUser(Long userId) {
}
@Override
public void revokeAccessTokensForUser(Long userId, AuthClientType clientType) {
}
@Override
public boolean isAccessTokenRevoked(Long userId, AuthClientType clientType, Instant issuedAt) {
return false;
}
@Override
public void blacklistRefreshTokenHash(String tokenHash, Instant expiresAt) {
}
@Override
public boolean isRefreshTokenHashBlacklisted(String tokenHash) {
return false;
}
}

View File

@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@@ -34,4 +35,19 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
int revokeAllActiveByUserIdAndClientType(@Param("userId") Long userId,
@Param("clientType") String clientType,
@Param("revokedAt") LocalDateTime revokedAt);
@Query("""
select token from RefreshToken token
where token.user.id = :userId and token.revoked = false and token.expiresAt > :now
""")
List<RefreshToken> findActiveByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
@Query("""
select token from RefreshToken token
where token.user.id = :userId and token.revoked = false and token.expiresAt > :now
and (token.clientType = :clientType or (:clientType = 'DESKTOP' and token.clientType is null))
""")
List<RefreshToken> findActiveByUserIdAndClientType(@Param("userId") Long userId,
@Param("clientType") String clientType,
@Param("now") LocalDateTime now);
}

View File

@@ -11,9 +11,12 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
@Service
@RequiredArgsConstructor
@@ -23,6 +26,7 @@ public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProperties jwtProperties;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
@@ -47,7 +51,12 @@ public class RefreshTokenService {
@Transactional(noRollbackFor = BusinessException.class)
public RotatedRefreshToken rotateRefreshToken(String rawToken) {
RefreshToken existing = refreshTokenRepository.findForUpdateByTokenHash(hashToken(rawToken))
String tokenHash = hashToken(rawToken);
if (authTokenInvalidationService.isRefreshTokenHashBlacklisted(tokenHash)) {
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效或已使用");
}
RefreshToken existing = refreshTokenRepository.findForUpdateByTokenHash(tokenHash)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效"));
if (existing.isRevoked()) {
@@ -56,12 +65,14 @@ public class RefreshTokenService {
if (existing.getExpiresAt().isBefore(LocalDateTime.now())) {
existing.revoke(LocalDateTime.now());
authTokenInvalidationService.blacklistRefreshTokenHash(existing.getTokenHash(), toInstant(existing.getExpiresAt()));
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌已过期");
}
User user = existing.getUser();
AuthClientType clientType = AuthClientType.fromHeader(existing.getClientType());
existing.revoke(LocalDateTime.now());
authTokenInvalidationService.blacklistRefreshTokenHash(existing.getTokenHash(), toInstant(existing.getExpiresAt()));
revokeAllForUser(user.getId(), clientType);
String nextRefreshToken = issueRefreshToken(user, clientType);
@@ -70,12 +81,18 @@ public class RefreshTokenService {
@Transactional
public void revokeAllForUser(Long userId) {
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
List<RefreshToken> tokens = refreshTokenRepository.findActiveByUserId(userId, now);
refreshTokenRepository.revokeAllActiveByUserId(userId, now);
blacklistRefreshTokens(tokens);
}
@Transactional
public void revokeAllForUser(Long userId, AuthClientType clientType) {
refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
List<RefreshToken> tokens = refreshTokenRepository.findActiveByUserIdAndClientType(userId, clientType.name(), now);
refreshTokenRepository.revokeAllActiveByUserIdAndClientType(userId, clientType.name(), now);
blacklistRefreshTokens(tokens);
}
private String generateRawToken() {
@@ -97,6 +114,16 @@ public class RefreshTokenService {
}
}
private void blacklistRefreshTokens(List<RefreshToken> tokens) {
for (RefreshToken token : tokens) {
authTokenInvalidationService.blacklistRefreshTokenHash(token.getTokenHash(), toInstant(token.getExpiresAt()));
}
}
private Instant toInstant(LocalDateTime dateTime) {
return dateTime.atZone(ZoneId.systemDefault()).toInstant();
}
public record RotatedRefreshToken(User user, String refreshToken, AuthClientType clientType) {
}
}

View File

@@ -28,6 +28,20 @@ public class RegistrationInviteService {
return ensureCurrentState().getInviteCode();
}
@Transactional
public String updateCurrentInviteCode(String inviteCode) {
RegistrationInviteState state = ensureCurrentStateForUpdate();
state.setInviteCode(requireValidInviteCode(inviteCode));
return registrationInviteStateRepository.save(state).getInviteCode();
}
@Transactional
public String rotateCurrentInviteCode() {
RegistrationInviteState state = ensureCurrentStateForUpdate();
state.setInviteCode(generateNextInviteCode(state.getInviteCode()));
return registrationInviteStateRepository.save(state).getInviteCode();
}
@Transactional
public void consumeInviteCode(String inviteCode) {
RegistrationInviteState state = ensureCurrentStateForUpdate();
@@ -93,4 +107,15 @@ public class RegistrationInviteService {
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String requireValidInviteCode(String inviteCode) {
String normalized = normalize(inviteCode);
if (!StringUtils.hasText(normalized)) {
throw new BusinessException(ErrorCode.UNKNOWN, "邀请码不能为空");
}
if (normalized.length() > 64) {
throw new BusinessException(ErrorCode.UNKNOWN, "邀请码长度不能超过 64 个字符");
}
return normalized;
}
}

View File

@@ -3,5 +3,9 @@ package com.yoyuzh.auth;
public enum UserRole {
USER,
MODERATOR,
ADMIN
ADMIN;
public boolean canAccessAdmin() {
return this == MODERATOR || this == ADMIN;
}
}

View File

@@ -0,0 +1,44 @@
package com.yoyuzh.common.broker;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Deque;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public class InMemoryLightweightBrokerService implements LightweightBrokerService {
private final ConcurrentHashMap<String, Deque<Map<String, Object>>> queues = new ConcurrentHashMap<>();
@Override
public void publish(String topic, Map<String, Object> payload) {
queues.computeIfAbsent(topic, ignored -> new ConcurrentLinkedDeque<>())
.offerLast(copyPayload(payload));
}
@Override
public Optional<Map<String, Object>> poll(String topic) {
Deque<Map<String, Object>> queue = queues.get(topic);
if (queue == null) {
return Optional.empty();
}
Map<String, Object> payload = queue.pollFirst();
return payload == null ? Optional.empty() : Optional.of(new LinkedHashMap<>(payload));
}
@Override
public void requeue(String topic, Map<String, Object> payload) {
queues.computeIfAbsent(topic, ignored -> new ConcurrentLinkedDeque<>())
.offerFirst(copyPayload(payload));
}
private Map<String, Object> copyPayload(Map<String, Object> payload) {
return payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload);
}
}

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.common.broker;
import java.util.Map;
import java.util.Optional;
public interface LightweightBrokerService {
void publish(String topic, Map<String, Object> payload);
Optional<Map<String, Object>> poll(String topic);
void requeue(String topic, Map<String, Object> payload);
}

View File

@@ -0,0 +1,87 @@
package com.yoyuzh.common.broker;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.AppRedisProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class RedisLightweightBrokerService implements LightweightBrokerService {
private static final Logger log = LoggerFactory.getLogger(RedisLightweightBrokerService.class);
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final AppRedisProperties redisProperties;
public RedisLightweightBrokerService(StringRedisTemplate stringRedisTemplate,
ObjectMapper objectMapper,
AppRedisProperties redisProperties) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
this.redisProperties = redisProperties;
}
@Override
public void publish(String topic, Map<String, Object> payload) {
stringRedisTemplate.opsForList().rightPush(buildQueueKey(topic), toJson(payload));
}
@Override
public Optional<Map<String, Object>> poll(String topic) {
String queueKey = buildQueueKey(topic);
while (true) {
String payload = stringRedisTemplate.opsForList().leftPop(queueKey);
if (!StringUtils.hasText(payload)) {
return Optional.empty();
}
try {
return Optional.of(parsePayload(payload));
} catch (IllegalStateException ex) {
log.warn("Dropping malformed broker payload for topic {}", topic, ex);
}
}
}
@Override
public void requeue(String topic, Map<String, Object> payload) {
stringRedisTemplate.opsForList().leftPush(buildQueueKey(topic), toJson(payload));
}
private String buildQueueKey(String topic) {
return redisProperties.getKeyPrefix()
+ ":"
+ redisProperties.getNamespaces().getBroker()
+ ":"
+ topic
+ ":queue";
}
private String toJson(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload == null ? Map.of() : payload);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize broker payload", ex);
}
}
private Map<String, Object> parsePayload(String payload) {
try {
return objectMapper.readValue(payload, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to parse broker payload", ex);
}
}
}

View File

@@ -0,0 +1,32 @@
package com.yoyuzh.common.lock;
import java.time.Duration;
import java.util.function.Supplier;
public interface DistributedLockService {
<T> T executeWithLock(String lockName, Duration ttl, Supplier<T> action);
default void runWithLock(String lockName, Duration ttl, Runnable action) {
executeWithLock(lockName, ttl, () -> {
action.run();
return null;
});
}
static DistributedLockService noOp() {
return NoOpHolder.INSTANCE;
}
final class NoOpHolder {
private static final DistributedLockService INSTANCE = new DistributedLockService() {
@Override
public <T> T executeWithLock(String lockName, Duration ttl, Supplier<T> action) {
return action.get();
}
};
private NoOpHolder() {
}
}
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.common.lock;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.function.Supplier;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public class NoOpDistributedLockService implements DistributedLockService {
@Override
public <T> T executeWithLock(String lockName, Duration ttl, Supplier<T> action) {
return action.get();
}
}

View File

@@ -0,0 +1,63 @@
package com.yoyuzh.common.lock;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.AppRedisProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class RedisDistributedLockService implements DistributedLockService {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
Long.class
);
private final StringRedisTemplate stringRedisTemplate;
private final AppRedisProperties redisProperties;
public RedisDistributedLockService(StringRedisTemplate stringRedisTemplate,
AppRedisProperties redisProperties) {
this.stringRedisTemplate = stringRedisTemplate;
this.redisProperties = redisProperties;
}
@Override
public <T> T executeWithLock(String lockName, Duration ttl, Supplier<T> action) {
if (!StringUtils.hasText(lockName)) {
return action.get();
}
String key = buildLockKey(lockName);
String ownerToken = UUID.randomUUID().toString();
Duration effectiveTtl = ttl == null || ttl.isZero() || ttl.isNegative()
? Duration.ofSeconds(60)
: ttl;
Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(key, ownerToken, effectiveTtl);
if (!Boolean.TRUE.equals(acquired)) {
throw new BusinessException(ErrorCode.UNKNOWN, "操作正在处理中,请稍后重试");
}
try {
return action.get();
} finally {
stringRedisTemplate.execute(RELEASE_SCRIPT, List.of(key), ownerToken);
}
}
private String buildLockKey(String lockName) {
return redisProperties.getKeyPrefix()
+ ":" + redisProperties.getNamespaces().getLocks()
+ ":" + lockName.trim();
}
}

View File

@@ -5,6 +5,7 @@ import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.storage.FileContentStorage;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.io.IOException;
@@ -18,6 +19,7 @@ public class AndroidReleaseService {
private final ObjectMapper objectMapper;
private final AndroidReleaseProperties androidReleaseProperties;
@Cacheable(cacheNames = RedisCacheNames.ANDROID_RELEASE, key = "'latest'")
public AndroidReleaseResponse getLatestRelease() {
AndroidReleaseMetadata metadata = loadReleaseMetadata();
return new AndroidReleaseResponse(

View File

@@ -0,0 +1,159 @@
package com.yoyuzh.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.redis")
public class AppRedisProperties {
private boolean enabled;
private String keyPrefix = "yoyuzh";
private long ttlBufferSeconds = 60;
private final Cache cache = new Cache();
private final Namespaces namespaces = new Namespaces();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getKeyPrefix() {
return keyPrefix;
}
public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}
public long getTtlBufferSeconds() {
return ttlBufferSeconds;
}
public void setTtlBufferSeconds(long ttlBufferSeconds) {
this.ttlBufferSeconds = ttlBufferSeconds;
}
public Cache getCache() {
return cache;
}
public Namespaces getNamespaces() {
return namespaces;
}
public static class Cache {
private long filesListTtlSeconds = 60;
private long directoryVersionTtlSeconds = 3600;
private long adminSummaryTtlSeconds = 30;
private long storagePoliciesTtlSeconds = 300;
private long androidReleaseTtlSeconds = 60;
public long getFilesListTtlSeconds() {
return filesListTtlSeconds;
}
public void setFilesListTtlSeconds(long filesListTtlSeconds) {
this.filesListTtlSeconds = filesListTtlSeconds;
}
public long getDirectoryVersionTtlSeconds() {
return directoryVersionTtlSeconds;
}
public void setDirectoryVersionTtlSeconds(long directoryVersionTtlSeconds) {
this.directoryVersionTtlSeconds = directoryVersionTtlSeconds;
}
public long getAdminSummaryTtlSeconds() {
return adminSummaryTtlSeconds;
}
public void setAdminSummaryTtlSeconds(long adminSummaryTtlSeconds) {
this.adminSummaryTtlSeconds = adminSummaryTtlSeconds;
}
public long getStoragePoliciesTtlSeconds() {
return storagePoliciesTtlSeconds;
}
public void setStoragePoliciesTtlSeconds(long storagePoliciesTtlSeconds) {
this.storagePoliciesTtlSeconds = storagePoliciesTtlSeconds;
}
public long getAndroidReleaseTtlSeconds() {
return androidReleaseTtlSeconds;
}
public void setAndroidReleaseTtlSeconds(long androidReleaseTtlSeconds) {
this.androidReleaseTtlSeconds = androidReleaseTtlSeconds;
}
}
public static class Namespaces {
private String cache = "cache";
private String auth = "auth";
private String transferSessions = "transfer-sessions";
private String uploadState = "upload-state";
private String locks = "locks";
private String fileEvents = "file-events";
private String broker = "broker";
public String getCache() {
return cache;
}
public void setCache(String cache) {
this.cache = cache;
}
public String getAuth() {
return auth;
}
public void setAuth(String auth) {
this.auth = auth;
}
public String getTransferSessions() {
return transferSessions;
}
public void setTransferSessions(String transferSessions) {
this.transferSessions = transferSessions;
}
public String getUploadState() {
return uploadState;
}
public void setUploadState(String uploadState) {
this.uploadState = uploadState;
}
public String getLocks() {
return locks;
}
public void setLocks(String locks) {
this.locks = locks;
}
public String getFileEvents() {
return fileEvents;
}
public void setFileEvents(String fileEvents) {
this.fileEvents = fileEvents;
}
public String getBroker() {
return broker;
}
public void setBroker(String broker) {
this.broker = broker;
}
}
}

View File

@@ -1,6 +1,8 @@
package com.yoyuzh.config;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.AuthClientType;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.JwtTokenProvider;
import com.yoyuzh.auth.User;
@@ -24,6 +26,7 @@ import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final CustomUserDetailsService userDetailsService;
private final AdminMetricsService adminMetricsService;
@@ -36,6 +39,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String token = header.substring(7);
if (jwtTokenProvider.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
Long userId = jwtTokenProvider.getUserId(token);
AuthClientType clientType = jwtTokenProvider.getClientType(token);
if (authTokenInvalidationService.isAccessTokenRevoked(
userId,
clientType,
jwtTokenProvider.getIssuedAt(token))) {
filterChain.doFilter(request, response);
return;
}
String username = jwtTokenProvider.getUsername(token);
User domainUser;
try {

View File

@@ -0,0 +1,21 @@
package com.yoyuzh.config;
import java.util.Set;
public final class RedisCacheNames {
public static final String FILES_LIST = "files:list";
public static final String ADMIN_SUMMARY = "admin:summary";
public static final String STORAGE_POLICIES = "admin:storage-policies";
public static final String ANDROID_RELEASE = "android:release";
public static final Set<String> ALL = Set.of(
FILES_LIST,
ADMIN_SUMMARY,
STORAGE_POLICIES,
ANDROID_RELEASE
);
private RedisCacheNames() {
}
}

View File

@@ -0,0 +1,73 @@
package com.yoyuzh.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.support.NoOpCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching
public class RedisConfiguration {
@Bean
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
ObjectMapper objectMapper,
AppRedisProperties redisProperties) {
RedisCacheConfiguration baseConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(cacheName -> redisProperties.getKeyPrefix()
+ ":" + redisProperties.getNamespaces().getCache()
+ ":" + cacheName + ":")
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
redisValueSerializer(objectMapper)))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put(
RedisCacheNames.FILES_LIST,
baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getFilesListTtlSeconds()))
);
cacheConfigurations.put(
RedisCacheNames.ADMIN_SUMMARY,
baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getAdminSummaryTtlSeconds()))
);
cacheConfigurations.put(
RedisCacheNames.STORAGE_POLICIES,
baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getStoragePoliciesTtlSeconds()))
);
cacheConfigurations.put(
RedisCacheNames.ANDROID_RELEASE,
baseConfiguration.entryTtl(Duration.ofSeconds(redisProperties.getCache().getAndroidReleaseTtlSeconds()))
);
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(baseConfiguration)
.withInitialCacheConfigurations(cacheConfigurations)
.initialCacheNames(RedisCacheNames.ALL)
.build();
}
@Bean
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public CacheManager noOpCacheManager() {
return new NoOpCacheManager();
}
static GenericJackson2JsonRedisSerializer redisValueSerializer(ObjectMapper objectMapper) {
return new GenericJackson2JsonRedisSerializer(objectMapper.copy());
}
}

View File

@@ -52,6 +52,24 @@ public class SecurityConfig {
.permitAll()
.requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
.permitAll()
.requestMatchers("/api/v2/tasks/**")
.authenticated()
.requestMatchers("/api/v2/files/**")
.authenticated()
.requestMatchers(HttpMethod.GET, "/api/v2/shares/mine")
.authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v2/shares/*")
.authenticated()
.requestMatchers(HttpMethod.POST, "/api/v2/shares")
.authenticated()
.requestMatchers(HttpMethod.GET, "/api/v2/shares/*")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/v2/shares/*/verify-password")
.permitAll()
.requestMatchers("/api/v2/shares/**")
.authenticated()
.requestMatchers("/api/transfer/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")

View File

@@ -1,89 +0,0 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_share_link", indexes = {
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
@Index(name = "idx_file_share_created_at", columnList = "created_at")
})
public class FileShareLink {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "file_id", nullable = false)
private StoredFile file;
@Column(nullable = false, length = 96, unique = true)
private String token;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getOwner() {
return owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
public StoredFile getFile() {
return file;
}
public void setFile(StoredFile file) {
this.file = file;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,12 +0,0 @@
package com.yoyuzh.files;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
Optional<FileShareLink> findByToken(String token);
}

View File

@@ -0,0 +1,78 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService;
import java.util.Optional;
final class ContentAssetBindingService {
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final StoragePolicyService storagePolicyService;
ContentAssetBindingService(FileEntityRepository fileEntityRepository,
StoredFileEntityRepository storedFileEntityRepository,
StoragePolicyService storagePolicyService) {
this.fileEntityRepository = fileEntityRepository;
this.storedFileEntityRepository = storedFileEntityRepository;
this.storagePolicyService = storagePolicyService;
}
FileEntity createOrReferencePrimaryEntity(User user, FileBlob blob) {
if (fileEntityRepository == null) {
return createTransientPrimaryEntity(user, blob);
}
Optional<FileEntity> existingEntity = fileEntityRepository.findByObjectKeyAndEntityType(
blob.getObjectKey(),
FileEntityType.VERSION
);
if (existingEntity.isPresent()) {
FileEntity entity = existingEntity.get();
entity.setReferenceCount(entity.getReferenceCount() + 1);
return fileEntityRepository.save(entity);
}
return fileEntityRepository.save(createTransientPrimaryEntity(user, blob));
}
StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy());
}
void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) {
if (storedFileEntityRepository == null) {
return;
}
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(primaryEntity);
relation.setEntityRole("PRIMARY");
storedFileEntityRepository.save(relation);
}
private FileEntity createTransientPrimaryEntity(User user, FileBlob blob) {
FileEntity entity = new FileEntity();
entity.setObjectKey(blob.getObjectKey());
entity.setContentType(blob.getContentType());
entity.setSize(blob.getSize());
entity.setEntityType(FileEntityType.VERSION);
entity.setReferenceCount(1);
entity.setCreatedBy(user);
entity.setStoragePolicyId(resolveDefaultStoragePolicyId());
return entity;
}
private Long resolveDefaultStoragePolicyId() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.ensureDefaultPolicy().getId();
}
}

View File

@@ -0,0 +1,103 @@
package com.yoyuzh.files.core;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.storage.FileContentStorage;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
final class ContentBlobLifecycleService {
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final FileContentStorage fileContentStorage;
ContentBlobLifecycleService(StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileContentStorage fileContentStorage) {
this.storedFileRepository = storedFileRepository;
this.fileBlobRepository = fileBlobRepository;
this.fileContentStorage = fileContentStorage;
}
<T> T executeAfterBlobStored(String objectKey, Supplier<T> operation) {
try {
return operation.get();
} catch (RuntimeException ex) {
try {
fileContentStorage.deleteBlob(objectKey);
} catch (RuntimeException cleanupEx) {
ex.addSuppressed(cleanupEx);
}
throw ex;
}
}
void cleanupWrittenBlobs(List<String> writtenBlobObjectKeys, RuntimeException ex) {
for (String objectKey : writtenBlobObjectKeys) {
try {
fileContentStorage.deleteBlob(objectKey);
} catch (RuntimeException cleanupEx) {
ex.addSuppressed(cleanupEx);
}
}
}
FileBlob createAndSaveBlob(String objectKey, String contentType, long size) {
FileBlob blob = new FileBlob();
blob.setObjectKey(objectKey);
blob.setContentType(contentType);
blob.setSize(size);
return fileBlobRepository.save(blob);
}
FileBlob getRequiredBlob(StoredFile storedFile) {
if (storedFile.isDirectory() || storedFile.getBlob() == null) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件内容不存在");
}
return storedFile.getBlob();
}
List<FileBlob> collectBlobsToDelete(List<StoredFile> filesToDelete) {
Map<Long, BlobDeletionCandidate> candidates = new HashMap<>();
for (StoredFile file : filesToDelete) {
if (file.getBlob() == null || file.getBlob().getId() == null) {
continue;
}
BlobDeletionCandidate candidate = candidates.computeIfAbsent(
file.getBlob().getId(),
ignored -> new BlobDeletionCandidate(file.getBlob())
);
candidate.referencesToDelete += 1;
}
List<FileBlob> blobsToDelete = new ArrayList<>();
for (BlobDeletionCandidate candidate : candidates.values()) {
long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId());
if (currentReferences == candidate.referencesToDelete) {
blobsToDelete.add(candidate.blob);
}
}
return blobsToDelete;
}
void deleteBlobs(List<FileBlob> blobsToDelete) {
for (FileBlob blob : blobsToDelete) {
fileContentStorage.deleteBlob(blob.getObjectKey());
fileBlobRepository.delete(blob);
}
}
private static final class BlobDeletionCandidate {
private final FileBlob blob;
private long referencesToDelete;
private BlobDeletionCandidate(FileBlob blob) {
this.blob = blob;
}
}
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.validation.constraints.NotBlank;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
public record DownloadUrlResponse(String url) {
}

View File

@@ -0,0 +1,75 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import org.springframework.util.StringUtils;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
final class ExternalImportRulesService {
private final WorkspaceNodeRulesService workspaceNodeRulesService;
private final FileUploadRulesService fileUploadRulesService;
ExternalImportRulesService(WorkspaceNodeRulesService workspaceNodeRulesService,
FileUploadRulesService fileUploadRulesService) {
this.workspaceNodeRulesService = workspaceNodeRulesService;
this.fileUploadRulesService = fileUploadRulesService;
}
List<String> normalizeDirectories(List<String> directories) {
if (directories == null || directories.isEmpty()) {
return List.of();
}
return directories.stream()
.map(workspaceNodeRulesService::normalizeDirectoryPath)
.distinct()
.sorted(Comparator.comparingInt(String::length).thenComparing(String::compareTo))
.toList();
}
List<FileService.ExternalFileImport> normalizeFiles(List<FileService.ExternalFileImport> files) {
if (files == null || files.isEmpty()) {
return List.of();
}
return files.stream()
.map(file -> new FileService.ExternalFileImport(
workspaceNodeRulesService.normalizeDirectoryPath(file.path()),
workspaceNodeRulesService.normalizeLeafName(file.filename()),
StringUtils.hasText(file.contentType()) ? file.contentType().trim() : "application/octet-stream",
file.content() == null ? new byte[0] : file.content()
))
.toList();
}
void validateBatch(User recipient,
List<String> directories,
List<FileService.ExternalFileImport> files) {
fileUploadRulesService.ensureWithinStorageQuota(recipient, files.stream().mapToLong(FileService.ExternalFileImport::size).sum());
Set<String> plannedTargets = new LinkedHashSet<>();
for (String directory : directories) {
if ("/".equals(directory)) {
continue;
}
if (!plannedTargets.add(directory)) {
continue;
}
String parentPath = workspaceNodeRulesService.extractParentPath(directory);
String directoryName = workspaceNodeRulesService.extractLeafName(directory);
workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), parentPath, directoryName, "解压目标已存在");
}
for (FileService.ExternalFileImport file : files) {
String logicalPath = workspaceNodeRulesService.buildTargetLogicalPath(file.path(), file.filename());
if (plannedTargets.contains(logicalPath) || !plannedTargets.add(logicalPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), file.path(), file.filename(), "同目录下文件已存在");
}
}
}

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.files.storage.FileContentStorage;
import lombok.RequiredArgsConstructor;

View File

@@ -1,14 +1,18 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface FileBlobRepository extends JpaRepository<FileBlob, Long> {
Optional<FileBlob> findByObjectKey(String objectKey);
List<FileBlob> findAllByObjectKeyIn(Collection<String> objectKeys);
@Query("""
select coalesce(sum(b.size), 0)
from FileBlob b

View File

@@ -1,8 +1,21 @@
package com.yoyuzh.files;
package com.yoyuzh.files.core;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.api.v2.shares.CreateShareV2Request;
import com.yoyuzh.api.v2.shares.ImportShareV2Request;
import com.yoyuzh.api.v2.shares.ShareV2Response;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.share.CreateFileShareLinkResponse;
import com.yoyuzh.files.share.FileShareDetailsResponse;
import com.yoyuzh.files.share.ImportSharedFileRequest;
import com.yoyuzh.files.share.ShareV2Service;
import com.yoyuzh.files.upload.CompleteUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadResponse;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -31,6 +44,7 @@ public class FileController {
private final FileService fileService;
private final CustomUserDetailsService userDetailsService;
private final ShareV2Service shareV2Service;
@Operation(summary = "上传文件")
@PostMapping("/upload")
@@ -142,15 +156,46 @@ public class FileController {
@PostMapping("/{fileId}/share-links")
public ApiResponse<CreateFileShareLinkResponse> createShareLink(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return ApiResponse.success(
fileService.createShareLink(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId)
try {
ShareV2Response response = shareV2Service.createShare(
userDetailsService.loadDomainUser(userDetails.getUsername()),
new CreateShareV2Request(fileId, null, null, null, null, null, null)
);
if (response.file() == null) {
throw new BusinessException(ErrorCode.UNKNOWN, "share file metadata missing");
}
return ApiResponse.success(new CreateFileShareLinkResponse(
response.token(),
response.file().filename(),
response.file().size(),
response.file().contentType(),
response.createdAt()
));
} catch (ApiV2Exception ex) {
throw mapLegacyShareApiException(ex);
}
}
@Operation(summary = "查看分享详情")
@GetMapping("/share-links/{token}")
public ApiResponse<FileShareDetailsResponse> getShareDetails(@PathVariable String token) {
return ApiResponse.success(fileService.getShareDetails(token));
try {
ShareV2Response response = shareV2Service.getShare(token);
if (response.file() == null) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "璇ュ垎浜摼鎺ラ渶瑕侀獙璇佸瘑鐮?");
}
return ApiResponse.success(new FileShareDetailsResponse(
response.token(),
response.ownerUsername(),
response.file().filename(),
response.file().size(),
response.file().contentType(),
response.file().directory(),
response.createdAt()
));
} catch (ApiV2Exception ex) {
throw mapLegacyShareApiException(ex);
}
}
@Operation(summary = "导入共享文件")
@@ -158,13 +203,17 @@ public class FileController {
public ApiResponse<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String token,
@Valid @RequestBody ImportSharedFileRequest request) {
try {
return ApiResponse.success(
fileService.importSharedFile(
shareV2Service.importSharedFile(
userDetailsService.loadDomainUser(userDetails.getUsername()),
token,
request.path()
new ImportShareV2Request(request.path(), null)
)
);
} catch (ApiV2Exception ex) {
throw mapLegacyShareApiException(ex);
}
}
@Operation(summary = "删除文件")
@@ -184,4 +233,14 @@ public class FileController {
fileId
));
}
private BusinessException mapLegacyShareApiException(ApiV2Exception ex) {
ErrorCode code = switch (ex.getErrorCode()) {
case FILE_NOT_FOUND -> ErrorCode.FILE_NOT_FOUND;
case PERMISSION_DENIED -> ErrorCode.PERMISSION_DENIED;
case NOT_LOGGED_IN -> ErrorCode.NOT_LOGGED_IN;
case BAD_REQUEST, INTERNAL_ERROR -> ErrorCode.UNKNOWN;
};
return new BusinessException(code, ex.getMessage());
}
}

View File

@@ -0,0 +1,163 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file_entity", indexes = {
@Index(name = "uk_file_entity_key_type", columnList = "object_key,entity_type", unique = true),
@Index(name = "idx_file_entity_created_at", columnList = "created_at")
})
public class FileEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "object_key", nullable = false, length = 512)
private String objectKey;
@Column(nullable = false)
private Long size;
@Column(name = "content_type", length = 255)
private String contentType;
@Enumerated(EnumType.STRING)
@Column(name = "entity_type", nullable = false, length = 32)
private FileEntityType entityType;
@Column(name = "reference_count", nullable = false)
private Integer referenceCount;
@Column(name = "storage_policy_id")
private Long storagePolicyId;
@Column(name = "upload_session_id", length = 64)
private String uploadSessionId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
@OnDelete(action = OnDeleteAction.SET_NULL)
private User createdBy;
@Column(name = "props_json", columnDefinition = "TEXT")
private String propsJson;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (referenceCount == null) {
referenceCount = 0;
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getObjectKey() {
return objectKey;
}
public void setObjectKey(String objectKey) {
this.objectKey = objectKey;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public FileEntityType getEntityType() {
return entityType;
}
public void setEntityType(FileEntityType entityType) {
this.entityType = entityType;
}
public Integer getReferenceCount() {
return referenceCount;
}
public void setReferenceCount(Integer referenceCount) {
this.referenceCount = referenceCount;
}
public Long getStoragePolicyId() {
return storagePolicyId;
}
public void setStoragePolicyId(Long storagePolicyId) {
this.storagePolicyId = storagePolicyId;
}
public String getUploadSessionId() {
return uploadSessionId;
}
public void setUploadSessionId(String uploadSessionId) {
this.uploadSessionId = uploadSessionId;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public String getPropsJson() {
return propsJson;
}
public void setPropsJson(String propsJson) {
this.propsJson = propsJson;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,67 @@
package com.yoyuzh.files.core;
import com.yoyuzh.files.policy.StoragePolicyService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Component
@Order(1)
@RequiredArgsConstructor
public class FileEntityBackfillService implements CommandLineRunner {
static final String PRIMARY_ENTITY_ROLE = "PRIMARY";
private final StoredFileRepository storedFileRepository;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final StoragePolicyService storagePolicyService;
@Override
@Transactional
public void run(String... args) {
backfillPrimaryEntities();
}
@Transactional
public void backfillPrimaryEntities() {
for (StoredFile storedFile : storedFileRepository.findAllByDirectoryFalseAndBlobIsNotNullAndPrimaryEntityIsNull()) {
FileBlob blob = storedFile.getBlob();
Optional<FileEntity> existingEntity = fileEntityRepository
.findByObjectKeyAndEntityType(blob.getObjectKey(), FileEntityType.VERSION);
FileEntity fileEntity = existingEntity.orElseGet(() -> createEntity(storedFile, blob));
if (existingEntity.isPresent()) {
fileEntity.setReferenceCount(fileEntity.getReferenceCount() + 1);
fileEntityRepository.save(fileEntity);
}
storedFile.setPrimaryEntity(fileEntity);
storedFileRepository.save(storedFile);
storedFileEntityRepository.save(createRelation(storedFile, fileEntity));
}
}
private FileEntity createEntity(StoredFile storedFile, FileBlob blob) {
FileEntity fileEntity = new FileEntity();
fileEntity.setObjectKey(blob.getObjectKey());
fileEntity.setSize(blob.getSize());
fileEntity.setContentType(blob.getContentType());
fileEntity.setEntityType(FileEntityType.VERSION);
fileEntity.setReferenceCount(1);
fileEntity.setCreatedBy(storedFile.getUser());
fileEntity.setStoragePolicyId(storagePolicyService.ensureDefaultPolicy().getId());
return fileEntityRepository.save(fileEntity);
}
private StoredFileEntity createRelation(StoredFile storedFile, FileEntity fileEntity) {
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(fileEntity);
relation.setEntityRole(PRIMARY_ENTITY_ROLE);
return relation;
}
}

Some files were not shown because too many files have changed in this diff Show More