APP 更新管理系统 开发者接入文档

APP 版本更新接入说明

客户端只需持有分类 ID,调用版本检测接口即可获取最新版本、更新内容、安装包地址与 MD5; 下载完成后校验 MD5,即可确保安装包完整。全部接口为公开接口,无需登录、无需签名。

公开接口(免登录) 按分类 ID 检测 自动取最高版本 MD5 完整性校验 支持断点续传

AI用 AI 一键接入(推荐)

适用于任意 AI Agent 与编辑器(Claude Code / Cursor / Trae / Codex / Copilot / Windsurf / Cline 等): 复制下面的提示词发给它,它会先阅读官方文档,再按文档改造你的客户端或发布新版本。

1) AI 接入提示词(客户端改造)

复制后发给 AI

          

2) AI 发布提示词(上传新版安装包)

复制后发给 AI

          

3) 文档地址(任意 AI 均可直接读取)

接入文档 {{ORIGIN}}/skills/app-update-integration/SKILL.md

app-update-integration:客户端接入技能。覆盖技术栈识别、版本号来源定位、检测接口封装、更新弹窗、带进度下载、MD5 校验、调起安装与三种场景自测。

发布文档 {{ORIGIN}}/skills/app-publish/SKILL.md

app-publish:安装包发布技能。覆盖分类匹配、登录或 API Key 认证、multipart 上传与 MD5 比对,用于给客户端造一个「有更新」的版本。

提示词中已包含服务端地址 / 与分类 ID 占位符, 复制后把 <分类 ID>、<当前版本> 换成真实值即可。

1接入概览

整个更新链路只依赖 3 个接口,服务端地址即本站地址:/。

接口 方法 鉴权 用途
/api/apps/check-update GET 免登录 按分类 ID + 当前版本,检测是否有新版本,并返回安装包地址与 MD5
/api/apps/:id/download GET 免登录 下载指定安装包,自动累计下载次数并记录下载日志
/api/public/categories GET 免登录 获取完整分类树,用于查分类 ID(一般只在开发调试时使用)
分类 ID 就是「应用身份」。 每个 APP 在管理后台建一棵分类(如 买菜APP / android端), 该分类的 ID 即客户端要内置的 categoryId;同一分类下发布的新版本会被自动识别为更新。 分类 ID 可在管理后台「项目管理」的分类树中直接看到,也可通过分类树接口查询。

2快速开始

  1. 拿到分类 ID:在管理后台「项目管理」查看分类树中标注的 ID(如 4)。
  2. 内置两个参数:把分类 ID 与当前 APP 版本号(如 1.2.0)写进客户端配置。
  3. 启动时检测:请求 /api/apps/check-update?categoryId=4&version=1.2.0。
  4. 有新版本就下载:使用返回的 app.downloadUrl 下载,下载后比对 app.md5。
  5. 校验通过再安装:MD5 一致则调起安装,不一致则删除文件并提示重试。

获取分类 ID

方式一:直接在管理后台「项目管理 → 分类树」查看每个分类名称后的 #ID 标识。

方式二:调用分类树接口,返回结构为 id / name / children:

curl
curl "{{ORIGIN}}/api/public/categories"
响应示例(节选)
{
  "code": 0,
  "message": "ok",
  "data": [
    {
      "id": 1,
      "name": "买菜APP",
      "parentId": 0,
      "children": [
        { "id": 4, "name": "android端", "parentId": 1, "children": [] },
        { "id": 5, "name": "ios端", "parentId": 1, "children": [] }
      ]
    }
  ]
}
检测接口按分类及其所有子孙分类一起匹配:例如传入父分类 1(买菜APP), 其下各端发布的新版本都会被检测到,并返回其中版本号最高的那一个。 客户端建议直接内置自己所属的最末级分类 ID,避免多端安装包相互串版本。

3检测新版本

这是更新的核心接口,客户端启动或定时调用,服务端比对版本号后返回结果。

接口地址 {{ORIGIN}}/api/apps/check-update?categoryId=4&version=1.2.0

请求参数(Query)

参数 类型 必填 说明
categoryId number 是 分类 ID,支持父分类(含所有子孙分类)
version string 是 客户端当前版本号,如 1.2.0;建议保持 x.y.z 格式

