Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9af2d38e37 | ||
|
|
30a9bbc1e7 | ||
|
|
f59515f5dd | ||
|
|
12005cc606 | ||
|
|
99e00cd7f7 | ||
|
|
67cd0f6e6f | ||
|
|
3906a523fd | ||
|
|
da576e0253 | ||
|
|
7d6ceaf6d8 | ||
|
|
977eb60b17 | ||
|
|
c5362ebe31 | ||
|
|
3e67760712 | ||
|
|
00b268c30f | ||
|
|
19c296a212 | ||
|
|
6da0d196ee | ||
|
|
f582e600aa | ||
|
|
06a95bc489 | ||
|
|
35b0691188 | ||
|
|
7ddef9bddb | ||
|
|
5802f396c5 | ||
|
|
9d5fdd9ea3 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"codex@openai-codex": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "implementer"
|
||||
description = "Code-writing agent. It makes focused changes in frontend, backend, scripts, or docs after planning/exploration are complete, and leaves broad verification to tester."
|
||||
description = "Code-writing agent. It owns delegated feature implementation and focused changes in frontend, backend, scripts, or docs after planning/exploration are complete; broad or time-consuming edits should come here instead of being handled by orchestrator. It leaves broad verification to tester."
|
||||
nickname_candidates = ["implementer", "impl", "builder"]
|
||||
sandbox_mode = "workspace-write"
|
||||
include_apply_patch_tool = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "orchestrator"
|
||||
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis."
|
||||
description = "Default top-level agent for this repo. It coordinates specialist agents, keeps scope aligned with the user request, and owns the final synthesis. It should not directly own feature implementation or broad code edits; only tiny alignment fixes such as imports, field/signature synchronization, or obvious one-line consistency repairs may be handled locally when delegating would be slower than the fix itself."
|
||||
nickname_candidates = ["orchestrator", "lead", "coord"]
|
||||
sandbox_mode = "read-only"
|
||||
include_apply_patch_tool = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "tester"
|
||||
description = "Verification-only agent. It runs lint, test, build, package, and type-check commands that already exist in this repo, reports failures, and does not edit source files."
|
||||
description = "Verification-only agent. It runs only repository-backed verification commands that already exist in this repo, reports exact failures, and does not edit source files. Use the `multi-angle-verification` skill as the default verification workflow so command coverage, browser-flow checks, UI review, and coverage-gap reporting stay consistent. Android emulator or device simulation is out of scope for this agent and is handled manually by the user."
|
||||
nickname_candidates = ["tester", "qa", "verify"]
|
||||
sandbox_mode = "workspace-write"
|
||||
include_apply_patch_tool = false
|
||||
|
||||
@@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml"
|
||||
nickname_candidates = ["implementer", "impl", "builder"]
|
||||
|
||||
[agents.tester]
|
||||
description = "Runs repository-backed verification commands only."
|
||||
description = "Runs repository-backed verification through the multi-angle-verification workflow, including browser and UI review when the task warrants it."
|
||||
config_file = ".codex/agents/tester.toml"
|
||||
nickname_candidates = ["tester", "qa", "verify"]
|
||||
|
||||
|
||||
38
.devcontainer/devcontainer.json
Normal file
38
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "my_site backend",
|
||||
"image": "mcr.microsoft.com/devcontainers/java:1-17-bullseye",
|
||||
"remoteUser": "vscode",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/backend",
|
||||
"runArgs": [
|
||||
"--init"
|
||||
],
|
||||
"mounts": [
|
||||
"source=my-site-backend-maven-cache,target=/home/vscode/.m2,type=volume"
|
||||
],
|
||||
"containerEnv": {
|
||||
"APP_JWT_SECRET": "devcontainer-local-jwt-secret-please-change-if-needed",
|
||||
"SPRING_PROFILES_ACTIVE": "dev"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8080
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8080": {
|
||||
"label": "Spring Boot backend",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"vscjava.vscode-java-pack",
|
||||
"vscjava.vscode-lombok",
|
||||
"vmware.vscode-spring-boot"
|
||||
],
|
||||
"settings": {
|
||||
"java.import.maven.enabled": true,
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
backend/target/
|
||||
data/
|
||||
storage/
|
||||
/storage/
|
||||
/backend/storage/
|
||||
node_modules/
|
||||
output/
|
||||
tmp/
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
BIN
backend/data-backup-20260409-2030/yoyuzh_portal_dev.mv.db
Normal file
BIN
backend/data-backup-20260409-2030/yoyuzh_portal_dev.mv.db
Normal file
Binary file not shown.
190
backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db
Normal file
190
backend/data-backup-20260409-2030/yoyuzh_portal_dev.trace.db
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java
Normal file
19
backend/src/main/java/com/yoyuzh/admin/AdminAuditAction.java
Normal 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
|
||||
}
|
||||
126
backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java
Normal file
126
backend/src/main/java/com/yoyuzh/admin/AdminAuditLogEntity.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.api.v2.tasks.BackgroundTaskResponse;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.core.FileEntityType;
|
||||
import com.yoyuzh.files.tasks.BackgroundTask;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskType;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -16,23 +26,55 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("@adminAccessEvaluator.isAdmin(authentication)")
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
private final AdminInspectionQueryService adminInspectionQueryService;
|
||||
private final AdminTaskQueryService adminTaskQueryService;
|
||||
private final AdminStoragePolicyQueryService adminStoragePolicyQueryService;
|
||||
private final AdminAuditQueryService adminAuditQueryService;
|
||||
private final AdminResourceGovernanceService adminResourceGovernanceService;
|
||||
private final AdminStorageGovernanceService adminStorageGovernanceService;
|
||||
private final AdminConfigSnapshotService adminConfigSnapshotService;
|
||||
private final AdminMutableSettingsService adminMutableSettingsService;
|
||||
private final AdminUserGovernanceService adminUserGovernanceService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<AdminSummaryResponse> summary() {
|
||||
return ApiResponse.success(adminService.getSummary());
|
||||
return ApiResponse.success(adminInspectionQueryService.getSummary());
|
||||
}
|
||||
|
||||
@GetMapping("/settings")
|
||||
public ApiResponse<AdminSettingsResponse> settings() {
|
||||
return ApiResponse.success(adminConfigSnapshotService.getSettings());
|
||||
}
|
||||
|
||||
@PatchMapping("/settings/registration/invite-code")
|
||||
public ApiResponse<AdminRegistrationInviteCodeResponse> updateRegistrationInviteCode(
|
||||
@Valid @RequestBody AdminRegistrationInviteCodeUpdateRequest request) {
|
||||
return ApiResponse.success(adminMutableSettingsService.updateRegistrationInviteCode(request.inviteCode()));
|
||||
}
|
||||
|
||||
@PostMapping("/settings/registration/invite-code/rotate")
|
||||
public ApiResponse<AdminRegistrationInviteCodeResponse> rotateRegistrationInviteCode() {
|
||||
return ApiResponse.success(adminMutableSettingsService.rotateRegistrationInviteCode());
|
||||
}
|
||||
|
||||
@GetMapping("/filesystem")
|
||||
public ApiResponse<AdminFilesystemResponse> filesystem() {
|
||||
return ApiResponse.success(adminConfigSnapshotService.getFilesystem());
|
||||
}
|
||||
|
||||
@PatchMapping("/settings/offline-transfer-storage-limit")
|
||||
public ApiResponse<AdminOfflineTransferStorageLimitResponse> updateOfflineTransferStorageLimit(
|
||||
@Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateOfflineTransferStorageLimit(
|
||||
return ApiResponse.success(adminMutableSettingsService.updateOfflineTransferStorageLimit(
|
||||
request.offlineTransferStorageLimitBytes()
|
||||
));
|
||||
}
|
||||
@@ -41,7 +83,7 @@ public class AdminController {
|
||||
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String query) {
|
||||
return ApiResponse.success(adminService.listUsers(page, size, query));
|
||||
return ApiResponse.success(adminUserGovernanceService.listUsers(page, size, query));
|
||||
}
|
||||
|
||||
@GetMapping("/files")
|
||||
@@ -49,47 +91,155 @@ public class AdminController {
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "") String ownerQuery) {
|
||||
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
|
||||
return ApiResponse.success(adminInspectionQueryService.listFiles(page, size, query, ownerQuery));
|
||||
}
|
||||
|
||||
@GetMapping("/file-blobs")
|
||||
public ApiResponse<PageResponse<AdminFileBlobResponse>> fileBlobs(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String userQuery,
|
||||
@RequestParam(required = false) Long storagePolicyId,
|
||||
@RequestParam(defaultValue = "") String objectKey,
|
||||
@RequestParam(required = false) FileEntityType entityType) {
|
||||
return ApiResponse.success(adminInspectionQueryService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType));
|
||||
}
|
||||
|
||||
@GetMapping("/shares")
|
||||
public ApiResponse<PageResponse<AdminShareResponse>> shares(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String userQuery,
|
||||
@RequestParam(defaultValue = "") String fileName,
|
||||
@RequestParam(defaultValue = "") String token,
|
||||
@RequestParam(required = false) Boolean passwordProtected,
|
||||
@RequestParam(required = false) Boolean expired) {
|
||||
return ApiResponse.success(adminInspectionQueryService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired));
|
||||
}
|
||||
|
||||
@DeleteMapping("/shares/{shareId}")
|
||||
public ApiResponse<Void> deleteShare(@PathVariable Long shareId) {
|
||||
adminResourceGovernanceService.deleteShare(shareId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@GetMapping("/tasks")
|
||||
public ApiResponse<PageResponse<AdminTaskResponse>> tasks(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String userQuery,
|
||||
@RequestParam(required = false) BackgroundTaskType type,
|
||||
@RequestParam(required = false) BackgroundTaskStatus status,
|
||||
@RequestParam(required = false) BackgroundTaskFailureCategory failureCategory,
|
||||
@RequestParam(required = false) AdminTaskLeaseState leaseState) {
|
||||
return ApiResponse.success(adminTaskQueryService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
public ApiResponse<AdminTaskResponse> task(@PathVariable Long taskId) {
|
||||
return ApiResponse.success(adminTaskQueryService.getTask(taskId));
|
||||
}
|
||||
|
||||
@GetMapping("/storage-policies")
|
||||
public ApiResponse<List<AdminStoragePolicyResponse>> storagePolicies() {
|
||||
return ApiResponse.success(adminStoragePolicyQueryService.listStoragePolicies());
|
||||
}
|
||||
|
||||
@GetMapping("/audits")
|
||||
public ApiResponse<PageResponse<AdminAuditLogResponse>> audits(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String actorQuery,
|
||||
@RequestParam(defaultValue = "") String actionType,
|
||||
@RequestParam(defaultValue = "") String targetType,
|
||||
@RequestParam(required = false) Long targetId) {
|
||||
return ApiResponse.success(adminAuditQueryService.listAuditLogs(
|
||||
page,
|
||||
size,
|
||||
actorQuery,
|
||||
actionType,
|
||||
targetType,
|
||||
targetId
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/storage-policies")
|
||||
public ApiResponse<AdminStoragePolicyResponse> createStoragePolicy(
|
||||
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
|
||||
return ApiResponse.success(adminStorageGovernanceService.createStoragePolicy(request));
|
||||
}
|
||||
|
||||
@PutMapping("/storage-policies/{policyId}")
|
||||
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicy(
|
||||
@PathVariable Long policyId,
|
||||
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
|
||||
return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicy(policyId, request));
|
||||
}
|
||||
|
||||
@PatchMapping("/storage-policies/{policyId}/status")
|
||||
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicyStatus(
|
||||
@PathVariable Long policyId,
|
||||
@Valid @RequestBody AdminStoragePolicyStatusUpdateRequest request) {
|
||||
return ApiResponse.success(adminStorageGovernanceService.updateStoragePolicyStatus(policyId, request.enabled()));
|
||||
}
|
||||
|
||||
@PostMapping("/storage-policies/migrations")
|
||||
public ApiResponse<BackgroundTaskResponse> createStoragePolicyMigrationTask(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiResponse.success(toTaskResponse(adminStorageGovernanceService.createStoragePolicyMigrationTask(user, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/files/{fileId}")
|
||||
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
|
||||
adminService.deleteFile(fileId);
|
||||
adminResourceGovernanceService.deleteFile(fileId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/role")
|
||||
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserRole(userId, request.role()));
|
||||
return ApiResponse.success(adminUserGovernanceService.updateUserRole(userId, request.role()));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/status")
|
||||
public ApiResponse<AdminUserResponse> updateUserStatus(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserBanned(userId, request.banned()));
|
||||
return ApiResponse.success(adminUserGovernanceService.updateUserBanned(userId, request.banned()));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{userId}/password")
|
||||
public ApiResponse<AdminUserResponse> updateUserPassword(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserPasswordUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword()));
|
||||
return ApiResponse.success(adminUserGovernanceService.updateUserPassword(userId, request.newPassword()));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/storage-quota")
|
||||
public ApiResponse<AdminUserResponse> updateUserStorageQuota(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserStorageQuotaUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserStorageQuota(userId, request.storageQuotaBytes()));
|
||||
return ApiResponse.success(adminUserGovernanceService.updateUserStorageQuota(userId, request.storageQuotaBytes()));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/max-upload-size")
|
||||
public ApiResponse<AdminUserResponse> updateUserMaxUploadSize(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserMaxUploadSizeUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes()));
|
||||
return ApiResponse.success(adminUserGovernanceService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes()));
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/password/reset")
|
||||
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
|
||||
return ApiResponse.success(adminService.resetUserPassword(userId));
|
||||
return ApiResponse.success(adminUserGovernanceService.resetUserPassword(userId));
|
||||
}
|
||||
|
||||
private BackgroundTaskResponse toTaskResponse(BackgroundTask task) {
|
||||
return new BackgroundTaskResponse(
|
||||
task.getId(),
|
||||
task.getType(),
|
||||
task.getStatus(),
|
||||
task.getUserId(),
|
||||
task.getPublicStateJson(),
|
||||
task.getCorrelationId(),
|
||||
task.getErrorMessage(),
|
||||
task.getCreatedAt(),
|
||||
task.getUpdatedAt(),
|
||||
task.getFinishedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminRegistrationInviteCodeResponse(
|
||||
String currentInviteCode
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminStoragePolicyMigrationCreateRequest(
|
||||
@NotNull(message = "sourcePolicyId 不能为空")
|
||||
Long sourcePolicyId,
|
||||
@NotNull(message = "targetPolicyId 不能为空")
|
||||
Long targetPolicyId,
|
||||
String correlationId
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
|
||||
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
|
||||
import com.yoyuzh.files.policy.StoragePolicyType;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminStoragePolicyResponse(
|
||||
Long id,
|
||||
String name,
|
||||
StoragePolicyType type,
|
||||
String bucketName,
|
||||
String endpoint,
|
||||
String region,
|
||||
boolean privateBucket,
|
||||
String prefix,
|
||||
StoragePolicyCredentialMode credentialMode,
|
||||
long maxSizeBytes,
|
||||
StoragePolicyCapabilities capabilities,
|
||||
boolean enabled,
|
||||
boolean defaultPolicy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminStoragePolicyStatusUpdateRequest(
|
||||
@NotNull(message = "enabled 不能为空")
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
|
||||
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
|
||||
import com.yoyuzh.files.policy.StoragePolicyType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public record AdminStoragePolicyUpsertRequest(
|
||||
@NotBlank(message = "存储策略名称不能为空")
|
||||
String name,
|
||||
@NotNull(message = "存储策略类型不能为空")
|
||||
StoragePolicyType type,
|
||||
String bucketName,
|
||||
String endpoint,
|
||||
String region,
|
||||
boolean privateBucket,
|
||||
String prefix,
|
||||
@NotNull(message = "凭证模式不能为空")
|
||||
StoragePolicyCredentialMode credentialMode,
|
||||
@Positive(message = "最大对象大小必须大于 0")
|
||||
long maxSizeBytes,
|
||||
@NotNull(message = "能力声明不能为空")
|
||||
StoragePolicyCapabilities capabilities,
|
||||
boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public enum AdminTaskLeaseState {
|
||||
ACTIVE,
|
||||
EXPIRED,
|
||||
NONE
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.AuthSessionPolicy;
|
||||
import com.yoyuzh.auth.AuthTokenInvalidationService;
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import com.yoyuzh.auth.RegistrationInviteService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.RefreshTokenService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.FileBlobRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
import com.yoyuzh.files.core.StoredFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -23,133 +20,147 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminService {
|
||||
public class AdminUserGovernanceService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileBlobRepository fileBlobRepository;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final AuthTokenInvalidationService authTokenInvalidationService;
|
||||
private final AuthSessionPolicy authSessionPolicy;
|
||||
private final AdminAuditService adminAuditService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot();
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
fileBlobRepository.sumAllBlobSize(),
|
||||
metrics.downloadTrafficBytes(),
|
||||
metrics.requestCount(),
|
||||
metrics.transferUsageBytes(),
|
||||
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
|
||||
metrics.offlineTransferStorageLimitBytes(),
|
||||
metrics.dailyActiveUsers(),
|
||||
metrics.requestTimeline(),
|
||||
registrationInviteService.getCurrentInviteCode()
|
||||
);
|
||||
}
|
||||
|
||||
public PageResponse<AdminUserResponse> listUsers(int page, int size, String query) {
|
||||
Page<User> result = userRepository.searchByUsernameOrEmail(
|
||||
normalizeQuery(query),
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
List<AdminUserResponse> items = result.getContent().stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<AdminFileResponse> listFiles(int page, int size, String query, String ownerQuery) {
|
||||
Page<StoredFile> result = storedFileRepository.searchAdminFiles(
|
||||
normalizeQuery(query),
|
||||
normalizeQuery(ownerQuery),
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username")
|
||||
.and(Sort.by(Sort.Direction.DESC, "createdAt")))
|
||||
List<User> users = result.getContent();
|
||||
Map<Long, Long> usedStorageByUserId = loadUsedStorageByUserIds(users);
|
||||
return new PageResponse<>(
|
||||
users.stream()
|
||||
.map(user -> toUserResponse(user, usedStorageByUserId.getOrDefault(user.getId(), 0L)))
|
||||
.toList(),
|
||||
result.getTotalElements(),
|
||||
page,
|
||||
size
|
||||
);
|
||||
List<AdminFileResponse> items = result.getContent().stream()
|
||||
.map(this::toFileResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteFile(Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
fileService.delete(storedFile.getUser(), fileId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserRole(Long userId, UserRole role) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setRole(role);
|
||||
return toUserResponse(userRepository.save(user));
|
||||
AdminUserResponse response = toUserResponse(userRepository.save(user));
|
||||
adminAuditService.record(
|
||||
AdminAuditAction.UPDATE_USER_ROLE,
|
||||
"USER",
|
||||
userId,
|
||||
"Updated user role",
|
||||
Map.of("role", role.name())
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setBanned(banned);
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setMobileActiveSessionId(UUID.randomUUID().toString());
|
||||
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
|
||||
authSessionPolicy.rotateAllActiveSessions(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
AdminUserResponse response = toUserResponse(userRepository.save(user));
|
||||
adminAuditService.record(
|
||||
AdminAuditAction.UPDATE_USER_BANNED,
|
||||
"USER",
|
||||
userId,
|
||||
banned ? "Banned user" : "Unbanned user",
|
||||
Map.of("banned", banned)
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserPassword(Long userId, String newPassword) {
|
||||
if (!PasswordPolicy.isStrong(newPassword)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setMobileActiveSessionId(UUID.randomUUID().toString());
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
return updateUserPasswordInternal(userId, newPassword, AdminAuditAction.UPDATE_USER_PASSWORD);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setStorageQuotaBytes(storageQuotaBytes);
|
||||
return toUserResponse(userRepository.save(user));
|
||||
AdminUserResponse response = toUserResponse(userRepository.save(user));
|
||||
adminAuditService.record(
|
||||
AdminAuditAction.UPDATE_USER_STORAGE_QUOTA,
|
||||
"USER",
|
||||
userId,
|
||||
"Updated user storage quota",
|
||||
Map.of("storageQuotaBytes", storageQuotaBytes)
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setMaxUploadSizeBytes(maxUploadSizeBytes);
|
||||
return toUserResponse(userRepository.save(user));
|
||||
AdminUserResponse response = toUserResponse(userRepository.save(user));
|
||||
adminAuditService.record(
|
||||
AdminAuditAction.UPDATE_USER_MAX_UPLOAD_SIZE,
|
||||
"USER",
|
||||
userId,
|
||||
"Updated user max upload size",
|
||||
Map.of("maxUploadSizeBytes", maxUploadSizeBytes)
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminPasswordResetResponse resetUserPassword(Long userId) {
|
||||
String temporaryPassword = generateTemporaryPassword();
|
||||
updateUserPassword(userId, temporaryPassword);
|
||||
updateUserPasswordInternal(userId, temporaryPassword, AdminAuditAction.RESET_USER_PASSWORD);
|
||||
return new AdminPasswordResetResponse(temporaryPassword);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
|
||||
return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes);
|
||||
private AdminUserResponse updateUserPasswordInternal(Long userId, String newPassword, AdminAuditAction action) {
|
||||
if (!PasswordPolicy.isStrong(newPassword)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
|
||||
authSessionPolicy.rotateAllActiveSessions(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
AdminUserResponse response = toUserResponse(userRepository.save(user));
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("passwordLength", newPassword.length());
|
||||
details.put("temporaryPassword", action == AdminAuditAction.RESET_USER_PASSWORD);
|
||||
adminAuditService.record(
|
||||
action,
|
||||
"USER",
|
||||
userId,
|
||||
action == AdminAuditAction.RESET_USER_PASSWORD
|
||||
? "Reset user password"
|
||||
: "Updated user password",
|
||||
details
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
private AdminUserResponse toUserResponse(User user) {
|
||||
long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
|
||||
return toUserResponse(user, storedFileRepository.sumFileSizeByUserId(user.getId()));
|
||||
}
|
||||
|
||||
private AdminUserResponse toUserResponse(User user, long usedStorageBytes) {
|
||||
return new AdminUserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
@@ -164,25 +175,24 @@ public class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
private AdminFileResponse toFileResponse(StoredFile storedFile) {
|
||||
User owner = storedFile.getUser();
|
||||
return new AdminFileResponse(
|
||||
storedFile.getId(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getPath(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
storedFile.isDirectory(),
|
||||
storedFile.getCreatedAt(),
|
||||
owner.getId(),
|
||||
owner.getUsername(),
|
||||
owner.getEmail()
|
||||
);
|
||||
private Map<Long, Long> loadUsedStorageByUserIds(List<User> users) {
|
||||
Set<Long> userIds = users.stream()
|
||||
.map(User::getId)
|
||||
.filter(id -> id != null)
|
||||
.collect(Collectors.toSet());
|
||||
if (userIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return storedFileRepository.sumFileSizeByUserIds(userIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
StoredFileRepository.UserStorageUsageProjection::getUserId,
|
||||
projection -> projection.getUsedStorageBytes() == null ? 0L : projection.getUsedStorageBytes()
|
||||
));
|
||||
}
|
||||
|
||||
private User getRequiredUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found"));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
27
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal file
27
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.yoyuzh.api.v2;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public enum ApiV2ErrorCode {
|
||||
BAD_REQUEST(2400, HttpStatus.BAD_REQUEST),
|
||||
NOT_LOGGED_IN(2401, HttpStatus.UNAUTHORIZED),
|
||||
PERMISSION_DENIED(2403, HttpStatus.FORBIDDEN),
|
||||
FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND),
|
||||
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
private final int code;
|
||||
private final HttpStatus httpStatus;
|
||||
|
||||
ApiV2ErrorCode(int code, HttpStatus httpStatus) {
|
||||
this.code = code;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public HttpStatus getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
}
|
||||
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal file
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.yoyuzh.api.v2;
|
||||
|
||||
public class ApiV2Exception extends RuntimeException {
|
||||
|
||||
private final ApiV2ErrorCode errorCode;
|
||||
|
||||
public ApiV2Exception(ApiV2ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public ApiV2ErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yoyuzh.api.v2;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(basePackages = "com.yoyuzh.api.v2")
|
||||
public class ApiV2ExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ApiV2Exception.class)
|
||||
public ResponseEntity<ApiV2Response<Void>> handleApiV2Exception(ApiV2Exception ex) {
|
||||
ApiV2ErrorCode errorCode = ex.getErrorCode();
|
||||
return ResponseEntity
|
||||
.status(errorCode.getHttpStatus())
|
||||
.body(ApiV2Response.error(errorCode, ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiV2Response<Void>> handleBusinessException(BusinessException ex) {
|
||||
ApiV2ErrorCode errorCode = mapBusinessErrorCode(ex.getErrorCode());
|
||||
return ResponseEntity
|
||||
.status(errorCode.getHttpStatus())
|
||||
.body(ApiV2Response.error(errorCode, ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
|
||||
return ResponseEntity
|
||||
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
|
||||
.body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
|
||||
}
|
||||
|
||||
private ApiV2ErrorCode mapBusinessErrorCode(ErrorCode errorCode) {
|
||||
return switch (errorCode) {
|
||||
case NOT_LOGGED_IN -> ApiV2ErrorCode.NOT_LOGGED_IN;
|
||||
case PERMISSION_DENIED -> ApiV2ErrorCode.PERMISSION_DENIED;
|
||||
case FILE_NOT_FOUND -> ApiV2ErrorCode.FILE_NOT_FOUND;
|
||||
case UNKNOWN -> ApiV2ErrorCode.BAD_REQUEST;
|
||||
};
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal file
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.api.v2;
|
||||
|
||||
public record ApiV2Response<T>(int code, String msg, T data) {
|
||||
|
||||
public static <T> ApiV2Response<T> success(T data) {
|
||||
return new ApiV2Response<>(0, "success", data);
|
||||
}
|
||||
|
||||
public static ApiV2Response<Void> error(ApiV2ErrorCode errorCode, String msg) {
|
||||
return new ApiV2Response<>(errorCode.getCode(), msg, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateUploadSessionV2Request(
|
||||
@NotBlank String path,
|
||||
@NotBlank String filename,
|
||||
String contentType,
|
||||
@Min(0) long size
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.events.FileEventService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileEventsV2Controller {
|
||||
|
||||
private final FileEventService fileEventService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@GetMapping(value = "/events", produces = "text/event-stream")
|
||||
public SseEmitter events(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false, defaultValue = "/") String path,
|
||||
@RequestHeader(value = "X-Yoyuzh-Client-Id", required = false) String clientId) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return fileEventService.openStream(user, path, clientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2ErrorCode;
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.api.v2.ApiV2Response;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.core.FileMetadataResponse;
|
||||
import com.yoyuzh.files.search.FileSearchQuery;
|
||||
import com.yoyuzh.files.search.FileSearchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileSearchV2Controller {
|
||||
|
||||
private final FileSearchService fileSearchService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@GetMapping("/search")
|
||||
public ApiV2Response<PageResponse<FileMetadataResponse>> search(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) Long sizeGte,
|
||||
@RequestParam(required = false) Long sizeLte,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime createdGte,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime createdLte,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime updatedGte,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime updatedLte,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(fileSearchService.search(
|
||||
user,
|
||||
new FileSearchQuery(name, parseType(type), sizeGte, sizeLte, createdGte, createdLte, updatedGte, updatedLte, page, size)
|
||||
));
|
||||
}
|
||||
|
||||
private Boolean parseType(String type) {
|
||||
if (!StringUtils.hasText(type) || "all".equalsIgnoreCase(type.trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (type.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "file" -> false;
|
||||
case "directory", "folder" -> true;
|
||||
default -> throw new ApiV2Exception(ApiV2ErrorCode.BAD_REQUEST, "文件类型筛选只支持 file 或 directory");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MarkUploadSessionPartV2Request(
|
||||
@NotBlank String etag,
|
||||
@Min(0) long size
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record PreparedUploadV2Response(
|
||||
boolean direct,
|
||||
String uploadUrl,
|
||||
String method,
|
||||
Map<String, String> headers,
|
||||
String storageName
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Response;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.upload.UploadSession;
|
||||
import com.yoyuzh.files.upload.UploadSessionRuntimeState;
|
||||
import com.yoyuzh.files.upload.UploadSessionCreateCommand;
|
||||
import com.yoyuzh.files.upload.UploadSessionUploadMode;
|
||||
import com.yoyuzh.files.upload.UploadSessionPartCommand;
|
||||
import com.yoyuzh.files.upload.UploadSessionService;
|
||||
import com.yoyuzh.files.storage.PreparedUpload;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/files/upload-sessions")
|
||||
@RequiredArgsConstructor
|
||||
public class UploadSessionV2Controller {
|
||||
|
||||
private final UploadSessionService uploadSessionService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@PostMapping
|
||||
public ApiV2Response<UploadSessionV2Response> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateUploadSessionV2Request request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
UploadSession session = uploadSessionService.createSession(user, new UploadSessionCreateCommand(
|
||||
request.path(),
|
||||
request.filename(),
|
||||
request.contentType(),
|
||||
request.size()
|
||||
));
|
||||
return ApiV2Response.success(toResponse(session));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}")
|
||||
public ApiV2Response<UploadSessionV2Response> getSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId)));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}/prepare")
|
||||
public ApiV2Response<PreparedUploadV2Response> prepareUpload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, sessionId);
|
||||
return ApiV2Response.success(new PreparedUploadV2Response(
|
||||
preparedUpload.direct(),
|
||||
preparedUpload.uploadUrl(),
|
||||
preparedUpload.method(),
|
||||
preparedUpload.headers(),
|
||||
preparedUpload.storageName()
|
||||
));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{sessionId}")
|
||||
public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(uploadSessionService.cancelOwnedSession(user, sessionId)));
|
||||
}
|
||||
|
||||
@PostMapping("/{sessionId}/complete")
|
||||
public ApiV2Response<UploadSessionV2Response> completeSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(uploadSessionService.completeOwnedSession(user, sessionId)));
|
||||
}
|
||||
|
||||
@PutMapping("/{sessionId}/parts/{partIndex}")
|
||||
public ApiV2Response<UploadSessionV2Response> recordPart(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable int partIndex,
|
||||
@Valid @RequestBody MarkUploadSessionPartV2Request request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
UploadSession session = uploadSessionService.recordUploadedPart(
|
||||
user,
|
||||
sessionId,
|
||||
partIndex,
|
||||
new UploadSessionPartCommand(request.etag(), request.size())
|
||||
);
|
||||
return ApiV2Response.success(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/{sessionId}/content")
|
||||
public ApiV2Response<UploadSessionV2Response> uploadContent(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(uploadSessionService.uploadOwnedContent(user, sessionId, file)));
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}/parts/{partIndex}/prepare")
|
||||
public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable int partIndex) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedPartUpload(user, sessionId, partIndex);
|
||||
return ApiV2Response.success(new PreparedUploadV2Response(
|
||||
preparedUpload.direct(),
|
||||
preparedUpload.uploadUrl(),
|
||||
preparedUpload.method(),
|
||||
preparedUpload.headers(),
|
||||
preparedUpload.storageName()
|
||||
));
|
||||
}
|
||||
|
||||
private UploadSessionV2Response toResponse(UploadSession session) {
|
||||
UploadSessionUploadMode uploadMode = uploadSessionService.resolveUploadMode(session);
|
||||
if (uploadMode == null) {
|
||||
uploadMode = session.getMultipartUploadId() != null
|
||||
? UploadSessionUploadMode.DIRECT_MULTIPART
|
||||
: UploadSessionUploadMode.PROXY;
|
||||
}
|
||||
return new UploadSessionV2Response(
|
||||
session.getSessionId(),
|
||||
session.getObjectKey(),
|
||||
uploadMode != UploadSessionUploadMode.PROXY,
|
||||
uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART,
|
||||
uploadMode.name(),
|
||||
session.getTargetPath(),
|
||||
session.getFilename(),
|
||||
session.getContentType(),
|
||||
session.getSize(),
|
||||
session.getStoragePolicyId(),
|
||||
session.getStatus().name(),
|
||||
session.getChunkSize(),
|
||||
session.getChunkCount(),
|
||||
session.getExpiresAt(),
|
||||
session.getCreatedAt(),
|
||||
session.getUpdatedAt(),
|
||||
uploadSessionService.getRuntimeState(session.getSessionId())
|
||||
.map(this::toRuntimeResponse)
|
||||
.orElse(null),
|
||||
toStrategyResponse(session.getSessionId(), uploadMode)
|
||||
);
|
||||
}
|
||||
|
||||
private UploadSessionRuntimeStateV2Response toRuntimeResponse(UploadSessionRuntimeState runtimeState) {
|
||||
return new UploadSessionRuntimeStateV2Response(
|
||||
runtimeState.phase(),
|
||||
runtimeState.uploadedBytes(),
|
||||
runtimeState.uploadedPartCount(),
|
||||
runtimeState.progressPercent(),
|
||||
runtimeState.lastUpdatedAt(),
|
||||
runtimeState.expiresAt()
|
||||
);
|
||||
}
|
||||
|
||||
private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) {
|
||||
String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId;
|
||||
return switch (uploadMode) {
|
||||
case PROXY -> new UploadSessionV2StrategyResponse(
|
||||
null,
|
||||
sessionBasePath + "/content",
|
||||
null,
|
||||
null,
|
||||
sessionBasePath + "/complete",
|
||||
"file"
|
||||
);
|
||||
case DIRECT_SINGLE -> new UploadSessionV2StrategyResponse(
|
||||
sessionBasePath + "/prepare",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sessionBasePath + "/complete",
|
||||
null
|
||||
);
|
||||
case DIRECT_MULTIPART -> new UploadSessionV2StrategyResponse(
|
||||
null,
|
||||
null,
|
||||
sessionBasePath + "/parts/{partIndex}/prepare",
|
||||
sessionBasePath + "/parts/{partIndex}",
|
||||
sessionBasePath + "/complete",
|
||||
null
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UploadSessionV2Response(
|
||||
String sessionId,
|
||||
String objectKey,
|
||||
boolean directUpload,
|
||||
boolean multipartUpload,
|
||||
String uploadMode,
|
||||
String path,
|
||||
String filename,
|
||||
String contentType,
|
||||
long size,
|
||||
Long storagePolicyId,
|
||||
String status,
|
||||
long chunkSize,
|
||||
int chunkCount,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
UploadSessionRuntimeStateV2Response runtime,
|
||||
UploadSessionV2StrategyResponse strategy
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.api.v2.files;
|
||||
|
||||
public record UploadSessionV2StrategyResponse(
|
||||
String prepareUrl,
|
||||
String proxyContentUrl,
|
||||
String partPrepareUrlTemplate,
|
||||
String partRecordUrlTemplate,
|
||||
String completeUrl,
|
||||
String proxyFormField
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CreateShareV2Request(
|
||||
@NotNull Long fileId,
|
||||
String password,
|
||||
LocalDateTime expiresAt,
|
||||
@Min(1) Integer maxDownloads,
|
||||
Boolean allowImport,
|
||||
Boolean allowDownload,
|
||||
String shareName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ImportShareV2Request(
|
||||
@NotBlank String path,
|
||||
String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Response;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.core.FileMetadataResponse;
|
||||
import com.yoyuzh.files.share.ShareV2Service;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/shares")
|
||||
@RequiredArgsConstructor
|
||||
public class ShareV2Controller {
|
||||
|
||||
private final ShareV2Service shareV2Service;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@PostMapping
|
||||
public ApiV2Response<ShareV2Response> createShare(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateShareV2Request request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(shareV2Service.createShare(user, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{token}")
|
||||
public ApiV2Response<ShareV2Response> getShare(@PathVariable String token) {
|
||||
return ApiV2Response.success(shareV2Service.getShare(token));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{token}", params = "download")
|
||||
public ResponseEntity<?> downloadShare(@PathVariable String token,
|
||||
@RequestParam(required = false) String password) {
|
||||
return shareV2Service.downloadSharedFile(token, password);
|
||||
}
|
||||
|
||||
@PostMapping("/{token}/verify-password")
|
||||
public ApiV2Response<ShareV2Response> verifyPassword(@PathVariable String token,
|
||||
@Valid @RequestBody VerifySharePasswordV2Request request) {
|
||||
return ApiV2Response.success(shareV2Service.verifyPassword(token, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{token}/import")
|
||||
public ApiV2Response<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String token,
|
||||
@Valid @RequestBody ImportShareV2Request request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(shareV2Service.importSharedFile(user, token, request));
|
||||
}
|
||||
|
||||
@GetMapping("/mine")
|
||||
public ApiV2Response<PageResponse<ShareV2Response>> mine(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
var result = shareV2Service.listOwnedShares(user, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||
return ApiV2Response.success(new PageResponse<>(result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiV2Response<Void> deleteShare(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long id) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
shareV2Service.deleteOwnedShare(user, id);
|
||||
return ApiV2Response.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import com.yoyuzh.files.core.FileMetadataResponse;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ShareV2Response(
|
||||
Long id,
|
||||
String token,
|
||||
String shareName,
|
||||
String ownerUsername,
|
||||
boolean passwordRequired,
|
||||
boolean passwordVerified,
|
||||
boolean allowImport,
|
||||
boolean allowDownload,
|
||||
Integer maxDownloads,
|
||||
long downloadCount,
|
||||
long viewCount,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime createdAt,
|
||||
FileMetadataResponse file
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.api.v2.shares;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record VerifySharePasswordV2Request(
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yoyuzh.api.v2.site;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Response;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/site")
|
||||
public class SiteV2Controller {
|
||||
|
||||
@GetMapping("/ping")
|
||||
public ApiV2Response<SiteV2PingResponse> ping() {
|
||||
return ApiV2Response.success(new SiteV2PingResponse("ok", "v2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.api.v2.site;
|
||||
|
||||
public record SiteV2PingResponse(String status, String apiVersion) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yoyuzh.api.v2.tasks;
|
||||
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskType;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record BackgroundTaskResponse(
|
||||
Long id,
|
||||
BackgroundTaskType type,
|
||||
BackgroundTaskStatus status,
|
||||
Long userId,
|
||||
String publicStateJson,
|
||||
String correlationId,
|
||||
String errorMessage,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
LocalDateTime finishedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.yoyuzh.api.v2.tasks;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Response;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.tasks.BackgroundTask;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskCommandService;
|
||||
import com.yoyuzh.files.tasks.BackgroundTaskType;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/tasks")
|
||||
@RequiredArgsConstructor
|
||||
public class BackgroundTaskV2Controller {
|
||||
|
||||
private final BackgroundTaskCommandService backgroundTaskCommandService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@GetMapping
|
||||
public ApiV2Response<PageResponse<BackgroundTaskResponse>> list(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
var result = backgroundTaskCommandService.listOwnedTasks(
|
||||
user,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
return ApiV2Response.success(new PageResponse<>(
|
||||
result.getContent().stream().map(this::toResponse).toList(),
|
||||
result.getTotalElements(),
|
||||
result.getNumber(),
|
||||
result.getSize()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiV2Response<BackgroundTaskResponse> get(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long id) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(backgroundTaskCommandService.getOwnedTask(user, id)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiV2Response<BackgroundTaskResponse> cancel(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long id) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(backgroundTaskCommandService.cancelOwnedTask(user, id)));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/retry")
|
||||
public ApiV2Response<BackgroundTaskResponse> retry(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long id) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiV2Response.success(toResponse(backgroundTaskCommandService.retryOwnedTask(user, id)));
|
||||
}
|
||||
|
||||
@PostMapping("/archive")
|
||||
public ApiV2Response<BackgroundTaskResponse> createArchiveTask(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateBackgroundTaskRequest request) {
|
||||
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.ARCHIVE, request));
|
||||
}
|
||||
|
||||
@PostMapping("/extract")
|
||||
public ApiV2Response<BackgroundTaskResponse> createExtractTask(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateBackgroundTaskRequest request) {
|
||||
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.EXTRACT, request));
|
||||
}
|
||||
|
||||
@PostMapping("/media-metadata")
|
||||
public ApiV2Response<BackgroundTaskResponse> createMediaMetadataTask(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateBackgroundTaskRequest request) {
|
||||
return ApiV2Response.success(createTask(userDetails, BackgroundTaskType.MEDIA_META, request));
|
||||
}
|
||||
|
||||
private BackgroundTaskResponse createTask(UserDetails userDetails,
|
||||
BackgroundTaskType type,
|
||||
CreateBackgroundTaskRequest request) {
|
||||
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
BackgroundTask task = backgroundTaskCommandService.createQueuedFileTask(
|
||||
user,
|
||||
type,
|
||||
request.fileId(),
|
||||
request.path(),
|
||||
request.correlationId()
|
||||
);
|
||||
return toResponse(task);
|
||||
}
|
||||
|
||||
private BackgroundTaskResponse toResponse(BackgroundTask task) {
|
||||
return new BackgroundTaskResponse(
|
||||
task.getId(),
|
||||
task.getType(),
|
||||
task.getStatus(),
|
||||
task.getUserId(),
|
||||
task.getPublicStateJson(),
|
||||
task.getCorrelationId(),
|
||||
task.getErrorMessage(),
|
||||
task.getCreatedAt(),
|
||||
task.getUpdatedAt(),
|
||||
task.getFinishedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.api.v2.tasks;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateBackgroundTaskRequest(
|
||||
@NotNull Long fileId,
|
||||
@NotBlank String path,
|
||||
String correlationId
|
||||
) {
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
|
||||
import com.yoyuzh.auth.dto.UserProfileResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.InitiateUploadResponse;
|
||||
import com.yoyuzh.files.core.FileService;
|
||||
import com.yoyuzh.files.upload.InitiateUploadResponse;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -44,6 +44,8 @@ public class AuthService {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final AuthTokenInvalidationService authTokenInvalidationService;
|
||||
private final AuthSessionPolicy authSessionPolicy;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
@@ -115,13 +117,20 @@ public class AuthService {
|
||||
}
|
||||
|
||||
final String finalCandidate = candidate;
|
||||
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
||||
UserRole desiredRole = resolveDevLoginRole(finalCandidate);
|
||||
User user = userRepository.findByUsername(finalCandidate).map(existing -> {
|
||||
if (existing.getRole() != desiredRole) {
|
||||
existing.setRole(desiredRole);
|
||||
return userRepository.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}).orElseGet(() -> {
|
||||
User created = new User();
|
||||
created.setUsername(finalCandidate);
|
||||
created.setDisplayName(finalCandidate);
|
||||
created.setEmail(finalCandidate + "@dev.local");
|
||||
created.setPasswordHash(passwordEncoder.encode("1"));
|
||||
created.setRole(UserRole.USER);
|
||||
created.setRole(desiredRole);
|
||||
created.setPreferredLanguage("zh-CN");
|
||||
return userRepository.save(created);
|
||||
});
|
||||
@@ -291,6 +300,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
private AuthResponse issueFreshTokens(User user, AuthClientType clientType) {
|
||||
authTokenInvalidationService.revokeAccessTokensForUser(user.getId(), clientType);
|
||||
refreshTokenService.revokeAllForUser(user.getId(), clientType);
|
||||
return issueTokens(user, refreshTokenService.issueRefreshToken(user, clientType), clientType);
|
||||
}
|
||||
@@ -300,31 +310,20 @@ public class AuthService {
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||
sessionUser.getId(),
|
||||
sessionUser.getUsername(),
|
||||
getActiveSessionId(sessionUser, clientType),
|
||||
authSessionPolicy.getActiveSessionId(sessionUser, clientType),
|
||||
clientType
|
||||
);
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
|
||||
}
|
||||
|
||||
private User rotateActiveSession(User user, AuthClientType clientType) {
|
||||
String nextSessionId = UUID.randomUUID().toString();
|
||||
if (clientType == AuthClientType.MOBILE) {
|
||||
user.setMobileActiveSessionId(nextSessionId);
|
||||
} else {
|
||||
user.setDesktopActiveSessionId(nextSessionId);
|
||||
user.setActiveSessionId(nextSessionId);
|
||||
}
|
||||
authSessionPolicy.rotateActiveSession(user, clientType);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
private void rotateAllActiveSessions(User user) {
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
|
||||
user.setMobileActiveSessionId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
private String getActiveSessionId(User user, AuthClientType clientType) {
|
||||
return clientType == AuthClientType.MOBILE ? user.getMobileActiveSessionId() : user.getDesktopActiveSessionId();
|
||||
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
|
||||
authSessionPolicy.rotateAllActiveSessions(user);
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
@@ -335,6 +334,16 @@ public class AuthService {
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private UserRole resolveDevLoginRole(String username) {
|
||||
if ("admin".equalsIgnoreCase(username)) {
|
||||
return UserRole.ADMIN;
|
||||
}
|
||||
if ("operator".equalsIgnoreCase(username) || "moderator".equalsIgnoreCase(username)) {
|
||||
return UserRole.MODERATOR;
|
||||
}
|
||||
return UserRole.USER;
|
||||
}
|
||||
|
||||
private String normalizePreferredLanguage(String preferredLanguage) {
|
||||
if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) {
|
||||
return "zh-CN";
|
||||
|
||||
35
backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java
Normal file
35
backend/src/main/java/com/yoyuzh/auth/AuthSessionPolicy.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.files.core.FileService;
|
||||
import com.yoyuzh.files.core.StoredFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@ package com.yoyuzh.auth;
|
||||
public enum UserRole {
|
||||
USER,
|
||||
MODERATOR,
|
||||
ADMIN
|
||||
ADMIN;
|
||||
|
||||
public boolean canAccessAdmin() {
|
||||
return this == MODERATOR || this == ADMIN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
159
backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java
Normal file
159
backend/src/main/java/com/yoyuzh/config/AppRedisProperties.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
21
backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java
Normal file
21
backend/src/main/java/com/yoyuzh/config/RedisCacheNames.java
Normal 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() {
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,24 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/v2/tasks/**")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/v2/files/**")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v2/shares/mine")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v2/shares/*")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/v2/shares")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v2/shares/*")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/v2/shares/*/verify-password")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/v2/shares/**")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/transfer/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file_share_link", indexes = {
|
||||
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
|
||||
@Index(name = "idx_file_share_created_at", columnList = "created_at")
|
||||
})
|
||||
public class FileShareLink {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "file_id", nullable = false)
|
||||
private StoredFile file;
|
||||
|
||||
@Column(nullable = false, length = 96, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(User owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public StoredFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public void setFile(StoredFile file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
|
||||
Optional<FileShareLink> findByToken(String token);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
public record DownloadUrlResponse(String url) {
|
||||
}
|
||||
@@ -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(), "同目录下文件已存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileBlobRepository extends JpaRepository<FileBlob, Long> {
|
||||
|
||||
Optional<FileBlob> findByObjectKey(String objectKey);
|
||||
|
||||
List<FileBlob> findAllByObjectKeyIn(Collection<String> objectKeys);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(b.size), 0)
|
||||
from FileBlob b
|
||||
@@ -1,8 +1,21 @@
|
||||
package com.yoyuzh.files;
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import com.yoyuzh.api.v2.ApiV2Exception;
|
||||
import com.yoyuzh.api.v2.shares.CreateShareV2Request;
|
||||
import com.yoyuzh.api.v2.shares.ImportShareV2Request;
|
||||
import com.yoyuzh.api.v2.shares.ShareV2Response;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.share.CreateFileShareLinkResponse;
|
||||
import com.yoyuzh.files.share.FileShareDetailsResponse;
|
||||
import com.yoyuzh.files.share.ImportSharedFileRequest;
|
||||
import com.yoyuzh.files.share.ShareV2Service;
|
||||
import com.yoyuzh.files.upload.CompleteUploadRequest;
|
||||
import com.yoyuzh.files.upload.InitiateUploadRequest;
|
||||
import com.yoyuzh.files.upload.InitiateUploadResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -31,6 +44,7 @@ public class FileController {
|
||||
|
||||
private final FileService fileService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final ShareV2Service shareV2Service;
|
||||
|
||||
@Operation(summary = "上传文件")
|
||||
@PostMapping("/upload")
|
||||
@@ -141,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 = "导入共享文件")
|
||||
@@ -158,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 = "删除文件")
|
||||
@@ -184,4 +233,14 @@ public class FileController {
|
||||
fileId
|
||||
));
|
||||
}
|
||||
|
||||
private BusinessException mapLegacyShareApiException(ApiV2Exception ex) {
|
||||
ErrorCode code = switch (ex.getErrorCode()) {
|
||||
case FILE_NOT_FOUND -> ErrorCode.FILE_NOT_FOUND;
|
||||
case PERMISSION_DENIED -> ErrorCode.PERMISSION_DENIED;
|
||||
case NOT_LOGGED_IN -> ErrorCode.NOT_LOGGED_IN;
|
||||
case BAD_REQUEST, INTERNAL_ERROR -> ErrorCode.UNKNOWN;
|
||||
};
|
||||
return new BusinessException(code, ex.getMessage());
|
||||
}
|
||||
}
|
||||
163
backend/src/main/java/com/yoyuzh/files/core/FileEntity.java
Normal file
163
backend/src/main/java/com/yoyuzh/files/core/FileEntity.java
Normal file
@@ -0,0 +1,163 @@
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file_entity", indexes = {
|
||||
@Index(name = "uk_file_entity_key_type", columnList = "object_key,entity_type", unique = true),
|
||||
@Index(name = "idx_file_entity_created_at", columnList = "created_at")
|
||||
})
|
||||
public class FileEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "object_key", nullable = false, length = 512)
|
||||
private String objectKey;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long size;
|
||||
|
||||
@Column(name = "content_type", length = 255)
|
||||
private String contentType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "entity_type", nullable = false, length = 32)
|
||||
private FileEntityType entityType;
|
||||
|
||||
@Column(name = "reference_count", nullable = false)
|
||||
private Integer referenceCount;
|
||||
|
||||
@Column(name = "storage_policy_id")
|
||||
private Long storagePolicyId;
|
||||
|
||||
@Column(name = "upload_session_id", length = 64)
|
||||
private String uploadSessionId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
@OnDelete(action = OnDeleteAction.SET_NULL)
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "props_json", columnDefinition = "TEXT")
|
||||
private String propsJson;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
if (referenceCount == null) {
|
||||
referenceCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getObjectKey() {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
public void setObjectKey(String objectKey) {
|
||||
this.objectKey = objectKey;
|
||||
}
|
||||
|
||||
public Long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(Long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public void setContentType(String contentType) {
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public FileEntityType getEntityType() {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
public void setEntityType(FileEntityType entityType) {
|
||||
this.entityType = entityType;
|
||||
}
|
||||
|
||||
public Integer getReferenceCount() {
|
||||
return referenceCount;
|
||||
}
|
||||
|
||||
public void setReferenceCount(Integer referenceCount) {
|
||||
this.referenceCount = referenceCount;
|
||||
}
|
||||
|
||||
public Long getStoragePolicyId() {
|
||||
return storagePolicyId;
|
||||
}
|
||||
|
||||
public void setStoragePolicyId(Long storagePolicyId) {
|
||||
this.storagePolicyId = storagePolicyId;
|
||||
}
|
||||
|
||||
public String getUploadSessionId() {
|
||||
return uploadSessionId;
|
||||
}
|
||||
|
||||
public void setUploadSessionId(String uploadSessionId) {
|
||||
this.uploadSessionId = uploadSessionId;
|
||||
}
|
||||
|
||||
public User getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
public void setCreatedBy(User createdBy) {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public String getPropsJson() {
|
||||
return propsJson;
|
||||
}
|
||||
|
||||
public void setPropsJson(String propsJson) {
|
||||
this.propsJson = propsJson;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
import com.yoyuzh.files.policy.StoragePolicyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
@Order(1)
|
||||
@RequiredArgsConstructor
|
||||
public class FileEntityBackfillService implements CommandLineRunner {
|
||||
|
||||
static final String PRIMARY_ENTITY_ROLE = "PRIMARY";
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileEntityRepository fileEntityRepository;
|
||||
private final StoredFileEntityRepository storedFileEntityRepository;
|
||||
private final StoragePolicyService storagePolicyService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
backfillPrimaryEntities();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void backfillPrimaryEntities() {
|
||||
for (StoredFile storedFile : storedFileRepository.findAllByDirectoryFalseAndBlobIsNotNullAndPrimaryEntityIsNull()) {
|
||||
FileBlob blob = storedFile.getBlob();
|
||||
Optional<FileEntity> existingEntity = fileEntityRepository
|
||||
.findByObjectKeyAndEntityType(blob.getObjectKey(), FileEntityType.VERSION);
|
||||
FileEntity fileEntity = existingEntity.orElseGet(() -> createEntity(storedFile, blob));
|
||||
|
||||
if (existingEntity.isPresent()) {
|
||||
fileEntity.setReferenceCount(fileEntity.getReferenceCount() + 1);
|
||||
fileEntityRepository.save(fileEntity);
|
||||
}
|
||||
storedFile.setPrimaryEntity(fileEntity);
|
||||
storedFileRepository.save(storedFile);
|
||||
storedFileEntityRepository.save(createRelation(storedFile, fileEntity));
|
||||
}
|
||||
}
|
||||
|
||||
private FileEntity createEntity(StoredFile storedFile, FileBlob blob) {
|
||||
FileEntity fileEntity = new FileEntity();
|
||||
fileEntity.setObjectKey(blob.getObjectKey());
|
||||
fileEntity.setSize(blob.getSize());
|
||||
fileEntity.setContentType(blob.getContentType());
|
||||
fileEntity.setEntityType(FileEntityType.VERSION);
|
||||
fileEntity.setReferenceCount(1);
|
||||
fileEntity.setCreatedBy(storedFile.getUser());
|
||||
fileEntity.setStoragePolicyId(storagePolicyService.ensureDefaultPolicy().getId());
|
||||
return fileEntityRepository.save(fileEntity);
|
||||
}
|
||||
|
||||
private StoredFileEntity createRelation(StoredFile storedFile, FileEntity fileEntity) {
|
||||
StoredFileEntity relation = new StoredFileEntity();
|
||||
relation.setStoredFile(storedFile);
|
||||
relation.setFileEntity(fileEntity);
|
||||
relation.setEntityRole(PRIMARY_ENTITY_ROLE);
|
||||
return relation;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user