@@ -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 : /(?<![a-z0-9])x86(?![a-z0-9])/i , variant : 'x86' } ,
{ pattern : /universal/i , variant : 'universal' } ,
{ pattern : /aab/i , variant : 'aab' }
] ;
@Injectable ( )
export class AppReleaseService {
@@ -13,6 +22,36 @@ export class AppReleaseService {
private readonly minio : MinioService
) { }
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' ;
}
normalizeVariant ( raw? : string , fileName? : string ) {
const trimmed = raw ? . trim ( ) . toLowerCase ( ) ;
if ( trimmed ) {
const normalized = trimmed . replace ( /\s+/g , '-' ) . replace ( /[^a-z0-9._-]/g , '' ) ;
if ( ! normalized || ! VARIANT_PATTERN . test ( normalized ) ) {
throw new BadRequestException ( 'Вариант сборки должен содержать только латиницу, цифры, точки и дефисы' ) ;
}
return normalized ;
}
if ( fileName ? . trim ( ) ) {
return this . detectVariantFromFileName ( fileName ) ;
}
return 'universal' ;
}
private normalizePlatform ( platform : string ) : AppReleasePlatform {
const normalized = platform . trim ( ) . toUpperCase ( ) ;
if ( normalized === 'ANDROID' || normalized === 'WINDOWS' ) {
@@ -26,6 +65,7 @@ export class AppReleaseService {
platform : AppReleasePlatform ;
version : string ;
versionCode : number ;
variant : string ;
fileName : string ;
storageKey : string ;
fileSize : bigint ;
@@ -40,6 +80,7 @@ export class AppReleaseService {
platform : release.platform ,
version : release.version ,
versionCode : release.versionCode ,
variant : release.variant ,
fileName : release.fileName ,
storageKey : release.storageKey ,
fileSize : release.fileSize.toString ( ) ,
@@ -72,10 +113,33 @@ export class AppReleaseService {
}
}
buildStorageKey ( platform : AppReleasePlatform , version : string , fileName : string ) {
buildStorageKey ( platform : AppReleasePlatform , version : string , variant : string , fileName : string ) {
const safeVersion = version . replace ( /[^a-zA-Z0-9._-]+/g , '_' ) ;
const safeVariant = variant . replace ( /[^a-zA-Z0-9._-]+/g , '_' ) ;
const safeName = fileName . replace ( /[^a-zA-Z0-9._-]+/g , '_' ) ;
return ` releases/ ${ platform . toLowerCase ( ) } / ${ safeVersion } / ${ randomUUID ( ) } - ${ safeName } ` ;
return ` releases/ ${ platform . toLowerCase ( ) } / ${ safeVersion } / ${ safeVariant } / ${ randomUUID( ) } - ${ safeName } ` ;
}
private preferVariant < T extends { variant : string } > ( 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 releas e = await this . prisma . appReleas e. findFirst ( {
where : { platform : normalized , isPublished : true } ,
orderBy : [ { versionCode : 'desc' } , { createdAt : 'desc' } ]
} ) ;
const versionCod e = await this . getLatestPublishedVersionCod e( 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 = v ersionCode < latest . versionCode ;
const normalized = this . normalizePlatform ( platform ) ;
const latestV ersionCode = 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
} ;
}
}