diff --git a/backend/services/admin-service/package-lock.json b/backend/services/admin-service/package-lock.json index 9f803e0e..0187a51c 100644 --- a/backend/services/admin-service/package-lock.json +++ b/backend/services/admin-service/package-lock.json @@ -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", diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json index d5f4d922..fad28e2f 100644 --- a/backend/services/admin-service/package.json +++ b/backend/services/admin-service/package.json @@ -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", diff --git a/backend/services/admin-service/src/api/controllers/version.controller.ts b/backend/services/admin-service/src/api/controllers/version.controller.ts index 30180103..02a04256 100644 --- a/backend/services/admin-service/src/api/controllers/version.controller.ts +++ b/backend/services/admin-service/src/api/controllers/version.controller.ts @@ -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 { - 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) diff --git a/backend/services/admin-service/src/api/dto/request/check-update.dto.ts b/backend/services/admin-service/src/api/dto/request/check-update.dto.ts index 1b819062..293b37b8 100644 --- a/backend/services/admin-service/src/api/dto/request/check-update.dto.ts +++ b/backend/services/admin-service/src/api/dto/request/check-update.dto.ts @@ -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 diff --git a/backend/services/admin-service/src/api/dto/request/create-version.dto.ts b/backend/services/admin-service/src/api/dto/request/create-version.dto.ts index 285d222d..949249b5 100644 --- a/backend/services/admin-service/src/api/dto/request/create-version.dto.ts +++ b/backend/services/admin-service/src/api/dto/request/create-version.dto.ts @@ -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 diff --git a/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts b/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts index 43556162..8dd46d55 100644 --- a/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts +++ b/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts @@ -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 diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 152c2ddf..7847ce37 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -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, diff --git a/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts b/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts index a997e0a6..02c7ba27 100644 --- a/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts +++ b/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts @@ -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, ) {} } diff --git a/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts b/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts index 43161366..b7ab38f1 100644 --- a/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts +++ b/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts @@ -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 { + // 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, }) diff --git a/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts new file mode 100644 index 00000000..a16225b3 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts @@ -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 { + 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 { + 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 { + 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 | null { + try { + const result: Record = {} + const keyRegex = /([^<]+)<\/key>\s*([^<]+)<\/string>/g + let match + + while ((match = keyRegex.exec(content)) !== null) { + result[match[1]] = match[2] + } + + // Also try to match integer values + const intRegex = /([^<]+)<\/key>\s*([^<]+)<\/integer>/g + while ((match = intRegex.exec(content)) !== null) { + result[match[1]] = match[2] + } + + return Object.keys(result).length > 0 ? result : null + } catch { + return null + } + } +} diff --git a/backend/services/admin-service/src/types/adbkit-apkreader.d.ts b/backend/services/admin-service/src/types/adbkit-apkreader.d.ts new file mode 100644 index 00000000..bbb0e82b --- /dev/null +++ b/backend/services/admin-service/src/types/adbkit-apkreader.d.ts @@ -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 + } + + function open(source: string | Buffer): Promise + + export = { + open, + } +}