云服务推送

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

59
.github/workflows/deploy-cloudfunc.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Deploy CloudFunction (pack & optional upload)
on:
push:
branches: [ main ]
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install minimal deps for pack script
run: |
cd server
npm install archiver node-fetch form-data
- name: Pack & Upload CloudFunction
env:
CLOUD_UPLOAD_URL: ${{ secrets.CLOUD_UPLOAD_URL }}
CLOUD_UPLOAD_TOKEN: ${{ secrets.CLOUD_UPLOAD_TOKEN }}
UNI_PUSH_APPID: ${{ secrets.UNI_PUSH_APPID }}
run: |
node server/tools/deploy-cloudfunc.js --upload
- name: Trigger Deploy API (optional)
if: ${{ secrets.CLOUD_DEPLOY_API != '' }}
env:
CLOUD_DEPLOY_API: ${{ secrets.CLOUD_DEPLOY_API }}
CLOUD_DEPLOY_TOKEN: ${{ secrets.CLOUD_DEPLOY_TOKEN }}
UPLOAD_URL: ${{ secrets.CLOUD_UPLOAD_URL }}
run: |
echo "Triggering deploy API: $CLOUD_DEPLOY_API"
# POST deploy trigger with basic info; adapt fields to your provider API
curl -sS -X POST "$CLOUD_DEPLOY_API" \
-H "Authorization: Bearer $CLOUD_DEPLOY_TOKEN" \
-H 'Content-Type: application/json' \
-d "{ \"uploadUrl\": \"$UPLOAD_URL\" }" \
|| echo "deploy trigger returned non-zero status"
- name: Invoke Cloud Function (test - optional)
if: ${{ secrets.CLOUD_FUNC_URL != '' }}
env:
CLOUD_FUNC_URL: ${{ secrets.CLOUD_FUNC_URL }}
PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }}
TEST_DEVICE_CID: ${{ secrets.TEST_DEVICE_CID }}
run: |
echo "Invoking cloud function for smoke test"
curl -sS -X POST "$CLOUD_FUNC_URL" \
-H 'Content-Type: application/json' \
-d "{ \"token\": \"$PUSH_TOKEN\", \"push_clientid\": \"$TEST_DEVICE_CID\", \"title\": \"CI Test\", \"content\": \"hello from CI\", \"payload\": {} }" \
|| echo "invoke returned non-zero"

View File

