287 lines
8.9 KiB
JavaScript
287 lines
8.9 KiB
JavaScript
/**
|
||
* 上传已打包的 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 API_BASE = 'https://hcscmtest.zzfzyc.com/hcscm';
|
||
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 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 = API_BASE;
|
||
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);
|
||
});
|