Migrate storage to DogeCloud and expand admin dashboard

This commit is contained in:
yoyuzh
2026-04-02 12:20:50 +08:00
parent 2424fbd2a7
commit 97edc4cc32
65 changed files with 2842 additions and 380 deletions

View File

@@ -2,26 +2,31 @@
# cp .env.oss.example .env.oss.local # 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 # 说明:文件名仍叫 `.env.oss.local`,但内容已经切换为多吉云临时密钥配置
# 当前项目使用东京区域 OSS默认保持这个值即可。
YOYUZH_OSS_ENDPOINT="https://oss-ap-northeast-1.aliyuncs.com"
# 前端静态站点所在的 OSS Bucket 名称 # 多吉云服务端 API AccessKey / SecretKey
YOYUZH_OSS_BUCKET="yoyuzh-2026"
# 可选:上传到 Bucket 内的子目录。
# 为空表示直接上传到 Bucket 根目录。
YOYUZH_OSS_PREFIX=""
# 阿里云 AccessKey ID。
# 不要把真实值提交到 git。 # 不要把真实值提交到 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 # 可选:多吉云服务端 API 地址
# 不要把真实值提交到 git。 YOYUZH_DOGECLOUD_API_BASE_URL="https://api.dogecloud.com"
YOYUZH_OSS_ACCESS_KEY_SECRET="YOUR_ACCESS_KEY_SECRET"
# 多吉云 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=""

View 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]

View 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]

View 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]

View 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]

View File

@@ -0,0 +1 @@
- generic [ref=e3]: 正在检查登录状态...

View File

@@ -106,6 +106,10 @@ Important:
- Do not propose any solution beyond the user's stated requirement if it could shift business logic. - 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. - 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 ## 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`. - Do not run `npm` commands at the repository root. This repo has a root `package-lock.json` but no root `package.json`.

View File

@@ -61,8 +61,8 @@
### 存储与基础设施 ### 存储与基础设施
- MySQL 8.x - MySQL 8.x
- 本地文件系统 / 阿里云 OSS - 本地文件系统 / 多吉云 S3 兼容对象存储
- OSS 静态资源发布 - S3 兼容静态资源发布
## 仓库结构 ## 仓库结构
@@ -157,19 +157,31 @@ APP_ADMIN_USERNAMES=admin1,admin2
APP_AUTH_REGISTRATION_INVITE_CODE=<初始化邀请码种子> APP_AUTH_REGISTRATION_INVITE_CODE=<初始化邀请码种子>
``` ```
### OSS 相关 ### S3 相关
```env ```env
YOYUZH_STORAGE_PROVIDER=oss YOYUZH_STORAGE_PROVIDER=s3
YOYUZH_OSS_ENDPOINT=... YOYUZH_DOGECLOUD_API_BASE_URL=https://api.dogecloud.com
YOYUZH_OSS_BUCKET=... YOYUZH_DOGECLOUD_API_ACCESS_KEY=...
YOYUZH_OSS_ACCESS_KEY_ID=... YOYUZH_DOGECLOUD_API_SECRET_KEY=...
YOYUZH_OSS_ACCESS_KEY_SECRET=... 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 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
```
### 后端发布 ### 后端发布
先打包: 先打包:

View File

@@ -89,22 +89,26 @@ DROP TABLE IF EXISTS portal_grade;
- `POST /api/transfer/sessions/{sessionId}/signals` - `POST /api/transfer/sessions/{sessionId}/signals`
- `GET /api/transfer/sessions/{sessionId}/signals` - `GET /api/transfer/sessions/{sessionId}/signals`
## OSS 直传说明 ## S3 兼容直传说明
生产环境如果启用: 生产环境如果启用:
```env ```env
YOYUZH_STORAGE_PROVIDER=oss YOYUZH_STORAGE_PROVIDER=s3
YOYUZH_OSS_ENDPOINT=https://oss-ap-northeast-1.aliyuncs.com YOYUZH_DOGECLOUD_API_BASE_URL=https://api.dogecloud.com
YOYUZH_OSS_BUCKET=your-bucket YOYUZH_DOGECLOUD_API_ACCESS_KEY=...
YOYUZH_OSS_ACCESS_KEY_ID=... YOYUZH_DOGECLOUD_API_SECRET_KEY=...
YOYUZH_OSS_ACCESS_KEY_SECRET=... 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` - Origin: `https://yoyuzh.xyz`
- Methods: `PUT`, `GET`, `HEAD` - Methods: `PUT`, `GET`, `HEAD`
- Headers: `Content-Type`, `x-oss-*` - Headers: `Content-Type`, `x-amz-*`
如果生产环境里曾经存在“数据库元数据已经在 OSS 模式下运行,但本地磁盘里没有对应文件”的历史数据,需要额外做一次对象迁移或元数据修复;否则旧记录在重命名/删除时仍可能失败。 后端运行时使用的是 AWS S3 Java SDK V2适配多吉云文档中的 S3 兼容接入方式。如果生产环境里曾经存在“数据库元数据已经在对象存储模式下运行,但新 Bucket 里没有对应文件”的历史数据,需要额外做一次对象迁移;否则旧记录在重命名/删除时仍可能失败。

View File

@@ -66,9 +66,9 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.aliyun.oss</groupId> <groupId>software.amazon.awssdk</groupId>
<artifactId>aliyun-sdk-oss</artifactId> <artifactId>s3</artifactId>
<version>3.17.4</version> <version>2.31.66</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>

View File

@@ -29,6 +29,14 @@ public class AdminController {
return ApiResponse.success(adminService.getSummary()); 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") @GetMapping("/users")
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page, public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,

View 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);
}
}

View File

@@ -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
) {
}

View File

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

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.admin;
public record AdminOfflineTransferStorageLimitResponse(
long offlineTransferStorageLimitBytes
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.Positive;
public record AdminOfflineTransferStorageLimitUpdateRequest(
@Positive(message = "离线快传存储上限必须大于 0")
long offlineTransferStorageLimitBytes
) {
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.admin;
public record AdminRequestTimelinePoint(
int hour,
String label,
long requestCount
) {
}

View File

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

View File

@@ -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);
}

View File

