APP版本管理全链路(后端设计)

144 阅读4分钟

APP版本管理全链路 — 从零到一实现若依框架下的APP更新系统


一、背景与需求

在若依(RuoYi)框架下,为APP实现版本管理功能,包括:

  • APP端:自动检查更新,提示用户下载新版本
  • Web管理端:超管在后台管理版本信息、上传APK

二、方案选型:数据库配置 vs 硬编码

方案优点缺点
硬编码简单每次更新需改代码、重启服务
数据库配置 ✅动态管理,无需重启需要建表、写CRUD

选择数据库方案,建表适配 PostgreSQL:

SQL

CREATE TABLE sys_app_version (
    id            BIGSERIAL PRIMARY KEY,
    version_code  INT          NOT NULL,
    version_name  VARCHAR(50)  NOT NULL,
    apk_url       VARCHAR(500),
    wgt_url       VARCHAR(500),
    update_type   VARCHAR(20)  DEFAULT 'suggest',
    update_log    TEXT,
    create_time   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
);

三、踩坑记录

坑1:静态资源访问 401

上传APK到 static/app/ 后,浏览器访问返回401。

原因:Spring Security 拦截了 /static/** 路径。

修复:在 SecurityConfig 中放行:

Java

.antMatchers("/static/**").permitAll()

坑2:静态资源访问 404

放行后仍404。

原因:Spring Boot 默认不映射 /static/** 到 classpath:/static/。

修复:在 ResourcesConfig 中显式配置:

Java

registry.addResourceHandler("/static/**")
        .addResourceLocations("classpath:/static/");

坑3:返回的 apkUrl 中IP/端口不对

上传后返回的URL里IP是客户端IP而非服务器IP。

原因:误用了 request.getRemoteHost() / getRemotePort()。

修复:改用 request.getServerName() / getServerPort()。

坑4:补录旧版本导致返回错误版本

/api/app/version 用 ORDER BY id DESC 取最新版本,补录旧版本时id更大,导致返回旧版本。

修复:改为 ORDER BY version_code DESC。

XML

<select id="selectLatestVersion" resultMap="SysAppVersionResult">
    <include refid="selectAppVersionVo"/>
    order by version_code desc limit 1
</select>

坑5:apk-parser 依赖 NoClassDefFoundError

apk-parser 加在 ruoyi-common 模块,但运行时找不到类,报 NoClassDefFoundError: net/dongliu/apk/parser/ApkFile。

原因:依赖只在 ruoyi-common 声明,但运行时打包在 ruoyi-admin 模块,依赖未传递进最终JAR。

修复:在 ruoyi-admin/pom.xml 也显式声明依赖,确保打包进最终JAR:

XML

<!-- APK文件解析 -->
<dependency>
    <groupId>net.dongliu</groupId>
    <artifactId>apk-parser</artifactId>
    <version>2.6.10</version>
</dependency>

四、方案二:服务端自动解析APK真实versionCode

问题

前端手动填写 versionCode 容易出错,比如填101实际却是102。

解决

引入 net.dongliu:apk-parser:2.6.10,上传APK时自动解析真实版本信息。

① 添加Maven依赖(ruoyi-admin/pom.xml):

XML

<!-- APK文件解析 -->
<dependency>
    <groupId>net.dongliu</groupId>
    <artifactId>apk-parser</artifactId>
    <version>2.6.10</version>
</dependency>

如果工具类放在 ruoyi-common 模块,ruoyi-common/pom.xml 也需要一份,确保编译通过。

② 工具类 ApkInfoUtil.java:

Java

package com.ruoyi.common.utils;

import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;

public class ApkInfoUtil {
    private static final Logger log = LoggerFactory.getLogger(ApkInfoUtil.class);

    public static ApkVersionInfo extractVersion(String apkFilePath) throws IOException {
        try (ApkFile apkFile = new ApkFile(new File(apkFilePath))) {
            ApkMeta meta = apkFile.getApkMeta();
            ApkVersionInfo info = new ApkVersionInfo();
            info.setVersionCode(meta.getVersionCode().intValue());
            info.setVersionName(meta.getVersionName());
            return info;
        } catch (Exception e) {
            log.error("APK文件解析失败: {}", apkFilePath, e);
            throw new IOException("APK文件解析失败: " + e.getMessage(), e);
        }
    }

    public static class ApkVersionInfo {
        private int versionCode;
        private String versionName;
        // getter / setter 省略...
    }
}

③ 上传接口 保存文件后立即解析,返回真实版本信息:

JSON

{
  "code": 200,
  "msg": "上传成功",
  "apkUrl": "/static/app/2026/07/29/patrolApp-1.0.2.apk",
  "versionCode": 102,
  "versionName": "1.0.2"
}

④ 新增/编辑接口 兜底覆盖——即使前端传错,也以APK解析值为准:

Java

private void fillVersionFromApk(SysAppVersion appVersion) {
    if (StringUtils.isEmpty(appVersion.getApkUrl())) return;
    String absolutePath = System.getProperty("user.dir")
        + "/ruoyi-admin/src/main/resources" + appVersion.getApkUrl();
    ApkInfoUtil.ApkVersionInfo info = ApkInfoUtil.extractVersion(absolutePath);
    appVersion.setVersionCode(info.getVersionCode());
    appVersion.setVersionName(info.getVersionName());
}

流程图

Plain Text

前端上传 APK
    │
    ▼
POST /upload
    │ ① 保存至 static/app/
    │ ② apk-parser 解析真实 versionCode / versionName
    │ ③ 返回 apkUrl + versionCode + versionName
    ▼
前端拿到 versionCode(填入表单只读展示)
    │
    ▼
POST /appVersion(新增)/ PUT /appVersion(编辑)
    │ fillVersionFromApk() 再次解析 APK 兜底
    │ 确保数据库存的永远 = APK 真实值
    ▼
数据库: versionCode = 102 ✓

五、前端防御方案:同版本不反复弹窗

问题:用户点了"暂不更新"后,每次打开APP都弹窗。

方案:localStorage 记录"已提示过的 versionCode",同版本跳过。

JavaScript

_shouldPrompt(remote, isManual) {
    const key = 'APP_LAST_PROMPTED_VERSION_CODE';
    const lastPrompted = uni.getStorageSync(key);
    const localVC = plus.runtime.versionCode;

    if (remote.versionCode <= localVC) return false; // 无需更新

    if (remote.versionCode === lastPrompted) {
        if (isManual) uni.showToast({ title: '当前已是最新版本' });
        return false; // 已提示过,跳过
    }

    uni.setStorageSync(key, remote.versionCode); // 记录本次
    return true;
}

效果:

时间线本地VC服务端VClastPrompted结果
T1 自动检查100101无弹窗 ✅
T2 用户安装(实际VC=100)100101101—
T3 下次自动检查100101101101==101 → 跳过 ✅
T4 真正新版本100102101102≠101 → 弹窗 ✅

六、最终架构

Plain Text

┌──────────────────────────────────────────────────────┐
│  Web管理端(超管)                                      │
│                                                        │
│  上传APK → apk-parser解析versionCode/versionName        │
│  → 写入数据库 sys_app_version                           │
│  列表/新增/编辑/删除 → CRUD                              │
└──────────────────────┬─────────────────────────────────┘
                       │
                  sys_app_version 表
                       │
┌──────────────────────┴─────────────────────────────────┐
│  APP端                                                 │
│                                                        │
│  GET /api/app/version → ORDER BY version_code DESC     │
│  → 对比本地 versionCode                                 │
│  → localStorage 防重复弹窗                              │
│  → 下载 APK 覆盖安装                                    │
└──────────────────────────────────────────────────────┘

七、关键设计决策

  1. APK存 static/app/  而非 profile/upload/,利用静态资源映射直接提供下载,无需额外接口
  2. versionCode 以服务端解析为准,不信任前端传值,保证数据一致性
  3. ORDER BY version_code DESC 而非 id DESC,防止补录旧版本干扰
  4. 前端 localStorage 防重复弹窗,同一版本只在首次提示,用户体验友好
  5. apk-parser 依赖需在 ruoyi-admin 模块显式声明,否则运行时打包不进去,报 NoClassDefFoundError