Compare commits

..

4 Commits

461 changed files with 31193 additions and 27236 deletions

5
.claude/settings.json Normal file
View File

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

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"
}
}
}
}

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

@@ -5,7 +5,11 @@ 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;
@@ -30,18 +34,47 @@ import java.util.List;
@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()
));
}
@@ -50,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")
@@ -58,32 +91,92 @@ 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(adminService.listStoragePolicies());
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(adminService.createStoragePolicy(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(adminService.updateStoragePolicy(policyId, 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(adminService.updateStoragePolicyStatus(policyId, request.enabled()));
return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicyStatus(policyId, request.enabled()));
}
@PostMapping("/storage-policies/migrations")
@@ -91,48 +184,48 @@ public class AdminController {
@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(toTaskResponse(adminService.createStoragePolicyMigrationTask(user, request)));
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) {

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

@@ -1,378 +0,0 @@
package com.yoyuzh.admin;
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.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
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.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AdminService {
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 StoragePolicyRepository storagePolicyRepository;
private final StoragePolicyService storagePolicyService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final BackgroundTaskService backgroundTaskService;
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<AdminFileResponse> items = result.getContent().stream()
.map(this::toFileResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
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(this::toStoragePolicyResponse)
.toList();
}
@Transactional
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
if (policy.isDefaultPolicy() && !enabled) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
}
policy.setEnabled(enabled);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@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, "源存储策略和目标存储策略不能相同");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
java.util.Map<String, Object> state = new java.util.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");
java.util.Map<String, Object> privateState = new java.util.LinkedHashMap<>(state);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
return backgroundTaskService.createQueuedTask(
user,
BackgroundTaskType.STORAGE_POLICY_MIGRATION,
state,
privateState,
request.correlationId()
);
}
@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));
}
@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());
refreshTokenService.revokeAllForUser(user.getId());
return toUserResponse(userRepository.save(user));
}
@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));
}
@Transactional
public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) {
User user = getRequiredUser(userId);
user.setStorageQuotaBytes(storageQuotaBytes);
return toUserResponse(userRepository.save(user));
}
@Transactional
public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) {
User user = getRequiredUser(userId);
user.setMaxUploadSizeBytes(maxUploadSizeBytes);
return toUserResponse(userRepository.save(user));
}
@Transactional
public AdminPasswordResetResponse resetUserPassword(Long userId) {
String temporaryPassword = generateTemporaryPassword();
updateUserPassword(userId, temporaryPassword);
return new AdminPasswordResetResponse(temporaryPassword);
}
@Transactional
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes);
}
private AdminUserResponse toUserResponse(User user) {
long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
return new AdminUserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPhoneNumber(),
user.getCreatedAt(),
user.getRole(),
user.isBanned(),
usedStorageBytes,
user.getStorageQuotaBytes(),
user.getMaxUploadSizeBytes()
);
}
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 AdminStoragePolicyResponse toStoragePolicyResponse(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()
);
}
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 User getRequiredUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
}
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
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");
}
}
private String generateTemporaryPassword() {
String lowers = "abcdefghjkmnpqrstuvwxyz";
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";
String digits = "23456789";
String specials = "!@#$%^&*";
String all = lowers + uppers + digits + specials;
char[] password = new char[12];
password[0] = lowers.charAt(secureRandom.nextInt(lowers.length()));
password[1] = uppers.charAt(secureRandom.nextInt(uppers.length()));
password[2] = digits.charAt(secureRandom.nextInt(digits.length()));
password[3] = specials.charAt(secureRandom.nextInt(specials.length()));
for (int i = 4; i < password.length; i += 1) {
password[i] = all.charAt(secureRandom.nextInt(all.length()));
}
for (int i = password.length - 1; i > 0; i -= 1) {
int j = secureRandom.nextInt(i + 1);
char tmp = password[i];
password[i] = password[j];
password[j] = tmp;
}
return new String(password);
}
}

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,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,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,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

@@ -0,0 +1,227 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.AuthSessionPolicy;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
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.core.StoredFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AdminUserGovernanceService {
private final UserRepository userRepository;
private final StoredFileRepository storedFileRepository;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final AuthSessionPolicy authSessionPolicy;
private final AdminAuditService adminAuditService;
private final SecureRandom secureRandom = new SecureRandom();
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<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
);
}
@Transactional
public AdminUserResponse updateUserRole(Long userId, UserRole role) {
User user = getRequiredUser(userId);
user.setRole(role);
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);
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
authSessionPolicy.rotateAllActiveSessions(user);
refreshTokenService.revokeAllForUser(user.getId());
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) {
return updateUserPasswordInternal(userId, newPassword, AdminAuditAction.UPDATE_USER_PASSWORD);
}
@Transactional
public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) {
User user = getRequiredUser(userId);
user.setStorageQuotaBytes(storageQuotaBytes);
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);
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();
updateUserPasswordInternal(userId, temporaryPassword, AdminAuditAction.RESET_USER_PASSWORD);
return new AdminPasswordResetResponse(temporaryPassword);
}
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) {
return toUserResponse(user, storedFileRepository.sumFileSizeByUserId(user.getId()));
}
private AdminUserResponse toUserResponse(User user, long usedStorageBytes) {
return new AdminUserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPhoneNumber(),
user.getCreatedAt(),
user.getRole(),
user.isBanned(),
usedStorageBytes,
user.getStorageQuotaBytes(),
user.getMaxUploadSizeBytes()
);
}
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, "user not found"));
}
private String normalizeQuery(String query) {
if (query == null) {
return "";
}
return query.trim();
}
private String generateTemporaryPassword() {
String lowers = "abcdefghjkmnpqrstuvwxyz";
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";
String digits = "23456789";
String specials = "!@#$%^&*";
String all = lowers + uppers + digits + specials;
char[] password = new char[12];
password[0] = lowers.charAt(secureRandom.nextInt(lowers.length()));
password[1] = uppers.charAt(secureRandom.nextInt(uppers.length()));
password[2] = digits.charAt(secureRandom.nextInt(digits.length()));
password[3] = specials.charAt(secureRandom.nextInt(specials.length()));
for (int i = 4; i < password.length; i += 1) {
password[i] = all.charAt(secureRandom.nextInt(all.length()));
}
for (int i = password.length - 1; i > 0; i -= 1) {
int j = secureRandom.nextInt(i + 1);
char tmp = password[i];
password[i] = password[j];
password[j] = tmp;
}
return new String(password);
}
}

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

