摘要
远程升级服务如果只靠手改 update-config.json,很快就会变得不好维护。尤其是有多个 App、多个渠道、多个历史版本时,手改 JSON 很容易写错。
所以服务端配了一个轻量 Web 后台:
text
/admin
后台支持登录、版本列表、新增应用、编辑版本、删除版本、下载 APK,并且会按应用分组展示。
适用场景
- 一个升级服务要维护多个 Android App。
- 每个 App 有不同
applicationId或渠道。 - 运维或测试同事需要通过浏览器上传 APK。
- 希望自动识别当前最高
versionCode,不用手动改latest。
本文效果
完成后后台能看到:
- 应用页签。
- 每个应用的版本列表。
- 当前生效版本标识。
- APK 上传与保存。
- 自动识别 APK 的版本名、版本号、应用 ID。
背景
服务端判断更新时,真正依赖的是 update-config.json:
text
versions[] -> 按 appIds 过滤 -> 取最高 versionCode -> 返回给 App
如果每次都手动维护 JSON,容易出现这些问题:
versionCode写错。- APK 文件名和
fileUrl不一致。 - 多个 App 混在一个列表里,不知道哪个版本生效。
- 删除版本时忘记删除 APK。
后台页面的价值就是把这些操作变成可视化表单。
后台架构图
#mermaid-svg-sYng5yeNfYHcSIZX{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-sYng5yeNfYHcSIZX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sYng5yeNfYHcSIZX .error-icon{fill:#552222;}#mermaid-svg-sYng5yeNfYHcSIZX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sYng5yeNfYHcSIZX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sYng5yeNfYHcSIZX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sYng5yeNfYHcSIZX .marker.cross{stroke:#333333;}#mermaid-svg-sYng5yeNfYHcSIZX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sYng5yeNfYHcSIZX p{margin:0;}#mermaid-svg-sYng5yeNfYHcSIZX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sYng5yeNfYHcSIZX .cluster-label text{fill:#333;}#mermaid-svg-sYng5yeNfYHcSIZX .cluster-label span{color:#333;}#mermaid-svg-sYng5yeNfYHcSIZX .cluster-label span p{background-color:transparent;}#mermaid-svg-sYng5yeNfYHcSIZX .label text,#mermaid-svg-sYng5yeNfYHcSIZX span{fill:#333;color:#333;}#mermaid-svg-sYng5yeNfYHcSIZX .node rect,#mermaid-svg-sYng5yeNfYHcSIZX .node circle,#mermaid-svg-sYng5yeNfYHcSIZX .node ellipse,#mermaid-svg-sYng5yeNfYHcSIZX .node polygon,#mermaid-svg-sYng5yeNfYHcSIZX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sYng5yeNfYHcSIZX .rough-node .label text,#mermaid-svg-sYng5yeNfYHcSIZX .node .label text,#mermaid-svg-sYng5yeNfYHcSIZX .image-shape .label,#mermaid-svg-sYng5yeNfYHcSIZX .icon-shape .label{text-anchor:middle;}#mermaid-svg-sYng5yeNfYHcSIZX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sYng5yeNfYHcSIZX .rough-node .label,#mermaid-svg-sYng5yeNfYHcSIZX .node .label,#mermaid-svg-sYng5yeNfYHcSIZX .image-shape .label,#mermaid-svg-sYng5yeNfYHcSIZX .icon-shape .label{text-align:center;}#mermaid-svg-sYng5yeNfYHcSIZX .node.clickable{cursor:pointer;}#mermaid-svg-sYng5yeNfYHcSIZX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sYng5yeNfYHcSIZX .arrowheadPath{fill:#333333;}#mermaid-svg-sYng5yeNfYHcSIZX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sYng5yeNfYHcSIZX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sYng5yeNfYHcSIZX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sYng5yeNfYHcSIZX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sYng5yeNfYHcSIZX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sYng5yeNfYHcSIZX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sYng5yeNfYHcSIZX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sYng5yeNfYHcSIZX .cluster text{fill:#333;}#mermaid-svg-sYng5yeNfYHcSIZX .cluster span{color:#333;}#mermaid-svg-sYng5yeNfYHcSIZX 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-sYng5yeNfYHcSIZX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sYng5yeNfYHcSIZX rect.text{fill:none;stroke-width:0;}#mermaid-svg-sYng5yeNfYHcSIZX .icon-shape,#mermaid-svg-sYng5yeNfYHcSIZX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sYng5yeNfYHcSIZX .icon-shape p,#mermaid-svg-sYng5yeNfYHcSIZX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sYng5yeNfYHcSIZX .icon-shape .label rect,#mermaid-svg-sYng5yeNfYHcSIZX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sYng5yeNfYHcSIZX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sYng5yeNfYHcSIZX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sYng5yeNfYHcSIZX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /admin/api/login
GET /admin/api/versions
POST /admin/api/apk/inspect
POST /admin/api/versions
PUT /admin/api/versions/:id
DELETE /admin/api/versions/:id
浏览器 /admin
静态页面 public/admin
登录接口
版本列表接口
APK 信息识别
新增版本
编辑版本
删除版本
update-config.json
public/updates/*.apk
1. 登录接口
后台登录没有接数据库,而是通过环境变量 ADMIN_PASSWORD 验证。登录成功后写入一个带签名的 cookie。
核心逻辑:
js
function signSession(expiresAt) {
return crypto
.createHmac("sha256", adminPassword)
.update(`admin:${expiresAt}`)
.digest("hex");
}
function makeSessionToken() {
const expiresAt = Date.now() + sessionMaxAgeMs;
return `admin:${expiresAt}:${signSession(expiresAt)}`;
}
function isValidSession(token) {
if (!adminPassword || !token) {
return false;
}
const parts = String(token).split(":");
if (parts.length !== 3 || parts[0] !== "admin") {
return false;
}
const expiresAt = Number(parts[1]);
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) {
return false;
}
const expected = signSession(expiresAt);
const actual = parts[2];
if (expected.length !== actual.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
}
登录接口:
js
app.post("/admin/api/login", (req, res) => {
if (!adminPassword) {
res.status(503).json({ ok: false, msg: "ADMIN_PASSWORD is not configured" });
return;
}
if (req.body.password !== adminPassword) {
res.status(401).json({ ok: false, msg: "invalid password" });
return;
}
res.cookie(sessionCookieName, makeSessionToken(), {
httpOnly: true,
sameSite: "lax",
maxAge: sessionMaxAgeMs
});
res.json({ ok: true });
});
所有后台 API 都经过 requireAdmin:
js
function requireAdmin(req, res, next) {
if (!adminPassword) {
res.status(503).json({ ok: false, msg: "ADMIN_PASSWORD is not configured" });
return;
}
if (!isValidSession(req.cookies[sessionCookieName])) {
res.status(401).json({ ok: false, msg: "login required" });
return;
}
next();
}
2. 版本分组:多应用页签
后台不是直接把所有版本平铺出来,而是先按应用名分组。
核心代码:
js
function buildAppViews(versions) {
const groups = new Map();
for (const version of versions) {
const appName = version.appName || version.appIds[0] || "Unnamed";
const key = appNameKey(version);
const group = groups.get(key) || { appName, appIds: new Set(), versions: [] };
for (const appId of version.appIds) {
group.appIds.add(appId);
}
group.versions.push(version);
groups.set(key, group);
}
const targetsByVersionId = new Map();
const apps = [...groups.values()].map((group) => {
const sorted = sortedVersions(group.versions);
const latest = selectLatest(sorted);
const appIds = [...group.appIds].sort();
if (latest) {
targetsByVersionId.set(latest.id, [{
appName: group.appName,
appIds
}]);
}
const decoratedVersions = sorted.map((version) => ({
...version,
latestTargets: latest && version.id === latest.id
? [{ appName: group.appName, appIds }]
: [],
isLatest: Boolean(latest && version.id === latest.id)
}));
return {
appName: group.appName,
appIds,
latest: latest
? {
versionId: latest.id,
versionName: latest.versionName,
versionCode: latest.versionCode,
fileName: latest.fileName
}
: null,
versions: decoratedVersions
};
});
apps.sort((a, b) => a.appName.localeCompare(b.appName));
const latestGroups = apps
.filter((appView) => appView.latest)
.map((appView) => ({
appName: appView.appName,
appIds: appView.appIds,
...appView.latest
}));
return { apps, latestGroups, targetsByVersionId };
}
这里有一个实战小细节:后台展示和客户端升级判断是两件事。
- 后台展示:按应用名分组,方便人看。
- 升级判断:按
appIds匹配,方便机器判断。
最终当前生效版本仍然是每组里 versionCode 最大且启用的版本。
3. 版本列表接口
后台加载版本时调用:
js
app.get("/admin/api/versions", requireAdmin, (req, res) => {
const config = readConfig();
const appViews = buildAppViews(config.versions);
res.json({
ok: true,
latest: config.latest,
apps: appViews.apps,
latestGroups: appViews.latestGroups,
lastAdded: config.versions.length > 0
? config.versions[config.versions.length - 1]
: null,
versions: sortedVersions(config.versions).map((version) => ({
...version,
latestTargets: appViews.targetsByVersionId.get(version.id) || [],
isLatest: (appViews.targetsByVersionId.get(version.id) || []).length > 0
}))
});
});
前端加载:
js
async function loadVersions() {
try {
const data = await api("/admin/api/versions");
state.versions = data.versions || [];
state.apps = data.apps || [];
state.latest = data.latest || null;
state.latestGroups = data.latestGroups || [];
renderAppTabs();
renderVersions();
} catch (err) {
showLogin(err.message);
}
}
4. 前端渲染版本列表
列表里会展示应用 ID、版本名、版本号、渠道、是否当前生效、是否强制更新。
核心代码裁剪如下:
js
function renderVersions() {
const selectedApp = activeApp();
const visibleVersions = selectedApp ? selectedApp.versions : [];
els.versionCount.textContent = String(visibleVersions.length);
els.versionList.innerHTML = "";
for (const version of visibleVersions) {
const row = document.createElement("article");
row.className = "version-row";
const latestTargets = version.latestTargets || [];
const latestTargetText = latestTargets.length > 0
? latestTargets.map((target) => (target.appIds || [target.appId]).join(", ")).join(", ")
: "-";
row.innerHTML = `
<div class="version-main">
<p class="meta-line"><span class="label">应用ID:</span>${escapeHtml((version.appIds || []).join(","))}</p>
<p class="meta-line"><span class="label">应用:</span>${escapeHtml(version.appName)}${version.isLatest ? '<span class="badge">当前生效</span>' : ""}</p>
<p class="meta-line"><span class="label">版本:</span>${escapeHtml(version.versionName)}</p>
<p class="meta-line"><span class="label">版本号:</span>${version.versionCode}</p>
<p class="meta-line"><span class="label">生效目标:</span>${escapeHtml(latestTargetText)}</p>
</div>
<div class="version-actions">
<button data-action="edit" data-id="${version.id}" type="button">编辑</button>
<button data-action="delete" data-id="${version.id}" type="button">删除</button>
<button data-action="download" data-id="${version.id}" type="button">直接下载</button>
</div>
`;
els.versionList.appendChild(row);
}
}
实际页面里样式更多一些,但核心就是根据接口返回的 apps 和 versions 渲染。
5. 新增版本:上传 APK 并写入配置
新增版本时,前端用 FormData 把表单字段和 APK 一起传给服务端。
服务端逻辑:
js
app.post("/admin/api/versions", requireAdmin, upload.single("apk"), (req, res) => {
try {
let finalFileName = "";
if (req.file) {
finalFileName = uniqueApkName(req.body.appName, req.body.versionName);
fs.renameSync(req.file.path, safeUpdatePath(finalFileName));
}
const sizeKB = finalFileName
? Math.max(1, Math.ceil(fs.statSync(safeUpdatePath(finalFileName)).size / 1024))
: 0;
const newVersion = buildVersionFromBody(req.body, finalFileName, sizeKB);
const error = validateVersionInput(newVersion, true);
if (error) {
res.status(400).json({ ok: false, msg: error });
return;
}
const config = readConfig();
config.versions.push(newVersion);
writeConfig(config.versions);
res.json({ ok: true, version: newVersion, latest: readConfig().latest });
} catch (err) {
res.status(500).json({ ok: false, msg: err.message });
}
});
buildVersionFromBody() 会统一规范字段:
js
function buildVersionFromBody(body, fileName, sizeKB, existing = {}) {
const now = new Date().toISOString();
return normalizeVersion({
...existing,
id: existing.id || makeVersionId(body.versionName, body.versionCode),
appName: body.appName,
appIds: normalizeArray(body.appIds),
flavors: normalizeArray(body.flavors),
versionCode: Number(body.versionCode || 0),
versionName: body.versionName,
fileName: fileName || existing.fileName,
fileUrl: fileName ? `/updates/${fileName}` : existing.fileUrl,
sizeKB: sizeKB || existing.sizeKB || 0,
force: body.force === true || body.force === "true" || body.force === "on",
description: body.description,
descriptionEn: body.descriptionEn,
createdAt: existing.createdAt || now,
updatedAt: now,
enabled: body.enabled === undefined
? existing.enabled !== false
: body.enabled === true || body.enabled === "true" || body.enabled === "on"
});
}
6. 编辑和删除
编辑时只改版本元数据,不要求重新上传 APK:
js
app.put("/admin/api/versions/:id", requireAdmin, (req, res) => {
const config = readConfig();
const index = config.versions.findIndex((version) => version.id === req.params.id);
if (index < 0) {
res.status(404).json({ ok: false, msg: "version not found" });
return;
}
const updated = buildVersionFromBody(req.body, "", 0, config.versions[index]);
const error = validateVersionInput(updated, false);
if (error) {
res.status(400).json({ ok: false, msg: error });
return;
}
config.versions[index] = updated;
writeConfig(config.versions);
res.json({ ok: true, version: updated, latest: readConfig().latest });
});
删除时同时删除对应 APK:
js
app.delete("/admin/api/versions/:id", requireAdmin, (req, res) => {
const config = readConfig();
const target = config.versions.find((version) => version.id === req.params.id);
if (!target) {
res.status(404).json({ ok: false, msg: "version not found" });
return;
}
const remaining = config.versions.filter((version) => version.id !== req.params.id);
if (target.fileName) {
const apkPath = safeUpdatePath(target.fileName);
if (fs.existsSync(apkPath)) {
fs.rmSync(apkPath, { force: true });
}
}
writeConfig(remaining);
res.json({ ok: true, latest: readConfig().latest });
});
7. 前端保存流程
前端提交时区分编辑和新增:
js
els.versionForm.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = formPayload();
try {
if (state.editing) {
await api(`/admin/api/versions/${state.editing.id}`, {
method: "PUT",
body: JSON.stringify(payload)
});
} else {
const data = new FormData();
Object.entries(payload).forEach(([key, value]) => data.append(key, value));
data.append("apk", document.querySelector("#apk").files[0]);
await xhrWithUploadProgress("/admin/api/versions", data);
}
await loadVersions();
closeModal();
} catch (err) {
els.formMsg.textContent = err.message;
}
});
上传大 APK 时用 XMLHttpRequest 是为了拿到上传进度,比普通 fetch() 更方便。
本地验证
启动服务:
powershell
cd D:\your_workspace\server
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://localhost:8080"
npm start
浏览器打开:
text
http://localhost:8080/admin
重点验证:
- 能正常登录。
- 应用页签数量正确。
- 版本列表按应用分组。
- 新增版本必须选择 APK。
- 上传 APK 后能自动保存到
public/updates。 - 当前生效版本是启用状态下最高
versionCode。 - 删除版本后对应 APK 也被删除。
服务器验证
部署到 ECS 后打开:
text
http://203.0.113.10:8080/admin
如果走 Nginx:
text
http://203.0.113.10/admin
上传 APK 后再调用升级接口:
powershell
Invoke-RestMethod -Method Post `
-Uri http://203.0.113.10:8080/fra/check/update `
-ContentType 'application/x-www-form-urlencoded' `
-Body '&app_ids=com.example.gcs&app_code=10307&app_flavor=release'
要确认返回的 fileUrl 是公网地址,不是本机地址。
常见问题
1. 后台登录提示 ADMIN_PASSWORD is not configured
说明 systemd 环境变量没有读到。检查:
bash
cat /opt/gcs-update-server/server/deploy/guardsky-upgrade.env
systemctl status guardsky-upgrade
journalctl -u guardsky-upgrade -n 100
2. 上传 APK 失败
优先检查:
public/updates目录是否存在。- Node 进程是否有写权限。
- Nginx 是否配置
client_max_body_size。 - 浏览器控制台或服务端日志里的错误信息。
3. 当前生效版本不符合预期
判断规则是:同一个应用分组里,启用状态下 versionCode 最大的版本生效。
如果没有生效,检查:
enabled是否为 true。versionCode是否真的更大。- 应用 ID 是否和旧版本混在了错误分组里。
小结
后台管理页面解决的是"版本维护成本"问题。对远程升级来说,真正重要的不是页面多华丽,而是这些动作可靠:
text
上传 APK -> 识别版本 -> 写入配置 -> 选择最高版本 -> App 能下载
下一篇单独拆 APK 自动识别,因为这里面有一个很容易踩的点:APK 里的 AndroidManifest.xml 不是普通文本 XML,而是 Android 二进制 XML。