From 97edc4cc32e523c04aa41223555359470b76e9e6 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Thu, 2 Apr 2026 12:20:50 +0800 Subject: [PATCH] Migrate storage to DogeCloud and expand admin dashboard --- .env.oss.example | 39 +- .../page-2026-03-31T10-42-22-102Z.yml | 30 + .../page-2026-03-31T10-43-43-923Z.yml | 30 + .../page-2026-03-31T10-43-57-458Z.yml | 30 + .../page-2026-03-31T10-44-34-928Z.yml | 30 + .../page-2026-03-31T10-45-01-963Z.yml | 1 + AGENTS.md | 4 + README.md | 53 +- backend/README.md | 22 +- backend/pom.xml | 6 +- .../com/yoyuzh/admin/AdminController.java | 8 + .../com/yoyuzh/admin/AdminMetricsService.java | 161 ++++ .../yoyuzh/admin/AdminMetricsSnapshot.java | 12 + .../com/yoyuzh/admin/AdminMetricsState.java | 95 +++ .../admin/AdminMetricsStateRepository.java | 16 + ...inOfflineTransferStorageLimitResponse.java | 6 + ...lineTransferStorageLimitUpdateRequest.java | 9 + .../admin/AdminRequestTimelinePoint.java | 8 + .../AdminRequestTimelinePointEntity.java | 76 ++ .../AdminRequestTimelinePointRepository.java | 24 + .../java/com/yoyuzh/admin/AdminService.java | 21 +- .../yoyuzh/admin/AdminSummaryResponse.java | 9 + .../admin/AdminUserPasswordUpdateRequest.java | 4 +- .../com/yoyuzh/admin/AdminUserResponse.java | 1 + .../yoyuzh/admin/ApiRequestMetricsFilter.java | 33 + .../java/com/yoyuzh/auth/PasswordPolicy.java | 17 +- .../com/yoyuzh/auth/dto/RegisterRequest.java | 4 +- .../auth/dto/UpdateUserPasswordRequest.java | 4 +- .../config/FileStorageConfiguration.java | 6 +- .../yoyuzh/config/FileStorageProperties.java | 68 +- .../com/yoyuzh/config/SecurityConfig.java | 3 + .../java/com/yoyuzh/files/FileService.java | 5 + .../yoyuzh/files/StoredFileRepository.java | 7 + .../OfflineTransferSessionRepository.java | 8 + .../com/yoyuzh/transfer/TransferService.java | 11 + backend/src/main/resources/application.yml | 13 +- .../admin/AdminControllerIntegrationTest.java | 103 ++- .../yoyuzh/admin/AdminMetricsServiceTest.java | 79 ++ .../com/yoyuzh/admin/AdminServiceTest.java | 34 +- .../auth/AuthControllerValidationTest.java | 2 +- .../com/yoyuzh/auth/PasswordPolicyTest.java | 35 +- .../auth/RegisterRequestValidationTest.java | 6 +- .../com/yoyuzh/config/SecurityConfigTest.java | 2 + .../yoyuzh/files/FileServiceEdgeCaseTest.java | 5 +- .../com/yoyuzh/files/FileServiceTest.java | 5 +- docs/architecture.md | 42 +- front/src/App.tsx | 2 + front/src/admin/dashboard-state.test.ts | 59 +- front/src/admin/dashboard-state.ts | 116 ++- front/src/admin/dashboard.tsx | 703 +++++++++++++++--- front/src/admin/users-list.tsx | 35 +- front/src/auth/admin-access.test.ts | 7 + front/src/lib/types.ts | 18 + front/src/pages/GamePlayer.tsx | 71 ++ front/src/pages/Games.tsx | 29 +- front/src/pages/Login.tsx | 6 +- front/src/pages/games-links.test.ts | 45 ++ front/src/pages/games-links.ts | 21 + memory.md | 27 +- scripts/deploy-front-oss.mjs | 67 +- scripts/migrate-aliyun-oss-to-s3.mjs | 402 ++++++++++ scripts/migrate-aliyun-oss-to-s3.test.mjs | 38 + scripts/migrate-file-storage-to-oss.mjs | 114 +-- scripts/oss-deploy-lib.mjs | 178 ++++- scripts/oss-deploy-lib.test.mjs | 97 ++- 65 files changed, 2842 insertions(+), 380 deletions(-) create mode 100644 .playwright-cli/page-2026-03-31T10-42-22-102Z.yml create mode 100644 .playwright-cli/page-2026-03-31T10-43-43-923Z.yml create mode 100644 .playwright-cli/page-2026-03-31T10-43-57-458Z.yml create mode 100644 .playwright-cli/page-2026-03-31T10-44-34-928Z.yml create mode 100644 .playwright-cli/page-2026-03-31T10-45-01-963Z.yml create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminMetricsState.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminMetricsStateRepository.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitUpdateRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePoint.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointEntity.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointRepository.java create mode 100644 backend/src/main/java/com/yoyuzh/admin/ApiRequestMetricsFilter.java create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java create mode 100644 front/src/pages/GamePlayer.tsx create mode 100644 front/src/pages/games-links.test.ts create mode 100644 front/src/pages/games-links.ts create mode 100644 scripts/migrate-aliyun-oss-to-s3.mjs create mode 100644 scripts/migrate-aliyun-oss-to-s3.test.mjs diff --git a/.env.oss.example b/.env.oss.example index e11054b..8336284 100644 --- a/.env.oss.example +++ b/.env.oss.example @@ -2,26 +2,31 @@ # cp .env.oss.example .env.oss.local # # 发布命令: -# ./scripts/deploy-front-oss.mjs +# node scripts/deploy-front-oss.mjs # # 仅预览将要上传的文件: -# ./scripts/deploy-front-oss.mjs --skip-build --dry-run +# node scripts/deploy-front-oss.mjs --skip-build --dry-run -# 阿里云 OSS Endpoint。 -# 当前项目使用东京区域 OSS,默认保持这个值即可。 -YOYUZH_OSS_ENDPOINT="https://oss-ap-northeast-1.aliyuncs.com" +# 说明:文件名仍叫 `.env.oss.local`,但内容已经切换为多吉云临时密钥配置。 -# 前端静态站点所在的 OSS Bucket 名称。 -YOYUZH_OSS_BUCKET="yoyuzh-2026" - -# 可选:上传到 Bucket 内的子目录。 -# 为空表示直接上传到 Bucket 根目录。 -YOYUZH_OSS_PREFIX="" - -# 阿里云 AccessKey ID。 +# 多吉云服务端 API AccessKey / SecretKey。 # 不要把真实值提交到 git。 -YOYUZH_OSS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID" +YOYUZH_DOGECLOUD_API_ACCESS_KEY="YOUR_DOGECLOUD_ACCESS_KEY" +YOYUZH_DOGECLOUD_API_SECRET_KEY="YOUR_DOGECLOUD_SECRET_KEY" -# 阿里云 AccessKey Secret。 -# 不要把真实值提交到 git。 -YOYUZH_OSS_ACCESS_KEY_SECRET="YOUR_ACCESS_KEY_SECRET" +# 可选:多吉云服务端 API 地址。 +YOYUZH_DOGECLOUD_API_BASE_URL="https://api.dogecloud.com" + +# 多吉云 S3 兼容区域。多吉云官方文档建议使用 automatic。 +YOYUZH_DOGECLOUD_S3_REGION="automatic" + +# 前端静态站点的逻辑桶名 / scope。 +# 你的当前桶建议填写 `yoyuzh-front`。 +YOYUZH_DOGECLOUD_FRONT_SCOPE="yoyuzh-front" + +# 可选:前端发布拿临时密钥的有效期,单位秒。 +YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS="3600" + +# 可选:上传到桶内的子目录。 +# 为空表示直接上传到桶根目录。 +YOYUZH_DOGECLOUD_FRONT_PREFIX="" diff --git a/.playwright-cli/page-2026-03-31T10-42-22-102Z.yml b/.playwright-cli/page-2026-03-31T10-42-22-102Z.yml new file mode 100644 index 0000000..a0dc86f --- /dev/null +++ b/.playwright-cli/page-2026-03-31T10-42-22-102Z.yml @@ -0,0 +1,30 @@ +- generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e10]: Access Portal + - generic [ref=e11]: + - heading "YOYUZH.XYZ" [level=2] [ref=e12] + - heading "个人网站 统一入口" [level=1] [ref=e13]: + - text: 个人网站 + - text: 统一入口 + - paragraph [ref=e14]: 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。 + - generic [ref=e17]: + - generic [ref=e18]: + - heading "登录" [level=3] [ref=e19]: + - img [ref=e20] + - text: 登录 + - paragraph [ref=e23]: 请输入您的账号和密码以继续 + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: + - text: 用户名 + - generic [ref=e28]: + - img [ref=e29] + - textbox "账号 / 用户名 / 学号" [ref=e32] + - generic [ref=e33]: + - text: 密码 + - generic [ref=e34]: + - img [ref=e35] + - textbox "••••••••" [ref=e38] + - generic [ref=e39]: + - button "进入系统" [ref=e40] + - button "还没有账号?立即注册" [ref=e42] \ No newline at end of file diff --git a/.playwright-cli/page-2026-03-31T10-43-43-923Z.yml b/.playwright-cli/page-2026-03-31T10-43-43-923Z.yml new file mode 100644 index 0000000..a0dc86f --- /dev/null +++ b/.playwright-cli/page-2026-03-31T10-43-43-923Z.yml @@ -0,0 +1,30 @@ +- generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e10]: Access Portal + - generic [ref=e11]: + - heading "YOYUZH.XYZ" [level=2] [ref=e12] + - heading "个人网站 统一入口" [level=1] [ref=e13]: + - text: 个人网站 + - text: 统一入口 + - paragraph [ref=e14]: 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。 + - generic [ref=e17]: + - generic [ref=e18]: + - heading "登录" [level=3] [ref=e19]: + - img [ref=e20] + - text: 登录 + - paragraph [ref=e23]: 请输入您的账号和密码以继续 + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: + - text: 用户名 + - generic [ref=e28]: + - img [ref=e29] + - textbox "账号 / 用户名 / 学号" [ref=e32] + - generic [ref=e33]: + - text: 密码 + - generic [ref=e34]: + - img [ref=e35] + - textbox "••••••••" [ref=e38] + - generic [ref=e39]: + - button "进入系统" [ref=e40] + - button "还没有账号?立即注册" [ref=e42] \ No newline at end of file diff --git a/.playwright-cli/page-2026-03-31T10-43-57-458Z.yml b/.playwright-cli/page-2026-03-31T10-43-57-458Z.yml new file mode 100644 index 0000000..a0dc86f --- /dev/null +++ b/.playwright-cli/page-2026-03-31T10-43-57-458Z.yml @@ -0,0 +1,30 @@ +- generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e10]: Access Portal + - generic [ref=e11]: + - heading "YOYUZH.XYZ" [level=2] [ref=e12] + - heading "个人网站 统一入口" [level=1] [ref=e13]: + - text: 个人网站 + - text: 统一入口 + - paragraph [ref=e14]: 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。 + - generic [ref=e17]: + - generic [ref=e18]: + - heading "登录" [level=3] [ref=e19]: + - img [ref=e20] + - text: 登录 + - paragraph [ref=e23]: 请输入您的账号和密码以继续 + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: + - text: 用户名 + - generic [ref=e28]: + - img [ref=e29] + - textbox "账号 / 用户名 / 学号" [ref=e32] + - generic [ref=e33]: + - text: 密码 + - generic [ref=e34]: + - img [ref=e35] + - textbox "••••••••" [ref=e38] + - generic [ref=e39]: + - button "进入系统" [ref=e40] + - button "还没有账号?立即注册" [ref=e42] \ No newline at end of file diff --git a/.playwright-cli/page-2026-03-31T10-44-34-928Z.yml b/.playwright-cli/page-2026-03-31T10-44-34-928Z.yml new file mode 100644 index 0000000..a0dc86f --- /dev/null +++ b/.playwright-cli/page-2026-03-31T10-44-34-928Z.yml @@ -0,0 +1,30 @@ +- generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e10]: Access Portal + - generic [ref=e11]: + - heading "YOYUZH.XYZ" [level=2] [ref=e12] + - heading "个人网站 统一入口" [level=1] [ref=e13]: + - text: 个人网站 + - text: 统一入口 + - paragraph [ref=e14]: 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。 + - generic [ref=e17]: + - generic [ref=e18]: + - heading "登录" [level=3] [ref=e19]: + - img [ref=e20] + - text: 登录 + - paragraph [ref=e23]: 请输入您的账号和密码以继续 + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: + - text: 用户名 + - generic [ref=e28]: + - img [ref=e29] + - textbox "账号 / 用户名 / 学号" [ref=e32] + - generic [ref=e33]: + - text: 密码 + - generic [ref=e34]: + - img [ref=e35] + - textbox "••••••••" [ref=e38] + - generic [ref=e39]: + - button "进入系统" [ref=e40] + - button "还没有账号?立即注册" [ref=e42] \ No newline at end of file diff --git a/.playwright-cli/page-2026-03-31T10-45-01-963Z.yml b/.playwright-cli/page-2026-03-31T10-45-01-963Z.yml new file mode 100644 index 0000000..de85c20 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T10-45-01-963Z.yml @@ -0,0 +1 @@ +- generic [ref=e3]: 正在检查登录状态... \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 9038288..586ea8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ Important: - Do not propose any solution beyond the user's stated requirement if it could shift business logic. - Every proposed modification or refactor plan must be logically correct and validated across the full request path before it is presented. +### 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. + ## Repo-specific guardrails - Do not run `npm` commands at the repository root. This repo has a root `package-lock.json` but no root `package.json`. diff --git a/README.md b/README.md index 2d49824..2ff6f1a 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ ### 存储与基础设施 - MySQL 8.x -- 本地文件系统 / 阿里云 OSS -- OSS 静态资源发布 +- 本地文件系统 / 多吉云 S3 兼容对象存储 +- S3 兼容静态资源发布 ## 仓库结构 @@ -157,19 +157,31 @@ APP_ADMIN_USERNAMES=admin1,admin2 APP_AUTH_REGISTRATION_INVITE_CODE=<初始化邀请码种子> ``` -### OSS 相关 +### S3 相关 ```env -YOYUZH_STORAGE_PROVIDER=oss -YOYUZH_OSS_ENDPOINT=... -YOYUZH_OSS_BUCKET=... -YOYUZH_OSS_ACCESS_KEY_ID=... -YOYUZH_OSS_ACCESS_KEY_SECRET=... +YOYUZH_STORAGE_PROVIDER=s3 +YOYUZH_DOGECLOUD_API_BASE_URL=https://api.dogecloud.com +YOYUZH_DOGECLOUD_API_ACCESS_KEY=... +YOYUZH_DOGECLOUD_API_SECRET_KEY=... +YOYUZH_DOGECLOUD_STORAGE_SCOPE=yoyuzh-files +YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS=3600 +YOYUZH_DOGECLOUD_S3_REGION=automatic ``` ### 前端发布配置 -前端发布脚本会从环境变量或 `.env.oss.local` 中读取 OSS 配置。 +前端发布脚本会从环境变量或 `.env.oss.local` 中读取多吉云 API 凭据,再动态换取临时 S3 密钥。前端静态桶应填写逻辑桶名 `yoyuzh-front`,不要直接把底层 `s3Bucket` 写死到配置里。 + +常用变量: + +```env +YOYUZH_DOGECLOUD_API_ACCESS_KEY=... +YOYUZH_DOGECLOUD_API_SECRET_KEY=... +YOYUZH_DOGECLOUD_FRONT_SCOPE=yoyuzh-front +YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS=3600 +YOYUZH_DOGECLOUD_FRONT_PREFIX= +``` 参考文件: @@ -193,6 +205,29 @@ node scripts/deploy-front-oss.mjs --dry-run node scripts/deploy-front-oss.mjs --skip-build ``` +### 阿里云 OSS 到多吉云 S3 迁移 + +静态站点桶或文件桶需要整桶迁移时,可在仓库根目录执行: + +```bash +node scripts/migrate-aliyun-oss-to-s3.mjs \ + --source-bucket=<阿里云源 Bucket> \ + --source-access-key-id=<阿里云 AccessKeyId> \ + --source-access-key-secret=<阿里云 AccessKeySecret> \ + --target-scope=<多吉云逻辑桶名> \ + --target-api-access-key=<多吉云 AccessKey> \ + --target-api-secret-key=<多吉云 SecretKey> +``` + +可选参数: + +```bash +node scripts/migrate-aliyun-oss-to-s3.mjs --dry-run +node scripts/migrate-aliyun-oss-to-s3.mjs --prefix=race/ +node scripts/migrate-aliyun-oss-to-s3.mjs --overwrite +node scripts/migrate-aliyun-oss-to-s3.mjs --target-api-base-url=https://api.dogecloud.com +``` + ### 后端发布 先打包: diff --git a/backend/README.md b/backend/README.md index fc636be..cad0386 100644 --- a/backend/README.md +++ b/backend/README.md @@ -89,22 +89,26 @@ DROP TABLE IF EXISTS portal_grade; - `POST /api/transfer/sessions/{sessionId}/signals` - `GET /api/transfer/sessions/{sessionId}/signals` -## OSS 直传说明 +## S3 兼容直传说明 生产环境如果启用: ```env -YOYUZH_STORAGE_PROVIDER=oss -YOYUZH_OSS_ENDPOINT=https://oss-ap-northeast-1.aliyuncs.com -YOYUZH_OSS_BUCKET=your-bucket -YOYUZH_OSS_ACCESS_KEY_ID=... -YOYUZH_OSS_ACCESS_KEY_SECRET=... +YOYUZH_STORAGE_PROVIDER=s3 +YOYUZH_DOGECLOUD_API_BASE_URL=https://api.dogecloud.com +YOYUZH_DOGECLOUD_API_ACCESS_KEY=... +YOYUZH_DOGECLOUD_API_SECRET_KEY=... +YOYUZH_DOGECLOUD_STORAGE_SCOPE=yoyuzh-files +YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS=3600 +YOYUZH_DOGECLOUD_S3_REGION=automatic ``` -前端会先调用后端拿签名上传地址,再由浏览器直接把文件内容传到 OSS。为保证浏览器可以直传,请在 OSS Bucket 上放行站点域名对应的 CORS 规则,至少允许: +后端会先用多吉云服务端 API 换取 `OSS_FULL` 临时密钥,再生成浏览器直传和下载所需的 S3 预签名地址。`YOYUZH_DOGECLOUD_STORAGE_SCOPE` 需要填写多吉云逻辑桶名;按你当前环境,文件桶应填 `yoyuzh-files`,而不是底层 `s3Bucket`。 + +为保证浏览器可以直传,请在对象存储 Bucket 上放行站点域名对应的 CORS 规则,至少允许: - Origin: `https://yoyuzh.xyz` - Methods: `PUT`, `GET`, `HEAD` -- Headers: `Content-Type`, `x-oss-*` +- Headers: `Content-Type`, `x-amz-*` -如果生产环境里曾经存在“数据库元数据已经在 OSS 模式下运行,但本地磁盘里没有对应文件”的历史数据,需要额外做一次对象迁移或元数据修复;否则旧记录在重命名/删除时仍可能失败。 +后端运行时使用的是 AWS S3 Java SDK V2,适配多吉云文档中的 S3 兼容接入方式。如果生产环境里曾经存在“数据库元数据已经在对象存储模式下运行,但新 Bucket 里没有对应文件”的历史数据,需要额外做一次对象迁移;否则旧记录在重命名/删除时仍可能失败。 diff --git a/backend/pom.xml b/backend/pom.xml index d4b8c21..f30da72 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -66,9 +66,9 @@ runtime - com.aliyun.oss - aliyun-sdk-oss - 3.17.4 + software.amazon.awssdk + s3 + 2.31.66 org.postgresql diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java index 789d379..4092700 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminController.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -29,6 +29,14 @@ public class AdminController { return ApiResponse.success(adminService.getSummary()); } + @PatchMapping("/settings/offline-transfer-storage-limit") + public ApiResponse updateOfflineTransferStorageLimit( + @Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) { + return ApiResponse.success(adminService.updateOfflineTransferStorageLimit( + request.offlineTransferStorageLimitBytes() + )); + } + @GetMapping("/users") public ApiResponse> users(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java new file mode 100644 index 0000000..2b40951 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java @@ -0,0 +1,161 @@ +package com.yoyuzh.admin; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +public class AdminMetricsService { + + private static final Long STATE_ID = 1L; + private static final long DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES = 20L * 1024 * 1024 * 1024; + + private final AdminMetricsStateRepository adminMetricsStateRepository; + private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository; + + @Transactional + public AdminMetricsSnapshot getSnapshot() { + LocalDate today = LocalDate.now(); + AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true); + return toSnapshot(state, today); + } + + @Transactional + public long getOfflineTransferStorageLimitBytes() { + return ensureCurrentState().getOfflineTransferStorageLimitBytes(); + } + + @Transactional + public void incrementRequestCount() { + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentStateForUpdate(), today, false); + state.setRequestCount(state.getRequestCount() + 1); + adminMetricsStateRepository.save(state); + incrementRequestTimelinePoint(today, now.getHour()); + } + + @Transactional + public void recordDownloadTraffic(long bytes) { + if (bytes <= 0) { + return; + } + AdminMetricsState state = ensureCurrentStateForUpdate(); + state.setDownloadTrafficBytes(state.getDownloadTrafficBytes() + bytes); + adminMetricsStateRepository.save(state); + } + + @Transactional + public void recordTransferUsage(long bytes) { + if (bytes <= 0) { + return; + } + AdminMetricsState state = ensureCurrentStateForUpdate(); + state.setTransferUsageBytes(state.getTransferUsageBytes() + bytes); + adminMetricsStateRepository.save(state); + } + + @Transactional + public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) { + AdminMetricsState state = ensureCurrentStateForUpdate(); + state.setOfflineTransferStorageLimitBytes(offlineTransferStorageLimitBytes); + AdminMetricsState saved = adminMetricsStateRepository.save(state); + return new AdminOfflineTransferStorageLimitResponse(saved.getOfflineTransferStorageLimitBytes()); + } + + private AdminMetricsSnapshot toSnapshot(AdminMetricsState state, LocalDate metricDate) { + return new AdminMetricsSnapshot( + state.getRequestCount(), + state.getDownloadTrafficBytes(), + state.getTransferUsageBytes(), + state.getOfflineTransferStorageLimitBytes(), + buildRequestTimeline(metricDate) + ); + } + + private AdminMetricsState ensureCurrentState() { + return adminMetricsStateRepository.findById(STATE_ID) + .orElseGet(this::createInitialState); + } + + private AdminMetricsState ensureCurrentStateForUpdate() { + return adminMetricsStateRepository.findByIdForUpdate(STATE_ID) + .orElseGet(() -> { + createInitialState(); + return adminMetricsStateRepository.findByIdForUpdate(STATE_ID) + .orElseThrow(() -> new IllegalStateException("管理统计状态初始化失败")); + }); + } + + private AdminMetricsState createInitialState() { + AdminMetricsState state = new AdminMetricsState(); + state.setId(STATE_ID); + state.setRequestCount(0L); + state.setRequestCountDate(LocalDate.now()); + state.setDownloadTrafficBytes(0L); + state.setTransferUsageBytes(0L); + state.setOfflineTransferStorageLimitBytes(DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES); + try { + return adminMetricsStateRepository.saveAndFlush(state); + } catch (DataIntegrityViolationException ignored) { + return adminMetricsStateRepository.findById(STATE_ID) + .orElseThrow(() -> ignored); + } + } + + private AdminMetricsState refreshRequestCountDateIfNeeded(AdminMetricsState state, LocalDate today, boolean persistImmediately) { + if (today.equals(state.getRequestCountDate())) { + return state; + } + state.setRequestCount(0L); + state.setRequestCountDate(today); + if (persistImmediately) { + return adminMetricsStateRepository.save(state); + } + return state; + } + + private List buildRequestTimeline(LocalDate metricDate) { + Map countsByHour = new HashMap<>(); + for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) { + countsByHour.put(point.getHour(), point.getRequestCount()); + } + return IntStream.range(0, 24) + .mapToObj(hour -> new AdminRequestTimelinePoint(hour, formatHourLabel(hour), countsByHour.getOrDefault(hour, 0L))) + .toList(); + } + + private void incrementRequestTimelinePoint(LocalDate metricDate, int hour) { + AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository + .findByMetricDateAndHourForUpdate(metricDate, hour) + .orElseGet(() -> createTimelinePoint(metricDate, hour)); + point.setRequestCount(point.getRequestCount() + 1); + adminRequestTimelinePointRepository.save(point); + } + + private AdminRequestTimelinePointEntity createTimelinePoint(LocalDate metricDate, int hour) { + AdminRequestTimelinePointEntity point = new AdminRequestTimelinePointEntity(); + point.setMetricDate(metricDate); + point.setHour(hour); + point.setRequestCount(0L); + try { + return adminRequestTimelinePointRepository.saveAndFlush(point); + } catch (DataIntegrityViolationException ignored) { + return adminRequestTimelinePointRepository.findByMetricDateAndHourForUpdate(metricDate, hour) + .orElseThrow(() -> ignored); + } + } + + private String formatHourLabel(int hour) { + return "%02d:00".formatted(hour); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java new file mode 100644 index 0000000..855db28 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java @@ -0,0 +1,12 @@ +package com.yoyuzh.admin; + +import java.util.List; + +public record AdminMetricsSnapshot( + long requestCount, + long downloadTrafficBytes, + long transferUsageBytes, + long offlineTransferStorageLimitBytes, + List requestTimeline +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsState.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsState.java new file mode 100644 index 0000000..6f5f2f9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsState.java @@ -0,0 +1,95 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "portal_admin_metrics_state") +public class AdminMetricsState { + + @Id + private Long id; + + @Column(name = "request_count", nullable = false) + private long requestCount; + + @Column(name = "request_count_date") + private LocalDate requestCountDate; + + @Column(name = "download_traffic_bytes", nullable = false) + private long downloadTrafficBytes; + + @Column(name = "transfer_usage_bytes", nullable = false) + private long transferUsageBytes; + + @Column(name = "offline_transfer_storage_limit_bytes", nullable = false) + private long offlineTransferStorageLimitBytes; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + @PreUpdate + public void touch() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public long getRequestCount() { + return requestCount; + } + + public void setRequestCount(long requestCount) { + this.requestCount = requestCount; + } + + public LocalDate getRequestCountDate() { + return requestCountDate; + } + + public void setRequestCountDate(LocalDate requestCountDate) { + this.requestCountDate = requestCountDate; + } + + public long getDownloadTrafficBytes() { + return downloadTrafficBytes; + } + + public void setDownloadTrafficBytes(long downloadTrafficBytes) { + this.downloadTrafficBytes = downloadTrafficBytes; + } + + public long getTransferUsageBytes() { + return transferUsageBytes; + } + + public void setTransferUsageBytes(long transferUsageBytes) { + this.transferUsageBytes = transferUsageBytes; + } + + public long getOfflineTransferStorageLimitBytes() { + return offlineTransferStorageLimitBytes; + } + + public void setOfflineTransferStorageLimitBytes(long offlineTransferStorageLimitBytes) { + this.offlineTransferStorageLimitBytes = offlineTransferStorageLimitBytes; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsStateRepository.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsStateRepository.java new file mode 100644 index 0000000..fde32dc --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsStateRepository.java @@ -0,0 +1,16 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface AdminMetricsStateRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select state from AdminMetricsState state where state.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitResponse.java new file mode 100644 index 0000000..7b11fc7 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitResponse.java @@ -0,0 +1,6 @@ +package com.yoyuzh.admin; + +public record AdminOfflineTransferStorageLimitResponse( + long offlineTransferStorageLimitBytes +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitUpdateRequest.java new file mode 100644 index 0000000..1b11f35 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminOfflineTransferStorageLimitUpdateRequest.java @@ -0,0 +1,9 @@ +package com.yoyuzh.admin; + +import jakarta.validation.constraints.Positive; + +public record AdminOfflineTransferStorageLimitUpdateRequest( + @Positive(message = "离线快传存储上限必须大于 0") + long offlineTransferStorageLimitBytes +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePoint.java b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePoint.java new file mode 100644 index 0000000..62087ae --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePoint.java @@ -0,0 +1,8 @@ +package com.yoyuzh.admin; + +public record AdminRequestTimelinePoint( + int hour, + String label, + long requestCount +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointEntity.java b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointEntity.java new file mode 100644 index 0000000..c46c90a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointEntity.java @@ -0,0 +1,76 @@ +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.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "portal_admin_request_timeline_point", + uniqueConstraints = @UniqueConstraint(name = "uk_admin_request_timeline_date_hour", columnNames = {"metric_date", "metric_hour"}) +) +public class AdminRequestTimelinePointEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "metric_hour", nullable = false) + private int hour; + + @Column(name = "request_count", nullable = false) + private long requestCount; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + @PreUpdate + public void touch() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public LocalDate getMetricDate() { + return metricDate; + } + + public void setMetricDate(LocalDate metricDate) { + this.metricDate = metricDate; + } + + public int getHour() { + return hour; + } + + public void setHour(int hour) { + this.hour = hour; + } + + public long getRequestCount() { + return requestCount; + } + + public void setRequestCount(long requestCount) { + this.requestCount = requestCount; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointRepository.java b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointRepository.java new file mode 100644 index 0000000..fc02296 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminRequestTimelinePointRepository.java @@ -0,0 +1,24 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AdminRequestTimelinePointRepository extends JpaRepository { + + List findAllByMetricDateOrderByHourAsc(LocalDate metricDate); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select point from AdminRequestTimelinePointEntity point + where point.metricDate = :metricDate and point.hour = :hour + """) + Optional findByMetricDateAndHourForUpdate(@Param("metricDate") LocalDate metricDate, + @Param("hour") int hour); +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index 6732c77..7c6dff7 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -12,6 +12,7 @@ import com.yoyuzh.common.PageResponse; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; +import com.yoyuzh.transfer.OfflineTransferSessionRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -21,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -34,12 +36,22 @@ public class AdminService { private final PasswordEncoder passwordEncoder; private final RefreshTokenService refreshTokenService; private final RegistrationInviteService registrationInviteService; + private final OfflineTransferSessionRepository offlineTransferSessionRepository; + private final AdminMetricsService adminMetricsService; private final SecureRandom secureRandom = new SecureRandom(); public AdminSummaryResponse getSummary() { + AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot(); return new AdminSummaryResponse( userRepository.count(), storedFileRepository.count(), + storedFileRepository.sumAllFileSize(), + metrics.downloadTrafficBytes(), + metrics.requestCount(), + metrics.transferUsageBytes(), + offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()), + metrics.offlineTransferStorageLimitBytes(), + metrics.requestTimeline(), registrationInviteService.getCurrentInviteCode() ); } @@ -94,7 +106,7 @@ public class AdminService { @Transactional public AdminUserResponse updateUserPassword(Long userId, String newPassword) { if (!PasswordPolicy.isStrong(newPassword)) { - throw new BusinessException(ErrorCode.UNKNOWN, "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"); + throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE); } User user = getRequiredUser(userId); user.setPasswordHash(passwordEncoder.encode(newPassword)); @@ -124,7 +136,13 @@ public class AdminService { return new AdminPasswordResetResponse(temporaryPassword); } + @Transactional + public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) { + return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes); + } + private AdminUserResponse toUserResponse(User user) { + long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); return new AdminUserResponse( user.getId(), user.getUsername(), @@ -133,6 +151,7 @@ public class AdminService { user.getCreatedAt(), user.getRole(), user.isBanned(), + usedStorageBytes, user.getStorageQuotaBytes(), user.getMaxUploadSizeBytes() ); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java index 05ddfbf..1e19844 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java @@ -1,8 +1,17 @@ package com.yoyuzh.admin; +import java.util.List; + public record AdminSummaryResponse( long totalUsers, long totalFiles, + long totalStorageBytes, + long downloadTrafficBytes, + long requestCount, + long transferUsageBytes, + long offlineTransferStorageBytes, + long offlineTransferStorageLimitBytes, + List requestTimeline, String inviteCode ) { } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java index e2c076b..13a1ee9 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java @@ -7,11 +7,11 @@ import jakarta.validation.constraints.Size; public record AdminUserPasswordUpdateRequest( @NotBlank - @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE) String newPassword ) { - @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE) public boolean isPasswordStrong() { return PasswordPolicy.isStrong(newPassword); } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java index 5ccb69b..5aad99a 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java @@ -12,6 +12,7 @@ public record AdminUserResponse( LocalDateTime createdAt, UserRole role, boolean banned, + long usedStorageBytes, long storageQuotaBytes, long maxUploadSizeBytes ) { diff --git a/backend/src/main/java/com/yoyuzh/admin/ApiRequestMetricsFilter.java b/backend/src/main/java/com/yoyuzh/admin/ApiRequestMetricsFilter.java new file mode 100644 index 0000000..e4da106 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/ApiRequestMetricsFilter.java @@ -0,0 +1,33 @@ +package com.yoyuzh.admin; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class ApiRequestMetricsFilter extends OncePerRequestFilter { + + private final AdminMetricsService adminMetricsService; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return HttpMethod.OPTIONS.matches(request.getMethod()) || path == null || !path.startsWith("/api/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + adminMetricsService.incrementRequestCount(); + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java b/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java index 6088c95..2aeac64 100644 --- a/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java +++ b/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java @@ -1,33 +1,26 @@ package com.yoyuzh.auth; public final class PasswordPolicy { + public static final int MIN_LENGTH = 8; + public static final String VALIDATION_MESSAGE = "密码至少8位,且必须包含大写字母"; private PasswordPolicy() { } public static boolean isStrong(String password) { - if (password == null || password.length() < 10) { + if (password == null || password.length() < MIN_LENGTH) { return false; } - boolean hasLower = false; boolean hasUpper = false; - boolean hasDigit = false; - boolean hasSpecial = false; for (int i = 0; i < password.length(); i += 1) { char c = password.charAt(i); - if (Character.isLowerCase(c)) { - hasLower = true; - } else if (Character.isUpperCase(c)) { + if (Character.isUpperCase(c)) { hasUpper = true; - } else if (Character.isDigit(c)) { - hasDigit = true; - } else { - hasSpecial = true; } } - return hasLower && hasUpper && hasDigit && hasSpecial; + return hasUpper; } } diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java index a026e96..0f49b59 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java @@ -13,12 +13,12 @@ public record RegisterRequest( @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号") String phoneNumber, - @NotBlank @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") String password, + @NotBlank @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE) String password, @NotBlank String confirmPassword, @NotBlank(message = "请输入邀请码") String inviteCode ) { - @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE) public boolean isPasswordStrong() { return PasswordPolicy.isStrong(password); } diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java index e2a3127..6355e07 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java @@ -8,11 +8,11 @@ import jakarta.validation.constraints.Size; public record UpdateUserPasswordRequest( @NotBlank String currentPassword, @NotBlank - @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE) String newPassword ) { - @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE) public boolean isPasswordStrong() { return PasswordPolicy.isStrong(newPassword); } diff --git a/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java b/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java index d792031..99f4617 100644 --- a/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java +++ b/backend/src/main/java/com/yoyuzh/config/FileStorageConfiguration.java @@ -2,7 +2,7 @@ package com.yoyuzh.config; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.LocalFileContentStorage; -import com.yoyuzh.files.storage.OssFileContentStorage; +import com.yoyuzh.files.storage.S3FileContentStorage; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,8 +11,8 @@ public class FileStorageConfiguration { @Bean public FileContentStorage fileContentStorage(FileStorageProperties properties) { - if ("oss".equalsIgnoreCase(properties.getProvider())) { - return new OssFileContentStorage(properties); + if ("s3".equalsIgnoreCase(properties.getProvider())) { + return new S3FileContentStorage(properties); } return new LocalFileContentStorage(properties); } diff --git a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java index 2eaa061..8395242 100644 --- a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java +++ b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java @@ -7,7 +7,7 @@ public class FileStorageProperties { private String provider = "local"; private final Local local = new Local(); - private final Oss oss = new Oss(); + private final S3 s3 = new S3(); private long maxFileSize = 500L * 1024 * 1024L; public String getProvider() { @@ -22,8 +22,8 @@ public class FileStorageProperties { return local; } - public Oss getOss() { - return oss; + public S3 getS3() { + return s3; } public long getMaxFileSize() { @@ -55,60 +55,60 @@ public class FileStorageProperties { } } - public static class Oss { - private String endpoint; - private String bucket; - private String accessKeyId; - private String accessKeySecret; - private String publicBaseUrl; - private boolean privateBucket = true; + public static class S3 { + private String apiBaseUrl = "https://api.dogecloud.com"; + private String apiAccessKey; + private String apiSecretKey; + private String scope; + private int ttlSeconds = 3600; + private String region = "automatic"; - public String getEndpoint() { - return endpoint; + public String getApiBaseUrl() { + return apiBaseUrl; } - public void setEndpoint(String endpoint) { - this.endpoint = endpoint; + public void setApiBaseUrl(String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; } - public String getBucket() { - return bucket; + public String getApiAccessKey() { + return apiAccessKey; } - public void setBucket(String bucket) { - this.bucket = bucket; + public void setApiAccessKey(String apiAccessKey) { + this.apiAccessKey = apiAccessKey; } - public String getAccessKeyId() { - return accessKeyId; + public String getApiSecretKey() { + return apiSecretKey; } - public void setAccessKeyId(String accessKeyId) { - this.accessKeyId = accessKeyId; + public void setApiSecretKey(String apiSecretKey) { + this.apiSecretKey = apiSecretKey; } - public String getAccessKeySecret() { - return accessKeySecret; + public String getScope() { + return scope; } - public void setAccessKeySecret(String accessKeySecret) { - this.accessKeySecret = accessKeySecret; + public void setScope(String scope) { + this.scope = scope; } - public String getPublicBaseUrl() { - return publicBaseUrl; + public int getTtlSeconds() { + return ttlSeconds; } - public void setPublicBaseUrl(String publicBaseUrl) { - this.publicBaseUrl = publicBaseUrl; + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; } - public boolean isPrivateBucket() { - return privateBucket; + public String getRegion() { + return region; } - public void setPrivateBucket(boolean privateBucket) { - this.privateBucket = privateBucket; + public void setRegion(String region) { + this.region = region; } } } diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index a78afa6..a9b1f00 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.yoyuzh.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.admin.ApiRequestMetricsFilter; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.common.ApiResponse; import com.yoyuzh.common.ErrorCode; @@ -35,6 +36,7 @@ import java.util.List; public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ApiRequestMetricsFilter apiRequestMetricsFilter; private final CustomUserDetailsService userDetailsService; private final ObjectMapper objectMapper; private final CorsProperties corsProperties; @@ -72,6 +74,7 @@ public class SecurityConfig { objectMapper.writeValue(response.getWriter(), ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足")); })) + .addFilterBefore(apiRequestMetricsFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index a644270..82edd00 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -1,5 +1,6 @@ package com.yoyuzh.files; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; @@ -38,15 +39,18 @@ public class FileService { private final StoredFileRepository storedFileRepository; private final FileContentStorage fileContentStorage; private final FileShareLinkRepository fileShareLinkRepository; + private final AdminMetricsService adminMetricsService; private final long maxFileSize; public FileService(StoredFileRepository storedFileRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, + AdminMetricsService adminMetricsService, FileStorageProperties properties) { this.storedFileRepository = storedFileRepository; this.fileContentStorage = fileContentStorage; this.fileShareLinkRepository = fileShareLinkRepository; + this.adminMetricsService = adminMetricsService; this.maxFileSize = properties.getMaxFileSize(); } @@ -346,6 +350,7 @@ public class FileService { if (storedFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); } + adminMetricsService.recordDownloadTraffic(storedFile.getSize()); if (fileContentStorage.supportsDirectDownload()) { return new DownloadUrlResponse(fileContentStorage.createDownloadUrl( diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index 3b84eb3..8a0f6b6 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -71,5 +71,12 @@ public interface StoredFileRepository extends JpaRepository { """) long sumFileSizeByUserId(@Param("userId") Long userId); + @Query(""" + select coalesce(sum(f.size), 0) + from StoredFile f + where f.directory = false + """) + long sumAllFileSize(); + List findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId); } diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java index e72850a..d699cb0 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java @@ -35,4 +35,12 @@ public interface OfflineTransferSessionRepository extends JpaRepository findAllExpiredWithFiles(@Param("now") Instant now); + + @Query(""" + select coalesce(sum(file.size), 0) + from OfflineTransferFile file + join file.session session + where file.uploaded = true and session.expiresAt >= :now + """) + long sumUploadedFileSizeByExpiresAtAfter(@Param("now") Instant now); } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java index 44e4dfa..9694a05 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -1,5 +1,6 @@ package com.yoyuzh.transfer; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; @@ -36,23 +37,27 @@ public class TransferService { private final OfflineTransferSessionRepository offlineTransferSessionRepository; private final FileContentStorage fileContentStorage; private final FileService fileService; + private final AdminMetricsService adminMetricsService; private final long maxFileSize; public TransferService(TransferSessionStore sessionStore, OfflineTransferSessionRepository offlineTransferSessionRepository, FileContentStorage fileContentStorage, FileService fileService, + AdminMetricsService adminMetricsService, FileStorageProperties properties) { this.sessionStore = sessionStore; this.offlineTransferSessionRepository = offlineTransferSessionRepository; this.fileContentStorage = fileContentStorage; this.fileService = fileService; + this.adminMetricsService = adminMetricsService; this.maxFileSize = properties.getMaxFileSize(); } @Transactional public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) { pruneExpiredSessions(); + adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum()); if (request.mode() == TransferMode.OFFLINE) { return createOfflineSession(sender, request); } @@ -104,6 +109,11 @@ public class TransferService { if (multipartFile.getSize() != targetFile.getSize()) { throw new BusinessException(ErrorCode.UNKNOWN, "离线文件大小与会话清单不一致"); } + long currentOfflineStorageBytes = offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()); + long additionalBytes = targetFile.isUploaded() ? 0L : targetFile.getSize(); + if (currentOfflineStorageBytes + additionalBytes > adminMetricsService.getOfflineTransferStorageLimitBytes()) { + throw new BusinessException(ErrorCode.UNKNOWN, "离线快传存储空间不足,请联系管理员调整上限"); + } try { fileContentStorage.storeTransferFile( @@ -150,6 +160,7 @@ public class TransferService { OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); OfflineTransferFile file = getRequiredOfflineFile(session, fileId); ensureOfflineFileUploaded(file); + adminMetricsService.recordDownloadTraffic(file.getSize()); if (fileContentStorage.supportsDirectDownload()) { String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename()); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 2596609..ef6c77a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -32,8 +32,17 @@ app: registration: invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:} storage: - root-dir: ./storage - max-file-size: 524288000 + provider: ${YOYUZH_STORAGE_PROVIDER:local} + max-file-size: ${YOYUZH_STORAGE_MAX_FILE_SIZE:524288000} + local: + root-dir: ${YOYUZH_STORAGE_LOCAL_ROOT_DIR:./storage} + s3: + api-base-url: ${YOYUZH_DOGECLOUD_API_BASE_URL:https://api.dogecloud.com} + api-access-key: ${YOYUZH_DOGECLOUD_API_ACCESS_KEY:} + api-secret-key: ${YOYUZH_DOGECLOUD_API_SECRET_KEY:} + scope: ${YOYUZH_DOGECLOUD_STORAGE_SCOPE:} + ttl-seconds: ${YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS:3600} + region: ${YOYUZH_DOGECLOUD_S3_REGION:automatic} cors: allowed-origins: - http://localhost:3000 diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 9017ec4..4d970e6 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -1,25 +1,31 @@ package com.yoyuzh.admin; import com.yoyuzh.PortalBackendApplication; +import com.yoyuzh.admin.AdminMetricsStateRepository; import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; +import com.yoyuzh.transfer.OfflineTransferSessionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; +import java.time.LocalTime; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -44,9 +50,15 @@ class AdminControllerIntegrationTest { @Autowired private UserRepository userRepository; + @Autowired + private PasswordEncoder passwordEncoder; @Autowired private StoredFileRepository storedFileRepository; + @Autowired + private OfflineTransferSessionRepository offlineTransferSessionRepository; + @Autowired + private AdminMetricsStateRepository adminMetricsStateRepository; private User portalUser; private User secondaryUser; @@ -55,14 +67,16 @@ class AdminControllerIntegrationTest { @BeforeEach void setUp() { + offlineTransferSessionRepository.deleteAll(); storedFileRepository.deleteAll(); userRepository.deleteAll(); + adminMetricsStateRepository.deleteAll(); portalUser = new User(); portalUser.setUsername("alice"); portalUser.setEmail("alice@example.com"); portalUser.setPhoneNumber("13800138000"); - portalUser.setPasswordHash("encoded-password"); + portalUser.setPasswordHash(passwordEncoder.encode("OriginalA")); portalUser.setCreatedAt(LocalDateTime.now()); portalUser = userRepository.save(portalUser); @@ -70,7 +84,7 @@ class AdminControllerIntegrationTest { secondaryUser.setUsername("bob"); secondaryUser.setEmail("bob@example.com"); secondaryUser.setPhoneNumber("13900139000"); - secondaryUser.setPasswordHash("encoded-password"); + secondaryUser.setPasswordHash(passwordEncoder.encode("OriginalB")); secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1)); secondaryUser = userRepository.save(secondaryUser); @@ -100,6 +114,8 @@ class AdminControllerIntegrationTest { @Test @WithMockUser(username = "admin") void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception { + int currentHour = LocalTime.now().getHour(); + mockMvc.perform(get("/api/admin/users?page=0&size=10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) @@ -107,6 +123,7 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000")) .andExpect(jsonPath("$.data.items[0].role").value("USER")) .andExpect(jsonPath("$.data.items[0].banned").value(false)) + .andExpect(jsonPath("$.data.items[0].usedStorageBytes").value(1024L)) .andExpect(jsonPath("$.data.items[0].storageQuotaBytes").isNumber()) .andExpect(jsonPath("$.data.items[0].maxUploadSizeBytes").isNumber()); @@ -114,6 +131,16 @@ class AdminControllerIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.totalUsers").value(2)) .andExpect(jsonPath("$.data.totalFiles").value(2)) + .andExpect(jsonPath("$.data.totalStorageBytes").value(1280L)) + .andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L)) + .andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.data.requestTimeline.length()").value(24)) + .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].hour").value(currentHour)) + .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour))) + .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.data.transferUsageBytes").value(0L)) + .andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L)) + .andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber()) .andExpect(jsonPath("$.data.inviteCode").isNotEmpty()); } @@ -150,7 +177,7 @@ class AdminControllerIntegrationTest { mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId()) .contentType("application/json") .content(""" - {"newPassword":"AdminSetPass1!"} + {"newPassword":"AdminPass"} """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(portalUser.getId())); @@ -173,11 +200,81 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.data.id").value(portalUser.getId())) .andExpect(jsonPath("$.data.maxUploadSizeBytes").value(10485760L)); + mockMvc.perform(patch("/api/admin/settings/offline-transfer-storage-limit") + .contentType("application/json") + .content(""" + {"offlineTransferStorageLimitBytes":2147483648} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").value(2147483648L)); + mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty()); } + @Test + @WithMockUser(username = "admin") + void shouldInvalidateOldPasswordAfterAdminPasswordUpdate() throws Exception { + mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId()) + .contentType("application/json") + .content(""" + {"newPassword":"AdminPass"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(portalUser.getId())); + + mockMvc.perform(post("/api/auth/login") + .contentType("application/json") + .content(""" + { + "username": "alice", + "password": "OriginalA" + } + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.msg").value("用户名或密码错误")); + + mockMvc.perform(post("/api/auth/login") + .contentType("application/json") + .content(""" + { + "username": "alice", + "password": "AdminPass" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.username").value("alice")); + } + + @Test + void shouldExposeTrafficAndTransferMetricsInSummary() throws Exception { + mockMvc.perform(get("/api/files/download/{fileId}/url", storedFile.getId()) + .with(user("alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.url").value("/api/files/download/" + storedFile.getId())); + + mockMvc.perform(post("/api/transfer/sessions") + .with(user("alice")) + .contentType("application/json") + .content(""" + { + "mode": "OFFLINE", + "files": [ + {"name": "offline.txt", "relativePath": "资料/offline.txt", "size": 13, "contentType": "text/plain"} + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("OFFLINE")); + + mockMvc.perform(get("/api/admin/summary").with(user("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.downloadTrafficBytes").value(1024L)) + .andExpect(jsonPath("$.data.transferUsageBytes").value(13L)) + .andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(2))); + } + @Test @WithMockUser(username = "admin") void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception { diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java new file mode 100644 index 0000000..bb5c471 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java @@ -0,0 +1,79 @@ +package com.yoyuzh.admin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminMetricsServiceTest { + + @Mock + private AdminMetricsStateRepository adminMetricsStateRepository; + @Mock + private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository; + + private AdminMetricsService adminMetricsService; + + @BeforeEach + void setUp() { + adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository); + } + + @Test + void shouldResetDailyRequestCountWhenSnapshotReadsPreviousDayState() { + AdminMetricsState state = new AdminMetricsState(); + state.setId(1L); + state.setRequestCount(42L); + state.setRequestCountDate(LocalDate.now().minusDays(1)); + state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024); + + when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state)); + when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of()); + + AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot(); + + assertThat(snapshot.requestCount()).isZero(); + assertThat(state.getRequestCount()).isZero(); + assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now()); + assertThat(snapshot.requestTimeline()).hasSize(24); + assertThat(snapshot.requestTimeline().get(0)).isEqualTo(new AdminRequestTimelinePoint(0, "00:00", 0L)); + verify(adminMetricsStateRepository).save(state); + } + + @Test + void shouldStartNewDayRequestCountAtOneWhenIncrementingPreviousDayState() { + AdminMetricsState state = new AdminMetricsState(); + state.setId(1L); + state.setRequestCount(42L); + state.setRequestCountDate(LocalDate.now().minusDays(1)); + state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024); + + when(adminMetricsStateRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(state)); + when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(adminRequestTimelinePointRepository.findByMetricDateAndHourForUpdate(LocalDate.now(), LocalTime.now().getHour())) + .thenReturn(Optional.empty()); + when(adminRequestTimelinePointRepository.save(any(AdminRequestTimelinePointEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(adminRequestTimelinePointRepository.saveAndFlush(any(AdminRequestTimelinePointEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + adminMetricsService.incrementRequestCount(); + + assertThat(state.getRequestCount()).isEqualTo(1L); + assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now()); + verify(adminMetricsStateRepository).save(state); + verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class)); + } +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java index f84f2ff..a855a71 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java @@ -11,6 +11,7 @@ import com.yoyuzh.common.PageResponse; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; +import com.yoyuzh.transfer.OfflineTransferSessionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,6 +49,10 @@ class AdminServiceTest { private RefreshTokenService refreshTokenService; @Mock private RegistrationInviteService registrationInviteService; + @Mock + private OfflineTransferSessionRepository offlineTransferSessionRepository; + @Mock + private AdminMetricsService adminMetricsService; private AdminService adminService; @@ -55,7 +60,8 @@ class AdminServiceTest { void setUp() { adminService = new AdminService( userRepository, storedFileRepository, fileService, - passwordEncoder, refreshTokenService, registrationInviteService); + passwordEncoder, refreshTokenService, registrationInviteService, + offlineTransferSessionRepository, adminMetricsService); } // --- getSummary --- @@ -64,12 +70,34 @@ class AdminServiceTest { void shouldReturnSummaryWithCountsAndInviteCode() { when(userRepository.count()).thenReturn(5L); when(storedFileRepository.count()).thenReturn(42L); + when(storedFileRepository.sumAllFileSize()).thenReturn(8192L); + when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot( + 0L, + 0L, + 0L, + 20L * 1024 * 1024 * 1024, + List.of( + new AdminRequestTimelinePoint(0, "00:00", 0L), + new AdminRequestTimelinePoint(1, "01:00", 3L) + ) + )); + when(offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(any())).thenReturn(0L); when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001"); AdminSummaryResponse summary = adminService.getSummary(); assertThat(summary.totalUsers()).isEqualTo(5L); assertThat(summary.totalFiles()).isEqualTo(42L); + assertThat(summary.totalStorageBytes()).isEqualTo(8192L); + assertThat(summary.downloadTrafficBytes()).isZero(); + assertThat(summary.requestCount()).isZero(); + assertThat(summary.transferUsageBytes()).isZero(); + assertThat(summary.offlineTransferStorageBytes()).isZero(); + assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L); + assertThat(summary.requestTimeline()).containsExactly( + new AdminRequestTimelinePoint(0, "00:00", 0L), + new AdminRequestTimelinePoint(1, "01:00", 3L) + ); assertThat(summary.inviteCode()).isEqualTo("INV-001"); } @@ -80,11 +108,13 @@ class AdminServiceTest { User user = createUser(1L, "alice", "alice@example.com"); when(userRepository.searchByUsernameOrEmail(anyString(), any())) .thenReturn(new PageImpl<>(List.of(user))); + when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L); PageResponse response = adminService.listUsers(0, 10, "alice"); assertThat(response.items()).hasSize(1); assertThat(response.items().get(0).username()).isEqualTo("alice"); + assertThat(response.items().get(0).usedStorageBytes()).isEqualTo(2048L); } @Test @@ -205,7 +235,7 @@ class AdminServiceTest { void shouldRejectWeakPasswordWhenUpdating() { assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass")) .isInstanceOf(BusinessException.class) - .hasMessageContaining("密码至少10位"); + .hasMessageContaining("密码至少8位"); verify(userRepository, never()).findById(any()); } diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java index 0e39639..94e1010 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java @@ -53,7 +53,7 @@ class AuthControllerValidationTest { """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(1000)) - .andExpect(jsonPath("$.msg").value("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")); + .andExpect(jsonPath("$.msg").value("密码至少8位,且必须包含大写字母")); } @Test diff --git a/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java b/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java index afee6c9..369552d 100644 --- a/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java @@ -14,49 +14,34 @@ class PasswordPolicyTest { } @Test - void shouldRejectPasswordShorterThanTenCharacters() { - assertThat(PasswordPolicy.isStrong("Abc1!defg")).isFalse(); // 9 chars + void shouldRejectPasswordShorterThanEightCharacters() { + assertThat(PasswordPolicy.isStrong("Abcdefg")).isFalse(); // 7 chars } @Test - void shouldAcceptPasswordWithExactlyTenCharacters() { - assertThat(PasswordPolicy.isStrong("Abcdefg1!x")).isTrue(); // 10 chars + void shouldAcceptPasswordWithExactlyEightCharacters() { + assertThat(PasswordPolicy.isStrong("Abcdefgh")).isTrue(); // 8 chars } @Test void shouldRejectPasswordMissingUppercase() { - assertThat(PasswordPolicy.isStrong("abcdefg1!x")).isFalse(); + assertThat(PasswordPolicy.isStrong("abcdefgh")).isFalse(); } @Test - void shouldRejectPasswordMissingLowercase() { - assertThat(PasswordPolicy.isStrong("ABCDEFG1!X")).isFalse(); - } - - @Test - void shouldRejectPasswordMissingDigit() { - assertThat(PasswordPolicy.isStrong("Abcdefgh!x")).isFalse(); - } - - @Test - void shouldRejectPasswordMissingSpecialCharacter() { - assertThat(PasswordPolicy.isStrong("Abcdefg12x")).isFalse(); - } - - @Test - void shouldAcceptStrongPasswordWithAllRequirements() { - assertThat(PasswordPolicy.isStrong("MyP@ssw0rd!")).isTrue(); + void shouldAcceptPasswordThatOnlyNeedsUppercaseAndLength() { + assertThat(PasswordPolicy.isStrong("ABCDEFGH")).isTrue(); } @ParameterizedTest - @ValueSource(strings = {"", "short", "nouppercase1!", "NOLOWERCASE1!", "NoSpecialChar1", "NoDigit!AbcXyz"}) + @ValueSource(strings = {"", "short", "noupper", "abcdefghi"}) void shouldRejectWeakPasswords(String password) { assertThat(PasswordPolicy.isStrong(password)).isFalse(); } @Test - void shouldAcceptLongPasswordWithAllRequirements() { - assertThat(PasswordPolicy.isStrong("MyV3ryStr0ng&SecureP@ssword2024!")).isTrue(); + void shouldAcceptLongPasswordWithUppercase() { + assertThat(PasswordPolicy.isStrong("MyVerySimplePassword")).isTrue(); } @Test diff --git a/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java b/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java index baf30dc..9bec097 100644 --- a/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java @@ -26,7 +26,7 @@ class RegisterRequestValidationTest { assertThat(violations) .extracting(violation -> violation.getMessage()) - .contains("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"); + .contains("密码至少8位,且必须包含大写字母"); } @Test @@ -35,8 +35,8 @@ class RegisterRequestValidationTest { "alice", "alice@example.com", "13800138000", - "StrongPass1!", - "StrongPass1!", + "Abcdefgh", + "Abcdefgh", "invite-code" ); diff --git a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java index b6cbc92..723905a 100644 --- a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java +++ b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java @@ -1,6 +1,7 @@ package com.yoyuzh.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yoyuzh.admin.ApiRequestMetricsFilter; import org.junit.jupiter.api.Test; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -23,6 +24,7 @@ class SecurityConfigTest { corsProperties.setAllowedOrigins(java.util.List.of("https://yoyuzh.xyz")); SecurityConfig securityConfig = new SecurityConfig( + null, null, null, new ObjectMapper(), diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java index 4c170c0..a612837 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java @@ -1,5 +1,6 @@ package com.yoyuzh.files; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.config.FileStorageProperties; @@ -34,6 +35,8 @@ class FileServiceEdgeCaseTest { private FileContentStorage fileContentStorage; @Mock private FileShareLinkRepository fileShareLinkRepository; + @Mock + private AdminMetricsService adminMetricsService; private FileService fileService; @@ -41,7 +44,7 @@ class FileServiceEdgeCaseTest { void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); - fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); + fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties); } // --- normalizeDirectoryPath edge cases --- diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index ce34fb5..c7024ba 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -1,5 +1,6 @@ package com.yoyuzh.files; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.config.FileStorageProperties; @@ -46,6 +47,8 @@ class FileServiceTest { @Mock private FileShareLinkRepository fileShareLinkRepository; + @Mock + private AdminMetricsService adminMetricsService; private FileService fileService; @@ -53,7 +56,7 @@ class FileServiceTest { void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); - fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); + fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties); } @Test diff --git a/docs/architecture.md b/docs/architecture.md index df95d89..3662fdc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ 1. React 前端站点 2. Spring Boot 后端 API -3. 文件存储层(本地文件系统或 OSS) +3. 文件存储层(本地文件系统或 S3 兼容对象存储) 业务主线已经从旧教务方向切换为: @@ -61,7 +61,7 @@ - 网盘元数据与文件流转 - 快传信令与会话状态 - 管理台 API -- OSS / 本地存储抽象 +- S3 兼容对象存储 / 本地存储抽象 后端包结构: @@ -79,7 +79,7 @@ ### 2.3 文档与脚本 - `docs/`: 实现计划与补充文档 -- `scripts/`: 前端 OSS 发布、存储迁移和本地辅助脚本 +- `scripts/`: 前端静态站发布、对象存储迁移和本地辅助脚本 ## 3. 模块划分 @@ -97,6 +97,7 @@ - 注册、登录、刷新登录态 - 用户资料查询和修改 +- 用户自行修改密码 - 头像上传 - 单设备登录控制 - 邀请码消费与轮换 @@ -107,6 +108,7 @@ - refresh token 持久化到数据库 - 当前会话通过 `activeSessionId + JWT sid claim` 绑定 - 新登录会挤掉旧设备 +- 当前密码策略统一为“至少 8 位且包含大写字母” ### 3.2 网盘模块 @@ -129,8 +131,8 @@ - 文件元数据在数据库 - 文件内容走存储层抽象 -- 支持本地磁盘和 OSS -- 当前线上网盘文件存储已切到阿里云 OSS 成都地域桶 `yoyuzh-files2` +- 支持本地磁盘和 S3 兼容对象存储 +- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 - 前端会缓存目录列表和最后访问路径 ### 3.3 快传模块 @@ -160,7 +162,7 @@ - 接收端支持部分文件选择 - 多文件或文件夹可走 ZIP 下载 - 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 -- 离线快传会把文件内容落到站点存储,线上环境使用 OSS,默认保留 7 天并支持重复接收 +- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收 ### 3.4 管理台模块 @@ -175,11 +177,15 @@ - 管理用户 - 管理文件 - 查看邀请码 +- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图 +- 调整离线快传总上限 关键实现说明: - 管理台依赖后端 summary/users/files 接口 - 当前邀请码由后端返回给管理台展示 +- 用户列表会展示每个用户的已用空间 / 配额 +- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录 ## 4. 关键业务流程 @@ -210,7 +216,7 @@ 1. 前端在 `Files` 页面选择文件或文件夹 2. 前端优先调用 `/api/files/upload/initiate` -3. 如果存储支持直传,则浏览器直接上传到 OSS +3. 如果存储支持直传,则浏览器直接上传到对象存储 4. 前端再调用 `/api/files/upload/complete` 5. 如果直传失败,会回退到代理上传接口 `/api/files/upload` @@ -240,6 +246,14 @@ 6. 接收端可直接下载离线文件,也可登录后存入网盘 7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁 +### 4.7 管理员改密流程 + +1. 管理台调用 `PUT /api/admin/users/{userId}/password` +2. 后端按统一密码规则校验新密码 +3. 后端重算密码哈希并写回用户表 +4. 后端刷新 `activeSessionId` 并撤销该用户全部 refresh token +5. 旧密码后续登录应失败,新密码登录成功 + ## 5. 前端路由架构 路由入口: @@ -290,7 +304,7 @@ 实现方向: - 本地文件系统 -- OSS +- S3 兼容对象存储 设计目的: @@ -299,17 +313,17 @@ 当前线上状态: -- 生产环境文件桶已从东京地域迁到成都地域 `yoyuzh-files2` -- 生产后端当前使用 `https://oss-cn-chengdu.aliyuncs.com` 作为 OSS endpoint -- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连 OSS 下载”的主链路 -- 2026-03-24 已对抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶执行 HEAD 校验并返回 200 +- 生产环境文件桶已切到多吉云对象存储 +- 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken` +- 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址 +- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连对象存储下载”的主链路 ## 8. 部署架构 ### 8.1 前端 - 构建工具:Vite -- 发布方式:OSS 静态资源发布 +- 发布方式:对象存储静态站发布 - 发布脚本:`node scripts/deploy-front-oss.mjs` ### 8.2 后端 @@ -324,7 +338,7 @@ - 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar` - 额外配置文件:`/opt/yoyuzh/application-prod.yml` - 环境变量文件:`/opt/yoyuzh/app.env` -- 2026-03-24 已把生产后端 OSS 配置切换到成都新桶 `yoyuzh-files2` +- 2026-04-02 已重新部署,服务状态为 `active (running)` ## 9. 开发注意事项 diff --git a/front/src/App.tsx b/front/src/App.tsx index 302d899..7a5b661 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -8,6 +8,7 @@ import Files from './pages/Files'; import Transfer from './pages/Transfer'; import FileShare from './pages/FileShare'; import Games from './pages/Games'; +import GamePlayer from './pages/GamePlayer'; import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share'; import { getTransferRouterMode, @@ -58,6 +59,7 @@ function AppRoutes() { } /> } /> } /> + } /> { assert.deepEqual( getInviteCodePanelState({ totalUsers: 12, totalFiles: 34, + totalStorageBytes: 0, + downloadTrafficBytes: 0, + requestCount: 0, + transferUsageBytes: 0, + offlineTransferStorageBytes: 0, + offlineTransferStorageLimitBytes: 0, + requestTimeline: [], inviteCode: ' AbCd1234 ', }), { @@ -22,6 +34,13 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in getInviteCodePanelState({ totalUsers: 12, totalFiles: 34, + totalStorageBytes: 0, + downloadTrafficBytes: 0, + requestCount: 0, + transferUsageBytes: 0, + offlineTransferStorageBytes: 0, + offlineTransferStorageLimitBytes: 0, + requestTimeline: [], inviteCode: ' ', }), { @@ -30,3 +49,41 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in }, ); }); + +test('formatMetricValue formats byte metrics with binary units', () => { + assert.equal(formatMetricValue(1536, 'bytes'), '1.5 KB'); + assert.equal(formatMetricValue(50 * 1024 * 1024 * 1024, 'bytes'), '50 GB'); +}); + +test('formatMetricValue formats count metrics with locale separators', () => { + assert.equal(formatMetricValue(1234567, 'count'), '1,234,567'); +}); + +test('parseStorageLimitInput accepts common storage unit inputs', () => { + assert.equal(parseStorageLimitInput('20GB'), 20 * 1024 * 1024 * 1024); + assert.equal(parseStorageLimitInput('512 mb'), 512 * 1024 * 1024); +}); + +test('parseStorageLimitInput rejects invalid or non-positive inputs', () => { + assert.equal(parseStorageLimitInput('0GB'), null); + assert.equal(parseStorageLimitInput('abc'), null); +}); + +test('buildRequestLineChartModel converts hourly request data into chart coordinates', () => { + const model = buildRequestLineChartModel([ + { hour: 0, label: '00:00', requestCount: 0 }, + { hour: 1, label: '01:00', requestCount: 30 }, + { hour: 2, label: '02:00', requestCount: 60 }, + { hour: 3, label: '03:00', requestCount: 15 }, + ]); + + assert.equal(model.points.length, 4); + assert.equal(model.points[0]?.x, 0); + assert.equal(model.points[0]?.y, 100); + assert.equal(model.points[2]?.y, 0); + assert.equal(model.points[3]?.x, 100); + assert.equal(model.maxValue, 60); + assert.equal(model.linePath, 'M 0 100 L 33.333 50 L 66.667 0 L 100 75'); + assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]); + assert.equal(model.peakPoint?.label, '02:00'); +}); diff --git a/front/src/admin/dashboard-state.ts b/front/src/admin/dashboard-state.ts index d4be982..6d274a1 100644 --- a/front/src/admin/dashboard-state.ts +++ b/front/src/admin/dashboard-state.ts @@ -1,10 +1,108 @@ -import type { AdminSummary } from '@/src/lib/types'; +import type { AdminRequestTimelinePoint, AdminSummary } from '@/src/lib/types'; export interface InviteCodePanelState { inviteCode: string; canCopy: boolean; } +export interface RequestLineChartPoint extends AdminRequestTimelinePoint { + x: number; + y: number; +} + +export interface RequestLineChartModel { + points: RequestLineChartPoint[]; + linePath: string; + areaPath: string; + yAxisTicks: number[]; + maxValue: number; + peakPoint: RequestLineChartPoint | null; +} + +type MetricValueKind = 'bytes' | 'count'; + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + +export function formatMetricValue(value: number, kind: MetricValueKind): string { + if (kind === 'count') { + return new Intl.NumberFormat('en-US').format(value); + } + if (value <= 0) { + return '0 B'; + } + + const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1); + const unitValue = value / 1024 ** unitIndex; + const formatted = unitValue >= 10 || unitIndex === 0 ? unitValue.toFixed(0) : unitValue.toFixed(1); + return `${formatted} ${BYTE_UNITS[unitIndex]}`; +} + +export function parseStorageLimitInput(value: string): number | null { + const normalized = value.trim().toLowerCase(); + const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/); + if (!matched) { + return null; + } + + const amount = Number.parseFloat(matched[1] ?? '0'); + if (!Number.isFinite(amount) || amount <= 0) { + return null; + } + + const unit = matched[2] ?? 'b'; + const multiplier = unit === 'pb' + ? 1024 ** 5 + : unit === 'tb' + ? 1024 ** 4 + : unit === 'gb' + ? 1024 ** 3 + : unit === 'mb' + ? 1024 ** 2 + : unit === 'kb' + ? 1024 + : 1; + return Math.floor(amount * multiplier); +} + +export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]): RequestLineChartModel { + if (timeline.length === 0) { + return { + points: [], + linePath: '', + areaPath: '', + yAxisTicks: [0, 1, 2, 3, 4], + maxValue: 0, + peakPoint: null, + }; + } + + const maxValue = Math.max(...timeline.map((point) => point.requestCount), 0); + const scaleMax = maxValue > 0 ? maxValue : 1; + const lastIndex = Math.max(timeline.length - 1, 1); + const points = timeline.map((point, index) => ({ + ...point, + x: roundChartValue((index / lastIndex) * 100), + y: roundChartValue(100 - (point.requestCount / scaleMax) * 100), + })); + const linePath = points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${formatChartNumber(point.x)} ${formatChartNumber(point.y)}`) + .join(' '); + + return { + points, + linePath, + areaPath: linePath ? `${linePath} L 100 100 L 0 100 Z` : '', + yAxisTicks: buildYAxisTicks(maxValue), + maxValue, + peakPoint: points.reduce((peak, point) => { + if (!peak || point.requestCount > peak.requestCount) { + return point; + } + return peak; + }, null), + }; +} + export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState { const inviteCode = summary?.inviteCode?.trim() ?? ''; if (!inviteCode) { @@ -19,3 +117,19 @@ export function getInviteCodePanelState(summary: AdminSummary | null | undefined canCopy: true, }; } + +function buildYAxisTicks(maxValue: number): number[] { + if (maxValue <= 0) { + return [0, 1, 2, 3, 4]; + } + return Array.from({ length: 5 }, (_, index) => roundChartValue((maxValue / 4) * index)); +} + +function roundChartValue(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function formatChartNumber(value: number): string { + const rounded = roundChartValue(value); + return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); +} diff --git a/front/src/admin/dashboard.tsx b/front/src/admin/dashboard.tsx index b4715ca..f68729a 100644 --- a/front/src/admin/dashboard.tsx +++ b/front/src/admin/dashboard.tsx @@ -1,35 +1,409 @@ import { useEffect, useState } from 'react'; +import ArchiveRoundedIcon from '@mui/icons-material/ArchiveRounded'; +import BoltRoundedIcon from '@mui/icons-material/BoltRounded'; +import CloudDownloadRoundedIcon from '@mui/icons-material/CloudDownloadRounded'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import EditRoundedIcon from '@mui/icons-material/EditRounded'; +import FolderRoundedIcon from '@mui/icons-material/FolderRounded'; +import HubRoundedIcon from '@mui/icons-material/HubRounded'; import RefreshIcon from '@mui/icons-material/Refresh'; -import { Alert, Button, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material'; +import StorageRoundedIcon from '@mui/icons-material/StorageRounded'; +import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, Stack, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; import { apiRequest } from '@/src/lib/api'; import { readStoredSession } from '@/src/lib/session'; -import type { AdminSummary } from '@/src/lib/types'; -import { getInviteCodePanelState } from './dashboard-state'; +import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types'; +import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state'; interface DashboardState { summary: AdminSummary | null; } -const DASHBOARD_ITEMS = [ - { - title: '文件资源', - description: '已接入 /api/admin/files 与 /api/admin/files/{id} 删除接口,可查看全站文件元数据。', - status: 'connected', - }, - { - title: '用户管理', - description: '已接入 /api/admin/users,可查看账号、邮箱、手机号与权限状态。', - status: 'connected', - }, - { - title: '门户运营', - description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。', - status: 'connected', - }, -]; +interface MetricCardDefinition { + key: string; + title: string; + scope: string; + accent: string; + icon: React.ReactNode; + value: string; + helper: string; +} + +const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]); +const DASHBOARD_CARD_BG = '#111827'; +const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)'; +const DASHBOARD_CARD_TEXT = '#f8fafc'; +const DASHBOARD_CARD_MUTED_TEXT = 'rgba(226, 232, 240, 0.72)'; + +function DashboardMetricCard({ metric }: { metric: MetricCardDefinition }) { + return ( + ({ + borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider', + backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary, + boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none', + height: '100%', + position: 'relative', + overflow: 'hidden', + '&::before': { + content: '""', + position: 'absolute', + inset: '0 auto 0 0', + width: 4, + backgroundColor: metric.accent, + }, + })} + > + + + + + {metric.icon} + + + {metric.scope} + + + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + fontWeight: 700, + })} + > + {metric.title} + + ({ + fontWeight: 800, + lineHeight: 1.05, + letterSpacing: '-0.02em', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + {metric.value} + + + ({ + mt: 'auto', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {metric.helper} + + + + + ); +} + +function RequestTrendChart({ summary }: { summary: AdminSummary }) { + const chart = buildRequestLineChartModel(summary.requestTimeline); + const currentHour = new Date().getHours(); + const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null; + const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour)); + const hasRequests = chart.maxValue > 0; + const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4; + + return ( + ({ + borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider', + backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary, + boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none', + })} + > + + + + + + 今日请求折线图 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + 按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。 + + + + + theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.06)' : 'action.hover', + border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid transparent', + }} + > + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + fontWeight={700} + > + 当前小时 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + {formatMetricValue(currentPoint?.requestCount ?? 0, 'count')} + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {currentPoint?.label ?? '--'} + + + theme.palette.mode === 'dark' ? 'rgba(37, 99, 235, 0.14)' : '#eff6ff', + border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(96, 165, 250, 0.2)' : '1px solid transparent', + }} + > + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + fontWeight={700} + > + 今日峰值 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + {formatMetricValue(chart.peakPoint?.requestCount ?? 0, 'count')} + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {chart.peakPoint?.label ?? '--'} + + + + + + theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)', + background: (theme) => + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(15, 23, 42, 0.72) 0%, rgba(17, 24, 39, 0.94) 100%)' + : 'linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%)', + }} + > + + + {chart.yAxisTicks.slice().reverse().map((tick) => ( + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {formatMetricValue(tick, 'count')} + + ))} + + + + theme.palette.mode === 'dark' ? 'rgba(15, 23, 42, 0.58)' : 'rgba(255, 255, 255, 0.72)', + }} + > + + + + + + + + + {chart.yAxisTicks.map((tick) => { + const y = 100 - (tick / scaleMax) * 100; + return ( + + ); + })} + + {currentPoint && ( + + )} + + {chart.areaPath && } + {chart.linePath && ( + + )} + + {chart.points.map((point) => ( + + ))} + + + {!hasRequests && ( + theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(15, 23, 42, 0.82)' : 'rgba(248, 250, 252, 0.68)', + }} + > + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + 今日还没有请求数据 + + + 新请求进入后,这里会自动形成实时折线。 + + + )} + + + + {xAxisPoints.map((point) => ( + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {point.label} + + ))} + + + + + + + + ); +} export function PortalAdminDashboard() { const [state, setState] = useState({ @@ -37,7 +411,9 @@ export function PortalAdminDashboard() { }); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); const [copyMessage, setCopyMessage] = useState(''); + const [updatingLimit, setUpdatingLimit] = useState(false); const navigate = useNavigate(); const session = readStoredSession(); @@ -47,10 +423,7 @@ export function PortalAdminDashboard() { try { const summary = await apiRequest('/admin/summary'); - - setState({ - summary, - }); + setState({ summary }); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败'); } finally { @@ -59,44 +432,68 @@ export function PortalAdminDashboard() { } useEffect(() => { - let active = true; - - void (async () => { - setLoading(true); - setError(''); - - try { - const summary = await apiRequest('/admin/summary'); - - if (!active) { - return; - } - - setState({ - summary, - }); - } catch (requestError) { - if (!active) { - return; - } - - setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败'); - } finally { - if (active) { - setLoading(false); - } - } - })(); - - return () => { - active = false; - }; + void loadDashboardData(); }, []); const inviteCodePanel = getInviteCodePanelState(state.summary); + const summary = state.summary; + + const metrics: MetricCardDefinition[] = summary ? [ + { + key: 'total-storage', + title: '总存储量', + scope: '累计', + value: formatMetricValue(summary.totalStorageBytes, 'bytes'), + helper: `全站普通文件 ${formatMetricValue(summary.totalFiles, 'count')} 个。`, + accent: '#0f766e', + icon: , + }, + { + key: 'download-traffic', + title: '下载流量', + scope: '累计', + value: formatMetricValue(summary.downloadTrafficBytes, 'bytes'), + helper: '文件下载和离线快传下载都会计入这里。', + accent: '#2563eb', + icon: , + }, + { + key: 'request-count', + title: '今日请求次数', + scope: '今日', + value: formatMetricValue(summary.requestCount, 'count'), + helper: '只统计今天的 `/api/**` 请求,不再显示累计值。', + accent: '#d97706', + icon: , + }, + { + key: 'transfer-usage', + title: '快传使用量', + scope: '累计', + value: formatMetricValue(summary.transferUsageBytes, 'bytes'), + helper: '按快传会话申报的文件体积累计统计。', + accent: '#7c3aed', + icon: , + }, + { + key: 'offline-transfer-storage', + title: '快传离线存储量', + scope: '当前', + value: formatMetricValue(summary.offlineTransferStorageBytes, 'bytes'), + helper: `当前上限 ${formatMetricValue(summary.offlineTransferStorageLimitBytes, 'bytes')}。`, + accent: '#be123c', + icon: ( + + + + + ), + }, + ] : []; async function handleRefreshInviteCode() { setCopyMessage(''); + setSuccessMessage(''); await loadDashboardData(); } @@ -118,6 +515,42 @@ export function PortalAdminDashboard() { } } + async function handleUpdateOfflineTransferLimit() { + if (!summary) { + return; + } + + const input = window.prompt( + `请输入新的离线快传存储上限(支持 B/KB/MB/GB/TB,当前 ${formatMetricValue(summary.offlineTransferStorageLimitBytes, 'bytes')})`, + `${Math.max(1, Math.floor(summary.offlineTransferStorageLimitBytes / 1024 / 1024 / 1024))}GB`, + ); + if (!input) { + return; + } + + const offlineTransferStorageLimitBytes = parseStorageLimitInput(input); + if (!offlineTransferStorageLimitBytes) { + setError('输入格式不正确,请输入例如 20GB 或 21474836480'); + return; + } + + setUpdatingLimit(true); + setError(''); + setSuccessMessage(''); + try { + const result = await apiRequest('/admin/settings/offline-transfer-storage-limit', { + method: 'PATCH', + body: { offlineTransferStorageLimitBytes }, + }); + setSuccessMessage(`离线快传存储上限已更新为 ${formatMetricValue(result.offlineTransferStorageLimitBytes, 'bytes')}`); + await loadDashboardData(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : '离线快传存储上限更新失败'); + } finally { + setUpdatingLimit(false); + } + } + return ( @@ -126,7 +559,7 @@ export function PortalAdminDashboard() { YOYUZH Admin - 这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。 + 管理面板展示站点核心指标,并按小时画出今天的请求走势。 + + + + + + + ({ + borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider', + backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary, + boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none', + height: '100%', + })} + > + + + + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + 当前邀请码 + + + ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}> 注册成功一次后会自动刷新,后台展示的始终是下一次可用的邀请码。 ({ display: 'inline-block', width: 'fit-content', px: 1.5, py: 1, borderRadius: 1, - backgroundColor: 'action.hover', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'action.hover', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.95rem', - }} + })} > {inviteCodePanel.inviteCode} - + - {copyMessage && {copyMessage}} diff --git a/front/src/admin/users-list.tsx b/front/src/admin/users-list.tsx index 5eab505..c726a0d 100644 --- a/front/src/admin/users-list.tsx +++ b/front/src/admin/users-list.tsx @@ -59,6 +59,10 @@ function UsersListActions() { ); } +function formatStorageUsage(usedBytes: number, quotaBytes: number) { + return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`; +} + function AdminUserActions({ record }: { record: AdminUser }) { const notify = useNotify(); const refresh = useRefresh(); @@ -116,7 +120,7 @@ function AdminUserActions({ record }: { record: AdminUser }) { async function handleSetPassword() { const newPassword = window.prompt( - '请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。', + '请输入新密码。密码至少8位,且必须包含大写字母。', ); if (!newPassword) { return; @@ -215,20 +219,20 @@ function AdminUserActions({ record }: { record: AdminUser }) { } return ( - - - - - - + + + + +
+
+ +