diff --git a/.env.oss.example b/.env.oss.example index 8336284..80bc775 100644 --- a/.env.oss.example +++ b/.env.oss.example @@ -30,3 +30,10 @@ YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS="3600" # 可选:上传到桶内的子目录。 # 为空表示直接上传到桶根目录。 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" diff --git a/.env.oss.local b/.env.oss.local new file mode 100644 index 0000000..fd1bc1e --- /dev/null +++ b/.env.oss.local @@ -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" diff --git a/.gitignore b/.gitignore index 053ec08..a44add9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,12 +15,6 @@ frontend-dev.out.log frontend-dev.err.log *.log -.env.oss.local -.env.local -.env.*.local -front/.env.production - -账号密码.txt .history/ .vscode/ .idea/ @@ -30,7 +24,6 @@ front/.env.production !.codex/agents/ .codex/agents/* !.codex/agents/*.toml -开发测试账号.md .DS_Store *.swp diff --git a/AGENTS.md b/AGENTS.md index 586ea8b..ee36f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ Important: there is no dedicated backend lint command and no dedicated backend t ### Script files +- `scripts/deploy-android-apk.mjs` +- `scripts/deploy-android-release.mjs` - `scripts/deploy-front-oss.mjs` - `scripts/migrate-file-storage-to-oss.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 +- 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: `node scripts/deploy-front-oss.mjs` - 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: -- `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. - 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. diff --git a/README.md b/README.md index 2ff6f1a..98d235c 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ YOYUZH_DOGECLOUD_API_SECRET_KEY=... YOYUZH_DOGECLOUD_FRONT_SCOPE=yoyuzh-front YOYUZH_DOGECLOUD_FRONT_TTL_SECONDS=3600 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 ``` +### 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 迁移 静态站点桶或文件桶需要整桶迁移时,可在仓库根目录执行: diff --git a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java index 953ebc2..0ca8e53 100644 --- a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java +++ b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java @@ -1,6 +1,7 @@ package com.yoyuzh; import com.yoyuzh.config.AdminProperties; +import com.yoyuzh.config.AndroidReleaseProperties; import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.JwtProperties; @@ -15,7 +16,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties FileStorageProperties.class, CorsProperties.class, AdminProperties.class, - RegistrationProperties.class + RegistrationProperties.class, + AndroidReleaseProperties.class }) public class PortalBackendApplication { diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseController.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseController.java new file mode 100644 index 0000000..51b1870 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseController.java @@ -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 getLatestRelease() { + return ApiResponse.success(androidReleaseService.getLatestRelease()); + } + + @GetMapping("/download") + public ResponseEntity downloadLatestRelease() { + return buildDownloadResponse(androidReleaseService.downloadLatestRelease()); + } + + @GetMapping("/download/{fileName:.+}") + public ResponseEntity downloadVersionedRelease(@PathVariable String fileName) { + AndroidReleaseDownload download = androidReleaseService.downloadLatestRelease(); + if (!download.fileName().equals(fileName)) { + return ResponseEntity.notFound().build(); + } + return buildDownloadResponse(download); + } + + private ResponseEntity 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()); + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseDownload.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseDownload.java new file mode 100644 index 0000000..d5f3a34 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseDownload.java @@ -0,0 +1,7 @@ +package com.yoyuzh.config; + +public record AndroidReleaseDownload( + String fileName, + byte[] content +) { +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseMetadata.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseMetadata.java new file mode 100644 index 0000000..6b29e46 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseMetadata.java @@ -0,0 +1,10 @@ +package com.yoyuzh.config; + +public record AndroidReleaseMetadata( + String objectKey, + String fileName, + String versionCode, + String versionName, + String publishedAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseProperties.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseProperties.java new file mode 100644 index 0000000..83b24a3 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseResponse.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseResponse.java new file mode 100644 index 0000000..628e8c0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseResponse.java @@ -0,0 +1,10 @@ +package com.yoyuzh.config; + +public record AndroidReleaseResponse( + String downloadUrl, + String fileName, + String versionCode, + String versionName, + String publishedAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java new file mode 100644 index 0000000..776a9cd --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AndroidReleaseService.java @@ -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 安装包元数据读取失败"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java index 8395242..f3d8a91 100644 --- a/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java +++ b/backend/src/main/java/com/yoyuzh/config/FileStorageProperties.java @@ -62,6 +62,10 @@ public class FileStorageProperties { private String scope; private int ttlSeconds = 3600; private String region = "automatic"; + private String publicDownloadBaseUrl; + private String packageDownloadBaseUrl; + private String packageDownloadSecret; + private int packageDownloadTtlSeconds = 300; public String getApiBaseUrl() { return apiBaseUrl; @@ -110,5 +114,37 @@ public class FileStorageProperties { public void setRegion(String 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; + } } } diff --git a/backend/src/main/java/com/yoyuzh/config/RestClientConfig.java b/backend/src/main/java/com/yoyuzh/config/RestClientConfig.java index 0d8cdb2..5481300 100644 --- a/backend/src/main/java/com/yoyuzh/config/RestClientConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/RestClientConfig.java @@ -2,6 +2,7 @@ package com.yoyuzh.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration @@ -9,6 +10,9 @@ public class RestClientConfig { @Bean public RestClient restClient(RestClient.Builder builder) { - return builder.build(); + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(3000); + requestFactory.setReadTimeout(5000); + return builder.requestFactory(requestFactory).build(); } } diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index a9b1f00..558d4ef 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -50,6 +50,8 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() + .requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*") + .permitAll() .requestMatchers("/api/transfer/**") .permitAll() .requestMatchers(HttpMethod.GET, "/api/files/share-links/*") diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index ef03667..4835a82 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -8,6 +8,7 @@ import com.yoyuzh.common.PageResponse; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.PreparedUpload; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; @@ -24,8 +25,12 @@ import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Base64; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashSet; @@ -34,6 +39,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.Locale; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -49,19 +55,42 @@ public class FileService { private final FileShareLinkRepository fileShareLinkRepository; private final AdminMetricsService adminMetricsService; 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, FileBlobRepository fileBlobRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, 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.fileBlobRepository = fileBlobRepository; this.fileContentStorage = fileContentStorage; this.fileShareLinkRepository = fileShareLinkRepository; this.adminMetricsService = adminMetricsService; 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 @@ -370,6 +399,12 @@ public class FileService { return downloadDirectory(user, storedFile); } + if (shouldUsePublicPackageDownload(storedFile)) { + return ResponseEntity.status(302) + .location(URI.create(buildPublicPackageDownloadUrl(storedFile))) + .build(); + } + if (fileContentStorage.supportsDirectDownload()) { return ResponseEntity.status(302) .location(URI.create(fileContentStorage.createBlobDownloadUrl( @@ -393,6 +428,10 @@ public class FileService { } adminMetricsService.recordDownloadTraffic(storedFile.getSize()); + if (shouldUsePublicPackageDownload(storedFile)) { + return new DownloadUrlResponse(buildPublicPackageDownloadUrl(storedFile)); + } + if (fileContentStorage.supportsDirectDownload()) { return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl( getRequiredBlob(storedFile).getObjectKey(), @@ -521,6 +560,117 @@ public class FileService { .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, String normalizedPath, String filename, diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ecbd283..7a5efe9 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -31,6 +31,9 @@ app: usernames: ${APP_ADMIN_USERNAMES:} registration: 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: provider: ${YOYUZH_STORAGE_PROVIDER:local} max-file-size: ${YOYUZH_STORAGE_MAX_FILE_SIZE:524288000} @@ -43,6 +46,10 @@ app: scope: ${YOYUZH_DOGECLOUD_STORAGE_SCOPE:} ttl-seconds: ${YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS:3600} 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: allowed-origins: - http://localhost:3000 diff --git a/backend/src/test/java/com/yoyuzh/config/AndroidReleaseControllerTest.java b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseControllerTest.java new file mode 100644 index 0000000..05e43e3 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseControllerTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceTest.java b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceTest.java new file mode 100644 index 0000000..559fbd2 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/AndroidReleaseServiceTest.java @@ -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())); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index 6fa3194..c825ee8 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -15,11 +15,16 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import java.io.ByteArrayInputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -500,6 +505,75 @@ class FileServiceTest { 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 void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() { User user = createUser(7L); diff --git a/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/docs/api-reference.md b/docs/api-reference.md index 1b68baf..b9324cc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -122,15 +122,36 @@ - 是否可用取决于当前环境配置 - 同样支持可选请求头 `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` -### 2.6 更新用户资料 +### 2.8 更新用户资料 `PUT /api/user/profile` -### 2.7 修改密码 +### 2.9 修改密码 `POST /api/user/password` @@ -139,7 +160,7 @@ - 成功后会重新签发新的登录态 - 同时会顶掉旧设备会话 -### 2.8 头像相关 +### 2.10 头像相关 - `POST /api/user/avatar/upload/initiate` - `POST /api/user/avatar/upload` @@ -191,6 +212,7 @@ - 普通文件优先获取下载 URL - 文件夹可走 ZIP 下载 +- 私有 `apk/ipa` 下载会返回一个短时有效的 `https://api.yoyuzh.xyz/_dl/...` URL;该 URL 由 Nginx 按签名和过期时间校验后代理到对象存储自定义下载域名,不是长期可复用的公开直链 ### 3.4 文件操作 diff --git a/docs/architecture.md b/docs/architecture.md index b66116c..be381d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 壳 - 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` -- 前端总览页会在 Web 环境展示稳定 APK 下载入口 `/downloads/yoyuzh-portal.apk` -- Capacitor 原生壳内的移动端总览页会改为“检查更新”入口;前端会先对 OSS 上的 APK 做 `HEAD` 探测并读取最新修改时间,再直接打开下载链接完成更新 -- 前端 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 包 +- 仓库根目录已提供一键脚本 `node scripts/deploy-android-apk.mjs`,会串起前端构建、Capacitor 同步、Gradle 打包、前端静态站发布与 Android 独立发包,并在 `cap sync` 之后自动补回 Android 插件工程里的 Google Maven 镜像配置 +- `node scripts/deploy-android-release.mjs` 会把 APK 和 `android/releases/latest.json` 上传到 Android 独立对象路径;默认复用文件桶 scope,不再写入前端静态桶 +- 前端总览页在 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 工程,需要重新确认镜像配置仍存在 ### 3.3 快传模块 @@ -371,6 +373,7 @@ Android 壳补充说明: - 后端通过多吉云临时密钥 API 获取短期 `accessKeyId / secretAccessKey / sessionToken` - 实际对象访问走 S3 兼容协议,底层 endpoint 为 COS 兼容地址 - 普通文件下载仍采用“后端鉴权后返回签名 URL,浏览器直连对象存储下载”的主链路 +- 私有 `apk/ipa` 下载是例外:后端只负责返回短时签名的 `/_dl` 地址,真正文件流量经过服务器 Nginx 反向代理到 `dl.yoyuzh.xyz`,不经过 Spring Boot 业务进程 ## 8. 部署架构 diff --git a/front/android/app/build.gradle b/front/android/app/build.gradle index 74c120d..c4413bc 100644 --- a/front/android/app/build.gradle +++ b/front/android/app/build.gradle @@ -1,5 +1,9 @@ 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 { namespace = "xyz.yoyuzh.portal" compileSdk = rootProject.ext.compileSdkVersion @@ -7,8 +11,8 @@ android { applicationId "xyz.yoyuzh.portal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode Integer.parseInt(buildVersionCode) + versionName buildVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/front/android/app/capacitor.build.gradle b/front/android/app/capacitor.build.gradle index bbfb44f..1005ebc 100644 --- a/front/android/app/capacitor.build.gradle +++ b/front/android/app/capacitor.build.gradle @@ -9,7 +9,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - + implementation project(':capacitor-app') } diff --git a/front/android/capacitor.settings.gradle b/front/android/capacitor.settings.gradle index 9a5fa87..2085c86 100644 --- a/front/android/capacitor.settings.gradle +++ b/front/android/capacitor.settings.gradle @@ -1,3 +1,6 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN include ':capacitor-android' 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') diff --git a/front/package-lock.json b/front/package-lock.json index 885cba7..85e4861 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@capacitor/android": "^8.3.0", + "@capacitor/app": "^8.1.0", "@capacitor/cli": "^8.3.0", "@capacitor/core": "^8.3.0", "@emotion/react": "^11.14.0", @@ -326,6 +327,15 @@ "@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": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz", diff --git a/front/package.json b/front/package.json index 834d150..29f9259 100644 --- a/front/package.json +++ b/front/package.json @@ -12,6 +12,7 @@ "test": "node --import tsx --test src/**/*.test.ts" }, "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/android": "^8.3.0", "@capacitor/cli": "^8.3.0", "@capacitor/core": "^8.3.0", diff --git a/front/src/components/layout/UploadProgressPanel.test.ts b/front/src/components/layout/UploadProgressPanel.test.ts new file mode 100644 index 0000000..fe2466f --- /dev/null +++ b/front/src/components/layout/UploadProgressPanel.test.ts @@ -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/); +}); diff --git a/front/src/components/layout/UploadProgressPanel.tsx b/front/src/components/layout/UploadProgressPanel.tsx index 8db8509..1c80878 100644 --- a/front/src/components/layout/UploadProgressPanel.tsx +++ b/front/src/components/layout/UploadProgressPanel.tsx @@ -10,21 +10,84 @@ import { toggleFilesUploadPanelOpen, useFilesUploadStore, } 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(); if (uploads.length === 0) { return null; } + const summary = getUploadProgressSummary(uploads); + const isMobile = variant === 'mobile'; + return (
- - 上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length}) - +
+ + {isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`} + + {isMobile ? ( + {summary.detail} + ) : null} +
+ {isMobile ? ( + + {summary.progress}% + + ) : null} @@ -55,7 +128,12 @@ export function UploadProgressPanel() { {isUploadPanelOpen && ( - +
{uploads.map((task) => (
(null); @@ -333,9 +342,9 @@ export function MobileLayout({ children }: LayoutProps = {}) { {/* Upload Panel (Floating above bottom bar) */} -
+
- +
diff --git a/front/src/mobile-pages/MobileFiles.test.ts b/front/src/mobile-pages/MobileFiles.test.ts new file mode 100644 index 0000000..3a09591 --- /dev/null +++ b/front/src/mobile-pages/MobileFiles.test.ts @@ -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/); +}); diff --git a/front/src/mobile-pages/MobileFiles.tsx b/front/src/mobile-pages/MobileFiles.tsx index d3979a1..f6b4c57 100644 --- a/front/src/mobile-pages/MobileFiles.tsx +++ b/front/src/mobile-pages/MobileFiles.tsx @@ -121,6 +121,15 @@ interface UiFile { 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() { const navigate = useNavigate(); const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; @@ -152,6 +161,7 @@ export default function MobileFiles() { // Floating Action Button const [fabOpen, setFabOpen] = useState(false); + const layoutClassNames = getMobileFilesLayoutClassNames(); const loadCurrentPath = async (pathParts: string[]) => { const response = await apiRequest>( @@ -437,7 +447,7 @@ export default function MobileFiles() { }; return ( -
+
@@ -448,8 +458,8 @@ export default function MobileFiles() { {/* Top Header - Path navigation */} -
-
+
+
{currentPath.length > 0 && (
{/* File List */} -
+
{currentFiles.length === 0 ? (
diff --git a/front/src/mobile-pages/MobileOverview.tsx b/front/src/mobile-pages/MobileOverview.tsx index b22e3e6..20421ad 100644 --- a/front/src/mobile-pages/MobileOverview.tsx +++ b/front/src/mobile-pages/MobileOverview.tsx @@ -24,14 +24,15 @@ import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { resolveStoredFileType } from '@/src/lib/file-type'; import { getOverviewCacheKey } from '@/src/lib/page-cache'; 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 { - APK_DOWNLOAD_PUBLIC_URL, APK_DOWNLOAD_PATH, + formatApkPublishedAtLabel, getMobileOverviewApkEntryMode, getOverviewLoadErrorMessage, getOverviewStorageQuotaLabel, + isAndroidReleaseNewer, shouldShowOverviewApkDownload, } 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); } +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() { const navigate = useNavigate(); const cachedOverview = readCachedValue<{ @@ -90,34 +107,49 @@ export default function MobileOverview() { setApkActionMessage(''); try { - const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, { - method: 'HEAD', - cache: 'no-store', + const [release, installedVersion] = await Promise.all([ + apiRequest('/app/android/latest', { + 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( - lastModified - ? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(lastModified))},正在打开下载链接。` - : '发现最新安装包,正在打开下载链接。' + publishedAtLabel + ? `发现新版本 ${release.versionName ?? ''},更新时间 ${publishedAtLabel},正在打开下载链接。` + : `发现新版本 ${release.versionName ?? ''},正在打开下载链接。` ); 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) { - window.location.href = APK_DOWNLOAD_PUBLIC_URL; + window.location.href = downloadUrl; } } } catch (error) { - setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试'); + setApkActionMessage( + error instanceof Error && error.message + ? `更新服务暂时不可用:${error.message}` + : '更新服务暂时不可用,请稍后重试' + ); } finally { setCheckingApkUpdate(false); } @@ -232,48 +264,6 @@ export default function MobileOverview() { - {showApkDownload || apkEntryMode === 'update' ? ( - -
- -
-
- -
-
-

Android 客户端

-

- {apkEntryMode === 'update' - ? '在 App 内检查 OSS 上的最新安装包,并跳转到更新下载链接。' - : '总览页可直接下载最新 APK,安装包与前端站点一起托管在 OSS。'} -

-
-
- {apkEntryMode === 'update' ? ( - - ) : ( - - 下载 APK - - )} - {apkActionMessage ? ( -

{apkActionMessage}

- ) : null} -
- - ) : null} - {/* 近期文件 (精简版) */} @@ -318,6 +308,47 @@ export default function MobileOverview() { + + {showApkDownload || apkEntryMode === 'update' ? ( + +
+ +
+
+ +
+
+

Android 客户端

+

+ {apkEntryMode === 'update' + ? '在 App 内检查最新安装包,并跳转到当前版本的下载地址。' + : '总览页可直接下载最新 APK,安装包通过独立发包链路提供。'} +

+
+
+ {apkEntryMode === 'update' ? ( + + ) : ( + + 下载 APK + + )} + {apkActionMessage ? ( +

{apkActionMessage}

+ ) : null} +
+ + ) : null} {/* 留出底部边距给导航栏 */}
diff --git a/front/src/mobile-pages/MobileTransfer.test.ts b/front/src/mobile-pages/MobileTransfer.test.ts new file mode 100644 index 0000000..cfd9a71 --- /dev/null +++ b/front/src/mobile-pages/MobileTransfer.test.ts @@ -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/); +}); diff --git a/front/src/mobile-pages/MobileTransfer.tsx b/front/src/mobile-pages/MobileTransfer.tsx index cc7fdf8..61f321e 100644 --- a/front/src/mobile-pages/MobileTransfer.tsx +++ b/front/src/mobile-pages/MobileTransfer.tsx @@ -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() { const navigate = useNavigate(); const { ready: authReady, session: authSession } = useAuth(); @@ -116,6 +127,7 @@ export default function MobileTransfer() { const [offlineHistoryError, setOfflineHistoryError] = useState(''); const [selectedOfflineSession, setSelectedOfflineSession] = useState(null); const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState(null); + const layoutClassNames = getMobileTransferLayoutClassNames(); const fileInputRef = useRef(null); const folderInputRef = useRef(null); @@ -406,7 +418,7 @@ export default function MobileTransfer() { } return ( -
+
@@ -416,35 +428,44 @@ export default function MobileTransfer() { - {/* 顶部标题区 */} -
-
-
- - 快传 +
+
+
+
+
+
+
+ +
+
+ + 快传 +
+
+ + {allowSend && ( +
+ + +
+ )} +
- {allowSend && ( -
- - -
- )} - -
+
{authReady && !isAuthenticated && (

无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。

@@ -520,7 +541,7 @@ export default function MobileTransfer() {
{/* 文件列表 */} -
+

共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}

{selectedFiles.map((f, i) => (
diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index 47cc939..938d58b 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -352,18 +352,17 @@ export default function Overview() {

下载 APK 安装包

- 当前 Android 安装包会随前端站点一起发布到 OSS,可直接从这里下载最新版本。 + 当前 Android 安装包通过独立发包链路维护,可直接从这里获取最新版本。

- 稳定路径 - OSS 托管 + 后端分发 + 独立发包 一键下载
下载 APK diff --git a/front/src/pages/overview-state.test.ts b/front/src/pages/overview-state.test.ts index 19ab382..f30fc4f 100644 --- a/front/src/pages/overview-state.test.ts +++ b/front/src/pages/overview-state.test.ts @@ -4,11 +4,13 @@ import { test } from 'node:test'; import { APK_DOWNLOAD_PATH, APK_DOWNLOAD_PUBLIC_URL, + formatApkPublishedAtLabel, getDesktopOverviewSectionColumns, getDesktopOverviewStretchSection, getMobileOverviewApkEntryMode, getOverviewLoadErrorMessage, getOverviewStorageQuotaLabel, + isAndroidReleaseNewer, shouldShowOverviewApkDownload, } 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', () => { - assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk'); - assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk'); +test('overview exposes a backend download endpoint for apk delivery', () => { + assert.equal(APK_DOWNLOAD_PATH, 'https://api.yoyuzh.xyz/api/app/android/download'); + 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', () => { @@ -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(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); +}); diff --git a/front/src/pages/overview-state.ts b/front/src/pages/overview-state.ts index 35b1ec4..6f0a2a7 100644 --- a/front/src/pages/overview-state.ts +++ b/front/src/pages/overview-state.ts @@ -1,7 +1,73 @@ import { isNativeAppShellLocation } from '@/src/lib/app-shell'; -export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk'; -export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk'; +export const APK_DOWNLOAD_PATH = 'https://api.yoyuzh.xyz/api/app/android/download'; +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, right: Array) { + 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) { if (size <= 0) { diff --git a/memory.md b/memory.md index 70547a7..bc35317 100644 --- a/memory.md +++ b/memory.md @@ -30,10 +30,14 @@ - 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 由于这台机器直连 `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 前端 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-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 风格 - 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 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-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 本地打包命令链: - `cd front && npm run build` - `cd front && npx cap sync android` - `cd front/android && ./gradlew assembleDebug` +- Android 一键发包命令: + - `node scripts/deploy-android-apk.mjs` - 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`,不要把内容写进文档或对外输出 ## 参考资料 diff --git a/scripts/deploy-android-apk.mjs b/scripts/deploy-android-apk.mjs new file mode 100644 index 0000000..adfb2f9 --- /dev/null +++ b/scripts/deploy-android-apk.mjs @@ -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); +}); diff --git a/scripts/deploy-android-release.mjs b/scripts/deploy-android-release.mjs new file mode 100644 index 0000000..45cd376 --- /dev/null +++ b/scripts/deploy-android-release.mjs @@ -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); +}); diff --git a/scripts/deploy-front-oss.mjs b/scripts/deploy-front-oss.mjs index 49323aa..ce5b481 100755 --- a/scripts/deploy-front-oss.mjs +++ b/scripts/deploy-front-oss.mjs @@ -23,8 +23,6 @@ const repoRoot = process.cwd(); const frontDir = path.join(repoRoot, 'front'); const distDir = path.join(frontDir, 'dist'); 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) { 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() { const {dryRun, skipBuild} = parseArgs(process.argv.slice(2)); @@ -265,17 +221,6 @@ async function main() { remotePrefix, dryRun, }); - - await uploadApkIfPresent({ - bucket, - endpoint, - region, - accessKeyId, - secretAccessKey, - sessionToken, - remotePrefix, - dryRun, - }); } main().catch((error) => { diff --git a/开发测试账号.md b/开发测试账号.md new file mode 100644 index 0000000..a0c463a --- /dev/null +++ b/开发测试账号.md @@ -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 数据。为避免混淆,直接填表中的教务密码即可。 diff --git a/账号密码.txt b/账号密码.txt new file mode 100644 index 0000000..7d86695 --- /dev/null +++ b/账号密码.txt @@ -0,0 +1,2 @@ +ubuntu@1.14.49.201 +)at8^56?mTUf_D