/** * 上传已打包的 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。 * 版本包上传成功后,再把同一份安装包上传为 /version/{upgradeName}/latest.apk。 * 运行时输入 ERP 手机号和密码获取 token。多个账套时再选择一次。 */ const fs = require('fs'); const path = require('path'); const readline = require('readline'); const ROOT = path.resolve(__dirname, '..'); const UPYUN_API = 'https://v0.api.upyun.com'; const DEFAULT_API_BASE = 'https://hcscmtest.zzfzyc.com/hcscm_weave'; const DEFAULT_CDN_HOST = { test: 'https://testhcscm.cdn.zzfzyc.com', prod: 'https://hcscm.cdn.zzfzyc.com', }; /** 织厂环境签到 zhoushang 空间,下载域名与 ERP 相同 */ const MODE_UPLOAD = { zhoushang: { apiBase: 'https://zszc.yushangfangzhi.com/hcscm_weave', cdnHost: 'https://zsfzcdn.yushangfangzhi.com', status: 1, }, }; 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' || key === '--phone' || key === '--password' || key === '--tenant') { 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 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, { phone: phoneArg, password: passwordArg, tenant: tenantArg } = {}) { const phone = phoneArg || await ask('手机号: '); const password = passwordArg || 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) { if (tenantArg) { const picked = Number(tenantArg); tenant = tenants[picked - 1]; if (!tenant) throw new Error('账套序号无效'); } else { 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; } function toPublicUrl(cdnHost, saveKey, uploaded) { if (uploaded.url && String(uploaded.url).startsWith('http')) return uploaded.url; return `${cdnHost}${saveKey}`; } async function uploadApkFile({ apiBase, token, apkPath, saveKey, fileName, status }) { console.log(`场景路径: ${saveKey}`); 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(`空间: ${sign.bucket}`); console.log(`正在上传 ${fileName}...`); const uploaded = await uploadToUpyun({ bucket: sign.bucket, authorization: sign.authorization, policy: sign.policy, filePath: apkPath, fileName, }); console.log('上传成功'); return uploaded; } async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.file || !args.target) { console.error('用法: npm run upload:apk -- --file --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 modeUpload = MODE_UPLOAD[args.mode]; const status = modeUpload ? modeUpload.status : (args.target === 'prod' ? 2 : 1); const fileName = `${version}.apk`; const saveKey = `/version/${upgradeName}/${fileName}`; const latestFileName = 'latest.apk'; const latestSaveKey = `/version/${upgradeName}/${latestFileName}`; const apiBase = modeUpload ? modeUpload.apiBase : DEFAULT_API_BASE; const cdnHost = modeUpload ? modeUpload.cdnHost : DEFAULT_CDN_HOST[args.target]; const token = await login(apiBase, { phone: args.phone, password: args.password, tenant: args.tenant, }); console.log(`版本号: ${version}`); console.log(`签名接口: ${apiBase}`); console.log(`CDN: ${cdnHost}`); const uploaded = await uploadApkFile({ apiBase, token, apkPath, saveKey, fileName, status, }); console.log(toPublicUrl(cdnHost, saveKey, uploaded)); const latestUploaded = await uploadApkFile({ apiBase, token, apkPath, saveKey: latestSaveKey, fileName: latestFileName, status, }); console.log(toPublicUrl(cdnHost, latestSaveKey, latestUploaded)); } main().catch((error) => { console.error(error.message || error); process.exit(1); });