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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
mock-maker-subclass