@@ -0,0 +1,293 @@
/**
* 上传已打包的 PDA APK,等价于 ERP「开发者资源上传」里的安装包上传。
*
* 用法:
* npm run upload:apk -- --file D:\path\app.apk --target test
* npm run upload:apk -- --file D:\path\app.apk --target prod
* npm run upload:apk -- --file D:\path\app.apk --target test --mode zhisheng
*
* --target 只决定 APK 放到哪套 CDN:
* test 测试 CDN
* prod 正式 CDN
* 两者没有别的差别。生产环境的 PDA 也可以从测试 CDN 下载并安装。
*
* 版本号取 src/manifest.json 的 versionName。
* 场景值取对应 .env 的 VUE_APP_UPGRADE_NAME,拼成 /version/{upgradeName}/{version}.apk。
* 运行时输入 ERP 手机号和密码获取 token。多个账套时再选择一次。
*/
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const readline = require ( 'readline' ) ;
const ROOT = path . resolve ( _ _dirname , '..' ) ;
const LOCAL _CONFIG = path . join ( _ _dirname , 'upload-apk.local.json' ) ;
const API _BASE _DEFAULT = 'https://hcscmtest.zzfzyc.com/hcscm_weave' ;
const UPYUN _API = 'https://v0.api.upyun.com' ;
const CDN _HOST = {
test : 'https://testhcscm.cdn.zzfzyc.com' ,
prod : 'https://hcscm.cdn.zzfzyc.com' ,
} ;
const MODE _ENV = {
zhoushang : '.env.zhoushang' ,
zhisheng : '.env.zhisheng' ,
mes : '.env.mes' ,
cjt : '.env.cjt' ,
} ;
function parseArgs ( argv ) {
const args = { mode : 'zhoushang' } ;
for ( let i = 0 ; i < argv . length ; i += 1 ) {
const key = argv [ i ] ;
const value = argv [ i + 1 ] ;
if ( key === '--file' || key === '--target' || key === '--mode' || key === '--version' ) {
args [ key . slice ( 2 ) ] = value ;
i += 1 ;
}
}
return args ;
}
function readEnvValue ( filePath , name ) {
const text = fs . readFileSync ( filePath , 'utf8' ) ;
const line = text . split ( /\r?\n/ ) . find ( ( item ) => item . trim ( ) . startsWith ( ` ${ name } ` ) ) ;
if ( ! line ) return '' ;
const raw = line . slice ( line . indexOf ( '=' ) + 1 ) . trim ( ) ;
return raw . replace ( /^['"]|['"]$/g , '' ) ;
}
function readManifestVersion ( ) {
const manifest = JSON . parse ( fs . readFileSync ( path . join ( ROOT , 'src/manifest.json' ) , 'utf8' ) ) ;
return String ( manifest . versionName || '' ) . trim ( ) ;
}
function loadApiBase ( ) {
if ( ! fs . existsSync ( LOCAL _CONFIG ) ) return API _BASE _DEFAULT ;
const config = JSON . parse ( fs . readFileSync ( LOCAL _CONFIG , 'utf8' ) ) ;
return String ( config . apiBase || API _BASE _DEFAULT ) . replace ( /\/+$/ , '' ) ;
}
function ask ( question ) {
const rl = readline . createInterface ( { input : process . stdin , output : process . stdout } ) ;
return new Promise ( ( resolve ) => {
rl . question ( question , ( answer ) => {
rl . close ( ) ;
resolve ( String ( answer || '' ) . trim ( ) ) ;
} ) ;
} ) ;
}
function askPassword ( question ) {
return new Promise ( ( resolve ) => {
const stdin = process . stdin ;
const stdout = process . stdout ;
if ( ! stdin . isTTY || typeof stdin . setRawMode !== 'function' ) {
ask ( question ) . then ( resolve ) ;
return ;
}
stdout . write ( question ) ;
stdin . setRawMode ( true ) ;
stdin . resume ( ) ;
stdin . setEncoding ( 'utf8' ) ;
let value = '' ;
const onData = ( char ) => {
switch ( char ) {
case '\n' :
case '\r' :
case '\u0004' :
stdin . setRawMode ( false ) ;
stdin . pause ( ) ;
stdin . removeListener ( 'data' , onData ) ;
stdout . write ( '\n' ) ;
resolve ( value ) ;
break ;
case '\u0003' :
process . exit ( ) ;
break ;
case '\u007f' :
case '\b' :
value = value . slice ( 0 , - 1 ) ;
break ;
default :
value += char ;
break ;
}
} ;
stdin . on ( 'data' , onData ) ;
} ) ;
}
async function apiJson ( apiBase , urlPath , { method = 'GET' , token , body } = { } ) {
const headers = { Platform : '1' } ;
if ( token ) headers . Authorization = token ;
if ( body ) headers [ 'Content-Type' ] = 'application/json' ;
const res = await fetch ( ` ${ apiBase } ${ urlPath } ` , {
method ,
headers ,
body : body ? JSON . stringify ( body ) : undefined ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok || data . code !== 0 ) {
throw new Error ( data . msg || ` 请求失败(HTTP ${ res . status } ) ` ) ;
}
return data . data || { } ;
}
async function login ( apiBase ) {
const phone = await ask ( '手机号: ' ) ;
const password = await askPassword ( '密码: ' ) ;
if ( ! phone || ! password ) {
throw new Error ( '请输入手机号和密码' ) ;
}
console . log ( '正在登录...' ) ;
const first = await apiJson ( apiBase , '/admin/v1/login' , {
method : 'POST' ,
body : { phone , password } ,
} ) ;
const tempToken = first . token ;
if ( ! tempToken ) throw new Error ( '登录未返回 token' ) ;
const dropdown = await apiJson ( apiBase , '/admin/v1/tenantManagement/dropdownList' , {
token : tempToken ,
} ) ;
const tenants = dropdown . list || [ ] ;
if ( ! tenants . length ) return tempToken ;
let tenant = tenants [ 0 ] ;
if ( tenants . length > 1 ) {
console . log ( '请选择账套:' ) ;
tenants . forEach ( ( item , index ) => {
const name = item . company _name || item . database _name || item . tenant _contacts || item . tenant _management _id ;
console . log ( ` ${ index + 1 } . ${ name } ` ) ;
} ) ;
const picked = Number ( await ask ( '序号: ' ) ) ;
tenant = tenants [ picked - 1 ] ;
if ( ! tenant ) throw new Error ( '账套序号无效' ) ;
}
const second = await apiJson ( apiBase , '/admin/v1/login' , {
method : 'POST' ,
token : tempToken ,
body : {
token : tempToken ,
tenant _management _id : tenant . tenant _management _id ,
} ,
} ) ;
if ( ! second . token ) throw new Error ( '选择账套后未返回 token' ) ;
return second . token ;
}
async function getCdnSign ( { apiBase , token , saveKey , status } ) {
const url = new URL ( ` ${ apiBase } /admin/v1/cdn/token ` ) ;
url . searchParams . set ( 'method' , 'post' ) ;
url . searchParams . set ( 'save_key' , saveKey ) ;
url . searchParams . set ( 'status' , String ( status ) ) ;
const res = await fetch ( url , {
headers : {
Authorization : token ,
Platform : '1' ,
} ,
} ) ;
const body = await res . json ( ) ;
if ( ! res . ok || body . code !== 0 ) {
throw new Error ( body . msg || ` 获取签名失败(HTTP ${ res . status } ) ` ) ;
}
return body . data || { } ;
}
async function uploadToUpyun ( { bucket , authorization , policy , filePath , fileName } ) {
const buf = fs . readFileSync ( filePath ) ;
const form = new FormData ( ) ;
form . append ( 'authorization' , authorization ) ;
form . append ( 'policy' , policy ) ;
form . append ( 'file' , new Blob ( [ buf ] , { type : 'application/vnd.android.package-archive' } ) , fileName ) ;
const res = await fetch ( ` ${ UPYUN _API } / ${ bucket } ` , {
method : 'POST' ,
body : form ,
} ) ;
const text = await res . text ( ) ;
let body = { } ;
try {
body = JSON . parse ( text ) ;
} catch ( e ) {
body = { raw : text } ;
}
if ( ! res . ok || ( body . code != null && Number ( body . code ) !== 200 ) ) {
throw new Error ( body . message || body . msg || body . raw || ` 上传失败(HTTP ${ res . status } ) ` ) ;
}
return body ;
}
async function main ( ) {
const args = parseArgs ( process . argv . slice ( 2 ) ) ;
if ( ! args . file || ! args . target ) {
console . error ( '用法: npm run upload:apk -- --file <apk路径> --target test|prod [--mode zhoushang]' ) ;
console . error ( '--target 只切换 APK 的 CDN: test 测试 CDN, prod 正式 CDN。生产环境也可以从测试 CDN 下载安装。' ) ;
process . exit ( 1 ) ;
}
if ( args . target !== 'test' && args . target !== 'prod' ) {
console . error ( '--target 只能是 test 或 prod。两者只是 APK 的 CDN 环境不同,生产环境也可以从测试 CDN 下载安装。' ) ;
process . exit ( 1 ) ;
}
const envFile = MODE _ENV [ args . mode ] ;
if ( ! envFile ) {
console . error ( ` 不支持的 --mode: ${ args . mode } ,可选 zhoushang、zhisheng、mes、cjt ` ) ;
process . exit ( 1 ) ;
}
const apkPath = path . resolve ( args . file ) ;
if ( ! fs . existsSync ( apkPath ) ) {
console . error ( ` 找不到 APK: ${ apkPath } ` ) ;
process . exit ( 1 ) ;
}
const version = args . version || readManifestVersion ( ) ;
if ( ! version ) {
console . error ( 'manifest.json 里没有 versionName' ) ;
process . exit ( 1 ) ;
}
const upgradeName = readEnvValue ( path . join ( ROOT , envFile ) , 'VUE_APP_UPGRADE_NAME' ) ;
if ( ! upgradeName ) {
console . error ( ` ${ envFile } 里没有 VUE_APP_UPGRADE_NAME ` ) ;
process . exit ( 1 ) ;
}
const status = args . target === 'prod' ? 2 : 1 ;
const fileName = ` ${ version } .apk ` ;
const saveKey = ` /version/ ${ upgradeName } / ${ fileName } ` ;
const apiBase = loadApiBase ( ) ;
const token = await login ( apiBase ) ;
console . log ( ` 版本号: ${ version } ` ) ;
console . log ( ` 场景路径: ${ saveKey } ` ) ;
const cdnLabel = args . target === 'prod' ? '正式 CDN' : '测试 CDN' ;
console . log ( ` CDN: ${ cdnLabel } (只影响下载地址,生产环境也可以从测试 CDN 安装) ` ) ;
console . log ( '正在获取签名...' ) ;
const sign = await getCdnSign ( {
apiBase ,
token ,
saveKey ,
status ,
} ) ;
if ( ! sign . bucket || ! sign . authorization || ! sign . policy ) {
throw new Error ( '签名返回缺少 bucket、authorization 或 policy' ) ;
}
console . log ( '正在上传 APK...' ) ;
const uploaded = await uploadToUpyun ( {
bucket : sign . bucket ,
authorization : sign . authorization ,
policy : sign . policy ,
filePath : apkPath ,
fileName ,
} ) ;
console . log ( '上传成功' ) ;
const pathUrl = uploaded . url && String ( uploaded . url ) . startsWith ( 'http' )
? uploaded . url
: ` ${ CDN _HOST [ args . target ] } ${ saveKey } ` ;
console . log ( pathUrl ) ;
}
main ( ) . catch ( ( error ) => {
console . error ( error . message || error ) ;
process . exit ( 1 ) ;
} ) ;