云服务推送

This commit is contained in:
not-like-juvenile
2026-02-27 16:02:44 +08:00
parent 065f12b16a
commit 427010f7db
16 changed files with 2144 additions and 5 deletions

View File

@@ -0,0 +1,65 @@
<#
Simple local CI helper (PowerShell):
- 打包 zip
- 可选上传到上传接口
- 可选触发部署 API
- 可选调用云函数做一次 smoke test
Usage:
# 只打包
.\ci-deploy.ps1 -Pack
# 打包并上传(提供 uploadUrl/uploadToken
.\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token'
# 上传后触发部署 API
.\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token' -DeployApi 'https://your-deploy-api' -DeployToken 'token'
#>
param(
[switch]$Pack,
[string]$UploadUrl,
[string]$UploadToken,
[string]$DeployApi,
[string]$DeployToken,
[string]$FuncInvokeUrl,
[string]$PushToken,
[string]$TestCid
)
Push-Location (Split-Path -Parent $MyInvocation.MyCommand.Definition)
if ($Pack) {
Write-Host "Packing cloud function..."
npm install archiver node-fetch form-data | Out-Null
node .\deploy-cloudfunc.js
}
if ($UploadUrl) {
Write-Host "Uploading to $UploadUrl"
$zip = Join-Path ..\dist testUnipush2.zip
if (!(Test-Path $zip)) { Write-Error "zip not found: $zip"; exit 2 }
$headers = @{}
if ($UploadToken) { $headers.Add('Authorization', "Bearer $UploadToken") }
$form = @{ file = Get-Item $zip }
# Use curl.exe for simple multipart upload
$curlArgs = @('-sS','-X','POST',$UploadUrl)
if ($UploadToken) { $curlArgs += @('-H',"Authorization: Bearer $UploadToken") }
$curlArgs += @('-F',"file=@$zip")
& curl.exe @curlArgs
}
if ($DeployApi) {
Write-Host "Triggering deploy API: $DeployApi"
$body = @{ uploadUrl = $UploadUrl } | ConvertTo-Json
$headers = @()
if ($DeployToken) { $headers += @('-H',"Authorization: Bearer $DeployToken") }
& curl.exe -sS -X POST $DeployApi -H 'Content-Type: application/json' $headers -d $body
}
if ($FuncInvokeUrl) {
Write-Host "Invoking function: $FuncInvokeUrl"
$payload = @{ token = $PushToken; push_clientid = $TestCid; title='CI Test'; content='hello from CI'; payload = @{} } | ConvertTo-Json
& curl.exe -sS -X POST $FuncInvokeUrl -H 'Content-Type: application/json' -d $payload
}
Pop-Location

View File