@@ -12,6 +12,7 @@ import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.FileService; import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository; import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -21,6 +22,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -34,12 +36,22 @@ public class AdminService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final RegistrationInviteService registrationInviteService; private final RegistrationInviteService registrationInviteService;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final AdminMetricsService adminMetricsService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() { public AdminSummaryResponse getSummary() {
AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot();
return new AdminSummaryResponse( return new AdminSummaryResponse(
userRepository.count(), userRepository.count(),
storedFileRepository.count(), storedFileRepository.count(),
storedFileRepository.sumAllFileSize(),
metrics.downloadTrafficBytes(),
metrics.requestCount(),
metrics.transferUsageBytes(),
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
metrics.offlineTransferStorageLimitBytes(),
metrics.requestTimeline(),
registrationInviteService.getCurrentInviteCode() registrationInviteService.getCurrentInviteCode()
); );
} }
@@ -94,7 +106,7 @@ public class AdminService {
@Transactional @Transactional
public AdminUserResponse updateUserPassword(Long userId, String newPassword) { public AdminUserResponse updateUserPassword(Long userId, String newPassword) {
if (!PasswordPolicy.isStrong(newPassword)) { if (!PasswordPolicy.isStrong(newPassword)) {
throw new BusinessException(ErrorCode.UNKNOWN, "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符"); throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
} }
User user = getRequiredUser(userId); User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
@@ -124,7 +136,13 @@ public class AdminService {
return new AdminPasswordResetResponse(temporaryPassword); return new AdminPasswordResetResponse(temporaryPassword);
} }
@Transactional
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes);
}
private AdminUserResponse toUserResponse(User user) { private AdminUserResponse toUserResponse(User user) {
long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
return new AdminUserResponse( return new AdminUserResponse(
user.getId(), user.getId(),
user.getUsername(), user.getUsername(),
@@ -133,6 +151,7 @@ public class AdminService {
user.getCreatedAt(), user.getCreatedAt(),
user.getRole(), user.getRole(),
user.isBanned(), user.isBanned(),
usedStorageBytes,
user.getStorageQuotaBytes(), user.getStorageQuotaBytes(),
user.getMaxUploadSizeBytes() user.getMaxUploadSizeBytes()
); );

View File

@@ -1,8 +1,17 @@
package com.yoyuzh.admin; package com.yoyuzh.admin;
import java.util.List;
public record AdminSummaryResponse( public record AdminSummaryResponse(
long totalUsers, long totalUsers,
long totalFiles, long totalFiles,
long totalStorageBytes,
long downloadTrafficBytes,
long requestCount,
long transferUsageBytes,
long offlineTransferStorageBytes,
long offlineTransferStorageLimitBytes,
List<AdminRequestTimelinePoint> requestTimeline,
String inviteCode String inviteCode
) { ) {
} }

View File

@@ -7,11 +7,11 @@ import jakarta.validation.constraints.Size;
public record AdminUserPasswordUpdateRequest( public record AdminUserPasswordUpdateRequest(
@NotBlank @NotBlank
@Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE)
String newPassword String newPassword
) { ) {
@AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
public boolean isPasswordStrong() { public boolean isPasswordStrong() {
return PasswordPolicy.isStrong(newPassword); return PasswordPolicy.isStrong(newPassword);
} }

View File

@@ -12,6 +12,7 @@ public record AdminUserResponse(
LocalDateTime createdAt, LocalDateTime createdAt,
UserRole role, UserRole role,
boolean banned, boolean banned,
long usedStorageBytes,
long storageQuotaBytes, long storageQuotaBytes,
long maxUploadSizeBytes long maxUploadSizeBytes
) { ) {

View File

@@ -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);
}
}

View File

@@ -1,33 +1,26 @@
package com.yoyuzh.auth; package com.yoyuzh.auth;
public final class PasswordPolicy { public final class PasswordPolicy {
public static final int MIN_LENGTH = 8;
public static final String VALIDATION_MESSAGE = "密码至少8位且必须包含大写字母";
private PasswordPolicy() { private PasswordPolicy() {
} }
public static boolean isStrong(String password) { public static boolean isStrong(String password) {
if (password == null || password.length() < 10) { if (password == null || password.length() < MIN_LENGTH) {
return false; return false;
} }
boolean hasLower = false;
boolean hasUpper = false; boolean hasUpper = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (int i = 0; i < password.length(); i += 1) { for (int i = 0; i < password.length(); i += 1) {
char c = password.charAt(i); char c = password.charAt(i);
if (Character.isLowerCase(c)) { if (Character.isUpperCase(c)) {
hasLower = true;
} else if (Character.isUpperCase(c)) {
hasUpper = true; hasUpper = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else {
hasSpecial = true;
} }
} }
return hasLower && hasUpper && hasDigit && hasSpecial; return hasUpper;
} }
} }

View File

@@ -13,12 +13,12 @@ public record RegisterRequest(
@NotBlank @NotBlank
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号") @Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
String phoneNumber, 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 String confirmPassword,
@NotBlank(message = "请输入邀请码") String inviteCode @NotBlank(message = "请输入邀请码") String inviteCode
) { ) {
@AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
public boolean isPasswordStrong() { public boolean isPasswordStrong() {
return PasswordPolicy.isStrong(password); return PasswordPolicy.isStrong(password);
} }

View File

@@ -8,11 +8,11 @@ import jakarta.validation.constraints.Size;
public record UpdateUserPasswordRequest( public record UpdateUserPasswordRequest(
@NotBlank String currentPassword, @NotBlank String currentPassword,
@NotBlank @NotBlank
@Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE)
String newPassword String newPassword
) { ) {
@AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
public boolean isPasswordStrong() { public boolean isPasswordStrong() {
return PasswordPolicy.isStrong(newPassword); return PasswordPolicy.isStrong(newPassword);
} }

View File

@@ -2,7 +2,7 @@ package com.yoyuzh.config;
import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.LocalFileContentStorage; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -11,8 +11,8 @@ public class FileStorageConfiguration {
@Bean @Bean
public FileContentStorage fileContentStorage(FileStorageProperties properties) { public FileContentStorage fileContentStorage(FileStorageProperties properties) {
if ("oss".equalsIgnoreCase(properties.getProvider())) { if ("s3".equalsIgnoreCase(properties.getProvider())) {
return new OssFileContentStorage(properties); return new S3FileContentStorage(properties);
} }
return new LocalFileContentStorage(properties); return new LocalFileContentStorage(properties);
} }

View File

@@ -7,7 +7,7 @@ public class FileStorageProperties {
private String provider = "local"; private String provider = "local";
private final Local local = new 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; private long maxFileSize = 500L * 1024 * 1024L;
public String getProvider() { public String getProvider() {
@@ -22,8 +22,8 @@ public class FileStorageProperties {
return local; return local;
} }
public Oss getOss() { public S3 getS3() {
return oss; return s3;
} }
public long getMaxFileSize() { public long getMaxFileSize() {
@@ -55,60 +55,60 @@ public class FileStorageProperties {
} }
} }
public static class Oss { public static class S3 {
private String endpoint; private String apiBaseUrl = "https://api.dogecloud.com";
private String bucket; private String apiAccessKey;
private String accessKeyId; private String apiSecretKey;
private String accessKeySecret; private String scope;
private String publicBaseUrl; private int ttlSeconds = 3600;
private boolean privateBucket = true; private String region = "automatic";
public String getEndpoint() { public String getApiBaseUrl() {
return endpoint; return apiBaseUrl;
} }
public void setEndpoint(String endpoint) { public void setApiBaseUrl(String apiBaseUrl) {
this.endpoint = endpoint; this.apiBaseUrl = apiBaseUrl;
} }
public String getBucket() { public String getApiAccessKey() {
return bucket; return apiAccessKey;
} }
public void setBucket(String bucket) { public void setApiAccessKey(String apiAccessKey) {
this.bucket = bucket; this.apiAccessKey = apiAccessKey;
} }
public String getAccessKeyId() { public String getApiSecretKey() {
return accessKeyId; return apiSecretKey;
} }
public void setAccessKeyId(String accessKeyId) { public void setApiSecretKey(String apiSecretKey) {
this.accessKeyId = accessKeyId; this.apiSecretKey = apiSecretKey;
} }
public String getAccessKeySecret() { public String getScope() {
return accessKeySecret; return scope;
} }
public void setAccessKeySecret(String accessKeySecret) { public void setScope(String scope) {
this.accessKeySecret = accessKeySecret; this.scope = scope;
} }
public String getPublicBaseUrl() { public int getTtlSeconds() {
return publicBaseUrl; return ttlSeconds;
} }
public void setPublicBaseUrl(String publicBaseUrl) { public void setTtlSeconds(int ttlSeconds) {
this.publicBaseUrl = publicBaseUrl; this.ttlSeconds = ttlSeconds;
} }
public boolean isPrivateBucket() { public String getRegion() {
return privateBucket; return region;
} }
public void setPrivateBucket(boolean privateBucket) { public void setRegion(String region) {
this.privateBucket = privateBucket; this.region = region;
} }
} }
} }

View File

@@ -1,6 +1,7 @@
package com.yoyuzh.config; package com.yoyuzh.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.admin.ApiRequestMetricsFilter;
import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.common.ApiResponse; import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
@@ -35,6 +36,7 @@ import java.util.List;
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ApiRequestMetricsFilter apiRequestMetricsFilter;
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final CorsProperties corsProperties; private final CorsProperties corsProperties;
@@ -72,6 +74,7 @@ public class SecurityConfig {
objectMapper.writeValue(response.getWriter(), objectMapper.writeValue(response.getWriter(),
ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足")); ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足"));
})) }))
.addFilterBefore(apiRequestMetricsFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

View File

@@ -1,5 +1,6 @@
package com.yoyuzh.files; package com.yoyuzh.files;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
@@ -38,15 +39,18 @@ public class FileService {
private final StoredFileRepository storedFileRepository; private final StoredFileRepository storedFileRepository;
private final FileContentStorage fileContentStorage; private final FileContentStorage fileContentStorage;
private final FileShareLinkRepository fileShareLinkRepository; private final FileShareLinkRepository fileShareLinkRepository;
private final AdminMetricsService adminMetricsService;
private final long maxFileSize; private final long maxFileSize;
public FileService(StoredFileRepository storedFileRepository, public FileService(StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage, FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository, FileShareLinkRepository fileShareLinkRepository,
AdminMetricsService adminMetricsService,
FileStorageProperties properties) { FileStorageProperties properties) {
this.storedFileRepository = storedFileRepository; this.storedFileRepository = storedFileRepository;
this.fileContentStorage = fileContentStorage; this.fileContentStorage = fileContentStorage;
this.fileShareLinkRepository = fileShareLinkRepository; this.fileShareLinkRepository = fileShareLinkRepository;
this.adminMetricsService = adminMetricsService;
this.maxFileSize = properties.getMaxFileSize(); this.maxFileSize = properties.getMaxFileSize();
} }
@@ -346,6 +350,7 @@ public class FileService {
if (storedFile.isDirectory()) { if (storedFile.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载"); throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
} }
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
if (fileContentStorage.supportsDirectDownload()) { if (fileContentStorage.supportsDirectDownload()) {
return new DownloadUrlResponse(fileContentStorage.createDownloadUrl( return new DownloadUrlResponse(fileContentStorage.createDownloadUrl(

View File

@@ -71,5 +71,12 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
""") """)
long sumFileSizeByUserId(@Param("userId") Long userId); 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); List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
} }

View File

@@ -35,4 +35,12 @@ public interface OfflineTransferSessionRepository extends JpaRepository<OfflineT
where session.expiresAt < :now where session.expiresAt < :now
""") """)
List<OfflineTransferSession> findAllExpiredWithFiles(@Param("now") Instant 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);
} }

View File

@@ -1,5 +1,6 @@
package com.yoyuzh.transfer; package com.yoyuzh.transfer;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
@@ -36,23 +37,27 @@ public class TransferService {
private final OfflineTransferSessionRepository offlineTransferSessionRepository; private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final FileContentStorage fileContentStorage; private final FileContentStorage fileContentStorage;
private final FileService fileService; private final FileService fileService;
private final AdminMetricsService adminMetricsService;
private final long maxFileSize; private final long maxFileSize;
public TransferService(TransferSessionStore sessionStore, public TransferService(TransferSessionStore sessionStore,
OfflineTransferSessionRepository offlineTransferSessionRepository, OfflineTransferSessionRepository offlineTransferSessionRepository,
FileContentStorage fileContentStorage, FileContentStorage fileContentStorage,
FileService fileService, FileService fileService,
AdminMetricsService adminMetricsService,
FileStorageProperties properties) { FileStorageProperties properties) {
this.sessionStore = sessionStore; this.sessionStore = sessionStore;
this.offlineTransferSessionRepository = offlineTransferSessionRepository; this.offlineTransferSessionRepository = offlineTransferSessionRepository;
this.fileContentStorage = fileContentStorage; this.fileContentStorage = fileContentStorage;
this.fileService = fileService; this.fileService = fileService;
this.adminMetricsService = adminMetricsService;
this.maxFileSize = properties.getMaxFileSize(); this.maxFileSize = properties.getMaxFileSize();
} }
@Transactional @Transactional
public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) { public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) {
pruneExpiredSessions(); pruneExpiredSessions();
adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum());
if (request.mode() == TransferMode.OFFLINE) { if (request.mode() == TransferMode.OFFLINE) {
return createOfflineSession(sender, request); return createOfflineSession(sender, request);
} }
@@ -104,6 +109,11 @@ public class TransferService {
if (multipartFile.getSize() != targetFile.getSize()) { if (multipartFile.getSize() != targetFile.getSize()) {
throw new BusinessException(ErrorCode.UNKNOWN, "离线文件大小与会话清单不一致"); 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 { try {
fileContentStorage.storeTransferFile( fileContentStorage.storeTransferFile(
@@ -150,6 +160,7 @@ public class TransferService {
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
OfflineTransferFile file = getRequiredOfflineFile(session, fileId); OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
ensureOfflineFileUploaded(file); ensureOfflineFileUploaded(file);
adminMetricsService.recordDownloadTraffic(file.getSize());
if (fileContentStorage.supportsDirectDownload()) { if (fileContentStorage.supportsDirectDownload()) {
String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename()); String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename());

View File

@@ -32,8 +32,17 @@ app:
registration: registration:
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:} invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
storage: storage:
root-dir: ./storage provider: ${YOYUZH_STORAGE_PROVIDER:local}
max-file-size: 524288000 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: cors:
allowed-origins: allowed-origins:
- http://localhost:3000 - http://localhost:3000

View File

@@ -1,25 +1,31 @@
package com.yoyuzh.admin; package com.yoyuzh.admin;
import com.yoyuzh.PortalBackendApplication; import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.admin.AdminMetricsStateRepository;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository; import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository; import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; 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.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime; 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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -44,9 +50,15 @@ class AdminControllerIntegrationTest {
@Autowired @Autowired
private UserRepository userRepository; private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired @Autowired
private StoredFileRepository storedFileRepository; private StoredFileRepository storedFileRepository;
@Autowired
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@Autowired
private AdminMetricsStateRepository adminMetricsStateRepository;
private User portalUser; private User portalUser;
private User secondaryUser; private User secondaryUser;
@@ -55,14 +67,16 @@ class AdminControllerIntegrationTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
offlineTransferSessionRepository.deleteAll();
storedFileRepository.deleteAll(); storedFileRepository.deleteAll();
userRepository.deleteAll(); userRepository.deleteAll();
adminMetricsStateRepository.deleteAll();
portalUser = new User(); portalUser = new User();
portalUser.setUsername("alice"); portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com"); portalUser.setEmail("alice@example.com");
portalUser.setPhoneNumber("13800138000"); portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password"); portalUser.setPasswordHash(passwordEncoder.encode("OriginalA"));
portalUser.setCreatedAt(LocalDateTime.now()); portalUser.setCreatedAt(LocalDateTime.now());
portalUser = userRepository.save(portalUser); portalUser = userRepository.save(portalUser);
@@ -70,7 +84,7 @@ class AdminControllerIntegrationTest {
secondaryUser.setUsername("bob"); secondaryUser.setUsername("bob");
secondaryUser.setEmail("bob@example.com"); secondaryUser.setEmail("bob@example.com");
secondaryUser.setPhoneNumber("13900139000"); secondaryUser.setPhoneNumber("13900139000");
secondaryUser.setPasswordHash("encoded-password"); secondaryUser.setPasswordHash(passwordEncoder.encode("OriginalB"));
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1)); secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
secondaryUser = userRepository.save(secondaryUser); secondaryUser = userRepository.save(secondaryUser);
@@ -100,6 +114,8 @@ class AdminControllerIntegrationTest {
@Test @Test
@WithMockUser(username = "admin") @WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception { void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
int currentHour = LocalTime.now().getHour();
mockMvc.perform(get("/api/admin/users?page=0&size=10")) mockMvc.perform(get("/api/admin/users?page=0&size=10"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
@@ -107,6 +123,7 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000")) .andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
.andExpect(jsonPath("$.data.items[0].role").value("USER")) .andExpect(jsonPath("$.data.items[0].role").value("USER"))
.andExpect(jsonPath("$.data.items[0].banned").value(false)) .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].storageQuotaBytes").isNumber())
.andExpect(jsonPath("$.data.items[0].maxUploadSizeBytes").isNumber()); .andExpect(jsonPath("$.data.items[0].maxUploadSizeBytes").isNumber());
@@ -114,6 +131,16 @@ class AdminControllerIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalUsers").value(2)) .andExpect(jsonPath("$.data.totalUsers").value(2))
.andExpect(jsonPath("$.data.totalFiles").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()); .andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
} }
@@ -150,7 +177,7 @@ class AdminControllerIntegrationTest {
mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId()) mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId())
.contentType("application/json") .contentType("application/json")
.content(""" .content("""
{"newPassword":"AdminSetPass1!"} {"newPassword":"AdminPass"}
""")) """))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(portalUser.getId())); .andExpect(jsonPath("$.data.id").value(portalUser.getId()));
@@ -173,11 +200,81 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.id").value(portalUser.getId())) .andExpect(jsonPath("$.data.id").value(portalUser.getId()))
.andExpect(jsonPath("$.data.maxUploadSizeBytes").value(10485760L)); .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())) mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty()); .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 @Test
@WithMockUser(username = "admin") @WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception { void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception {

View File

@@ -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));
}
}

View File

@@ -11,6 +11,7 @@ import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.FileService; import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository; import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -48,6 +49,10 @@ class AdminServiceTest {
private RefreshTokenService refreshTokenService; private RefreshTokenService refreshTokenService;
@Mock @Mock
private RegistrationInviteService registrationInviteService; private RegistrationInviteService registrationInviteService;
@Mock
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@Mock
private AdminMetricsService adminMetricsService;
private AdminService adminService; private AdminService adminService;
@@ -55,7 +60,8 @@ class AdminServiceTest {
void setUp() { void setUp() {
adminService = new AdminService( adminService = new AdminService(
userRepository, storedFileRepository, fileService, userRepository, storedFileRepository, fileService,
passwordEncoder, refreshTokenService, registrationInviteService); passwordEncoder, refreshTokenService, registrationInviteService,
offlineTransferSessionRepository, adminMetricsService);
} }
// --- getSummary --- // --- getSummary ---
@@ -64,12 +70,34 @@ class AdminServiceTest {
void shouldReturnSummaryWithCountsAndInviteCode() { void shouldReturnSummaryWithCountsAndInviteCode() {
when(userRepository.count()).thenReturn(5L); when(userRepository.count()).thenReturn(5L);
when(storedFileRepository.count()).thenReturn(42L); 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"); when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001");
AdminSummaryResponse summary = adminService.getSummary(); AdminSummaryResponse summary = adminService.getSummary();
assertThat(summary.totalUsers()).isEqualTo(5L); assertThat(summary.totalUsers()).isEqualTo(5L);
assertThat(summary.totalFiles()).isEqualTo(42L); 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"); assertThat(summary.inviteCode()).isEqualTo("INV-001");
} }
@@ -80,11 +108,13 @@ class AdminServiceTest {
User user = createUser(1L, "alice", "alice@example.com"); User user = createUser(1L, "alice", "alice@example.com");
when(userRepository.searchByUsernameOrEmail(anyString(), any())) when(userRepository.searchByUsernameOrEmail(anyString(), any()))
.thenReturn(new PageImpl<>(List.of(user))); .thenReturn(new PageImpl<>(List.of(user)));
when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L);
PageResponse<AdminUserResponse> response = adminService.listUsers(0, 10, "alice"); PageResponse<AdminUserResponse> response = adminService.listUsers(0, 10, "alice");
assertThat(response.items()).hasSize(1); assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).username()).isEqualTo("alice"); assertThat(response.items().get(0).username()).isEqualTo("alice");
assertThat(response.items().get(0).usedStorageBytes()).isEqualTo(2048L);
} }
@Test @Test
@@ -205,7 +235,7 @@ class AdminServiceTest {
void shouldRejectWeakPasswordWhenUpdating() { void shouldRejectWeakPasswordWhenUpdating() {
assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass")) assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass"))
.isInstanceOf(BusinessException.class) .isInstanceOf(BusinessException.class)
.hasMessageContaining("密码至少10"); .hasMessageContaining("密码至少8");
verify(userRepository, never()).findById(any()); verify(userRepository, never()).findById(any());
} }

