13 套工作流后端共用一个 App 检查更新接口:0 张表,一行 SQL 没写

作者:mldong日期:2026/9/23

一个有 App 的团队,"发版本"和"让用户升上来"是两件事。前者你熟:改代码、打生产包、传到某个地方、发个链接让人重装。后者才是麻烦的:手机怎么知道有新版本?谁告诉它新版本叫什么、多大、更新了什么、去哪儿下?如果这套东西还要同时服务 13 套后端框架,那它就必须是一次设计、处处一致的,而不是每套框架各自发明一遍。

我们这套工作流的手机审批端,刚跑完一轮真实的自我升级;借这个由头把这块业务从头到尾讲清楚:一共九道,每道谁做、落在哪个技术栈上,以及为什么后端一格数据库都没建。

一、九道:四道在研发侧,五道在用户侧

研发侧(每次发版走一遍)

  1. 版本号双轨 bumpversionName 是给人看的(0.2.3),versionCode 是拿来比的(19,只增不减)。两个都写在 manifest.json 里,一次改齐。
  2. 云证书云打包。HBuilderX 云打包出生产签名的 apk,24.6MB 一个文件。
  3. 传分发平台。上传时把 versionCode 作为平台的 buildVersionNo,更新说明也写在这一层——说明文字属于这一次发布,不属于代码仓库
  4. 发布态核验。回查平台:buildVersionNo=19isLastest=1。这一步做完,"世界上存在 19 这个版本"才成立。

用户侧(每台手机各走一遍)

  1. 两个入口。冷启动后延后一拍静默检查;「我的」页一个「检查更新」菜单项,「关于」弹窗里还有一次。静默的含义是:无更新和检查失败都不出声,只有真有新版本才弹窗。
  2. 后端问平台比大小。App 带着 platform 和本机 versionCode 来,后端去平台要"当前最新版",回来比个大小。免登录。
  3. 弹窗。标题里的版本号、正文的大小和更新说明,App 一个字都不写,全部来自后端字段。强制更新时不给"稍后再说"。
  4. 不遮手下载。底部一条进度浮层,用户可以继续用 App;下完先问一句"是否立即安装",不抢操作。
  5. 系统安装器。App 把包准备好、把安装器拉起来,剩下交给安卓。装完 versionCode 变 19,用户数据不丢。

九道里,1–4 是发版业务,5–9 是升级业务,中间靠"平台上有 19 这个版本"这一个事实对接。这就是不建表的前提:两边都指向同一个真相,就不需要有人手工抄一遍。

二、版本号为什么要双轨

versionNameversionCode 不是一个东西,混了会出事:

  • versionName 是展示用的字符串,0.2.100.2.9 谁新?字符串比不出来,得拆段。
  • versionCode 是整数,210 > 209,一次比较就完事。安卓的安装器也认它——只有 versionCode 更高,系统才认为这是"升级"而不是"新装",才会给出"现有的数据不会丢失"那句话。

对上分发平台时有个字段坑值得单独说:平台返回的 buildVersionNo 才是版本号口径(对应我们的 versionCode),而 buildBuildVersion上传计数——它也在涨,长得也像,但它不是版本号。我们的契约里 versionCode 全程走 buildVersionNo 这一路。

还有一条:平台还有个 buildHaveNewVersion 字段,看着正好是"有没有新版本"的意思,但不传比较参数时它恒为 false。所以我们只把它当元数据,比较自己做:latest <= 本机 就是没更新。

三、检查这件事,谁问谁、什么时候问

契约本身定得很少,越少越好传播:

1POST /app/appVersion/check        免登录
2请求  { "platform": 1, "versionCode": 18 }
3      platform: 1=Android 2=HarmonyOS 3=iOS
4响应  无更新  { "code": 0, "data": null, "msg": "成功" }
5      有更新  { "code": 0, "data": { "hasUpdate": true,
6                 "versionCode": 19, "versionName": "0.2.3",
7                 "downloadUrl": "...", "fileSize": 24609293,
8                 "fileSizeInfo": "23.5MB", "releaseNotes": "...",
9                 "forceUpdate": false }, "msg": "成功" }
10      参数不合法  HTTP 200 + code=99999999 + 具体 msg
11

三个业务决定值得说明白:

为什么免登录。 更新检查发生在启动路径上,用户可能还没登录(甚至不打算登录)。这个端点不能要求任何身份,也不能因为拿不到身份就失败。