@@ -4,6 +4,19 @@
{
"playground" : "standard",
"type" : "uni-app:app-android"
},
{
"app" : {
"launchtype" : "remote"
},
"default" : {
"launchtype" : "local"
},
"h5" : {
"launchtype" : "local"
},
"provider" : "alipay",
"type" : "uniCloud"
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "mall",
"appid": "uni.app.UNI9462CA7",
"appid": "__UNI__9462CA7",
"description": "A multi-role e-commerce application.",
"versionName": "1.0.0",
"versionCode": "100",

View File

@@ -105,6 +105,9 @@ CREATE TABLE IF NOT EXISTS public.express_notifications (
-- 事件摘要(下发给客户端的安全/脱敏文案)
event_text_safe TEXT NULL,
status_code VARCHAR(32) NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL,
next_attempt_at TIMESTAMP WITH TIME ZONE NULL,
event_time TIMESTAMP WITH TIME ZONE NULL,
-- 透传/审计用 payload不包含敏感字段raw_payload 请勿透传给客户端)

80
server/DEPLOY_WORKFLOW.md Normal file
View File

@@ -0,0 +1,80 @@
**部署工作流(打包 → 上传/发布 → smoketest**
- **目的**: 描述如何把本地 `uniCloud` 云函数项目自动化打包、上传并发布为可供后端调用的云函数;说明两种上传方式(直接调用上传 API / 使用 `unicloud` CLI与 smoketest 验证流程;给出 CI 集成与常见故障排查建议。
- **目录结构(示例)**:
- `uniCloud-alipay/cloudfunctions/<funcName>/` - 云函数源码目录(含 `index.js``package.json` 等)
- `server/tools/deploy-cloudfunc.js` - 打包并上传/发布的脚本
- `server/dist/` - 输出的 zip 文件目录
1) 打包
- 使用脚本 `server/tools/deploy-cloudfunc.js` 的默认行为:把指定云函数目录压缩为 zip例如 `dist/testUnipush2.zip`)。
- 本地快速命令:
```bash
# 只打包(不上传)
node server/tools/deploy-cloudfunc.js --dir path/to/your/cloudfunctions/func
```
2) 上传 / 发布(两种方式,任选其一)
- A. 使用提供商“上传/发布”HTTP API推荐用于无 CLI 的环境)
- 要求:提供上传 URLCLOUD_UPLOAD_URL 或 `--upload`)、必要的 tokenCLOUD_UPLOAD_TOKEN和可能的额外表单字段例如 appId
- 流程:脚本把 zip 作为 `multipart/form-data` POST 到上传端点;若上传端点返回仅是“已接收”而非“已发布”,还需调用发布/部署 APICLOUD_DEPLOY_API 或 `--deployApi`)。
- 注意:不要把 zip 发到函数的 invoke调用URL例如 `/test`)。如果你把文件 POST 到 invoke URL平台会把请求当作函数执行并返回业务错误例如 `{"errCode":400,"errMsg":"push_clientid required"}`)。脚本内已添加检测,当响应看起来像 invoke 返回会立即报错并提示。
- B. 使用 `unicloud` CLI简单且与 HBuilder 官方流程一致)
- 前提:机器/CI 能安装并执行 `unicloud`,并已登录或有可用凭证。常用命令示例:
```bash
unicloud upload -p <SPACE_ID> -f <FUNCTION_NAME> ./uniCloud-alipay/cloudfunctions/<FUNCTION_NAME>
```
- 优点:不需要逆向抓包,等同于控制台发布,适合 CI。缺点需在 CI 中安装 CLI 并处理登录。
- 建议:在脚本中添加 CLI fallback当未提供 `--upload` 时尝试调用 `unicloud upload`(脚本可接收 `--spaceId` 或环境变量 `CLOUD_UNICLOUD_SPACEID`)。
3) smoketest发布后验证
- 在上传/发布成功后,应通过函数的 invoke URL 做一次简单调用,验证函数行为(例如发送测试推送或返回 expected response。脚本支持传入 `--invokeUrl` / `--funcInvokeUrl` 与 `--testCid` 做自动验证。
- 若 smoketest 失败:检查是否为 invoke URL 被误用为 upload URL、是否发布尚未完成、或权限/鉴权token问题。
4) CI 集成GitHub Actions 示例思路)
- 步骤:
1. Checkout 代码
2. 安装依赖(若使用脚本中的 archiver/form-data/node-fetch
3. 若使用 CLI安装 `unicloud` 并通过 secrets 登录(或在 runner 上提前配置 service account
4. 运行 `node server/tools/deploy-cloudfunc.js --upload ${{ secrets.CLOUD_UPLOAD_URL }} --invokeUrl ${{ secrets.CLOUD_FUNC_URL }} --testCid ${{ secrets.TEST_DEVICE_CID }}` 或运行 CLI fallback
5. 检查返回码并在失败时标记 Job 失败
5) 必要环境变量与参数
- CLOUD_UPLOAD_URL / --upload : 上传 API URL非函数 invoke URL
- CLOUD_UPLOAD_TOKEN : 上传时使用的 Bearer token或其它鉴权
- CLOUD_DEPLOY_API / --deployApi : 若上传后需单独调用发布 API可传入
- CLOUD_FUNC_URL / --invokeUrl : 发布后的函数调用 URL用来做 smoketest
- TEST_DEVICE_CID / --testCid : 用于 push smoketest 的测试设备 CID
- CLOUD_UNICLOUD_SPACEID / --spaceId : 若使用 `unicloud` CLI 上传则为服务空间 ID
6) 常见问题与排查步骤
- 问:脚本返回 `Only absolute URLs are supported` 或 fetch 报错
- 答:通常因为 `--upload` 使用但没有提供值;请改为 `--upload <URL>` 或设置 `CLOUD_UPLOAD_URL`。
- 问:上传后看到 `{"errCode":400,"errMsg":"push_clientid required"}`
- 答:说明你把 zip POST 到了函数的 invoke URL它把请求当作函数调用处理。请提供正确的上传/发布 API 或使用 `unicloud upload` CLI 来发布。
- 排查建议:
- 在 HBuilderX 的发布流程中打开 Network 面板,执行一次发布并抓取对应请求(记录 URL、method、form 字段、headers把它贴到脚本配置中或发给维护者。
- 在 PowerShell 或 Linux 上用 `curl -v` 验证上传端点返回内容,注意响应结构是否像“发布成功”而非函数执行结果。
7) 安全建议
- 把上传/发布的 token、spaceId、testCid 等放在 CI secrets 或受保护的环境变量中,避免写入代码库。
- 脚本中的自动部署入口应加鉴权(例如 `DEPLOY_BEARER`),不要在公网上暴露未经保护的上传接口。
8) 下一步(可选)
- 我可以把 `deploy-cloudfunc.js` 增加 `unicloud` CLI fallback检测 `--spaceId` 或 `CLOUD_UNICLOUD_SPACEID` 并在没有 `--upload` 时调用 CLI也可直接把你抓到的 HBuilder 上传请求模式接入脚本以实现完全自动化。请告知你偏好哪个方案。
-----------------
文件位置server/DEPLOY_WORKFLOW.md

View File