@@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const fetch = require('node-fetch');
const FormData = require('form-data');
function packDirToZip(srcDir, outPath) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve({ bytes: archive.pointer(), path: outPath }));
archive.on('error', err => reject(err));
archive.pipe(output);
archive.directory(srcDir, false);
archive.finalize();
});
}
async function uploadZip(url, token, zipPath, extraFields = {}) {
const form = new FormData();
form.append('file', fs.createReadStream(zipPath));
for (const k of Object.keys(extraFields)) form.append(k, extraFields[k]);
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const resp = await fetch(url, { method: 'POST', headers: Object.assign(headers, form.getHeaders()), body: form });
const text = await resp.text();
let json;
try { json = JSON.parse(text); } catch (e) { json = { statusText: text }; }
return { ok: resp.ok, status: resp.status, body: json };
}
async function triggerDeployApi(deployApi, deployToken, payload = {}) {
const headers = { 'Content-Type': 'application/json' };
if (deployToken) headers.Authorization = `Bearer ${deployToken}`;
const resp = await fetch(deployApi, { method: 'POST', headers, body: JSON.stringify(payload) });
const json = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, body: json };
}
async function invokeFunction(funcUrl, pushToken, testCid, title = 'CI Test', content = 'hello from backend') {
const body = { token: pushToken, push_clientid: testCid, title, content, payload: {} };
const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const json = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, body: json };
}
async function deployCloudFunction(options = {}) {
const funcDir = options.funcDir || path.join(__dirname, '..', '..', 'uniCloud-alipay', 'cloudfunctions', 'testUnipush2');
const outDir = path.join(__dirname, '..', 'dist');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const zipPath = path.join(outDir, path.basename(funcDir) + '.zip');
const result = { packed: null, uploaded: null, deployed: null, invoked: null };
// pack
const packRes = await packDirToZip(funcDir, zipPath);
result.packed = packRes;
// upload
if (!options.uploadUrl) return result;
const extra = {};
if (options.uniAppId) extra.appId = options.uniAppId;
const up = await uploadZip(options.uploadUrl, options.uploadToken, zipPath, extra);
result.uploaded = up;
// trigger deploy API (optional)
if (options.deployApi) {
const payload = Object.assign({}, options.deployPayload || {}, { uploadUrl: options.uploadUrl, uploadResponse: up.body });
const dp = await triggerDeployApi(options.deployApi, options.deployToken, payload);
result.deployed = dp;
}
// invoke function for smoke test (optional)
if (options.funcInvokeUrl && options.pushToken && options.testCid) {
const iv = await invokeFunction(options.funcInvokeUrl, options.pushToken, options.testCid, options.testTitle, options.testContent);
result.invoked = iv;
}
return result;
}
module.exports = { deployCloudFunction, packDirToZip, uploadZip, triggerDeployApi, invokeFunction };

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env node
// 打包并可选上传云函数到指定上传接口的工具脚本(通用模版)
// 依赖: archiver, node-fetch, form-data
const fs = require('fs');
const path = require('path');
async function ensureDeps() {
try {
require.resolve('archiver');
require.resolve('node-fetch');
require.resolve('form-data');
} catch (e) {
console.error('\n依赖缺失请先在仓库根或 server 目录安装依赖:');
console.error(' cd server');
console.error(' npm install archiver node-fetch form-data');
process.exit(1);
}
}
const archiver = require('archiver');
const fetch = require('node-fetch');
const FormData = require('form-data');
const { spawnSync } = require('child_process');
function packDirToZip(srcDir, outPath) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve({ bytes: archive.pointer(), path: outPath }));
archive.on('error', err => reject(err));
archive.pipe(output);
archive.directory(srcDir, false);
archive.finalize();
});
}
async function uploadZip(url, token, zipPath, extraFields = {}) {
const form = new FormData();
form.append('file', fs.createReadStream(zipPath));
for (const k of Object.keys(extraFields)) form.append(k, extraFields[k]);
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const resp = await fetch(url, { method: 'POST', headers: Object.assign(headers, form.getHeaders()), body: form });
const text = await resp.text();
let json;
try { json = JSON.parse(text); } catch (e) { json = { statusText: text }; }
return { ok: resp.ok, status: resp.status, body: json };
}
function runUnicloudUpload(spaceId, funcDir, funcName) {
try {
// 尝试使用全局 unicloud
let args = ['upload', '-p', spaceId, '-f', funcName, funcDir];
console.log('尝试使用全局 `unicloud` CLI', ['unicloud', ...args].join(' '));
let r = spawnSync('unicloud', args, { stdio: 'inherit' });
if (r && r.status === 0) return { ok: true };
// 否则尝试使用 npx 调用包内命令(不要求全局安装)
args = ['@dcloudio/unicloud-cli', 'upload', '-p', spaceId, '-f', funcName, funcDir];
console.log('尝试使用 npx 调用 unicloud-cli', ['npx', ...args].join(' '));
r = spawnSync('npx', args, { stdio: 'inherit' });
if (r && r.status === 0) return { ok: true };
return { ok: false, error: 'unicloud CLI 上传失败,返回码: ' + (r && r.status) };
} catch (e) {
return { ok: false, error: String(e) };
}
}
async function callDeployApi(deployApi, deployToken, uploadResult) {
try {
const headers = { 'Content-Type': 'application/json' };
if (deployToken) headers.Authorization = `Bearer ${deployToken}`;
const body = Object.assign({ uploadResult }, {});
const resp = await fetch(deployApi, { method: 'POST', headers, body: JSON.stringify(body) });
const txt = await resp.text();
let json;
try { json = JSON.parse(txt); } catch (e) { json = { statusText: txt }; }
return { ok: resp.ok, status: resp.status, body: json };
} catch (e) {
return { ok: false, error: String(e) };
}
}
async function invokeCloudFunction(funcUrl, pushToken, testCid) {
try {
const body = { token: pushToken || process.env.PUSH_TOKEN || null };
if (testCid) body.push_clientid = testCid;
body.title = 'smoke-test';
body.content = 'smoke-test';
const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const txt = await resp.text();
let json;
try { json = JSON.parse(txt); } catch (e) { json = { statusText: txt }; }
return { ok: resp.ok, status: resp.status, body: json };
} catch (e) {
return { ok: false, error: String(e) };
}
}
async function main() {
await ensureDeps();
const argv = process.argv.slice(2);
const args = {};
// 明确哪些参数需要值;若缺失则报错并退出,避免像 --upload 没给 URL 的情况
const expectsValue = new Set(['upload','deployApi','deployToken','invokeUrl','funcInvokeUrl','pushToken','testCid','dir','spaceId','name']);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const key = a.replace('--','');
const next = argv[i+1] && !argv[i+1].startsWith('--') ? argv[i+1] : undefined;
if (expectsValue.has(key)) {
if (!next) {
console.error(`参数错误:--${key} 需要一个值(例如 --${key} <value>)。`);
process.exit(1);
}
args[key] = next;
i++; // skip value
} else {
args[key] = true;
}
}
const funcDir = args.dir || process.env.CLOUD_FUNCTION_DIR || path.join(__dirname, '..', '..', 'uniCloud-alipay', 'cloudfunctions', 'testUnipush2');
const outDir = path.join(__dirname, '..', 'dist');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const zipPath = path.join(outDir, path.basename(funcDir) + '.zip');
console.log('打包目录:', funcDir);
try {
const r = await packDirToZip(funcDir, zipPath);
console.log('打包完成 ->', zipPath, `(${r.bytes} bytes)`);
} catch (e) {
console.error('打包失败', e);
process.exit(2);
}
let uploadUrl = args.upload || process.env.CLOUD_UPLOAD_URL;
const uploadToken = process.env.CLOUD_UPLOAD_TOKEN || process.env.CLOUD_TOKEN;
// 如果没有提供 upload URL但提供了 UniCloud 的 spaceId则默认使用官方上传 API
const spaceId = args.spaceId || process.env.CLOUD_UNICLOUD_SPACEID || process.env.SPACE_ID || null;
if (!uploadUrl && spaceId) {
uploadUrl = process.env.CLOUD_UPLOAD_URL || 'https://unicloud.dcloud.net.cn/api/uni-cloud/function/upload';
console.log('未指定 --upload检测到 spaceId使用 UniCloud 上传 API', uploadUrl);
}
if (!uploadUrl) {
console.log('\n未配置上传地址 (CLOUD_UPLOAD_URL),只生成 zip 文件。');
console.log('若要上传,请设置环境变量 CLOUD_UPLOAD_URL 与 CLOUD_UPLOAD_TOKEN或使用 --upload <URL> 参数,或提供 --spaceId 使用 unicloud 上传 API。');
process.exit(0);
}
console.log('上传到:', uploadUrl);
try {
const extra = {};
if (process.env.UNI_PUSH_APPID) extra.appId = process.env.UNI_PUSH_APPID;
// 若使用 UniCloud 官方上传接口,需要传入 spaceId 与 name
if (spaceId) extra.spaceId = spaceId;
const funcName = args.name || path.basename(funcDir);
if (funcName) extra.name = funcName;
// 本地文件校验
if (!fs.existsSync(zipPath)) {
console.error('打包文件不存在:', zipPath);
process.exit(7);
}
let res = await uploadZip(uploadUrl, uploadToken, zipPath, extra);
console.log('上传响应:', res.status, JSON.stringify(res.body));
// 若 HTTP 上传被拒绝(例如 405或不被接受且提供了 spaceId则尝试使用 unicloud CLI 上传回退
if ((!res.ok || res.status === 405) && spaceId) {
console.log('HTTP 上传失败或被拒绝,尝试使用 unicloud CLI 上传回退...');
const funcName = args.name || path.basename(funcDir);
const cli = runUnicloudUpload(spaceId, funcDir, funcName);
if (cli.ok) {
console.log('unicloud CLI 上传成功(回退)。');
// 将 published 设为 true后续进行 smoke-test
res = { ok: true, status: 0, body: { source: 'unicloud-cli' } };
} else {
console.error('unicloud CLI 上传也失败:', cli.error);
process.exit(8);
}
}
if (!res.ok) process.exit(3);
// 常见错误检测:如果把包 POST 到了函数的 invoke URL云函数通常直接返回业务层错误
// e.g. {"errCode":400,"errMsg":"push_clientid required"}
try {
const probe = JSON.stringify(res.body || {});
if (/push_clientid|required/i.test(probe) && /errCode|errMsg|error/i.test(probe)) {
console.error('\n检测到上传目标看起来像云函数的调用invokeURL而不是上传/发布 API。');
console.error('请使用提供商的上传/发布接口(不是函数的 /test 或类似 invoke 路径)。');
console.error('建议:在 HBuilderX 或 控制台 的 Network 面板中捕获 “上传/发布” 请求,或联系平台文档获取上传 API。');
console.error('当前响应(供参考):', JSON.stringify(res.body));
process.exit(6);
}
} catch (e) { /* ignore probe errors */ }
// 简单判断上传响应是否已经标记为已发布/上线(根据不同厂商响应结构调整)
let published = false;
try {
const b = res.body || {};
if (b.errCode === 0 || b.errMsg === 'success' || /success|online|published/i.test(JSON.stringify(b))) published = true;
} catch (e) { /* ignore */ }
// 如果未明确发布且提供了 deploy API则调用之以触发发布
const deployApi = args.deployApi || process.env.CLOUD_DEPLOY_API || null;
const deployToken = args.deployToken || process.env.CLOUD_DEPLOY_TOKEN || null;
let deployResult = null;
if (!published && deployApi) {
console.log('调用部署 API:', deployApi);
deployResult = await callDeployApi(deployApi, deployToken, res.body);
console.log('部署 API 响应:', deployResult && (deployResult.body || deployResult.error));
if (deployResult && deployResult.ok) published = true;
}
// 可选:若指定了 funcInvokeUrl 或 CLOUD_FUNC_URL则在上传/发布后做一次 smoke-test 调用
const funcInvokeUrl = args.invokeUrl || args.funcInvokeUrl || process.env.CLOUD_FUNC_URL || null;
const pushToken = args.pushToken || process.env.PUSH_TOKEN || null;
const testCid = args.testCid || process.env.TEST_DEVICE_CID || null;
if (funcInvokeUrl) {
console.log('调用云函数做 smoke-test:', funcInvokeUrl);
const inv = await invokeCloudFunction(funcInvokeUrl, pushToken, testCid);
console.log('smoke-test 响应:', inv && (inv.body || inv.error));
if (!inv.ok) process.exit(5);
}
} catch (e) {
console.error('上传失败', e);
process.exit(4);
}
}
if (require.main === module) main();