feat: ship portal and android release updates

This commit is contained in:
yoyuzh
2026-04-05 13:57:13 +08:00
parent 52b5bbfe8e
commit ed837f5ec9
46 changed files with 1507 additions and 189 deletions

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.config;
public record AndroidReleaseDownload(
String fileName,
byte[] content
) {
}

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.config;
public record AndroidReleaseMetadata(
String objectKey,
String fileName,
String versionCode,
String versionName,
String publishedAt
) {
}

View File

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

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.config;
public record AndroidReleaseResponse(
String downloadUrl,
String fileName,
String versionCode,
String versionName,
String publishedAt
) {
}

View File

@@ -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 安装包元数据读取失败");
}
}
}

View File

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

View File

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

View File

@@ -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/*")

View File

@@ -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,

View File

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