为什么"无更新"是 data: null 而不是 hasUpdate: false 因为对 App 来说,"没有可用更新"和"这次问不出结果"应该走同一条代码分支——什么都不做。用一个空值表达"没有东西要给你",比造一个布尔字段再让 13 套实现各自决定怎么填它更省事。契约里额外钉了一条:这个键必须存在(字面 null),否则跨栈对拍时"字段缺失"和"值为 null"会被当成两件事。

为什么参数错误也是 HTTP 200。 整套框架的错误信封就是 code + msg,业务错误不占用 HTTP 状态码。检查更新的调用方只看 code,这样 13 套后端不需要各自维护一套 HTTP 语义。

至于失败:基准实现里"未配环境变量 / 平台不支持 / 请求失败 / 解析失败 / 平台返回非零 code / 版本号取不出整数"这六种情况,对 App 只有一种说法——data: null,日志里各留一条。理由很实在:用户打开手机是为了审批单子,不是为了看你的更新服务挂了。

四、13 套后端怎么说法一致

这是这块业务在我们工程里最值钱的部分。13 套框架(Java boot2/boot3/boot4、Go goframe/gin/hertz、Python fastapi/flask/django、Node nestjs、PHP laravel、Rust salvo、C# csharp)都要有同一个端点,做法是契约先行

  1. 先在契约文档里把上面那份请求/响应定死,含"任何失败降级为 data:null"这条口径;
  2. 基准实现(GoFrame)先落地,作为其余 12 套的对照;
  3. 契约测试跑手(contract_runner.py)新增第 11 组用例,默认批次从 10 组加到 11 组,13 套各跑一遍。

第 11 组只有三条断言,但设计上有意思:13 套要各跑一遍,可 API Key 按红线不进仓库、测试机上也没有真值,正向分支怎么断言?

1"""免登录端点,三条断言全部环境无关:参数错按全局约定精确断言 99999999;
2versionCode  2^31-1 必高于任何已发布 buildVersionNo,故"未配 PGYER_* / 已配 /
3蒲公英不可达"三态同判 data=null,无需真值 env 即可跑。hasUpdate=true 正向分支依赖
4真实蒲公英 key 与线上版本,不进 runner(由各栈收口报告的手工 curl 矩阵覆盖)。"""
5

versionCode = 2147483647 必然高于任何真实发出去的版本号,于是"没配 key / 配了 key / 平台挂了"三种环境收敛成同一个期望值,一条断言在零配置机器上也成立。反过来,hasUpdate=true 故意不写进自动化——它依赖线上真值,写进 runner 只会每天随机红,最后所有人学会忽略它。

另外两条支撑一致性的设计:

  • 扩平台 = 配一个环境变量。 platform → PGYER_APP_KEY_ANDROID / _HARMONYOS / _IOS 是一张字典,将来要加第四个平台,改配置不改代码;没配的那个平台一律视为无更新。
  • 真值由部署环境注入。 没配 key 的部署,这个端点恒返回"没有更新"。功能在,但不误报——这比"没配就抛错"友好得多,也比"没配就返回一个假地址"安全得多。

13 遍写下来,也确实攒了一本跨栈差异的账,挑三条:

  • fileSize 的 JSON 类型。 Java 和 C# 有全局的 Long→String 序列化器(防前端 JS 精度丢位),同一个字段在别的栈是数字、在这两栈是字符串。最后把它声明成 Integer:24.6MB 装得下;真超过 int32 就省略这个字段,只给 fileSizeInfo("23.5MB" 这种给人看的串)。契约宁可少给一个字段,也不要 13 套给两种类型。
  • Django 的响应冒号后带空格。 DRF 原样输出 {"code": 0, ...},其余 12 套是紧凑串。跨栈探针若按 "code":0 紧串匹配,django 会假报失败。最后探针统一 "code":\s*0——契约只锁字段和取值,不锁空白
  • Hutool 6 的超时。 Java 侧按常规写法设了超时,实测要 21 秒才失败:ClientConfig 设的超时被 SPI 选中的 HttpClient4Engine 覆盖了,得显式指定 engine。一个挂在启动路径上的接口挂 21 秒,比返回错误码恶劣,所以这类"设了但没生效"的配置必须真机计时,不能看代码。

五、下载和安装:交互是被业务事实决定的

先说那个业务事实:包 23.5MB。二十多兆的东西,从点下"立即更新"到能装,中间总有一段用户明显感觉得到的等待——够他切去回条消息、翻两条审批。

这段等待直接决定了三件事:

必须不遮手。 弹一个"正在下载,请勿退出"的遮罩,等于在那段等待里把整个 App 锁住。而用户点开"检查更新"的时候,往往正是他想继续用这个 App 的时候。所以下载态挂在模块级 reactive 单例上,浮层只是订阅它、渲染在页面根部:不随页面滚动、不拦任何点击、切页之后照样在、百分比照样涨

下完要问一句。 等完这一趟,用户可能早就切去干别的了,直接跳系统安装器会很突兀。所以是"新版本已下载完成,是否立即安装?"+ 稍后安装 / 立即安装。

重复检查要有闸门。 下载在飞的时候再点"检查更新",不该发第二个请求,只提示一句"新版本下载中"。

安装这一段,业务的边界要说清楚:App 只负责把包准备好,装不装得成不由它决定。 uni.installApk 把系统安装器拉到前台之后,剩下的界面、授权、"是否覆盖安装"全是安卓的。所以更新链的最后一道,其实是用户手机上的一个系统开关——这也是为什么安卓要在 AndroidManifest.xml 里显式声明 REQUEST_INSTALL_PACKAGES

强制更新走的是同一道链,差别只在弹窗少一个按钮:后端拿平台配置的 forceUpdateVersionNo 和本机 versionCode 比一下,得出 forceUpdate,App 侧据此决定给不给"稍后再说"。判定在数据源那一层,不在客户端——否则用户只要不点那个按钮,或者装一个旧版本,判定就被绕过了。

上面这五道,09-22 在模拟器上真跑了一遍,从冷启动弹窗到"应用安装完成"每一帧:

六、蒲公英只是内置的一种实现,改造点只有一个

先把"为什么不建表"说完整:不是表不该有,是不想为它养一套管理面。 要做"自己管版本",代价不是一个接口,是一整套——版本记录的增删改查、后台管理页、安装包上传与存放、下载地址的鉴权和有效期、谁有权发版、发错了怎么撤。为这点事维护一套后台,不如把包交给一个专门干这行的平台,让它当版本真相的源头。

这个选择没有被写进契约。契约锁住的只有三样:请求 {platform, versionCode}data:null 表示没有更新、有更新时给 versionCode / versionName / downloadUrl / fileSize / releaseNotes / forceUpdate。这几个字段从哪来,契约不管。

所以想自己管版本的人,改造面就一处——把"最新版从哪来"那一步换掉:

1现在:读 PGYER_API_KEY + 平台 appKey  问平台要最新版   buildVersionNo  比大小
2改成:读自己的 sys_app_version   取该平台 version_code 最大的一行  比大小
3

比大小、组装响应、失败降级为 data:null、免登录、错误信封,全部原样留着。App 侧一行都不用改;13 套后端的契约测试也照跑——那三条断言只吃 platformversionCode,不关心数据源长什么样。

要换的这三样东西,顺便说清楚会多出什么活儿,免得改造时踩空:

  • 下载地址要自己生成。 平台给的是带签名的时效直链;自己存就得自己回答"包放哪儿、这个地址能不能长期公开、要不要鉴权"。
  • 更新说明要自己录。 现在它跟着那次发布写在平台上;自建之后它变成表里的一个字段,也就变成了一个会忘记填的字段。
  • 发版动作要有人管。 往表里插一行就等于发版,所以"谁能插、插错了怎么撤"得有个说法——这正是当初不想要的那套后台。

一句话:内置的是"问一个外部数据源要答案",不是"蒲公英"。 答案的格式由契约锁定,答案的来源留给你换。

七、发版侧接回工程

研发侧那四道,在我们这儿是一条命令链,走的是 jeeflow-app-release 那条发版流程:

1manifest.json 双轨 bump(0.2.2/18  0.2.3/19)
2   HBuilderX 云打包(生产证书,签名与历史版本一致才能覆盖安装)
3   上传分发平台(buildVersionNo=19 + 更新说明)
4   回查发布态(isLastest=1)
5   用户侧下一次冷启动就能收到
6

签名这件事容易被忽略:覆盖安装要求新旧包签名一致。所以生产签名必须固定在同一套证书上,换证书等于让所有老用户先卸载再装——更新链直接断掉,而且不会有任何报错,只会表现为"检查更新说没更新"。

后端侧同理:某套栈升级了框架、重出了镜像,这个端点不需要跟着改——它的行为只由契约和部署时的两个环境变量决定。这也是"不建表"换来的另一样东西:发版链和解耦的后端链互不阻塞

八、边界

写清楚哪些还没做,免得被当成已验证:

  • 强制更新(forceUpdate=true)分支只到代码级,线上没配过强制更新版本号,那条"不给稍后再说"的路径没真值跑过。
  • 官网一键脚本部署出来的 13 张镜像,都还没配 PGYER_*,那条路上的这个端点目前恒返回"没有更新",要用的时候部署方自己配。
  • App 侧只发安卓包。iOS 走的是另一套分发规则(平台不允许你自己拉包装),契约里的 platform=3 是为将来留的口子。
  • 上一节那条"换成读自己的表"是留好的改造点,不是已经做完的第二套实现——我们没有自建版本表和后台,那条路上线前需要自己补齐上传、存放和鉴权。

回过头看,这块业务的方法论就三条:

  1. 两边指向同一个真相,中间就不用抄。 版本号的真相在分发平台上,抄一份进表,就多一个会忘记同步、且出错不报错的地方;真要自己管,换掉取数那一步就行,契约和 App 都不动。
  2. 契约要少到能传播,要严到能对齐。 少到只剩一个请求体和"没东西就返回 null",才谈得上 13 套各自实现;严到把"失败也必须是这个形状"写死,跨栈对拍才有意义。
  3. 交互设计先量一个物理量。 包多大、这段等待有多长,这两个事实一摆,"要不要遮手"就没有争论空间了——不用吵,也不用回头问产品。

参考资料


13 套工作流后端共用一个 App 检查更新接口:0 张表,一行 SQL 没写》 是转载文章,点击查看原文


相关推荐


Spring AI 2.0企业级RAG实战:引用校验、无依据拒答与知识治理怎么做?
QCodingDev2026/9/14

目录 1. 企业级RAG真正要解决的是“可信” 2. 检索结果相关,不代表它可以使用 3. 生产级RAG不要只做向量检索 4. 回答必须和Evidence绑定 5. Citation不是在答案后面加个[1] 6. 企业RAG必须敢于拒答 7. 文档上传成功,也不等于知识可以使用 8. 企业知识源也不应该只有“上传文件” 9. 回答错误之后,还需要Quality Loop 10. 我现在更推荐的生产级RAG架构 11. Spring AI 2.0负责什么,业务系统又该负责什么


告别科研检索反复横跳:用DeepScholar串起选刊、翻译与文献管理
承渊政道2026/9/6

🔥承渊政道:个人主页 ❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》 《cpolar知识学习》 ✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介: 做开题、写综述或准备投稿时,真正耗人的往往不只是“读论文”.打开检索结果后,我们还要确认期刊分区、查看影响因子、翻译摘要、


深入理解 TCP 协议(三):连接管理机制 —— 三次握手和四次挥手详解
Mortalbreeze2026/8/29

目录 前言 一、为什么 TCP 需要建立连接 1.1 TCP 是面向连接的协议 1.2 连接到底是什么 1.3 建立连接时必须要解决的问题 1.3.1 确认双方都具备通信条件 1.3.2 同步双方的初始序号 1.3.3 协商 TCP 通信所需要的参数 1.4 小结 二、TCP 三次握手 2.1 第一次握手 2.2 第二次握手 2.3 第三次握手 2.4 为什么需要三次握手 2.5 三次握手过程中的 TCP 状态变化 2.6 listen()、connect()


麒麟v10-Orchestrator高可用组件完整部署与使用(从入门到精通)
西部鳞斑响尾猫2026/8/21

环境:MySQL 8.0.35 GTID 一主两从(141 主 + 142/143 从)+ Orchestrator 3.2.6 raft 三节点(141/142/143)+ 元数据库(143:3307 独立实例)+ VIP(192.168.195.200)+ 最小化 SMTP 邮件服务(143:25) 本文覆盖 Orchestrator.pdf 全部内容:架构原理、安装、配置文件逐项讲解、运行、监控、企业级场景模拟、常见故障模拟与解决、邮件与 VIP、知识点补充。 所有命令均注明执行节点,可直


K8s 数据库 Secret 加密实战|密码明文漏洞彻底修复,等保密评双合规(金仓 / 达梦双库适配)
雨辰AI2026/8/8

摘要 90% 的团队第一次上 K8s 都会踩这个致命合规雷:以为 Secret 是加密存储,实则只是 Base64 编码,等于把数据库管理员密码、国密加密密钥明文存在 etcd 里,运维全员可见、配置提交 Git 直接泄露。等保、密评测评时一查一个准,整改一次就要推翻重配。 本文基于政务、金融信创项目合规落地经验,彻底拆解 K8s 数据库密钥的合规风险,输出从轻量到企业级的三套落地方案:SealedSecret 静态加密、国密 KMS 对接、Sidecar 动态零落地注入,覆盖人大金仓 V9


力扣hot100-240.搜索二维矩阵2-单调性剪枝详解
闪电悠米2026/7/30

LeetCode 240. 搜索二维矩阵 II:单调性剪枝详解 1. 算法思想 这题属于: 矩阵搜索 / 单调性剪枝 也常被称为 Z 字形搜索。它不是普通二分查找:每一行、每一列分别有序,但整个矩阵按行展开后并不整体有序。 例如: [ [1, 4, 7], [2, 5, 8], [3, 6, 9] ] 按行展开是 1, 4, 7, 2, 5, 8, 3, 6, 9,其中 7 后面是 2,因此不能把它当一维数组二分。 本题的关键是:从右上角开始,每次比较都能确定排除一整行或一整列。


「寒草呈献」工作六年,是否仍有创造未来的勇气 ✨
寒草2026/7/22

大家好,我是寒草 🌿 封笔多年,这一篇文章,献给自己~ 六年似弹指一瞬 2020 年盛夏至今,我已工作满整整六年,此间经历颇多。 『踌躇』 2020 年下半年,虽步履蹒跚,不知前路何方,仍一边裹着焦虑一边四处探寻。ps:还曾记得我当时为何来到我现在所在的公司,仅是因为董事长所谓『梦想』的感召。 「肆意」 2021 年开始在掘金创作,我自视与众不同,不喜技术输出(认为那是翻来覆去的陈词滥调),更偏爱人文关怀和新奇创意,那年与数不清的业界好友畅谈,好似那一整年的春夏秋冬都是热烈的盛夏。 「探寻


Rust 函数与返回值详解:参数、表达式与返回类型
程序员爱钓鱼2026/7/14

《Rust 编程实战》系列第 9 篇 在前面的文章中,我们已经学习了变量、数据类型、常量和静态变量。 接下来,我们需要解决一个非常重要的问题: 如何把一段功能独立出来,并在程序中的多个地方重复使用? 答案就是:函数(Function)。 函数是组织 Rust 程序最基本的方式之一。无论是命令行工具、Web 服务、桌面软件还是企业级项目,最终都会由大量函数共同组成。 本文将详细介绍: 如何定义函数 如何传递参数 如何声明参数类型 如何返回数据 Rust 中语句和表达式的区


认识 Horizon UI · 15/17:用模板定制控制台
SkyWalking中文站2026/7/6

Horizon UI 系列第十五篇:整个控制台都由可编辑模板驱动。你可以把任意 layer 或 overview 打开成模板,在本地草稿里调整组件、widget 和文案,预览后发布到 OAP 给整个组织使用,并在发布前查看差异,也可以导出和导入。 译自英文原文:Meet Horizon UI · 15/17: Customization — Config-Driven Layer Templates。 这是 Meet Horizon UI 系列的第十五篇,也开启第五幕 make it yours


用视频数据采集 API 构建个人视频搜索引擎:从 C 罗频道到 Elasticsearch 全文检索
硬核科技工作室2026/6/28

一、视频元数据好看,但不好稳定拿 做视频搜索、内容监测或者训练数据准备时,第一步通常不是模型,也不是搜索算法,而是先拿到一批质量稳定的视频元数据。 比如我们想做一个个人视频搜索引擎,输入关键词 Cristiano,系统可以返回相关视频的标题、描述、播放量、时长、上传者和视频链接。听起来很简单,但真正做起来会发现,视频平台页面结构经常变化,不同入口返回的信息也不一样:频道页、搜索页、标签页、播放页,每个页面的数据组织方式都不同。 如果自己做这件事,通常会有几种方案。 第一种是自己写数据采集

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读