响应结构

字段 类型 说明
code number 0 表示成功,其他值见 message
data.hasUpdate boolean 是否存在比当前版本更高的版本
data.app object | null 无更新时为 null;有更新时为最新版本详情
app.id number APP 记录 ID,下载接口使用
app.name string 应用名称
app.version string 最新版本号
app.updateContent string 更新内容,可直接展示在更新弹窗中
app.fileName string 安装包原始文件名,可作为保存文件名
app.fileSize number 安装包字节数,用于展示体积与进度
app.md5 string 安装包 MD5,下载完成后必须比对
app.downloadUrl string 下载地址(相对路径),需拼接服务端地址使用
有更新时的响应
{
  "code": 0,
  "message": "ok",
  "data": {
    "hasUpdate": true,
    "app": {
      "id": 12,
      "name": "买菜助手",
      "version": "1.3.0",
      "updateContent": "1. 新增今日特价专区\n2. 优化下单流程",
      "fileName": "maicai-1.3.0.apk",
      "fileSize": 23801472,
      "md5": "ebf3e64a58553bb034a94caeec047ad5",
      "downloadUrl": "/api/apps/12/download"
    }
  }
}
无更新时的响应
{
  "code": 0,
  "message": "ok",
  "data": { "hasUpdate": false, "app": null }
}
参数缺失时的响应
{
  "code": 1000,
  "message": "缺少 categoryId 或 version 参数",
  "data": null
}

版本比较规则

  • 按 . 拆分主版本号,逐段按数字比较:1.10.0 > 1.9.9、2.0.0 > 1.99.99。
  • 不足三段自动补 0:1.2 等价于 1.2.0。
  • 支持预发布后缀:1.3.0-beta 小于 1.3.0;两个预发布版本按后缀字符串比较。
  • 只比较 - 之前的数字部分,+ 之后的构建号不参与比较。
  • 仅已上架(status=1)的记录参与检测,下架或未发布的版本不会被返回。

4下载安装包

把检测接口返回的 app.downloadUrl 拼在服务端地址后面即可下载, 无需任何请求头与鉴权参数。

下载地址 {{ORIGIN}}/api/apps/12/download
  • 响应为二进制文件流,Content-Disposition 中带有原始文件名。
  • 每次请求成功后,服务端会把该 APP 的下载次数 +1,并记录下载日志(IP、时间)。
  • 支持 HTTP Range 断点续传,大文件建议使用系统下载器或支持续传的 HTTP 客户端。
  • 记录不存在或文件已被清理时返回 404,message 为「APP 不存在」或「安装包文件不存在」。
下载接口按「下载完成即计数」,同一客户端重复下载会重复计数。 如果只想做一次静默检测,请只调用 check-update,不要调用下载接口。

5MD5 校验

下载到本地后计算文件 MD5,与 app.md5 比对,一致才允许安装。 这是防止安装包被截断或篡改的唯一手段,请勿省略。

命令行
# Windows
certutil -hashfile "D:\downloads\maicai-1.3.0.apk" MD5

# Linux / macOS
md5sum ./maicai-1.3.0.apk
Node.js(Electron / Windows 客户端)
const crypto = require('node:crypto');
const fs = require('node:fs');

function md5File(filePath) {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash('md5');
    fs.createReadStream(filePath)
      .on('data', (chunk) => hash.update(chunk))
      .on('end', () => resolve(hash.digest('hex')))
      .on('error', reject);
  });
}

// 用法
const ok = (await md5File('D:/downloads/maicai-1.3.0.apk')) === remoteMd5;

6完整更新流程

  1. 读取本地版本:从构建信息或配置中拿到当前版本号,如 1.2.0。
  2. 检测更新:请求 GET /api/apps/check-update?categoryId={内置ID}&version={本地版本}。
  3. 判断结果:hasUpdate=false 直接结束;为 true 则展示更新弹窗(版本号 + 更新内容)。
  4. 用户确认:展示 app.version、updateContent 与安装包体积(fileSize),由用户选择立即更新或稍后。
  5. 下载安装包:下载 {服务端地址}{app.downloadUrl},展示进度(已下载 / 总大小、速度)。
  6. 校验 MD5:计算本地文件 MD5 与 app.md5 比对;不一致则删除文件并提示重新下载。
  7. 安装并收尾:调起系统安装;Android 需 REQUEST_INSTALL_PACKAGES 权限与 FileProvider,Windows 可调用安装器或自更新程序。