@@ -81,6 +81,220 @@ UNIPUSH 集成注意事项
- 文档:[server/PUSH_SERVER_README.md](server/PUSH_SERVER_README.md)
- 代码:[server/push-server.js](server/push-server.js)
自动化部署(可选)
- 本仓库新增了一个打包并可选上传云函数的脚本:`server/tools/deploy-cloudfunc.js`,会把
`uniCloud-alipay/cloudfunctions/testUnipush2` 打包到 `server/dist/testUnipush2.zip`
- 使用前请在 `server` 目录安装依赖:
```bash
cd server
npm install archiver node-fetch form-data
```
- 本地打包(只生成 zip
```bash
node server/tools/deploy-cloudfunc.js
```
- 如果你有云平台的上传 URL可直接上传脚本会读取 `CLOUD_UPLOAD_URL` 与 `CLOUD_UPLOAD_TOKEN` 环境变量,或使用 `--upload <URL>` 参数):
```bash
export CLOUD_UPLOAD_URL='https://your-cloud-upload-api'
export CLOUD_UPLOAD_TOKEN='xxxxx'
node server/tools/deploy-cloudfunc.js --upload
```
- 我也添加了一个 GitHub Actions 模板(.github/workflows/deploy-cloudfunc.yml可在仓库的 `main` 分支 push 时自动打包并上传(需要在 Actions Secrets 中配置 `CLOUD_UPLOAD_URL` 与 `CLOUD_UPLOAD_TOKEN`)。
CI / 自动化 说明
- 已提供 GitHub Actions workflow: [.github/workflows/deploy-cloudfunc.yml](.github/workflows/deploy-cloudfunc.yml)。支持可选步骤:上传 -> 触发部署 API -> 调用云函数做 smoke test。
- 推荐在仓库 Secrets 中添加(根据你云厂商调整):
- `CLOUD_UPLOAD_URL` : 上传 zip 的 HTTP endpointupload 接口)
- `CLOUD_UPLOAD_TOKEN` : 上传接口的 Bearer token
- `CLOUD_DEPLOY_API` : (可选)触发云函数部署/发布的 API
- `CLOUD_DEPLOY_TOKEN` : (可选)部署 API 的 token
- `CLOUD_FUNC_URL` : (可选)已部署云函数的外网 URLCI 将对其执行一次 smoke 测试
- `PUSH_TOKEN` : (可选)云函数鉴权 token用于 smoke 测试
- `TEST_DEVICE_CID` : (可选)用于 CI smoke 测试的设备 CID
- 我也添加了本地 helper 脚本:`server/tools/ci-deploy.ps1`,可在本地执行相同流程(打包 -> 上传 -> 触发 -> 调用)。
如果需要,我可以:
- 把 adapter 的请求体精确匹配你现有成功的 uni-push curl请把 curl 发来);或
- 为通知添加重试/记录字段并实现简单重试机制。
自动部署成功示例
-----------------
下面是一个你实际执行并确认成功的示例,便于复制到文档或在团队内复现:
- 启用自动部署并设置 tokenPowerShell 示例):
```powershell
$env:AUTO_DEPLOY_ON_START='true'
$env:DEPLOY_BEARER='your-secret' # 请使用真实随机 token 暂无
$env:CLOUD_UPLOAD_URL='https://env-00jy5x5oy9zd.dev-hz.cloudbasefunction.cn/test'
$env:CLOUD_UPLOAD_TOKEN='upload-token' # 如果上传需要鉴权
$env:AUTO_DEPLOY_ARGS='--upload' # 可选:将 --upload 传给 deploy 脚本
node push-server.js
```
- 预期控制台输出(已在你的环境中观察到):
```
Push server listening on http://0.0.0.0:7301
ENV: UNI_PUSH_URL= https://restapi.getui.com/v2/...
Auto-deploy: spawning node D:\...\server\tools\deploy-cloudfunc.js
[auto-deploy stdout] 打包目录: D:\...\uniCloud-alipay\cloudfunctions\testUnipush2
[auto-deploy stdout] 打包完成 -> D:\...\server\dist\testUnipush2.zip (1324 bytes)
[auto-deploy stdout] 上传到: https://env-00jy5x5oy9zd.dev-hz.cloudbasefunction.cn/test
[auto-deploy stdout] 上传响应: 200 {"data":{...},"errCode":0,"errMsg":"success"}
Auto-deploy process exited with code=0 signal=null
```
- 说明:
- `errCode:0` / `errMsg: "success"` 表示云平台已接收并下发(上线)该云函数包。具体字段会随云厂商略有不同,请以返回的 `errCode` / `errMsg` 或 `status` 为准。
- 若你需要进一步验证云函数可用性,请用 `curl` 或 `Invoke-RestMethod` 调用返回的云函数 URL 做一次 smoke test见上文的“直接调用云函数smoke test”示例
安全提醒:自动部署具有上传并触发发布的能力,请仅在受控环境或 CI 中启用,并把 `DEPLOY_BEARER` / `CLOUD_UPLOAD_TOKEN` 等密钥放入 CI Secrets 或受限的环境变量中。
快速上手(最小环境变量)
- 如果你只是想快速上传并通过云函数下发一次推送,最小只需设置两个环境变量并运行打包脚本:
PowerShell 示例:
```powershell
$env:CLOUD_UPLOAD_URL='https://env-00jy5x5oy9zd.dev-hz.cloudbasefunction.cn/test'
$env:UNI_PUSH_APPID='__UNI__9462CA7'
node tools\deploy-cloudfunc.js --upload
```
说明:设置 `CLOUD_UPLOAD_URL` 与 `UNI_PUSH_APPID` 后,运行 `deploy-cloudfunc.js` 会把 `uniCloud-alipay/cloudfunctions/testUnipush2` 打包并上传到该地址,上传成功后云平台会返回已下发/上线的结果,从而完成推送通路的测试。
**用户整合指南(一步到位)**
下面把之前讨论过的流程与命令整合成一个可复用的清单,便于在本地或 CI 中自动化执行:
- 先决条件:在 `server` 目录下有 `tools/deploy-cloudfunc.js`,并且已安装依赖 `archiver node-fetch form-data`。
1) 本地打包并上传(最小)
PowerShell
```powershell
cd D:\叶\桌面文件\hfkj_mall\mall\server
npm install archiver node-fetch form-data
$env:CLOUD_UPLOAD_URL='https://env-00jy5x5oy9zd.dev-hz.cloudbasefunction.cn/test'
$env:UNI_PUSH_APPID='__UNI__9462CA7'
node tools\deploy-cloudfunc.js --upload
```
说明:这两条环境变量(`CLOUD_UPLOAD_URL` + `UNI_PUSH_APPID`)在多数场景下已足够完成上传并让云平台下发推送;上传成功时脚本会打印上传后的 URL 与平台响应(检查返回的 `errCode` / `data` 字段以确认)。
2) 直接调用云函数smoke test
PowerShell使用 `curl.exe` 保持与 Linux curl 行为一致):
```powershell
curl.exe -X POST "https://env-.../test" `
-H "Content-Type: application/json" `
-d "{\"token\":\"<PUSH_TOKEN>\",\"push_clientid\":\"<DEVICE_CID>\",\"title\":\"测试\",\"content\":\"hi\",\"payload\":{}}"
```
或用 PowerShell 原生:
```powershell
$body = @{ token = '<PUSH_TOKEN>'; push_clientid = '<DEVICE_CID>'; title='测试'; content='hi'; payload=@{} } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri 'https://env-.../test' -ContentType 'application/json' -Body $body
```
3) 本地辅助脚本(整合流程)
- 已添加 `server/tools/ci-deploy.ps1`,支持:打包、上传、触发部署 API、调用云函数做 smoke test。用法示例
```powershell
# 只打包
.\server\tools\ci-deploy.ps1 -Pack
# 打包并上传并触发部署
.\server\tools\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token' -DeployApi 'https://your-deploy-api' -DeployToken 'token'
# 打包并上传然后调用云函数做 smoke test
.\server\tools\ci-deploy.ps1 -Pack -UploadUrl 'https://your-upload' -UploadToken 'token' -FuncInvokeUrl 'https://env-.../test' -PushToken 'xxx' -TestCid 'device-cid'
```
4) CIGitHub Actions
- workflow: `.github/workflows/deploy-cloudfunc.yml` 已包含:打包 -> 上传 ->(可选)触发部署 API ->(可选)调用云函数做 smoke test。
- 建议在 Actions Secrets 中添加:
- `CLOUD_UPLOAD_URL`(必需用于上传)
- `CLOUD_UPLOAD_TOKEN`(上传 token
- `CLOUD_DEPLOY_API` / `CLOUD_DEPLOY_TOKEN`(可选,用于触发厂商部署)
- `CLOUD_FUNC_URL` / `PUSH_TOKEN` / `TEST_DEVICE_CID`(可选,用于 smoke test
5) 后端直接单推(如果你愿意绕过云函数)
- 启动 `push-server`(示例):
```powershell
cd D:\叶\桌面文件\hfkj_mall\mall\server
npm install
SUPA_URL='http://rest:3000' SERVICE_ROLE_KEY='PASTE_SERVICE_ROLE_KEY' node push-server.js
```
- 直接按 CID 发送(跳过 DB
```powershell
curl.exe -X POST "http://localhost:7301/api/v1/push/send" -H "Content-Type: application/json" -d "{\"cids\":[\"<DEVICE_CID>\"],\"notification\":{\"title\":\"hi\",\"body\":\"msg\"}}"
```
注意与安全建议
- `uniCloud.uploadFile` 可以把 zip 存到云端存储(返回 `fileID`),但不会自动部署为云函数;若要自动部署需要调用云厂商的部署/发布 API或使用厂商控制台/CLI
- 保管好 `CLOUD_UPLOAD_TOKEN`、`CLOUD_DEPLOY_TOKEN`、`SERVICE_ROLE_KEY` 等机密,放到 CI Secrets 或安全环境变量中,不要硬编码在仓库。
- 上传后若要确认运行状态,请查看云平台函数控制台或调用厂商的状态/日志接口CI 的 smoke test 能快速验证下发路径是否通顺。
如果你希望,我可以把上面示例中的 `curl` 调用替换成你厂商的部署 API 或把 `ci-deploy.ps1` 改为更严格的错误处理与日志输出。
部署服务文件说明
----------------
以下两个文件用于让后端通过一个 HTTP 接口自动完成云函数的打包、上传、触发部署与可选的 smoke-test
- `server/tools/deploy-cloudfunc-service.js`
- 目的封装打包zip、上传、触发部署 API、调用云函数smoke-test的通用函数便于从后端代码调用或在 CI 中复用。
- 主要导出:`deployCloudFunction(options)`,接收的 `options` 支持字段:
- `uploadUrl`(必需或由环境变量提供):上传 zip 的 HTTP endpoint。
- `uploadToken`:上传接口的 Bearer token可选
- `uniAppId`:可附加到上传请求的 appId可选
- `deployApi` / `deployToken`:如果需要调用厂商的部署/发布 API可指定可选
- `funcInvokeUrl` / `pushToken` / `testCid`:若提供,将在上传/部署后调用该云函数进行 smoke-test可选
- 返回值:一个对象,包含 `packed`(打包信息)、`uploaded`(上传响应)、`deployed`(触发部署 API 的响应)和 `invoked`smoke-test 调用响应)四部分,便于在后端记录和判断各个阶段的结果。
- `server/routes/deploy.js`
- 目的:提供一个 Express 路由 `/api/v1/deploy-cloudfunc`,让后端或 CI 通过一次 HTTP POST 调用完成上面封装的流程。
- 请求体示例JSON
```json
{
"uploadUrl":"https://your-upload-endpoint",
"uploadToken":"<UPLOAD_TOKEN>",
"uniAppId":"__UNI__9462CA7",
"deployApi":"https://your-deploy-api",
"deployToken":"<DEPLOY_TOKEN>",
"funcInvokeUrl":"https://env-.../test",
"pushToken":"<PUSH_TOKEN>",
"testCid":"<DEVICE_CID>"
}
```
- 行为:路由优先使用请求体中的字段;若缺失则回落使用环境变量(例如 `CLOUD_UPLOAD_URL`、`CLOUD_UPLOAD_TOKEN`、`UNI_PUSH_APPID`、`CLOUD_DEPLOY_API`、`CLOUD_DEPLOY_TOKEN`、`CLOUD_FUNC_URL`、`PUSH_TOKEN`、`TEST_DEVICE_CID`)。
- 返回JSON 格式,包含 `result` 对象(见 `deployCloudFunction` 的返回结构)。
安全与接入建议
- 强烈建议只在内网或受保护的管理接口上暴露该路由,或在请求中加入鉴权头(例如 `Authorization: Bearer <ADMIN_TOKEN>`)并在服务端验证。此接口具有上传并触发部署的能力,若未保护将导致安全风险。
- 如果你需要,我可以:
- 把路由自动挂载到 `server/push-server.js`(并添加简单的 token 验证);或
- 增强 `deploy-cloudfunc-service.js` 的错误日志与重试策略,或把上传/部署调用改写成你云厂商的精确 API 参数格式。
异步 Consumer已实现说明
---------------------------------
- 目的:从数据库 `express_notifications` 拉取待发送的消息pending解耦写入与下发流程保证可重试、可审计与可运维。
- 实现位置:`server/push-server.js`(已新增 consumer 逻辑)。
- 启用:设置环境变量 `ENABLE_CONSUMER=true`(或 `CONSUMER_ENABLED=true`),可选配置轮询间隔 `CONSUMER_POLL_MS`(默认 2000 ms
- 关键环境变量:`SUPA_URL`、`SUPA_KEY`Supabase REST、`CLOUD_FUNC_URL`(云函数 invoke URL、`PUSH_TOKEN`(云函数鉴权)。
- 行为摘要:
- 轮询 `express_notifications`status_code IS NULL并选取记录
- 通过带过滤的 PATCH 抢占(将 `status_code` 设为 `processing`)以避免并发重复处理;
- 查询目标设备(`push_devices`),对每个 `cid` 构造 event 并 POST 到 `CLOUD_FUNC_URL`(若未配置则回退到 `UNI_PUSH_URL` 适配器);
- 根据调用结果回写 `express_notifications.status_code` 为 `success` / `failed` / `no-targets`。
- 限制与扩展点:当前 consumer 依赖 Supabase REST尚未在 DB 中新增 `retry_count`/`last_error` 字段(建议在迁移中加入以支持指数退避与重试);为保证高可用建议配合 `FOR UPDATE SKIP LOCKED` 或 Supabase Realtime 优化并发策略。
文档实践
---------------------------------
- 变更策略:今后每次由自动化工具或我生成/修改后端代码时,将同步把该变更的作用、配置项与启用方法记录到本文件(`server/PUSH_SERVER_README.md`)或对应 workflow 文档中,确保运行/运维人员能直接查阅到最新说明。

124
server/PUSH_WORKFLOW.md Normal file
View File

@@ -0,0 +1,124 @@
**推送工作流说明Push Queue + 后端消费者 + 云函数 invoke**
概述
- 目的是将数据库中产生的待推送消息可靠地传递到云函数进行实际推送(云函数使用 `uniPush`)。
- 思路:业务写库 → 写入 `push_queue`(持久化队列)→ `push-server` 或消费者监听队列 → 构造 `event` 并调用云函数 invoke URL → 云函数从 `event` 中读取参数并发送推送。
优点
- 可靠:持久化队列可重试、审计与人工干预。
- 解耦:业务写库与实际推送解耦,降低失败传播风险。
- 可扩展:支持多 worker 并发消费与并发控制。
组件与职责
- `push_queue`(数据库表):保存待推送任务与状态;保证持久化与可重试。
- `push-server`:消费者或 HTTP 接口,负责从队列读取任务、锁定、调用云函数,并更新任务状态。
- 云函数(已上传并发布一次):例如 `uniCloud` 云函数 `testUnipush2`,接收 `event` 并调用 `uniPush.sendMessage` 完成推送。
Schema 示例Postgres / Supabase
```sql
CREATE TABLE push_queue (
id serial PRIMARY KEY,
push_clientid varchar NOT NULL,
title text,
content text,
payload jsonb DEFAULT '{}'::jsonb,
status varchar(16) DEFAULT 'pending', -- pending, processing, success, failed
retry_count int DEFAULT 0,
last_error text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX ON push_queue (status);
```
写入队列(业务侧)
- 在业务事务中,除了写业务表外,同时插入一条 `push_queue` 记录(保证一致性)。
- 如果业务不想同步写队列,也可由异步任务或触发器生成。
消费者(`push-server`)行为说明
- 1) 轮询或订阅:通过 Supabase Realtime、Postgres LISTEN/NOTIFY、或定时轮询读取 `status='pending'` 的记录。
- 2) 锁定任务:在读取后将该记录设为 `processing`(或用数据库事务/乐观锁),防止重复消费。
- 3) 构造 event
{
token: <PUSH_TOKEN>,
push_clientid: <push_clientid>,
title: <title>,
content: <content>,
payload: <payload>
}
- 4) 调用云函数POST JSON 到云函数 invoke URL或使用云 SDK
- 5) 根据响应更新:
- 成功:`status='success'`,记录返回结果。
- 失败:增加 `retry_count`、写 `last_error`、按重试策略重试或标记 `failed`
示例消费者伪代码Node.js
```javascript
const fetch = require('node-fetch');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function consumeOnce() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const res = await client.query("SELECT * FROM push_queue WHERE status='pending' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1");
if (res.rowCount === 0) { await client.query('COMMIT'); return; }
const rec = res.rows[0];
await client.query("UPDATE push_queue SET status='processing', updated_at=now() WHERE id=$1", [rec.id]);
await client.query('COMMIT');
// 调用云函数
const body = {
token: process.env.PUSH_TOKEN,
push_clientid: rec.push_clientid,
title: rec.title,
content: rec.content,
payload: rec.payload
};
const r = await fetch(process.env.CLOUD_FUNC_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const txt = await r.text();
if (r.ok) {
await pool.query("UPDATE push_queue SET status='success', updated_at=now() WHERE id=$1", [rec.id]);
} else {
await pool.query("UPDATE push_queue SET status='pending', retry_count = retry_count + 1, last_error=$2, updated_at=now() WHERE id=$1", [rec.id, txt]);
}
} catch (e) {
await client.query('ROLLBACK');
console.error('consume error', e);
} finally { client.release(); }
}
setInterval(consumeOnce, 1000); // 或使用更复杂的 worker 池
```
云函数调用约定
- 云函数在 `index.js` 中接收 `event`,你的函数示例:
- 检查 `event.token` 是否与环境变量 `PUSH_TOKEN` 匹配(鉴权);
- 必要字段 `push_clientid` 缺失返回 400
- 成功后调用 `uniPush.sendMessage(...)` 并返回 `{ errCode:0, errMsg:'success' }`
安全与鉴权
- 在后端调用云函数时带上 `token`,不要把明文 token 写入仓库,使用环境变量或 CI secrets管理。
- `push-server` 的对外 API若开放应加鉴权Bearer token/IP白名单等
重试与幂等
- 采用 `retry_count` + 指数退避策略;超过阈值标记 `failed` 并告警。
- 消费前将任务标记为 `processing`(或使用 `FOR UPDATE SKIP LOCKED`),防止多个 worker 重复处理。
部署注意
- 云函数只需上传/发布一次;后续调用使用 invoke URL。
-`CLOUD_FUNC_URL`, `PUSH_TOKEN`, `DATABASE_URL` 等设置为运行环境的环境变量。
监控与日志
- 记录每次调用响应、失败原因、重试次数;在失败率异常时发送报警。
- 可把 `push_queue` 的任务历史导出用于审计。
示例 SQL + 流程总结见上方片段。
下一步建议
- 我可以为你:
1) 在 `push-server` 中实现上面的 PostgreSQL consumer并提交代码
2) 实现基于 Supabase Realtime 的监听示例并集成;或
3) 添加一个业务 webhook API业务方直接 POST 任务到 `push-server`,由 `push-server` 写队列并触发消费。
文件位置server/PUSH_WORKFLOW.md