View File

@@ -53,7 +53,7 @@ class AuthControllerValidationTest {
""")) """))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000)) .andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")); .andExpect(jsonPath("$.msg").value("密码至少8位,且必须包含大写字母"));
} }
@Test @Test

View File

@@ -14,49 +14,34 @@ class PasswordPolicyTest {
} }
@Test @Test
void shouldRejectPasswordShorterThanTenCharacters() { void shouldRejectPasswordShorterThanEightCharacters() {
assertThat(PasswordPolicy.isStrong("Abc1!defg")).isFalse(); // 9 chars assertThat(PasswordPolicy.isStrong("Abcdefg")).isFalse(); // 7 chars
} }
@Test @Test
void shouldAcceptPasswordWithExactlyTenCharacters() { void shouldAcceptPasswordWithExactlyEightCharacters() {
assertThat(PasswordPolicy.isStrong("Abcdefg1!x")).isTrue(); // 10 chars assertThat(PasswordPolicy.isStrong("Abcdefgh")).isTrue(); // 8 chars
} }
@Test @Test
void shouldRejectPasswordMissingUppercase() { void shouldRejectPasswordMissingUppercase() {
assertThat(PasswordPolicy.isStrong("abcdefg1!x")).isFalse(); assertThat(PasswordPolicy.isStrong("abcdefgh")).isFalse();
} }
@Test @Test
void shouldRejectPasswordMissingLowercase() { void shouldAcceptPasswordThatOnlyNeedsUppercaseAndLength() {
assertThat(PasswordPolicy.isStrong("ABCDEFG1!X")).isFalse(); assertThat(PasswordPolicy.isStrong("ABCDEFGH")).isTrue();
}
@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();
} }
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"", "short", "nouppercase1!", "NOLOWERCASE1!", "NoSpecialChar1", "NoDigit!AbcXyz"}) @ValueSource(strings = {"", "short", "noupper", "abcdefghi"})
void shouldRejectWeakPasswords(String password) { void shouldRejectWeakPasswords(String password) {
assertThat(PasswordPolicy.isStrong(password)).isFalse(); assertThat(PasswordPolicy.isStrong(password)).isFalse();
} }
@Test @Test
void shouldAcceptLongPasswordWithAllRequirements() { void shouldAcceptLongPasswordWithUppercase() {
assertThat(PasswordPolicy.isStrong("MyV3ryStr0ng&SecureP@ssword2024!")).isTrue(); assertThat(PasswordPolicy.isStrong("MyVerySimplePassword")).isTrue();
} }
@Test @Test

