feat(admin-service): 增强移动端版本上传功能

- 添加 APK/IPA 文件解析器自动提取版本信息
- 支持从安装包自动读取 versionName 和 versionCode
- 添加 adbkit-apkreader 依赖解析 APK 文件
- 添加 plist 依赖解析 IPA 文件
- 优化上传接口支持自动填充版本信息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-12-03 02:39:36 -08:00
parent de5cbce0d3
commit f9deca5df0
11 changed files with 403 additions and 51 deletions

View File

@ -19,12 +19,15 @@
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"unzipper": "^0.12.3"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@ -35,6 +38,7 @@
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^6.0.0",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
@ -2589,6 +2593,16 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/unzipper": {
"version": "0.10.11",
"resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz",
"integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@ -3056,6 +3070,30 @@
"node": ">=0.4.0"
}
},
"node_modules/adbkit-apkreader": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/adbkit-apkreader/-/adbkit-apkreader-3.2.0.tgz",
"integrity": "sha512-QwsxPYCqWSmCAiW/A4gq0eytb4jtZc7WNbECIhLCRfGEB38oXzIV/YkTpkOTQFKSg3S4Svb6y///qOUH7UrWWw==",
"license": "Apache-2.0",
"dependencies": {
"bluebird": "^3.4.7",
"debug": "~4.1.1",
"yauzl": "^2.7.0"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/adbkit-apkreader/node_modules/debug": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
"deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@ -3416,6 +3454,15 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -3441,6 +3488,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -3480,6 +3533,18 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/bplist-parser": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz",
"integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==",
"license": "MIT",
"dependencies": {
"big-integer": "1.6.x"
},
"engines": {
"node": ">= 5.10.0"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -3586,6 +3651,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@ -4089,7 +4163,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cors": {
@ -4396,6 +4469,45 @@
"node": ">= 0.4"
}
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/duplexer2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexer2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexer2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -5062,6 +5174,15 @@
"bser": "2.1.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -5574,7 +5695,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphemer": {
@ -6023,6 +6143,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -6930,7 +7056,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@ -7494,7 +7619,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
@ -7847,6 +7971,12 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -8045,6 +8175,12 @@
"fsevents": "2.3.3"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -9700,7 +9836,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
@ -9715,6 +9850,33 @@
"node": ">= 0.8"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/unzipper/node_modules/fs-extra": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz",
"integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@ -10125,6 +10287,16 @@
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@ -39,12 +39,15 @@
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"unzipper": "^0.12.3"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@ -55,6 +58,7 @@
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^6.0.0",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",

View File

@ -103,14 +103,16 @@ export class VersionController {
@Get()
@ApiOperation({ summary: '获取版本列表 (管理员)' })
@ApiBearerAuth()
@ApiQuery({ name: 'platform', required: false, enum: Platform, description: '筛选平台' })
@ApiQuery({ name: 'platform', required: false, enum: ['android', 'ios', 'ANDROID', 'IOS'], description: '筛选平台' })
@ApiQuery({ name: 'includeDisabled', required: false, type: Boolean, description: '是否包含已禁用版本' })
@ApiResponse({ status: 200, type: [VersionDto] })
async listVersions(
@Query('platform') platform?: Platform,
@Query('platform') platform?: string,
@Query('includeDisabled') includeDisabled?: string,
): Promise<VersionDto[]> {
const query = new ListVersionsQuery(platform, includeDisabled === 'true')
// Convert platform to uppercase enum value
const platformEnum = platform ? (platform.toUpperCase() as Platform) : undefined
const query = new ListVersionsQuery(platformEnum, includeDisabled === 'true')
const versions = await this.listVersionsHandler.execute(query)
return versions.map((v) => this.toVersionDto(v))
}
@ -202,7 +204,7 @@ export class VersionController {
@ApiBody({
schema: {
type: 'object',
required: ['file', 'platform', 'versionCode', 'versionName', 'buildNumber', 'changelog'],
required: ['file', 'platform', 'changelog'],
properties: {
file: {
type: 'string',
@ -216,16 +218,16 @@ export class VersionController {
},
versionCode: {
type: 'integer',
description: '版本号',
description: '版本号 (可选可从APK/IPA自动检测)',
minimum: 1,
},
versionName: {
type: 'string',
description: '版本名称 (格式: x.y.z)',
description: '版本名称 (可选可从APK/IPA自动检测格式: x.y.z)',
},
buildNumber: {
type: 'string',
description: '构建号',
description: '构建号 (可选可从APK/IPA自动检测)',
},
changelog: {
type: 'string',
@ -238,7 +240,7 @@ export class VersionController {
},
minOsVersion: {
type: 'string',
description: '最低操作系统版本',
description: '最低操作系统版本 (可选可从APK/IPA自动检测)',
},
releaseDate: {
type: 'string',
@ -271,16 +273,16 @@ export class VersionController {
const command = new UploadVersionCommand(
dto.platform,
file.buffer,
file.originalname,
dto.changelog,
dto.isForceUpdate ?? false,
'admin', // TODO: Get from JWT token
dto.versionCode,
dto.versionName,
dto.buildNumber,
dto.changelog,
dto.isForceUpdate ?? false,
dto.minOsVersion ?? null,
dto.minOsVersion,
dto.releaseDate ? new Date(dto.releaseDate) : null,
file.buffer,
file.originalname,
'admin', // TODO: Get from JWT token
)
const version = await this.uploadVersionHandler.execute(command)

View File

@ -1,9 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsEnum, IsInt, Min } from 'class-validator'
import { Transform } from 'class-transformer'
import { Platform } from '@/domain/enums/platform.enum'
export class CheckUpdateDto {
@ApiProperty({ enum: Platform, description: '平台类型' })
@ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'], description: '平台类型' })
@Transform(({ value }) => (typeof value === 'string' ? value.toUpperCase() : value))
@IsEnum(Platform)
platform: Platform

View File

@ -1,9 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsEnum, IsInt, IsString, IsBoolean, IsUrl, Min, IsOptional, IsDateString } from 'class-validator'
import { Transform } from 'class-transformer'
import { Platform } from '@/domain/enums/platform.enum'
export class CreateVersionDto {
@ApiProperty({ enum: Platform, description: '平台类型' })
@ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'], description: '平台类型' })
@Transform(({ value }) => (typeof value === 'string' ? value.toUpperCase() : value))
@IsEnum(Platform)
platform: Platform

View File

@ -1,27 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsString, IsInt, IsBoolean, IsOptional, IsDateString, Min, Matches } from 'class-validator'
import { IsString, IsInt, IsBoolean, IsOptional, IsDateString, Min, Matches, IsEnum } from 'class-validator'
import { Type, Transform } from 'class-transformer'
import { Platform } from '@/domain/enums/platform.enum'
export class UploadVersionDto {
@ApiProperty({ enum: Platform, description: '平台 (android/ios)' })
@IsString()
@ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'], description: '平台 (android/ios)' })
@Transform(({ value }) => (typeof value === 'string' ? value.toUpperCase() : value))
@IsEnum(Platform)
platform: Platform
@ApiProperty({ description: '版本号', example: 100, minimum: 1 })
@ApiPropertyOptional({ description: '版本号 (可从APK/IPA自动检测)', example: 100, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
versionCode: number
versionCode?: number
@ApiProperty({ description: '版本名称', example: '1.0.0' })
@ApiPropertyOptional({ description: '版本名称 (可从APK/IPA自动检测)', example: '1.0.0' })
@IsOptional()
@IsString()
@Matches(/^\d+\.\d+\.\d+$/, { message: 'versionName must be in format x.y.z' })
versionName: string
versionName?: string
@ApiProperty({ description: '构建号', example: '100' })
@ApiPropertyOptional({ description: '构建号 (可从APK/IPA自动检测)', example: '100' })
@IsOptional()
@IsString()
buildNumber: string
buildNumber?: string
@ApiProperty({ description: '更新日志' })
@IsString()
@ -33,7 +37,7 @@ export class UploadVersionDto {
@IsBoolean()
isForceUpdate?: boolean
@ApiPropertyOptional({ description: '最低操作系统版本', example: '10.0' })
@ApiPropertyOptional({ description: '最低操作系统版本 (可从APK/IPA自动检测)', example: '10.0' })
@IsOptional()
@IsString()
minOsVersion?: string

View File

@ -8,6 +8,7 @@ import { AppVersionMapper } from './infrastructure/persistence/mappers/app-versi
import { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl';
import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository';
import { FileStorageService } from './infrastructure/storage/file-storage.service';
import { PackageParserService } from './infrastructure/parsers/package-parser.service';
import { CheckUpdateHandler } from './application/queries/check-update/check-update.handler';
import { ListVersionsHandler } from './application/queries/list-versions/list-versions.handler';
import { GetVersionHandler } from './application/queries/get-version/get-version.handler';
@ -37,6 +38,7 @@ import { HealthController } from './api/controllers/health.controller';
PrismaService,
AppVersionMapper,
FileStorageService,
PackageParserService,
{
provide: APP_VERSION_REPOSITORY,
useClass: AppVersionRepositoryImpl,

View File

@ -3,15 +3,16 @@ import { Platform } from '@/domain/enums/platform.enum'
export class UploadVersionCommand {
constructor(
public readonly platform: Platform,
public readonly versionCode: number,
public readonly versionName: string,
public readonly buildNumber: string,
public readonly changelog: string,
public readonly isForceUpdate: boolean,
public readonly minOsVersion: string | null,
public readonly releaseDate: Date | null,
public readonly fileBuffer: Buffer,
public readonly originalFilename: string,
public readonly changelog: string,
public readonly isForceUpdate: boolean,
public readonly createdBy: string,
// Optional fields - can be auto-detected from package
public readonly versionCode?: number,
public readonly versionName?: string,
public readonly buildNumber?: string,
public readonly minOsVersion?: string | null,
public readonly releaseDate?: Date | null,
) {}
}

View File

@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'
import { Inject, Injectable, BadRequestException } from '@nestjs/common'
import { UploadVersionCommand } from './upload-version.command'
import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository'
import { AppVersion } from '@/domain/entities/app-version.entity'
@ -11,6 +11,7 @@ import { FileSha256 } from '@/domain/value-objects/file-sha256.vo'
import { Changelog } from '@/domain/value-objects/changelog.vo'
import { MinOsVersion } from '@/domain/value-objects/min-os-version.vo'
import { FileStorageService } from '@/infrastructure/storage/file-storage.service'
import { PackageParserService } from '@/infrastructure/parsers/package-parser.service'
@Injectable()
export class UploadVersionHandler {
@ -18,40 +19,65 @@ export class UploadVersionHandler {
@Inject(APP_VERSION_REPOSITORY)
private readonly appVersionRepository: AppVersionRepository,
private readonly fileStorageService: FileStorageService,
private readonly packageParserService: PackageParserService,
) {}
async execute(command: UploadVersionCommand): Promise<AppVersion> {
// Parse package info from APK/IPA
const parsedInfo = await this.packageParserService.parsePackage(
command.fileBuffer,
command.platform,
)
// Determine version info (use provided values or fall back to parsed values)
const versionCode = command.versionCode ?? parsedInfo?.versionCode
const versionName = command.versionName ?? parsedInfo?.versionName
const buildNumber = command.buildNumber ?? versionCode?.toString()
const minOsVersion = command.minOsVersion ?? parsedInfo?.minSdkVersion
// Validate required fields
if (!versionCode) {
throw new BadRequestException(
'Unable to detect version code from package. Please provide it manually.',
)
}
if (!versionName) {
throw new BadRequestException(
'Unable to detect version name from package. Please provide it manually.',
)
}
// Save the uploaded file
const uploadResult = await this.fileStorageService.saveFile(
command.fileBuffer,
command.originalFilename,
command.platform,
command.versionName,
versionName,
)
// Create value objects
const versionCode = VersionCode.create(command.versionCode)
const versionName = VersionName.create(command.versionName)
const buildNumber = BuildNumber.create(command.buildNumber)
const versionCodeVO = VersionCode.create(versionCode)
const versionNameVO = VersionName.create(versionName)
const buildNumberVO = BuildNumber.create(buildNumber || versionCode.toString())
const downloadUrl = DownloadUrl.create(uploadResult.url)
const fileSize = FileSize.create(BigInt(uploadResult.size))
const fileSha256 = FileSha256.create(uploadResult.sha256)
const changelog = Changelog.create(command.changelog)
const minOsVersion = command.minOsVersion ? MinOsVersion.create(command.minOsVersion) : null
const minOsVersionVO = minOsVersion ? MinOsVersion.create(minOsVersion) : null
// Create the app version entity
const appVersion = AppVersion.create({
platform: command.platform,
versionCode,
versionName,
buildNumber,
versionCode: versionCodeVO,
versionName: versionNameVO,
buildNumber: buildNumberVO,
downloadUrl,
fileSize,
fileSha256,
changelog,
isForceUpdate: command.isForceUpdate,
minOsVersion,
releaseDate: command.releaseDate,
minOsVersion: minOsVersionVO,
releaseDate: command.releaseDate ?? null,
createdBy: command.createdBy,
})

View File

@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common'
import * as ApkReader from 'adbkit-apkreader'
import * as unzipper from 'unzipper'
import * as bplist from 'bplist-parser'
import { Platform } from '@/domain/enums/platform.enum'
export interface ParsedPackageInfo {
packageName: string
versionCode: number
versionName: string
minSdkVersion?: string
targetSdkVersion?: string
}
@Injectable()
export class PackageParserService {
private readonly logger = new Logger(PackageParserService.name)
async parsePackage(buffer: Buffer, platform: Platform): Promise<ParsedPackageInfo | null> {
try {
if (platform === Platform.ANDROID) {
return await this.parseApk(buffer)
} else if (platform === Platform.IOS) {
return await this.parseIpa(buffer)
}
return null
} catch (error) {
this.logger.error(`Failed to parse package: ${error.message}`, error.stack)
return null
}
}
private async parseApk(buffer: Buffer): Promise<ParsedPackageInfo | null> {
try {
const reader = await ApkReader.open(buffer)
const manifest = await reader.readManifest()
return {
packageName: manifest.package,
versionCode: manifest.versionCode,
versionName: manifest.versionName,
minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(),
targetSdkVersion: manifest.usesSdk?.targetSdkVersion?.toString(),
}
} catch (error) {
this.logger.error(`Failed to parse APK: ${error.message}`)
throw error
}
}
private async parseIpa(buffer: Buffer): Promise<ParsedPackageInfo | null> {
try {
const directory = await unzipper.Open.buffer(buffer)
// Find Info.plist in the .app directory
const plistFile = directory.files.find(
(file: { path: string }) => file.path.match(/Payload\/[^/]+\.app\/Info\.plist$/)
)
if (!plistFile) {
throw new Error('Info.plist not found in IPA')
}
const plistBuffer = await plistFile.buffer()
// Try to parse as binary plist first
let plistData: any
try {
const parsed = bplist.parseBuffer(plistBuffer)
plistData = parsed[0]
} catch {
// If binary parsing fails, try XML parsing
const plistContent = plistBuffer.toString('utf8')
plistData = this.parseXmlPlist(plistContent)
}
if (!plistData) {
throw new Error('Failed to parse Info.plist')
}
return {
packageName: plistData.CFBundleIdentifier || '',
versionCode: parseInt(plistData.CFBundleVersion || '0', 10),
versionName: plistData.CFBundleShortVersionString || plistData.CFBundleVersion || '',
minSdkVersion: plistData.MinimumOSVersion,
}
} catch (error) {
this.logger.error(`Failed to parse IPA: ${error.message}`)
throw error
}
}
private parseXmlPlist(content: string): Record<string, any> | null {
try {
const result: Record<string, any> = {}
const keyRegex = /<key>([^<]+)<\/key>\s*<string>([^<]+)<\/string>/g
let match
while ((match = keyRegex.exec(content)) !== null) {
result[match[1]] = match[2]
}
// Also try to match integer values
const intRegex = /<key>([^<]+)<\/key>\s*<integer>([^<]+)<\/integer>/g
while ((match = intRegex.exec(content)) !== null) {
result[match[1]] = match[2]
}
return Object.keys(result).length > 0 ? result : null
} catch {
return null
}
}
}

View File

@ -0,0 +1,23 @@
declare module 'adbkit-apkreader' {
interface UsesSdk {
minSdkVersion?: number
targetSdkVersion?: number
}
interface Manifest {
package: string
versionCode: number
versionName: string
usesSdk?: UsesSdk
}
interface ApkReader {
readManifest(): Promise<Manifest>
}
function open(source: string | Buffer): Promise<ApkReader>
export = {
open,
}
}