999
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,11 @@
"start": "node push-server.js"
},
"dependencies": {
"archiver": "^7.0.1",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"node-fetch": "^2.6.12"
"form-data": "^4.0.5",
"node-fetch": "^2.7.0"
}
}

View File

@@ -5,6 +5,7 @@ const fs = require('fs').promises
const path = require('path')
const fetch = require('node-fetch')
const crypto = require('crypto')
const { spawn } = require('child_process')
const PORT = process.env.PORT || 7301
const DATA_DIR = path.join(__dirname, 'data')
@@ -14,6 +15,17 @@ const SUPA_URL = process.env.SUPA_URL || ''
const SUPA_KEY = process.env.SUPA_KEY || process.env.SERVICE_ROLE_KEY || ''
const SUPA_SCHEMA = process.env.SUPA_SCHEMA || 'public'
// Consumer 配置
const CONSUMER_ENABLED = (process.env.ENABLE_CONSUMER === 'true') || (process.env.CONSUMER_ENABLED === 'true')
const CONSUMER_POLL_MS = parseInt(process.env.CONSUMER_POLL_MS || process.env.CONSUMER_POLL_INTERVAL_MS || '2000', 10)
const CLOUD_FUNC_URL = process.env.CLOUD_FUNC_URL || ''
const PUSH_TOKEN = process.env.PUSH_TOKEN || ''
// Retry/backoff config
const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10)
const RETRY_INITIAL_MS = parseInt(process.env.RETRY_INITIAL_MS || '5000', 10)
const RETRY_FACTOR = parseInt(process.env.RETRY_FACTOR || '2', 10)
const RETRY_MAX_MS = parseInt(process.env.RETRY_MAX_MS || '3600000', 10) // 1 hour
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true })
@@ -238,8 +250,174 @@ async function start() {
app.use(cors())
app.use(bodyParser.json())
// 部署路由与简单鉴权(若设置了 DEPLOY_BEARER 或 DEPLOY_TOKEN 环境变量)
try {
const deployRouter = require('./routes/deploy')
const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
app.use('/api/v1', (req, res, next) => {
if (req.path === '/deploy-cloudfunc' && DEPLOY_BEARER) {
const auth = req.headers.authorization || ''
if (auth !== `Bearer ${DEPLOY_BEARER}`) return res.status(401).json({ ok: false, error: 'unauthorized' })
}
next()
}, deployRouter)
} catch (e) {
console.warn('deploy route not mounted (missing file?):', e && e.message)
}
app.get('/health', (req, res) => res.json({ ok: true }))
// ---- Consumer: 从 express_notifications 拉取 pending 记录并调用云函数 ----
async function fetchPendingNotifications(limit = 5) {
// 只在配置了 Supabase REST 时工作
if (!SUPA_URL || !SUPA_KEY) return []
try {
// 查询 status_code IS NULL (pending) 或 next_attempt_at <= now 的记录
const now = new Date().toISOString()
// 使用 OR 过滤status_code.is.null 或 next_attempt_at <= now
const q = `express_notifications?or=(status_code.is.null,next_attempt_at=lte.${encodeURIComponent(now)})&order=created_at.asc&limit=${limit}`
const resp = await supaFetch(q, { method: 'GET' })
if (!resp.ok) return []
const data = await resp.json().catch(() => [])
return Array.isArray(data) ? data : []
} catch (e) {
console.warn('fetchPendingNotifications error', e)
return []
}
}
async function claimNotification(id) {
try {
const body = { status_code: 'processing', updated_at: new Date().toISOString() }
// 仅当当前仍为 pending 或 retrying 且到期时,才抢占
const path = `express_notifications?id=eq.${encodeURIComponent(id)}&or=(status_code.is.null,next_attempt_at=lte.${encodeURIComponent(new Date().toISOString())})`
const resp = await supaFetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Prefer: 'return=representation' }, body: JSON.stringify(body) })
if (!resp.ok) return null
const j = await resp.json().catch(() => null)
if (Array.isArray(j) && j.length > 0) return j[0]
return j
} catch (e) {
console.warn('claimNotification error', e)
return null
}
}
async function updateNotificationStatus(id, status, note) {
try {
const body = { status_code: String(status), updated_at: new Date().toISOString() }
if (note) body.event_text_safe = String(note).substring(0, 2000)
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
} catch (e) { console.warn('updateNotificationStatus error', e) }
}
async function invokeCloudFuncForCid(funcUrl, token, pushCid, title, content, payload) {
try {
const body = { token: token || PUSH_TOKEN || null, push_clientid: pushCid, title: title || '', content: content || '', payload: payload || {} }
const resp = await fetch(funcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const txt = await resp.text().catch(() => '')
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 processNotificationRecord(rec) {
if (!rec || !rec.id) return
// claim the record (set processing) to avoid races
const claimed = await claimNotification(rec.id)
if (!claimed) return // someone else claimed or failed
const aud = claimed.aud
const recipient_id = claimed.recipient_id
const notification = claimed.payload && claimed.payload.notification ? claimed.payload.notification : (claimed.event_text_safe ? { title: claimed.event_text_safe, body: '' } : {})
const payload = claimed.payload || {}
// build targets similar to /api/v1/notifications
let targets = []
try {
if (SUPA_URL && SUPA_KEY) {
if (aud === 'user') {
const devices = await getDevicesFromSupabase({ user_id: recipient_id, active: true })
targets = devices.map(d => d.cid)
} else if (aud === 'merchant') {
const resp = await supaFetch(`push_devices?merchant_id=eq.${encodeURIComponent(recipient_id)}&is_active=eq.true`, { method: 'GET' })
if (resp.ok) {
const devs = await resp.json().catch(() => [])
targets = devs.map(d => d.cid)
}
}
}
} catch (e) {
console.warn('processNotificationRecord fetch devices failed', e)
}
if (!targets || targets.length === 0) {
await updateNotificationStatus(claimed.id, 'no-targets', 'no active devices')
return
}
// If CLOUD_FUNC_URL is configured, POST per cid to cloud function; otherwise fallback to sendToUniPush if configured
let allOk = true
let lastNote = ''
if (CLOUD_FUNC_URL) {
const calls = await Promise.all(targets.map(cid => invokeCloudFuncForCid(CLOUD_FUNC_URL, PUSH_TOKEN, cid, notification && notification.title, notification && notification.body, payload)))
for (const c of calls) {
if (!c.ok) { allOk = false; lastNote = (c.error || JSON.stringify(c.body)).toString().substring(0, 1000); break }
}
} else if (process.env.UNI_PUSH_URL) {
try {
const r = await sendToUniPush(targets, notification, payload)
if (!r || (r.status && r.status >= 400)) { allOk = false; lastNote = JSON.stringify(r && r.body).substring(0, 1000) }
} catch (e) { allOk = false; lastNote = String(e) }
} else {
allOk = false
lastNote = 'no CLOUD_FUNC_URL and no UNI_PUSH_URL configured'
}
if (allOk) {
await updateNotificationStatus(claimed.id, 'success', null)
} else {
// failure -> increment retry_count, set last_error and next_attempt_at or mark failed
const currentRetry = parseInt(claimed.retry_count || 0, 10)
const nextRetry = currentRetry + 1
if (nextRetry >= MAX_RETRIES) {
// mark failed
try {
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: 'failed', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), updated_at: new Date().toISOString() }) })
} catch (e) { console.warn('mark failed error', e) }
} else {
// compute exponential backoff
let delay = RETRY_INITIAL_MS * Math.pow(RETRY_FACTOR, currentRetry)
if (delay > RETRY_MAX_MS) delay = RETRY_MAX_MS
const nextAt = new Date(Date.now() + delay).toISOString()
try {
await supaFetch(`express_notifications?id=eq.${encodeURIComponent(claimed.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_code: 'retrying', retry_count: nextRetry, last_error: String(lastNote).substring(0,2000), next_attempt_at: nextAt, updated_at: new Date().toISOString() }) })
} catch (e) { console.warn('schedule retry error', e) }
}
}
}
async function consumerOnce() {
try {
const pending = await fetchPendingNotifications(5)
if (!pending || pending.length === 0) return
for (const rec of pending) {
try { await processNotificationRecord(rec) } catch (e) { console.warn('consumer process error', e) }
}
} catch (e) {
console.warn('consumerOnce error', e)
}
}
if (CONSUMER_ENABLED) {
if (!SUPA_URL || !SUPA_KEY) console.warn('Consumer enabled but SUPA_URL/SUPA_KEY not configured; consumer will not run against Supabase.')
else console.log('Notification consumer enabled, poll interval(ms)=', CONSUMER_POLL_MS)
setInterval(consumerOnce, CONSUMER_POLL_MS)
}
// ---- end consumer ----
// 注册或更新设备
app.post('/api/v1/push/register', async (req, res) => {
const { cid, user_id, platform } = req.body || {}
@@ -516,6 +694,28 @@ async function start() {
console.log('ENV: UNI_PUSH_AUTH_URL=', process.env.UNI_PUSH_AUTH_URL)
console.log('ENV: UNI_PUSH_APPID=', process.env.UNI_PUSH_APPID)
console.log('ENV: PUSH_PROXY_URL=', process.env.PUSH_PROXY_URL)
// // 可选:在启动时自动运行部署脚本以打包并上传云函数
// try {
// const AUTO_DEPLOY = (process.env.AUTO_DEPLOY_ON_START === 'true' || process.env.AUTO_DEPLOY_ON_START === '1')
// const DEPLOY_BEARER = process.env.DEPLOY_BEARER || process.env.DEPLOY_TOKEN || process.env.ADMIN_DEPLOY_TOKEN || ''
// if (AUTO_DEPLOY) {
// if (!DEPLOY_BEARER) {
// console.warn('AUTO_DEPLOY_ON_START enabled but no DEPLOY_BEARER found; skipping auto deploy for safety.')
// } else {
// const argsEnv = process.env.AUTO_DEPLOY_ARGS ? process.env.AUTO_DEPLOY_ARGS.split(' ') : []
// const deployScript = path.join(__dirname, 'tools', 'deploy-cloudfunc.js')
// const nodeArgs = [deployScript].concat(argsEnv)
// console.log('Auto-deploy: spawning node', nodeArgs.join(' '))
// const child = spawn(process.execPath, nodeArgs, { cwd: __dirname, env: Object.assign({}, process.env), stdio: ['ignore', 'pipe', 'pipe'] })
// child.stdout.on('data', d => console.log('[auto-deploy stdout]', d.toString().trim()))
// child.stderr.on('data', d => console.error('[auto-deploy stderr]', d.toString().trim()))
// child.on('exit', (code, signal) => console.log(`Auto-deploy process exited with code=${code} signal=${signal}`))
// child.on('error', e => console.error('Auto-deploy spawn error', e))
// }
// }
// } catch (e) {
// console.warn('auto-deploy runtime error', e)
// }
})
}

32
server/routes/deploy.js Normal file
View File

@@ -0,0 +1,32 @@
const express = require('express');
const router = express.Router();
const { deployCloudFunction } = require('../tools/deploy-cloudfunc-service');
// POST /api/v1/deploy-cloudfunc
// body: { uploadUrl, uploadToken, deployApi?, deployToken?, funcInvokeUrl?, pushToken?, testCid? }
router.post('/deploy-cloudfunc', async (req, res) => {
try {
const body = req.body || {};
const options = {
uploadUrl: body.uploadUrl || process.env.CLOUD_UPLOAD_URL,
uploadToken: body.uploadToken || process.env.CLOUD_UPLOAD_TOKEN,
uniAppId: body.uniAppId || process.env.UNI_PUSH_APPID,
deployApi: body.deployApi || process.env.CLOUD_DEPLOY_API,
deployToken: body.deployToken || process.env.CLOUD_DEPLOY_TOKEN,
funcInvokeUrl: body.funcInvokeUrl || process.env.CLOUD_FUNC_URL,
pushToken: body.pushToken || process.env.PUSH_TOKEN,
testCid: body.testCid || process.env.TEST_DEVICE_CID,
deployPayload: body.deployPayload || {}
};
if (!options.uploadUrl) return res.status(400).json({ error: 'uploadUrl required (or set CLOUD_UPLOAD_URL env)' });
const result = await deployCloudFunction(options);
res.json({ ok: true, result });
} catch (e) {
console.error('deploy endpoint error', e);
res.status(500).json({ ok: false, error: e && e.message });
}
});
module.exports = router;

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();

View File

@@ -1,5 +1,37 @@
// 简单的使用示例
'use strict';
// 可配置的云函数 uni-push 示例:从 event 中读取参数并校验简单 token
const uniPush = uniCloud.getPushManager({ appId: process.env.UNI_PUSH_APPID || "__UNI__9462CA7" });
exports.main = async (event, context) => {
console.log('test run event:', event);
return { ok:true, now: Date.now(), event };
// event: { token, push_clientid, title, content, payload }
const { token, push_clientid, title = '', content = '', payload = {} } = event || {};
// 简单鉴权,建议把真实 secret 存到云函数环境变量 PUSH_TOKEN
const SECRET = process.env.PUSH_TOKEN;
if (SECRET && token !== SECRET) {
console.warn('unauthorized token', { hasToken: !!token });
return { errCode: 401, errMsg: 'unauthorized' };
}
if (!push_clientid) {
return { errCode: 400, errMsg: 'push_clientid required' };
}
try {
console.log('sending uni-push', { push_clientid, title, content, payload });
const res = await uniPush.sendMessage({
push_clientid,
force_notification: true,
title,
content,
settings: { ttl: 86400000 },
payload
});
console.log('uni-push response', res);
return { errCode: 0, errMsg: 'success', data: res };
} catch (e) {
console.error('uni-push send error', e && (e.stack || e.message || e));
return { errCode: 500, errMsg: e && e.message ? e.message : 'push error', detail: e };
}
};

View File

@@ -0,0 +1,4 @@
// 本文件中的json内容将在云函数【运行】时作为参数传给云函数。
// 配置教程参考https://uniapp.dcloud.net.cn/uniCloud/rundebug.html#runparam
{
}