Migrate storage to DogeCloud and expand admin dashboard
This commit is contained in:
@@ -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=""
|
||||
|
||||
30
.playwright-cli/page-2026-03-31T10-42-22-102Z.yml
Normal file
30
.playwright-cli/page-2026-03-31T10-42-22-102Z.yml
Normal file
@@ -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]
|
||||
30
.playwright-cli/page-2026-03-31T10-43-43-923Z.yml
Normal file
30
.playwright-cli/page-2026-03-31T10-43-43-923Z.yml
Normal file
@@ -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]
|
||||
30
.playwright-cli/page-2026-03-31T10-43-57-458Z.yml
Normal file
30
.playwright-cli/page-2026-03-31T10-43-57-458Z.yml
Normal file
@@ -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]
|
||||
30
.playwright-cli/page-2026-03-31T10-44-34-928Z.yml
Normal file
30
.playwright-cli/page-2026-03-31T10-44-34-928Z.yml
Normal file
@@ -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]
|
||||
1
.playwright-cli/page-2026-03-31T10-45-01-963Z.yml
Normal file
1
.playwright-cli/page-2026-03-31T10-45-01-963Z.yml
Normal file
@@ -0,0 +1 @@
|
||||
- generic [ref=e3]: 正在检查登录状态...
|
||||
@@ -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`.
|
||||
|
||||
53
README.md
53
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
|
||||
```
|
||||
|
||||
### 后端发布
|
||||
|
||||
先打包:
|
||||
|
||||
@@ -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 里没有对应文件”的历史数据,需要额外做一次对象迁移;否则旧记录在重命名/删除时仍可能失败。
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.17.4</version>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>2.31.66</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
|
||||
@@ -29,6 +29,14 @@ public class AdminController {
|
||||
return ApiResponse.success(adminService.getSummary());
|
||||
}
|
||||
|
||||
@PatchMapping("/settings/offline-transfer-storage-limit")
|
||||
public ApiResponse<AdminOfflineTransferStorageLimitResponse> updateOfflineTransferStorageLimit(
|
||||
@Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateOfflineTransferStorageLimit(
|
||||
request.offlineTransferStorageLimitBytes()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/users")
|
||||
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
|
||||
161
backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java
Normal file
161
backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java
Normal file
@@ -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<AdminRequestTimelinePoint> buildRequestTimeline(LocalDate metricDate) {
|
||||
Map<Integer, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminMetricsSnapshot(
|
||||
long requestCount,
|
||||
long downloadTrafficBytes,
|
||||
long transferUsageBytes,
|
||||
long offlineTransferStorageLimitBytes,
|
||||
List<AdminRequestTimelinePoint> requestTimeline
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AdminMetricsState, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select state from AdminMetricsState state where state.id = :id")
|
||||
Optional<AdminMetricsState> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminOfflineTransferStorageLimitResponse(
|
||||
long offlineTransferStorageLimitBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public record AdminOfflineTransferStorageLimitUpdateRequest(
|
||||
@Positive(message = "离线快传存储上限必须大于 0")
|
||||
long offlineTransferStorageLimitBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminRequestTimelinePoint(
|
||||
int hour,
|
||||
String label,
|
||||
long requestCount
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AdminRequestTimelinePointEntity, Long> {
|
||||
|
||||
List<AdminRequestTimelinePointEntity> findAllByMetricDateOrderByHourAsc(LocalDate metricDate);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select point from AdminRequestTimelinePointEntity point
|
||||
where point.metricDate = :metricDate and point.hour = :hour
|
||||
""")
|
||||
Optional<AdminRequestTimelinePointEntity> findByMetricDateAndHourForUpdate(@Param("metricDate") LocalDate metricDate,
|
||||
@Param("hour") int hour);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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<AdminRequestTimelinePoint> requestTimeline,
|
||||
String inviteCode
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public record AdminUserResponse(
|
||||
LocalDateTime createdAt,
|
||||
UserRole role,
|
||||
boolean banned,
|
||||
long usedStorageBytes,
|
||||
long storageQuotaBytes,
|
||||
long maxUploadSizeBytes
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,5 +71,12 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
""")
|
||||
long sumFileSizeByUserId(@Param("userId") Long userId);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(f.size), 0)
|
||||
from StoredFile f
|
||||
where f.directory = false
|
||||
""")
|
||||
long sumAllFileSize();
|
||||
|
||||
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,12 @@ public interface OfflineTransferSessionRepository extends JpaRepository<OfflineT
|
||||
where session.expiresAt < :now
|
||||
""")
|
||||
List<OfflineTransferSession> 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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<AdminUserResponse> 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 开发注意事项
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route path="overview" element={<Overview />} />
|
||||
<Route path="files" element={<Files />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
<Route path="games/:gameId" element={<GamePlayer />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getInviteCodePanelState } from './dashboard-state';
|
||||
import {
|
||||
buildRequestLineChartModel,
|
||||
formatMetricValue,
|
||||
getInviteCodePanelState,
|
||||
parseStorageLimitInput,
|
||||
} from './dashboard-state';
|
||||
|
||||
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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<RequestLineChartPoint | null>((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(/\.$/, '');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
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 (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
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,
|
||||
},
|
||||
{
|
||||
title: '用户管理',
|
||||
description: '已接入 /api/admin/users,可查看账号、邮箱、手机号与权限状态。',
|
||||
status: 'connected',
|
||||
},
|
||||
{
|
||||
title: '门户运营',
|
||||
description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。',
|
||||
status: 'connected',
|
||||
},
|
||||
];
|
||||
})}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', pl: 2.5 }}>
|
||||
<Stack spacing={1.25} sx={{ height: '100%' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: metric.accent,
|
||||
backgroundColor: `${metric.accent}14`,
|
||||
}}
|
||||
>
|
||||
{metric.icon}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
borderRadius: 99,
|
||||
color: metric.accent,
|
||||
backgroundColor: `${metric.accent}12`,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{metric.scope}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={0.75}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
fontWeight: 700,
|
||||
})}
|
||||
>
|
||||
{metric.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={(theme) => ({
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: '-0.02em',
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
{metric.value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
mt: 'auto',
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{metric.helper}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2.5}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', lg: 'row' }}
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', lg: 'center' }}
|
||||
>
|
||||
<Stack spacing={0.75}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
今日请求折线图
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||
<Stack
|
||||
spacing={0.35}
|
||||
sx={{
|
||||
minWidth: 132,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: (theme) => 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',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
fontWeight={700}
|
||||
>
|
||||
当前小时
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={800}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(currentPoint?.requestCount ?? 0, 'count')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{currentPoint?.label ?? '--'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
spacing={0.35}
|
||||
sx={{
|
||||
minWidth: 132,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: (theme) => 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',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
fontWeight={700}
|
||||
>
|
||||
今日峰值
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={800}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(chart.peakPoint?.requestCount ?? 0, 'count')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{chart.peakPoint?.label ?? '--'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 1.5, md: 2 },
|
||||
borderRadius: 3,
|
||||
border: (theme) => 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%)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '56px minmax(0, 1fr)' },
|
||||
gap: 1.5,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={0}
|
||||
justifyContent="space-between"
|
||||
sx={{ py: 1.25, display: { xs: 'none', md: 'flex' } }}
|
||||
>
|
||||
{chart.yAxisTicks.slice().reverse().map((tick) => (
|
||||
<Typography
|
||||
key={tick}
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(tick, 'count')}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: { xs: 220, md: 280 },
|
||||
borderRadius: 2.5,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(15, 23, 42, 0.58)' : 'rgba(255, 255, 255, 0.72)',
|
||||
}}
|
||||
>
|
||||
<Box component="svg" viewBox="0 0 100 100" preserveAspectRatio="none" sx={{ display: 'block', width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<linearGradient id="request-trend-area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{chart.yAxisTicks.map((tick) => {
|
||||
const y = 100 - (tick / scaleMax) * 100;
|
||||
return (
|
||||
<line
|
||||
key={tick}
|
||||
x1="0"
|
||||
x2="100"
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgba(148, 163, 184, 0.28)"
|
||||
strokeDasharray="3 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentPoint && (
|
||||
<line
|
||||
x1={currentPoint.x}
|
||||
x2={currentPoint.x}
|
||||
y1="0"
|
||||
y2="100"
|
||||
stroke="rgba(15, 23, 42, 0.18)"
|
||||
strokeDasharray="2 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.areaPath && <path d={chart.areaPath} fill="url(#request-trend-area)" />}
|
||||
{chart.linePath && (
|
||||
<path
|
||||
d={chart.linePath}
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((point) => (
|
||||
<circle
|
||||
key={point.label}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
|
||||
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{!hasRequests && (
|
||||
<Stack
|
||||
spacing={0.4}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
color: (theme) => 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)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={700}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
今日还没有请求数据
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
新请求进入后,这里会自动形成实时折线。
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ px: 0.5 }}>
|
||||
{xAxisPoints.map((point) => (
|
||||
<Typography
|
||||
key={point.label}
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{point.label}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalAdminDashboard() {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
@@ -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<AdminSummary>('/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<AdminSummary>('/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: <StorageRoundedIcon />,
|
||||
},
|
||||
{
|
||||
key: 'download-traffic',
|
||||
title: '下载流量',
|
||||
scope: '累计',
|
||||
value: formatMetricValue(summary.downloadTrafficBytes, 'bytes'),
|
||||
helper: '文件下载和离线快传下载都会计入这里。',
|
||||
accent: '#2563eb',
|
||||
icon: <CloudDownloadRoundedIcon />,
|
||||
},
|
||||
{
|
||||
key: 'request-count',
|
||||
title: '今日请求次数',
|
||||
scope: '今日',
|
||||
value: formatMetricValue(summary.requestCount, 'count'),
|
||||
helper: '只统计今天的 `/api/**` 请求,不再显示累计值。',
|
||||
accent: '#d97706',
|
||||
icon: <HubRoundedIcon />,
|
||||
},
|
||||
{
|
||||
key: 'transfer-usage',
|
||||
title: '快传使用量',
|
||||
scope: '累计',
|
||||
value: formatMetricValue(summary.transferUsageBytes, 'bytes'),
|
||||
helper: '按快传会话申报的文件体积累计统计。',
|
||||
accent: '#7c3aed',
|
||||
icon: <BoltRoundedIcon />,
|
||||
},
|
||||
{
|
||||
key: 'offline-transfer-storage',
|
||||
title: '快传离线存储量',
|
||||
scope: '当前',
|
||||
value: formatMetricValue(summary.offlineTransferStorageBytes, 'bytes'),
|
||||
helper: `当前上限 ${formatMetricValue(summary.offlineTransferStorageLimitBytes, 'bytes')}。`,
|
||||
accent: '#be123c',
|
||||
icon: (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<ArchiveRoundedIcon fontSize="small" />
|
||||
<BoltRoundedIcon fontSize="small" />
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
] : [];
|
||||
|
||||
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<AdminOfflineTransferStorageLimitResponse>('/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 (
|
||||
<Stack spacing={3} sx={{ p: 2 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
|
||||
@@ -126,7 +559,7 @@ export function PortalAdminDashboard() {
|
||||
YOYUZH Admin
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||
管理面板展示站点核心指标,并按小时画出今天的请求走势。
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button variant="outlined" onClick={() => navigate('/overview')}>
|
||||
@@ -142,61 +575,53 @@ export function PortalAdminDashboard() {
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{successMessage && <Alert severity="success">{successMessage}</Alert>}
|
||||
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{DASHBOARD_ITEMS.map((item) => (
|
||||
<Grid key={item.title} size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Chip label={item.status} size="small" color="primary" sx={{ width: 'fit-content' }} />
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{metrics.map((metric) => (
|
||||
<Grid key={metric.key} size={{ xs: 12, sm: 6, xl: 2.4 }}>
|
||||
<DashboardMetricCard metric={metric} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{summary && <RequestTrendChart summary={summary} />}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
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%',
|
||||
})}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
<Stack spacing={1.25} sx={{ height: '100%' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
当前管理员
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
用户名:{session?.user.username ?? '-'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
邮箱:{session?.user.email ?? '-'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
用户 ID:{session?.user.id ?? '-'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
后台汇总
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
用户总数:{state.summary?.totalUsers ?? 0}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
文件总数:{state.summary?.totalFiles ?? 0}
|
||||
<Typography sx={(theme) => ({ mt: 'auto', color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
管理用户 {formatMetricValue(summary?.totalUsers ?? 0, 'count')},文件总量 {formatMetricValue(summary?.totalFiles ?? 0, 'count')}。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
@@ -204,31 +629,95 @@ export function PortalAdminDashboard() {
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
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%',
|
||||
})}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
<Stack spacing={1.5} sx={{ height: '100%' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
离线快传配置
|
||||
</Typography>
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
当前离线占用:{formatMetricValue(summary?.offlineTransferStorageBytes ?? 0, 'bytes')}
|
||||
</Typography>
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
当前上限:{formatMetricValue(summary?.offlineTransferStorageLimitBytes ?? 0, 'bytes')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={(theme) => ({ mt: 'auto', color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
调整后会立即影响新的离线快传上传校验,避免离线存储无限增长。
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={updatingLimit || !summary}
|
||||
onClick={() => void handleUpdateOfflineTransferLimit()}
|
||||
>
|
||||
调整离线上限
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
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%',
|
||||
})}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
<Stack spacing={1.5} sx={{ height: '100%' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<FolderRoundedIcon color="primary" />
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
当前邀请码
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
</Stack>
|
||||
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
|
||||
注册成功一次后会自动刷新,后台展示的始终是下一次可用的邀请码。
|
||||
</Typography>
|
||||
<Typography
|
||||
component="code"
|
||||
sx={{
|
||||
sx={(theme) => ({
|
||||
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}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 'auto' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
@@ -243,12 +732,10 @@ export function PortalAdminDashboard() {
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => void handleRefreshInviteCode()}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Stack>
|
||||
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleRoleAssign()}>
|
||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap">
|
||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleRoleAssign()}>
|
||||
角色分配
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetPassword()}>
|
||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetPassword()}>
|
||||
修改密码
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleResetPassword()}>
|
||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleResetPassword()}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetStorageQuota()}>
|
||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetStorageQuota()}>
|
||||
存储上限
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetMaxUploadSize()}>
|
||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetMaxUploadSize()}>
|
||||
单文件上限
|
||||
</Button>
|
||||
<Button
|
||||
@@ -236,6 +240,7 @@ function AdminUserActions({ record }: { record: AdminUser }) {
|
||||
variant={record.banned ? 'contained' : 'outlined'}
|
||||
color={record.banned ? 'success' : 'warning'}
|
||||
disabled={busy}
|
||||
sx={{ minWidth: 'auto', px: 1 }}
|
||||
onClick={() => void handleToggleBan()}
|
||||
>
|
||||
{record.banned ? '解封' : '封禁'}
|
||||
@@ -254,14 +259,24 @@ export function PortalAdminUsersList() {
|
||||
title="用户管理"
|
||||
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||
>
|
||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||
<Datagrid
|
||||
bulkActionButtons={false}
|
||||
rowClick={false}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .RaDatagrid-table th, & .RaDatagrid-table td': {
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TextField source="id" label="ID" />
|
||||
<TextField source="username" label="用户名" />
|
||||
<TextField source="email" label="邮箱" />
|
||||
<TextField source="phoneNumber" label="手机号" emptyText="-" />
|
||||
<FunctionField<AdminUser>
|
||||
label="存储上限"
|
||||
render={(record) => formatLimitSize(record.storageQuotaBytes)}
|
||||
label="存储使用"
|
||||
render={(record) => formatStorageUsage(record.usedStorageBytes, record.storageQuotaBytes)}
|
||||
/>
|
||||
<FunctionField<AdminUser>
|
||||
label="单文件上限"
|
||||
|
||||
@@ -9,6 +9,13 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
||||
const request = async () => ({
|
||||
totalUsers: 1,
|
||||
totalFiles: 2,
|
||||
totalStorageBytes: 0,
|
||||
downloadTrafficBytes: 0,
|
||||
requestCount: 0,
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
requestTimeline: [],
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
|
||||
@@ -15,12 +15,29 @@ export interface UserProfile {
|
||||
|
||||
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
|
||||
export interface AdminRequestTimelinePoint {
|
||||
hour: number;
|
||||
label: string;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
totalStorageBytes: number;
|
||||
downloadTrafficBytes: number;
|
||||
requestCount: number;
|
||||
transferUsageBytes: number;
|
||||
offlineTransferStorageBytes: number;
|
||||
offlineTransferStorageLimitBytes: number;
|
||||
requestTimeline: AdminRequestTimelinePoint[];
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export interface AdminOfflineTransferStorageLimitResponse {
|
||||
offlineTransferStorageLimitBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -29,6 +46,7 @@ export interface AdminUser {
|
||||
createdAt: string;
|
||||
role: AdminUserRole;
|
||||
banned: boolean;
|
||||
usedStorageBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
maxUploadSizeBytes: number;
|
||||
}
|
||||
|
||||
71
front/src/pages/GamePlayer.tsx
Normal file
71
front/src/pages/GamePlayer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
|
||||
import { GAME_EXIT_PATH, isGameId, resolveGameHref } from './games-links';
|
||||
|
||||
export default function GamePlayer() {
|
||||
const navigate = useNavigate();
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
|
||||
if (!gameId || !isGameId(gameId)) {
|
||||
return <Navigate to={GAME_EXIT_PATH} replace />;
|
||||
}
|
||||
|
||||
const gameHref = resolveGameHref(gameId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="glass-panel relative overflow-hidden rounded-3xl p-4 shadow-xl md:p-6">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-white/10 to-transparent" />
|
||||
<div className="relative z-10 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.32em] text-slate-400">Game Session</div>
|
||||
<h1 className="mt-2 text-2xl font-bold text-white">{gameId.toUpperCase()}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
className="gap-2"
|
||||
onClick={() => navigate(GAME_EXIT_PATH)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
退出游戏
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
className="gap-2"
|
||||
onClick={() => window.open(gameHref, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
新窗口打开
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel relative mt-5 overflow-hidden rounded-2xl p-1 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||||
<div className="relative overflow-hidden rounded-xl bg-black">
|
||||
<Button
|
||||
type="button"
|
||||
variant="glass"
|
||||
className="absolute left-4 top-4 z-10 gap-2 shadow-lg"
|
||||
onClick={() => navigate(GAME_EXIT_PATH)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
退出
|
||||
</Button>
|
||||
<iframe
|
||||
title={`${gameId} game`}
|
||||
src={gameHref}
|
||||
className="h-[70vh] min-h-[560px] w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Gamepad2, Cat, Car, Play } from 'lucide-react';
|
||||
import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { calculateCardTilt } from './games-card-tilt';
|
||||
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from './games-links';
|
||||
|
||||
const GAMES = [
|
||||
const GAMES: Array<{
|
||||
id: GameId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: typeof Cat;
|
||||
color: string;
|
||||
category: 'featured';
|
||||
}> = [
|
||||
{
|
||||
id: 'cat',
|
||||
name: 'CAT',
|
||||
@@ -34,6 +43,7 @@ function applyCardTilt(card: HTMLDivElement, rotateX: number, rotateY: number, g
|
||||
}
|
||||
|
||||
function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
|
||||
const navigate = useNavigate();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -110,7 +120,11 @@ function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10 mt-auto pt-4">
|
||||
<Button className="w-full gap-2 transition-all group-hover:bg-white group-hover:text-black">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(resolveGamePlayerPath(game.id))}
|
||||
className="w-full gap-2 transition-all group-hover:bg-white group-hover:text-black"
|
||||
>
|
||||
<Play className="h-4 w-4" fill="currentColor" /> Launch
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -140,6 +154,15 @@ export default function Games() {
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
保留轻量试玩与静态资源检查入口,维持与整站一致的毛玻璃语言。在这里您可以快速启动站内集成的小游戏。
|
||||
</p>
|
||||
<a
|
||||
href={MORE_GAMES_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{MORE_GAMES_LABEL}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -331,12 +331,12 @@ export default function Login() {
|
||||
value={registerPassword}
|
||||
onChange={(event) => setRegisterPassword(event.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
minLength={8}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 ml-1">
|
||||
至少 10 位,并包含大写字母、小写字母、数字和特殊字符。
|
||||
至少 8 位,并包含大写字母。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -350,7 +350,7 @@ export default function Login() {
|
||||
value={registerConfirmPassword}
|
||||
onChange={(event) => setRegisterConfirmPassword(event.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
minLength={8}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
45
front/src/pages/games-links.test.ts
Normal file
45
front/src/pages/games-links.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
GAME_EXIT_PATH,
|
||||
MORE_GAMES_LABEL,
|
||||
MORE_GAMES_URL,
|
||||
isGameId,
|
||||
resolveGameHref,
|
||||
resolveGamePlayerPath,
|
||||
} from './games-links';
|
||||
|
||||
test('resolveGameHref maps the cat game to the t_race OSS page', () => {
|
||||
assert.equal(resolveGameHref('cat'), '/t_race/');
|
||||
});
|
||||
|
||||
test('resolveGameHref maps the race game to the race OSS page', () => {
|
||||
assert.equal(resolveGameHref('race'), '/race/');
|
||||
});
|
||||
|
||||
test('resolveGamePlayerPath maps the cat game to the in-app player route', () => {
|
||||
assert.equal(resolveGamePlayerPath('cat'), '/games/cat');
|
||||
});
|
||||
|
||||
test('resolveGamePlayerPath maps the race game to the in-app player route', () => {
|
||||
assert.equal(resolveGamePlayerPath('race'), '/games/race');
|
||||
});
|
||||
|
||||
test('isGameId only accepts supported game ids', () => {
|
||||
assert.equal(isGameId('cat'), true);
|
||||
assert.equal(isGameId('race'), true);
|
||||
assert.equal(isGameId('t_race'), false);
|
||||
});
|
||||
|
||||
test('GAME_EXIT_PATH points back to the games lobby', () => {
|
||||
assert.equal(GAME_EXIT_PATH, '/games');
|
||||
});
|
||||
|
||||
test('MORE_GAMES_URL points to the requested external games site', () => {
|
||||
assert.equal(MORE_GAMES_URL, 'https://quruifps.xyz');
|
||||
});
|
||||
|
||||
test('MORE_GAMES_LABEL keeps the friendly-link copy', () => {
|
||||
assert.equal(MORE_GAMES_LABEL, '更多游戏请访问quruifps.xyz');
|
||||
});
|
||||
21
front/src/pages/games-links.ts
Normal file
21
front/src/pages/games-links.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const GAME_HREFS = {
|
||||
cat: '/t_race/',
|
||||
race: '/race/',
|
||||
} as const;
|
||||
|
||||
export type GameId = keyof typeof GAME_HREFS;
|
||||
export const GAME_EXIT_PATH = '/games';
|
||||
export const MORE_GAMES_URL = 'https://quruifps.xyz';
|
||||
export const MORE_GAMES_LABEL = '更多游戏请访问quruifps.xyz';
|
||||
|
||||
export function resolveGameHref(gameId: GameId) {
|
||||
return GAME_HREFS[gameId];
|
||||
}
|
||||
|
||||
export function resolveGamePlayerPath(gameId: GameId) {
|
||||
return `${GAME_EXIT_PATH}/${gameId}`;
|
||||
}
|
||||
|
||||
export function isGameId(value: string): value is GameId {
|
||||
return value in GAME_HREFS;
|
||||
}
|
||||
27
memory.md
27
memory.md
@@ -8,13 +8,16 @@
|
||||
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
|
||||
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
|
||||
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
|
||||
- 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`,并已重新发布
|
||||
- 线上后端文件存储已从旧东京 OSS 桶切换到成都新桶 `yoyuzh-files2`,并已完成对象级存在性验证
|
||||
- 后端已补生产 CORS,默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz`
|
||||
- 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
|
||||
- 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
|
||||
- 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
|
||||
- 游戏页已接入 `/race/`、`/t_race/`,带站内播放器、退出按钮和友情链接
|
||||
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
|
||||
- 根目录 README 已重写为中文公开版 GitHub 风格
|
||||
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
||||
- 进行中:
|
||||
- 继续观察 VS Code Java/Lombok 误报是否完全消失
|
||||
- 继续排查 `api.yoyuzh.xyz` 在不同网络/设备下的 TLS/SNI 链路稳定性
|
||||
- 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图
|
||||
- 待开始:
|
||||
- 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向
|
||||
@@ -28,16 +31,16 @@
|
||||
| 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 |
|
||||
| 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 |
|
||||
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token,而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 |
|
||||
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式 OSS 发布脚本,流程稳定 | 手动上传 OSS: 容易出错,也不利于复用 |
|
||||
| 前端发布继续使用 `node scripts/deploy-front-oss.mjs` | 仓库已有正式静态站发布脚本,现已切到多吉云临时密钥 + S3 兼容上传流程 | 手动上传对象存储: 容易出错,也不利于复用 |
|
||||
| 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
|
||||
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
||||
| 线上网盘文件桶切到成都 `yoyuzh-files2` | 现有普通文件下载主链路是浏览器直连 OSS,主要性能瓶颈在对象存储地域与公网链路 | 继续使用东京桶: 中国内地用户下载链路更长,难以直接改善速度 |
|
||||
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
||||
| 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 |
|
||||
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
|
||||
|
||||
## 待解决问题
|
||||
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
||||
- [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理
|
||||
- [ ] 前端构建仍有 chunk size warning,目前不阻塞发布,但后续可以考虑做更细的拆包
|
||||
- [ ] `api.yoyuzh.xyz` 仍存在“同机房 IP 直连可用,但带域名 TLS/SNI 有时失败”的链路问题;这不是后端业务代码错误
|
||||
- [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`,API 子域名异常时会直接表现为“网络异常/登录失败”
|
||||
|
||||
## 关键约束
|
||||
@@ -50,11 +53,9 @@
|
||||
- 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar`
|
||||
- 已知新服务器公网 IP 是 `1.14.49.201`
|
||||
- 已知线上后端额外配置文件是 `/opt/yoyuzh/application-prod.yml`,环境变量文件是 `/opt/yoyuzh/app.env`
|
||||
- 2026-03-24 已将线上 OSS 文件存储切换到 `https://oss-cn-chengdu.aliyuncs.com` + `yoyuzh-files2`
|
||||
- 2026-03-24 已为线上配置文件创建备份:`/opt/yoyuzh/app.env.bak-before-chengdu`、`/opt/yoyuzh/application-prod.yml.bak-before-chengdu`
|
||||
- 2026-03-23 排障确认:`api.yoyuzh.xyz` 在部分网络下存在 TLS/SNI 握手异常,但后端服务与 nginx 正常,且 IP 直连加 `Host: api.yoyuzh.xyz` 时可正常返回
|
||||
- 2026-03-23 实时日志确认:Mac 端 `202.202.9.243` 登录链路 `OPTIONS /api/auth/login -> POST /api/auth/login -> 后续 /api/*` 全部返回 200;手机失败时并不总能在服务端日志中看到对应登录请求
|
||||
- 2026-03-24 线上 smoke 验证:`https://api.yoyuzh.xyz/swagger-ui.html` 返回 302,`my-site-api.service` 重启后为 active;抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶 HEAD 返回 200
|
||||
- 2026-04-01 已将线上文件桶与前端桶切到多吉云对象存储,后端配置走多吉云临时密钥 API
|
||||
- 2026-04-02 部署验证:`http://yoyuzh.xyz/` 返回 200,`https://yoyuzh.xyz/` 返回 200,`https://api.yoyuzh.xyz/swagger-ui.html` 最终返回 200,前端资源 `https://yoyuzh.xyz/assets/AdminApp-C9j3tmPO.js` 返回 200
|
||||
- 2026-04-02 后端服务重启后为 active,启动时间为 `2026-04-02 12:14:25 CST`
|
||||
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
||||
|
||||
## 参考资料
|
||||
@@ -69,6 +70,8 @@
|
||||
- JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java`
|
||||
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java`
|
||||
- CORS 配置: `backend/src/main/java/com/yoyuzh/config/CorsProperties.java`、`backend/src/main/resources/application.yml`
|
||||
- 密码策略: `backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java`
|
||||
- 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts`
|
||||
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
||||
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
|
||||
- 前端生产 API 基址: `front/.env.production`
|
||||
|
||||
@@ -7,7 +7,7 @@ import {spawnSync} from 'node:child_process';
|
||||
|
||||
import {
|
||||
buildObjectKey,
|
||||
createAuthorizationHeader,
|
||||
createAwsV4Headers,
|
||||
encodeObjectKey,
|
||||
getFrontendSpaAliasContentType,
|
||||
getFrontendSpaAliasKeys,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listFiles,
|
||||
normalizeEndpoint,
|
||||
parseSimpleEnv,
|
||||
requestDogeCloudTemporaryS3Session,
|
||||
} from './oss-deploy-lib.mjs';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
@@ -72,34 +73,39 @@ function runBuild() {
|
||||
async function uploadFile({
|
||||
bucket,
|
||||
endpoint,
|
||||
region,
|
||||
objectKey,
|
||||
filePath,
|
||||
contentTypeOverride,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
}) {
|
||||
const body = await fs.readFile(filePath);
|
||||
const contentType = contentTypeOverride || getContentType(objectKey);
|
||||
const date = new Date().toUTCString();
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
|
||||
const authorization = createAuthorizationHeader({
|
||||
const signatureHeaders = createAwsV4Headers({
|
||||
method: 'PUT',
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
amzDate,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
...signatureHeaders,
|
||||
'Cache-Control': getCacheControl(objectKey),
|
||||
'Content-Length': String(body.byteLength),
|
||||
'Content-Type': contentType,
|
||||
Date: date,
|
||||
},
|
||||
body,
|
||||
});
|
||||
@@ -113,9 +119,11 @@ async function uploadFile({
|
||||
async function uploadSpaAliases({
|
||||
bucket,
|
||||
endpoint,
|
||||
region,
|
||||
distIndexPath,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
remotePrefix,
|
||||
dryRun,
|
||||
}) {
|
||||
@@ -133,11 +141,13 @@ async function uploadSpaAliases({
|
||||
await uploadFile({
|
||||
bucket,
|
||||
endpoint,
|
||||
region,
|
||||
objectKey,
|
||||
filePath: distIndexPath,
|
||||
contentTypeOverride: contentType,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
});
|
||||
console.log(`uploaded alias ${objectKey}`);
|
||||
}
|
||||
@@ -148,11 +158,26 @@ async function main() {
|
||||
|
||||
await loadEnvFileIfPresent();
|
||||
|
||||
const accessKeyId = requireEnv('YOYUZH_OSS_ACCESS_KEY_ID');
|
||||
const accessKeySecret = requireEnv('YOYUZH_OSS_ACCESS_KEY_SECRET');
|
||||
const endpoint = requireEnv('YOYUZH_OSS_ENDPOINT');
|
||||
const bucket = requireEnv('YOYUZH_OSS_BUCKET');
|
||||
const remotePrefix = process.env.YOYUZH_OSS_PREFIX || '';
|
||||
const apiAccessKey = requireEnv('YOYUZH_DOGECLOUD_API_ACCESS_KEY');
|
||||
const apiSecretKey = requireEnv('YOYUZH_DOGECLOUD_API_SECRET_KEY');
|
||||
const scope = requireEnv('YOYUZH_DOGECLOUD_FRONT_SCOPE');
|
||||
const apiBaseUrl = process.env.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
|
||||
const region = process.env.YOYUZH_DOGECLOUD_S3_REGION || 'automatic';
|
||||
const remotePrefix = process.env.YOYUZH_DOGECLOUD_FRONT_PREFIX || '';
|
||||
const ttlSeconds = Number(process.env.YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS || '3600');
|
||||
const {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
endpoint,
|
||||
bucket,
|
||||
} = await requestDogeCloudTemporaryS3Session({
|
||||
apiBaseUrl,
|
||||
accessKey: apiAccessKey,
|
||||
secretKey: apiSecretKey,
|
||||
scope,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
if (!skipBuild) {
|
||||
runBuild();
|
||||
@@ -175,10 +200,12 @@ async function main() {
|
||||
await uploadFile({
|
||||
bucket,
|
||||
endpoint,
|
||||
region,
|
||||
objectKey,
|
||||
filePath,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
});
|
||||
console.log(`uploaded ${objectKey}`);
|
||||
}
|
||||
@@ -186,9 +213,11 @@ async function main() {
|
||||
await uploadSpaAliases({
|
||||
bucket,
|
||||
endpoint,
|
||||
region,
|
||||
distIndexPath: path.join(distDir, 'index.html'),
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
remotePrefix,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
402
scripts/migrate-aliyun-oss-to-s3.mjs
Normal file
402
scripts/migrate-aliyun-oss-to-s3.mjs
Normal file
@@ -0,0 +1,402 @@
|
||||
import crypto from 'node:crypto';
|
||||
import https from 'node:https';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import {
|
||||
createAwsV4Headers,
|
||||
encodeObjectKey,
|
||||
normalizeEndpoint,
|
||||
requestDogeCloudTemporaryS3Session,
|
||||
} from './oss-deploy-lib.mjs';
|
||||
|
||||
const DEFAULTS = {
|
||||
sourceEndpoint: 'https://oss-ap-northeast-1.aliyuncs.com',
|
||||
targetEndpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
targetRegion: 'automatic',
|
||||
prefix: '',
|
||||
dryRun: false,
|
||||
overwrite: false,
|
||||
};
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {...DEFAULTS};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--overwrite') {
|
||||
options.overwrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--prefix=')) {
|
||||
options.prefix = arg.slice('--prefix='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--source-endpoint=')) {
|
||||
options.sourceEndpoint = arg.slice('--source-endpoint='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--source-bucket=')) {
|
||||
options.sourceBucket = arg.slice('--source-bucket='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--source-access-key-id=')) {
|
||||
options.sourceAccessKeyId = arg.slice('--source-access-key-id='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--source-access-key-secret=')) {
|
||||
options.sourceAccessKeySecret = arg.slice('--source-access-key-secret='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-api-base-url=')) {
|
||||
options.targetApiBaseUrl = arg.slice('--target-api-base-url='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-region=')) {
|
||||
options.targetRegion = arg.slice('--target-region='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-scope=')) {
|
||||
options.targetScope = arg.slice('--target-scope='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-api-access-key=')) {
|
||||
options.targetApiAccessKey = arg.slice('--target-api-access-key='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-api-secret-key=')) {
|
||||
options.targetApiSecretKey = arg.slice('--target-api-secret-key='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target-ttl-seconds=')) {
|
||||
options.targetTtlSeconds = Number(arg.slice('--target-ttl-seconds='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function pickTransferredHeaders(sourceHeaders) {
|
||||
const forwardedHeaders = {};
|
||||
const supportedHeaders = [
|
||||
'cache-control',
|
||||
'content-disposition',
|
||||
'content-encoding',
|
||||
'content-type',
|
||||
];
|
||||
|
||||
for (const headerName of supportedHeaders) {
|
||||
const value = sourceHeaders[headerName];
|
||||
if (typeof value === 'string' && value) {
|
||||
forwardedHeaders[headerName === 'content-type' ? 'Content-Type' : headerName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return forwardedHeaders;
|
||||
}
|
||||
|
||||
function requireOption(options, key) {
|
||||
const value = options[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required option: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createOssAuthorizationHeader({
|
||||
method,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
}) {
|
||||
const stringToSign = [
|
||||
method.toUpperCase(),
|
||||
'',
|
||||
contentType,
|
||||
date,
|
||||
`/${bucket}/${objectKey}`,
|
||||
].join('\n');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha1', accessKeySecret)
|
||||
.update(stringToSign)
|
||||
.digest('base64');
|
||||
return `OSS ${accessKeyId}:${signature}`;
|
||||
}
|
||||
|
||||
function sourceRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, query = ''}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const date = new Date().toUTCString();
|
||||
const authorization = createOssAuthorizationHeader({
|
||||
method,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType: '',
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
});
|
||||
|
||||
const request = https.request({
|
||||
hostname: `${bucket}.${normalizedEndpoint}`,
|
||||
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
|
||||
method,
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
Date: date,
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function targetRequest({
|
||||
method,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
query = '',
|
||||
headers = {},
|
||||
body,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const signedHeaders = createAwsV4Headers({
|
||||
method,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
objectKey,
|
||||
query,
|
||||
headers,
|
||||
amzDate,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
});
|
||||
|
||||
const request = https.request({
|
||||
hostname: `${bucket}.${normalizedEndpoint}`,
|
||||
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
|
||||
method,
|
||||
headers: signedHeaders,
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
if (body) {
|
||||
request.end(body);
|
||||
return;
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function extractXmlValues(xmlBuffer, tagName) {
|
||||
const xml = xmlBuffer.toString('utf8');
|
||||
const pattern = new RegExp(`<${tagName}>(.*?)</${tagName}>`, 'g');
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
async function listSourceObjects(context, prefix) {
|
||||
const keys = [];
|
||||
let continuationToken = '';
|
||||
|
||||
while (true) {
|
||||
const query = new URLSearchParams({
|
||||
'list-type': '2',
|
||||
'max-keys': '1000',
|
||||
prefix,
|
||||
});
|
||||
if (continuationToken) {
|
||||
query.set('continuation-token', continuationToken);
|
||||
}
|
||||
|
||||
const response = await sourceRequest({
|
||||
...context,
|
||||
method: 'GET',
|
||||
objectKey: '',
|
||||
query: query.toString(),
|
||||
});
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`List failed for prefix "${prefix}": ${response.statusCode} ${response.body.toString('utf8')}`);
|
||||
}
|
||||
|
||||
keys.push(...extractXmlValues(response.body, 'Key'));
|
||||
const truncated = extractXmlValues(response.body, 'IsTruncated')[0] === 'true';
|
||||
continuationToken = extractXmlValues(response.body, 'NextContinuationToken')[0] || '';
|
||||
if (!truncated || !continuationToken) {
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function targetObjectExists(context, objectKey) {
|
||||
const response = await targetRequest({
|
||||
...context,
|
||||
method: 'HEAD',
|
||||
objectKey,
|
||||
});
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
}
|
||||
|
||||
async function copyObject(context, objectKey) {
|
||||
const sourceResponse = await sourceRequest({
|
||||
endpoint: context.sourceEndpoint,
|
||||
bucket: context.sourceBucket,
|
||||
accessKeyId: context.sourceAccessKeyId,
|
||||
accessKeySecret: context.sourceAccessKeySecret,
|
||||
method: 'GET',
|
||||
objectKey,
|
||||
});
|
||||
if (sourceResponse.statusCode < 200 || sourceResponse.statusCode >= 300) {
|
||||
throw new Error(`Download failed for ${objectKey}: ${sourceResponse.statusCode} ${sourceResponse.body.toString('utf8')}`);
|
||||
}
|
||||
|
||||
const forwardedHeaders = pickTransferredHeaders(sourceResponse.headers);
|
||||
const response = await targetRequest({
|
||||
endpoint: context.targetEndpoint,
|
||||
region: context.targetRegion,
|
||||
bucket: context.targetBucket,
|
||||
accessKeyId: context.targetAccessKeyId,
|
||||
secretAccessKey: context.targetSecretAccessKey,
|
||||
sessionToken: context.targetSessionToken,
|
||||
method: 'PUT',
|
||||
objectKey,
|
||||
headers: {
|
||||
...forwardedHeaders,
|
||||
'Content-Length': String(sourceResponse.body.byteLength),
|
||||
},
|
||||
body: sourceResponse.body,
|
||||
});
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`Upload failed for ${objectKey}: ${response.statusCode} ${response.body.toString('utf8')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const targetSession = await requestDogeCloudTemporaryS3Session({
|
||||
apiBaseUrl: options.targetApiBaseUrl || 'https://api.dogecloud.com',
|
||||
accessKey: requireOption(options, 'targetApiAccessKey'),
|
||||
secretKey: requireOption(options, 'targetApiSecretKey'),
|
||||
scope: requireOption(options, 'targetScope'),
|
||||
ttlSeconds: options.targetTtlSeconds || 3600,
|
||||
});
|
||||
const context = {
|
||||
sourceEndpoint: options.sourceEndpoint,
|
||||
sourceBucket: requireOption(options, 'sourceBucket'),
|
||||
sourceAccessKeyId: requireOption(options, 'sourceAccessKeyId'),
|
||||
sourceAccessKeySecret: requireOption(options, 'sourceAccessKeySecret'),
|
||||
targetEndpoint: targetSession.endpoint,
|
||||
targetRegion: options.targetRegion,
|
||||
targetBucket: targetSession.bucket,
|
||||
targetAccessKeyId: targetSession.accessKeyId,
|
||||
targetSecretAccessKey: targetSession.secretAccessKey,
|
||||
targetSessionToken: targetSession.sessionToken,
|
||||
};
|
||||
|
||||
const keys = await listSourceObjects({
|
||||
endpoint: context.sourceEndpoint,
|
||||
bucket: context.sourceBucket,
|
||||
accessKeyId: context.sourceAccessKeyId,
|
||||
accessKeySecret: context.sourceAccessKeySecret,
|
||||
}, options.prefix);
|
||||
|
||||
const summary = {
|
||||
listed: keys.length,
|
||||
copied: 0,
|
||||
skippedExisting: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
for (const objectKey of keys) {
|
||||
if (!options.overwrite && await targetObjectExists({
|
||||
endpoint: context.targetEndpoint,
|
||||
region: context.targetRegion,
|
||||
bucket: context.targetBucket,
|
||||
accessKeyId: context.targetAccessKeyId,
|
||||
secretAccessKey: context.targetSecretAccessKey,
|
||||
sessionToken: context.targetSessionToken,
|
||||
}, objectKey)) {
|
||||
summary.skippedExisting += 1;
|
||||
console.log(`[skip] ${objectKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`${options.dryRun ? '[dry-run]' : '[copy]'} ${objectKey}`);
|
||||
if (options.dryRun) {
|
||||
summary.copied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyObject(context, objectKey);
|
||||
summary.copied += 1;
|
||||
} catch (error) {
|
||||
summary.failed += 1;
|
||||
console.error(`[failed] ${objectKey}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nSummary');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
38
scripts/migrate-aliyun-oss-to-s3.test.mjs
Normal file
38
scripts/migrate-aliyun-oss-to-s3.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {parseArgs, pickTransferredHeaders} from './migrate-aliyun-oss-to-s3.mjs';
|
||||
|
||||
test('parseArgs keeps migration flags and bucket options', () => {
|
||||
const options = parseArgs([
|
||||
'--dry-run',
|
||||
'--overwrite',
|
||||
'--prefix=games/',
|
||||
'--source-bucket=aliyun-front',
|
||||
'--target-scope=yoyuzh-front',
|
||||
'--target-api-access-key=doge-ak',
|
||||
]);
|
||||
|
||||
assert.equal(options.dryRun, true);
|
||||
assert.equal(options.overwrite, true);
|
||||
assert.equal(options.prefix, 'games/');
|
||||
assert.equal(options.sourceBucket, 'aliyun-front');
|
||||
assert.equal(options.targetScope, 'yoyuzh-front');
|
||||
assert.equal(options.targetApiAccessKey, 'doge-ak');
|
||||
});
|
||||
|
||||
test('pickTransferredHeaders preserves only safe object metadata headers', () => {
|
||||
const headers = pickTransferredHeaders({
|
||||
'cache-control': 'public,max-age=31536000,immutable',
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
'content-disposition': 'attachment; filename=test.js',
|
||||
etag: '"demo"',
|
||||
server: 'OSS',
|
||||
});
|
||||
|
||||
assert.deepEqual(headers, {
|
||||
'cache-control': 'public,max-age=31536000,immutable',
|
||||
'Content-Type': 'text/javascript; charset=utf-8',
|
||||
'content-disposition': 'attachment; filename=test.js',
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,13 @@ import {constants as fsConstants} from 'node:fs';
|
||||
import {spawn} from 'node:child_process';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
createAwsV4Headers,
|
||||
normalizeEndpoint,
|
||||
parseSimpleEnv,
|
||||
encodeObjectKey,
|
||||
requestDogeCloudTemporaryS3Session,
|
||||
} from './oss-deploy-lib.mjs';
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -16,7 +17,8 @@ const DEFAULTS = {
|
||||
storageRoot: '/opt/yoyuzh/storage',
|
||||
database: 'yoyuzh_portal',
|
||||
bucket: 'yoyuzh-files',
|
||||
endpoint: 'https://oss-ap-northeast-1.aliyuncs.com',
|
||||
endpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
region: 'automatic',
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -114,37 +116,6 @@ function runCommand(command, args) {
|
||||
});
|
||||
}
|
||||
|
||||
function createOssAuthorizationHeader({
|
||||
method,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers = {},
|
||||
}) {
|
||||
const canonicalizedHeaders = Object.entries(headers)
|
||||
.map(([key, value]) => [key.toLowerCase().trim(), String(value).trim()])
|
||||
.filter(([key]) => key.startsWith('x-oss-'))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}\n`)
|
||||
.join('');
|
||||
const canonicalizedResource = `/${bucket}/${objectKey}`;
|
||||
const stringToSign = [
|
||||
method.toUpperCase(),
|
||||
'',
|
||||
contentType,
|
||||
date,
|
||||
`${canonicalizedHeaders}${canonicalizedResource}`,
|
||||
].join('\n');
|
||||
const signature = crypto
|
||||
.createHmac('sha1', accessKeySecret)
|
||||
.update(stringToSign)
|
||||
.digest('base64');
|
||||
return `OSS ${accessKeyId}:${signature}`;
|
||||
}
|
||||
|
||||
async function readAppEnv(appEnvPath) {
|
||||
const raw = await fs.readFile(appEnvPath, 'utf8');
|
||||
return parseSimpleEnv(raw);
|
||||
@@ -178,20 +149,34 @@ async function queryFiles(database) {
|
||||
});
|
||||
}
|
||||
|
||||
function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, headers = {}, query = '', body}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const date = new Date().toUTCString();
|
||||
const contentType = headers['Content-Type'] || headers['content-type'] || '';
|
||||
const auth = createOssAuthorizationHeader({
|
||||
function s3Request({
|
||||
method,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
headers = {},
|
||||
query = '',
|
||||
body,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const signatureHeaders = createAwsV4Headers({
|
||||
method,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
objectKey,
|
||||
query,
|
||||
headers,
|
||||
amzDate,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
});
|
||||
|
||||
const request = https.request({
|
||||
@@ -199,9 +184,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
|
||||
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
|
||||
method,
|
||||
headers: {
|
||||
Date: date,
|
||||
Authorization: auth,
|
||||
...headers,
|
||||
...signatureHeaders,
|
||||
},
|
||||
}, (response) => {
|
||||
let data = '';
|
||||
@@ -229,7 +212,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
|
||||
}
|
||||
|
||||
async function objectExists(context, objectKey) {
|
||||
const response = await ossRequest({
|
||||
const response = await s3Request({
|
||||
...context,
|
||||
method: 'HEAD',
|
||||
objectKey,
|
||||
@@ -243,7 +226,7 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
|
||||
const stat = await fileHandle.stat();
|
||||
|
||||
try {
|
||||
const response = await ossRequest({
|
||||
const response = await s3Request({
|
||||
...context,
|
||||
method: 'PUT',
|
||||
objectKey,
|
||||
@@ -263,12 +246,12 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
|
||||
}
|
||||
|
||||
async function copyObject(context, sourceKey, targetKey) {
|
||||
const response = await ossRequest({
|
||||
const response = await s3Request({
|
||||
...context,
|
||||
method: 'PUT',
|
||||
objectKey: targetKey,
|
||||
headers: {
|
||||
'x-oss-copy-source': `/${context.bucket}/${encodeObjectKey(sourceKey)}`,
|
||||
'x-amz-copy-source': `/${context.bucket}/${encodeObjectKey(sourceKey)}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -278,7 +261,7 @@ async function copyObject(context, sourceKey, targetKey) {
|
||||
}
|
||||
|
||||
async function deleteObject(context, objectKey) {
|
||||
const response = await ossRequest({
|
||||
const response = await s3Request({
|
||||
...context,
|
||||
method: 'DELETE',
|
||||
objectKey,
|
||||
@@ -318,7 +301,7 @@ async function listObjects(context, prefix) {
|
||||
query.set('continuation-token', continuationToken);
|
||||
}
|
||||
|
||||
const response = await ossRequest({
|
||||
const response = await s3Request({
|
||||
...context,
|
||||
method: 'GET',
|
||||
objectKey: '',
|
||||
@@ -370,21 +353,38 @@ async function buildArchivedObjectMap(context, files) {
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const appEnv = await readAppEnv(options.appEnvPath);
|
||||
const endpoint = appEnv.YOYUZH_OSS_ENDPOINT || DEFAULTS.endpoint;
|
||||
const bucket = options.bucket;
|
||||
const accessKeyId = appEnv.YOYUZH_OSS_ACCESS_KEY_ID;
|
||||
const accessKeySecret = appEnv.YOYUZH_OSS_ACCESS_KEY_SECRET;
|
||||
const apiAccessKey = appEnv.YOYUZH_DOGECLOUD_API_ACCESS_KEY;
|
||||
const apiSecretKey = appEnv.YOYUZH_DOGECLOUD_API_SECRET_KEY;
|
||||
const scope = appEnv.YOYUZH_DOGECLOUD_STORAGE_SCOPE || options.bucket;
|
||||
const apiBaseUrl = appEnv.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
|
||||
const region = appEnv.YOYUZH_DOGECLOUD_S3_REGION || DEFAULTS.region;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new Error('Missing OSS credentials in app env');
|
||||
if (!apiAccessKey || !apiSecretKey || !scope) {
|
||||
throw new Error('Missing DogeCloud storage configuration in app env');
|
||||
}
|
||||
|
||||
const {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
endpoint,
|
||||
bucket,
|
||||
} = await requestDogeCloudTemporaryS3Session({
|
||||
apiBaseUrl,
|
||||
accessKey: apiAccessKey,
|
||||
secretKey: apiSecretKey,
|
||||
scope,
|
||||
ttlSeconds: Number(appEnv.YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS || '3600'),
|
||||
});
|
||||
|
||||
const files = await queryFiles(options.database);
|
||||
const context = {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
};
|
||||
const archivedObjectsByKey = await buildArchivedObjectMap(context, files);
|
||||
|
||||
|
||||
@@ -68,29 +68,179 @@ export function getFrontendSpaAliasContentType() {
|
||||
return 'text/html; charset=utf-8';
|
||||
}
|
||||
|
||||
export function createAuthorizationHeader({
|
||||
function toAmzDateParts(amzDate) {
|
||||
return {
|
||||
amzDate,
|
||||
dateStamp: amzDate.slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hmac(key, value, encoding) {
|
||||
const digest = crypto.createHmac('sha256', key).update(value).digest();
|
||||
return encoding ? digest.toString(encoding) : digest;
|
||||
}
|
||||
|
||||
function hmacSha1Hex(key, value) {
|
||||
return crypto.createHmac('sha1', key).update(value).digest('hex');
|
||||
}
|
||||
|
||||
function buildSigningKey(secretAccessKey, dateStamp, region, service) {
|
||||
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
function encodeQueryComponent(value) {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function toCanonicalQueryString(query) {
|
||||
const params = new URLSearchParams(query);
|
||||
return [...params.entries()]
|
||||
.sort(([leftKey, leftValue], [rightKey, rightValue]) =>
|
||||
leftKey === rightKey ? leftValue.localeCompare(rightValue) : leftKey.localeCompare(rightKey)
|
||||
)
|
||||
.map(([key, value]) => `${encodeQueryComponent(key)}=${encodeQueryComponent(value)}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function extractDogeCloudScopeBucketName(scope) {
|
||||
const separatorIndex = scope.indexOf(':');
|
||||
return separatorIndex >= 0 ? scope.slice(0, separatorIndex) : scope;
|
||||
}
|
||||
|
||||
export function createDogeCloudApiAuthorization({apiPath, body, accessKey, secretKey}) {
|
||||
return `TOKEN ${accessKey}:${hmacSha1Hex(secretKey, `${apiPath}\n${body}`)}`;
|
||||
}
|
||||
|
||||
export async function requestDogeCloudTemporaryS3Session({
|
||||
apiBaseUrl = 'https://api.dogecloud.com',
|
||||
accessKey,
|
||||
secretKey,
|
||||
scope,
|
||||
ttlSeconds = 3600,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const apiPath = '/auth/tmp_token.json';
|
||||
const body = JSON.stringify({
|
||||
channel: 'OSS_FULL',
|
||||
ttl: ttlSeconds,
|
||||
scopes: [scope],
|
||||
});
|
||||
const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, '')}${apiPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: createDogeCloudApiAuthorization({
|
||||
apiPath,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`DogeCloud tmp_token request failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload.code !== 200) {
|
||||
throw new Error(`DogeCloud tmp_token request failed: ${payload.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const bucketName = extractDogeCloudScopeBucketName(scope);
|
||||
const buckets = Array.isArray(payload.data?.Buckets) ? payload.data.Buckets : [];
|
||||
const bucket = buckets.find((entry) => entry.name === bucketName) ?? buckets[0];
|
||||
if (!bucket) {
|
||||
throw new Error(`DogeCloud tmp_token response did not include a bucket for scope: ${bucketName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId: payload.data.Credentials?.accessKeyId || '',
|
||||
secretAccessKey: payload.data.Credentials?.secretAccessKey || '',
|
||||
sessionToken: payload.data.Credentials?.sessionToken || '',
|
||||
bucket: bucket.s3Bucket,
|
||||
endpoint: bucket.s3Endpoint,
|
||||
bucketName: bucket.name,
|
||||
expiresAt: payload.data.ExpiredAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAwsV4Headers({
|
||||
method,
|
||||
endpoint,
|
||||
bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
date,
|
||||
query = '',
|
||||
headers: extraHeaders = {},
|
||||
amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''),
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
region = 'automatic',
|
||||
}) {
|
||||
const stringToSign = [
|
||||
const {dateStamp} = toAmzDateParts(amzDate);
|
||||
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
||||
const host = `${bucket}.${normalizedEndpoint}`;
|
||||
const canonicalUri = `/${encodeObjectKey(objectKey)}`;
|
||||
const canonicalQueryString = toCanonicalQueryString(query);
|
||||
const payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
const service = 's3';
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const signedHeaderEntries = [
|
||||
['host', host],
|
||||
['x-amz-content-sha256', payloadHash],
|
||||
['x-amz-date', amzDate],
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(extraHeaders)) {
|
||||
signedHeaderEntries.push([key.toLowerCase(), String(value).trim()]);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
signedHeaderEntries.push(['x-amz-security-token', sessionToken]);
|
||||
}
|
||||
|
||||
signedHeaderEntries.sort(([left], [right]) => left.localeCompare(right));
|
||||
const signedHeaders = signedHeaderEntries.map(([key]) => key).join(';');
|
||||
const canonicalHeadersText = signedHeaderEntries.map(([key, value]) => `${key}:${value}\n`).join('');
|
||||
const canonicalRequest = [
|
||||
method.toUpperCase(),
|
||||
'',
|
||||
contentType,
|
||||
date,
|
||||
`/${bucket}/${objectKey}`,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeadersText,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
amzDate,
|
||||
credentialScope,
|
||||
sha256Hex(canonicalRequest),
|
||||
].join('\n');
|
||||
const signature = hmac(buildSigningKey(secretAccessKey, dateStamp, region, service), stringToSign, 'hex');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha1', accessKeySecret)
|
||||
.update(stringToSign)
|
||||
.digest('base64');
|
||||
const resultHeaders = {
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
'x-amz-date': amzDate,
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
return `OSS ${accessKeyId}:${signature}`;
|
||||
if (sessionToken) {
|
||||
resultHeaders['x-amz-security-token'] = sessionToken;
|
||||
}
|
||||
|
||||
return resultHeaders;
|
||||
}
|
||||
|
||||
export function encodeObjectKey(objectKey) {
|
||||
|
||||
@@ -3,12 +3,15 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildObjectKey,
|
||||
createAuthorizationHeader,
|
||||
createDogeCloudApiAuthorization,
|
||||
createAwsV4Headers,
|
||||
extractDogeCloudScopeBucketName,
|
||||
getFrontendSpaAliasContentType,
|
||||
getFrontendSpaAliasKeys,
|
||||
getCacheControl,
|
||||
getContentType,
|
||||
normalizeEndpoint,
|
||||
requestDogeCloudTemporaryS3Session,
|
||||
} from './oss-deploy-lib.mjs';
|
||||
|
||||
test('normalizeEndpoint strips scheme and trailing slashes', () => {
|
||||
@@ -43,16 +46,98 @@ test('frontend spa aliases are uploaded as html entry points', () => {
|
||||
assert.equal(getFrontendSpaAliasContentType(), 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
test('createAuthorizationHeader is stable for a known request', () => {
|
||||
const header = createAuthorizationHeader({
|
||||
test('createAwsV4Headers signs uploads with S3-compatible SigV4 headers', () => {
|
||||
const headers = createAwsV4Headers({
|
||||
method: 'PUT',
|
||||
endpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
bucket: 'demo-bucket',
|
||||
objectKey: 'assets/index.js',
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
date: 'Tue, 17 Mar 2026 12:00:00 GMT',
|
||||
amzDate: '20260317T120000Z',
|
||||
accessKeyId: 'test-id',
|
||||
accessKeySecret: 'test-secret',
|
||||
secretAccessKey: 'test-secret',
|
||||
region: 'automatic',
|
||||
});
|
||||
|
||||
assert.equal(header, 'OSS test-id:JgyH7mTiSILGGWsnXJwg4KIBRO4=');
|
||||
assert.equal(headers['x-amz-content-sha256'], 'UNSIGNED-PAYLOAD');
|
||||
assert.equal(headers['x-amz-date'], '20260317T120000Z');
|
||||
assert.ok(headers.Authorization.startsWith('AWS4-HMAC-SHA256 Credential=test-id/20260317/automatic/s3/aws4_request'));
|
||||
});
|
||||
|
||||
test('extractDogeCloudScopeBucketName keeps only the logical bucket name', () => {
|
||||
assert.equal(extractDogeCloudScopeBucketName('yoyuzh-files:users/*'), 'yoyuzh-files');
|
||||
assert.equal(extractDogeCloudScopeBucketName('yoyuzh-front'), 'yoyuzh-front');
|
||||
});
|
||||
|
||||
test('createDogeCloudApiAuthorization signs body with HMAC-SHA1 hex', () => {
|
||||
const authorization = createDogeCloudApiAuthorization({
|
||||
apiPath: '/auth/tmp_token.json',
|
||||
body: '{"channel":"OSS_FULL","ttl":1800,"scopes":["yoyuzh-files"]}',
|
||||
accessKey: 'doge-ak',
|
||||
secretKey: 'doge-sk',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
authorization,
|
||||
'TOKEN doge-ak:2cf0cf7cf6ddaf673cfe47e55646779d44470929'
|
||||
);
|
||||
});
|
||||
|
||||
test('requestDogeCloudTemporaryS3Session requests temp credentials and returns matching bucket', async () => {
|
||||
const requests = [];
|
||||
const session = await requestDogeCloudTemporaryS3Session({
|
||||
apiBaseUrl: 'https://api.dogecloud.com',
|
||||
accessKey: 'doge-ak',
|
||||
secretKey: 'doge-sk',
|
||||
scope: 'yoyuzh-front:assets/*',
|
||||
ttlSeconds: 1200,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({url, options});
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return {
|
||||
code: 200,
|
||||
msg: 'OK',
|
||||
data: {
|
||||
Credentials: {
|
||||
accessKeyId: 'tmp-ak',
|
||||
secretAccessKey: 'tmp-sk',
|
||||
sessionToken: 'tmp-token',
|
||||
},
|
||||
ExpiredAt: 1777777777,
|
||||
Buckets: [
|
||||
{
|
||||
name: 'yoyuzh-files',
|
||||
s3Bucket: 's-cd-14873-yoyuzh-files-1258813047',
|
||||
s3Endpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
},
|
||||
{
|
||||
name: 'yoyuzh-front',
|
||||
s3Bucket: 's-cd-14873-yoyuzh-front-1258813047',
|
||||
s3Endpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(requests[0].url, 'https://api.dogecloud.com/auth/tmp_token.json');
|
||||
assert.equal(requests[0].options.method, 'POST');
|
||||
assert.equal(requests[0].options.headers['Content-Type'], 'application/json');
|
||||
assert.ok(requests[0].options.headers.Authorization.startsWith('TOKEN doge-ak:'));
|
||||
assert.equal(requests[0].options.body, '{"channel":"OSS_FULL","ttl":1200,"scopes":["yoyuzh-front:assets/*"]}');
|
||||
assert.deepEqual(session, {
|
||||
accessKeyId: 'tmp-ak',
|
||||
secretAccessKey: 'tmp-sk',
|
||||
sessionToken: 'tmp-token',
|
||||
bucket: 's-cd-14873-yoyuzh-front-1258813047',
|
||||
endpoint: 'https://cos.ap-chengdu.myqcloud.com',
|
||||
bucketName: 'yoyuzh-front',
|
||||
expiresAt: 1777777777,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user