@@ -4,6 +4,7 @@ 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;
@@ -142,10 +143,24 @@ public class UploadSessionV2Controller {
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) {

View File

@@ -19,6 +19,7 @@ public record UploadSessionV2Response(
LocalDateTime expiresAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
UploadSessionRuntimeStateV2Response runtime,
UploadSessionV2StrategyResponse strategy
) {
}

View File

@@ -5,7 +5,7 @@ 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.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskCommandService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
@@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class BackgroundTaskV2Controller {
private final BackgroundTaskService backgroundTaskService;
private final BackgroundTaskCommandService backgroundTaskCommandService;
private final CustomUserDetailsService userDetailsService;
@GetMapping
@@ -37,7 +37,7 @@ public class BackgroundTaskV2Controller {
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
var result = backgroundTaskService.listOwnedTasks(
var result = backgroundTaskCommandService.listOwnedTasks(
user,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
@@ -53,21 +53,21 @@ public class BackgroundTaskV2Controller {
public ApiV2Response<BackgroundTaskResponse> get(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long id) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(backgroundTaskService.getOwnedTask(user, id)));
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(backgroundTaskService.cancelOwnedTask(user, id)));
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(backgroundTaskService.retryOwnedTask(user, id)));
return ApiV2Response.success(toResponse(backgroundTaskCommandService.retryOwnedTask(user, id)));
}
@PostMapping("/archive")
@@ -92,7 +92,7 @@ public class BackgroundTaskV2Controller {
BackgroundTaskType type,
CreateBackgroundTaskRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
BackgroundTask task = backgroundTaskService.createQueuedFileTask(
BackgroundTask task = backgroundTaskCommandService.createQueuedFileTask(
user,
type,
request.fileId(),

View File

@@ -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

@@ -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

@@ -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

@@ -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

@@ -3,12 +3,16 @@ 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,11 +1,18 @@
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;
@@ -37,6 +44,7 @@ public class FileController {
private final FileService fileService;
private final CustomUserDetailsService userDetailsService;
private final ShareV2Service shareV2Service;
@Operation(summary = "上传文件")
@PostMapping("/upload")
@@ -147,16 +155,47 @@ public class FileController {
@Operation(summary = "创建分享链接")
@PostMapping("/{fileId}/share-links")
public ApiResponse<CreateFileShareLinkResponse> createShareLink(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return ApiResponse.success(
fileService.createShareLink(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId)
);
@PathVariable Long 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 = "导入共享文件")
@@ -164,13 +203,17 @@ public class FileController {
public ApiResponse<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String token,
@Valid @RequestBody ImportSharedFileRequest request) {
return ApiResponse.success(
fileService.importSharedFile(
userDetailsService.loadDomainUser(userDetails.getUsername()),
token,
request.path()
)
);
try {
return ApiResponse.success(
shareV2Service.importSharedFile(
userDetailsService.loadDomainUser(userDetails.getUsername()),
token,
new ImportShareV2Request(request.path(), null)
)
);
} catch (ApiV2Exception ex) {
throw mapLegacyShareApiException(ex);
}
}
@Operation(summary = "删除文件")
@@ -190,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

@@ -1,6 +1,11 @@
package com.yoyuzh.files.core;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -12,4 +17,28 @@ public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType);
List<FileEntity> findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType);
@EntityGraph(attributePaths = {"createdBy"})
@Query("""
select entity from FileEntity entity
where (:storagePolicyId is null or entity.storagePolicyId = :storagePolicyId)
and (:entityType is null or entity.entityType = :entityType)
and (:objectKey is null or :objectKey = ''
or lower(entity.objectKey) like lower(concat('%', :objectKey, '%')))
and (:userQuery is null or :userQuery = '' or exists (
select 1 from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity = entity
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
""")
Page<FileEntity> searchAdminEntities(@Param("userQuery") String userQuery,
@Param("storagePolicyId") Long storagePolicyId,
@Param("objectKey") String objectKey,
@Param("entityType") FileEntityType entityType,
Pageable pageable);
}

View File

@@ -0,0 +1,45 @@
package com.yoyuzh.files.core;
import com.yoyuzh.common.PageResponse;
import java.util.Collection;
import java.util.function.Supplier;
public interface FileListDirectoryCacheService {
PageResponse<FileMetadataResponse> getOrLoad(Long userId,
String path,
int page,
int size,
Supplier<PageResponse<FileMetadataResponse>> loader);
void touchDirectories(Long userId, Collection<String> paths);
default void touchDirectory(Long userId, String path) {
touchDirectories(userId, java.util.List.of(path));
}
static FileListDirectoryCacheService noOp() {
return NoOpHolder.INSTANCE;
}
final class NoOpHolder {
private static final FileListDirectoryCacheService INSTANCE = new FileListDirectoryCacheService() {
@Override
public PageResponse<FileMetadataResponse> getOrLoad(Long userId,
String path,
int page,
int size,
Supplier<PageResponse<FileMetadataResponse>> loader) {
return loader.get();
}
@Override
public void touchDirectories(Long userId, Collection<String> paths) {
}
};
private NoOpHolder() {
}
}
}

View File

@@ -5,6 +5,7 @@ import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.common.lock.DistributedLockService;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.events.FileEventService;
import com.yoyuzh.files.events.FileEventType;
@@ -17,6 +18,7 @@ import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.PreparedUpload;
import com.yoyuzh.files.tasks.MediaMetadataTaskBrokerPublisher;
import com.yoyuzh.files.upload.CompleteUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadRequest;
import com.yoyuzh.files.upload.InitiateUploadResponse;
@@ -40,6 +42,7 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -64,7 +67,6 @@ public class FileService {
private static final long RECYCLE_BIN_RETENTION_DAYS = 10L;
private final StoredFileRepository storedFileRepository;
private final FileBlobRepository fileBlobRepository;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final FileContentStorage fileContentStorage;
@@ -76,8 +78,19 @@ public class FileService {
private final String packageDownloadSecret;
private final long packageDownloadTtlSeconds;
private final Clock clock;
private final WorkspaceNodeRulesService workspaceNodeRulesService;
private final FileUploadRulesService fileUploadRulesService;
private final ExternalImportRulesService externalImportRulesService;
private final ContentAssetBindingService contentAssetBindingService;
private final ContentBlobLifecycleService contentBlobLifecycleService;
@Autowired(required = false)
private FileEventService fileEventService;
@Autowired(required = false)
private FileListDirectoryCacheService fileListDirectoryCacheService = FileListDirectoryCacheService.noOp();
@Autowired(required = false)
private DistributedLockService distributedLockService = DistributedLockService.noOp();
@Autowired(required = false)
private MediaMetadataTaskBrokerPublisher mediaMetadataTaskBrokerPublisher;
@Autowired
public FileService(StoredFileRepository storedFileRepository,
@@ -103,7 +116,6 @@ public class FileService {
FileStorageProperties properties,
Clock clock) {
this.storedFileRepository = storedFileRepository;
this.fileBlobRepository = fileBlobRepository;
this.fileEntityRepository = fileEntityRepository;
this.storedFileEntityRepository = storedFileEntityRepository;
this.fileContentStorage = fileContentStorage;
@@ -119,6 +131,11 @@ public class FileService {
: null;
this.packageDownloadTtlSeconds = Math.max(1, properties.getS3().getPackageDownloadTtlSeconds());
this.clock = clock;
this.workspaceNodeRulesService = new WorkspaceNodeRulesService(storedFileRepository, fileContentStorage);
this.fileUploadRulesService = new FileUploadRulesService(storedFileRepository, storagePolicyService, workspaceNodeRulesService, maxFileSize);
this.externalImportRulesService = new ExternalImportRulesService(workspaceNodeRulesService, fileUploadRulesService);
this.contentAssetBindingService = new ContentAssetBindingService(fileEntityRepository, storedFileEntityRepository, storagePolicyService);
this.contentBlobLifecycleService = new ContentBlobLifecycleService(storedFileRepository, fileBlobRepository, fileContentStorage);
}
FileService(StoredFileRepository storedFileRepository,
@@ -144,13 +161,13 @@ public class FileService {
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
String normalizedPath = normalizeDirectoryPath(path);
String filename = normalizeUploadFilename(multipartFile.getOriginalFilename());
validateUpload(user, normalizedPath, filename, multipartFile.getSize());
fileUploadRulesService.validateUpload(user, normalizedPath, filename, multipartFile.getSize());
ensureDirectoryHierarchy(user, normalizedPath);
String objectKey = createBlobObjectKey();
return executeAfterBlobStored(objectKey, () -> {
return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> {
fileContentStorage.uploadBlob(objectKey, multipartFile);
FileBlob blob = createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize());
FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize());
return saveFileMetadata(user, normalizedPath, filename, multipartFile.getContentType(), multipartFile.getSize(), blob);
});
}
@@ -158,10 +175,10 @@ public class FileService {
public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) {
String normalizedPath = normalizeDirectoryPath(request.path());
String filename = normalizeLeafName(request.filename());
validateUpload(user, normalizedPath, filename, request.size());
fileUploadRulesService.validateUpload(user, normalizedPath, filename, request.size());
String objectKey = createBlobObjectKey();
StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities();
StoragePolicyCapabilities capabilities = contentAssetBindingService.resolveDefaultStoragePolicyCapabilities();
if (capabilities != null && !capabilities.directUpload()) {
return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey);
}
@@ -187,12 +204,12 @@ public class FileService {
String normalizedPath = normalizeDirectoryPath(request.path());
String filename = normalizeLeafName(request.filename());
String objectKey = normalizeBlobObjectKey(request.storageName());
validateUpload(user, normalizedPath, filename, request.size());
fileUploadRulesService.validateUpload(user, normalizedPath, filename, request.size());
ensureDirectoryHierarchy(user, normalizedPath);
return executeAfterBlobStored(objectKey, () -> {
return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> {
fileContentStorage.completeBlobUpload(objectKey, request.contentType(), request.size());
FileBlob blob = createAndSaveBlob(objectKey, request.contentType(), request.size());
FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, request.contentType(), request.size());
return saveFileMetadata(user, normalizedPath, filename, request.contentType(), request.size(), blob);
});
}
@@ -205,9 +222,7 @@ public class FileService {
}
String parentPath = extractParentPath(normalizedPath);
String directoryName = extractLeafName(normalizedPath);
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), parentPath, directoryName, "目录已存在");
fileContentStorage.createDirectory(user.getId(), normalizedPath);
@@ -215,18 +230,23 @@ public class FileService {
storedFile.setUser(user);
storedFile.setFilename(directoryName);
storedFile.setPath(parentPath);
storedFile.setLegacyStorageName(directoryName);
storedFile.setContentType("directory");
storedFile.setSize(0L);
storedFile.setDirectory(true);
return toResponse(storedFileRepository.save(storedFile));
FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile));
touchDirectoryListings(user, parentPath);
return response;
}
public PageResponse<FileMetadataResponse> list(User user, String path, int page, int size) {
String normalizedPath = normalizeDirectoryPath(path);
Page<StoredFile> result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
user.getId(), normalizedPath, PageRequest.of(page, size));
List<FileMetadataResponse> items = result.getContent().stream().map(this::toResponse).toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
return fileListDirectoryCacheService.getOrLoad(user.getId(), normalizedPath, page, size, () -> {
Page<StoredFile> result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
user.getId(), normalizedPath, PageRequest.of(page, size));
List<FileMetadataResponse> items = result.getContent().stream().map(this::toResponse).toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
});
}
public List<FileMetadataResponse> recent(User user) {
@@ -244,8 +264,9 @@ public class FileService {
@Transactional
public void ensureDefaultDirectories(User user) {
boolean createdAny = false;
for (String directoryName : DEFAULT_DIRECTORIES) {
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), "/", directoryName)) {
if (workspaceNodeRulesService.existsNodeName(user.getId(), "/", directoryName)) {
continue;
}
@@ -256,10 +277,15 @@ public class FileService {
storedFile.setUser(user);
storedFile.setFilename(directoryName);
storedFile.setPath("/");
storedFile.setLegacyStorageName(directoryName);
storedFile.setContentType("directory");
storedFile.setSize(0L);
storedFile.setDirectory(true);
storedFileRepository.save(storedFile);
createdAny = true;
}
if (createdAny) {
touchDirectoryListings(user, "/");
}
}
@@ -275,33 +301,42 @@ public class FileService {
filesToRecycle.addAll(descendants);
}
moveToRecycleBin(filesToRecycle, storedFile.getId());
touchDirectoryListings(user, extractParentPath(fromPath));
recordFileEvent(user, FileEventType.DELETED, storedFile, fromPath, buildLogicalPath(storedFile));
}
@Transactional
public FileMetadataResponse restoreFromRecycleBin(User user, Long fileId) {
StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId);
String fromPath = buildLogicalPath(recycleRoot);
String toPath = buildTargetLogicalPath(requireRecycleOriginalPath(recycleRoot), recycleRoot.getFilename());
List<StoredFile> recycleGroupItems = loadRecycleGroupItems(recycleRoot);
long additionalBytes = recycleGroupItems.stream()
.filter(item -> !item.isDirectory())
.mapToLong(StoredFile::getSize)
.sum();
ensureWithinStorageQuota(user, additionalBytes);
validateRecycleRestoreTargets(user.getId(), recycleGroupItems);
ensureRecycleRestoreParentHierarchy(user, recycleRoot);
return distributedLockService.executeWithLock(
"files:recycle-restore:" + fileId,
Duration.ofSeconds(120),
() -> {
StoredFile recycleRoot = getOwnedRecycleRootFile(user, fileId);
String fromPath = buildLogicalPath(recycleRoot);
String restoreParentPath = requireRecycleOriginalPath(recycleRoot);
String toPath = buildTargetLogicalPath(restoreParentPath, recycleRoot.getFilename());
List<StoredFile> recycleGroupItems = loadRecycleGroupItems(recycleRoot);
long additionalBytes = recycleGroupItems.stream()
.filter(item -> !item.isDirectory())
.mapToLong(StoredFile::getSize)
.sum();
fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes);
validateRecycleRestoreTargets(user.getId(), recycleGroupItems);
ensureRecycleRestoreParentHierarchy(user, recycleRoot);
for (StoredFile item : recycleGroupItems) {
item.setPath(requireRecycleOriginalPath(item));
item.setDeletedAt(null);
item.setRecycleOriginalPath(null);
item.setRecycleGroupId(null);
item.setRecycleRoot(false);
}
storedFileRepository.saveAll(recycleGroupItems);
recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath);
return toResponse(recycleRoot);
for (StoredFile item : recycleGroupItems) {
item.setPath(requireRecycleOriginalPath(item));
item.setDeletedAt(null);
item.setRecycleOriginalPath(null);
item.setRecycleGroupId(null);
item.setRecycleRoot(false);
}
storedFileRepository.saveAll(recycleGroupItems);
touchDirectoryListings(user, restoreParentPath);
recordFileEvent(user, FileEventType.RESTORED, recycleRoot, fromPath, toPath);
return toResponse(recycleRoot);
}
);
}
@Scheduled(fixedDelay = 60 * 60 * 1000L)
@@ -312,11 +347,11 @@ public class FileService {
return;
}
List<FileBlob> blobsToDelete = collectBlobsToDelete(
List<FileBlob> blobsToDelete = contentBlobLifecycleService.collectBlobsToDelete(
expiredItems.stream().filter(item -> !item.isDirectory()).toList()
);
storedFileRepository.deleteAll(expiredItems);
deleteBlobs(blobsToDelete);
contentBlobLifecycleService.deleteBlobs(blobsToDelete);
}
@Transactional
@@ -327,9 +362,7 @@ public class FileService {
if (sanitizedFilename.equals(storedFile.getFilename())) {
return toResponse(storedFile);
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), storedFile.getPath(), sanitizedFilename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), storedFile.getPath(), sanitizedFilename, "同目录下文件已存在");
if (storedFile.isDirectory()) {
String oldLogicalPath = buildLogicalPath(storedFile);
@@ -353,6 +386,7 @@ public class FileService {
storedFile.setFilename(sanitizedFilename);
FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile));
touchDirectoryListings(user, storedFile.getPath());
recordFileEvent(user, FileEventType.RENAMED, storedFile, fromPath, buildLogicalPath(storedFile));
return response;
}
@@ -367,9 +401,7 @@ public class FileService {
}
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedTargetPath, storedFile.getFilename(), "目标目录已存在同名文件");
if (storedFile.isDirectory()) {
String oldLogicalPath = buildLogicalPath(storedFile);
@@ -396,6 +428,7 @@ public class FileService {
storedFile.setPath(normalizedTargetPath);
FileMetadataResponse response = toResponse(storedFileRepository.save(storedFile));
touchDirectoryListings(user, extractParentPath(fromPath), normalizedTargetPath);
recordFileEvent(user, FileEventType.MOVED, storedFile, fromPath, buildLogicalPath(storedFile));
return response;
}
@@ -405,13 +438,13 @@ public class FileService {
StoredFile storedFile = getOwnedActiveFile(user, fileId, "复制");
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedTargetPath, storedFile.getFilename(), "目标目录已存在同名文件");
if (!storedFile.isDirectory()) {
ensureWithinStorageQuota(user, storedFile.getSize());
return toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user));
fileUploadRulesService.ensureWithinStorageQuota(user, storedFile.getSize());
FileMetadataResponse response = toResponse(saveCopiedStoredFile(copyStoredFile(storedFile, user, normalizedTargetPath), user));
touchDirectoryListings(user, normalizedTargetPath);
return response;
}
String oldLogicalPath = buildLogicalPath(storedFile);
@@ -425,7 +458,7 @@ public class FileService {
.filter(descendant -> !descendant.isDirectory())
.mapToLong(StoredFile::getSize)
.sum();
ensureWithinStorageQuota(user, additionalBytes);
fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes);
List<StoredFile> copiedEntries = new ArrayList<>();
StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath);
@@ -438,9 +471,7 @@ public class FileService {
.thenComparing(StoredFile::getFilename))
.forEach(descendant -> {
String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath);
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), copiedPath, descendant.getFilename(), "目标目录已存在同名文件");
copiedEntries.add(copyStoredFile(descendant, user, copiedPath));
});
@@ -452,6 +483,7 @@ public class FileService {
savedRoot = savedEntry;
}
}
touchDirectoryListings(user, normalizedTargetPath);
return toResponse(savedRoot == null ? copiedRoot : savedRoot);
}
@@ -470,7 +502,7 @@ public class FileService {
if (fileContentStorage.supportsDirectDownload()) {
return ResponseEntity.status(302)
.location(URI.create(fileContentStorage.createBlobDownloadUrl(
getRequiredBlob(storedFile).getObjectKey(),
contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey(),
storedFile.getFilename())))
.build();
}
@@ -480,7 +512,7 @@ public class FileService {
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
.contentType(MediaType.parseMediaType(
storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType()))
.body(fileContentStorage.readBlob(getRequiredBlob(storedFile).getObjectKey()));
.body(fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey()));
}
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
@@ -496,7 +528,7 @@ public class FileService {
if (fileContentStorage.supportsDirectDownload()) {
return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl(
getRequiredBlob(storedFile).getObjectKey(),
contentBlobLifecycleService.getRequiredBlob(storedFile).getObjectKey(),
storedFile.getFilename()
));
}
@@ -553,7 +585,7 @@ public class FileService {
sourceFile.getFilename(),
sourceFile.getContentType(),
sourceFile.getSize(),
getRequiredBlob(sourceFile)
contentBlobLifecycleService.getRequiredBlob(sourceFile)
);
}
@@ -566,12 +598,12 @@ public class FileService {
byte[] content) {
String normalizedPath = normalizeDirectoryPath(path);
String normalizedFilename = normalizeLeafName(filename);
validateUpload(recipient, normalizedPath, normalizedFilename, size);
fileUploadRulesService.validateUpload(recipient, normalizedPath, normalizedFilename, size);
ensureDirectoryHierarchy(recipient, normalizedPath);
String objectKey = createBlobObjectKey();
return executeAfterBlobStored(objectKey, () -> {
return contentBlobLifecycleService.executeAfterBlobStored(objectKey, () -> {
fileContentStorage.storeBlob(objectKey, contentType, content);
FileBlob blob = createAndSaveBlob(objectKey, contentType, size);
FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, contentType, size);
return saveFileMetadata(
recipient,
@@ -596,9 +628,9 @@ public class FileService {
List<String> directories,
List<ExternalFileImport> files,
ExternalImportProgressListener progressListener) {
List<String> normalizedDirectories = normalizeExternalImportDirectories(directories);
List<ExternalFileImport> normalizedFiles = normalizeExternalImportFiles(files);
validateExternalImportBatch(recipient, normalizedDirectories, normalizedFiles);
List<String> normalizedDirectories = externalImportRulesService.normalizeDirectories(directories);
List<ExternalFileImport> normalizedFiles = externalImportRulesService.normalizeFiles(files);
externalImportRulesService.validateBatch(recipient, normalizedDirectories, normalizedFiles);
List<String> writtenBlobObjectKeys = new ArrayList<>();
int totalDirectoryCount = normalizedDirectories.size();
@@ -619,7 +651,7 @@ public class FileService {
processedDirectoryCount, totalDirectoryCount);
}
} catch (RuntimeException ex) {
cleanupWrittenBlobs(writtenBlobObjectKeys, ex);
contentBlobLifecycleService.cleanupWrittenBlobs(writtenBlobObjectKeys, ex);
throw ex;
}
}
@@ -658,7 +690,7 @@ public class FileService {
}
public ZipCompatibleArchive readZipCompatibleArchive(StoredFile source) {
byte[] archiveBytes = fileContentStorage.readBlob(getRequiredBlob(source).getObjectKey());
byte[] archiveBytes = fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(source).getObjectKey());
try (ZipInputStream zipInputStream = new ZipInputStream(
new ByteArrayInputStream(archiveBytes),
StandardCharsets.UTF_8)) {
@@ -709,7 +741,7 @@ public class FileService {
}
private String buildPublicPackageDownloadUrl(StoredFile storedFile) {
FileBlob blob = getRequiredBlob(storedFile);
FileBlob blob = contentBlobLifecycleService.getRequiredBlob(storedFile);
String base = packageDownloadBaseUrl.endsWith("/")
? packageDownloadBaseUrl.substring(0, packageDownloadBaseUrl.length() - 1)
: packageDownloadBaseUrl;
@@ -817,68 +849,30 @@ public class FileService {
storedFile.setSize(size);
storedFile.setDirectory(false);
storedFile.setBlob(blob);
storedFile.setLegacyStorageName(blob.getObjectKey());
FileEntity primaryEntity = createOrReferencePrimaryEntity(user, blob);
storedFile.setPrimaryEntity(primaryEntity);
StoredFile savedFile = storedFileRepository.save(storedFile);
savePrimaryEntityRelation(savedFile, primaryEntity);
touchDirectoryListings(user, normalizedPath);
publishMediaMetadataTrigger(savedFile);
recordFileEvent(user, FileEventType.CREATED, savedFile, null, buildLogicalPath(savedFile));
return toResponse(savedFile);
}
private 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));
}
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();
}
private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy());
return contentAssetBindingService.createOrReferencePrimaryEntity(user, blob);
}
private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) {
if (storedFileEntityRepository == null) {
contentAssetBindingService.savePrimaryEntityRelation(storedFile, primaryEntity);
}
private void publishMediaMetadataTrigger(StoredFile storedFile) {
if (mediaMetadataTaskBrokerPublisher == null) {
return;
}
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(primaryEntity);
relation.setEntityRole("PRIMARY");
storedFileEntityRepository.save(relation);
mediaMetadataTaskBrokerPublisher.publishAfterCommit(storedFile);
}
private FileShareLink getShareLink(String token) {
@@ -939,6 +933,10 @@ public class FileService {
}
private void validateUpload(User user, String normalizedPath, String filename, long size) {
if (fileUploadRulesService != null) {
fileUploadRulesService.validateUpload(user, normalizedPath, filename, size);
return;
}
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy);
@@ -951,9 +949,7 @@ public class FileService {
if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedPath, filename, "同目录下文件已存在");
ensureWithinStorageQuota(user, size);
}
@@ -985,7 +981,7 @@ public class FileService {
private void validateExternalImportBatch(User recipient,
List<String> directories,
List<ExternalFileImport> files) {
ensureWithinStorageQuota(recipient, files.stream().mapToLong(ExternalFileImport::size).sum());
fileUploadRulesService.ensureWithinStorageQuota(recipient, files.stream().mapToLong(ExternalFileImport::size).sum());
Set<String> plannedTargets = new LinkedHashSet<>();
for (String directory : directories) {
@@ -997,9 +993,7 @@ public class FileService {
}
String parentPath = extractParentPath(directory);
String directoryName = extractLeafName(directory);
if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), parentPath, directoryName)) {
throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), parentPath, directoryName, "解压目标已存在");
}
for (ExternalFileImport file : files) {
@@ -1007,13 +1001,15 @@ public class FileService {
if (plannedTargets.contains(logicalPath) || !plannedTargets.add(logicalPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "解压目标已存在");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(recipient.getId(), file.path(), file.filename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
workspaceNodeRulesService.ensureNodeNameAvailable(recipient.getId(), file.path(), file.filename(), "同目录下文件已存在");
}
}
private void ensureWithinStorageQuota(User user, long additionalBytes) {
if (fileUploadRulesService != null) {
fileUploadRulesService.ensureWithinStorageQuota(user, additionalBytes);
return;
}
if (additionalBytes <= 0) {
return;
}
@@ -1026,48 +1022,18 @@ public class FileService {
}
private void ensureDirectoryHierarchy(User user, String normalizedPath) {
if ("/".equals(normalizedPath)) {
return;
}
String[] segments = normalizedPath.substring(1).split("/");
String currentPath = "/";
for (String segment : segments) {
Optional<StoredFile> existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment);
if (existing.isPresent()) {
if (!existing.get().isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
}
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
continue;
}
String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
fileContentStorage.ensureDirectory(user.getId(), logicalPath);
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(segment);
storedFile.setPath(currentPath);
storedFile.setContentType("directory");
storedFile.setSize(0L);
storedFile.setDirectory(true);
storedFileRepository.save(storedFile);
currentPath = logicalPath;
}
workspaceNodeRulesService.ensureDirectoryHierarchy(user, normalizedPath);
}
private void storeExternalImportFile(User recipient,
ExternalFileImport file,
List<String> writtenBlobObjectKeys) {
validateUpload(recipient, file.path(), file.filename(), file.size());
fileUploadRulesService.validateUpload(recipient, file.path(), file.filename(), file.size());
ensureDirectoryHierarchy(recipient, file.path());
String objectKey = createBlobObjectKey();
writtenBlobObjectKeys.add(objectKey);
fileContentStorage.storeBlob(objectKey, file.contentType(), file.content());
FileBlob blob = createAndSaveBlob(objectKey, file.contentType(), file.size());
FileBlob blob = contentBlobLifecycleService.createAndSaveBlob(objectKey, file.contentType(), file.size());
saveFileMetadata(
recipient,
file.path(),
@@ -1130,12 +1096,7 @@ public class FileService {
}
private void validateRecycleRestoreTargets(Long userId, List<StoredFile> recycleGroupItems) {
for (StoredFile item : recycleGroupItems) {
String originalPath = requireRecycleOriginalPath(item);
if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, originalPath, item.getFilename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复");
}
}
workspaceNodeRulesService.validateRecycleRestoreTargets(userId, recycleGroupItems, this::requireRecycleOriginalPath);
}
private void ensureRecycleRestoreParentHierarchy(User user, StoredFile recycleRoot) {
@@ -1143,28 +1104,11 @@ public class FileService {
}
private void ensureExistingDirectoryPath(Long userId, String normalizedPath) {
if ("/".equals(normalizedPath)) {
return;
}
String[] segments = normalizedPath.substring(1).split("/");
String currentPath = "/";
for (String segment : segments) {
StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在"));
if (!directory.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
}
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
}
workspaceNodeRulesService.ensureExistingDirectoryPath(userId, normalizedPath);
}
private String normalizeUploadFilename(String originalFilename) {
String filename = StringUtils.cleanPath(originalFilename);
if (!StringUtils.hasText(filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
return normalizeLeafName(filename);
return workspaceNodeRulesService.normalizeUploadFilename(originalFilename);
}
private FileMetadataResponse toResponse(StoredFile storedFile) {
@@ -1180,30 +1124,15 @@ public class FileService {
}
private String normalizeDirectoryPath(String path) {
if (!StringUtils.hasText(path) || "/".equals(path.trim())) {
return "/";
}
String normalized = path.replace("\\", "/").trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
normalized = normalized.replaceAll("/{2,}", "/");
if (normalized.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
}
if (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
return workspaceNodeRulesService.normalizeDirectoryPath(path);
}
private String extractParentPath(String normalizedPath) {
int lastSlash = normalizedPath.lastIndexOf('/');
return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash);
return workspaceNodeRulesService.extractParentPath(normalizedPath);
}
private String extractLeafName(String normalizedPath) {
return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1);
return workspaceNodeRulesService.extractLeafName(normalizedPath);
}
private String buildLogicalPath(StoredFile storedFile) {
@@ -1213,9 +1142,7 @@ public class FileService {
}
private String buildTargetLogicalPath(String normalizedTargetPath, String filename) {
return "/".equals(normalizedTargetPath)
? "/" + filename
: normalizedTargetPath + "/" + filename;
return workspaceNodeRulesService.buildTargetLogicalPath(normalizedTargetPath, filename);
}
private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) {
@@ -1277,7 +1204,7 @@ public class FileService {
ArchiveBuildProgressState progressState) throws IOException {
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName, progressState);
writeFileEntry(zipOutputStream, createdEntries, entryName, progressState,
fileContentStorage.readBlob(getRequiredBlob(file).getObjectKey()));
fileContentStorage.readBlob(contentBlobLifecycleService.getRequiredBlob(file).getObjectKey()));
}
private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
@@ -1476,15 +1403,26 @@ public class FileService {
fileEventService.record(user, eventType, storedFile.getId(), fromPath, toPath, payload);
}
private void touchDirectoryListings(User user, String... paths) {
if (user == null || user.getId() == null || paths == null || paths.length == 0) {
return;
}
List<String> affectedPaths = new ArrayList<>();
for (String path : paths) {
if (StringUtils.hasText(path)) {
affectedPaths.add(normalizeDirectoryPath(path));
}
}
if (affectedPaths.isEmpty()) {
return;
}
fileListDirectoryCacheService.touchDirectories(user.getId(), affectedPaths);
}
private String normalizeLeafName(String filename) {
String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim();
if (!StringUtils.hasText(cleaned)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法");
}
return cleaned;
return workspaceNodeRulesService.normalizeLeafName(filename);
}
private String createBlobObjectKey() {
@@ -1499,37 +1437,6 @@ public class FileService {
return cleaned;
}
private <T> T executeAfterBlobStored(String objectKey, BlobWriteOperation<T> operation) {
try {
return operation.run();
} catch (RuntimeException ex) {
try {
fileContentStorage.deleteBlob(objectKey);
} catch (RuntimeException cleanupEx) {
ex.addSuppressed(cleanupEx);
}
throw ex;
}
}
private void cleanupWrittenBlobs(List<String> writtenBlobObjectKeys, RuntimeException ex) {
for (String objectKey : writtenBlobObjectKeys) {
try {
fileContentStorage.deleteBlob(objectKey);
} catch (RuntimeException cleanupEx) {
ex.addSuppressed(cleanupEx);
}
}
}
private 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);
}
private FileMetadataResponse importReferencedBlob(User recipient,
String path,
String filename,
@@ -1538,7 +1445,7 @@ public class FileService {
FileBlob blob) {
String normalizedPath = normalizeDirectoryPath(path);
String normalizedFilename = normalizeLeafName(filename);
validateUpload(recipient, normalizedPath, normalizedFilename, size);
fileUploadRulesService.validateUpload(recipient, normalizedPath, normalizedFilename, size);
ensureDirectoryHierarchy(recipient, normalizedPath);
return saveFileMetadata(
recipient,
@@ -1557,50 +1464,6 @@ public class FileService {
return storedFile.getBlob();
}
private 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;
}
private 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;
}
}
@FunctionalInterface
private interface BlobWriteOperation<T> {
T run();
}
public static record ZipCompatibleArchive(List<ZipCompatibleArchiveEntry> entries, String commonRootDirectoryName) {
}

View File

@@ -0,0 +1,55 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService;
public final class FileUploadRulesService {
private final StoredFileRepository storedFileRepository;
private final StoragePolicyService storagePolicyService;
private final WorkspaceNodeRulesService workspaceNodeRulesService;
private final long maxFileSize;
public FileUploadRulesService(StoredFileRepository storedFileRepository,
StoragePolicyService storagePolicyService,
WorkspaceNodeRulesService workspaceNodeRulesService,
long maxFileSize) {
this.storedFileRepository = storedFileRepository;
this.storagePolicyService = storagePolicyService;
this.workspaceNodeRulesService = workspaceNodeRulesService;
this.maxFileSize = maxFileSize;
}
public void validateUpload(User user, String normalizedPath, String filename, long size) {
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy);
if (defaultPolicy != null && defaultPolicy.getMaxSizeBytes() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, defaultPolicy.getMaxSizeBytes());
}
if (capabilities != null && capabilities.maxObjectSize() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize());
}
if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
workspaceNodeRulesService.ensureNodeNameAvailable(user.getId(), normalizedPath, filename, "同目录下文件已存在");
ensureWithinStorageQuota(user, size);
}
public void ensureWithinStorageQuota(User user, long additionalBytes) {
if (additionalBytes <= 0) {
return;
}
long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
long quotaBytes = user.getStorageQuotaBytes();
if (usedBytes > Long.MAX_VALUE - additionalBytes || usedBytes + additionalBytes > quotaBytes) {
throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足");
}
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.files.core;
import com.yoyuzh.common.PageResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.function.Supplier;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public class NoOpFileListDirectoryCacheService implements FileListDirectoryCacheService {
@Override
public PageResponse<FileMetadataResponse> getOrLoad(Long userId,
String path,
int page,
int size,
Supplier<PageResponse<FileMetadataResponse>> loader) {
return loader.get();
}
@Override
public void touchDirectories(Long userId, Collection<String> paths) {
}
}

View File

@@ -0,0 +1,166 @@
package com.yoyuzh.files.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.config.AppRedisProperties;
import com.yoyuzh.config.RedisCacheNames;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class RedisFileListDirectoryCacheService implements FileListDirectoryCacheService {
private static final String SORT_CONTEXT = "directory-desc-created-desc";
private final CacheManager cacheManager;
private final StringRedisTemplate stringRedisTemplate;
private final AppRedisProperties redisProperties;
private final ObjectMapper objectMapper;
public RedisFileListDirectoryCacheService(CacheManager cacheManager,
StringRedisTemplate stringRedisTemplate,
AppRedisProperties redisProperties,
ObjectMapper objectMapper) {
this.cacheManager = cacheManager;
this.stringRedisTemplate = stringRedisTemplate;
this.redisProperties = redisProperties;
this.objectMapper = objectMapper;
}
@Override
public PageResponse<FileMetadataResponse> getOrLoad(Long userId,
String path,
int page,
int size,
Supplier<PageResponse<FileMetadataResponse>> loader) {
Cache cache = cacheManager.getCache(RedisCacheNames.FILES_LIST);
if (cache == null) {
return loader.get();
}
long version = readDirectoryVersion(userId, path);
String cacheKey = buildCacheKey(userId, path, page, size, version);
CachedFileListPage cached = readCachedPage(cache, cacheKey);
if (cached != null) {
return cached.toPageResponse();
}
PageResponse<FileMetadataResponse> loaded = loader.get();
cache.put(cacheKey, CachedFileListPage.from(loaded));
return loaded;
}
@Override
public void touchDirectories(Long userId, Collection<String> paths) {
if (userId == null || paths == null || paths.isEmpty()) {
return;
}
Set<String> normalizedPaths = new LinkedHashSet<>();
for (String path : paths) {
String normalized = normalizeDirectoryPath(path);
if (normalized != null) {
normalizedPaths.add(normalized);
}
}
if (normalizedPaths.isEmpty()) {
return;
}
Duration ttl = Duration.ofSeconds(Math.max(
redisProperties.getCache().getDirectoryVersionTtlSeconds(),
redisProperties.getCache().getFilesListTtlSeconds() * 2
));
for (String path : normalizedPaths) {
String key = buildDirectoryVersionKey(userId, path);
stringRedisTemplate.opsForValue().increment(key);
stringRedisTemplate.expire(key, ttl);
}
}
private CachedFileListPage readCachedPage(Cache cache, String cacheKey) {
Cache.ValueWrapper wrapper = cache.get(cacheKey);
if (wrapper == null || wrapper.get() == null) {
return null;
}
Object cachedValue = wrapper.get();
if (cachedValue instanceof CachedFileListPage cachedFileListPage) {
return cachedFileListPage;
}
return objectMapper.convertValue(cachedValue, CachedFileListPage.class);
}
private long readDirectoryVersion(Long userId, String path) {
String value = stringRedisTemplate.opsForValue().get(buildDirectoryVersionKey(userId, path));
if (!StringUtils.hasText(value)) {
return 0L;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ex) {
return 0L;
}
}
private String buildCacheKey(Long userId, String path, int page, int size, long version) {
return "u:" + userId
+ ":path:" + encode(path)
+ ":page:" + page
+ ":size:" + size
+ ":sort:" + SORT_CONTEXT
+ ":v:" + version;
}
private String buildDirectoryVersionKey(Long userId, String path) {
return redisProperties.getKeyPrefix()
+ ":" + redisProperties.getNamespaces().getCache()
+ ":files-list:version:u:" + userId
+ ":path:" + encode(path);
}
private String encode(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private String normalizeDirectoryPath(String path) {
if (!StringUtils.hasText(path)) {
return "/";
}
String normalized = path.trim().replace("\\", "/");
while (normalized.contains("//")) {
normalized = normalized.replace("//", "/");
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private record CachedFileListPage(List<FileMetadataResponse> items, long total, int page, int size) {
private static CachedFileListPage from(PageResponse<FileMetadataResponse> response) {
return new CachedFileListPage(response.items(), response.total(), response.page(), response.size());
}
private PageResponse<FileMetadataResponse> toPageResponse() {
return new PageResponse<>(items, total, page, size);
}
}
}

View File

@@ -4,8 +4,23 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface StoredFileEntityRepository extends JpaRepository<StoredFileEntity, Long> {
interface FileEntityLinkStatsProjection {
Long getFileEntityId();
Long getLinkedStoredFileCount();
Long getLinkedOwnerCount();
String getSampleOwnerUsername();
String getSampleOwnerEmail();
}
@Query("""
select count(distinct relation.storedFile.id)
from StoredFileEntity relation
@@ -14,4 +29,45 @@ public interface StoredFileEntityRepository extends JpaRepository<StoredFileEnti
""")
long countDistinctStoredFilesByStoragePolicyIdAndEntityType(@Param("storagePolicyId") Long storagePolicyId,
@Param("entityType") FileEntityType entityType);
long countByFileEntityId(Long fileEntityId);
@Query("""
select count(distinct relation.storedFile.user.id)
from StoredFileEntity relation
where relation.fileEntity.id = :fileEntityId
""")
long countDistinctOwnersByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.username)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerUsernameByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.email)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerEmailByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select relation.fileEntity.id as fileEntityId,
count(distinct relation.storedFile.id) as linkedStoredFileCount,
count(distinct owner.id) as linkedOwnerCount,
min(owner.username) as sampleOwnerUsername,
min(owner.email) as sampleOwnerEmail
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id in :fileEntityIds
group by relation.fileEntity.id
""")
List<FileEntityLinkStatsProjection> findAdminLinkStatsByFileEntityIds(@Param("fileEntityIds") Collection<Long> fileEntityIds);
}

View File

@@ -8,11 +8,18 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
interface UserStorageUsageProjection {
Long getUserId();
Long getUsedStorageBytes();
}
@EntityGraph(attributePaths = {"user", "blob"})
Page<StoredFile> findAllByOrderByCreatedAtDesc(Pageable pageable);
@@ -104,6 +111,14 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
""")
long sumFileSizeByUserId(@Param("userId") Long userId);
@Query("""
select f.user.id as userId, coalesce(sum(f.size), 0) as usedStorageBytes
from StoredFile f
where f.user.id in :userIds and f.directory = false and f.deletedAt is null
group by f.user.id
""")
List<UserStorageUsageProjection> sumFileSizeByUserIds(@Param("userIds") Collection<Long> userIds);
@Query("""
select coalesce(sum(f.size), 0)
from StoredFile f

View File

@@ -0,0 +1,147 @@
package com.yoyuzh.files.core;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.storage.FileContentStorage;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
public final class WorkspaceNodeRulesService {
private final StoredFileRepository storedFileRepository;
private final FileContentStorage fileContentStorage;
public WorkspaceNodeRulesService(StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage) {
this.storedFileRepository = storedFileRepository;
this.fileContentStorage = fileContentStorage;
}
public String normalizeDirectoryPath(String path) {
if (!StringUtils.hasText(path) || "/".equals(path.trim())) {
return "/";
}
String normalized = path.replace("\\", "/").trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
normalized = normalized.replaceAll("/{2,}", "/");
if (normalized.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
}
if (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
public String extractParentPath(String normalizedPath) {
int lastSlash = normalizedPath.lastIndexOf('/');
return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash);
}
public String extractLeafName(String normalizedPath) {
return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1);
}
public String buildTargetLogicalPath(String normalizedTargetPath, String filename) {
return "/".equals(normalizedTargetPath)
? "/" + filename
: normalizedTargetPath + "/" + filename;
}
public String normalizeUploadFilename(String originalFilename) {
String filename = StringUtils.cleanPath(originalFilename);
if (!StringUtils.hasText(filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
return normalizeLeafName(filename);
}
public String normalizeLeafName(String filename) {
String cleaned = StringUtils.cleanPath(filename == null ? "" : filename).trim();
if (!StringUtils.hasText(cleaned)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
if (cleaned.contains("/") || cleaned.contains("\\") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不合法");
}
return cleaned;
}
public boolean existsNodeName(Long userId, String path, String filename) {
return storedFileRepository.existsByUserIdAndPathAndFilename(userId, path, filename);
}
public void ensureNodeNameAvailable(Long userId, String path, String filename, String errorMessage) {
if (existsNodeName(userId, path, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, errorMessage);
}
}
public void ensureDirectoryHierarchy(User user, String normalizedPath) {
if ("/".equals(normalizedPath)) {
return;
}
String[] segments = normalizedPath.substring(1).split("/");
String currentPath = "/";
for (String segment : segments) {
Optional<StoredFile> existing = storedFileRepository.findByUserIdAndPathAndFilename(user.getId(), currentPath, segment);
if (existing.isPresent()) {
if (!existing.get().isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
}
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
continue;
}
String logicalPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
fileContentStorage.ensureDirectory(user.getId(), logicalPath);
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(segment);
storedFile.setPath(currentPath);
storedFile.setContentType("directory");
storedFile.setSize(0L);
storedFile.setDirectory(true);
storedFileRepository.save(storedFile);
currentPath = logicalPath;
}
}
public void ensureExistingDirectoryPath(Long userId, String normalizedPath) {
if ("/".equals(normalizedPath)) {
return;
}
String[] segments = normalizedPath.substring(1).split("/");
String currentPath = "/";
for (String segment : segments) {
StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在"));
if (!directory.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
}
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
}
}
public void validateRecycleRestoreTargets(Long userId,
List<StoredFile> recycleGroupItems,
Function<StoredFile, String> recycleOriginalPathResolver) {
for (StoredFile item : recycleGroupItems) {
String originalPath = recycleOriginalPathResolver.apply(item);
if (existsNodeName(userId, originalPath, item.getFilename())) {
throw new BusinessException(ErrorCode.UNKNOWN, "原目录已存在同名文件,无法恢复");
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files.events;
public interface FileEventCrossInstancePublisher {
void publish(FileEvent event);
}

View File

@@ -0,0 +1,141 @@
package com.yoyuzh.files.events;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class FileEventDispatcher {
private static final String READY_EVENT_NAME = "READY";
private final FileEventPayloadCodec payloadCodec;
private final ConcurrentHashMap<Long, Set<Subscription>> subscriptions = new ConcurrentHashMap<>();
public FileEventDispatcher(FileEventPayloadCodec payloadCodec) {
this.payloadCodec = payloadCodec;
}
public SseEmitter openStream(Long userId, String path, String clientId) {
String normalizedPath = normalizePath(path);
String normalizedClientId = normalizeClientId(clientId);
SseEmitter emitter = createEmitter();
Subscription subscription = new Subscription(emitter, normalizedPath, normalizedClientId);
subscriptions.computeIfAbsent(userId, ignored -> ConcurrentHashMap.newKeySet()).add(subscription);
emitter.onCompletion(() -> removeSubscription(userId, subscription));
emitter.onTimeout(() -> removeSubscription(userId, subscription));
emitter.onError(ex -> removeSubscription(userId, subscription));
try {
emitter.send(SseEmitter.event()
.name(READY_EVENT_NAME)
.data(payloadCodec.createReadyPayload(normalizedPath, normalizedClientId)));
} catch (IOException ex) {
removeSubscription(userId, subscription);
throw new IllegalStateException("Failed to initialize file event stream", ex);
}
return emitter;
}
public void broadcast(FileEvent event) {
Set<Subscription> userSubscriptions = subscriptions.get(event.getUserId());
if (userSubscriptions == null || userSubscriptions.isEmpty()) {
return;
}
for (Subscription subscription : userSubscriptions.toArray(new Subscription[0])) {
if (!subscription.matches(event)) {
continue;
}
try {
subscription.emitter.send(SseEmitter.event()
.name(event.getEventType().name())
.data(payloadCodec.createEmitterPayload(event)));
} catch (IOException | IllegalStateException ex) {
removeSubscription(event.getUserId(), subscription);
}
}
}
protected SseEmitter createEmitter() {
return new SseEmitter();
}
private void removeSubscription(Long userId, Subscription subscription) {
Set<Subscription> userSubscriptions = subscriptions.get(userId);
if (userSubscriptions == null) {
return;
}
userSubscriptions.remove(subscription);
if (userSubscriptions.isEmpty()) {
subscriptions.remove(userId, userSubscriptions);
}
}
private String normalizeClientId(String clientId) {
if (!StringUtils.hasText(clientId)) {
return null;
}
String cleaned = clientId.trim();
return cleaned.isEmpty() ? null : cleaned;
}
private String normalizePath(String path) {
if (!StringUtils.hasText(path)) {
return "/";
}
String cleaned = path.trim().replace("\\", "/");
while (cleaned.contains("//")) {
cleaned = cleaned.replace("//", "/");
}
if (!cleaned.startsWith("/")) {
cleaned = "/" + cleaned;
}
if (cleaned.length() > 1 && cleaned.endsWith("/")) {
cleaned = cleaned.substring(0, cleaned.length() - 1);
}
return cleaned;
}
private boolean isPathMatch(String filterPath, String eventPath) {
if (!StringUtils.hasText(filterPath) || "/".equals(filterPath)) {
return true;
}
if (!StringUtils.hasText(eventPath)) {
return false;
}
return Objects.equals(filterPath, eventPath) || eventPath.startsWith(filterPath + "/");
}
private final class Subscription {
private final SseEmitter emitter;
private final String path;
private final String clientId;
private Subscription(SseEmitter emitter, String path, String clientId) {
this.emitter = emitter;
this.path = path;
this.clientId = clientId;
}
private boolean matches(FileEvent event) {
boolean pathMatches;
if (event.getFromPath() != null && event.getToPath() != null) {
pathMatches = isPathMatch(path, event.getFromPath()) || isPathMatch(path, event.getToPath());
} else {
String eventPath = event.getToPath() != null ? event.getToPath() : event.getFromPath();
pathMatches = isPathMatch(path, eventPath);
}
if (!pathMatches) {
return false;
}
return clientId == null || event.getClientId() == null || !clientId.equals(event.getClientId());
}
}
}

View File

@@ -0,0 +1,23 @@
package com.yoyuzh.files.events;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class FileEventInstanceIdentity {
private final String instanceId;
public FileEventInstanceIdentity() {
this(UUID.randomUUID().toString());
}
FileEventInstanceIdentity(String instanceId) {
this.instanceId = instanceId;
}
public String getInstanceId() {
return instanceId;
}
}

View File

@@ -0,0 +1,52 @@
package com.yoyuzh.files.events;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class FileEventPayloadCodec {
private final ObjectMapper objectMapper;
public FileEventPayloadCodec(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public String toJson(Map<String, Object> payload) {
Map<String, Object> safePayload = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload);
if (!safePayload.containsKey("createdAt")) {
safePayload.put("createdAt", LocalDateTime.now());
}
try {
return objectMapper.writeValueAsString(safePayload);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize file event payload", ex);
}
}
public Map<String, Object> createReadyPayload(String path, String clientId) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", "READY");
payload.put("path", path);
payload.put("clientId", clientId);
payload.put("createdAt", LocalDateTime.now());
return payload;
}
public Map<String, Object> createEmitterPayload(FileEvent event) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", event.getEventType().name());
payload.put("fileId", event.getFileId());
payload.put("fromPath", event.getFromPath());
payload.put("toPath", event.getToPath());
payload.put("clientId", event.getClientId());
payload.put("createdAt", event.getCreatedAt());
payload.put("payload", event.getPayloadJson());
return payload;
}
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.files.events;
import java.time.LocalDateTime;
record FileEventPubSubMessage(
String originInstanceId,
Long eventId,
Long userId,
FileEventType eventType,
Long fileId,
String fromPath,
String toPath,
String clientId,
String payloadJson,
LocalDateTime createdAt
) {
}

View File

@@ -0,0 +1,23 @@
package com.yoyuzh.files.events;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@Configuration
public class FileEventRedisPubSubConfiguration {
@Bean
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public RedisMessageListenerContainer fileEventRedisMessageListenerContainer(
RedisConnectionFactory redisConnectionFactory,
RedisFileEventPubSubListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.addMessageListener(listener, new ChannelTopic(listener.buildTopic()));
return container;
}
}

View File

@@ -1,7 +1,5 @@
package com.yoyuzh.files.events;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.User;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
@@ -12,46 +10,29 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class FileEventService {
private static final String CLIENT_ID_HEADER = "X-Yoyuzh-Client-Id";
private static final String READY_EVENT_NAME = "READY";
private final FileEventRepository fileEventRepository;
private final ObjectMapper objectMapper;
private final ConcurrentHashMap<Long, Set<Subscription>> subscriptions = new ConcurrentHashMap<>();
private final FileEventPayloadCodec payloadCodec;
private final FileEventDispatcher fileEventDispatcher;
private final FileEventCrossInstancePublisher fileEventCrossInstancePublisher;
public FileEventService(FileEventRepository fileEventRepository, ObjectMapper objectMapper) {
public FileEventService(FileEventRepository fileEventRepository,
FileEventPayloadCodec payloadCodec,
FileEventDispatcher fileEventDispatcher,
FileEventCrossInstancePublisher fileEventCrossInstancePublisher) {
this.fileEventRepository = fileEventRepository;
this.objectMapper = objectMapper;
this.payloadCodec = payloadCodec;
this.fileEventDispatcher = fileEventDispatcher;
this.fileEventCrossInstancePublisher = fileEventCrossInstancePublisher;
}
public SseEmitter openStream(User user, String path, String clientId) {
String normalizedPath = normalizePath(path);
SseEmitter emitter = createEmitter();
Subscription subscription = new Subscription(emitter, normalizedPath, normalizeClientId(clientId));
subscriptions.computeIfAbsent(user.getId(), ignored -> ConcurrentHashMap.newKeySet()).add(subscription);
emitter.onCompletion(() -> removeSubscription(user.getId(), subscription));
emitter.onTimeout(() -> removeSubscription(user.getId(), subscription));
emitter.onError(ex -> removeSubscription(user.getId(), subscription));
try {
emitter.send(SseEmitter.event()
.name(READY_EVENT_NAME)
.data(createReadyPayload(normalizedPath, subscription.clientId)));
} catch (IOException ex) {
removeSubscription(user.getId(), subscription);
throw new IllegalStateException("Failed to initialize file event stream", ex);
}
return emitter;
return fileEventDispatcher.openStream(user.getId(), path, resolveClientId(clientId));
}
public FileEvent record(User user,
@@ -68,7 +49,7 @@ public class FileEventService {
event.setFromPath(fromPath);
event.setToPath(toPath);
event.setClientId(resolveClientId(clientId));
event.setPayloadJson(toJson(payload));
event.setPayloadJson(payloadCodec.toJson(payload));
fileEventRepository.save(event);
broadcast(event);
return event;
@@ -83,37 +64,14 @@ public class FileEventService {
return record(user, eventType, fileId, fromPath, toPath, null, payload);
}
protected SseEmitter createEmitter() {
return new SseEmitter();
void broadcastReplicatedEvent(FileEvent event) {
fileEventDispatcher.broadcast(event);
}
private void broadcast(FileEvent event) {
Runnable broadcastTask = () -> {
Set<Subscription> userSubscriptions = subscriptions.get(event.getUserId());
if (userSubscriptions == null || userSubscriptions.isEmpty()) {
return;
}
for (Subscription subscription : userSubscriptions.toArray(new Subscription[0])) {
if (!subscription.matches(event)) {
continue;
}
try {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", event.getEventType().name());
payload.put("fileId", event.getFileId());
payload.put("fromPath", event.getFromPath());
payload.put("toPath", event.getToPath());
payload.put("clientId", event.getClientId());
payload.put("createdAt", event.getCreatedAt());
payload.put("payload", event.getPayloadJson());
subscription.emitter.send(SseEmitter.event()
.name(event.getEventType().name())
.data(payload));
} catch (IOException | IllegalStateException ex) {
removeSubscription(event.getUserId(), subscription);
}
}
fileEventDispatcher.broadcast(event);
fileEventCrossInstancePublisher.publish(event);
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
@@ -129,32 +87,9 @@ public class FileEventService {
broadcastTask.run();
}
private void removeSubscription(Long userId, Subscription subscription) {
Set<Subscription> userSubscriptions = subscriptions.get(userId);
if (userSubscriptions == null) {
return;
}
userSubscriptions.remove(subscription);
if (userSubscriptions.isEmpty()) {
subscriptions.remove(userId, userSubscriptions);
}
}
private String toJson(Map<String, Object> payload) {
Map<String, Object> safePayload = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload);
if (!safePayload.containsKey("createdAt")) {
safePayload.put("createdAt", LocalDateTime.now());
}
try {
return objectMapper.writeValueAsString(safePayload);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize file event payload", ex);
}
}
private String resolveClientId(String explicitClientId) {
if (StringUtils.hasText(explicitClientId)) {
return normalizeClientId(explicitClientId);
return explicitClientId.trim();
}
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
@@ -162,79 +97,7 @@ public class FileEventService {
return null;
}
HttpServletRequest request = attributes.getRequest();
return normalizeClientId(request.getHeader(CLIENT_ID_HEADER));
}
private String normalizeClientId(String clientId) {
if (!StringUtils.hasText(clientId)) {
return null;
}
String cleaned = clientId.trim();
return cleaned.isEmpty() ? null : cleaned;
}
private String normalizePath(String path) {
if (!StringUtils.hasText(path)) {
return "/";
}
String cleaned = path.trim().replace("\\", "/");
while (cleaned.contains("//")) {
cleaned = cleaned.replace("//", "/");
}
if (!cleaned.startsWith("/")) {
cleaned = "/" + cleaned;
}
if (cleaned.length() > 1 && cleaned.endsWith("/")) {
cleaned = cleaned.substring(0, cleaned.length() - 1);
}
return cleaned;
}
private boolean isPathMatch(String filterPath, String eventPath) {
if (!StringUtils.hasText(filterPath) || "/".equals(filterPath)) {
return true;
}
if (!StringUtils.hasText(eventPath)) {
return false;
}
return Objects.equals(filterPath, eventPath) || eventPath.startsWith(filterPath + "/");
}
private Map<String, Object> createReadyPayload(String path, String clientId) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventType", READY_EVENT_NAME);
payload.put("path", path);
payload.put("clientId", clientId);
payload.put("createdAt", LocalDateTime.now());
return payload;
}
private final class Subscription {
private final SseEmitter emitter;
private final String path;
private final String clientId;
private Subscription(SseEmitter emitter, String path, String clientId) {
this.emitter = emitter;
this.path = path;
this.clientId = clientId;
}
private boolean matches(FileEvent event) {
boolean pathMatches;
if (event.getFromPath() != null && event.getToPath() != null) {
pathMatches = FileEventService.this.isPathMatch(path, event.getFromPath())
|| FileEventService.this.isPathMatch(path, event.getToPath());
} else {
String eventPath = event.getToPath() != null ? event.getToPath() : event.getFromPath();
pathMatches = FileEventService.this.isPathMatch(path, eventPath);
}
if (!pathMatches) {
return false;
}
return clientId == null || event.getClientId() == null || !clientId.equals(event.getClientId());
}
String requestClientId = request.getHeader(CLIENT_ID_HEADER);
return StringUtils.hasText(requestClientId) ? requestClientId.trim() : null;
}
}

View File

@@ -0,0 +1,14 @@
package com.yoyuzh.files.events;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "false", matchIfMissing = true)
public class NoOpFileEventCrossInstancePublisher implements FileEventCrossInstancePublisher {
@Override
public void publish(FileEvent event) {
// Redis disabled: keep single-instance in-memory broadcast behavior.
}
}

View File

@@ -0,0 +1,88 @@
package com.yoyuzh.files.events;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.AppRedisProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
@Component
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class RedisFileEventPubSubListener implements MessageListener {
private final ObjectMapper objectMapper;
private final AppRedisProperties redisProperties;
private final FileEventService fileEventService;
private final String instanceId;
@Autowired
public RedisFileEventPubSubListener(ObjectMapper objectMapper,
AppRedisProperties redisProperties,
FileEventService fileEventService,
FileEventInstanceIdentity instanceIdentity) {
this(objectMapper, redisProperties, fileEventService, instanceIdentity.getInstanceId());
}
RedisFileEventPubSubListener(ObjectMapper objectMapper,
AppRedisProperties redisProperties,
FileEventService fileEventService,
String instanceId) {
this.objectMapper = objectMapper;
this.redisProperties = redisProperties;
this.fileEventService = fileEventService;
this.instanceId = instanceId;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody(), StandardCharsets.UTF_8);
if (!StringUtils.hasText(payload)) {
return;
}
FileEventPubSubMessage pubSubMessage;
try {
pubSubMessage = parsePayload(payload);
} catch (IllegalStateException ex) {
return;
}
if (instanceId.equals(pubSubMessage.originInstanceId())) {
return;
}
fileEventService.broadcastReplicatedEvent(toEvent(pubSubMessage));
}
String buildTopic() {
return redisProperties.getKeyPrefix()
+ ":"
+ redisProperties.getNamespaces().getFileEvents()
+ ":pubsub";
}
private FileEventPubSubMessage parsePayload(String payload) {
try {
return objectMapper.readValue(payload, FileEventPubSubMessage.class);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to parse file event pub/sub payload", ex);
}
}
private FileEvent toEvent(FileEventPubSubMessage message) {
FileEvent event = new FileEvent();
event.setId(message.eventId());
event.setUserId(message.userId());
event.setEventType(message.eventType());
event.setFileId(message.fileId());
event.setFromPath(message.fromPath());
event.setToPath(message.toPath());
event.setClientId(message.clientId());
event.setPayloadJson(message.payloadJson());
event.setCreatedAt(message.createdAt());
return event;
}
}

View File

@@ -0,0 +1,71 @@
package com.yoyuzh.files.events;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.AppRedisProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
public class RedisFileEventPubSubPublisher implements FileEventCrossInstancePublisher {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final AppRedisProperties redisProperties;
private final String instanceId;
@Autowired
public RedisFileEventPubSubPublisher(StringRedisTemplate stringRedisTemplate,
ObjectMapper objectMapper,
AppRedisProperties redisProperties,
FileEventInstanceIdentity instanceIdentity) {
this(stringRedisTemplate, objectMapper, redisProperties, instanceIdentity.getInstanceId());
}
RedisFileEventPubSubPublisher(StringRedisTemplate stringRedisTemplate,
ObjectMapper objectMapper,
AppRedisProperties redisProperties,
String instanceId) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
this.redisProperties = redisProperties;
this.instanceId = instanceId;
}
@Override
public void publish(FileEvent event) {
try {
stringRedisTemplate.convertAndSend(
buildTopic(),
objectMapper.writeValueAsString(toMessage(event))
);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize file event pub/sub payload", ex);
}
}
String buildTopic() {
return redisProperties.getKeyPrefix()
+ ":"
+ redisProperties.getNamespaces().getFileEvents()
+ ":pubsub";
}
private FileEventPubSubMessage toMessage(FileEvent event) {
return new FileEventPubSubMessage(
instanceId,
event.getId(),
event.getUserId(),
event.getEventType(),
event.getFileId(),
event.getFromPath(),
event.getToPath(),
event.getClientId(),
event.getPayloadJson(),
event.getCreatedAt()
);
}
}

View File

@@ -4,6 +4,10 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -17,4 +21,31 @@ public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Lo
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.blob"})
Optional<FileShareLink> findByIdAndOwnerId(Long id, Long ownerId);
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.primaryEntity", "file.blob"})
@Query("""
select share from FileShareLink share
join share.owner owner
join share.file file
where (:userQuery is null or :userQuery = ''
or lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%')))
and (:fileName is null or :fileName = ''
or lower(file.filename) like lower(concat('%', :fileName, '%')))
and (:token is null or :token = ''
or lower(share.token) like lower(concat('%', :token, '%')))
and (:passwordProtected is null
or (:passwordProtected = true and share.passwordHash is not null and share.passwordHash <> '')
or (:passwordProtected = false and (share.passwordHash is null or share.passwordHash = '')))
and (:expired is null
or (:expired = true and share.expiresAt is not null and share.expiresAt < :now)
or (:expired = false and (share.expiresAt is null or share.expiresAt >= :now)))
""")
Page<FileShareLink> searchAdminShares(@Param("userQuery") String userQuery,
@Param("fileName") String fileName,
@Param("token") String token,
@Param("passwordProtected") Boolean passwordProtected,
@Param("expired") Boolean expired,
@Param("now") LocalDateTime now,
Pageable pageable);
}

View File

@@ -1,8 +1,5 @@
package com.yoyuzh.files.tasks;
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.files.core.FileMetadataResponse;
@@ -23,16 +20,16 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler {
private final StoredFileRepository storedFileRepository;
private final UserRepository userRepository;
private final FileService fileService;
private final ObjectMapper objectMapper;
private final BackgroundTaskStateManager stateManager;
public ArchiveBackgroundTaskHandler(StoredFileRepository storedFileRepository,
UserRepository userRepository,
FileService fileService,
ObjectMapper objectMapper) {
BackgroundTaskStateManager stateManager) {
this.storedFileRepository = storedFileRepository;
this.userRepository = userRepository;
this.fileService = fileService;
this.objectMapper = objectMapper;
this.stateManager = stateManager;
}
@Override
@@ -48,10 +45,14 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler {
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
Map<String, Object> state = parseState(task.getPrivateStateJson(), task.getPublicStateJson());
Long fileId = extractLong(state.get("fileId"));
String outputPath = extractText(state.get("outputPath"));
String outputFilename = extractText(state.get("outputFilename"));
Map<String, Object> state = stateManager.mergeJsonObjects(
task.getPublicStateJson(),
task.getPrivateStateJson(),
"archive task state is invalid"
);
Long fileId = stateManager.readLong(state.get("fileId"));
String outputPath = stateManager.readText(state.get("outputPath"));
String outputFilename = stateManager.readText(state.get("outputFilename"));
if (fileId == null) {
throw new IllegalStateException("archive task missing fileId");
}
@@ -127,38 +128,4 @@ public class ArchiveBackgroundTaskHandler implements BackgroundTaskHandler {
return Math.min(100, (int) Math.floor((processed * 100.0d) / total));
}
private Map<String, Object> parseState(String privateStateJson, String publicStateJson) {
Map<String, Object> state = new LinkedHashMap<>(parseJsonObject(publicStateJson));
state.putAll(parseJsonObject(privateStateJson));
return state;
}
private Map<String, Object> parseJsonObject(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
throw new IllegalStateException("archive task state is invalid", ex);
}
}
private Long extractLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
private String extractText(Object value) {
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
return null;
}
}

View File

@@ -11,6 +11,7 @@ import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@@ -18,8 +19,9 @@ import java.time.LocalDateTime;
@Table(name = "portal_background_task", indexes = {
@Index(name = "idx_background_task_user_created_at", columnList = "user_id,created_at"),
@Index(name = "idx_background_task_status_created_at", columnList = "status,created_at"),
@Index(name = "idx_background_task_status_lease_expires_at", columnList = "status,lease_expires_at"),
@Index(name = "idx_background_task_correlation_id", columnList = "correlation_id")
@Index(name = "idx_background_task_status_lease_expires_at", columnList = "status,lease_expires_at")
}, uniqueConstraints = {
@UniqueConstraint(name = "uk_background_task_correlation_id", columnNames = "correlation_id")
})
public class BackgroundTask {

View File

@@ -0,0 +1,55 @@
package com.yoyuzh.files.tasks;
import com.yoyuzh.auth.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class BackgroundTaskCommandService {
private final BackgroundTaskService backgroundTaskService;
public BackgroundTask createQueuedFileTask(User user,
BackgroundTaskType type,
Long fileId,
String requestedPath,
String correlationId) {
return backgroundTaskService.createQueuedFileTask(user, type, fileId, requestedPath, correlationId);
}
public Optional<BackgroundTask> createQueuedAutoMediaMetadataTask(Long userId,
Long fileId,
String correlationId) {
return backgroundTaskService.createQueuedAutoMediaMetadataTask(userId, fileId, correlationId);
}
public BackgroundTask createQueuedTask(User user,
BackgroundTaskType type,
Map<String, Object> publicState,
Map<String, Object> privateState,
String correlationId) {
return backgroundTaskService.createQueuedTask(user, type, publicState, privateState, correlationId);
}
public Page<BackgroundTask> listOwnedTasks(User user, Pageable pageable) {
return backgroundTaskService.listOwnedTasks(user, pageable);
}
public BackgroundTask getOwnedTask(User user, Long id) {
return backgroundTaskService.getOwnedTask(user, id);
}
public BackgroundTask cancelOwnedTask(User user, Long id) {
return backgroundTaskService.cancelOwnedTask(user, id);
}
public BackgroundTask retryOwnedTask(User user, Long id) {
return backgroundTaskService.retryOwnedTask(user, id);
}
}

View File

@@ -0,0 +1,243 @@
package com.yoyuzh.files.tasks;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class BackgroundTaskExecutionService {
private static final List<String> RETRY_TRANSIENT_STATE_KEYS = List.of(
BackgroundTaskStateKeys.RETRY_SCHEDULED,
BackgroundTaskStateKeys.NEXT_RETRY_AT,
BackgroundTaskStateKeys.RETRY_DELAY_SECONDS,
BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE,
BackgroundTaskStateKeys.LAST_FAILURE_AT,
BackgroundTaskStateKeys.FAILURE_CATEGORY
);
private static final List<String> RUNNING_TRANSIENT_STATE_KEYS = List.of(
BackgroundTaskStateKeys.WORKER_OWNER,
BackgroundTaskStateKeys.LEASE_EXPIRES_AT
);
private static final int EXPIRED_RUNNING_TASK_BATCH_SIZE = 100;
private final BackgroundTaskRepository backgroundTaskRepository;
private final BackgroundTaskRetryPolicy retryPolicy;
private final BackgroundTaskStateManager stateManager;
@Transactional
public int requeueExpiredRunningTasks() {
LocalDateTime now = LocalDateTime.now();
int recovered = 0;
for (Long taskId : backgroundTaskRepository.findExpiredRunningTaskIds(
BackgroundTaskStatus.RUNNING,
now,
PageRequest.of(0, EXPIRED_RUNNING_TASK_BATCH_SIZE)
)) {
int requeued = backgroundTaskRepository.requeueExpiredRunningTask(
taskId,
BackgroundTaskStatus.RUNNING,
BackgroundTaskStatus.QUEUED,
now,
now
);
if (requeued != 1) {
continue;
}
BackgroundTask task = backgroundTaskRepository.findById(taskId)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
resetTaskToQueued(task);
backgroundTaskRepository.save(task);
recovered += 1;
}
return recovered;
}
public List<Long> findQueuedTaskIds(int limit) {
if (limit <= 0) {
return List.of();
}
return backgroundTaskRepository.findReadyTaskIdsByStatusOrder(
BackgroundTaskStatus.QUEUED,
LocalDateTime.now(),
PageRequest.of(0, limit)
);
}
@Transactional
public Optional<BackgroundTask> claimQueuedTask(Long id, String workerOwner, long leaseDurationSeconds) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
int claimed = backgroundTaskRepository.claimQueuedTask(
id,
BackgroundTaskStatus.QUEUED,
BackgroundTaskStatus.RUNNING,
workerOwner,
leaseExpiresAt,
now,
now
);
if (claimed != 1) {
return Optional.empty();
}
Optional<BackgroundTask> task = backgroundTaskRepository.findById(id);
task.ifPresent(claimedTask -> {
claimedTask.setLeaseOwner(workerOwner);
claimedTask.setLeaseExpiresAt(leaseExpiresAt);
claimedTask.setHeartbeatAt(now);
claimedTask.setPublicStateJson(stateManager.merge(
claimedTask.getPublicStateJson(),
stateManager.runningStatePatch(claimedTask, workerOwner, now, leaseExpiresAt, true),
RETRY_TRANSIENT_STATE_KEYS
));
});
task.ifPresent(backgroundTaskRepository::save);
return task;
}
@Transactional
public BackgroundTask markWorkerTaskProgress(Long id,
String workerOwner,
Map<String, Object> publicStatePatch,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
task.setLeaseOwner(workerOwner);
task.setLeaseExpiresAt(leaseTouch.leaseExpiresAt());
task.setHeartbeatAt(leaseTouch.now());
Map<String, Object> nextPatch = new LinkedHashMap<>(stateManager.runningStatePatch(
task,
workerOwner,
leaseTouch.now(),
leaseTouch.leaseExpiresAt(),
false
));
if (publicStatePatch != null) {
nextPatch.putAll(publicStatePatch);
}
task.setPublicStateJson(stateManager.merge(task.getPublicStateJson(), nextPatch));
return backgroundTaskRepository.save(task);
}
@Transactional
public BackgroundTask markWorkerTaskCompleted(Long id,
String workerOwner,
Map<String, Object> publicStatePatch,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
stateManager.completedStatePatch(task, leaseTouch.now(), publicStatePatch),
stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
));
task.setStatus(BackgroundTaskStatus.COMPLETED);
task.setNextRunAt(null);
clearLease(task);
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(null);
return backgroundTaskRepository.save(task);
}
@Transactional
public BackgroundTask markWorkerTaskFailed(Long id,
String workerOwner,
String errorMessage,
BackgroundTaskFailureCategory failureCategory,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
String normalizedErrorMessage = StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed";
LocalDateTime now = leaseTouch.now();
if (failureCategory.isRetryable() && retryPolicy.hasRemainingAttempts(task)) {
long retryDelaySeconds = retryPolicy.resolveRetryDelaySeconds(task.getType(), failureCategory, task.getAttemptCount());
LocalDateTime nextRunAt = now.plusSeconds(retryDelaySeconds);
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setNextRunAt(nextRunAt);
clearLease(task);
task.setFinishedAt(null);
task.setErrorMessage(null);
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
stateManager.retryQueuedStatePatch(
task,
normalizedErrorMessage,
failureCategory,
nextRunAt,
retryDelaySeconds,
now
),
RUNNING_TRANSIENT_STATE_KEYS
));
return backgroundTaskRepository.save(task);
}
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
stateManager.failedStatePatch(task, normalizedErrorMessage, failureCategory, now),
stateManager.removableKeys(
List.of(
BackgroundTaskStateKeys.RETRY_SCHEDULED,
BackgroundTaskStateKeys.NEXT_RETRY_AT,
BackgroundTaskStateKeys.RETRY_DELAY_SECONDS
),
RUNNING_TRANSIENT_STATE_KEYS
)
));
task.setStatus(BackgroundTaskStatus.FAILED);
task.setFinishedAt(now);
task.setErrorMessage(normalizedErrorMessage);
return backgroundTaskRepository.save(task);
}
private void resetTaskToQueued(BackgroundTask task) {
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(stateManager.resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setFinishedAt(null);
task.setErrorMessage(null);
}
private LeaseTouch refreshLease(Long id, String workerOwner, long leaseDurationSeconds) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
int refreshed = backgroundTaskRepository.refreshRunningTaskLease(
id,
BackgroundTaskStatus.RUNNING,
workerOwner,
leaseExpiresAt,
now,
now
);
if (refreshed != 1) {
throw new BackgroundTaskLeaseLostException(id, workerOwner);
}
return new LeaseTouch(now, leaseExpiresAt);
}
private void clearLease(BackgroundTask task) {
task.setLeaseOwner(null);
task.setLeaseExpiresAt(null);
task.setHeartbeatAt(null);
}
private record LeaseTouch(LocalDateTime now, LocalDateTime leaseExpiresAt) {
}
}

View File

@@ -15,8 +15,36 @@ public interface BackgroundTaskRepository extends JpaRepository<BackgroundTask,
Page<BackgroundTask> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
@Query("""
select task from BackgroundTask task
where (:userQuery is null or :userQuery = '' or exists (
select 1 from User owner
where owner.id = task.userId
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
and (:type is null or task.type = :type)
and (:status is null or task.status = :status)
and (:failureCategoryPattern is null or lower(task.publicStateJson) like lower(concat('%', :failureCategoryPattern, '%')))
and (:leaseState is null
or (:leaseState = 'ACTIVE' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt > :now)
or (:leaseState = 'EXPIRED' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt <= :now)
or (:leaseState = 'NONE' and (task.leaseOwner is null or task.leaseExpiresAt is null)))
""")
Page<BackgroundTask> searchAdminTasks(@Param("userQuery") String userQuery,
@Param("type") BackgroundTaskType type,
@Param("status") BackgroundTaskStatus status,
@Param("failureCategoryPattern") String failureCategoryPattern,
@Param("leaseState") String leaseState,
@Param("now") LocalDateTime now,
Pageable pageable);
Optional<BackgroundTask> findByIdAndUserId(Long id, Long userId);
boolean existsByCorrelationId(String correlationId);
List<BackgroundTask> findByStatusOrderByCreatedAtAsc(BackgroundTaskStatus status, Pageable pageable);
List<BackgroundTask> findByStatusOrderByUpdatedAtAsc(BackgroundTaskStatus status);

View File

@@ -0,0 +1,41 @@
package com.yoyuzh.files.tasks;
import org.springframework.stereotype.Component;
@Component
public class BackgroundTaskRetryPolicy {
public int resolveMaxAttempts(BackgroundTaskType type) {
return switch (type) {
case ARCHIVE -> 4;
case EXTRACT -> 3;
case MEDIA_META -> 2;
default -> 1;
};
}
public boolean hasRemainingAttempts(BackgroundTask task) {
return task.getAttemptCount() != null
&& task.getMaxAttempts() != null
&& task.getAttemptCount() < task.getMaxAttempts();
}
public long resolveRetryDelaySeconds(BackgroundTaskType type,
BackgroundTaskFailureCategory failureCategory,
Integer attemptCount) {
int safeAttemptCount = attemptCount == null ? 1 : Math.max(1, attemptCount);
long baseDelaySeconds = switch (type) {
case ARCHIVE -> 30L;
case EXTRACT -> 45L;
case MEDIA_META -> 15L;
default -> 30L;
};
if (failureCategory == BackgroundTaskFailureCategory.RATE_LIMITED) {
baseDelaySeconds *= 4L;
} else if (failureCategory == BackgroundTaskFailureCategory.UNKNOWN) {
baseDelaySeconds *= 2L;
}
long delay = baseDelaySeconds * (1L << Math.min(safeAttemptCount - 1, 2));
return Math.min(delay, baseDelaySeconds * 4L);
}
}

View File

@@ -1,21 +1,20 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.api.v2.ApiV2ErrorCode;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.lock.DistributedLockService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
@@ -25,29 +24,23 @@ import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class BackgroundTaskService {
static final String STATE_PHASE_KEY = "phase";
static final String STATE_ATTEMPT_COUNT_KEY = "attemptCount";
static final String STATE_MAX_ATTEMPTS_KEY = "maxAttempts";
static final String STATE_RETRY_SCHEDULED_KEY = "retryScheduled";
static final String STATE_NEXT_RETRY_AT_KEY = "nextRetryAt";
static final String STATE_RETRY_DELAY_SECONDS_KEY = "retryDelaySeconds";
static final String STATE_LAST_FAILURE_MESSAGE_KEY = "lastFailureMessage";
static final String STATE_LAST_FAILURE_AT_KEY = "lastFailureAt";
static final String STATE_FAILURE_CATEGORY_KEY = "failureCategory";
static final String STATE_WORKER_OWNER_KEY = "workerOwner";
static final String STATE_HEARTBEAT_AT_KEY = "heartbeatAt";
static final String STATE_LEASE_EXPIRES_AT_KEY = "leaseExpiresAt";
static final String STATE_STARTED_AT_KEY = "startedAt";
static final String STATE_PHASE_KEY = BackgroundTaskStateKeys.PHASE;
static final String STATE_ATTEMPT_COUNT_KEY = BackgroundTaskStateKeys.ATTEMPT_COUNT;
static final String STATE_MAX_ATTEMPTS_KEY = BackgroundTaskStateKeys.MAX_ATTEMPTS;
static final String STATE_RETRY_SCHEDULED_KEY = BackgroundTaskStateKeys.RETRY_SCHEDULED;
static final String STATE_NEXT_RETRY_AT_KEY = BackgroundTaskStateKeys.NEXT_RETRY_AT;
static final String STATE_RETRY_DELAY_SECONDS_KEY = BackgroundTaskStateKeys.RETRY_DELAY_SECONDS;
static final String STATE_LAST_FAILURE_MESSAGE_KEY = BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE;
static final String STATE_LAST_FAILURE_AT_KEY = BackgroundTaskStateKeys.LAST_FAILURE_AT;
static final String STATE_FAILURE_CATEGORY_KEY = BackgroundTaskStateKeys.FAILURE_CATEGORY;
static final String STATE_WORKER_OWNER_KEY = BackgroundTaskStateKeys.WORKER_OWNER;
static final String STATE_HEARTBEAT_AT_KEY = BackgroundTaskStateKeys.HEARTBEAT_AT;
static final String STATE_LEASE_EXPIRES_AT_KEY = BackgroundTaskStateKeys.LEASE_EXPIRES_AT;
static final String STATE_STARTED_AT_KEY = BackgroundTaskStateKeys.STARTED_AT;
private static final List<String> ZIP_COMPATIBLE_EXTENSIONS = List.of(".zip", ".jar", ".war");
private static final List<String> MEDIA_EXTENSIONS = List.of(
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg",
".mp4", ".mov", ".mkv", ".webm", ".avi",
".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"
);
private static final List<String> RETRY_TRANSIENT_STATE_KEYS = List.of(
STATE_RETRY_SCHEDULED_KEY,
STATE_NEXT_RETRY_AT_KEY,
@@ -60,11 +53,43 @@ public class BackgroundTaskService {
STATE_WORKER_OWNER_KEY,
STATE_LEASE_EXPIRES_AT_KEY
);
private static final int EXPIRED_RUNNING_TASK_BATCH_SIZE = 100;
private static final Duration CORRELATION_LOCK_TTL = Duration.ofSeconds(5);
private final BackgroundTaskRepository backgroundTaskRepository;
private final StoredFileRepository storedFileRepository;
private final ObjectMapper objectMapper;
private final DistributedLockService distributedLockService;
private final BackgroundTaskRetryPolicy retryPolicy;
private final BackgroundTaskStateManager stateManager;
@Autowired
public BackgroundTaskService(BackgroundTaskRepository backgroundTaskRepository,
StoredFileRepository storedFileRepository,
com.fasterxml.jackson.databind.ObjectMapper objectMapper,
DistributedLockService distributedLockService,
BackgroundTaskRetryPolicy retryPolicy,
BackgroundTaskStateManager stateManager) {
this.backgroundTaskRepository = backgroundTaskRepository;
this.storedFileRepository = storedFileRepository;
this.distributedLockService = distributedLockService == null
? DistributedLockService.noOp()
: distributedLockService;
this.retryPolicy = retryPolicy == null ? new BackgroundTaskRetryPolicy() : retryPolicy;
this.stateManager = stateManager == null ? new BackgroundTaskStateManager(objectMapper) : stateManager;
}
BackgroundTaskService(BackgroundTaskRepository backgroundTaskRepository,
StoredFileRepository storedFileRepository,
com.fasterxml.jackson.databind.ObjectMapper objectMapper,
DistributedLockService distributedLockService) {
this(
backgroundTaskRepository,
storedFileRepository,
objectMapper,
distributedLockService,
new BackgroundTaskRetryPolicy(),
new BackgroundTaskStateManager(objectMapper)
);
}
@Transactional
public BackgroundTask createQueuedFileTask(User user,
@@ -78,27 +103,40 @@ public class BackgroundTaskService {
if (!logicalPath.equals(normalizeLogicalPath(requestedPath))) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "task path does not match file path");
}
validateTaskTarget(type, file);
return createQueuedFileTaskInternal(user.getId(), type, file, correlationId, false);
}
Map<String, Object> publicState = fileState(file, logicalPath);
Map<String, Object> privateState = new LinkedHashMap<>(publicState);
privateState.put("taskType", type.name());
if (type == BackgroundTaskType.ARCHIVE) {
String outputPath = file.getPath();
String outputFilename = file.getFilename() + ".zip";
publicState.put("outputPath", outputPath);
publicState.put("outputFilename", outputFilename);
privateState.put("outputPath", outputPath);
privateState.put("outputFilename", outputFilename);
} else if (type == BackgroundTaskType.EXTRACT) {
String outputPath = file.getPath();
String outputDirectoryName = deriveExtractOutputDirectoryName(file.getFilename());
publicState.put("outputPath", outputPath);
publicState.put("outputDirectoryName", outputDirectoryName);
privateState.put("outputPath", outputPath);
privateState.put("outputDirectoryName", outputDirectoryName);
@Transactional
public Optional<BackgroundTask> createQueuedAutoMediaMetadataTask(Long userId,
Long fileId,
String correlationId) {
String normalizedCorrelationId = StringUtils.hasText(correlationId)
? correlationId.trim()
: "media-meta:auto:file:" + fileId;
try {
return distributedLockService.executeWithLock(
correlationLockName(normalizedCorrelationId),
CORRELATION_LOCK_TTL,
() -> {
if (backgroundTaskRepository.existsByCorrelationId(normalizedCorrelationId)) {
return Optional.empty();
}
return storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(fileId, userId)
.filter(file -> !file.isDirectory())
.filter(file -> MediaTaskSupport.isMediaLike(file.getFilename(), file.getContentType()))
.map(file -> createQueuedFileTaskInternal(
userId,
BackgroundTaskType.MEDIA_META,
file,
normalizedCorrelationId,
true
));
}
);
} catch (DataIntegrityViolationException ex) {
return Optional.empty();
}
return createQueuedTask(user, type, publicState, privateState, correlationId);
}
@Transactional
@@ -107,20 +145,36 @@ public class BackgroundTaskService {
Map<String, Object> publicState,
Map<String, Object> privateState,
String correlationId) {
return createQueuedTask(user.getId(), type, publicState, privateState, correlationId);
}
private BackgroundTask createQueuedTask(Long userId,
BackgroundTaskType type,
Map<String, Object> publicState,
Map<String, Object> privateState,
String correlationId) {
return createQueuedTask(userId, type, publicState, privateState, correlationId, false);
}
private BackgroundTask createQueuedTask(Long userId,
BackgroundTaskType type,
Map<String, Object> publicState,
Map<String, Object> privateState,
String correlationId,
boolean flushOnSave) {
BackgroundTask task = new BackgroundTask();
task.setUserId(user.getId());
task.setUserId(userId);
task.setType(type);
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setAttemptCount(0);
task.setMaxAttempts(resolveMaxAttempts(type));
task.setMaxAttempts(retryPolicy.resolveMaxAttempts(type));
task.setNextRunAt(null);
Map<String, Object> nextPublicState = new LinkedHashMap<>(publicState == null ? Map.of() : publicState);
nextPublicState.put(STATE_PHASE_KEY, "queued");
nextPublicState.putAll(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts()));
task.setPublicStateJson(toJson(nextPublicState));
task.setPrivateStateJson(toJson(privateState));
task.setPublicStateJson(stateManager.createInitialPublicState(publicState, task.getAttemptCount(), task.getMaxAttempts()));
task.setPrivateStateJson(stateManager.toJson(privateState));
task.setCorrelationId(normalizeCorrelationId(correlationId));
return backgroundTaskRepository.save(task);
return flushOnSave
? backgroundTaskRepository.saveAndFlush(task)
: backgroundTaskRepository.save(task);
}
public Page<BackgroundTask> listOwnedTasks(User user, Pageable pageable) {
@@ -143,15 +197,10 @@ public class BackgroundTaskService {
task.setStatus(BackgroundTaskStatus.CANCELLED);
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(mergePublicStateJson(
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "cancelled",
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
),
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
stateManager.cancelledStatePatch(task, LocalDateTime.now()),
stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
));
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(null);
@@ -171,7 +220,7 @@ public class BackgroundTaskService {
task.setAttemptCount(0);
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
task.setPublicStateJson(stateManager.resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setFinishedAt(null);
task.setErrorMessage(null);
@@ -185,7 +234,7 @@ public class BackgroundTaskService {
return task;
}
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setPublicStateJson(mergePublicStateJson(
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "running",
@@ -206,16 +255,11 @@ public class BackgroundTaskService {
task.setStatus(BackgroundTaskStatus.COMPLETED);
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(mergePublicStateJson(
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "completed",
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
),
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
));
stateManager.completedStatePatch(task, LocalDateTime.now(), null),
stateManager.removableKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
));
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(null);
return backgroundTaskRepository.save(task);
@@ -230,201 +274,18 @@ public class BackgroundTaskService {
task.setStatus(BackgroundTaskStatus.FAILED);
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(mergePublicStateJson(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "failed",
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
STATE_LAST_FAILURE_MESSAGE_KEY, StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed",
STATE_LAST_FAILURE_AT_KEY, LocalDateTime.now().toString(),
STATE_FAILURE_CATEGORY_KEY, BackgroundTaskFailureCategory.UNKNOWN.name(),
STATE_HEARTBEAT_AT_KEY, LocalDateTime.now().toString()
),
removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY), RUNNING_TRANSIENT_STATE_KEYS)
));
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed");
return backgroundTaskRepository.save(task);
}
@Transactional
public int requeueExpiredRunningTasks() {
LocalDateTime now = LocalDateTime.now();
int recovered = 0;
for (Long taskId : backgroundTaskRepository.findExpiredRunningTaskIds(
BackgroundTaskStatus.RUNNING,
now,
PageRequest.of(0, EXPIRED_RUNNING_TASK_BATCH_SIZE)
)) {
int requeued = backgroundTaskRepository.requeueExpiredRunningTask(
taskId,
BackgroundTaskStatus.RUNNING,
BackgroundTaskStatus.QUEUED,
now,
now
);
if (requeued != 1) {
continue;
}
BackgroundTask task = backgroundTaskRepository.findById(taskId)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
resetTaskToQueued(task);
backgroundTaskRepository.save(task);
recovered += 1;
}
return recovered;
}
public List<Long> findQueuedTaskIds(int limit) {
if (limit <= 0) {
return List.of();
}
return backgroundTaskRepository.findReadyTaskIdsByStatusOrder(
BackgroundTaskStatus.QUEUED,
LocalDateTime.now(),
PageRequest.of(0, limit)
);
}
@Transactional
public Optional<BackgroundTask> claimQueuedTask(Long id, String workerOwner, long leaseDurationSeconds) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
int claimed = backgroundTaskRepository.claimQueuedTask(
id,
BackgroundTaskStatus.QUEUED,
BackgroundTaskStatus.RUNNING,
workerOwner,
leaseExpiresAt,
now,
now
);
if (claimed != 1) {
return Optional.empty();
}
Optional<BackgroundTask> task = backgroundTaskRepository.findById(id);
task.ifPresent(claimedTask -> {
claimedTask.setLeaseOwner(workerOwner);
claimedTask.setLeaseExpiresAt(leaseExpiresAt);
claimedTask.setHeartbeatAt(now);
claimedTask.setPublicStateJson(mergePublicStateJson(
claimedTask.getPublicStateJson(),
runningStatePatch(claimedTask, workerOwner, now, leaseExpiresAt, true),
RETRY_TRANSIENT_STATE_KEYS
));
});
task.ifPresent(backgroundTaskRepository::save);
return task;
}
@Transactional
public BackgroundTask markWorkerTaskProgress(Long id,
String workerOwner,
Map<String, Object> publicStatePatch,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
task.setLeaseOwner(workerOwner);
task.setLeaseExpiresAt(leaseTouch.leaseExpiresAt());
task.setHeartbeatAt(leaseTouch.now());
Map<String, Object> nextPatch = new LinkedHashMap<>(runningStatePatch(
task,
workerOwner,
leaseTouch.now(),
leaseTouch.leaseExpiresAt(),
false
));
if (publicStatePatch != null) {
nextPatch.putAll(publicStatePatch);
}
task.setPublicStateJson(mergePublicStateJson(task.getPublicStateJson(), nextPatch));
return backgroundTaskRepository.save(task);
}
@Transactional
public BackgroundTask markWorkerTaskCompleted(Long id,
String workerOwner,
Map<String, Object> publicStatePatch,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
Map<String, Object> nextPatch = new LinkedHashMap<>(publicStatePatch == null ? Map.of() : publicStatePatch);
nextPatch.put(STATE_PHASE_KEY, "completed");
nextPatch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount());
nextPatch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts());
nextPatch.put(STATE_HEARTBEAT_AT_KEY, leaseTouch.now().toString());
task.setPublicStateJson(mergePublicStateJson(
task.getPublicStateJson(),
nextPatch,
removableStateKeys(RETRY_TRANSIENT_STATE_KEYS, RUNNING_TRANSIENT_STATE_KEYS)
));
task.setStatus(BackgroundTaskStatus.COMPLETED);
task.setNextRunAt(null);
clearLease(task);
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(null);
return backgroundTaskRepository.save(task);
}
@Transactional
public BackgroundTask markWorkerTaskFailed(Long id,
String workerOwner,
String errorMessage,
BackgroundTaskFailureCategory failureCategory,
long leaseDurationSeconds) {
LeaseTouch leaseTouch = refreshLease(id, workerOwner, leaseDurationSeconds);
BackgroundTask task = backgroundTaskRepository.findById(id)
.orElseThrow(() -> new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "task not found"));
String normalizedErrorMessage = StringUtils.hasText(errorMessage) ? errorMessage.trim() : "task failed";
LocalDateTime now = leaseTouch.now();
if (failureCategory.isRetryable() && hasRemainingAttempts(task)) {
long retryDelaySeconds = resolveRetryDelaySeconds(task.getType(), failureCategory, task.getAttemptCount());
LocalDateTime nextRunAt = now.plusSeconds(retryDelaySeconds);
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setNextRunAt(nextRunAt);
clearLease(task);
task.setFinishedAt(null);
task.setErrorMessage(null);
task.setPublicStateJson(mergePublicStateJson(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "queued",
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
STATE_RETRY_SCHEDULED_KEY, true,
STATE_NEXT_RETRY_AT_KEY, nextRunAt.toString(),
STATE_RETRY_DELAY_SECONDS_KEY, retryDelaySeconds,
STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage,
STATE_LAST_FAILURE_AT_KEY, now.toString(),
STATE_FAILURE_CATEGORY_KEY, failureCategory.name(),
STATE_HEARTBEAT_AT_KEY, now.toString()
),
RUNNING_TRANSIENT_STATE_KEYS
));
return backgroundTaskRepository.save(task);
}
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(mergePublicStateJson(
task.setPublicStateJson(stateManager.merge(
task.getPublicStateJson(),
Map.of(
STATE_PHASE_KEY, "failed",
STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount(),
STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts(),
STATE_LAST_FAILURE_MESSAGE_KEY, normalizedErrorMessage,
STATE_LAST_FAILURE_AT_KEY, now.toString(),
STATE_FAILURE_CATEGORY_KEY, failureCategory.name(),
STATE_HEARTBEAT_AT_KEY, now.toString()
stateManager.failedStatePatch(
task,
normalizedErrorMessage,
BackgroundTaskFailureCategory.UNKNOWN,
LocalDateTime.now()
),
removableStateKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY, STATE_RETRY_DELAY_SECONDS_KEY), RUNNING_TRANSIENT_STATE_KEYS)
stateManager.removableKeys(List.of(STATE_RETRY_SCHEDULED_KEY, STATE_NEXT_RETRY_AT_KEY), RUNNING_TRANSIENT_STATE_KEYS)
));
task.setStatus(BackgroundTaskStatus.FAILED);
task.setFinishedAt(now);
task.setFinishedAt(LocalDateTime.now());
task.setErrorMessage(normalizedErrorMessage);
return backgroundTaskRepository.save(task);
}
@@ -436,6 +297,10 @@ public class BackgroundTaskService {
return UUID.randomUUID().toString().replace("-", "");
}
private String correlationLockName(String correlationId) {
return "background-task-correlation:" + correlationId;
}
private void validateTaskTarget(BackgroundTaskType type, StoredFile file) {
if (type == BackgroundTaskType.ARCHIVE) {
return;
@@ -446,7 +311,8 @@ public class BackgroundTaskService {
if (type == BackgroundTaskType.EXTRACT && !isZipCompatibleArchive(file)) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "extract task only supports zip-compatible archives");
}
if (type == BackgroundTaskType.MEDIA_META && !isMediaLike(file)) {
if (type == BackgroundTaskType.MEDIA_META
&& !MediaTaskSupport.isMediaLike(file.getFilename(), file.getContentType())) {
throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "media metadata task only supports media files");
}
}
@@ -470,14 +336,6 @@ public class BackgroundTaskService {
return hasExtension(file.getFilename(), ZIP_COMPATIBLE_EXTENSIONS);
}
private boolean isMediaLike(StoredFile file) {
String contentType = normalizeContentType(file.getContentType());
if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) {
return true;
}
return hasExtension(file.getFilename(), MEDIA_EXTENSIONS);
}
private String deriveExtractOutputDirectoryName(String filename) {
if (!StringUtils.hasText(filename)) {
return "extracted";
@@ -511,6 +369,42 @@ public class BackgroundTaskService {
return contentType.trim().toLowerCase(Locale.ROOT);
}
private BackgroundTask createQueuedFileTaskInternal(Long userId,
BackgroundTaskType type,
StoredFile file,
String correlationId) {
return createQueuedFileTaskInternal(userId, type, file, correlationId, false);
}
private BackgroundTask createQueuedFileTaskInternal(Long userId,
BackgroundTaskType type,
StoredFile file,
String correlationId,
boolean flushOnSave) {
String logicalPath = buildLogicalPath(file);
validateTaskTarget(type, file);
Map<String, Object> publicState = fileState(file, logicalPath);
Map<String, Object> privateState = new LinkedHashMap<>(publicState);
privateState.put("taskType", type.name());
if (type == BackgroundTaskType.ARCHIVE) {
String outputPath = file.getPath();
String outputFilename = file.getFilename() + ".zip";
publicState.put("outputPath", outputPath);
publicState.put("outputFilename", outputFilename);
privateState.put("outputPath", outputPath);
privateState.put("outputFilename", outputFilename);
} else if (type == BackgroundTaskType.EXTRACT) {
String outputPath = file.getPath();
String outputDirectoryName = deriveExtractOutputDirectoryName(file.getFilename());
publicState.put("outputPath", outputPath);
publicState.put("outputDirectoryName", outputDirectoryName);
privateState.put("outputPath", outputPath);
privateState.put("outputDirectoryName", outputDirectoryName);
}
return createQueuedTask(userId, type, publicState, privateState, correlationId, flushOnSave);
}
private String buildLogicalPath(StoredFile file) {
String parent = normalizeLogicalPath(file.getPath());
if ("/".equals(parent)) {
@@ -536,148 +430,9 @@ public class BackgroundTaskService {
return normalized;
}
private String toJson(Map<String, Object> value) {
Map<String, Object> safeValue = value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value);
try {
return objectMapper.writeValueAsString(safeValue);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize background task state", ex);
}
}
private Map<String, Object> parseJsonObject(String value) {
if (!StringUtils.hasText(value)) {
return new LinkedHashMap<>();
}
try {
return objectMapper.readValue(value, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to parse background task state", ex);
}
}
private String mergePublicStateJson(String currentValue, Map<String, Object> patch) {
return mergePublicStateJson(currentValue, patch, List.of());
}
private String mergePublicStateJson(String currentValue, Map<String, Object> patch, List<String> keysToRemove) {
Map<String, Object> nextPublicState = parseJsonObject(currentValue);
if (keysToRemove != null) {
keysToRemove.forEach(nextPublicState::remove);
}
if (patch != null) {
nextPublicState.putAll(patch);
}
return toJson(nextPublicState);
}
private String resetPublicStateForRetry(String privateStateJson, int attemptCount, int maxAttempts) {
Map<String, Object> nextPublicState = parseJsonObject(privateStateJson);
nextPublicState.remove("taskType");
nextPublicState.put(STATE_PHASE_KEY, "queued");
nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts));
return toJson(nextPublicState);
}
private void resetTaskToQueued(BackgroundTask task) {
task.setNextRunAt(null);
clearLease(task);
task.setPublicStateJson(resetPublicStateForRetry(task.getPrivateStateJson(), task.getAttemptCount(), task.getMaxAttempts()));
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setFinishedAt(null);
task.setErrorMessage(null);
}
private int resolveMaxAttempts(BackgroundTaskType type) {
return switch (type) {
case ARCHIVE -> 4;
case EXTRACT -> 3;
case MEDIA_META -> 2;
default -> 1;
};
}
private Map<String, Object> retryStatePatch(int attemptCount, int maxAttempts) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(STATE_ATTEMPT_COUNT_KEY, attemptCount);
patch.put(STATE_MAX_ATTEMPTS_KEY, maxAttempts);
return patch;
}
private boolean hasRemainingAttempts(BackgroundTask task) {
return task.getAttemptCount() != null
&& task.getMaxAttempts() != null
&& task.getAttemptCount() < task.getMaxAttempts();
}
private long resolveRetryDelaySeconds(BackgroundTaskType type,
BackgroundTaskFailureCategory failureCategory,
Integer attemptCount) {
int safeAttemptCount = attemptCount == null ? 1 : Math.max(1, attemptCount);
long baseDelaySeconds = switch (type) {
case ARCHIVE -> 30L;
case EXTRACT -> 45L;
case MEDIA_META -> 15L;
default -> 30L;
};
if (failureCategory == BackgroundTaskFailureCategory.RATE_LIMITED) {
baseDelaySeconds *= 4L;
} else if (failureCategory == BackgroundTaskFailureCategory.UNKNOWN) {
baseDelaySeconds *= 2L;
}
long delay = baseDelaySeconds * (1L << Math.min(safeAttemptCount - 1, 2));
return Math.min(delay, baseDelaySeconds * 4L);
}
private LeaseTouch refreshLease(Long id, String workerOwner, long leaseDurationSeconds) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime leaseExpiresAt = now.plusSeconds(Math.max(30L, leaseDurationSeconds));
int refreshed = backgroundTaskRepository.refreshRunningTaskLease(
id,
BackgroundTaskStatus.RUNNING,
workerOwner,
leaseExpiresAt,
now,
now
);
if (refreshed != 1) {
throw new BackgroundTaskLeaseLostException(id, workerOwner);
}
return new LeaseTouch(now, leaseExpiresAt);
}
private Map<String, Object> runningStatePatch(BackgroundTask task,
String workerOwner,
LocalDateTime heartbeatAt,
LocalDateTime leaseExpiresAt,
boolean includeStartedAt) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(STATE_PHASE_KEY, "running");
patch.put(STATE_ATTEMPT_COUNT_KEY, task.getAttemptCount());
patch.put(STATE_MAX_ATTEMPTS_KEY, task.getMaxAttempts());
patch.put(STATE_WORKER_OWNER_KEY, workerOwner);
patch.put(STATE_HEARTBEAT_AT_KEY, heartbeatAt.toString());
patch.put(STATE_LEASE_EXPIRES_AT_KEY, leaseExpiresAt.toString());
if (includeStartedAt) {
patch.put(STATE_STARTED_AT_KEY, heartbeatAt.toString());
}
return patch;
}
private List<String> removableStateKeys(List<String> primary, List<String> secondary) {
List<String> keys = new java.util.ArrayList<>(primary);
keys.addAll(secondary);
return keys;
}
private void clearLease(BackgroundTask task) {
task.setLeaseOwner(null);
task.setLeaseExpiresAt(null);
task.setHeartbeatAt(null);
}
private record LeaseTouch(LocalDateTime now, LocalDateTime leaseExpiresAt) {
}
}

View File

@@ -11,11 +11,11 @@ import org.springframework.stereotype.Component;
@Slf4j
public class BackgroundTaskStartupRecovery {
private final BackgroundTaskService backgroundTaskService;
private final BackgroundTaskExecutionService backgroundTaskExecutionService;
@EventListener(ApplicationReadyEvent.class)
public void recoverOnStartup() {
int recovered = backgroundTaskService.requeueExpiredRunningTasks();
int recovered = backgroundTaskExecutionService.requeueExpiredRunningTasks();
if (recovered > 0) {
log.warn("Recovered {} expired RUNNING background task leases back to QUEUED on startup", recovered);
}

View File

@@ -0,0 +1,21 @@
package com.yoyuzh.files.tasks;
public final class BackgroundTaskStateKeys {
public static final String PHASE = "phase";
public static final String ATTEMPT_COUNT = "attemptCount";
public static final String MAX_ATTEMPTS = "maxAttempts";
public static final String RETRY_SCHEDULED = "retryScheduled";
public static final String NEXT_RETRY_AT = "nextRetryAt";
public static final String RETRY_DELAY_SECONDS = "retryDelaySeconds";
public static final String LAST_FAILURE_MESSAGE = "lastFailureMessage";
public static final String LAST_FAILURE_AT = "lastFailureAt";
public static final String FAILURE_CATEGORY = "failureCategory";
public static final String WORKER_OWNER = "workerOwner";
public static final String HEARTBEAT_AT = "heartbeatAt";
public static final String LEASE_EXPIRES_AT = "leaseExpiresAt";
public static final String STARTED_AT = "startedAt";
private BackgroundTaskStateKeys() {
}
}

View File

@@ -0,0 +1,185 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class BackgroundTaskStateManager {
private static final TypeReference<LinkedHashMap<String, Object>> JSON_OBJECT_TYPE = new TypeReference<>() {
};
private final ObjectMapper objectMapper;
public BackgroundTaskStateManager(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public String toJson(Map<String, Object> value) {
Map<String, Object> safeValue = value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value);
try {
return objectMapper.writeValueAsString(safeValue);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize background task state", ex);
}
}
public Map<String, Object> retryStatePatch(int attemptCount, int maxAttempts) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(BackgroundTaskStateKeys.ATTEMPT_COUNT, attemptCount);
patch.put(BackgroundTaskStateKeys.MAX_ATTEMPTS, maxAttempts);
return patch;
}
public Map<String, Object> runningStatePatch(BackgroundTask task,
String workerOwner,
LocalDateTime heartbeatAt,
LocalDateTime leaseExpiresAt,
boolean includeStartedAt) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(BackgroundTaskStateKeys.PHASE, "running");
patch.put(BackgroundTaskStateKeys.ATTEMPT_COUNT, task.getAttemptCount());
patch.put(BackgroundTaskStateKeys.MAX_ATTEMPTS, task.getMaxAttempts());
patch.put(BackgroundTaskStateKeys.WORKER_OWNER, workerOwner);
patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString());
patch.put(BackgroundTaskStateKeys.LEASE_EXPIRES_AT, leaseExpiresAt.toString());
if (includeStartedAt) {
patch.put(BackgroundTaskStateKeys.STARTED_AT, heartbeatAt.toString());
}
return patch;
}
public Map<String, Object> cancelledStatePatch(BackgroundTask task, LocalDateTime heartbeatAt) {
return terminalStatePatch("cancelled", task, heartbeatAt);
}
public Map<String, Object> completedStatePatch(BackgroundTask task,
LocalDateTime heartbeatAt,
Map<String, Object> additionalPatch) {
Map<String, Object> patch = new LinkedHashMap<>(additionalPatch == null ? Map.of() : additionalPatch);
patch.putAll(terminalStatePatch("completed", task, heartbeatAt));
return patch;
}
public Map<String, Object> failedStatePatch(BackgroundTask task,
String errorMessage,
BackgroundTaskFailureCategory failureCategory,
LocalDateTime heartbeatAt) {
Map<String, Object> patch = new LinkedHashMap<>(terminalStatePatch("failed", task, heartbeatAt));
patch.put(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE, errorMessage);
patch.put(BackgroundTaskStateKeys.LAST_FAILURE_AT, heartbeatAt.toString());
patch.put(BackgroundTaskStateKeys.FAILURE_CATEGORY, failureCategory.name());
return patch;
}
public Map<String, Object> retryQueuedStatePatch(BackgroundTask task,
String errorMessage,
BackgroundTaskFailureCategory failureCategory,
LocalDateTime nextRetryAt,
long retryDelaySeconds,
LocalDateTime heartbeatAt) {
Map<String, Object> patch = new LinkedHashMap<>(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts()));
patch.put(BackgroundTaskStateKeys.PHASE, "queued");
patch.put(BackgroundTaskStateKeys.RETRY_SCHEDULED, true);
patch.put(BackgroundTaskStateKeys.NEXT_RETRY_AT, nextRetryAt.toString());
patch.put(BackgroundTaskStateKeys.RETRY_DELAY_SECONDS, retryDelaySeconds);
patch.put(BackgroundTaskStateKeys.LAST_FAILURE_MESSAGE, errorMessage);
patch.put(BackgroundTaskStateKeys.LAST_FAILURE_AT, heartbeatAt.toString());
patch.put(BackgroundTaskStateKeys.FAILURE_CATEGORY, failureCategory.name());
patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString());
return patch;
}
public String createInitialPublicState(Map<String, Object> baseState, int attemptCount, int maxAttempts) {
Map<String, Object> nextPublicState = new LinkedHashMap<>(baseState == null ? Map.of() : baseState);
nextPublicState.put(BackgroundTaskStateKeys.PHASE, "queued");
nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts));
return toJson(nextPublicState);
}
public String merge(String currentValue, Map<String, Object> patch) {
return merge(currentValue, patch, List.of());
}
public String merge(String currentValue, Map<String, Object> patch, List<String> keysToRemove) {
Map<String, Object> nextPublicState = parse(currentValue);
if (keysToRemove != null) {
keysToRemove.forEach(nextPublicState::remove);
}
if (patch != null) {
nextPublicState.putAll(patch);
}
return toJson(nextPublicState);
}
public String resetPublicStateForRetry(String privateStateJson, int attemptCount, int maxAttempts) {
Map<String, Object> nextPublicState = parse(privateStateJson);
nextPublicState.remove("taskType");
nextPublicState.put(BackgroundTaskStateKeys.PHASE, "queued");
nextPublicState.putAll(retryStatePatch(attemptCount, maxAttempts));
return toJson(nextPublicState);
}
public List<String> removableKeys(List<String> primary, List<String> secondary) {
List<String> keys = new java.util.ArrayList<>(primary);
keys.addAll(secondary);
return keys;
}
public Map<String, Object> parseJsonObject(String value, String invalidStateMessage) {
if (!StringUtils.hasText(value)) {
return Map.of();
}
try {
return objectMapper.readValue(value, JSON_OBJECT_TYPE);
} catch (JsonProcessingException ex) {
throw new IllegalStateException(invalidStateMessage, ex);
}
}
public Map<String, Object> mergeJsonObjects(String primaryJson,
String overlayJson,
String invalidStateMessage) {
Map<String, Object> state = new LinkedHashMap<>(parseJsonObject(primaryJson, invalidStateMessage));
state.putAll(parseJsonObject(overlayJson, invalidStateMessage));
return state;
}
public Long readLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
public String readText(Object value) {
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
return null;
}
private Map<String, Object> terminalStatePatch(String phase,
BackgroundTask task,
LocalDateTime heartbeatAt) {
Map<String, Object> patch = new LinkedHashMap<>(retryStatePatch(task.getAttemptCount(), task.getMaxAttempts()));
patch.put(BackgroundTaskStateKeys.PHASE, phase);
patch.put(BackgroundTaskStateKeys.HEARTBEAT_AT, heartbeatAt.toString());
return patch;
}
private Map<String, Object> parse(String value) {
return new LinkedHashMap<>(parseJsonObject(value, "Failed to parse background task state"));
}
}

View File

@@ -19,13 +19,13 @@ public class BackgroundTaskWorker {
private static final int DEFAULT_BATCH_SIZE = 5;
private static final long DEFAULT_LEASE_DURATION_SECONDS = 120L;
private final BackgroundTaskService backgroundTaskService;
private final BackgroundTaskExecutionService backgroundTaskExecutionService;
private final List<BackgroundTaskHandler> handlers;
private final String workerOwner;
public BackgroundTaskWorker(BackgroundTaskService backgroundTaskService,
public BackgroundTaskWorker(BackgroundTaskExecutionService backgroundTaskExecutionService,
List<BackgroundTaskHandler> handlers) {
this.backgroundTaskService = backgroundTaskService;
this.backgroundTaskExecutionService = backgroundTaskExecutionService;
this.handlers = List.copyOf(handlers);
this.workerOwner = UUID.randomUUID().toString().replace("-", "");
}
@@ -39,10 +39,10 @@ public class BackgroundTaskWorker {
}
public int processQueuedTasks(int maxTasks) {
backgroundTaskService.requeueExpiredRunningTasks();
backgroundTaskExecutionService.requeueExpiredRunningTasks();
int processedCount = 0;
for (Long taskId : backgroundTaskService.findQueuedTaskIds(maxTasks)) {
var claimedTask = backgroundTaskService.claimQueuedTask(taskId, workerOwner, DEFAULT_LEASE_DURATION_SECONDS);
for (Long taskId : backgroundTaskExecutionService.findQueuedTaskIds(maxTasks)) {
var claimedTask = backgroundTaskExecutionService.claimQueuedTask(taskId, workerOwner, DEFAULT_LEASE_DURATION_SECONDS);
if (claimedTask.isEmpty()) {
continue;
}
@@ -55,21 +55,21 @@ public class BackgroundTaskWorker {
private void execute(BackgroundTask task) {
try {
backgroundTaskService.markWorkerTaskProgress(
backgroundTaskExecutionService.markWorkerTaskProgress(
task.getId(),
workerOwner,
Map.of(BackgroundTaskService.STATE_PHASE_KEY, resolveRunningPhase(task.getType())),
Map.of(BackgroundTaskStateKeys.PHASE, resolveRunningPhase(task.getType())),
DEFAULT_LEASE_DURATION_SECONDS
);
BackgroundTaskHandler handler = findHandler(task);
BackgroundTaskHandlerResult result = handler.handle(task, publicStatePatch ->
backgroundTaskService.markWorkerTaskProgress(
backgroundTaskExecutionService.markWorkerTaskProgress(
task.getId(),
workerOwner,
publicStatePatch,
DEFAULT_LEASE_DURATION_SECONDS
));
backgroundTaskService.markWorkerTaskCompleted(
backgroundTaskExecutionService.markWorkerTaskCompleted(
task.getId(),
workerOwner,
result.publicStatePatch(),
@@ -80,7 +80,7 @@ public class BackgroundTaskWorker {
} catch (Exception ex) {
String message = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
try {
backgroundTaskService.markWorkerTaskFailed(
backgroundTaskExecutionService.markWorkerTaskFailed(
task.getId(),
workerOwner,
message,

View File

@@ -1,8 +1,5 @@
package com.yoyuzh.files.tasks;
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;
@@ -28,16 +25,16 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler {
private final StoredFileRepository storedFileRepository;
private final UserRepository userRepository;
private final FileService fileService;
private final ObjectMapper objectMapper;
private final BackgroundTaskStateManager stateManager;
public ExtractBackgroundTaskHandler(StoredFileRepository storedFileRepository,
UserRepository userRepository,
FileService fileService,
ObjectMapper objectMapper) {
BackgroundTaskStateManager stateManager) {
this.storedFileRepository = storedFileRepository;
this.userRepository = userRepository;
this.fileService = fileService;
this.objectMapper = objectMapper;
this.stateManager = stateManager;
}
@Override
@@ -53,10 +50,14 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler {
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
Map<String, Object> state = parseState(task.getPrivateStateJson(), task.getPublicStateJson());
Long fileId = extractLong(state.get("fileId"));
String outputPath = extractText(state.get("outputPath"));
String outputDirectoryName = extractText(state.get("outputDirectoryName"));
Map<String, Object> state = stateManager.mergeJsonObjects(
task.getPublicStateJson(),
task.getPrivateStateJson(),
"extract task state is invalid"
);
Long fileId = stateManager.readLong(state.get("fileId"));
String outputPath = stateManager.readText(state.get("outputPath"));
String outputDirectoryName = stateManager.readText(state.get("outputDirectoryName"));
if (fileId == null) {
throw new IllegalStateException("extract task missing fileId");
}
@@ -235,41 +236,6 @@ public class ExtractBackgroundTaskHandler implements BackgroundTaskHandler {
return relativePath;
}
private Map<String, Object> parseState(String privateStateJson, String publicStateJson) {
Map<String, Object> state = new LinkedHashMap<>(parseJsonObject(publicStateJson));
state.putAll(parseJsonObject(privateStateJson));
return state;
}
private Map<String, Object> parseJsonObject(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
throw new IllegalStateException("extract task state is invalid", ex);
}
}
private Long extractLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
private String extractText(Object value) {
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
return null;
}
private String normalizeDirectoryPath(String path) {
if (!StringUtils.hasText(path)) {
return "/";

View File

@@ -1,8 +1,5 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository;
@@ -18,7 +15,6 @@ import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -34,16 +30,16 @@ public class MediaMetadataBackgroundTaskHandler implements BackgroundTaskHandler
private final StoredFileRepository storedFileRepository;
private final FileMetadataRepository fileMetadataRepository;
private final FileContentStorage fileContentStorage;
private final ObjectMapper objectMapper;
private final BackgroundTaskStateManager stateManager;
public MediaMetadataBackgroundTaskHandler(StoredFileRepository storedFileRepository,
FileMetadataRepository fileMetadataRepository,
FileContentStorage fileContentStorage,
ObjectMapper objectMapper) {
BackgroundTaskStateManager stateManager) {
this.storedFileRepository = storedFileRepository;
this.fileMetadataRepository = fileMetadataRepository;
this.fileContentStorage = fileContentStorage;
this.objectMapper = objectMapper;
this.stateManager = stateManager;
}
@Override
@@ -114,39 +110,21 @@ public class MediaMetadataBackgroundTaskHandler implements BackgroundTaskHandler
}
private Long readFileId(BackgroundTask task) {
Long fileId = extractLong(parseState(task.getPrivateStateJson()).get("fileId"));
Long fileId = stateManager.readLong(
stateManager.parseJsonObject(task.getPrivateStateJson(), "media metadata task state is invalid").get("fileId")
);
if (fileId != null) {
return fileId;
}
fileId = extractLong(parseState(task.getPublicStateJson()).get("fileId"));
fileId = stateManager.readLong(
stateManager.parseJsonObject(task.getPublicStateJson(), "media metadata task state is invalid").get("fileId")
);
if (fileId != null) {
return fileId;
}
throw new IllegalStateException("media metadata task missing fileId");
}
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) {
throw new IllegalStateException("media metadata task state is invalid", ex);
}
}
private Long extractLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
private String firstText(String primary, String fallback) {
if (StringUtils.hasText(primary)) {
return primary.trim();

View File

@@ -0,0 +1,83 @@
package com.yoyuzh.files.tasks;
import com.yoyuzh.common.broker.LightweightBrokerService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Map;
@Component
public class MediaMetadataTaskBrokerConsumer {
private static final int DEFAULT_BATCH_SIZE = 10;
private final LightweightBrokerService lightweightBrokerService;
private final BackgroundTaskCommandService backgroundTaskCommandService;
public MediaMetadataTaskBrokerConsumer(LightweightBrokerService lightweightBrokerService,
BackgroundTaskCommandService backgroundTaskCommandService) {
this.lightweightBrokerService = lightweightBrokerService;
this.backgroundTaskCommandService = backgroundTaskCommandService;
}
@Scheduled(
fixedDelayString = "${app.redis.broker.media-meta.fixed-delay-ms:3000}",
initialDelayString = "${app.redis.broker.media-meta.initial-delay-ms:15000}"
)
public void runScheduledBatch() {
drainQueuedMessages(DEFAULT_BATCH_SIZE);
}
public int drainQueuedMessages(int maxMessages) {
int safeLimit = Math.max(0, maxMessages);
int processed = 0;
for (int i = 0; i < safeLimit; i++) {
var payload = lightweightBrokerService.poll(MediaMetadataTaskBrokerPublisher.TOPIC);
if (payload.isEmpty()) {
break;
}
try {
if (handlePayload(payload.get())) {
processed += 1;
}
} catch (RuntimeException ex) {
lightweightBrokerService.requeue(MediaMetadataTaskBrokerPublisher.TOPIC, payload.get());
break;
}
}
return processed;
}
private boolean handlePayload(Map<String, Object> payload) {
Long userId = readLong(payload.get("userId"));
Long fileId = readLong(payload.get("fileId"));
String correlationId = readString(payload.get("correlationId"));
if (userId == null || fileId == null) {
return false;
}
backgroundTaskCommandService.createQueuedAutoMediaMetadataTask(userId, fileId, correlationId);
return true;
}
private Long readLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
try {
return Long.parseLong(text.trim());
} catch (NumberFormatException ex) {
return null;
}
}
return null;
}
private String readString(Object value) {
if (!(value instanceof String text) || !StringUtils.hasText(text)) {
return null;
}
return text.trim();
}
}

View File

@@ -0,0 +1,58 @@
package com.yoyuzh.files.tasks;
import com.yoyuzh.common.broker.LightweightBrokerService;
import com.yoyuzh.files.core.StoredFile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Map;
@Component
public class MediaMetadataTaskBrokerPublisher {
public static final String TOPIC = "media-metadata-trigger";
private final LightweightBrokerService lightweightBrokerService;
public MediaMetadataTaskBrokerPublisher(LightweightBrokerService lightweightBrokerService) {
this.lightweightBrokerService = lightweightBrokerService;
}
public void publishAfterCommit(StoredFile storedFile) {
if (!shouldPublish(storedFile)) {
return;
}
Runnable publishTask = () -> lightweightBrokerService.publish(TOPIC, Map.of(
"userId", storedFile.getUser().getId(),
"fileId", storedFile.getId(),
"correlationId", buildCorrelationId(storedFile)
));
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishTask.run();
}
});
return;
}
publishTask.run();
}
private boolean shouldPublish(StoredFile storedFile) {
return storedFile != null
&& storedFile.getId() != null
&& storedFile.getUser() != null
&& storedFile.getUser().getId() != null
&& !storedFile.isDirectory()
&& MediaTaskSupport.isMediaLike(storedFile.getFilename(), storedFile.getContentType());
}
private String buildCorrelationId(StoredFile storedFile) {
return "media-meta:auto:file:" + storedFile.getId();
}
}

View File

@@ -0,0 +1,43 @@
package com.yoyuzh.files.tasks;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
final class MediaTaskSupport {
private static final List<String> MEDIA_EXTENSIONS = List.of(
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg",
".mp4", ".mov", ".mkv", ".webm", ".avi",
".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"
);
private MediaTaskSupport() {
}
static boolean isMediaLike(String filename, String contentType) {
String normalizedContentType = normalizeContentType(contentType);
if (normalizedContentType.startsWith("image/")
|| normalizedContentType.startsWith("video/")
|| normalizedContentType.startsWith("audio/")) {
return true;
}
return hasExtension(filename);
}
private static boolean hasExtension(String filename) {
if (!StringUtils.hasText(filename)) {
return false;
}
String normalized = filename.toLowerCase(Locale.ROOT);
return MEDIA_EXTENSIONS.stream().anyMatch(normalized::endsWith);
}
private static String normalizeContentType(String contentType) {
if (!StringUtils.hasText(contentType)) {
return "";
}
return contentType.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -1,8 +1,5 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.core.FileBlob;
@@ -40,20 +37,20 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa
private final FileBlobRepository fileBlobRepository;
private final StoredFileRepository storedFileRepository;
private final FileContentStorage fileContentStorage;
private final ObjectMapper objectMapper;
private final BackgroundTaskStateManager stateManager;
public StoragePolicyMigrationBackgroundTaskHandler(StoragePolicyRepository storagePolicyRepository,
FileEntityRepository fileEntityRepository,
FileBlobRepository fileBlobRepository,
StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage,
ObjectMapper objectMapper) {
BackgroundTaskStateManager stateManager) {
this.storagePolicyRepository = storagePolicyRepository;
this.fileEntityRepository = fileEntityRepository;
this.fileBlobRepository = fileBlobRepository;
this.storedFileRepository = storedFileRepository;
this.fileContentStorage = fileContentStorage;
this.objectMapper = objectMapper;
this.stateManager = stateManager;
}
@Override
@@ -69,7 +66,10 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
Map<String, Object> state = parseState(task.getPrivateStateJson());
Map<String, Object> state = stateManager.parseJsonObject(
task.getPrivateStateJson(),
"storage policy migration task state is invalid"
);
Long sourcePolicyId = readLong(state.get("sourcePolicyId"), "sourcePolicyId");
Long targetPolicyId = readLong(state.get("targetPolicyId"), "targetPolicyId");
@@ -210,7 +210,7 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa
String migrationStage,
boolean migrationPerformed) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(BackgroundTaskService.STATE_PHASE_KEY, "migrating-storage-policy");
patch.put(BackgroundTaskStateKeys.PHASE, "migrating-storage-policy");
patch.put("worker", "storage-policy-migration");
patch.put("migrationStage", migrationStage);
patch.put("migrationMode", migrationPerformed ? "executed" : "executing");
@@ -281,24 +281,10 @@ public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTa
}
}
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) {
throw new IllegalStateException("storage policy migration task state is invalid", ex);
}
}
private Long readLong(Object value, String key) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
Long parsed = stateManager.readLong(value);
if (parsed != null) {
return parsed;
}
throw new IllegalStateException("storage policy migration task missing " + key);
}

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