不要在版本号里做「=」判断,也不要假设版本一定递增 1。 客户端只上报自己的版本,是否更新完全由服务端返回的 hasUpdate 决定, 这样跨版本(如 1.0.0 → 2.0.0)也能正确升级。

7接入示例

以下示例均把服务端地址替换为本站地址 /,可直接改用。

Android(Kotlin + OkHttp)

Kotlin
data class ApiResult<T>(val code: Int, val message: String?, val data: T?)
data class CheckUpdateData(val hasUpdate: Boolean, val app: UpdateApp?)
data class UpdateApp(
    val id: Long,
    val name: String,
    val version: String,
    val updateContent: String?,
    val fileName: String?,
    val fileSize: Long,
    val md5: String,
    val downloadUrl: String,
)

private val BASE_URL = "{{ORIGIN}}"
private val CATEGORY_ID = 4          // 管理后台分类树中的 ID
private val CURRENT_VERSION = "1.2.0" // 当前客户端版本

fun checkUpdate(onResult: (UpdateApp?) -> Unit) {
    val url = "$BASE_URL/api/apps/check-update?categoryId=$CATEGORY_ID&version=$CURRENT_VERSION"
    val request = Request.Builder().url(url).get().build()
    OkHttpClient().newCall(request).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) = onResult(null)
        override fun onResponse(call: Call, response: Response) {
            val body = response.body?.string() ?: return onResult(null)
            val json = JSONObject(body)
            if (json.optInt("code") != 0) return onResult(null)
            val data = json.getJSONObject("data")
            if (!data.optBoolean("hasUpdate")) return onResult(null)
            val app = data.getJSONObject("app")
            onResult(
                UpdateApp(
                    id = app.getLong("id"),
                    name = app.getString("name"),
                    version = app.getString("version"),
                    updateContent = app.optString("updateContent"),
                    fileName = app.optString("fileName"),
                    fileSize = app.getLong("fileSize"),
                    md5 = app.getString("md5"),
                    downloadUrl = app.getString("downloadUrl"),
                )
            )
        }
    })
}

// 下载地址 = BASE_URL + app.downloadUrl
// 下载完成后计算文件 MD5 与 app.md5 比对,一致再调起安装

iOS(Swift + URLSession)

Swift
struct ApiResult<T: Decodable>: Decodable {
    let code: Int
    let message: String?
    let data: T?
}

struct CheckUpdateData: Decodable {
    let hasUpdate: Bool
    let app: UpdateApp?
}

struct UpdateApp: Decodable {
    let id: Int
    let name: String
    let version: String
    let updateContent: String?
    let fileName: String?
    let fileSize: Int64
    let md5: String
    let downloadUrl: String
}

let baseURL = "{{ORIGIN}}"
let categoryId = 4
let currentVersion = "1.2.0"

func checkUpdate() async throws -> UpdateApp? {
    var components = URLComponents(string: "\(baseURL)/api/apps/check-update")!
    components.queryItems = [
        URLQueryItem(name: "categoryId", value: String(categoryId)),
        URLQueryItem(name: "version", value: currentVersion),
    ]
    let (data, _) = try await URLSession.shared.data(from: components.url!)
    let result = try JSONDecoder().decode(ApiResult<CheckUpdateData>.self, from: data)
    guard result.code == 0, let payload = result.data, payload.hasUpdate else { return nil }
    return payload.app
}

Flutter(Dart)

Dart
import 'dart:convert';
import 'package:http/http.dart' as http;

const baseUrl = '{{ORIGIN}}';
const categoryId = 4;
const currentVersion = '1.2.0';

Future<Map<String, dynamic>?> checkUpdate() async {
  final uri = Uri.parse('$baseUrl/api/apps/check-update')
      .replace(queryParameters: {
    'categoryId': '$categoryId',
    'version': currentVersion,
  });
  final res = await http.get(uri);
  if (res.statusCode != 200) return null;

  final body = jsonDecode(res.body);
  if (body['code'] != 0) return null;
  if (body['data']['hasUpdate'] != true) return null;
  return body['data']['app'] as Map<String, dynamic>;
  // app['downloadUrl'] 拼接 baseUrl 后下载,app['md5'] 用于校验
}

