Codex实战:APP远程升级服务搭建(三)后台管理页面(APK 上传、版本管理、多应用页签)

摘要

远程升级服务如果只靠手改 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);
  }
}

实际页面里样式更多一些,但核心就是根据接口返回的 appsversions 渲染。

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。

相关推荐
caimouse2 小时前
Reactos 第 9 章 设备驱动 — 9.5 一组PnP设备驱动模块的实例
网络·windows
❀搜不到2 小时前
远程服务器codex使用本地cc-switch的deepseek api
运维·服务器
阿狸猿2 小时前
论 NoSQL 数据库技术及其应用
数据库·nosql
FBI HackerHarry浩2 小时前
DataGrip2023.2.3默认保存的数据库和.sql文件在哪里?怎么修改默认路径?
数据库
袁小皮皮不皮2 小时前
3.HCIP OSPF补充知识(优化版)
服务器·网络·数据库·网络协议·智能路由器
运筹vivo@2 小时前
Python ContextVar 底层机制与内存模型拆解
前端·数据库·python
志栋智能3 小时前
超自动化巡检:知识沉淀与团队协作的新载体
大数据·运维·网络·数据库·人工智能·自动化
syt_biancheng3 小时前
Redis初识
数据库·redis·缓存
酣大智3 小时前
策略路由PBR--企业双出口实验
网络·智能路由器·策略路由·pbr