摘要
最近给 APP(QGC/地面站)做了一套远程升级服务,目标很明确:App 启动后请求服务器,服务器根据当前 versionCode 判断是否有新版本,如果有就返回 APK 下载地址、版本号和更新说明,App 端再弹窗下载并安装。
这篇先讲服务端主链路,也就是:
text
App -> /fra/check/update -> update-config.json -> /updates/*.apk
本文偏实战,不讲大而全的发布平台,只把一个可落地、可部署、可维护的小型升级服务跑起来。
最终的web:

适用场景
- QGC、地面站、Android 工具类 App 需要远程升级。
- 暂时不想接第三方应用市场或企业分发平台。
- 需要自己控制多应用、多渠道、强制更新、更新说明。
- 服务端希望轻量,Node.js + Express 即可部署到 Ubuntu 云服务器。
本文效果
完成后可以得到:
- 一个健康检查接口
/health。 - 一个兼容 App 的升级检查接口
/fra/check/update。 - 一个静态 APK 下载目录
/updates。 - 一个本地 JSON 版本库
update-config.json。 - 支持按应用 ID 和版本号筛选最新版本。
背景
地面站项目里,App 端升级逻辑通常已经有一部分:比如启动时请求接口、拿到 APK 地址、显示升级弹窗、下载完成后调 Android 安装界面。
真正容易卡住的是服务端:
- App 请求地址必须稳定,不能写成本机
localhost。 - 返回 JSON 必须和客户端字段匹配。
- 版本判断要用
versionCode,不能只看versionName。 - APK 地址要返回公网可访问的完整 URL。
- 后续要能通过后台维护多个 App 的版本。
所以我这里没有一上来搞复杂数据库,而是先用 update-config.json 做版本配置,Express 提供接口,APK 放到静态目录里。
系统框架图
#mermaid-svg-OecVkCRg5twZNTez{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OecVkCRg5twZNTez .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OecVkCRg5twZNTez .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OecVkCRg5twZNTez .error-icon{fill:#552222;}#mermaid-svg-OecVkCRg5twZNTez .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OecVkCRg5twZNTez .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez .marker.cross{stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OecVkCRg5twZNTez p{margin:0;}#mermaid-svg-OecVkCRg5twZNTez .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label text{fill:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label span{color:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label span p{background-color:transparent;}#mermaid-svg-OecVkCRg5twZNTez .label text,#mermaid-svg-OecVkCRg5twZNTez span{fill:#333;color:#333;}#mermaid-svg-OecVkCRg5twZNTez .node rect,#mermaid-svg-OecVkCRg5twZNTez .node circle,#mermaid-svg-OecVkCRg5twZNTez .node ellipse,#mermaid-svg-OecVkCRg5twZNTez .node polygon,#mermaid-svg-OecVkCRg5twZNTez .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .rough-node .label text,#mermaid-svg-OecVkCRg5twZNTez .node .label text,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label,#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label{text-anchor:middle;}#mermaid-svg-OecVkCRg5twZNTez .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .rough-node .label,#mermaid-svg-OecVkCRg5twZNTez .node .label,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label,#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label{text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .node.clickable{cursor:pointer;}#mermaid-svg-OecVkCRg5twZNTez .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez .arrowheadPath{fill:#333333;}#mermaid-svg-OecVkCRg5twZNTez .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OecVkCRg5twZNTez .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OecVkCRg5twZNTez .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .cluster text{fill:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster span{color:#333;}#mermaid-svg-OecVkCRg5twZNTez div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OecVkCRg5twZNTez .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OecVkCRg5twZNTez rect.text{fill:none;stroke-width:0;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape,#mermaid-svg-OecVkCRg5twZNTez .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape p,#mermaid-svg-OecVkCRg5twZNTez .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label rect,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OecVkCRg5twZNTez .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OecVkCRg5twZNTez :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /fra/check/update
有新版本
无新版本
Android 地面站 App
Node.js Express 升级服务
读取 update-config.json
匹配 appId / versionCode
返回 code=2000 + APK 地址
返回 code=2201
App 下载 /updates/*.apk
Android 安装界面
目录结构
服务端目录大概是这样:
text
server
├── server.js
├── package.json
├── update-config.json
├── public
│ ├── admin
│ └── updates
├── deploy
└── client-porting
其中本篇重点是:
server.js:升级接口和版本判断。update-config.json:保存版本配置。public/updates:保存 APK 文件。
package.json
依赖很少,核心就是 Express、cookie、multer:
json
{
"name": "gcs-update-server",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"express": "^4.19.2",
"multer": "^2.1.1"
},
"engines": {
"node": ">=18"
}
}
安装依赖:
powershell
npm install
启动:
powershell
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://203.0.113.10:8080"
npm start
这里的 203.0.113.10 是示例公网 IP,实际部署时换成自己的 ECS IP 或域名。
版本配置结构
update-config.json 是当前服务端的版本库。为了方便公开,这里做了脱敏示例:
json
{
"latest": {
"id": "b369425a32ee",
"appName": "GCS Demo",
"appIds": ["com.example.gcs"],
"flavors": ["release"],
"versionCode": 10308,
"versionName": "1.03.08",
"fileName": "GCS-Demo-1.03.08.apk",
"fileUrl": "/updates/GCS-Demo-1.03.08.apk",
"sizeKB": 211849,
"force": false,
"description": "修复已知问题,优化升级流程",
"descriptionEn": "",
"enabled": true
},
"versions": [
{
"id": "b369425a32ee",
"appName": "GCS Demo",
"appIds": ["com.example.gcs"],
"flavors": ["release"],
"versionCode": 10308,
"versionName": "1.03.08",
"fileName": "GCS-Demo-1.03.08.apk",
"fileUrl": "/updates/GCS-Demo-1.03.08.apk",
"sizeKB": 211849,
"force": false,
"description": "修复已知问题,优化升级流程",
"descriptionEn": "",
"enabled": true
}
]
}
几个字段比较关键:
appIds:客户端传上来的应用 ID,必须能匹配。versionCode:升级判断真正使用的数字版本号。fileUrl:APK 相对地址,服务端会转换成完整公网地址。enabled:是否参与升级判断。force:是否强制升级,客户端可以据此控制弹窗按钮。
核心实现:选择最新版本
服务端不是简单返回 latest,而是从版本列表里筛选启用的版本,并选择 versionCode 最大的版本。
js
function selectLatest(versions) {
const enabledVersions = versions.filter((version) => version.enabled !== false);
if (enabledVersions.length === 0) {
return null;
}
return enabledVersions.reduce((best, version) => {
if (!best || Number(version.versionCode) > Number(best.versionCode)) {
return version;
}
return best;
}, null);
}
function selectLatestForRequest(versions, form) {
return selectLatest(versions.filter((version) => (
version.enabled !== false
&& isAllowed(form.appIds, version.appIds)
)));
}
function isAllowed(value, allowedValues) {
return !Array.isArray(allowedValues)
|| allowedValues.length === 0
|| allowedValues.includes(value);
}
这里的判断比较朴素,但实际够用:
- 后台可以保留历史版本。
- 只有
enabled !== false的版本参与升级。 - 先按
appIds匹配应用,再从匹配结果里选最大versionCode。
核心实现:拼接公网 APK 地址
客户端拿到的 fileUrl 必须能直接访问。部署在 Nginx 或反向代理后,不能简单写死 localhost。
js
function absoluteUrl(req, maybeRelativeUrl) {
if (/^https?:\/\//i.test(maybeRelativeUrl)) {
return maybeRelativeUrl;
}
const relativePath = maybeRelativeUrl.startsWith("/")
? maybeRelativeUrl
: `/${maybeRelativeUrl}`;
if (publicBaseUrl) {
return `${publicBaseUrl}${relativePath}`;
}
const proto = req.headers["x-forwarded-proto"] || req.protocol || "http";
const host = req.headers["x-forwarded-host"] || req.headers.host;
return `${proto}://${host}${relativePath}`;
}
我更建议线上配置 PUBLIC_BASE_URL,比如:
bash
PUBLIC_BASE_URL=http://203.0.113.10:8080
这样无论请求从哪里进来,返回给 App 的 APK 地址都是稳定的公网地址。
核心实现:升级检查接口
App 端请求:
text
POST /fra/check/update
Content-Type: application/x-www-form-urlencoded
&app_ids=com.example.gcs&app_code=10307&app_flavor=release
服务端接口:
js
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.post(["/fra/check/update", "/fra-bate/check/update"], (req, res) => {
const form = parseForm(req);
const config = readConfig();
const latest = selectLatestForRequest(config.versions, form);
if (!latest) {
res.json({ code: 2201, msg: "no update for this app" });
return;
}
if (form.appCode >= Number(latest.versionCode)) {
res.json({ code: 2201, msg: "already latest" });
return;
}
const apkPath = path.join(updatesDir, latest.fileName);
let sizeKB = Number(latest.sizeKB || 0);
if (fs.existsSync(apkPath)) {
sizeKB = Math.max(1, Math.ceil(fs.statSync(apkPath).size / 1024));
}
res.json({
code: 2000,
msg: "new version available",
data: {
versionCode: Number(latest.versionCode),
versionName: latest.versionName,
fileName: latest.fileName,
fileUrl: absoluteUrl(req, latest.fileUrl),
sizeKB,
force: Boolean(latest.force),
description: latest.description || "",
descriptionEn: latest.descriptionEn || ""
}
});
});
注意这里兼容了两个路径:
text
/fra/check/update
/fra-bate/check/update
这是为了兼容历史客户端,实际新项目只保留一个也可以。
升级请求时序图
public/updates update-config.json Express Server Android App public/updates update-config.json Express Server Android App #mermaid-svg-a9BlfpSmw0EYO1WL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a9BlfpSmw0EYO1WL .error-icon{fill:#552222;}#mermaid-svg-a9BlfpSmw0EYO1WL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a9BlfpSmw0EYO1WL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a9BlfpSmw0EYO1WL .marker.cross{stroke:#333333;}#mermaid-svg-a9BlfpSmw0EYO1WL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a9BlfpSmw0EYO1WL p{margin:0;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a9BlfpSmw0EYO1WL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .sequenceNumber{fill:white;}#mermaid-svg-a9BlfpSmw0EYO1WL #sequencenumber{fill:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageText{fill:#333;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL .labelText,#mermaid-svg-a9BlfpSmw0EYO1WL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .loopText,#mermaid-svg-a9BlfpSmw0EYO1WL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a9BlfpSmw0EYO1WL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-a9BlfpSmw0EYO1WL .noteText,#mermaid-svg-a9BlfpSmw0EYO1WL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .actorPopupMenu{position:absolute;}#mermaid-svg-a9BlfpSmw0EYO1WL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-man circle,#mermaid-svg-a9BlfpSmw0EYO1WL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-a9BlfpSmw0EYO1WL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt有新版本已是最新 POST /fra/check/updatereadConfig()versions\[\]appId 匹配 + versionCode 比较code=2000, fileUrl, versionNameGET /updates/GCS-Demo.apkAPK 文件code=2201
本地验证
启动服务:
powershell
cd D:\your_workspace\server
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://localhost:8080"
npm start
健康检查:
powershell
Invoke-RestMethod http://localhost:8080/health
期望结果:
json
{
"ok": true
}
升级接口验证:
powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:8080/fra/check/update `
-ContentType 'application/x-www-form-urlencoded' `
-Body '&app_ids=com.example.gcs&app_code=10307&app_flavor=release'
如果服务器上配置的版本号是 10308,客户端传 10307,正常会返回:
json
{
"code": 2000,
"msg": "new version available",
"data": {
"versionCode": 10308,
"versionName": "1.03.08",
"fileName": "GCS-Demo-1.03.08.apk",
"fileUrl": "http://localhost:8080/updates/GCS-Demo-1.03.08.apk",
"sizeKB": 211849,
"force": false,
"description": "修复已知问题,优化升级流程",
"descriptionEn": ""
}
}
如果客户端传的 app_code 已经大于等于服务端版本,则返回:
json
{
"code": 2201,
"msg": "already latest"
}
常见问题
1. 为什么不用 versionName 判断?
versionName 是展示给用户看的字符串,比如 1.03.08。字符串比较容易出问题,比如 1.10.0 和 1.9.9。
真正用于升级判断的应该是 Android 的 versionCode,它是整数,只要新包比旧包大即可。
2. 为什么 App 下载地址不能返回 localhost?
因为 localhost 对手机来说是手机自己,不是你的电脑或服务器。
本地调试可以让手机访问电脑局域网 IP,正式发布必须使用公网 IP 或域名。
3. 为什么返回 code=2201?
常见原因:
- 客户端
app_ids和服务端appIds不一致。 - 客户端
app_code已经是最新或更高。 - 服务端版本
enabled=false。 - APK 版本配置没有写入
update-config.json。 - 请求地址不对,比如少了
/fra/前缀。
小结
这一篇先把远程升级服务的主链路跑通了。核心其实就三件事:
- App 把应用 ID 和当前
versionCode发给服务端。 - 服务端从版本列表里找到匹配应用的最高可用版本。
- 如果服务器版本更高,就返回
code=2000和 APK 公网下载地址。
下一篇继续写部署,把这个 Node 服务放到阿里云 ECS 的 Ubuntu 上,用 systemd 托管,并处理安全组、Nginx 转发这些线上必踩点。