Electron / Windows 客户端(Node.js)

JavaScript
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');

const BASE_URL = '{{ORIGIN}}';
const CATEGORY_ID = 4;
const CURRENT_VERSION = '1.2.0';

async function checkUpdate() {
  const url = `${BASE_URL}/api/apps/check-update?categoryId=${CATEGORY_ID}&version=${CURRENT_VERSION}`;
  const res = await fetch(url);
  const body = await res.json();
  if (body.code !== 0 || !body.data.hasUpdate) return null;
  return body.data.app;
}

async function download(app, saveDir, onProgress) {
  const res = await fetch(BASE_URL + app.downloadUrl);
  if (!res.ok) throw new Error(`下载失败:${res.status}`);

  const total = Number(res.headers.get('content-length')) || app.fileSize;
  const filePath = path.join(saveDir, app.fileName || 'update.bin');
  const file = fs.createWriteStream(filePath);
  let received = 0;

  for await (const chunk of res.body) {
    received += chunk.length;
    file.write(chunk);
    onProgress?.({ received, total, percent: Math.round((received / total) * 100) });
  }
  await new Promise((resolve) => file.end(resolve));

  const localMd5 = crypto.createHash('md5')
    .update(fs.readFileSync(filePath))
    .digest('hex');
  if (localMd5 !== app.md5) {
    fs.unlinkSync(filePath);
    throw new Error('安装包 MD5 校验失败,请重新下载');
  }
  return filePath;
}

uni-app / H5(JavaScript)

JavaScript
const BASE_URL = '{{ORIGIN}}';
const CATEGORY_ID = 4;
const CURRENT_VERSION = '1.2.0';

// 检测更新
async function checkUpdate() {
  const url = `${BASE_URL}/api/apps/check-update?categoryId=${CATEGORY_ID}&version=${CURRENT_VERSION}`;
  const res = await fetch(url);
  const { code, data } = await res.json();
  if (code !== 0 || !data.hasUpdate) return null;
  return data.app;
}

// 下载并显示进度(浏览器 / H5)
async function downloadWithProgress(app, onProgress) {
  const res = await fetch(BASE_URL + app.downloadUrl);
  if (!res.ok) throw new Error('下载失败');

  const total = Number(res.headers.get('content-length')) || app.fileSize;
  const reader = res.body.getReader();
  const chunks = [];
  let received = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    received += value.length;
    onProgress?.({ received, total, percent: Math.round((received / total) * 100) });
  }
  return new Blob(chunks); // H5 可直接 saveBlob,原生端请使用下载插件
}

8常见问题

检测接口需要登录或 API Key 吗?

不需要。检测与下载都是公开接口,只需分类 ID 与版本号,可在客户端硬编码。

为什么 hasUpdate 一直是 false?

  • 分类 ID 不对:确认内置 ID 与发布时选择的分类一致,可在管理后台分类树核对。
  • 版本号没有变大:例如客户端写的是 1.3.0,而服务端最新也是 1.3.0。
  • 发布的是草稿或已下架:只有 status=1(已上架)的版本参与检测。
  • 客户端版本号格式异常,例如 v1.2.0 带着前缀字母,会被按数字解析为 0。

同一分类下有多个历史版本会怎样?

服务端会在所有比当前版本高的记录中取版本号最大的一个返回,历史版本不会被删除,仍可在下载中心看到。

下载次数是怎么统计的?

每次成功请求下载接口即 +1,并写入下载日志(APP ID、IP、时间)。管理后台「APP 管理」可查看累计次数。

可以在 APP 内直接展示下载中心页面吗?

可以,直接用 WebView / 内置浏览器打开 {{ORIGIN}}/,页面已适配移动端,支持分类浏览、搜索与进度下载。

服务端地址换了怎么办?

检测与下载都用相对路径返回(downloadUrl),客户端只需修改内置的 BASE_URL。建议域名保持稳定,或通过配置下发。