View File

@@ -26,7 +26,7 @@ class RegisterRequestValidationTest {
assertThat(violations) assertThat(violations)
.extracting(violation -> violation.getMessage()) .extracting(violation -> violation.getMessage())
.contains("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"); .contains("密码至少8位,且必须包含大写字母");
} }
@Test @Test
@@ -35,8 +35,8 @@ class RegisterRequestValidationTest {
"alice", "alice",
"alice@example.com", "alice@example.com",
"13800138000", "13800138000",
"StrongPass1!", "Abcdefgh",
"StrongPass1!", "Abcdefgh",
"invite-code" "invite-code"
); );

View File

@@ -1,6 +1,7 @@
package com.yoyuzh.config; package com.yoyuzh.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.admin.ApiRequestMetricsFilter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
@@ -23,6 +24,7 @@ class SecurityConfigTest {
corsProperties.setAllowedOrigins(java.util.List.of("https://yoyuzh.xyz")); corsProperties.setAllowedOrigins(java.util.List.of("https://yoyuzh.xyz"));
SecurityConfig securityConfig = new SecurityConfig( SecurityConfig securityConfig = new SecurityConfig(
null,
null, null,
null, null,
new ObjectMapper(), new ObjectMapper(),

View File

@@ -1,5 +1,6 @@
package com.yoyuzh.files; package com.yoyuzh.files;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
@@ -34,6 +35,8 @@ class FileServiceEdgeCaseTest {
private FileContentStorage fileContentStorage; private FileContentStorage fileContentStorage;
@Mock @Mock
private FileShareLinkRepository fileShareLinkRepository; private FileShareLinkRepository fileShareLinkRepository;
@Mock
private AdminMetricsService adminMetricsService;
private FileService fileService; private FileService fileService;
@@ -41,7 +44,7 @@ class FileServiceEdgeCaseTest {
void setUp() { void setUp() {
FileStorageProperties properties = new FileStorageProperties(); FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024); properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
} }
// --- normalizeDirectoryPath edge cases --- // --- normalizeDirectoryPath edge cases ---

View File

@@ -1,5 +1,6 @@
package com.yoyuzh.files; package com.yoyuzh.files;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
@@ -46,6 +47,8 @@ class FileServiceTest {
@Mock @Mock
private FileShareLinkRepository fileShareLinkRepository; private FileShareLinkRepository fileShareLinkRepository;
@Mock
private AdminMetricsService adminMetricsService;
private FileService fileService; private FileService fileService;
@@ -53,7 +56,7 @@ class FileServiceTest {
void setUp() { void setUp() {
FileStorageProperties properties = new FileStorageProperties(); FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024); properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
} }
@Test @Test

View File

@@ -8,7 +8,7 @@
1. React 前端站点 1. React 前端站点
2. Spring Boot 后端 API 2. Spring Boot 后端 API
3. 文件存储层(本地文件系统或 OSS 3. 文件存储层(本地文件系统或 S3 兼容对象存储
业务主线已经从旧教务方向切换为: 业务主线已经从旧教务方向切换为:
@@ -61,7 +61,7 @@
- 网盘元数据与文件流转 - 网盘元数据与文件流转
- 快传信令与会话状态 - 快传信令与会话状态
- 管理台 API - 管理台 API
- OSS / 本地存储抽象 - S3 兼容对象存储 / 本地存储抽象
后端包结构: 后端包结构:
@@ -79,7 +79,7 @@
### 2.3 文档与脚本 ### 2.3 文档与脚本
- `docs/`: 实现计划与补充文档 - `docs/`: 实现计划与补充文档
- `scripts/`: 前端 OSS 发布、存储迁移和本地辅助脚本 - `scripts/`: 前端静态站发布、对象存储迁移和本地辅助脚本
## 3. 模块划分 ## 3. 模块划分
@@ -97,6 +97,7 @@
- 注册、登录、刷新登录态 - 注册、登录、刷新登录态
- 用户资料查询和修改 - 用户资料查询和修改
- 用户自行修改密码
- 头像上传 - 头像上传
- 单设备登录控制 - 单设备登录控制
- 邀请码消费与轮换 - 邀请码消费与轮换
@@ -107,6 +108,7 @@
- refresh token 持久化到数据库 - refresh token 持久化到数据库
- 当前会话通过 `activeSessionId + JWT sid claim` 绑定 - 当前会话通过 `activeSessionId + JWT sid claim` 绑定
- 新登录会挤掉旧设备 - 新登录会挤掉旧设备
- 当前密码策略统一为“至少 8 位且包含大写字母”
### 3.2 网盘模块 ### 3.2 网盘模块
@@ -129,8 +131,8 @@
- 文件元数据在数据库 - 文件元数据在数据库
- 文件内容走存储层抽象 - 文件内容走存储层抽象
- 支持本地磁盘和 OSS - 支持本地磁盘和 S3 兼容对象存储
- 当前线上网盘文件存储已切到阿里云 OSS 成都地域桶 `yoyuzh-files2` - 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
- 前端会缓存目录列表和最后访问路径 - 前端会缓存目录列表和最后访问路径
### 3.3 快传模块 ### 3.3 快传模块
@@ -160,7 +162,7 @@
- 接收端支持部分文件选择 - 接收端支持部分文件选择
- 多文件或文件夹可走 ZIP 下载 - 多文件或文件夹可走 ZIP 下载
- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 - 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话
- 离线快传会把文件内容落到站点存储,线上环境使用 OSS,默认保留 7 天并支持重复接收 - 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收
### 3.4 管理台模块 ### 3.4 管理台模块
@@ -175,11 +177,15 @@
- 管理用户 - 管理用户
- 管理文件 - 管理文件
- 查看邀请码 - 查看邀请码
- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图
- 调整离线快传总上限
关键实现说明: 关键实现说明:
- 管理台依赖后端 summary/users/files 接口 - 管理台依赖后端 summary/users/files 接口
- 当前邀请码由后端返回给管理台展示 - 当前邀请码由后端返回给管理台展示
- 用户列表会展示每个用户的已用空间 / 配额
- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录
## 4. 关键业务流程 ## 4. 关键业务流程
@@ -210,7 +216,7 @@
1. 前端在 `Files` 页面选择文件或文件夹 1. 前端在 `Files` 页面选择文件或文件夹
2. 前端优先调用 `/api/files/upload/initiate` 2. 前端优先调用 `/api/files/upload/initiate`
3. 如果存储支持直传,则浏览器直接上传到 OSS 3. 如果存储支持直传,则浏览器直接上传到对象存储
4. 前端再调用 `/api/files/upload/complete` 4. 前端再调用 `/api/files/upload/complete`
5. 如果直传失败,会回退到代理上传接口 `/api/files/upload` 5. 如果直传失败,会回退到代理上传接口 `/api/files/upload`
@@ -240,6 +246,14 @@
6. 接收端可直接下载离线文件,也可登录后存入网盘 6. 接收端可直接下载离线文件,也可登录后存入网盘
7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁 7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁
### 4.7 管理员改密流程
1. 管理台调用 `PUT /api/admin/users/{userId}/password`
2. 后端按统一密码规则校验新密码
3. 后端重算密码哈希并写回用户表
4. 后端刷新 `activeSessionId` 并撤销该用户全部 refresh token
5. 旧密码后续登录应失败,新密码登录成功
## 5. 前端路由架构 ## 5. 前端路由架构
路由入口: 路由入口:
@@ -290,7 +304,7 @@
实现方向: 实现方向:
- 本地文件系统 - 本地文件系统
- OSS - S3 兼容对象存储
设计目的: 设计目的:
@@ -299,17 +313,17 @@
当前线上状态: 当前线上状态:
- 生产环境文件桶已从东京地域迁到成都地域 `yoyuzh-files2` - 生产环境文件桶已切到多吉云对象存储
- 生产后端当前使用 `https://oss-cn-chengdu.aliyuncs.com` 作为 OSS endpoint - 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken`
- 普通文件下载仍采用“后端鉴权后返回签名 URL浏览器直连 OSS 下载”的主链路 - 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址
- 2026-03-24 已对抽样对象 `users/6/第四组 脑机接口与脑启发计算.pptx` 在新桶执行 HEAD 校验并返回 200 - 普通文件下载仍采用“后端鉴权后返回签名 URL浏览器直连对象存储下载”的主链路
## 8. 部署架构 ## 8. 部署架构
### 8.1 前端 ### 8.1 前端
- 构建工具Vite - 构建工具Vite
- 发布方式:OSS 静态资源发布 - 发布方式:对象存储静态站发布
- 发布脚本:`node scripts/deploy-front-oss.mjs` - 发布脚本:`node scripts/deploy-front-oss.mjs`
### 8.2 后端 ### 8.2 后端
@@ -324,7 +338,7 @@
- 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar` - 运行包路径:`/opt/yoyuzh/yoyuzh-portal-backend.jar`
- 额外配置文件:`/opt/yoyuzh/application-prod.yml` - 额外配置文件:`/opt/yoyuzh/application-prod.yml`
- 环境变量文件:`/opt/yoyuzh/app.env` - 环境变量文件:`/opt/yoyuzh/app.env`
- 2026-03-24 已把生产后端 OSS 配置切换到成都新桶 `yoyuzh-files2` - 2026-04-02 已重新部署,服务状态为 `active (running)`
## 9. 开发注意事项 ## 9. 开发注意事项

View File

@@ -8,6 +8,7 @@ import Files from './pages/Files';
import Transfer from './pages/Transfer'; import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare'; import FileShare from './pages/FileShare';
import Games from './pages/Games'; import Games from './pages/Games';
import GamePlayer from './pages/GamePlayer';
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share'; import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
import { import {
getTransferRouterMode, getTransferRouterMode,
@@ -58,6 +59,7 @@ function AppRoutes() {
<Route path="overview" element={<Overview />} /> <Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} /> <Route path="files" element={<Files />} />
<Route path="games" element={<Games />} /> <Route path="games" element={<Games />} />
<Route path="games/:gameId" element={<GamePlayer />} />
</Route> </Route>
<Route <Route
path="/admin/*" path="/admin/*"

View File

@@ -1,13 +1,25 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
assert.deepEqual( assert.deepEqual(
getInviteCodePanelState({ getInviteCodePanelState({
totalUsers: 12, totalUsers: 12,
totalFiles: 34, totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: ' AbCd1234 ', inviteCode: ' AbCd1234 ',
}), }),
{ {
@@ -22,6 +34,13 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
getInviteCodePanelState({ getInviteCodePanelState({
totalUsers: 12, totalUsers: 12,
totalFiles: 34, totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: ' ', 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');
});

View File

@@ -1,10 +1,108 @@
import type { AdminSummary } from '@/src/lib/types'; import type { AdminRequestTimelinePoint, AdminSummary } from '@/src/lib/types';
export interface InviteCodePanelState { export interface InviteCodePanelState {
inviteCode: string; inviteCode: string;
canCopy: boolean; 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 { export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
const inviteCode = summary?.inviteCode?.trim() ?? ''; const inviteCode = summary?.inviteCode?.trim() ?? '';
if (!inviteCode) { if (!inviteCode) {
@@ -19,3 +117,19 @@ export function getInviteCodePanelState(summary: AdminSummary | null | undefined
canCopy: true, 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(/\.$/, '');
}

View File

@@ -1,35 +1,409 @@
import { useEffect, useState } from 'react'; 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 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 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 { useNavigate } from 'react-router-dom';
import { apiRequest } from '@/src/lib/api'; import { apiRequest } from '@/src/lib/api';
import { readStoredSession } from '@/src/lib/session'; import { readStoredSession } from '@/src/lib/session';
import type { AdminSummary } from '@/src/lib/types'; import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
import { getInviteCodePanelState } from './dashboard-state'; import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
interface DashboardState { interface DashboardState {
summary: AdminSummary | null; summary: AdminSummary | null;
} }
const DASHBOARD_ITEMS = [ interface MetricCardDefinition {
{ key: string;
title: '文件资源', title: string;
description: '已接入 /api/admin/files 与 /api/admin/files/{id} 删除接口,可查看全站文件元数据。', scope: string;
status: 'connected', 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可查看账号、邮箱、手机号与权限状态。', <CardContent sx={{ height: '100%', pl: 2.5 }}>
status: 'connected', <Stack spacing={1.25} sx={{ height: '100%' }}>
}, <Stack direction="row" justifyContent="space-between" alignItems="center">
{ <Box
title: '门户运营', sx={{
description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。', width: 42,
status: 'connected', 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() { export function PortalAdminDashboard() {
const [state, setState] = useState<DashboardState>({ const [state, setState] = useState<DashboardState>({
@@ -37,7 +411,9 @@ export function PortalAdminDashboard() {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [copyMessage, setCopyMessage] = useState(''); const [copyMessage, setCopyMessage] = useState('');
const [updatingLimit, setUpdatingLimit] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const session = readStoredSession(); const session = readStoredSession();
@@ -47,10 +423,7 @@ export function PortalAdminDashboard() {
try { try {
const summary = await apiRequest<AdminSummary>('/admin/summary'); const summary = await apiRequest<AdminSummary>('/admin/summary');
setState({ summary });
setState({
summary,
});
} catch (requestError) { } catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败'); setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
} finally { } finally {
@@ -59,44 +432,68 @@ export function PortalAdminDashboard() {
} }
useEffect(() => { useEffect(() => {
let active = true; void loadDashboardData();
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;
};
}, []); }, []);
const inviteCodePanel = getInviteCodePanelState(state.summary); 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() { async function handleRefreshInviteCode() {
setCopyMessage(''); setCopyMessage('');
setSuccessMessage('');
await loadDashboardData(); 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 ( return (
<Stack spacing={3} sx={{ p: 2 }}> <Stack spacing={3} sx={{ p: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}> <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 YOYUZH Admin
</Typography> </Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
react-admin `/api/admin/**`
</Typography> </Typography>
</Stack> </Stack>
<Button variant="outlined" onClick={() => navigate('/overview')}> <Button variant="outlined" onClick={() => navigate('/overview')}>
@@ -142,61 +575,53 @@ export function PortalAdminDashboard() {
)} )}
{error && <Alert severity="error">{error}</Alert>} {error && <Alert severity="error">{error}</Alert>}
{successMessage && <Alert severity="success">{successMessage}</Alert>}
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
<Grid container spacing={2}> <Grid container spacing={2}>
{DASHBOARD_ITEMS.map((item) => ( {metrics.map((metric) => (
<Grid key={item.title} size={{ xs: 12, md: 4 }}> <Grid key={metric.key} size={{ xs: 12, sm: 6, xl: 2.4 }}>
<Card variant="outlined"> <DashboardMetricCard metric={metric} />
<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>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
{summary && <RequestTrendChart summary={summary} />}
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined"> <Card
<CardContent> variant="outlined"
<Stack spacing={1}> sx={(theme) => ({
<Typography variant="h6" fontWeight={600}> 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>
<Typography color="text.secondary"> <Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{session?.user.username ?? '-'} {session?.user.username ?? '-'}
</Typography> </Typography>
<Typography color="text.secondary"> <Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{session?.user.email ?? '-'} {session?.user.email ?? '-'}
</Typography> </Typography>
<Typography color="text.secondary"> <Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
ID{session?.user.id ?? '-'} ID{session?.user.id ?? '-'}
</Typography> </Typography>
</Stack> <Typography sx={(theme) => ({ mt: 'auto', color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
</CardContent> {formatMetricValue(summary?.totalUsers ?? 0, 'count')} {formatMetricValue(summary?.totalFiles ?? 0, 'count')}
</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> </Typography>
</Stack> </Stack>
</CardContent> </CardContent>
@@ -204,31 +629,95 @@ export function PortalAdminDashboard() {
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined"> <Card
<CardContent> variant="outlined"
<Stack spacing={1.5}> sx={(theme) => ({
<Typography variant="h6" fontWeight={600}> 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>
<Typography color="text.secondary"> </Stack>
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
</Typography> </Typography>
<Typography <Typography
component="code" component="code"
sx={{ sx={(theme) => ({
display: 'inline-block', display: 'inline-block',
width: 'fit-content', width: 'fit-content',
px: 1.5, px: 1.5,
py: 1, py: 1,
borderRadius: 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', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.95rem', fontSize: '0.95rem',
}} })}
> >
{inviteCodePanel.inviteCode} {inviteCodePanel.inviteCode}
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1} sx={{ mt: 'auto' }}>
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
@@ -243,12 +732,10 @@ export function PortalAdminDashboard() {
size="small" size="small"
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
onClick={() => void handleRefreshInviteCode()} onClick={() => void handleRefreshInviteCode()}
disabled={loading}
> >
</Button> </Button>
</Stack> </Stack>
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -59,6 +59,10 @@ function UsersListActions() {
); );
} }
function formatStorageUsage(usedBytes: number, quotaBytes: number) {
return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`;
}
function AdminUserActions({ record }: { record: AdminUser }) { function AdminUserActions({ record }: { record: AdminUser }) {
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
@@ -116,7 +120,7 @@ function AdminUserActions({ record }: { record: AdminUser }) {
async function handleSetPassword() { async function handleSetPassword() {
const newPassword = window.prompt( const newPassword = window.prompt(
'请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。', '请输入新密码。密码至少8位,且必须包含大写字母。',
); );
if (!newPassword) { if (!newPassword) {
return; return;
@@ -215,20 +219,20 @@ function AdminUserActions({ record }: { record: AdminUser }) {
} }
return ( return (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap"> <Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap">
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleRoleAssign()}> <Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleRoleAssign()}>
</Button> </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>
<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>
<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>
<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>
<Button <Button
@@ -236,6 +240,7 @@ function AdminUserActions({ record }: { record: AdminUser }) {
variant={record.banned ? 'contained' : 'outlined'} variant={record.banned ? 'contained' : 'outlined'}
color={record.banned ? 'success' : 'warning'} color={record.banned ? 'success' : 'warning'}
disabled={busy} disabled={busy}
sx={{ minWidth: 'auto', px: 1 }}
onClick={() => void handleToggleBan()} onClick={() => void handleToggleBan()}
> >
{record.banned ? '解封' : '封禁'} {record.banned ? '解封' : '封禁'}
@@ -254,14 +259,24 @@ export function PortalAdminUsersList() {
title="用户管理" title="用户管理"
sort={{ field: 'createdAt', order: 'DESC' }} 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="id" label="ID" />
<TextField source="username" label="用户名" /> <TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" /> <TextField source="email" label="邮箱" />
<TextField source="phoneNumber" label="手机号" emptyText="-" /> <TextField source="phoneNumber" label="手机号" emptyText="-" />
<FunctionField<AdminUser> <FunctionField<AdminUser>
label="存储上限" label="存储使用"
render={(record) => formatLimitSize(record.storageQuotaBytes)} render={(record) => formatStorageUsage(record.usedStorageBytes, record.storageQuotaBytes)}
/> />
<FunctionField<AdminUser> <FunctionField<AdminUser>
label="单文件上限" label="单文件上限"

View File

@@ -9,6 +9,13 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
const request = async () => ({ const request = async () => ({
totalUsers: 1, totalUsers: 1,
totalFiles: 2, totalFiles: 2,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: 'invite-code', inviteCode: 'invite-code',
}); });

View File

@@ -15,12 +15,29 @@ export interface UserProfile {
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN'; export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminRequestTimelinePoint {
hour: number;
label: string;
requestCount: number;
}
export interface AdminSummary { export interface AdminSummary {
totalUsers: number; totalUsers: number;
totalFiles: number; totalFiles: number;
totalStorageBytes: number;
downloadTrafficBytes: number;
requestCount: number;
transferUsageBytes: number;
offlineTransferStorageBytes: number;
offlineTransferStorageLimitBytes: number;
requestTimeline: AdminRequestTimelinePoint[];
inviteCode: string; inviteCode: string;
} }
export interface AdminOfflineTransferStorageLimitResponse {
offlineTransferStorageLimitBytes: number;
}
export interface AdminUser { export interface AdminUser {
id: number; id: number;
username: string; username: string;
@@ -29,6 +46,7 @@ export interface AdminUser {
createdAt: string; createdAt: string;
role: AdminUserRole; role: AdminUserRole;
banned: boolean; banned: boolean;
usedStorageBytes: number;
storageQuotaBytes: number; storageQuotaBytes: number;
maxUploadSizeBytes: number; maxUploadSizeBytes: number;
} }

View 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>
);
}

View File

@@ -1,12 +1,21 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button'; 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 { cn } from '@/src/lib/utils';
import { calculateCardTilt } from './games-card-tilt'; 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', id: 'cat',
name: '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 }) { function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
const navigate = useNavigate();
const cardRef = useRef<HTMLDivElement | null>(null); const cardRef = useRef<HTMLDivElement | null>(null);
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => { const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -110,7 +120,11 @@ function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="relative z-10 mt-auto pt-4"> <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 <Play className="h-4 w-4" fill="currentColor" /> Launch
</Button> </Button>
</CardContent> </CardContent>
@@ -140,6 +154,15 @@ export default function Games() {
<p className="text-sm text-slate-400 leading-relaxed"> <p className="text-sm text-slate-400 leading-relaxed">
</p> </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> </div>
</motion.div> </motion.div>

View File

@@ -331,12 +331,12 @@ export default function Login() {
value={registerPassword} value={registerPassword}
onChange={(event) => setRegisterPassword(event.target.value)} onChange={(event) => setRegisterPassword(event.target.value)}
required required
minLength={10} minLength={8}
maxLength={64} maxLength={64}
/> />
</div> </div>
<p className="text-xs text-slate-500 ml-1"> <p className="text-xs text-slate-500 ml-1">
10 8
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -350,7 +350,7 @@ export default function Login() {
value={registerConfirmPassword} value={registerConfirmPassword}
onChange={(event) => setRegisterConfirmPassword(event.target.value)} onChange={(event) => setRegisterConfirmPassword(event.target.value)}
required required
minLength={10} minLength={8}
maxLength={64} maxLength={64}
/> />
</div> </div>

View 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');
});

View 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;
}

View File

@@ -8,13 +8,16 @@
- 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入 - 网盘已支持上传、下载、重命名、删除、移动、复制、公开分享、接收快传后存入
- 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制 - 注册改成邀请码机制,邀请码单次使用后自动刷新,并在管理台展示与复制
- 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效 - 同账号仅允许一台设备同时登录,旧设备会在下一次访问受保护接口时失效
- 后端已补生产 CORS默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz`,并已重新发布 - 后端已补生产 CORS默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz`
- 线上后端文件存储已从旧东京 OSS 桶切换到成都新桶 `yoyuzh-files2`,并已完成对象级存在性验证 - 线上文件存储与前端静态托管已迁到多吉云对象存储,后端通过临时密钥 API 获取短期 S3 会话访问底层 COS 兼容桶
- 管理台 dashboard 已显示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图,并支持调整离线快传总上限
- 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
- 游戏页已接入 `/race/``/t_race/`,带站内播放器、退出按钮和友情链接
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
- 根目录 README 已重写为中文公开版 GitHub 风格 - 根目录 README 已重写为中文公开版 GitHub 风格
- VS Code 工作区已补 `.vscode/settings.json``.vscode/extensions.json``lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor - VS Code 工作区已补 `.vscode/settings.json``.vscode/extensions.json``lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
- 进行中: - 进行中:
- 继续观察 VS Code Java/Lombok 误报是否完全消失 - 继续观察 VS Code Java/Lombok 误报是否完全消失
- 继续排查 `api.yoyuzh.xyz` 在不同网络/设备下的 TLS/SNI 链路稳定性
- 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图 - 后续如果再做 README/开源化展示,可以继续补 banner、截图和架构图
- 待开始: - 待开始:
- 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向 - 如果用户继续提需求,优先沿当前网站主线迭代,不再回到旧教务方向
@@ -28,16 +31,16 @@
| 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 | | 网盘侧边栏改成单一树状目录结构 | 更像真实网盘,层级关系清晰 | 保留“快速访问 + 目录”双区块: 结构割裂 |
| 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 | | 注册邀请码改成单次使用后自动刷新 | 更适合私域邀请式注册,管理台也能直接查看当前邀请码 | 固定邀请码: 容易扩散且不可控 |
| 单设备登录通过“用户当前会话 ID + JWT sid claim”实现 | 新登录能立即顶掉旧 access token而不仅仅是旧 refresh token | 只撤销 refresh token: 旧 access token 仍会继续有效一段时间 | | 单设备登录通过“用户当前会话 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 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 | | 后端发布继续采用“本地打包 + SSH/ SCP 上传 jar + systemd 重启” | 当前线上就按这个方式运行 | 自创部署脚本: 仓库里没有现成正式脚本,容易和现网偏离 |
| 主站 CORS 默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz` | 前端生产环境托管在 OSS 域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 | | 主站 CORS 默认放行 `https://yoyuzh.xyz``https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
| 线上网盘文件桶切到成都 `yoyuzh-files2` | 现有普通文件下载主链路是浏览器直连 OSS主要性能瓶颈在对象存储地域与公网链路 | 继续使用东京桶: 中国内地用户下载链路更长,难以直接改善速度 | | 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 |
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
## 待解决问题 ## 待解决问题
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误 - [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
- [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理 - [ ] `front/README.md` 仍是旧模板风格说明,当前真实入口说明以根目录 `README.md` 为准,后续可继续整理
- [ ] 前端构建仍有 chunk size warning目前不阻塞发布但后续可以考虑做更细的拆包 - [ ] 前端构建仍有 chunk size warning目前不阻塞发布但后续可以考虑做更细的拆包
- [ ] `api.yoyuzh.xyz` 仍存在“同机房 IP 直连可用,但带域名 TLS/SNI 有时失败”的链路问题;这不是后端业务代码错误
- [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`API 子域名异常时会直接表现为“网络异常/登录失败” - [ ] 线上前端 bundle 当前仍内嵌 `https://api.yoyuzh.xyz/api`API 子域名异常时会直接表现为“网络异常/登录失败”
## 关键约束 ## 关键约束
@@ -50,11 +53,9 @@
- 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar` - 已知线上后端运行包路径是 `/opt/yoyuzh/yoyuzh-portal-backend.jar`
- 已知新服务器公网 IP 是 `1.14.49.201` - 已知新服务器公网 IP 是 `1.14.49.201`
- 已知线上后端额外配置文件是 `/opt/yoyuzh/application-prod.yml`,环境变量文件是 `/opt/yoyuzh/app.env` - 已知线上后端额外配置文件是 `/opt/yoyuzh/application-prod.yml`,环境变量文件是 `/opt/yoyuzh/app.env`
- 2026-03-24 已将线上 OSS 文件存储切换到 `https://oss-cn-chengdu.aliyuncs.com` + `yoyuzh-files2` - 2026-04-01 已将线上文件桶与前端桶切到多吉云对象存储,后端配置走多吉云临时密钥 API
- 2026-03-24 已为线上配置文件创建备份:`/opt/yoyuzh/app.env.bak-before-chengdu``/opt/yoyuzh/application-prod.yml.bak-before-chengdu` - 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-03-23 排障确认:`api.yoyuzh.xyz` 在部分网络下存在 TLS/SNI 握手异常,但后端服务与 nginx 正常,且 IP 直连加 `Host: api.yoyuzh.xyz` 时可正常返回 - 2026-04-02 后端服务重启后为 active启动时间为 `2026-04-02 12:14:25 CST`
- 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
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出 - 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
## 参考资料 ## 参考资料
@@ -69,6 +70,8 @@
- JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java` - JWT 会话校验: `backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java`
- JWT 过滤器: `backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.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` - 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/Files.tsx``front/src/pages/files-tree.ts`
- 快传接收页: `front/src/pages/TransferReceive.tsx` - 快传接收页: `front/src/pages/TransferReceive.tsx`
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
- 前端生产 API 基址: `front/.env.production` - 前端生产 API 基址: `front/.env.production`

View File

@@ -7,7 +7,7 @@ import {spawnSync} from 'node:child_process';
import { import {
buildObjectKey, buildObjectKey,
createAuthorizationHeader, createAwsV4Headers,
encodeObjectKey, encodeObjectKey,
getFrontendSpaAliasContentType, getFrontendSpaAliasContentType,
getFrontendSpaAliasKeys, getFrontendSpaAliasKeys,
@@ -16,6 +16,7 @@ import {
listFiles, listFiles,
normalizeEndpoint, normalizeEndpoint,
parseSimpleEnv, parseSimpleEnv,
requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs'; } from './oss-deploy-lib.mjs';
const repoRoot = process.cwd(); const repoRoot = process.cwd();
@@ -72,34 +73,39 @@ function runBuild() {
async function uploadFile({ async function uploadFile({
bucket, bucket,
endpoint, endpoint,
region,
objectKey, objectKey,
filePath, filePath,
contentTypeOverride, contentTypeOverride,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
}) { }) {
const body = await fs.readFile(filePath); const body = await fs.readFile(filePath);
const contentType = contentTypeOverride || getContentType(objectKey); 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 url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
const authorization = createAuthorizationHeader({ const signatureHeaders = createAwsV4Headers({
method: 'PUT', method: 'PUT',
endpoint,
region,
bucket, bucket,
objectKey, objectKey,
contentType, headers: {
date, 'Content-Type': contentType,
},
amzDate,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
}); });
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: authorization, ...signatureHeaders,
'Cache-Control': getCacheControl(objectKey), 'Cache-Control': getCacheControl(objectKey),
'Content-Length': String(body.byteLength), 'Content-Length': String(body.byteLength),
'Content-Type': contentType,
Date: date,
}, },
body, body,
}); });
@@ -113,9 +119,11 @@ async function uploadFile({
async function uploadSpaAliases({ async function uploadSpaAliases({
bucket, bucket,
endpoint, endpoint,
region,
distIndexPath, distIndexPath,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
remotePrefix, remotePrefix,
dryRun, dryRun,
}) { }) {
@@ -133,11 +141,13 @@ async function uploadSpaAliases({
await uploadFile({ await uploadFile({
bucket, bucket,
endpoint, endpoint,
region,
objectKey, objectKey,
filePath: distIndexPath, filePath: distIndexPath,
contentTypeOverride: contentType, contentTypeOverride: contentType,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
}); });
console.log(`uploaded alias ${objectKey}`); console.log(`uploaded alias ${objectKey}`);
} }
@@ -148,11 +158,26 @@ async function main() {
await loadEnvFileIfPresent(); await loadEnvFileIfPresent();
const accessKeyId = requireEnv('YOYUZH_OSS_ACCESS_KEY_ID'); const apiAccessKey = requireEnv('YOYUZH_DOGECLOUD_API_ACCESS_KEY');
const accessKeySecret = requireEnv('YOYUZH_OSS_ACCESS_KEY_SECRET'); const apiSecretKey = requireEnv('YOYUZH_DOGECLOUD_API_SECRET_KEY');
const endpoint = requireEnv('YOYUZH_OSS_ENDPOINT'); const scope = requireEnv('YOYUZH_DOGECLOUD_FRONT_SCOPE');
const bucket = requireEnv('YOYUZH_OSS_BUCKET'); const apiBaseUrl = process.env.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
const remotePrefix = process.env.YOYUZH_OSS_PREFIX || ''; 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) { if (!skipBuild) {
runBuild(); runBuild();
@@ -175,10 +200,12 @@ async function main() {
await uploadFile({ await uploadFile({
bucket, bucket,
endpoint, endpoint,
region,
objectKey, objectKey,
filePath, filePath,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
}); });
console.log(`uploaded ${objectKey}`); console.log(`uploaded ${objectKey}`);
} }
@@ -186,9 +213,11 @@ async function main() {
await uploadSpaAliases({ await uploadSpaAliases({
bucket, bucket,
endpoint, endpoint,
region,
distIndexPath: path.join(distDir, 'index.html'), distIndexPath: path.join(distDir, 'index.html'),
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
remotePrefix, remotePrefix,
dryRun, dryRun,
}); });

View 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;
});
}

View 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',
});
});

View File

@@ -3,12 +3,13 @@ import {constants as fsConstants} from 'node:fs';
import {spawn} from 'node:child_process'; import {spawn} from 'node:child_process';
import https from 'node:https'; import https from 'node:https';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto';
import { import {
createAwsV4Headers,
normalizeEndpoint, normalizeEndpoint,
parseSimpleEnv, parseSimpleEnv,
encodeObjectKey, encodeObjectKey,
requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs'; } from './oss-deploy-lib.mjs';
const DEFAULTS = { const DEFAULTS = {
@@ -16,7 +17,8 @@ const DEFAULTS = {
storageRoot: '/opt/yoyuzh/storage', storageRoot: '/opt/yoyuzh/storage',
database: 'yoyuzh_portal', database: 'yoyuzh_portal',
bucket: 'yoyuzh-files', bucket: 'yoyuzh-files',
endpoint: 'https://oss-ap-northeast-1.aliyuncs.com', endpoint: 'https://cos.ap-chengdu.myqcloud.com',
region: 'automatic',
}; };
function parseArgs(argv) { 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) { async function readAppEnv(appEnvPath) {
const raw = await fs.readFile(appEnvPath, 'utf8'); const raw = await fs.readFile(appEnvPath, 'utf8');
return parseSimpleEnv(raw); return parseSimpleEnv(raw);
@@ -178,20 +149,34 @@ async function queryFiles(database) {
}); });
} }
function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKeySecret, headers = {}, query = '', body}) { function s3Request({
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({
method, method,
endpoint,
region,
bucket, bucket,
objectKey, objectKey,
contentType,
date,
accessKeyId, 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, headers,
amzDate,
accessKeyId,
secretAccessKey,
sessionToken,
}); });
const request = https.request({ const request = https.request({
@@ -199,9 +184,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`, path: `/${encodeObjectKey(objectKey)}${query ? `?${query}` : ''}`,
method, method,
headers: { headers: {
Date: date, ...signatureHeaders,
Authorization: auth,
...headers,
}, },
}, (response) => { }, (response) => {
let data = ''; let data = '';
@@ -229,7 +212,7 @@ function ossRequest({method, endpoint, bucket, objectKey, accessKeyId, accessKey
} }
async function objectExists(context, objectKey) { async function objectExists(context, objectKey) {
const response = await ossRequest({ const response = await s3Request({
...context, ...context,
method: 'HEAD', method: 'HEAD',
objectKey, objectKey,
@@ -243,7 +226,7 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
const stat = await fileHandle.stat(); const stat = await fileHandle.stat();
try { try {
const response = await ossRequest({ const response = await s3Request({
...context, ...context,
method: 'PUT', method: 'PUT',
objectKey, objectKey,
@@ -263,12 +246,12 @@ async function uploadLocalFile(context, objectKey, absolutePath, contentType = '
} }
async function copyObject(context, sourceKey, targetKey) { async function copyObject(context, sourceKey, targetKey) {
const response = await ossRequest({ const response = await s3Request({
...context, ...context,
method: 'PUT', method: 'PUT',
objectKey: targetKey, objectKey: targetKey,
headers: { 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) { async function deleteObject(context, objectKey) {
const response = await ossRequest({ const response = await s3Request({
...context, ...context,
method: 'DELETE', method: 'DELETE',
objectKey, objectKey,
@@ -318,7 +301,7 @@ async function listObjects(context, prefix) {
query.set('continuation-token', continuationToken); query.set('continuation-token', continuationToken);
} }
const response = await ossRequest({ const response = await s3Request({
...context, ...context,
method: 'GET', method: 'GET',
objectKey: '', objectKey: '',
@@ -370,21 +353,38 @@ async function buildArchivedObjectMap(context, files) {
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
const appEnv = await readAppEnv(options.appEnvPath); const appEnv = await readAppEnv(options.appEnvPath);
const endpoint = appEnv.YOYUZH_OSS_ENDPOINT || DEFAULTS.endpoint; const apiAccessKey = appEnv.YOYUZH_DOGECLOUD_API_ACCESS_KEY;
const bucket = options.bucket; const apiSecretKey = appEnv.YOYUZH_DOGECLOUD_API_SECRET_KEY;
const accessKeyId = appEnv.YOYUZH_OSS_ACCESS_KEY_ID; const scope = appEnv.YOYUZH_DOGECLOUD_STORAGE_SCOPE || options.bucket;
const accessKeySecret = appEnv.YOYUZH_OSS_ACCESS_KEY_SECRET; const apiBaseUrl = appEnv.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
const region = appEnv.YOYUZH_DOGECLOUD_S3_REGION || DEFAULTS.region;
if (!accessKeyId || !accessKeySecret) { if (!apiAccessKey || !apiSecretKey || !scope) {
throw new Error('Missing OSS credentials in app env'); 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 files = await queryFiles(options.database);
const context = { const context = {
endpoint, endpoint,
region,
bucket, bucket,
accessKeyId, accessKeyId,
accessKeySecret, secretAccessKey,
sessionToken,
}; };
const archivedObjectsByKey = await buildArchivedObjectMap(context, files); const archivedObjectsByKey = await buildArchivedObjectMap(context, files);

View File

@@ -68,29 +68,179 @@ export function getFrontendSpaAliasContentType() {
return 'text/html; charset=utf-8'; 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, method,
endpoint,
bucket, bucket,
objectKey, objectKey,
contentType, query = '',
date, headers: extraHeaders = {},
amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''),
accessKeyId, 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(), method.toUpperCase(),
'', canonicalUri,
contentType, canonicalQueryString,
date, canonicalHeadersText,
`/${bucket}/${objectKey}`, signedHeaders,
payloadHash,
].join('\n'); ].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 const resultHeaders = {
.createHmac('sha1', accessKeySecret) Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
.update(stringToSign) 'x-amz-content-sha256': payloadHash,
.digest('base64'); 'x-amz-date': amzDate,
...extraHeaders,
};
return `OSS ${accessKeyId}:${signature}`; if (sessionToken) {
resultHeaders['x-amz-security-token'] = sessionToken;
}
return resultHeaders;
} }
export function encodeObjectKey(objectKey) { export function encodeObjectKey(objectKey) {

View File

@@ -3,12 +3,15 @@ import test from 'node:test';
import { import {
buildObjectKey, buildObjectKey,
createAuthorizationHeader, createDogeCloudApiAuthorization,
createAwsV4Headers,
extractDogeCloudScopeBucketName,
getFrontendSpaAliasContentType, getFrontendSpaAliasContentType,
getFrontendSpaAliasKeys, getFrontendSpaAliasKeys,
getCacheControl, getCacheControl,
getContentType, getContentType,
normalizeEndpoint, normalizeEndpoint,
requestDogeCloudTemporaryS3Session,
} from './oss-deploy-lib.mjs'; } from './oss-deploy-lib.mjs';
test('normalizeEndpoint strips scheme and trailing slashes', () => { 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'); assert.equal(getFrontendSpaAliasContentType(), 'text/html; charset=utf-8');
}); });
test('createAuthorizationHeader is stable for a known request', () => { test('createAwsV4Headers signs uploads with S3-compatible SigV4 headers', () => {
const header = createAuthorizationHeader({ const headers = createAwsV4Headers({
method: 'PUT', method: 'PUT',
endpoint: 'https://cos.ap-chengdu.myqcloud.com',
bucket: 'demo-bucket', bucket: 'demo-bucket',
objectKey: 'assets/index.js', objectKey: 'assets/index.js',
contentType: 'text/javascript; charset=utf-8', contentType: 'text/javascript; charset=utf-8',
date: 'Tue, 17 Mar 2026 12:00:00 GMT', amzDate: '20260317T120000Z',
accessKeyId: 'test-id', 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,
});
}); });