feat: ship portal and android release updates
This commit is contained in:
@@ -30,3 +30,10 @@ YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS="3600"
|
|||||||
# 可选:上传到桶内的子目录。
|
# 可选:上传到桶内的子目录。
|
||||||
# 为空表示直接上传到桶根目录。
|
# 为空表示直接上传到桶根目录。
|
||||||
YOYUZH_DOGECLOUD_FRONT_PREFIX=""
|
YOYUZH_DOGECLOUD_FRONT_PREFIX=""
|
||||||
|
|
||||||
|
# Android APK 发布默认走文件桶;如需单独逻辑桶,可显式填写。
|
||||||
|
YOYUZH_DOGECLOUD_STORAGE_SCOPE="yoyuzh-files"
|
||||||
|
YOYUZH_DOGECLOUD_ANDROID_SCOPE="yoyuzh-files"
|
||||||
|
|
||||||
|
# APK 与元数据在文件桶中的发布前缀。
|
||||||
|
YOYUZH_ANDROID_RELEASE_PREFIX="android/releases"
|
||||||
|
|||||||
9
.env.oss.local
Normal file
9
.env.oss.local
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
YOYUZH_DOGECLOUD_API_BASE_URL="https://api.dogecloud.com"
|
||||||
|
YOYUZH_DOGECLOUD_API_ACCESS_KEY="eb8e79012b435492"
|
||||||
|
YOYUZH_DOGECLOUD_API_SECRET_KEY="3b9f241e61762f382ab2b6f88b9b4345"
|
||||||
|
YOYUZH_DOGECLOUD_S3_REGION="automatic"
|
||||||
|
YOYUZH_DOGECLOUD_FRONT_SCOPE="yoyuzh-front"
|
||||||
|
YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS="3600"
|
||||||
|
YOYUZH_DOGECLOUD_FRONT_PREFIX=""
|
||||||
|
YOYUZH_DOGECLOUD_STORAGE_SCOPE="yoyuzh-files"
|
||||||
|
YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS="3600"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,12 +15,6 @@ frontend-dev.out.log
|
|||||||
frontend-dev.err.log
|
frontend-dev.err.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
.env.oss.local
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
front/.env.production
|
|
||||||
|
|
||||||
账号密码.txt
|
|
||||||
.history/
|
.history/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -30,7 +24,6 @@ front/.env.production
|
|||||||
!.codex/agents/
|
!.codex/agents/
|
||||||
.codex/agents/*
|
.codex/agents/*
|
||||||
!.codex/agents/*.toml
|
!.codex/agents/*.toml
|
||||||
开发测试账号.md
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
10
AGENTS.md
10
AGENTS.md
@@ -42,6 +42,8 @@ Important: there is no dedicated backend lint command and no dedicated backend t
|
|||||||
|
|
||||||
### Script files
|
### Script files
|
||||||
|
|
||||||
|
- `scripts/deploy-android-apk.mjs`
|
||||||
|
- `scripts/deploy-android-release.mjs`
|
||||||
- `scripts/deploy-front-oss.mjs`
|
- `scripts/deploy-front-oss.mjs`
|
||||||
- `scripts/migrate-file-storage-to-oss.mjs`
|
- `scripts/migrate-file-storage-to-oss.mjs`
|
||||||
- `scripts/oss-deploy-lib.mjs`
|
- `scripts/oss-deploy-lib.mjs`
|
||||||
@@ -54,6 +56,10 @@ If you need one of these, run it explicitly from the file that already exists in
|
|||||||
|
|
||||||
### Release and deploy commands
|
### Release and deploy commands
|
||||||
|
|
||||||
|
- Android APK build + OSS publish from repo root:
|
||||||
|
`node scripts/deploy-android-apk.mjs`
|
||||||
|
- Android APK publish only from repo root:
|
||||||
|
`node scripts/deploy-android-release.mjs`
|
||||||
- Frontend OSS publish from repo root:
|
- Frontend OSS publish from repo root:
|
||||||
`node scripts/deploy-front-oss.mjs`
|
`node scripts/deploy-front-oss.mjs`
|
||||||
- Frontend OSS dry run from repo root:
|
- Frontend OSS dry run from repo root:
|
||||||
@@ -65,7 +71,9 @@ If you need one of these, run it explicitly from the file that already exists in
|
|||||||
|
|
||||||
Important:
|
Important:
|
||||||
|
|
||||||
- `scripts/deploy-front-oss.mjs` expects OSS credentials from environment variables or `.env.oss.local`.
|
- `scripts/deploy-android-apk.mjs` 会顺序执行前端构建、`npx cap sync android`、Android `assembleDebug`、前端静态站发布,以及独立的 APK 发布脚本,并自动补回 `capacitor-cordova-android-plugins/build.gradle` 里的 Google Maven 镜像配置。
|
||||||
|
- `scripts/deploy-android-release.mjs` 只负责把 APK 和 `android/releases/latest.json` 发布到 Android 独立对象路径,默认复用文件桶 scope,而不是前端静态桶。
|
||||||
|
- `scripts/deploy-front-oss.mjs` 现在只发布 `front/dist` 静态站资源,不再上传 APK。
|
||||||
- The repository does not currently contain a checked-in backend deploy script. Backend delivery is therefore a two-step process: build `backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar`, then upload/restart it via `ssh` or `scp` using the real target host and remote procedure that are available at deploy time.
|
- The repository does not currently contain a checked-in backend deploy script. Backend delivery is therefore a two-step process: build `backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar`, then upload/restart it via `ssh` or `scp` using the real target host and remote procedure that are available at deploy time.
|
||||||
- Do not invent a backend service name, process manager, remote directory, or restart command. Discover them from the server or ask only if they cannot be discovered safely.
|
- Do not invent a backend service name, process manager, remote directory, or restart command. Discover them from the server or ask only if they cannot be discovered safely.
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -181,6 +181,9 @@ YOYUZH_DOGECLOUD_API_SECRET_KEY=...
|
|||||||
YOYUZH_DOGECLOUD_FRONT_SCOPE=yoyuzh-front
|
YOYUZH_DOGECLOUD_FRONT_SCOPE=yoyuzh-front
|
||||||
YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS=3600
|
YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS=3600
|
||||||
YOYUZH_DOGECLOUD_FRONT_PREFIX=
|
YOYUZH_DOGECLOUD_FRONT_PREFIX=
|
||||||
|
YOYUZH_DOGECLOUD_STORAGE_SCOPE=yoyuzh-files
|
||||||
|
YOYUZH_DOGECLOUD_ANDROID_SCOPE=yoyuzh-files
|
||||||
|
YOYUZH_ANDROID_RELEASE_PREFIX=android/releases
|
||||||
```
|
```
|
||||||
|
|
||||||
参考文件:
|
参考文件:
|
||||||
@@ -205,6 +208,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Android APK 发包
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/deploy-android-apk.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
这个脚本会自动完成以下步骤:
|
||||||
|
|
||||||
|
- `cd front && npm run build`
|
||||||
|
- `cd front && npx cap sync android`
|
||||||
|
- 自动补回 Android 插件工程里的 Google Maven 镜像配置
|
||||||
|
- `cd front/android && ./gradlew assembleDebug`
|
||||||
|
- `node scripts/deploy-front-oss.mjs --skip-build`
|
||||||
|
- `node scripts/deploy-android-release.mjs`
|
||||||
|
|
||||||
|
如果只想重新上传 APK,不想重发前端静态站,可以直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/deploy-android-release.mjs
|
||||||
|
```
|
||||||
|
|
||||||
### 阿里云 OSS 到多吉云 S3 迁移
|
### 阿里云 OSS 到多吉云 S3 迁移
|
||||||
|
|
||||||
静态站点桶或文件桶需要整桶迁移时,可在仓库根目录执行:
|
静态站点桶或文件桶需要整桶迁移时,可在仓库根目录执行:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yoyuzh;
|
package com.yoyuzh;
|
||||||
|
|
||||||
import com.yoyuzh.config.AdminProperties;
|
import com.yoyuzh.config.AdminProperties;
|
||||||
|
import com.yoyuzh.config.AndroidReleaseProperties;
|
||||||
import com.yoyuzh.config.CorsProperties;
|
import com.yoyuzh.config.CorsProperties;
|
||||||
import com.yoyuzh.config.FileStorageProperties;
|
import com.yoyuzh.config.FileStorageProperties;
|
||||||
import com.yoyuzh.config.JwtProperties;
|
import com.yoyuzh.config.JwtProperties;
|
||||||
@@ -15,7 +16,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||||||
FileStorageProperties.class,
|
FileStorageProperties.class,
|
||||||
CorsProperties.class,
|
CorsProperties.class,
|
||||||
AdminProperties.class,
|
AdminProperties.class,
|
||||||
RegistrationProperties.class
|
RegistrationProperties.class,
|
||||||
|
AndroidReleaseProperties.class
|
||||||
})
|
})
|
||||||
public class PortalBackendApplication {
|
public class PortalBackendApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.yoyuzh.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/app/android")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidReleaseController {
|
||||||
|
|
||||||
|
private final AndroidReleaseService androidReleaseService;
|
||||||
|
|
||||||
|
@GetMapping("/latest")
|
||||||
|
public ApiResponse<AndroidReleaseResponse> getLatestRelease() {
|
||||||
|
return ApiResponse.success(androidReleaseService.getLatestRelease());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download")
|
||||||
|
public ResponseEntity<byte[]> downloadLatestRelease() {
|
||||||
|
return buildDownloadResponse(androidReleaseService.downloadLatestRelease());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download/{fileName:.+}")
|
||||||
|
public ResponseEntity<byte[]> downloadVersionedRelease(@PathVariable String fileName) {
|
||||||
|
AndroidReleaseDownload download = androidReleaseService.downloadLatestRelease();
|
||||||
|
if (!download.fileName().equals(fileName)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return buildDownloadResponse(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<byte[]> buildDownloadResponse(AndroidReleaseDownload download) {
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType("application/vnd.android.package-archive"))
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||||
|
.filename(download.fileName(), StandardCharsets.UTF_8)
|
||||||
|
.build()
|
||||||
|
.toString())
|
||||||
|
.contentLength(download.content().length)
|
||||||
|
.body(download.content());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
public record AndroidReleaseDownload(
|
||||||
|
String fileName,
|
||||||
|
byte[] content
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
public record AndroidReleaseMetadata(
|
||||||
|
String objectKey,
|
||||||
|
String fileName,
|
||||||
|
String versionCode,
|
||||||
|
String versionName,
|
||||||
|
String publishedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "app.android")
|
||||||
|
public class AndroidReleaseProperties {
|
||||||
|
|
||||||
|
private String metadataObjectKey = "android/releases/latest.json";
|
||||||
|
private String downloadPublicUrl = "https://api.yoyuzh.xyz/api/app/android/download";
|
||||||
|
|
||||||
|
public String getMetadataObjectKey() {
|
||||||
|
return metadataObjectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetadataObjectKey(String metadataObjectKey) {
|
||||||
|
this.metadataObjectKey = metadataObjectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDownloadPublicUrl() {
|
||||||
|
return downloadPublicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDownloadPublicUrl(String downloadPublicUrl) {
|
||||||
|
this.downloadPublicUrl = downloadPublicUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
public record AndroidReleaseResponse(
|
||||||
|
String downloadUrl,
|
||||||
|
String fileName,
|
||||||
|
String versionCode,
|
||||||
|
String versionName,
|
||||||
|
String publishedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yoyuzh.common.BusinessException;
|
||||||
|
import com.yoyuzh.common.ErrorCode;
|
||||||
|
import com.yoyuzh.files.storage.FileContentStorage;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidReleaseService {
|
||||||
|
|
||||||
|
private final FileContentStorage fileContentStorage;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AndroidReleaseProperties androidReleaseProperties;
|
||||||
|
|
||||||
|
public AndroidReleaseResponse getLatestRelease() {
|
||||||
|
AndroidReleaseMetadata metadata = loadReleaseMetadata();
|
||||||
|
return new AndroidReleaseResponse(
|
||||||
|
buildVersionedDownloadUrl(metadata),
|
||||||
|
metadata.fileName(),
|
||||||
|
metadata.versionCode(),
|
||||||
|
metadata.versionName(),
|
||||||
|
metadata.publishedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildLatestDownloadUrl() {
|
||||||
|
return androidReleaseProperties.getDownloadPublicUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AndroidReleaseDownload downloadLatestRelease() {
|
||||||
|
AndroidReleaseMetadata metadata = loadReleaseMetadata();
|
||||||
|
String objectKey = metadata.objectKey();
|
||||||
|
String fileName = metadata.fileName();
|
||||||
|
if (objectKey == null || objectKey.isBlank() || fileName == null || fileName.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "Android 安装包暂未发布");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AndroidReleaseDownload(
|
||||||
|
fileName,
|
||||||
|
fileContentStorage.readBlob(objectKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildVersionedDownloadUrl(AndroidReleaseMetadata metadata) {
|
||||||
|
String fileName = metadata.fileName();
|
||||||
|
if (fileName == null || fileName.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "Android 安装包暂未发布");
|
||||||
|
}
|
||||||
|
|
||||||
|
String baseUrl = androidReleaseProperties.getDownloadPublicUrl();
|
||||||
|
String separator = baseUrl.endsWith("/") ? "" : "/";
|
||||||
|
return baseUrl + separator + URI.create("https://placeholder/" + fileName).getPath().substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateReleaseMetadata(AndroidReleaseMetadata metadata) {
|
||||||
|
String objectKey = metadata.objectKey();
|
||||||
|
String fileName = metadata.fileName();
|
||||||
|
if (objectKey == null || objectKey.isBlank() || fileName == null || fileName.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "Android 安装包暂未发布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AndroidReleaseMetadata loadReleaseMetadata() {
|
||||||
|
String metadataObjectKey = androidReleaseProperties.getMetadataObjectKey();
|
||||||
|
if (metadataObjectKey == null || metadataObjectKey.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "Android 安装包暂未发布");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] content = fileContentStorage.readBlob(metadataObjectKey);
|
||||||
|
AndroidReleaseMetadata metadata = objectMapper.readValue(content, AndroidReleaseMetadata.class);
|
||||||
|
if (metadata == null) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "Android 安装包暂未发布");
|
||||||
|
}
|
||||||
|
validateReleaseMetadata(metadata);
|
||||||
|
return metadata;
|
||||||
|
} catch (BusinessException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new BusinessException(ErrorCode.UNKNOWN, "Android 安装包元数据读取失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,10 @@ public class FileStorageProperties {
|
|||||||
private String scope;
|
private String scope;
|
||||||
private int ttlSeconds = 3600;
|
private int ttlSeconds = 3600;
|
||||||
private String region = "automatic";
|
private String region = "automatic";
|
||||||
|
private String publicDownloadBaseUrl;
|
||||||
|
private String packageDownloadBaseUrl;
|
||||||
|
private String packageDownloadSecret;
|
||||||
|
private int packageDownloadTtlSeconds = 300;
|
||||||
|
|
||||||
public String getApiBaseUrl() {
|
public String getApiBaseUrl() {
|
||||||
return apiBaseUrl;
|
return apiBaseUrl;
|
||||||
@@ -110,5 +114,37 @@ public class FileStorageProperties {
|
|||||||
public void setRegion(String region) {
|
public void setRegion(String region) {
|
||||||
this.region = region;
|
this.region = region;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getPublicDownloadBaseUrl() {
|
||||||
|
return publicDownloadBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicDownloadBaseUrl(String publicDownloadBaseUrl) {
|
||||||
|
this.publicDownloadBaseUrl = publicDownloadBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPackageDownloadBaseUrl() {
|
||||||
|
return packageDownloadBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPackageDownloadBaseUrl(String packageDownloadBaseUrl) {
|
||||||
|
this.packageDownloadBaseUrl = packageDownloadBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPackageDownloadSecret() {
|
||||||
|
return packageDownloadSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPackageDownloadSecret(String packageDownloadSecret) {
|
||||||
|
this.packageDownloadSecret = packageDownloadSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPackageDownloadTtlSeconds() {
|
||||||
|
return packageDownloadTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPackageDownloadTtlSeconds(int packageDownloadTtlSeconds) {
|
||||||
|
this.packageDownloadTtlSeconds = packageDownloadTtlSeconds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yoyuzh.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -9,6 +10,9 @@ public class RestClientConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public RestClient restClient(RestClient.Builder builder) {
|
public RestClient restClient(RestClient.Builder builder) {
|
||||||
return builder.build();
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout(3000);
|
||||||
|
requestFactory.setReadTimeout(5000);
|
||||||
|
return builder.requestFactory(requestFactory).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
|
.requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*")
|
||||||
|
.permitAll()
|
||||||
.requestMatchers("/api/transfer/**")
|
.requestMatchers("/api/transfer/**")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.yoyuzh.common.PageResponse;
|
|||||||
import com.yoyuzh.config.FileStorageProperties;
|
import com.yoyuzh.config.FileStorageProperties;
|
||||||
import com.yoyuzh.files.storage.FileContentStorage;
|
import com.yoyuzh.files.storage.FileContentStorage;
|
||||||
import com.yoyuzh.files.storage.PreparedUpload;
|
import com.yoyuzh.files.storage.PreparedUpload;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -24,8 +25,12 @@ import java.io.IOException;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@@ -34,6 +39,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
@@ -49,19 +55,42 @@ public class FileService {
|
|||||||
private final FileShareLinkRepository fileShareLinkRepository;
|
private final FileShareLinkRepository fileShareLinkRepository;
|
||||||
private final AdminMetricsService adminMetricsService;
|
private final AdminMetricsService adminMetricsService;
|
||||||
private final long maxFileSize;
|
private final long maxFileSize;
|
||||||
|
private final String packageDownloadBaseUrl;
|
||||||
|
private final String packageDownloadSecret;
|
||||||
|
private final long packageDownloadTtlSeconds;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public FileService(StoredFileRepository storedFileRepository,
|
public FileService(StoredFileRepository storedFileRepository,
|
||||||
FileBlobRepository fileBlobRepository,
|
FileBlobRepository fileBlobRepository,
|
||||||
FileContentStorage fileContentStorage,
|
FileContentStorage fileContentStorage,
|
||||||
FileShareLinkRepository fileShareLinkRepository,
|
FileShareLinkRepository fileShareLinkRepository,
|
||||||
AdminMetricsService adminMetricsService,
|
AdminMetricsService adminMetricsService,
|
||||||
FileStorageProperties properties) {
|
FileStorageProperties properties) {
|
||||||
|
this(storedFileRepository, fileBlobRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
FileService(StoredFileRepository storedFileRepository,
|
||||||
|
FileBlobRepository fileBlobRepository,
|
||||||
|
FileContentStorage fileContentStorage,
|
||||||
|
FileShareLinkRepository fileShareLinkRepository,
|
||||||
|
AdminMetricsService adminMetricsService,
|
||||||
|
FileStorageProperties properties,
|
||||||
|
Clock clock) {
|
||||||
this.storedFileRepository = storedFileRepository;
|
this.storedFileRepository = storedFileRepository;
|
||||||
this.fileBlobRepository = fileBlobRepository;
|
this.fileBlobRepository = fileBlobRepository;
|
||||||
this.fileContentStorage = fileContentStorage;
|
this.fileContentStorage = fileContentStorage;
|
||||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||||
this.adminMetricsService = adminMetricsService;
|
this.adminMetricsService = adminMetricsService;
|
||||||
this.maxFileSize = properties.getMaxFileSize();
|
this.maxFileSize = properties.getMaxFileSize();
|
||||||
|
this.packageDownloadBaseUrl = StringUtils.hasText(properties.getS3().getPackageDownloadBaseUrl())
|
||||||
|
? properties.getS3().getPackageDownloadBaseUrl().trim()
|
||||||
|
: null;
|
||||||
|
this.packageDownloadSecret = StringUtils.hasText(properties.getS3().getPackageDownloadSecret())
|
||||||
|
? properties.getS3().getPackageDownloadSecret().trim()
|
||||||
|
: null;
|
||||||
|
this.packageDownloadTtlSeconds = Math.max(1, properties.getS3().getPackageDownloadTtlSeconds());
|
||||||
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -370,6 +399,12 @@ public class FileService {
|
|||||||
return downloadDirectory(user, storedFile);
|
return downloadDirectory(user, storedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldUsePublicPackageDownload(storedFile)) {
|
||||||
|
return ResponseEntity.status(302)
|
||||||
|
.location(URI.create(buildPublicPackageDownloadUrl(storedFile)))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
if (fileContentStorage.supportsDirectDownload()) {
|
if (fileContentStorage.supportsDirectDownload()) {
|
||||||
return ResponseEntity.status(302)
|
return ResponseEntity.status(302)
|
||||||
.location(URI.create(fileContentStorage.createBlobDownloadUrl(
|
.location(URI.create(fileContentStorage.createBlobDownloadUrl(
|
||||||
@@ -393,6 +428,10 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
||||||
|
|
||||||
|
if (shouldUsePublicPackageDownload(storedFile)) {
|
||||||
|
return new DownloadUrlResponse(buildPublicPackageDownloadUrl(storedFile));
|
||||||
|
}
|
||||||
|
|
||||||
if (fileContentStorage.supportsDirectDownload()) {
|
if (fileContentStorage.supportsDirectDownload()) {
|
||||||
return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl(
|
return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl(
|
||||||
getRequiredBlob(storedFile).getObjectKey(),
|
getRequiredBlob(storedFile).getObjectKey(),
|
||||||
@@ -521,6 +560,117 @@ public class FileService {
|
|||||||
.body(archiveBytes);
|
.body(archiveBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldUsePublicPackageDownload(StoredFile storedFile) {
|
||||||
|
return fileContentStorage.supportsDirectDownload()
|
||||||
|
&& StringUtils.hasText(packageDownloadBaseUrl)
|
||||||
|
&& StringUtils.hasText(packageDownloadSecret)
|
||||||
|
&& isAppPackage(storedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAppPackage(StoredFile storedFile) {
|
||||||
|
String filename = storedFile.getFilename() == null ? "" : storedFile.getFilename().toLowerCase(Locale.ROOT);
|
||||||
|
String contentType = storedFile.getContentType() == null ? "" : storedFile.getContentType().toLowerCase(Locale.ROOT);
|
||||||
|
return filename.endsWith(".apk")
|
||||||
|
|| filename.endsWith(".ipa")
|
||||||
|
|| "application/vnd.android.package-archive".equals(contentType)
|
||||||
|
|| "application/octet-stream".equals(contentType) && (filename.endsWith(".apk") || filename.endsWith(".ipa"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPublicPackageDownloadUrl(StoredFile storedFile) {
|
||||||
|
FileBlob blob = getRequiredBlob(storedFile);
|
||||||
|
String base = packageDownloadBaseUrl.endsWith("/")
|
||||||
|
? packageDownloadBaseUrl.substring(0, packageDownloadBaseUrl.length() - 1)
|
||||||
|
: packageDownloadBaseUrl;
|
||||||
|
String path = "/" + trimLeadingSlash(blob.getObjectKey());
|
||||||
|
if (base.endsWith("/_dl")) {
|
||||||
|
path = "/_dl" + path;
|
||||||
|
}
|
||||||
|
long expires = clock.instant().getEpochSecond() + packageDownloadTtlSeconds;
|
||||||
|
String signature = buildSecureLinkSignature(path, expires);
|
||||||
|
return base
|
||||||
|
+ "/"
|
||||||
|
+ trimLeadingSlash(blob.getObjectKey())
|
||||||
|
+ "?md5="
|
||||||
|
+ encodeQueryParam(signature)
|
||||||
|
+ "&expires="
|
||||||
|
+ expires
|
||||||
|
+ "&response-content-disposition="
|
||||||
|
+ encodeQueryParam(buildAsciiContentDisposition(storedFile.getFilename()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAsciiContentDisposition(String filename) {
|
||||||
|
String sanitized = sanitizeDownloadFilename(filename);
|
||||||
|
StringBuilder disposition = new StringBuilder("attachment; filename=\"")
|
||||||
|
.append(escapeContentDispositionFilename(buildAsciiDownloadFilename(sanitized)))
|
||||||
|
.append("\"");
|
||||||
|
if (StringUtils.hasText(sanitized)) {
|
||||||
|
disposition.append("; filename*=UTF-8''")
|
||||||
|
.append(sanitized);
|
||||||
|
}
|
||||||
|
return disposition.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAsciiDownloadFilename(String filename) {
|
||||||
|
String normalized = sanitizeDownloadFilename(filename);
|
||||||
|
if (!StringUtils.hasText(normalized)) {
|
||||||
|
return "download";
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitized = normalized.replaceAll("[\\r\\n]", "_");
|
||||||
|
StringBuilder ascii = new StringBuilder(sanitized.length());
|
||||||
|
for (int i = 0; i < sanitized.length(); i++) {
|
||||||
|
char current = sanitized.charAt(i);
|
||||||
|
if (current >= 32 && current <= 126 && current != '"' && current != '\\') {
|
||||||
|
ascii.append(current);
|
||||||
|
} else {
|
||||||
|
ascii.append('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String fallback = ascii.toString().trim();
|
||||||
|
String extension = extractAsciiExtension(normalized);
|
||||||
|
String baseName = extension.isEmpty() ? fallback : fallback.substring(0, Math.max(0, fallback.length() - extension.length()));
|
||||||
|
if (baseName.replace("_", "").isBlank()) {
|
||||||
|
return extension.isEmpty() ? "download" : "download" + extension;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeDownloadFilename(String filename) {
|
||||||
|
return StringUtils.hasText(filename) ? filename.trim().replaceAll("[\\r\\n]", "_") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractAsciiExtension(String filename) {
|
||||||
|
int extensionIndex = filename.lastIndexOf('.');
|
||||||
|
if (extensionIndex > 0 && extensionIndex < filename.length() - 1) {
|
||||||
|
String extension = filename.substring(extensionIndex).replaceAll("[^A-Za-z0-9.]", "");
|
||||||
|
return StringUtils.hasText(extension) ? extension : "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeContentDispositionFilename(String filename) {
|
||||||
|
return filename.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimLeadingSlash(String value) {
|
||||||
|
return value.startsWith("/") ? value.substring(1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSecureLinkSignature(String path, long expires) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||||
|
byte[] hash = digest.digest((expires + path + " " + packageDownloadSecret).getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("生成下载签名失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encodeQueryParam(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
}
|
||||||
|
|
||||||
private FileMetadataResponse saveFileMetadata(User user,
|
private FileMetadataResponse saveFileMetadata(User user,
|
||||||
String normalizedPath,
|
String normalizedPath,
|
||||||
String filename,
|
String filename,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ app:
|
|||||||
usernames: ${APP_ADMIN_USERNAMES:}
|
usernames: ${APP_ADMIN_USERNAMES:}
|
||||||
registration:
|
registration:
|
||||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
|
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
|
||||||
|
android:
|
||||||
|
metadata-object-key: ${YOYUZH_ANDROID_RELEASE_METADATA_OBJECT_KEY:android/releases/latest.json}
|
||||||
|
download-public-url: ${YOYUZH_ANDROID_DOWNLOAD_PUBLIC_URL:https://api.yoyuzh.xyz/api/app/android/download}
|
||||||
storage:
|
storage:
|
||||||
provider: ${YOYUZH_STORAGE_PROVIDER:local}
|
provider: ${YOYUZH_STORAGE_PROVIDER:local}
|
||||||
max-file-size: ${YOYUZH_STORAGE_MAX_FILE_SIZE:524288000}
|
max-file-size: ${YOYUZH_STORAGE_MAX_FILE_SIZE:524288000}
|
||||||
@@ -43,6 +46,10 @@ app:
|
|||||||
scope: ${YOYUZH_DOGECLOUD_STORAGE_SCOPE:}
|
scope: ${YOYUZH_DOGECLOUD_STORAGE_SCOPE:}
|
||||||
ttl-seconds: ${YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS:3600}
|
ttl-seconds: ${YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS:3600}
|
||||||
region: ${YOYUZH_DOGECLOUD_S3_REGION:automatic}
|
region: ${YOYUZH_DOGECLOUD_S3_REGION:automatic}
|
||||||
|
public-download-base-url: ${YOYUZH_DOGECLOUD_PUBLIC_DOWNLOAD_BASE_URL:}
|
||||||
|
package-download-base-url: ${YOYUZH_PACKAGE_DOWNLOAD_BASE_URL:https://api.yoyuzh.xyz/_dl}
|
||||||
|
package-download-secret: ${YOYUZH_PACKAGE_DOWNLOAD_SECRET:}
|
||||||
|
package-download-ttl-seconds: ${YOYUZH_PACKAGE_DOWNLOAD_TTL_SECONDS:300}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:3000
|
- http://localhost:3000
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.yoyuzh.common.GlobalExceptionHandler;
|
||||||
|
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 org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AndroidReleaseControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AndroidReleaseService androidReleaseService;
|
||||||
|
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockMvc = MockMvcBuilders.standaloneSetup(new AndroidReleaseController(androidReleaseService))
|
||||||
|
.setControllerAdvice(new GlobalExceptionHandler())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExposeLatestAndroidReleaseMetadataWithoutAuthentication() throws Exception {
|
||||||
|
AndroidReleaseResponse response = new AndroidReleaseResponse(
|
||||||
|
"https://api.yoyuzh.xyz/api/app/android/download/yoyuzh-portal-2026.04.03.1754.apk",
|
||||||
|
"yoyuzh-portal-2026.04.03.1754.apk",
|
||||||
|
"260931754",
|
||||||
|
"2026.04.03.1754",
|
||||||
|
"2026-04-03T08:33:54Z"
|
||||||
|
);
|
||||||
|
when(androidReleaseService.getLatestRelease()).thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/app/android/latest"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.downloadUrl").value("https://api.yoyuzh.xyz/api/app/android/download/yoyuzh-portal-2026.04.03.1754.apk"))
|
||||||
|
.andExpect(jsonPath("$.data.fileName").value("yoyuzh-portal-2026.04.03.1754.apk"))
|
||||||
|
.andExpect(jsonPath("$.data.versionCode").value("260931754"))
|
||||||
|
.andExpect(jsonPath("$.data.versionName").value("2026.04.03.1754"))
|
||||||
|
.andExpect(jsonPath("$.data.publishedAt").value("2026-04-03T08:33:54Z"));
|
||||||
|
|
||||||
|
verify(androidReleaseService).getLatestRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRedirectAndroidDownloadWithoutAuthentication() throws Exception {
|
||||||
|
when(androidReleaseService.downloadLatestRelease())
|
||||||
|
.thenReturn(new AndroidReleaseDownload("yoyuzh-portal-2026.04.03.1754.apk", "apk-binary".getBytes()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/app/android/download"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, org.hamcrest.Matchers.containsString("filename*=UTF-8''yoyuzh-portal-2026.04.03.1754.apk")));
|
||||||
|
|
||||||
|
verify(androidReleaseService).downloadLatestRelease();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yoyuzh.files.storage.FileContentStorage;
|
||||||
|
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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AndroidReleaseServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileContentStorage fileContentStorage;
|
||||||
|
|
||||||
|
private AndroidReleaseProperties properties;
|
||||||
|
private AndroidReleaseService androidReleaseService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
properties = new AndroidReleaseProperties();
|
||||||
|
androidReleaseService = new AndroidReleaseService(fileContentStorage, new ObjectMapper(), properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBuildLatestReleaseFromStorageMetadata() {
|
||||||
|
when(fileContentStorage.readBlob("android/releases/latest.json")).thenReturn("""
|
||||||
|
{
|
||||||
|
"objectKey": "android/releases/yoyuzh-portal-2026.04.03.1754.apk",
|
||||||
|
"fileName": "yoyuzh-portal-2026.04.03.1754.apk",
|
||||||
|
"versionCode": "260931754",
|
||||||
|
"versionName": "2026.04.03.1754",
|
||||||
|
"publishedAt": "2026-04-03T09:54:00Z"
|
||||||
|
}
|
||||||
|
""".getBytes());
|
||||||
|
|
||||||
|
AndroidReleaseResponse release = androidReleaseService.getLatestRelease();
|
||||||
|
|
||||||
|
assertEquals("https://api.yoyuzh.xyz/api/app/android/download/yoyuzh-portal-2026.04.03.1754.apk", release.downloadUrl());
|
||||||
|
assertEquals("yoyuzh-portal-2026.04.03.1754.apk", release.fileName());
|
||||||
|
assertEquals("260931754", release.versionCode());
|
||||||
|
assertEquals("2026.04.03.1754", release.versionName());
|
||||||
|
assertEquals("2026-04-03T09:54:00Z", release.publishedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReadLatestReleaseContentFromStorage() {
|
||||||
|
when(fileContentStorage.readBlob("android/releases/latest.json")).thenReturn("""
|
||||||
|
{
|
||||||
|
"objectKey": "android/releases/yoyuzh-portal-2026.04.03.1754.apk",
|
||||||
|
"fileName": "yoyuzh-portal-2026.04.03.1754.apk"
|
||||||
|
}
|
||||||
|
""".getBytes());
|
||||||
|
when(fileContentStorage.readBlob("android/releases/yoyuzh-portal-2026.04.03.1754.apk"))
|
||||||
|
.thenReturn("apk-binary".getBytes());
|
||||||
|
|
||||||
|
AndroidReleaseDownload download = androidReleaseService.downloadLatestRelease();
|
||||||
|
|
||||||
|
assertEquals("yoyuzh-portal-2026.04.03.1754.apk", download.fileName());
|
||||||
|
assertEquals("apk-binary", new String(download.content()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,16 @@ import org.springframework.data.domain.PageImpl;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.net.URI;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -500,6 +505,75 @@ class FileServiceTest {
|
|||||||
assertThat(response.url()).isEqualTo("https://download.example.com/file");
|
assertThat(response.url()).isEqualTo("https://download.example.com/file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUseDlUrlForPrivateApkWhenConfigured() {
|
||||||
|
FileStorageProperties properties = new FileStorageProperties();
|
||||||
|
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||||
|
properties.getS3().setPackageDownloadBaseUrl("https://api.yoyuzh.xyz/_dl");
|
||||||
|
properties.getS3().setPackageDownloadSecret("test-secret");
|
||||||
|
properties.getS3().setPackageDownloadTtlSeconds(300);
|
||||||
|
fileService = new FileService(
|
||||||
|
storedFileRepository,
|
||||||
|
fileBlobRepository,
|
||||||
|
fileContentStorage,
|
||||||
|
fileShareLinkRepository,
|
||||||
|
adminMetricsService,
|
||||||
|
properties,
|
||||||
|
Clock.fixed(Instant.parse("2026-04-04T04:30:00Z"), ZoneOffset.UTC)
|
||||||
|
);
|
||||||
|
|
||||||
|
User user = createUser(7L);
|
||||||
|
StoredFile file = createFile(22L, user, "/apps", "安装包.apk");
|
||||||
|
file.setContentType("application/vnd.android.package-archive");
|
||||||
|
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
|
||||||
|
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||||
|
|
||||||
|
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||||
|
|
||||||
|
URI uri = URI.create(response.url());
|
||||||
|
assertThat(uri.getScheme()).isEqualTo("https");
|
||||||
|
assertThat(uri.getHost()).isEqualTo("api.yoyuzh.xyz");
|
||||||
|
assertThat(uri.getPath()).isEqualTo("/_dl/blobs/blob-22");
|
||||||
|
assertThat(response.url()).contains("expires=1775277300");
|
||||||
|
assertThat(response.url()).contains("md5=1z0AP88pnPz-TpgnYfIT4A");
|
||||||
|
assertThat(response.url()).contains("response-content-disposition=attachment%3B%20filename%3D%22download.apk%22%3B%20filename*%3DUTF-8%27%27%E5%AE%89%E8%A3%85%E5%8C%85.apk");
|
||||||
|
verify(fileContentStorage, never()).createBlobDownloadUrl(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRedirectPrivateApkDownloadToDlWhenConfigured() {
|
||||||
|
FileStorageProperties properties = new FileStorageProperties();
|
||||||
|
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||||
|
properties.getS3().setPackageDownloadBaseUrl("https://api.yoyuzh.xyz/_dl");
|
||||||
|
properties.getS3().setPackageDownloadSecret("test-secret");
|
||||||
|
properties.getS3().setPackageDownloadTtlSeconds(300);
|
||||||
|
fileService = new FileService(
|
||||||
|
storedFileRepository,
|
||||||
|
fileBlobRepository,
|
||||||
|
fileContentStorage,
|
||||||
|
fileShareLinkRepository,
|
||||||
|
adminMetricsService,
|
||||||
|
properties,
|
||||||
|
Clock.fixed(Instant.parse("2026-04-04T04:30:00Z"), ZoneOffset.UTC)
|
||||||
|
);
|
||||||
|
|
||||||
|
User user = createUser(7L);
|
||||||
|
StoredFile file = createFile(22L, user, "/apps", "app-debug.apk");
|
||||||
|
file.setContentType("application/vnd.android.package-archive");
|
||||||
|
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
|
||||||
|
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||||
|
|
||||||
|
ResponseEntity<?> response = fileService.download(user, 22L);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(302);
|
||||||
|
assertThat(response.getHeaders().getLocation()).isNotNull();
|
||||||
|
assertThat(response.getHeaders().getLocation().getHost()).isEqualTo("api.yoyuzh.xyz");
|
||||||
|
assertThat(response.getHeaders().getLocation().getPath()).isEqualTo("/_dl/blobs/blob-22");
|
||||||
|
assertThat(response.getHeaders().getLocation().getQuery()).contains("expires=1775277300");
|
||||||
|
assertThat(response.getHeaders().getLocation().getQuery()).contains("md5=1z0AP88pnPz-TpgnYfIT4A");
|
||||||
|
verify(fileContentStorage, never()).createBlobDownloadUrl(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
mock-maker-subclass
|
||||||
@@ -122,15 +122,36 @@
|
|||||||
- 是否可用取决于当前环境配置
|
- 是否可用取决于当前环境配置
|
||||||
- 同样支持可选请求头 `X-Yoyuzh-Client: desktop|mobile`
|
- 同样支持可选请求头 `X-Yoyuzh-Client: desktop|mobile`
|
||||||
|
|
||||||
### 2.5 获取用户资料
|
### 2.5 Android 客户端更新信息
|
||||||
|
|
||||||
|
`GET /api/app/android/latest`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 公开接口,不需要登录
|
||||||
|
- 返回当前 Android 安装包下载地址、文件名和最新发布时间
|
||||||
|
- 后端会先读取文件桶中的 `android/releases/latest.json` 元数据,再返回当前 APK 对应的后端下载地址
|
||||||
|
- 安卓端原生壳应通过该接口检查更新
|
||||||
|
|
||||||
|
### 2.6 Android 客户端下载入口
|
||||||
|
|
||||||
|
`GET /api/app/android/download`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 公开接口,不需要登录
|
||||||
|
- 该接口会直接回传当前最新 APK 的字节流,并通过 `Content-Disposition` 指定带版本号的文件名
|
||||||
|
- Web 端总览页应直接使用这个公开下载入口,而不是直接访问对象存储路径
|
||||||
|
|
||||||
|
### 2.7 获取用户资料
|
||||||
|
|
||||||
`GET /api/user/profile`
|
`GET /api/user/profile`
|
||||||
|
|
||||||
### 2.6 更新用户资料
|
### 2.8 更新用户资料
|
||||||
|
|
||||||
`PUT /api/user/profile`
|
`PUT /api/user/profile`
|
||||||
|
|
||||||
### 2.7 修改密码
|
### 2.9 修改密码
|
||||||
|
|
||||||
`POST /api/user/password`
|
`POST /api/user/password`
|
||||||
|
|
||||||
@@ -139,7 +160,7 @@
|
|||||||
- 成功后会重新签发新的登录态
|
- 成功后会重新签发新的登录态
|
||||||
- 同时会顶掉旧设备会话
|
- 同时会顶掉旧设备会话
|
||||||
|
|
||||||
### 2.8 头像相关
|
### 2.10 头像相关
|
||||||
|
|
||||||
- `POST /api/user/avatar/upload/initiate`
|
- `POST /api/user/avatar/upload/initiate`
|
||||||
- `POST /api/user/avatar/upload`
|
- `POST /api/user/avatar/upload`
|
||||||
@@ -191,6 +212,7 @@
|
|||||||
|
|
||||||
- 普通文件优先获取下载 URL
|
- 普通文件优先获取下载 URL
|
||||||
- 文件夹可走 ZIP 下载
|
- 文件夹可走 ZIP 下载
|
||||||
|
- 私有 `apk/ipa` 下载会返回一个短时有效的 `https://api.yoyuzh.xyz/_dl/...` URL;该 URL 由 Nginx 按签名和过期时间校验后代理到对象存储自定义下载域名,不是长期可复用的公开直链
|
||||||
|
|
||||||
### 3.4 文件操作
|
### 3.4 文件操作
|
||||||
|
|
||||||
|
|||||||
@@ -161,9 +161,11 @@ Android 壳补充说明:
|
|||||||
- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳
|
- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳
|
||||||
- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public`
|
- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public`
|
||||||
- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk`
|
- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
- 前端总览页会在 Web 环境展示稳定 APK 下载入口 `/downloads/yoyuzh-portal.apk`
|
- 仓库根目录已提供一键脚本 `node scripts/deploy-android-apk.mjs`,会串起前端构建、Capacitor 同步、Gradle 打包、前端静态站发布与 Android 独立发包,并在 `cap sync` 之后自动补回 Android 插件工程里的 Google Maven 镜像配置
|
||||||
- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端会先对 OSS 上的 APK 做 `HEAD` 探测并读取最新修改时间,再直接打开下载链接完成更新
|
- `node scripts/deploy-android-release.mjs` 会把 APK 和 `android/releases/latest.json` 上传到 Android 独立对象路径;默认复用文件桶 scope,不再写入前端静态桶
|
||||||
- 前端 OSS 发布脚本会在上传 `front/dist` 后,额外把 `front/android/app/build/outputs/apk/debug/app-debug.apk` 上传到同一个静态站桶里的 `downloads/yoyuzh-portal.apk`;这里刻意不把 APK 放进 `front/dist`,以避免后续 `npx cap sync android` 时把旧 APK 再次打进新的 Android 包
|
- 前端总览页在 Web 环境下不再直接指向静态桶里的 APK,而是跳到后端公开下载入口 `https://api.yoyuzh.xyz/api/app/android/download`
|
||||||
|
- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端通过后端 `/api/app/android/latest` 获取更新信息,后端从文件桶里的 `android/releases/latest.json` 读取版本元数据,并返回带版本号的后端下载地址;真正下载时由 `/api/app/android/download` 直接回传 APK 字节流
|
||||||
|
- 私有网盘里的 `apk/ipa` 不再直接暴露对象存储默认域名,也不直接暴露长期有效的自定义域名直链;后端会返回短时 `https://api.yoyuzh.xyz/_dl/...` 下载地址,由 `api.yoyuzh.xyz` 上的 Nginx `secure_link` 做签名和过期校验,再代理到 `dl.yoyuzh.xyz`
|
||||||
- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在
|
- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在
|
||||||
|
|
||||||
### 3.3 快传模块
|
### 3.3 快传模块
|
||||||
@@ -371,6 +373,7 @@ Android 壳补充说明:
|
|||||||
- 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken`
|
- 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken`
|
||||||
- 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址
|
- 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址
|
||||||
- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连对象存储下载”的主链路
|
- 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连对象存储下载”的主链路
|
||||||
|
- 私有 `apk/ipa` 下载是例外:后端只负责返回短时签名的 `/_dl` 地址,真正文件流量经过服务器 Nginx 反向代理到 `dl.yoyuzh.xyz`,不经过 Spring Boot 业务进程
|
||||||
|
|
||||||
## 8. 部署架构
|
## 8. 部署架构
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
def buildTimestamp = new Date()
|
||||||
|
def buildVersionCode = System.getenv('YOYUZH_ANDROID_VERSION_CODE') ?: buildTimestamp.format('yyDDDHHmm')
|
||||||
|
def buildVersionName = System.getenv('YOYUZH_ANDROID_VERSION_NAME') ?: buildTimestamp.format('yyyy.MM.dd.HHmm')
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "xyz.yoyuzh.portal"
|
namespace = "xyz.yoyuzh.portal"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
@@ -7,8 +11,8 @@ android {
|
|||||||
applicationId "xyz.yoyuzh.portal"
|
applicationId "xyz.yoyuzh.portal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode Integer.parseInt(buildVersionCode)
|
||||||
versionName "1.0"
|
versionName buildVersionName
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|||||||
10
front/package-lock.json
generated
10
front/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.3.0",
|
"@capacitor/android": "^8.3.0",
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/cli": "^8.3.0",
|
"@capacitor/cli": "^8.3.0",
|
||||||
"@capacitor/core": "^8.3.0",
|
"@capacitor/core": "^8.3.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -326,6 +327,15 @@
|
|||||||
"@capacitor/core": "^8.3.0"
|
"@capacitor/core": "^8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/app": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test": "node --import tsx --test src/**/*.test.ts"
|
"test": "node --import tsx --test src/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/android": "^8.3.0",
|
"@capacitor/android": "^8.3.0",
|
||||||
"@capacitor/cli": "^8.3.0",
|
"@capacitor/cli": "^8.3.0",
|
||||||
"@capacitor/core": "^8.3.0",
|
"@capacitor/core": "^8.3.0",
|
||||||
|
|||||||
38
front/src/components/layout/UploadProgressPanel.test.ts
Normal file
38
front/src/components/layout/UploadProgressPanel.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { afterEach, test } from 'node:test';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { createUploadTask } from '@/src/pages/files-upload';
|
||||||
|
import {
|
||||||
|
clearFilesUploads,
|
||||||
|
replaceFilesUploads,
|
||||||
|
resetFilesUploadStoreForTests,
|
||||||
|
setFilesUploadPanelOpen,
|
||||||
|
} from '@/src/pages/files-upload-store';
|
||||||
|
|
||||||
|
import { UploadProgressPanel } from './UploadProgressPanel';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetFilesUploadStoreForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile upload progress panel renders as a top summary card instead of a bottom desktop panel', () => {
|
||||||
|
replaceFilesUploads([
|
||||||
|
createUploadTask(new File(['demo'], 'demo.txt', { type: 'text/plain' }), []),
|
||||||
|
]);
|
||||||
|
setFilesUploadPanelOpen(false);
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
React.createElement(UploadProgressPanel, {
|
||||||
|
variant: 'mobile',
|
||||||
|
className: 'top-offset-anchor',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
clearFilesUploads();
|
||||||
|
|
||||||
|
assert.match(html, /top-offset-anchor/);
|
||||||
|
assert.match(html, /已在后台上传 1 项/);
|
||||||
|
assert.doesNotMatch(html, /bottom-6/);
|
||||||
|
});
|
||||||
@@ -10,21 +10,84 @@ import {
|
|||||||
toggleFilesUploadPanelOpen,
|
toggleFilesUploadPanelOpen,
|
||||||
useFilesUploadStore,
|
useFilesUploadStore,
|
||||||
} from '@/src/pages/files-upload-store';
|
} from '@/src/pages/files-upload-store';
|
||||||
|
import type { UploadTask } from '@/src/pages/files-upload';
|
||||||
|
|
||||||
export function UploadProgressPanel() {
|
export type UploadProgressPanelVariant = 'desktop' | 'mobile';
|
||||||
|
|
||||||
|
export function getUploadProgressSummary(uploads: UploadTask[]) {
|
||||||
|
const uploadingCount = uploads.filter((task) => task.status === 'uploading').length;
|
||||||
|
const completedCount = uploads.filter((task) => task.status === 'completed').length;
|
||||||
|
const errorCount = uploads.filter((task) => task.status === 'error').length;
|
||||||
|
const cancelledCount = uploads.filter((task) => task.status === 'cancelled').length;
|
||||||
|
const uploadingTasks = uploads.filter((task) => task.status === 'uploading');
|
||||||
|
const activeProgress = uploadingTasks.length > 0
|
||||||
|
? Math.round(uploadingTasks.reduce((sum, task) => sum + task.progress, 0) / uploadingTasks.length)
|
||||||
|
: uploads.length > 0 && completedCount === uploads.length
|
||||||
|
? 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (uploadingCount > 0) {
|
||||||
|
return {
|
||||||
|
title: `已在后台上传 ${uploadingCount} 项`,
|
||||||
|
detail: `${completedCount}/${uploads.length} 已完成 · ${activeProgress}%`,
|
||||||
|
progress: activeProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCount > 0) {
|
||||||
|
return {
|
||||||
|
title: `上传结束,${errorCount} 项失败`,
|
||||||
|
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||||
|
progress: activeProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelledCount > 0) {
|
||||||
|
return {
|
||||||
|
title: '上传已停止',
|
||||||
|
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||||
|
progress: activeProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `上传已完成 ${completedCount} 项`,
|
||||||
|
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||||
|
progress: activeProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadProgressPanelProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: UploadProgressPanelVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadProgressPanel({
|
||||||
|
className,
|
||||||
|
variant = 'desktop',
|
||||||
|
}: UploadProgressPanelProps = {}) {
|
||||||
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
|
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
|
||||||
|
|
||||||
if (uploads.length === 0) {
|
if (uploads.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summary = getUploadProgressSummary(uploads);
|
||||||
|
const isMobile = variant === 'mobile';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||||
className="fixed bottom-6 right-6 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0f172a]/95 shadow-2xl backdrop-blur-xl"
|
className={cn(
|
||||||
|
'z-50 flex flex-col overflow-hidden border border-white/10 bg-[#0f172a]/95 backdrop-blur-xl',
|
||||||
|
isMobile
|
||||||
|
? 'w-full rounded-2xl shadow-[0_16px_40px_rgba(15,23,42,0.28)]'
|
||||||
|
: 'fixed bottom-6 right-6 w-[min(24rem,calc(100vw-2rem))] rounded-xl shadow-2xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
|
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
|
||||||
@@ -32,11 +95,21 @@ export function UploadProgressPanel() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
|
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
|
||||||
<span className="text-sm font-medium text-white">
|
<div className="flex min-w-0 flex-col">
|
||||||
上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length})
|
<span className="text-sm font-medium text-white">
|
||||||
</span>
|
{isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`}
|
||||||
|
</span>
|
||||||
|
{isMobile ? (
|
||||||
|
<span className="text-[11px] text-slate-400">{summary.detail}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{isMobile ? (
|
||||||
|
<span className="rounded-full bg-[#336EFF]/15 px-2 py-1 text-[11px] font-medium text-[#8fb0ff]">
|
||||||
|
{summary.progress}%
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
|
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
|
||||||
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -55,7 +128,12 @@ export function UploadProgressPanel() {
|
|||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{isUploadPanelOpen && (
|
{isUploadPanelOpen && (
|
||||||
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} exit={{ height: 0 }} className="max-h-80 overflow-y-auto">
|
<motion.div
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
className={cn(isMobile ? 'max-h-64 overflow-y-auto' : 'max-h-80 overflow-y-auto')}
|
||||||
|
>
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
{uploads.map((task) => (
|
{uploads.map((task) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ export interface DownloadUrlResponse {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AndroidReleaseInfo {
|
||||||
|
downloadUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
versionCode: string | null;
|
||||||
|
versionName: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateFileShareLinkResponse {
|
export interface CreateFileShareLinkResponse {
|
||||||
token: string;
|
token: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMobileUploadPanelOffsetClassName(isNativeShell = false) {
|
||||||
|
if (isNativeShell) {
|
||||||
|
return 'top-[calc(3.5rem+1rem+var(--app-safe-area-top))]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'top-[calc(3.5rem+0.75rem+var(--app-safe-area-top))]';
|
||||||
|
}
|
||||||
|
|
||||||
type ActiveModal = 'security' | 'settings' | null;
|
type ActiveModal = 'security' | 'settings' | null;
|
||||||
|
|
||||||
export function getVisibleNavItems(isAdmin: boolean) {
|
export function getVisibleNavItems(isAdmin: boolean) {
|
||||||
@@ -66,8 +74,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||||
const navItems = getVisibleNavItems(isAdmin);
|
const navItems = getVisibleNavItems(isAdmin);
|
||||||
|
const isNativeShell = typeof window !== 'undefined' && isNativeMobileShellLocation(window.location);
|
||||||
const viewportOffsets = getMobileViewportOffsetClassNames(
|
const viewportOffsets = getMobileViewportOffsetClassNames(
|
||||||
typeof window !== 'undefined' && isNativeMobileShellLocation(window.location),
|
isNativeShell,
|
||||||
);
|
);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -333,9 +342,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Upload Panel (Floating above bottom bar) */}
|
{/* Upload Panel (Floating above bottom bar) */}
|
||||||
<div className="fixed bottom-20 right-4 left-4 z-40 pointer-events-none">
|
<div className={cn('fixed left-3 right-3 z-40 pointer-events-none', getMobileUploadPanelOffsetClassName(isNativeShell))}>
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<UploadProgressPanel />
|
<UploadProgressPanel variant="mobile" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
19
front/src/mobile-pages/MobileFiles.test.ts
Normal file
19
front/src/mobile-pages/MobileFiles.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { getMobileFilesLayoutClassNames } from './MobileFiles';
|
||||||
|
|
||||||
|
test('mobile files uses a single page scroller and keeps the toolbar sticky', () => {
|
||||||
|
const classNames = getMobileFilesLayoutClassNames();
|
||||||
|
|
||||||
|
assert.match(classNames.root, /\bmin-h-full\b/);
|
||||||
|
assert.match(classNames.root, /\bbg-transparent\b/);
|
||||||
|
assert.doesNotMatch(classNames.root, /\boverflow-hidden\b/);
|
||||||
|
assert.match(classNames.toolbar, /\bsticky\b/);
|
||||||
|
assert.match(classNames.toolbar, /\btop-0\b/);
|
||||||
|
assert.match(classNames.toolbar, /\bpy-2\b/);
|
||||||
|
assert.match(classNames.toolbarInner, /\bglass-panel\b/);
|
||||||
|
assert.match(classNames.list, /\bpt-2\b/);
|
||||||
|
assert.match(classNames.list, /\bpb-4\b/);
|
||||||
|
assert.doesNotMatch(classNames.list, /\boverflow-y-auto\b/);
|
||||||
|
});
|
||||||
@@ -121,6 +121,15 @@ interface UiFile {
|
|||||||
|
|
||||||
type NetdiskTargetAction = 'move' | 'copy';
|
type NetdiskTargetAction = 'move' | 'copy';
|
||||||
|
|
||||||
|
export function getMobileFilesLayoutClassNames() {
|
||||||
|
return {
|
||||||
|
root: 'relative flex min-h-full flex-col text-white bg-transparent',
|
||||||
|
toolbar: 'sticky top-0 z-30 flex-none px-4 py-2',
|
||||||
|
toolbarInner: 'glass-panel flex items-center gap-3 rounded-[22px] border border-white/10 bg-[#0f172a]/72 px-3.5 py-2.5 shadow-md backdrop-blur-2xl',
|
||||||
|
list: 'relative z-10 flex-1 px-3 pt-2 pb-4 space-y-1.5',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileFiles() {
|
export default function MobileFiles() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||||
@@ -152,6 +161,7 @@ export default function MobileFiles() {
|
|||||||
|
|
||||||
// Floating Action Button
|
// Floating Action Button
|
||||||
const [fabOpen, setFabOpen] = useState(false);
|
const [fabOpen, setFabOpen] = useState(false);
|
||||||
|
const layoutClassNames = getMobileFilesLayoutClassNames();
|
||||||
|
|
||||||
const loadCurrentPath = async (pathParts: string[]) => {
|
const loadCurrentPath = async (pathParts: string[]) => {
|
||||||
const response = await apiRequest<PageResponse<FileMetadata>>(
|
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||||
@@ -437,7 +447,7 @@ export default function MobileFiles() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-3.5rem)] relative overflow-hidden text-white bg-[#07101D]">
|
<div className={layoutClassNames.root}>
|
||||||
<div className="pointer-events-none absolute inset-0 z-0">
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
||||||
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
|
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
|
||||||
@@ -448,8 +458,8 @@ export default function MobileFiles() {
|
|||||||
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
|
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
|
||||||
|
|
||||||
{/* Top Header - Path navigation */}
|
{/* Top Header - Path navigation */}
|
||||||
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
|
<div className={layoutClassNames.toolbar}>
|
||||||
<div className="flex items-center gap-3">
|
<div className={layoutClassNames.toolbarInner}>
|
||||||
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
|
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
|
||||||
{currentPath.length > 0 && (
|
{currentPath.length > 0 && (
|
||||||
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
|
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
|
||||||
@@ -476,7 +486,7 @@ export default function MobileFiles() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File List */}
|
{/* File List */}
|
||||||
<div className="relative z-10 flex-1 overflow-y-auto px-3 py-2 space-y-1.5 pb-24">
|
<div className={layoutClassNames.list}>
|
||||||
{currentFiles.length === 0 ? (
|
{currentFiles.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
|
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
|
||||||
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
|
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
|||||||
import { resolveStoredFileType } from '@/src/lib/file-type';
|
import { resolveStoredFileType } from '@/src/lib/file-type';
|
||||||
import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
||||||
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||||
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
import type { AndroidReleaseInfo, FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APK_DOWNLOAD_PUBLIC_URL,
|
|
||||||
APK_DOWNLOAD_PATH,
|
APK_DOWNLOAD_PATH,
|
||||||
|
formatApkPublishedAtLabel,
|
||||||
getMobileOverviewApkEntryMode,
|
getMobileOverviewApkEntryMode,
|
||||||
getOverviewLoadErrorMessage,
|
getOverviewLoadErrorMessage,
|
||||||
getOverviewStorageQuotaLabel,
|
getOverviewStorageQuotaLabel,
|
||||||
|
isAndroidReleaseNewer,
|
||||||
shouldShowOverviewApkDownload,
|
shouldShowOverviewApkDownload,
|
||||||
} from '@/src/pages/overview-state';
|
} from '@/src/pages/overview-state';
|
||||||
|
|
||||||
@@ -50,6 +51,22 @@ function formatRecentTime(value: string) {
|
|||||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
|
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getInstalledAndroidAppVersion() {
|
||||||
|
try {
|
||||||
|
const { App } = await import('@capacitor/app');
|
||||||
|
const info = await App.getInfo();
|
||||||
|
return {
|
||||||
|
versionName: info.version ?? null,
|
||||||
|
versionCode: info.build ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
versionName: null,
|
||||||
|
versionCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileOverview() {
|
export default function MobileOverview() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const cachedOverview = readCachedValue<{
|
const cachedOverview = readCachedValue<{
|
||||||
@@ -90,34 +107,49 @@ export default function MobileOverview() {
|
|||||||
setApkActionMessage('');
|
setApkActionMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, {
|
const [release, installedVersion] = await Promise.all([
|
||||||
method: 'HEAD',
|
apiRequest<AndroidReleaseInfo>('/app/android/latest', {
|
||||||
cache: 'no-store',
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
getInstalledAndroidAppVersion(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasNewerRelease = isAndroidReleaseNewer({
|
||||||
|
currentVersionCode: installedVersion.versionCode,
|
||||||
|
currentVersionName: installedVersion.versionName,
|
||||||
|
releaseVersionCode: release.versionCode,
|
||||||
|
releaseVersionName: release.versionName,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`检查更新失败 (${response.status})`);
|
if (!hasNewerRelease) {
|
||||||
|
setApkActionMessage(
|
||||||
|
installedVersion.versionName
|
||||||
|
? `当前已是最新版 ${installedVersion.versionName}`
|
||||||
|
: '当前已是最新版'
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastModified = response.headers.get('last-modified');
|
const downloadUrl = release.downloadUrl;
|
||||||
|
const publishedAtLabel = formatApkPublishedAtLabel(release.publishedAt);
|
||||||
setApkActionMessage(
|
setApkActionMessage(
|
||||||
lastModified
|
publishedAtLabel
|
||||||
? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', {
|
? `发现新版本 ${release.versionName ?? ''},更新时间 ${publishedAtLabel},正在打开下载链接。`
|
||||||
month: '2-digit',
|
: `发现新版本 ${release.versionName ?? ''},正在打开下载链接。`
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(lastModified))},正在打开下载链接。`
|
|
||||||
: '发现最新安装包,正在打开下载链接。'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const openedWindow = window.open(APK_DOWNLOAD_PUBLIC_URL, '_blank', 'noopener,noreferrer');
|
const openedWindow = window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
||||||
if (!openedWindow) {
|
if (!openedWindow) {
|
||||||
window.location.href = APK_DOWNLOAD_PUBLIC_URL;
|
window.location.href = downloadUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试');
|
setApkActionMessage(
|
||||||
|
error instanceof Error && error.message
|
||||||
|
? `更新服务暂时不可用:${error.message}`
|
||||||
|
: '更新服务暂时不可用,请稍后重试'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingApkUpdate(false);
|
setCheckingApkUpdate(false);
|
||||||
}
|
}
|
||||||
@@ -232,48 +264,6 @@ export default function MobileOverview() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{showApkDownload || apkEntryMode === 'update' ? (
|
|
||||||
<Card className="glass-panel overflow-hidden border-[#336EFF]/20 relative">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.2),transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.94),rgba(15,23,42,0.88))]" />
|
|
||||||
<CardContent className="relative z-10 p-4 space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#336EFF]/15 text-[#7ea4ff]">
|
|
||||||
<Smartphone className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold text-white">Android 客户端</p>
|
|
||||||
<p className="text-[11px] leading-5 text-slate-300">
|
|
||||||
{apkEntryMode === 'update'
|
|
||||||
? '在 App 内检查 OSS 上的最新安装包,并跳转到更新下载链接。'
|
|
||||||
: '总览页可直接下载最新 APK,安装包与前端站点一起托管在 OSS。'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{apkEntryMode === 'update' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleCheckApkUpdate()}
|
|
||||||
disabled={checkingApkUpdate}
|
|
||||||
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc] disabled:cursor-not-allowed disabled:opacity-70"
|
|
||||||
>
|
|
||||||
{checkingApkUpdate ? '检查中...' : '检查更新'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={APK_DOWNLOAD_PATH}
|
|
||||||
download="yoyuzh-portal.apk"
|
|
||||||
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
|
||||||
>
|
|
||||||
下载 APK
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{apkActionMessage ? (
|
|
||||||
<p className="text-[11px] leading-5 text-slate-300">{apkActionMessage}</p>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 近期文件 (精简版) */}
|
{/* 近期文件 (精简版) */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
|
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
|
||||||
@@ -318,6 +308,47 @@ export default function MobileOverview() {
|
|||||||
<ChevronRight className="h-5 w-5 text-cyan-400 opacity-70" />
|
<ChevronRight className="h-5 w-5 text-cyan-400 opacity-70" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{showApkDownload || apkEntryMode === 'update' ? (
|
||||||
|
<Card className="glass-panel overflow-hidden border-[#336EFF]/20 relative">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.2),transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.94),rgba(15,23,42,0.88))]" />
|
||||||
|
<CardContent className="relative z-10 p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#336EFF]/15 text-[#7ea4ff]">
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-white">Android 客户端</p>
|
||||||
|
<p className="text-[11px] leading-5 text-slate-300">
|
||||||
|
{apkEntryMode === 'update'
|
||||||
|
? '在 App 内检查最新安装包,并跳转到当前版本的下载地址。'
|
||||||
|
: '总览页可直接下载最新 APK,安装包通过独立发包链路提供。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{apkEntryMode === 'update' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCheckApkUpdate()}
|
||||||
|
disabled={checkingApkUpdate}
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{checkingApkUpdate ? '检查中...' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={APK_DOWNLOAD_PATH}
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
||||||
|
>
|
||||||
|
下载 APK
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{apkActionMessage ? (
|
||||||
|
<p className="text-[11px] leading-5 text-slate-300">{apkActionMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 留出底部边距给导航栏 */}
|
{/* 留出底部边距给导航栏 */}
|
||||||
<div className="h-6" />
|
<div className="h-6" />
|
||||||
|
|||||||
19
front/src/mobile-pages/MobileTransfer.test.ts
Normal file
19
front/src/mobile-pages/MobileTransfer.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { getMobileTransferLayoutClassNames } from './MobileTransfer';
|
||||||
|
|
||||||
|
test('mobile transfer keeps its header sticky and avoids nested file-list scrolling', () => {
|
||||||
|
const classNames = getMobileTransferLayoutClassNames();
|
||||||
|
|
||||||
|
assert.match(classNames.root, /\bmin-h-full\b/);
|
||||||
|
assert.match(classNames.root, /\bbg-transparent\b/);
|
||||||
|
assert.doesNotMatch(classNames.root, /\boverflow-hidden\b/);
|
||||||
|
assert.match(classNames.header, /\bsticky\b/);
|
||||||
|
assert.match(classNames.header, /\btop-0\b/);
|
||||||
|
assert.match(classNames.header, /\bpy-2\b/);
|
||||||
|
assert.match(classNames.headerPanel, /\bglass-panel\b/);
|
||||||
|
assert.match(classNames.titlePanel, /\brelative\b/);
|
||||||
|
assert.match(classNames.content, /\bpb-6\b/);
|
||||||
|
assert.doesNotMatch(classNames.sendFileList, /\boverflow-y-auto\b/);
|
||||||
|
});
|
||||||
@@ -94,6 +94,17 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMobileTransferLayoutClassNames() {
|
||||||
|
return {
|
||||||
|
root: 'relative flex min-h-full flex-col bg-transparent',
|
||||||
|
header: 'sticky top-0 z-30 px-4 py-2',
|
||||||
|
headerPanel: 'glass-panel relative overflow-hidden rounded-[24px] border border-white/12 bg-[#0b1528]/82 px-3.5 py-3 shadow-[0_14px_36px_rgba(8,15,30,0.32)] backdrop-blur-2xl',
|
||||||
|
titlePanel: 'relative overflow-hidden rounded-[18px] px-3.5 pt-3 pb-3',
|
||||||
|
content: 'relative z-10 flex-1 flex flex-col min-w-0 px-4 pt-3 pb-6',
|
||||||
|
sendFileList: 'glass-panel rounded-2xl p-2.5',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileTransfer() {
|
export default function MobileTransfer() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { ready: authReady, session: authSession } = useAuth();
|
const { ready: authReady, session: authSession } = useAuth();
|
||||||
@@ -116,6 +127,7 @@ export default function MobileTransfer() {
|
|||||||
const [offlineHistoryError, setOfflineHistoryError] = useState('');
|
const [offlineHistoryError, setOfflineHistoryError] = useState('');
|
||||||
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
|
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
|
||||||
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
|
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
|
||||||
|
const layoutClassNames = getMobileTransferLayoutClassNames();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -406,7 +418,7 @@ export default function MobileTransfer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col min-h-full overflow-hidden bg-[#07101D]">
|
<div className={layoutClassNames.root}>
|
||||||
<div className="pointer-events-none absolute inset-0 z-0">
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
||||||
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
|
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
|
||||||
@@ -416,35 +428,44 @@ export default function MobileTransfer() {
|
|||||||
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
||||||
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
|
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
|
||||||
|
|
||||||
{/* 顶部标题区 */}
|
<div className={layoutClassNames.header}>
|
||||||
<div className="relative z-10 overflow-hidden bg-[url('/noise.png')] px-5 pt-8 pb-4">
|
<div className={layoutClassNames.headerPanel}>
|
||||||
<div className="absolute top-[-50%] right-[-10%] h-[150%] w-[120%] rounded-full bg-[#336EFF] opacity-15 mix-blend-screen blur-[80px]" />
|
<div className="absolute inset-0 bg-[#0b1528]/64 backdrop-blur-2xl" />
|
||||||
<div className="relative z-10 font-bold text-2xl tracking-wide flex items-center">
|
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-30" />
|
||||||
<Send className="mr-3 w-6 h-6 text-cyan-400" />
|
<div className="absolute inset-x-0 top-0 h-px bg-white/18" />
|
||||||
快传
|
<div className="absolute top-[-40%] right-[-8%] h-[140%] w-[95%] rounded-full bg-[#336EFF] opacity-14 mix-blend-screen blur-[80px]" />
|
||||||
|
<div className="absolute bottom-[-65%] left-[-8%] h-[120%] w-[55%] rounded-full bg-cyan-400/10 mix-blend-screen blur-[72px]" />
|
||||||
|
|
||||||
|
<div className={layoutClassNames.titlePanel}>
|
||||||
|
<div className="relative z-10 flex items-center text-[1.375rem] font-bold tracking-wide">
|
||||||
|
<Send className="mr-2.5 h-5.5 w-5.5 text-cyan-400" />
|
||||||
|
快传
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowSend && (
|
||||||
|
<div className="relative z-10 mt-2.5 flex overflow-hidden rounded-2xl border border-white/8 bg-black/18">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('send')}
|
||||||
|
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
||||||
|
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
<UploadCloud className="w-4 h-4" /> 发送
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('receive')}
|
||||||
|
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
||||||
|
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
<DownloadCloud className="w-4 h-4" /> 接收
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-x-3 bottom-0 h-px bg-white/8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allowSend && (
|
<div className={layoutClassNames.content}>
|
||||||
<div className="relative z-10 flex bg-[#0f172a] shadow-md border-b border-white/5 mx-4 mt-2 rounded-2xl overflow-hidden glass-panel shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('send')}
|
|
||||||
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
|
||||||
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
|
||||||
>
|
|
||||||
<UploadCloud className="w-4 h-4" /> 发送
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('receive')}
|
|
||||||
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
|
||||||
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
|
||||||
>
|
|
||||||
<DownloadCloud className="w-4 h-4" /> 接收
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
|
|
||||||
{authReady && !isAuthenticated && (
|
{authReady && !isAuthenticated && (
|
||||||
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
|
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
|
||||||
<p className="leading-relaxed">无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。</p>
|
<p className="leading-relaxed">无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。</p>
|
||||||
@@ -520,7 +541,7 @@ export default function MobileTransfer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文件列表 */}
|
{/* 文件列表 */}
|
||||||
<div className="glass-panel rounded-2xl p-2.5 max-h-[40vh] overflow-y-auto">
|
<div className={layoutClassNames.sendFileList}>
|
||||||
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2">共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}</p>
|
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2">共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}</p>
|
||||||
{selectedFiles.map((f, i) => (
|
{selectedFiles.map((f, i) => (
|
||||||
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">
|
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">
|
||||||
|
|||||||
@@ -352,18 +352,17 @@ export default function Overview() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold text-white">下载 APK 安装包</h3>
|
<h3 className="text-2xl font-semibold text-white">下载 APK 安装包</h3>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
|
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
|
||||||
当前 Android 安装包会随前端站点一起发布到 OSS,可直接从这里下载最新版本。
|
当前 Android 安装包通过独立发包链路维护,可直接从这里获取最新版本。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
|
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">稳定路径</span>
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">后端分发</span>
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">OSS 托管</span>
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">独立发包</span>
|
||||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">一键下载</span>
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">一键下载</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={APK_DOWNLOAD_PATH}
|
href={APK_DOWNLOAD_PATH}
|
||||||
download="yoyuzh-portal.apk"
|
|
||||||
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl bg-[#336EFF] px-6 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl bg-[#336EFF] px-6 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
||||||
>
|
>
|
||||||
下载 APK
|
下载 APK
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { test } from 'node:test';
|
|||||||
import {
|
import {
|
||||||
APK_DOWNLOAD_PATH,
|
APK_DOWNLOAD_PATH,
|
||||||
APK_DOWNLOAD_PUBLIC_URL,
|
APK_DOWNLOAD_PUBLIC_URL,
|
||||||
|
formatApkPublishedAtLabel,
|
||||||
getDesktopOverviewSectionColumns,
|
getDesktopOverviewSectionColumns,
|
||||||
getDesktopOverviewStretchSection,
|
getDesktopOverviewStretchSection,
|
||||||
getMobileOverviewApkEntryMode,
|
getMobileOverviewApkEntryMode,
|
||||||
getOverviewLoadErrorMessage,
|
getOverviewLoadErrorMessage,
|
||||||
getOverviewStorageQuotaLabel,
|
getOverviewStorageQuotaLabel,
|
||||||
|
isAndroidReleaseNewer,
|
||||||
shouldShowOverviewApkDownload,
|
shouldShowOverviewApkDownload,
|
||||||
} from './overview-state';
|
} from './overview-state';
|
||||||
|
|
||||||
@@ -26,9 +28,9 @@ test('generic overview failures stay generic when not coming right after login',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overview exposes a stable apk download path for oss hosting', () => {
|
test('overview exposes a backend download endpoint for apk delivery', () => {
|
||||||
assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk');
|
assert.equal(APK_DOWNLOAD_PATH, 'https://api.yoyuzh.xyz/api/app/android/download');
|
||||||
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk');
|
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://api.yoyuzh.xyz/api/app/android/download');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overview hides the apk download entry inside the native app shell', () => {
|
test('overview hides the apk download entry inside the native app shell', () => {
|
||||||
@@ -64,3 +66,30 @@ test('overview storage quota label uses the real quota instead of a fixed 50 GB
|
|||||||
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
|
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
|
||||||
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
|
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('apk published time is formatted into a readable update label', () => {
|
||||||
|
assert.match(formatApkPublishedAtLabel('2026-04-03T08:33:54Z') ?? '', /04[/-]03 16:33/);
|
||||||
|
assert.equal(formatApkPublishedAtLabel(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('android update check compares numeric versionCode first', () => {
|
||||||
|
assert.equal(isAndroidReleaseNewer({
|
||||||
|
currentVersionCode: '260931807',
|
||||||
|
releaseVersionCode: '260931807',
|
||||||
|
}), false);
|
||||||
|
assert.equal(isAndroidReleaseNewer({
|
||||||
|
currentVersionCode: '260931807',
|
||||||
|
releaseVersionCode: '260931808',
|
||||||
|
}), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('android update check falls back to versionName comparison', () => {
|
||||||
|
assert.equal(isAndroidReleaseNewer({
|
||||||
|
currentVersionName: '2026.04.03.1807',
|
||||||
|
releaseVersionName: '2026.04.03.1807',
|
||||||
|
}), false);
|
||||||
|
assert.equal(isAndroidReleaseNewer({
|
||||||
|
currentVersionName: '2026.04.03.1807',
|
||||||
|
releaseVersionName: '2026.04.03.1810',
|
||||||
|
}), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,73 @@
|
|||||||
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
|
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
|
||||||
|
|
||||||
export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk';
|
export const APK_DOWNLOAD_PATH = 'https://api.yoyuzh.xyz/api/app/android/download';
|
||||||
export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk';
|
export const APK_DOWNLOAD_PUBLIC_URL = 'https://api.yoyuzh.xyz/api/app/android/download';
|
||||||
|
|
||||||
|
function normalizeVersionParts(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(/[^0-9A-Za-z]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => (/^\d+$/.test(part) ? Number(part) : part.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersionParts(left: Array<number | string>, right: Array<number | string>) {
|
||||||
|
const length = Math.max(left.length, right.length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftPart = left[index] ?? 0;
|
||||||
|
const rightPart = right[index] ?? 0;
|
||||||
|
|
||||||
|
if (leftPart === rightPart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||||
|
return leftPart > rightPart ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(leftPart).localeCompare(String(rightPart), 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAndroidReleaseNewer({
|
||||||
|
currentVersionCode,
|
||||||
|
currentVersionName,
|
||||||
|
releaseVersionCode,
|
||||||
|
releaseVersionName,
|
||||||
|
}: {
|
||||||
|
currentVersionCode?: string | null;
|
||||||
|
currentVersionName?: string | null;
|
||||||
|
releaseVersionCode?: string | null;
|
||||||
|
releaseVersionName?: string | null;
|
||||||
|
}) {
|
||||||
|
if (currentVersionCode && releaseVersionCode && /^\d+$/.test(currentVersionCode) && /^\d+$/.test(releaseVersionCode)) {
|
||||||
|
return BigInt(releaseVersionCode) > BigInt(currentVersionCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVersionName && releaseVersionName) {
|
||||||
|
return compareVersionParts(normalizeVersionParts(currentVersionName), normalizeVersionParts(releaseVersionName)) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatApkPublishedAtLabel(publishedAt: string | null) {
|
||||||
|
if (!publishedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(publishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
function formatOverviewStorageSize(size: number) {
|
function formatOverviewStorageSize(size: number) {
|
||||||
if (size <= 0) {
|
if (size <= 0) {
|
||||||
|
|||||||
17
memory.md
17
memory.md
@@ -30,10 +30,14 @@
|
|||||||
- 2026-04-03 Android 打包已确认走“Vite 产物 -> `npx cap sync android` -> Gradle `assembleDebug`”链路;当前应用包名为 `xyz.yoyuzh.portal`
|
- 2026-04-03 Android 打包已确认走“Vite 产物 -> `npx cap sync android` -> Gradle `assembleDebug`”链路;当前应用包名为 `xyz.yoyuzh.portal`
|
||||||
- 2026-04-03 Android WebView 壳内的前端 API 基址已改成运行时判断:Web 站点继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都会默认直连 `https://api.yoyuzh.xyz/api`,避免 APK 把请求误打到应用内本地地址;后端 CORS 也同步放行了 `https://localhost`
|
- 2026-04-03 Android WebView 壳内的前端 API 基址已改成运行时判断:Web 站点继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都会默认直连 `https://api.yoyuzh.xyz/api`,避免 APK 把请求误打到应用内本地地址;后端 CORS 也同步放行了 `https://localhost`
|
||||||
- 2026-04-03 由于这台机器直连 `dl.google.com` / Android Maven 仓库会 TLS 握手失败,Android 构建已改走阿里云 Google Maven 镜像,并通过 `redirector.gvt1.com` 手动落本机 SDK 包
|
- 2026-04-03 由于这台机器直连 `dl.google.com` / Android Maven 仓库会 TLS 握手失败,Android 构建已改走阿里云 Google Maven 镜像,并通过 `redirector.gvt1.com` 手动落本机 SDK 包
|
||||||
- 2026-04-03 总览页已新增 Android APK 下载入口;Web 桌面端和移动端总览都会展示稳定下载链接 `/downloads/yoyuzh-portal.apk`
|
- 2026-04-03 总览页已新增 Android APK 下载入口;当前 Web 总览已改走后端公开下载口 `https://api.yoyuzh.xyz/api/app/android/download`,不再直接指向前端静态桶
|
||||||
- 2026-04-03 鉴权链路已按客户端类型拆分会话:前端请求会带 `X-Yoyuzh-Client`,后端分别维护桌面和移动的活跃 `sid` 与 refresh token 集合,因此桌面 Web 与移动端 APK 可同时登录;移动端总览页在 Capacitor 原生壳内会显示“检查更新”,通过探测 OSS 上 APK 最新修改时间并直接跳转下载链接完成更新
|
- 2026-04-03 鉴权链路已按客户端类型拆分会话:前端请求会带 `X-Yoyuzh-Client`,后端分别维护桌面和移动的活跃 `sid` 与 refresh token 集合,因此桌面 Web 与移动端 APK 可同时登录;移动端总览页在 Capacitor 原生壳内会显示“检查更新”,通过探测 OSS 上 APK 最新修改时间并直接跳转下载链接完成更新
|
||||||
- 2026-04-03 前端 OSS 发布脚本已支持额外上传 `front/android/app/build/outputs/apk/debug/app-debug.apk` 到对象存储稳定 key `downloads/yoyuzh-portal.apk`;这样不会把 APK 混进 `front/dist`,也不会在后续 `npx cap sync android` 时被再次打包进 Android 壳
|
- 2026-04-03 前端 OSS 发布脚本现已收口为“只发布 `front/dist` 静态站”,不再上传 APK
|
||||||
|
- 2026-04-03 已新增仓库根脚本 `node scripts/deploy-android-release.mjs`,只负责把 APK 与 `android/releases/latest.json` 上传到 Android 独立对象路径;`node scripts/deploy-android-apk.mjs` 会在前端静态站发布后自动调用它
|
||||||
|
- 2026-04-03 Android 更新链路已改为“APK 存在文件桶独立路径 `android/releases/`,后端 `/api/app/android/latest` 读取 `android/releases/latest.json` 返回带版本号的后端下载地址,`/api/app/android/download` 直接分发 APK 字节流”;这样 App 内检查更新和 Web 下载都不会再误用前端静态桶旧包,也不依赖对象存储预签名下载
|
||||||
- 2026-04-03 网盘已新增回收站:`DELETE /api/files/{id}` 现在会把文件或整个目录树软删除进回收站,默认保留 10 天;前端桌面网盘页在左侧目录栏最下方新增“回收站”入口,移动端网盘页头也可进入回收站查看并恢复
|
- 2026-04-03 网盘已新增回收站:`DELETE /api/files/{id}` 现在会把文件或整个目录树软删除进回收站,默认保留 10 天;前端桌面网盘页在左侧目录栏最下方新增“回收站”入口,移动端网盘页头也可进入回收站查看并恢复
|
||||||
|
- 2026-04-05 Git 远程已从 GitHub 迁到自建私有 Gitea:`https://git.yoyuzh.xyz/yoyuz/my_site.git`;当前本地 `main` 已推到新的 `origin/main`
|
||||||
|
- 2026-04-05 因为仓库现在是私人仓库,`.gitignore` 已放开 `账号密码.txt`、`开发测试账号.md`、`.env.local`、`.env.*.local`、`.env.oss.local`、`front/.env.production` 等私有配置文件,后续可以直接纳入版本控制
|
||||||
- 根目录 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
|
||||||
- 进行中:
|
- 进行中:
|
||||||
@@ -92,13 +96,20 @@
|
|||||||
- 2026-04-02 共享 blob 上线后校验:`portal_file.blob_id` 列已存在,普通文件 `blob_id IS NULL` 数量为 0,`portal_file_blob` 当前共有 54 条记录
|
- 2026-04-02 共享 blob 上线后校验:`portal_file.blob_id` 列已存在,普通文件 `blob_id IS NULL` 数量为 0,`portal_file_blob` 当前共有 54 条记录
|
||||||
- 2026-04-02 18:45 CST 线上上传报 `Column 'storage_name' cannot be null`,已定位为旧表结构未把 `portal_file.storage_name` 放宽为可空;已在线执行 `ALTER TABLE portal_file MODIFY storage_name varchar(255) NULL` 修复
|
- 2026-04-02 18:45 CST 线上上传报 `Column 'storage_name' cannot be null`,已定位为旧表结构未把 `portal_file.storage_name` 放宽为可空;已在线执行 `ALTER TABLE portal_file MODIFY storage_name varchar(255) NULL` 修复
|
||||||
- 2026-04-02 19:08 CST 再次发布后端,`my-site-api.service` 启动时间更新为 `2026-04-02 19:08:14 CST`,`https://api.yoyuzh.xyz/swagger-ui.html` 再次确认返回 `200`
|
- 2026-04-02 19:08 CST 再次发布后端,`my-site-api.service` 启动时间更新为 `2026-04-02 19:08:14 CST`,`https://api.yoyuzh.xyz/swagger-ui.html` 再次确认返回 `200`
|
||||||
|
- 2026-04-04 私有 `apk/ipa` 下载链路已改为“后端鉴权后返回短时 `https://api.yoyuzh.xyz/_dl/...` 链接,Nginx `secure_link` 校验通过后再代理到 `dl.yoyuzh.xyz` 对象域名”;这样安装包不再走默认 `*.myqcloud.com` 域名,也不再暴露长期可用的公开 `dl` 直链
|
||||||
|
- 2026-04-04 12:48 CST 已将私有 `apk/ipa` 的 `/_dl` 短时签名修复重新部署到生产;`my-site-api.service` 重启成功,`https://api.yoyuzh.xyz/swagger-ui/index.html` 返回 `200`,带签名的 `https://api.yoyuzh.xyz/_dl/...` 实测返回 `200 OK`
|
||||||
|
- 2026-04-05 Git 远程 `origin` 已改为私有 Gitea 仓库 `https://git.yoyuzh.xyz/yoyuz/my_site.git`,默认分支 `main` 已建立对 `origin/main` 的跟踪
|
||||||
|
- 2026-04-05 仓库当前不再把密码文件、本地环境变量文件和前端生产环境文件视为必须忽略项;提交前要主动区分“想入库的私有配置”与“仍应保留本地的临时产物”
|
||||||
- Android 本机构建当前默认 SDK 根目录为 `/Users/mac/Library/Android/sdk`
|
- Android 本机构建当前默认 SDK 根目录为 `/Users/mac/Library/Android/sdk`
|
||||||
- Android 本地打包命令链:
|
- Android 本地打包命令链:
|
||||||
- `cd front && npm run build`
|
- `cd front && npm run build`
|
||||||
- `cd front && npx cap sync android`
|
- `cd front && npx cap sync android`
|
||||||
- `cd front/android && ./gradlew assembleDebug`
|
- `cd front/android && ./gradlew assembleDebug`
|
||||||
|
- Android 一键发包命令:
|
||||||
|
- `node scripts/deploy-android-apk.mjs`
|
||||||
- Android 调试 APK 当前输出路径:`front/android/app/build/outputs/apk/debug/app-debug.apk`
|
- Android 调试 APK 当前输出路径:`front/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
- 前端 OSS 发布现会额外把调试 APK 上传到稳定对象 key:`downloads/yoyuzh-portal.apk`
|
- Android APK 独立发包命令:
|
||||||
|
- `node scripts/deploy-android-release.mjs`
|
||||||
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
- 服务器登录信息保存在本地 `账号密码.txt`,不要把内容写进文档或对外输出
|
||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
|
|||||||
112
scripts/deploy-android-apk.mjs
Normal file
112
scripts/deploy-android-apk.mjs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
const frontDir = path.join(repoRoot, 'front');
|
||||||
|
const androidDir = path.join(frontDir, 'android');
|
||||||
|
const capacitorPluginsGradlePath = path.join(androidDir, 'capacitor-cordova-android-plugins', 'build.gradle');
|
||||||
|
const capacitorAppGradlePath = path.join(frontDir, 'node_modules', '@capacitor', 'app', 'android', 'build.gradle');
|
||||||
|
|
||||||
|
const googleMirrorValue = 'https://maven.aliyun.com/repository/google';
|
||||||
|
|
||||||
|
function runCommand(command, args, cwd, extraEnv = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...extraEnv,
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndroidBuildVersion() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = String(now.getFullYear()).slice(-2);
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const dayOfYear = String(Math.floor((now - startOfYear) / 86400000) + 1).padStart(3, '0');
|
||||||
|
const hour = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
versionCode: `${year}${dayOfYear}${hour}${minute}`,
|
||||||
|
versionName: `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}.${hour}${minute}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAndroidGoogleMirror() {
|
||||||
|
await Promise.all([
|
||||||
|
patchCapacitorPluginGradle(capacitorPluginsGradlePath),
|
||||||
|
patchCapacitorPluginGradle(capacitorAppGradlePath),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchCapacitorPluginGradle(gradlePath) {
|
||||||
|
try {
|
||||||
|
const original = await fs.readFile(gradlePath, 'utf-8');
|
||||||
|
let next = original;
|
||||||
|
|
||||||
|
if (!next.includes(`def googleMirror = '${googleMirrorValue}'`)) {
|
||||||
|
next = next.replace(
|
||||||
|
'buildscript {\n',
|
||||||
|
`buildscript {\n def googleMirror = '${googleMirrorValue}'\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next = next.replace(
|
||||||
|
/buildscript \{\n\s+def googleMirror = 'https:\/\/maven\.aliyun\.com\/repository\/google'\n\s+repositories \{\n\s+google\(\)\n/,
|
||||||
|
`buildscript {\n def googleMirror = '${googleMirrorValue}'\n repositories {\n maven { url googleMirror }\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
next = next.replace(
|
||||||
|
/repositories \{\n\s+google\(\)\n\s+mavenCentral\(\)/g,
|
||||||
|
`repositories {\n maven { url '${googleMirrorValue}' }\n mavenCentral()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next !== original) {
|
||||||
|
await fs.writeFile(gradlePath, next, 'utf-8');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const buildVersion = createAndroidBuildVersion();
|
||||||
|
const buildEnv = {
|
||||||
|
YOYUZH_ANDROID_VERSION_CODE: buildVersion.versionCode,
|
||||||
|
YOYUZH_ANDROID_VERSION_NAME: buildVersion.versionName,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Android versionCode=${buildVersion.versionCode}`);
|
||||||
|
console.log(`Android versionName=${buildVersion.versionName}`);
|
||||||
|
|
||||||
|
runCommand('npm', ['run', 'build'], frontDir, buildEnv);
|
||||||
|
runCommand('npx', ['cap', 'sync', 'android'], frontDir, buildEnv);
|
||||||
|
await ensureAndroidGoogleMirror();
|
||||||
|
|
||||||
|
const gradleCommand = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
|
||||||
|
runCommand(gradleCommand, ['assembleDebug'], androidDir, buildEnv);
|
||||||
|
runCommand('node', ['scripts/deploy-front-oss.mjs', '--skip-build'], repoRoot, buildEnv);
|
||||||
|
runCommand('node', ['scripts/deploy-android-release.mjs'], repoRoot, buildEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
210
scripts/deploy-android-release.mjs
Normal file
210
scripts/deploy-android-release.mjs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildObjectKey,
|
||||||
|
createAwsV4Headers,
|
||||||
|
encodeObjectKey,
|
||||||
|
getCacheControl,
|
||||||
|
normalizeEndpoint,
|
||||||
|
parseSimpleEnv,
|
||||||
|
requestDogeCloudTemporaryS3Session,
|
||||||
|
} from './oss-deploy-lib.mjs';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const envFilePath = path.join(repoRoot, '.env.oss.local');
|
||||||
|
const apkSourcePath = path.join(repoRoot, 'front', 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
return {
|
||||||
|
dryRun: argv.includes('--dry-run'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnvFileIfPresent() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(envFilePath, 'utf-8');
|
||||||
|
const values = parseSimpleEnv(raw);
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (!process.env[key]) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidReleaseVersion() {
|
||||||
|
const versionCode = requireEnv('YOYUZH_ANDROID_VERSION_CODE').trim();
|
||||||
|
const versionName = requireEnv('YOYUZH_ANDROID_VERSION_NAME').trim();
|
||||||
|
return {versionCode, versionName};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidReleasePrefix() {
|
||||||
|
return (process.env.YOYUZH_ANDROID_RELEASE_PREFIX || 'android/releases').trim().replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidReleaseScope() {
|
||||||
|
return (process.env.YOYUZH_DOGECLOUD_ANDROID_SCOPE || process.env.YOYUZH_DOGECLOUD_STORAGE_SCOPE || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidReleaseApkObjectKey(versionName) {
|
||||||
|
const safeVersionName = versionName.replace(/[^0-9A-Za-z._-]/g, '-');
|
||||||
|
return buildObjectKey(getAndroidReleasePrefix(), `yoyuzh-portal-${safeVersionName}.apk`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidReleaseMetadataObjectKey() {
|
||||||
|
return buildObjectKey(getAndroidReleasePrefix(), 'latest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAndroidReleaseMetadata() {
|
||||||
|
const {versionCode, versionName} = getAndroidReleaseVersion();
|
||||||
|
const fileName = path.posix.basename(getAndroidReleaseApkObjectKey(versionName));
|
||||||
|
return {
|
||||||
|
versionCode,
|
||||||
|
versionName,
|
||||||
|
fileName,
|
||||||
|
objectKey: getAndroidReleaseApkObjectKey(versionName),
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
objectKey,
|
||||||
|
filePath,
|
||||||
|
contentType,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
}) {
|
||||||
|
const body = await fs.readFile(filePath);
|
||||||
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||||
|
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
|
||||||
|
const signatureHeaders = createAwsV4Headers({
|
||||||
|
method: 'PUT',
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
},
|
||||||
|
amzDate,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...signatureHeaders,
|
||||||
|
'Cache-Control': getCacheControl(objectKey),
|
||||||
|
'Content-Length': String(body.byteLength),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Upload failed for ${objectKey}: ${response.status} ${response.statusText}\n${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const {dryRun} = parseArgs(process.argv.slice(2));
|
||||||
|
await loadEnvFileIfPresent();
|
||||||
|
|
||||||
|
const androidScope = getAndroidReleaseScope();
|
||||||
|
if (!androidScope) {
|
||||||
|
throw new Error('Missing required environment variable: YOYUZH_DOGECLOUD_ANDROID_SCOPE or YOYUZH_DOGECLOUD_STORAGE_SCOPE');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.access(apkSourcePath);
|
||||||
|
|
||||||
|
const apiAccessKey = requireEnv('YOYUZH_DOGECLOUD_API_ACCESS_KEY');
|
||||||
|
const apiSecretKey = requireEnv('YOYUZH_DOGECLOUD_API_SECRET_KEY');
|
||||||
|
const apiBaseUrl = process.env.YOYUZH_DOGECLOUD_API_BASE_URL || 'https://api.dogecloud.com';
|
||||||
|
const region = process.env.YOYUZH_DOGECLOUD_S3_REGION || 'automatic';
|
||||||
|
const ttlSeconds = Number(process.env.YOYUZH_DOGECLOUD_ANDROID_TTL_SECONDS || process.env.YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS || '3600');
|
||||||
|
|
||||||
|
const {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
endpoint,
|
||||||
|
bucket,
|
||||||
|
} = await requestDogeCloudTemporaryS3Session({
|
||||||
|
apiBaseUrl,
|
||||||
|
accessKey: apiAccessKey,
|
||||||
|
secretKey: apiSecretKey,
|
||||||
|
scope: androidScope,
|
||||||
|
ttlSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = buildAndroidReleaseMetadata();
|
||||||
|
const tempMetadataPath = path.join(repoRoot, '.tmp-android-release.json');
|
||||||
|
await fs.writeFile(tempMetadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploads = [
|
||||||
|
{
|
||||||
|
objectKey: metadata.objectKey,
|
||||||
|
filePath: apkSourcePath,
|
||||||
|
contentType: 'application/vnd.android.package-archive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectKey: getAndroidReleaseMetadataObjectKey(),
|
||||||
|
filePath: tempMetadataPath,
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const upload of uploads) {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] upload ${upload.filePath} -> ${upload.objectKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
objectKey: upload.objectKey,
|
||||||
|
filePath: upload.filePath,
|
||||||
|
contentType: upload.contentType,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
sessionToken,
|
||||||
|
});
|
||||||
|
console.log(`uploaded ${upload.objectKey}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempMetadataPath, {force: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -23,8 +23,6 @@ const repoRoot = process.cwd();
|
|||||||
const frontDir = path.join(repoRoot, 'front');
|
const frontDir = path.join(repoRoot, 'front');
|
||||||
const distDir = path.join(frontDir, 'dist');
|
const distDir = path.join(frontDir, 'dist');
|
||||||
const envFilePath = path.join(repoRoot, '.env.oss.local');
|
const envFilePath = path.join(repoRoot, '.env.oss.local');
|
||||||
const apkSourcePath = path.join(frontDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
|
||||||
const apkObjectPath = 'downloads/yoyuzh-portal.apk';
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
return {
|
return {
|
||||||
@@ -155,48 +153,6 @@ async function uploadSpaAliases({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadApkIfPresent({
|
|
||||||
bucket,
|
|
||||||
endpoint,
|
|
||||||
region,
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
sessionToken,
|
|
||||||
remotePrefix,
|
|
||||||
dryRun,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
await fs.access(apkSourcePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
||||||
console.warn(`skip apk upload: not found at ${apkSourcePath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectKey = buildObjectKey(remotePrefix, apkObjectPath);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(`[dry-run] upload ${apkObjectPath} -> ${objectKey}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await uploadFile({
|
|
||||||
bucket,
|
|
||||||
endpoint,
|
|
||||||
region,
|
|
||||||
objectKey,
|
|
||||||
filePath: apkSourcePath,
|
|
||||||
contentTypeOverride: 'application/vnd.android.package-archive',
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
sessionToken,
|
|
||||||
});
|
|
||||||
console.log(`uploaded ${objectKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
@@ -265,17 +221,6 @@ async function main() {
|
|||||||
remotePrefix,
|
remotePrefix,
|
||||||
dryRun,
|
dryRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
await uploadApkIfPresent({
|
|
||||||
bucket,
|
|
||||||
endpoint,
|
|
||||||
region,
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
sessionToken,
|
|
||||||
remotePrefix,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
18
开发测试账号.md
Normal file
18
开发测试账号.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 开发测试账号
|
||||||
|
|
||||||
|
以下账号会在后端以 `dev` profile 启动时自动初始化。
|
||||||
|
|
||||||
|
## 门户账号
|
||||||
|
|
||||||
|
| 门户用户名 | 门户密码 | 教务学号 | 教务密码 | 查询学期 | 网盘示例文件 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `portal-demo` | `portal123456` | `2023123456` | `portal123456` | `2025-spring` | `迎新资料.txt`、`课程规划.md`、`campus-shot.png` |
|
||||||
|
| `portal-study` | `study123456` | `2022456789` | `study123456` | `2024-fall` | `实验数据.csv`、`论文草稿.md`、`data-chart.png` |
|
||||||
|
| `portal-design` | `design123456` | `2021789012` | `design123456` | `2024-spring` | `素材清单.txt`、`作品说明.md`、`ui-mockup.png` |
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
- 先用上表中的“门户用户名 / 门户密码”登录站点。
|
||||||
|
- 登录后进入网盘页,每个用户都会看到自己的 `下载 / 文档 / 图片` 目录,以及各自不同的样例文件。
|
||||||
|
- 进入教务页后,填入对应的“教务学号 / 教务密码 / 查询学期”即可看到该用户对应的 mock 教务数据。
|
||||||
|
- 当前开发环境的教务密码字段仅用于前端占位,后端主要依据登录态、学号和学期返回该用户的 mock 数据。为避免混淆,直接填表中的教务密码即可。
|
||||||
Reference in New Issue
Block a user