From f36a8d74568d1fa528119b459ded9b957a90428c Mon Sep 17 00:00:00 2001 From: lendry Date: Tue, 7 Jul 2026 17:36:01 +0300 Subject: [PATCH] fix file size limit --- .../admin-app-release.controller.ts | 10 +- .../src/controllers/app-release.controller.ts | 20 +- apps/frontend/app/admin/releases/page.tsx | 227 ++++++++++++------ apps/frontend/app/downloads/page.tsx | 123 ++++++---- apps/frontend/lib/api.ts | 12 +- apps/frontend/lib/app-release-variants.ts | 56 +++++ apps/frontend/lib/proxy-release-download.ts | 4 +- .../migration.sql | 8 + apps/sso-core/prisma/schema.prisma | 5 +- .../src/domain/app-release.service.ts | 167 +++++++++++-- .../src/domain/auth-grpc.controller.ts | 5 +- shared/proto/app-release.proto | 4 + 12 files changed, 482 insertions(+), 159 deletions(-) create mode 100644 apps/frontend/lib/app-release-variants.ts create mode 100644 apps/sso-core/prisma/migrations/20260707140000_add_app_release_variants/migration.sql diff --git a/apps/api-gateway/src/controllers/admin-app-release.controller.ts b/apps/api-gateway/src/controllers/admin-app-release.controller.ts index 2725a3e..8ea3767 100644 --- a/apps/api-gateway/src/controllers/admin-app-release.controller.ts +++ b/apps/api-gateway/src/controllers/admin-app-release.controller.ts @@ -83,6 +83,7 @@ export class AdminAppReleaseController { platform: { type: 'string', enum: ['ANDROID', 'WINDOWS'] }, version: { type: 'string', example: '1.2.0' }, versionCode: { type: 'integer', example: 120 }, + variant: { type: 'string', example: 'arm64-v8a', description: 'Вариант сборки. Если пусто — определяется из имени файла.' }, releaseNotes: { type: 'string' }, file: { type: 'string', format: 'binary' } }, @@ -96,6 +97,7 @@ export class AdminAppReleaseController { @Body('platform') platform: string, @Body('version') version: string, @Body('versionCode') versionCodeRaw: string, + @Body('variant') variant: string | undefined, @Body('releaseNotes') releaseNotes?: string ) { assertAdminPermission(admin, 'canManageSettings'); @@ -124,7 +126,12 @@ export class AdminAppReleaseController { const safeVersion = version.trim().replace(/[^a-zA-Z0-9._-]+/g, '_'); const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_'); - const storageKey = `releases/${normalizedPlatform.toLowerCase()}/${safeVersion}/${randomUUID()}-${safeName}`; + const normalizedVariant = (variant?.trim() || fileName.replace(/\.apk$/i, '')) + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9._-]/g, '') + .slice(0, 64) || 'universal'; + const storageKey = `releases/${normalizedPlatform.toLowerCase()}/${safeVersion}/${normalizedVariant}/${randomUUID()}-${safeName}`; const sha256 = createHash('sha256').update(file.buffer).digest('hex'); const contentType = normalizedPlatform === 'ANDROID' @@ -146,6 +153,7 @@ export class AdminAppReleaseController { platform: normalizedPlatform, version: version.trim(), versionCode, + variant: variant?.trim() || undefined, fileName, storageKey, fileSize: String(file.size), diff --git a/apps/api-gateway/src/controllers/app-release.controller.ts b/apps/api-gateway/src/controllers/app-release.controller.ts index ad0801b..264ac58 100644 --- a/apps/api-gateway/src/controllers/app-release.controller.ts +++ b/apps/api-gateway/src/controllers/app-release.controller.ts @@ -79,14 +79,19 @@ export class AppReleaseController { @Get('download/:platform') @ApiOperation({ summary: 'Скачать последний релиз', description: 'Отдаёт файл последней опубликованной версии.' }) @ApiParam({ name: 'platform', enum: ['android', 'windows', 'ANDROID', 'WINDOWS'] }) + @ApiQuery({ name: 'variant', required: false, description: 'Вариант сборки, например universal или arm64-v8a' }) @ApiResponse({ status: 200, description: 'Файл релиза' }) - async downloadLatest(@Param('platform') platform: string, @Res({ passthrough: false }) response: StreamResponse) { + async downloadLatest( + @Param('platform') platform: string, + @Query('variant') variant: string | undefined, + @Res({ passthrough: false }) response: StreamResponse + ) { const normalized = this.normalizePlatform(platform); if (!normalized) { response.status(400).json({ message: 'Некорректная платформа' }); return; } - await this.streamRelease(response, normalized); + await this.streamRelease(response, normalized, undefined, variant); } @Get('download/:platform/:releaseId') @@ -104,9 +109,14 @@ export class AppReleaseController { await this.streamRelease(response, normalized, releaseId); } - private async streamRelease(response: StreamResponse, platform: string, releaseId?: string) { + private async streamRelease( + response: StreamResponse, + platform: string, + releaseId?: string, + variant?: string + ) { const resolved = (await firstValueFrom( - this.core.appRelease.ResolveAppReleaseDownload({ platform, releaseId }) + this.core.appRelease.ResolveAppReleaseDownload({ platform, releaseId, variant }) )) as { storageKey: string; fileName: string; @@ -115,6 +125,7 @@ export class AppReleaseController { sha256: string; version: string; versionCode: number; + variant: string; }; const bucket = process.env.MINIO_BUCKET ?? 'lendry-id'; @@ -129,6 +140,7 @@ export class AppReleaseController { response.setHeader('Content-Length', resolved.fileSize); response.setHeader('X-Release-Version', resolved.version); response.setHeader('X-Release-Version-Code', String(resolved.versionCode)); + response.setHeader('X-Release-Variant', resolved.variant); response.setHeader('X-Release-Sha256', resolved.sha256); response.setHeader('Cache-Control', 'public, max-age=300'); response.setHeader('Content-Disposition', buildContentDisposition('attachment', resolved.fileName)); diff --git a/apps/frontend/app/admin/releases/page.tsx b/apps/frontend/app/admin/releases/page.tsx index 1b974f8..24c47b6 100644 --- a/apps/frontend/app/admin/releases/page.tsx +++ b/apps/frontend/app/admin/releases/page.tsx @@ -15,6 +15,11 @@ import { updateAdminAppRelease, uploadAdminAppRelease } from '@/lib/api'; +import { + detectVariantFromFileName, + formatReleaseVariantLabel, + groupReleasesByVersion +} from '@/lib/app-release-variants'; const ANDROID_PLATFORM = 'ANDROID' as const; const APK_ACCEPT = '.apk,application/vnd.android.package-archive'; @@ -52,7 +57,7 @@ export default function AdminReleasesPage() { const [version, setVersion] = useState(''); const [versionCode, setVersionCode] = useState(''); const [releaseNotes, setReleaseNotes] = useState(''); - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const canManage = Boolean(user?.canManageSettings); @@ -78,16 +83,26 @@ export default function AdminReleasesPage() { [releases] ); + const groupedReleases = useMemo(() => groupReleasesByVersion(androidReleases), [androidReleases]); + + const detectedVariants = useMemo( + () => files.map((file) => ({ file, variant: detectVariantFromFileName(file.name) })), + [files] + ); + async function handleUpload(event: React.FormEvent) { event.preventDefault(); - if (!token || !file) { - showToast('Выберите файл релиза'); + if (!token || !files.length) { + showToast('Выберите один или несколько APK'); return; } - if (file.size > MAX_RELEASE_FILE_BYTES) { - showToast('APK не должен превышать 350 МБ'); + + const oversized = files.find((file) => file.size > MAX_RELEASE_FILE_BYTES); + if (oversized) { + showToast(`Файл ${oversized.name} превышает лимит 350 МБ`); return; } + const parsedVersionCode = Number(versionCode); if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) { showToast('Укажите версию и положительный код версии'); @@ -95,21 +110,34 @@ export default function AdminReleasesPage() { } setUploading(true); + const created: AppRelease[] = []; try { - const created = await uploadAdminAppRelease(token, { - platform: ANDROID_PLATFORM, - version: version.trim(), - versionCode: parsedVersionCode, - releaseNotes: releaseNotes.trim() || undefined, - file - }); - setReleases((current) => [created, ...current]); + for (const file of files) { + const item = await uploadAdminAppRelease(token, { + platform: ANDROID_PLATFORM, + version: version.trim(), + versionCode: parsedVersionCode, + variant: detectVariantFromFileName(file.name), + releaseNotes: releaseNotes.trim() || undefined, + file + }); + created.push(item); + } + + setReleases((current) => [...created, ...current]); setVersion(''); setVersionCode(''); setReleaseNotes(''); - setFile(null); - showToast('Релиз загружен и опубликован'); + setFiles([]); + showToast( + created.length === 1 + ? `Загружена сборка ${formatReleaseVariantLabel(created[0].variant)}` + : `Загружено сборок: ${created.length}` + ); } catch (error) { + if (created.length) { + setReleases((current) => [...created, ...current]); + } showToast(getApiErrorMessage(error, 'Не удалось загрузить релиз') ?? 'Ошибка'); } finally { setUploading(false); @@ -129,80 +157,102 @@ export default function AdminReleasesPage() { async function removeRelease(release: AppRelease) { if (!token) return; - if (!window.confirm(`Удалить версию ${release.version} (Android)?`)) return; + if (!window.confirm(`Удалить сборку ${formatReleaseVariantLabel(release.variant)} для v${release.version}?`)) return; try { await deleteAdminAppRelease(token, release.id); setReleases((current) => current.filter((item) => item.id !== release.id)); - showToast('Релиз удалён'); + showToast('Сборка удалена'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить релиз') ?? 'Ошибка'); } } - function renderReleaseList(items: AppRelease[], emptyLabel: string) { + function renderReleaseList(emptyLabel: string) { if (loading) { return
Загружаем релизы...
; } - if (!items.length) { + if (!groupedReleases.length) { return
{emptyLabel}
; } return ( -
- {items.map((release, index) => { - const isLatest = index === 0 && release.isPublished; +
+ {groupedReleases.map((group, groupIndex) => { + const publishedVariants = group.variants.filter((item) => item.isPublished); + const isLatest = groupIndex === 0 && publishedVariants.length > 0; + const notes = group.variants.find((item) => item.releaseNotes)?.releaseNotes; + return ( -
-
-
-
- -
-
-
-

v{release.version}

- - build {release.versionCode} - - {isLatest ? ( - - Последняя - - ) : null} - {!release.isPublished ? ( - - Скрыта - - ) : null} -
-

- {release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)} -

-

SHA-256: {release.sha256}

- {release.releaseNotes ? ( -

{release.releaseNotes}

- ) : null} -
-
-
- - - -
+
+
+ +
+
+
+ + {formatReleaseVariantLabel(release.variant)} + + {!release.isPublished ? ( + + Скрыта + + ) : null} +
+

+ {release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)} +

+

SHA-256: {release.sha256}

+
+
+
+ + + +
+
+ ))}
); @@ -229,7 +279,7 @@ export default function AdminReleasesPage() {

Новая версия Android

-

Загрузите APK с подписью SHA-256

+

Можно загрузить несколько APK одной версии

@@ -257,26 +307,45 @@ export default function AdminReleasesPage() { -

Версии Android

- {renderReleaseList(androidReleases, 'Релизы Android пока не загружены')} + {renderReleaseList('Релизы Android пока не загружены')}
diff --git a/apps/frontend/app/downloads/page.tsx b/apps/frontend/app/downloads/page.tsx index df82ecd..e275831 100644 --- a/apps/frontend/app/downloads/page.tsx +++ b/apps/frontend/app/downloads/page.tsx @@ -11,6 +11,7 @@ import { buildAppReleaseDownloadUrl, fetchPublicAppReleases } from '@/lib/api'; +import { formatReleaseVariantLabel, groupReleasesByVersion } from '@/lib/app-release-variants'; function formatBytes(value: string) { const size = Number(value); @@ -49,15 +50,15 @@ export default function DownloadsPage() { })(); }, []); - const latestRelease = useMemo( - () => releases.find((item) => item.platform === 'ANDROID') ?? null, + const groupedReleases = useMemo( + () => groupReleasesByVersion(releases.filter((item) => item.platform === 'ANDROID')), [releases] ); - const androidHistory = useMemo( - () => releases.filter((item) => item.platform === 'ANDROID'), - [releases] - ); + const latestGroup = groupedReleases[0] ?? null; + const latestVariants = latestGroup?.variants ?? []; + const preferredLatest = + latestVariants.find((item) => item.variant === 'universal') ?? latestVariants[0] ?? null; return (
@@ -81,8 +82,8 @@ export default function DownloadsPage() { Скачайте приложение

- Актуальная версия для Android. Каждый релиз подписан SHA-256 — мобильное приложение может - автоматически проверять обновления по коду версии. + Актуальная версия для Android. Доступны разные сборки: universal, arm64-v8a и другие. + Каждый файл подписан SHA-256.

@@ -96,7 +97,7 @@ export default function DownloadsPage() {

Android

-

Скачайте APK для телефона или планшета

+

Выберите подходящую сборку APK

@@ -104,37 +105,64 @@ export default function DownloadsPage() {
{loading ? (

Проверяем доступные версии...

- ) : latestRelease ? ( + ) : latestGroup && preferredLatest ? ( <>

Последняя версия

- v{latestRelease.version} + v{latestGroup.version} - build {latestRelease.versionCode} + build {latestGroup.versionCode} + {latestVariants.length > 1 ? ( + + {latestVariants.length} сборки + + ) : null}

- {formatBytes(latestRelease.fileSize)} · {formatDate(latestRelease.createdAt)} + {formatDate(preferredLatest.createdAt)}

- {latestRelease.releaseNotes ? ( + {preferredLatest.releaseNotes ? (

- {latestRelease.releaseNotes} + {preferredLatest.releaseNotes}

) : null} +
+ {latestVariants.map((release) => ( +
+
+

{formatReleaseVariantLabel(release.variant)}

+

+ {formatBytes(release.fileSize)} · {release.fileName} +

+
+ +
+ ))} +
+
@@ -149,32 +177,38 @@ export default function DownloadsPage() {

История версий

-
+
{loading ? (

Загружаем список версий...

- ) : androidHistory.length ? ( - androidHistory.map((release, index) => ( -
-
-
- v{release.version} - build {release.versionCode} - {index === 0 ? ( - - Актуальная - - ) : null} -
-

- {formatDate(release.createdAt)} · {formatBytes(release.fileSize)} -

+ ) : groupedReleases.length ? ( + groupedReleases.map((group, groupIndex) => ( +
+
+ v{group.version} + build {group.versionCode} + {groupIndex === 0 ? ( + + Актуальная + + ) : null} +
+
+ {group.variants.map((release) => ( +
+
+

+ {formatReleaseVariantLabel(release.variant)} +

+

+ {formatDate(release.createdAt)} · {formatBytes(release.fileSize)} +

+
+ +
+ ))}
-
)) ) : ( @@ -193,8 +227,9 @@ export default function DownloadsPage() {

Мобильный клиент может запрашивать{' '} GET /idp-api/releases/check?platform=ANDROID&versionCode=...{' '} - и сравнивать SHA-256 перед установкой. Постоянная ссылка на последнюю версию: - /downloads/android + и получать список всех сборок последней версии в поле variants. + Для конкретной архитектуры используйте{' '} + /downloads/android?variant=arm64-v8a.

diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index a6dda47..3df00d0 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -1734,6 +1734,7 @@ export interface AppRelease { platform: AppReleasePlatform; version: string; versionCode: number; + variant: string; fileName: string; storageKey: string; fileSize: string; @@ -1747,6 +1748,7 @@ export interface AppRelease { export interface AppUpdateCheckResponse { updateAvailable: boolean; latest?: AppRelease; + variants?: AppRelease[]; } export async function fetchPublicAppReleases(platform?: AppReleasePlatform) { @@ -1768,9 +1770,11 @@ export async function checkAppUpdate(platform: AppReleasePlatform, versionCode: ); } -export function buildAppReleaseDownloadUrl(platform: AppReleasePlatform, releaseId?: string) { +export function buildAppReleaseDownloadUrl(platform: AppReleasePlatform, releaseId?: string, variant?: string) { const slug = platform.toLowerCase(); - return releaseId ? `/downloads/${slug}/${releaseId}` : `/downloads/${slug}`; + if (releaseId) return `/downloads/${slug}/${releaseId}`; + if (variant) return `/downloads/${slug}?variant=${encodeURIComponent(variant)}`; + return `/downloads/${slug}`; } export async function fetchAdminAppReleases(token: string, platform?: AppReleasePlatform) { @@ -1784,6 +1788,7 @@ export async function uploadAdminAppRelease( platform: AppReleasePlatform; version: string; versionCode: number; + variant?: string; releaseNotes?: string; file: File; }, @@ -1793,6 +1798,9 @@ export async function uploadAdminAppRelease( formData.append('platform', payload.platform); formData.append('version', payload.version.trim()); formData.append('versionCode', String(payload.versionCode)); + if (payload.variant?.trim()) { + formData.append('variant', payload.variant.trim()); + } if (payload.releaseNotes?.trim()) { formData.append('releaseNotes', payload.releaseNotes.trim()); } diff --git a/apps/frontend/lib/app-release-variants.ts b/apps/frontend/lib/app-release-variants.ts new file mode 100644 index 0000000..9112ced --- /dev/null +++ b/apps/frontend/lib/app-release-variants.ts @@ -0,0 +1,56 @@ +const FILENAME_VARIANT_RULES: Array<{ pattern: RegExp; variant: string }> = [ + { pattern: /arm64[-_]?v8a/i, variant: 'arm64-v8a' }, + { pattern: /armeabi[-_]?v7a/i, variant: 'armeabi-v7a' }, + { pattern: /x86[-_]?64/i, variant: 'x86_64' }, + { pattern: /(? = { + universal: 'Universal', + 'arm64-v8a': 'ARM64 (arm64-v8a)', + 'armeabi-v7a': 'ARMv7 (armeabi-v7a)', + x86_64: 'x86_64', + x86: 'x86' +}; + +export function detectVariantFromFileName(fileName: string) { + const baseName = fileName.trim().replace(/\.apk$/i, ''); + for (const rule of FILENAME_VARIANT_RULES) { + if (rule.pattern.test(baseName)) { + return rule.variant; + } + } + const normalized = baseName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return normalized || 'universal'; +} + +export function formatReleaseVariantLabel(variant: string) { + return VARIANT_LABELS[variant] ?? variant; +} + +export function groupReleasesByVersion(releases: T[]) { + const groups = new Map(); + for (const release of releases) { + const key = `${release.versionCode}:${release.version}`; + const list = groups.get(key) ?? []; + list.push(release); + groups.set(key, list); + } + return [...groups.entries()] + .sort((left, right) => Number(right[0].split(':')[0]) - Number(left[0].split(':')[0])) + .map(([key, items]) => ({ + key, + version: items[0]?.version ?? '', + versionCode: items[0]?.versionCode ?? 0, + variants: [...items].sort((a, b) => { + const left = 'variant' in a ? String((a as { variant?: string }).variant ?? '') : ''; + const right = 'variant' in b ? String((b as { variant?: string }).variant ?? '') : ''; + return left.localeCompare(right, 'ru'); + }) + })); +} diff --git a/apps/frontend/lib/proxy-release-download.ts b/apps/frontend/lib/proxy-release-download.ts index 2130dfb..8a5366a 100644 --- a/apps/frontend/lib/proxy-release-download.ts +++ b/apps/frontend/lib/proxy-release-download.ts @@ -12,6 +12,7 @@ const PASSTHROUGH_HEADERS = [ 'content-disposition', 'x-release-version', 'x-release-version-code', + 'x-release-variant', 'x-release-sha256', 'cache-control' ] as const; @@ -22,9 +23,10 @@ export async function proxyReleaseDownload( releaseId?: string ) { const apiBase = resolveServerApiBase(request); + const variant = request.nextUrl.searchParams.get('variant'); const path = releaseId ? `/releases/download/${platform}/${releaseId}` - : `/releases/download/${platform}`; + : `/releases/download/${platform}${variant ? `?variant=${encodeURIComponent(variant)}` : ''}`; const upstream = await fetch(`${apiBase}${path}`, { method: 'GET', diff --git a/apps/sso-core/prisma/migrations/20260707140000_add_app_release_variants/migration.sql b/apps/sso-core/prisma/migrations/20260707140000_add_app_release_variants/migration.sql new file mode 100644 index 0000000..b96e2be --- /dev/null +++ b/apps/sso-core/prisma/migrations/20260707140000_add_app_release_variants/migration.sql @@ -0,0 +1,8 @@ +-- Add variant column for multiple APK builds per version (universal, arm64-v8a, etc.) +ALTER TABLE "AppRelease" ADD COLUMN "variant" TEXT NOT NULL DEFAULT 'universal'; + +DROP INDEX IF EXISTS "AppRelease_platform_version_key"; +DROP INDEX IF EXISTS "AppRelease_platform_versionCode_key"; + +CREATE UNIQUE INDEX "AppRelease_platform_versionCode_variant_key" ON "AppRelease"("platform", "versionCode", "variant"); +CREATE INDEX "AppRelease_platform_versionCode_isPublished_idx" ON "AppRelease"("platform", "versionCode", "isPublished"); diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index 905f9b0..d714656 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -51,6 +51,7 @@ model AppRelease { platform AppReleasePlatform version String versionCode Int + variant String @default("universal") fileName String storageKey String @unique fileSize BigInt @@ -61,9 +62,9 @@ model AppRelease { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([platform, version]) - @@unique([platform, versionCode]) + @@unique([platform, versionCode, variant]) @@index([platform, isPublished, createdAt]) + @@index([platform, versionCode, isPublished]) } model User { diff --git a/apps/sso-core/src/domain/app-release.service.ts b/apps/sso-core/src/domain/app-release.service.ts index ceb5e63..16bca61 100644 --- a/apps/sso-core/src/domain/app-release.service.ts +++ b/apps/sso-core/src/domain/app-release.service.ts @@ -4,7 +4,16 @@ import { AppReleasePlatform, Prisma } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { MinioService } from '../infra/minio.service'; -type PlatformInput = 'ANDROID' | 'WINDOWS'; +const VARIANT_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +const FILENAME_VARIANT_RULES: Array<{ pattern: RegExp; variant: string }> = [ + { pattern: /arm64[-_]?v8a/i, variant: 'arm64-v8a' }, + { pattern: /armeabi[-_]?v7a/i, variant: 'armeabi-v7a' }, + { pattern: /x86[-_]?64/i, variant: 'x86_64' }, + { pattern: /(?(releases: T[]) { + if (!releases.length) return null; + return releases.find((item) => item.variant === 'universal') ?? releases[0]; + } + + private async getLatestPublishedVersionCode(platform: AppReleasePlatform) { + const latest = await this.prisma.appRelease.findFirst({ + where: { platform, isPublished: true }, + orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }], + select: { versionCode: true } + }); + return latest?.versionCode ?? null; + } + + private async listPublishedVariants(platform: AppReleasePlatform, versionCode: number) { + const releases = await this.prisma.appRelease.findMany({ + where: { platform, isPublished: true, versionCode }, + orderBy: [{ variant: 'asc' }, { createdAt: 'desc' }] + }); + return releases; } async listReleases(platform?: string) { @@ -84,7 +148,7 @@ export class AppReleaseService { : {}; const releases = await this.prisma.appRelease.findMany({ where, - orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }] + orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { variant: 'asc' }, { createdAt: 'desc' }] }); return { releases: releases.map((release) => this.toResponse(release)) }; } @@ -96,7 +160,7 @@ export class AppReleaseService { }; const releases = await this.prisma.appRelease.findMany({ where, - orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }] + orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { variant: 'asc' }, { createdAt: 'desc' }] }); return { releases: releases.map((release) => this.toResponse(release)) }; } @@ -105,6 +169,7 @@ export class AppReleaseService { platform: string; version: string; versionCode: number; + variant?: string; fileName: string; storageKey: string; fileSize: string | number | bigint; @@ -114,6 +179,7 @@ export class AppReleaseService { }) { const platform = this.normalizePlatform(input.platform); const version = input.version.trim(); + const variant = this.normalizeVariant(input.variant, input.fileName); if (!/^\d+(?:\.\d+){0,3}(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { throw new BadRequestException('Версия должна быть в формате semver, например 1.2.3'); } @@ -142,6 +208,7 @@ export class AppReleaseService { platform, version, versionCode: input.versionCode, + variant, fileName: input.fileName.trim(), storageKey: input.storageKey, fileSize: BigInt(input.fileSize), @@ -153,7 +220,9 @@ export class AppReleaseService { return this.toResponse(release); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { - throw new BadRequestException('Релиз с такой версией или кодом версии уже существует для этой платформы'); + throw new BadRequestException( + `Сборка «${variant}» для версии ${version} (код ${input.versionCode}) уже загружена. Выберите другой вариант или удалите существующий файл.` + ); } throw error; } @@ -183,10 +252,13 @@ export class AppReleaseService { async getLatestRelease(platform: string) { const normalized = this.normalizePlatform(platform); - const release = await this.prisma.appRelease.findFirst({ - where: { platform: normalized, isPublished: true }, - orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }] - }); + const versionCode = await this.getLatestPublishedVersionCode(normalized); + if (versionCode === null) { + throw new NotFoundException('Опубликованный релиз для этой платформы не найден'); + } + + const variants = await this.listPublishedVariants(normalized, versionCode); + const release = this.preferVariant(variants); if (!release) { throw new NotFoundException('Опубликованный релиз для этой платформы не найден'); } @@ -194,29 +266,75 @@ export class AppReleaseService { } async checkUpdate(platform: string, versionCode: number) { - const latest = await this.getLatestRelease(platform); - const updateAvailable = versionCode < latest.versionCode; + const normalized = this.normalizePlatform(platform); + const latestVersionCode = await this.getLatestPublishedVersionCode(normalized); + if (latestVersionCode === null || versionCode >= latestVersionCode) { + return { updateAvailable: false, variants: [] }; + } + + const variants = await this.listPublishedVariants(normalized, latestVersionCode); + const preferred = this.preferVariant(variants); + if (!preferred) { + return { updateAvailable: false, variants: [] }; + } + return { - updateAvailable, - latest: updateAvailable ? latest : undefined + updateAvailable: true, + latest: this.toResponse(preferred), + variants: variants.map((item) => this.toResponse(item)) }; } - async resolveDownload(platform: string, releaseId?: string) { + async resolveDownload(platform: string, releaseId?: string, variant?: string) { const normalized = this.normalizePlatform(platform); - const release = releaseId - ? await this.prisma.appRelease.findFirst({ - where: { id: releaseId, platform: normalized, isPublished: true } - }) - : await this.prisma.appRelease.findFirst({ - where: { platform: normalized, isPublished: true }, - orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }] - }); + + if (releaseId) { + const release = await this.prisma.appRelease.findFirst({ + where: { id: releaseId, platform: normalized, isPublished: true } + }); + if (!release) { + throw new NotFoundException('Файл релиза не найден'); + } + return this.toDownloadResponse(release); + } + + const latestVersionCode = await this.getLatestPublishedVersionCode(normalized); + if (latestVersionCode === null) { + throw new NotFoundException('Файл релиза не найден'); + } + + const normalizedVariant = variant?.trim() ? this.normalizeVariant(variant) : 'universal'; + let release = await this.prisma.appRelease.findFirst({ + where: { + platform: normalized, + isPublished: true, + versionCode: latestVersionCode, + variant: normalizedVariant + } + }); + + if (!release) { + const variants = await this.listPublishedVariants(normalized, latestVersionCode); + release = this.preferVariant(variants); + } if (!release) { throw new NotFoundException('Файл релиза не найден'); } + return this.toDownloadResponse(release); + } + + private toDownloadResponse(release: { + storageKey: string; + fileName: string; + platform: AppReleasePlatform; + fileSize: bigint; + sha256: string; + version: string; + versionCode: number; + variant: string; + }) { return { storageKey: release.storageKey, fileName: release.fileName, @@ -224,7 +342,8 @@ export class AppReleaseService { fileSize: release.fileSize.toString(), sha256: release.sha256, version: release.version, - versionCode: release.versionCode + versionCode: release.versionCode, + variant: release.variant }; } } diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 6162a94..8380059 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -1411,6 +1411,7 @@ export class AuthGrpcController { platform: string; version: string; versionCode: number; + variant?: string; fileName: string; storageKey: string; fileSize: string; @@ -1445,7 +1446,7 @@ export class AuthGrpcController { } @GrpcMethod('AppReleaseService', 'ResolveAppReleaseDownload') - resolveAppReleaseDownload(command: { platform: string; releaseId?: string }) { - return this.appRelease.resolveDownload(command.platform, command.releaseId); + resolveAppReleaseDownload(command: { platform: string; releaseId?: string; variant?: string }) { + return this.appRelease.resolveDownload(command.platform, command.releaseId, command.variant); } } diff --git a/shared/proto/app-release.proto b/shared/proto/app-release.proto index 5a4b709..deb1def 100644 --- a/shared/proto/app-release.proto +++ b/shared/proto/app-release.proto @@ -28,6 +28,7 @@ message AppRelease { bool isPublished = 10; string createdAt = 11; string updatedAt = 12; + string variant = 13; } message ListAppReleasesRequest { @@ -52,6 +53,7 @@ message CreateAppReleaseRequest { string sha256 = 7; optional string releaseNotes = 8; optional string createdById = 9; + optional string variant = 10; } message UpdateAppReleaseRequest { @@ -76,11 +78,13 @@ message CheckAppUpdateRequest { message CheckAppUpdateResponse { bool updateAvailable = 1; optional AppRelease latest = 2; + repeated AppRelease variants = 3; } message ResolveAppReleaseDownloadRequest { string platform = 1; optional string releaseId = 2; + optional string variant = 3; } message AppReleaseDownloadResponse {