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", "@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"unzipper": "^0.12.3"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
@ -35,6 +38,7 @@
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.13", "@types/passport-jwt": "^3.0.13",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0", "eslint": "^8.42.0",
@ -2589,6 +2593,16 @@
"@types/superagent": "^8.1.0" "@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": { "node_modules/@types/validator": {
"version": "13.15.10", "version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@ -3056,6 +3070,30 @@
"node": ">=0.4.0" "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": { "node_modules/ajv": {
"version": "8.12.0", "version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@ -3416,6 +3454,15 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -3441,6 +3488,12 @@
"readable-stream": "^3.4.0" "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": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -3480,6 +3533,18 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -3586,6 +3651,15 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cors": { "node_modules/cors": {
@ -4396,6 +4469,45 @@
"node": ">= 0.4" "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": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -5062,6 +5174,15 @@
"bser": "2.1.1" "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": { "node_modules/fflate": {
"version": "0.8.2", "version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -5574,7 +5695,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@ -6023,6 +6143,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -6930,7 +7056,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
@ -7494,7 +7619,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
@ -7847,6 +7971,12 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -8045,6 +8175,12 @@
"fsevents": "2.3.3" "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": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -9700,7 +9836,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
@ -9715,6 +9850,33 @@
"node": ">= 0.8" "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": { "node_modules/update-browserslist-db": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@ -10125,6 +10287,16 @@
"node": ">=12" "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": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 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 { Type, Transform } from 'class-transformer'
import { Platform } from '@/domain/enums/platform.enum' import { Platform } from '@/domain/enums/platform.enum'
export class UploadVersionDto { export class UploadVersionDto {
@ApiProperty({ enum: Platform, description: '平台 (android/ios)' }) @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'], description: '平台 (android/ios)' })
@IsString() @Transform(({ value }) => (typeof value === 'string' ? value.toUpperCase() : value))
@IsEnum(Platform)
platform: Platform platform: Platform
@ApiProperty({ description: '版本号', example: 100, minimum: 1 }) @ApiPropertyOptional({ description: '版本号 (可从APK/IPA自动检测)', example: 100, minimum: 1 })
@IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
versionCode: number versionCode?: number
@ApiProperty({ description: '版本名称', example: '1.0.0' }) @ApiPropertyOptional({ description: '版本名称 (可从APK/IPA自动检测)', example: '1.0.0' })
@IsOptional()
@IsString() @IsString()
@Matches(/^\d+\.\d+\.\d+$/, { message: 'versionName must be in format x.y.z' }) @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() @IsString()
buildNumber: string buildNumber?: string
@ApiProperty({ description: '更新日志' }) @ApiProperty({ description: '更新日志' })
@IsString() @IsString()
@ -33,7 +37,7 @@ export class UploadVersionDto {
@IsBoolean() @IsBoolean()
isForceUpdate?: boolean isForceUpdate?: boolean
@ApiPropertyOptional({ description: '最低操作系统版本', example: '10.0' }) @ApiPropertyOptional({ description: '最低操作系统版本 (可从APK/IPA自动检测)', example: '10.0' })
@IsOptional() @IsOptional()
@IsString() @IsString()
minOsVersion?: string 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 { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl';
import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository'; import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository';
import { FileStorageService } from './infrastructure/storage/file-storage.service'; 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 { CheckUpdateHandler } from './application/queries/check-update/check-update.handler';
import { ListVersionsHandler } from './application/queries/list-versions/list-versions.handler'; import { ListVersionsHandler } from './application/queries/list-versions/list-versions.handler';
import { GetVersionHandler } from './application/queries/get-version/get-version.handler'; import { GetVersionHandler } from './application/queries/get-version/get-version.handler';
@ -37,6 +38,7 @@ import { HealthController } from './api/controllers/health.controller';
PrismaService, PrismaService,
AppVersionMapper, AppVersionMapper,
FileStorageService, FileStorageService,
PackageParserService,
{ {
provide: APP_VERSION_REPOSITORY, provide: APP_VERSION_REPOSITORY,
useClass: AppVersionRepositoryImpl, useClass: AppVersionRepositoryImpl,

View File

@ -3,15 +3,16 @@ import { Platform } from '@/domain/enums/platform.enum'
export class UploadVersionCommand { export class UploadVersionCommand {
constructor( constructor(
public readonly platform: Platform, 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 fileBuffer: Buffer,
public readonly originalFilename: string, public readonly originalFilename: string,
public readonly changelog: string,
public readonly isForceUpdate: boolean,
public readonly createdBy: string, 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 { UploadVersionCommand } from './upload-version.command'
import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository'
import { AppVersion } from '@/domain/entities/app-version.entity' 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 { Changelog } from '@/domain/value-objects/changelog.vo'
import { MinOsVersion } from '@/domain/value-objects/min-os-version.vo' import { MinOsVersion } from '@/domain/value-objects/min-os-version.vo'
import { FileStorageService } from '@/infrastructure/storage/file-storage.service' import { FileStorageService } from '@/infrastructure/storage/file-storage.service'
import { PackageParserService } from '@/infrastructure/parsers/package-parser.service'
@Injectable() @Injectable()
export class UploadVersionHandler { export class UploadVersionHandler {
@ -18,40 +19,65 @@ export class UploadVersionHandler {
@Inject(APP_VERSION_REPOSITORY) @Inject(APP_VERSION_REPOSITORY)
private readonly appVersionRepository: AppVersionRepository, private readonly appVersionRepository: AppVersionRepository,
private readonly fileStorageService: FileStorageService, private readonly fileStorageService: FileStorageService,
private readonly packageParserService: PackageParserService,
) {} ) {}
async execute(command: UploadVersionCommand): Promise<AppVersion> { 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 // Save the uploaded file
const uploadResult = await this.fileStorageService.saveFile( const uploadResult = await this.fileStorageService.saveFile(
command.fileBuffer, command.fileBuffer,
command.originalFilename, command.originalFilename,
command.platform, command.platform,
command.versionName, versionName,
) )
// Create value objects // Create value objects
const versionCode = VersionCode.create(command.versionCode) const versionCodeVO = VersionCode.create(versionCode)
const versionName = VersionName.create(command.versionName) const versionNameVO = VersionName.create(versionName)
const buildNumber = BuildNumber.create(command.buildNumber) const buildNumberVO = BuildNumber.create(buildNumber || versionCode.toString())
const downloadUrl = DownloadUrl.create(uploadResult.url) const downloadUrl = DownloadUrl.create(uploadResult.url)
const fileSize = FileSize.create(BigInt(uploadResult.size)) const fileSize = FileSize.create(BigInt(uploadResult.size))
const fileSha256 = FileSha256.create(uploadResult.sha256) const fileSha256 = FileSha256.create(uploadResult.sha256)
const changelog = Changelog.create(command.changelog) 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 // Create the app version entity
const appVersion = AppVersion.create({ const appVersion = AppVersion.create({
platform: command.platform, platform: command.platform,
versionCode, versionCode: versionCodeVO,
versionName, versionName: versionNameVO,
buildNumber, buildNumber: buildNumberVO,
downloadUrl, downloadUrl,
fileSize, fileSize,
fileSha256, fileSha256,
changelog, changelog,
isForceUpdate: command.isForceUpdate, isForceUpdate: command.isForceUpdate,
minOsVersion, minOsVersion: minOsVersionVO,
releaseDate: command.releaseDate, releaseDate: command.releaseDate ?? null,
createdBy: command.createdBy, 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,
}
}