简介:这套运营版大仙分发平台为第二版本,采用一键安装方式,面向需要搭建私有iOS应用分发系统的开发者与站长。系统完全运行于Linux环境,不依赖第三方收费签名工具,通过个人证书实现超级签名,兼顾稳定性与成本控制;同时支持入口域名和落地域名配置,可降低封禁风险。资源包共1171个文件、约20.62MB,以457个PHP后端逻辑、114个JS交互脚本、48个HTML页面模板、23个CSS样式及316个GIF、51个JPG、28个PNG等界面素材为主,另有SQL数据库脚本、配置文件和部署说明,结构完整,便于二次开发或直接上线。该版本已经半年以上稳定运行验证,支持苹果免签封装与打包,并内置一键执行全新安装流程。已有145人学习下载,适合需要降低签名成本、提升分发可控性的团队,可直接部署快速搭建不依赖第三方平台的分发系统。
1. 运营版大仙分发平台第二个版本:它到底解决什么问题
运营手里攒着几个安装包,每次发版都要人肉去改下载页面、覆盖文件、在群里丢链接,链接一过期又要重新传——这是我们做第一个版本时最熟悉的场景。第二个版本/一键安装版,就是把「应用上传、版本管理、更新检查、下载统计、强制更新、灰度发布」这一整套做成一个带后台的分发系统,并且把部署方式收敛成一条命令:拿到一个安装包脚本,跑完就能在服务器上拉起一个可运营的分发平台,不需要再手动配 Nginx、装 MySQL、建库建表。适合小团队自建内测分发、给客户做私有化部署,以及不想把包发到第三方平台的场景。这一版的核心不是功能堆叠,而是让运营真正能自己操作后台,同时让部署的人少踩环境的坑。
2. 第二版的数据模型:四张核心表把分发链路算清楚
2.1 为什么第二版把下载明细设计成业务表而不是日志
第一版最常见的形态是 Nginx 静态目录加一个 HTML 列表页,想看下载量只能去翻 access log。翻日志有几个绕不过去的问题:同一个用户反复点击算好几次、下载工具分片请求会把一次下载拆成几百条 206、爬虫和预下载的流量混在里面,根本分不清真实用户。第二版的第一个变化,就是把 download_log 从「日志」升级成「业务表」,每次点击下载都记录设备标识、渠道、IP 和版本,按需去重。
建表时我一般会把应用信息、版本信息、下载记录、更新规则拆成四张核心表,这样后台的版本管理、灰度策略和统计报表都有各自的落点:
CREATE TABLE app_info ( id INT PRIMARY KEY AUTO_INCREMENT, app_key VARCHAR(32) NOT NULL UNIQUE, app_name VARCHAR(64) NOT NULL, platform ENUM('android','ios') NOT NULL DEFAULT 'android', current_version_code INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE app_version ( id INT PRIMARY KEY AUTO_INCREMENT, app_id INT NOT NULL, version_code INT NOT NULL, version_name VARCHAR(32) NOT NULL, file_path VARCHAR(255) NOT NULL, sha256 CHAR(64) NOT NULL, download_url VARCHAR(255) NOT NULL, url_expire_at INT DEFAULT 0, release_note TEXT, is_force TINYINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_app_version (app_id, version_code) ); CREATE TABLE download_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, app_id INT NOT NULL, version_id INT NOT NULL, device_id VARCHAR(64) NOT NULL, channel VARCHAR(32) DEFAULT '', ip VARCHAR(45) DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_log_app_device (app_id, device_id) ); CREATE TABLE update_rule ( id INT PRIMARY KEY AUTO_INCREMENT, app_id INT NOT NULL, gray_percent TINYINT DEFAULT 0, fallback_version_code INT DEFAULT 0, start_time DATETIME, end_time DATETIME, status TINYINT DEFAULT 1 );这里的核心参数要解释清楚:app_key 是客户端调用更新接口时的身份标识,不要直接暴露自增 id;version_code 必须是整数,客户端比较版本大小时用它,不能用 version_name 做字符串比较,否则 9 比 10 大的问题会直接导致用户拿不到更新。sha256 字段在第二版里不是摆设,后面做安装包完整性校验全靠它。url_expire_at 是下载链接的过期时间戳,过了这个时间接口不再放行下载,这能防止有人把链接扒走做热链。is_force 是强制更新开关,灰度阶段通常先不开,验证没问题再打开。
2.2 上传与下载的 Nginx 配置:路径别名和请求体上限
分发平台的网络入口不能只写一个 root 指向静态目录,还要处理上传接口的反代、大文件请求体上限、目录浏览关闭这三个问题。第一版翻车最多的地方就是上传一个大包直接报 413,因为 Nginx 默认 client_max_body_size 只有 1MB。第二版的 Nginx 配置我一般这样写:
server { listen 80; server_name dl.daxian.local; client_max_body_size 2048m; location /files/ { alias /data/daxian/packages/; autoindex off; if (!-f $request_filename) { return 410; } } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 300s; } }location /files/ 用 alias 而不是 root,是因为 root 会把 location 前缀也拼进真实路径,导致找不到文件。autoindex off 是必须的,不关掉的话服务器上的整个安装包目录就暴露成列表页,别人可以直接扒包。if (!-f $request_filename) return 410 的作用是文件一旦被删除,下载返回 410 Gone,而不是 Nginx 默认的 404,客户端能据此给出「链接已失效」的明确提示,运营也不用再猜是路径写错还是文件被清了。
上传接口的反代要特别注意 proxy_read_timeout,php-fpm 处理大包压缩和入库时可能超过默认的 60 秒,不调大会出现「后端已经处理完,Nginx 却返回 504」的假故障。
2.3 更新检查接口:版本比较必须用 versionCode,灰度逻辑跟规则表联动
运营后台的价值最终要落到客户端调用的更新接口。这个接口要同时回答三个问题:有没有新版、是不是强制、走不走灰度。用 PHP 写一个最小实现大概是这样的:
function checkUpdate($appId, $versionCode, $deviceId, $channel) { $app = DB::findApp($appId); if (!$app) return ['code' => 404, 'msg' => 'app not found']; $latest = DB::findLatestVersion($appId); $rule = DB::findActiveUpdateRule($appId); if ($rule && $rule['gray_percent'] > 0 && $rule['gray_percent'] < 100) { // 设备哈希分桶,落在灰度区间才给新版本 $hash = crc32($deviceId) % 100; if ($hash >= $rule['gray_percent']) { $latest = DB::findVersion($appId, $rule['fallback_version_code']); } } $needUpdate = $latest && $latest['version_code'] > $versionCode; $force = $needUpdate && $latest['is_force'] == 1; return [ 'code' => 0, 'data' => [ 'has_update' => $needUpdate, 'force_update' => $force, 'version_code' => $latest['version_code'], 'download_url' => $latest['download_url'], 'release_note' => $latest['release_note'], 'expire_at' => $latest['url_expire_at'] ] ]; }灰度哈希用 crc32(device_id) % 100 而不是随机数,是为了让同一个设备在多次请求中都落在同一个分桶里,否则用户一刷新可能就在新旧版本之间来回跳。fallback_version_code 是灰度期的兜底版本,不在灰度区间的设备返回这个旧版本号,客户端自然不会去更新。这里有一个细节:findLatestVersion 的 SQL 一定要按 version_code 降序而不是按 created_at 降序,因为运营可能先传了新版后补传旧包,创建时间顺序和版本大小顺序不一定一致。
3. 一键安装脚本:环境检测、依赖安装与初始化要拆成三步
3.1 一键安装的核心顺序:先检测环境再碰服务
所谓一键安装版,并不是把命令都塞进一个巨大的安装脚本里莽跑,而是把流程拆成「检测环境 → 安装依赖 → 初始化数据目录 → 启动服务」。顺序错了,后面全得返工。比如新机器上还没装 MySQL,脚本先试着连库建表,那必然报错;再比如磁盘空间不够,包解压到一半卡死,留给用户的是一台半残的服务器。
我一般会在脚本开头就做三件事:确认操作系统发行版、确认端口没有被占用、确认内存不低于 1G。内存这个检查常常被忽略,但 MySQL 在低内存机器上会被 OOM 干掉,表现是安装脚本全程成功,服务一运行就崩,非常难排查。
#!/bin/bash set -euo pipefail INSTALL_DIR=/opt/daxian DB_NAME=daxian DB_USER=daxian # 用随机密码,避免脚本被扒出来后明文爆破 DB_PASS=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16) check_env() { if [ -f /etc/redhat-release ]; then PKG_MGR=yum elif [ -f /etc/debian_version ]; then PKG_MGR=apt else echo "暂不支持当前系统,请使用 CentOS 7+ / Ubuntu 20.04+" exit 1 fi for p in 80 3306 6379; do if ss -lnt | awk '{print $4}' | grep -q ":$p$"; then echo "端口 $p 已被占用,请先释放后重试" exit 1 fi done mem=$(free -m | awk '/^Mem:/{print $2}') if [ "$mem" -lt 1024 ]; then echo "建议内存不低于 1G,当前 ${mem}M" exit 1 fi }set -euo pipefail 这行值得多说一句:没有 set -e 的话,脚本里任何一条命令失败,后面还会继续执行,最后结果就是既报错又成功,用户根本不知道系统处于什么状态。端口检测用 ss 而不是 netstat,因为新版系统的 netstat 不一定装了。密码用随机生成的字母数字串,避免特殊字符在后续 SQL 语句和 shell 转义中出问题。
3.2 依赖分支:apt、yum 和离线源三分支怎么写
依赖安装是最容易踩坑的环节。在线机器可以分别走 apt 或 yum,但内网服务器经常连公网源都访问不了。脚本里要按包管理器分支,并且给离线安装留好接口。
install_deps() { if [ "$PKG_MGR" = "apt" ]; then apt-get update apt-get install -y nginx php-fpm php-mysql mysql-server redis-server else yum install -y nginx php-fpm php-mysqlnd mariadb-server redis fi }这里有几个细节:Ubuntu 的 php 连接 MySQL 的扩展包叫 php-mysql,CentOS 上对应的是 php-mysqlnd,包名不一样,提前写死必然有一个系统会装不上。CentOS 7 上默认的 MySQL 分支是 MariaDB,别去装 mysql-server 那个包名,大概率找不到。如果目标机器在内网,我习惯在脚本里加一个 OFFINE_PKG_DIR 变量,检测到目录下有本地 rpm/deb 包时直接 dpkg -i 或 rpm -Uvh 批量安装,跳过公网源,这个分支在给政企客户部署时基本都会用到。
3.3 数据库初始化和管理员创建:随机密码与强制改密
依赖装完后就进入初始化阶段:建库、建用户、导入表结构、创建管理员账号。这里最忌讳把数据库密码写死在安装脚本里,因为脚本会被复制、归档、发到群里,一传十十传百,密码等于公开的,所以我总是用随机密码并写到本地配置文件。
init_db() { systemctl start mariadb 2>/dev/null || systemctl start mysql 2>/dev/null mysql -uroot <<EOF CREATE DATABASE IF NOT EXISTS $DB_NAME DEFAULT CHARACTER SET utf8mb4; CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS'; GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES; EOF mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$INSTALL_DIR/install/schema.sql" ADMIN_PASS=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 12) mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e \ "INSERT INTO admin (username, password_hash, must_change) VALUES ('admin', SHA2('$ADMIN_PASS', 256), 1);" echo "数据库密码与管理员初始密码已写入 $INSTALL_DIR/conf/.env" }数据库字符集一定要用 utf8mb4,不能用 utf8,因为运营后台配置项、版本描述里很可能出现 emoji 字符,utf8 存不进去。连接方式写成 'daxian'@'localhost' 是让 PHP 通过 socket 连接,减少 TCP 暴露面。must_change=1 表示管理员第一次登录后台会被强制要求改密码,这个字段很关键,避免初始密码长期遗留在同事聊天记录里。
4. 从第一版迁到第二版:升级脚本与三个边界坑
4.1 迁移顺序与数据整理:先核对旧文件再碰库
升级场景比全新安装更容易翻车,因为目标机器上通常已有一套跑着的服务和一个静态目录里的老安装包。第一版数据往往是「一个 apps.json 加一堆 apk 文件」的形态,第二版全量重建了表结构,所以迁移的第一步不是写 INSERT,而是先核对旧文件。
import json, os, hashlib, pymysql def sha256_file(path): h = hashlib.sha256() with open(path, 'rb') as f: for chunk in iter(lambda: f.read(4096), b''): h.update(chunk) return h.hexdigest() def migrate(json_path, package_root, db): apps = json.load(open(json_path, encoding='utf-8')) cur = db.cursor() for app in apps: old_file = os.path.join(package_root, app['file']) if not os.path.exists(old_file): print(f"跳过缺失文件: {app['name']} -> {old_file}") continue sha = sha256_file(old_file) # 第一版没有版本号,用文件修改时间转成 version_code, # 但 mtime 不保证唯一,追加一个文件大小派生偏移 mtime = int(os.path.getmtime(old_file)) version_code = mtime % 1000000 + (os.path.getsize(old_file) % 1000) cur.execute( "INSERT INTO app_info (app_key, app_name, platform) VALUES (%s, %s, %s)", (app['key'], app['name'], app.get('platform', 'android')) ) app_id = cur.lastrowid cur.execute( "INSERT INTO app_version (app_id, version_code, version_name, file_path, sha256) " "VALUES (%s, %s, %s, %s, %s)", (app_id, version_code, app.get('version_name', 'legacy'), old_file, sha) ) db.commit()这段脚本里最值得说的是 version_code 的生成。直接用 mtime 会有一个隐蔽问题:文件在磁盘间复制、备份、同步时 mtime 可能被改掉,两个包的修改时间如果撞在同一秒,version_code 就一样了,唯一索引会直接报错。所以我用 mtime 取模后再加上文件大小的尾数来制造差异,虽然不是完美的方案,但迁移场景下足够用。sha256 每一行都现算,几百兆的包也就慢个几秒,换来的是迁移后能立即核对包内容是否被篡改过。
4.2 旧下载链接不能断:Rewrite 兼容与文件名编码
第一版的下载链接形如 /download/xxx.apk,第二版改成了 /files/xxx.apk 加签名参数。老用户手上可能还拿着旧链接,或者运营在其他系统里早就挂好了老地址,迁移后如果直接 404,推广物料全部失效。所以 Nginx 上要加一条 rewrite 把旧路径指过去。
location ~ ^/download/(.+)$ { rewrite ^/download/(.+)$ /files/$1 last; }用 last 而不是 redirect,是让浏览器地址栏保持旧链接不变,实际由 Nginx 内部转发到新路径,用户感知不到跳转。这里有个很容易踩的坑:第一版如果允许上传中文文件名,生成的链接是把中文路径经过 URL 编码的,Nginx 的 $1 拿到的可能是已经解码的字符,转发到 /files/ 时会因为编码不一致找不到文件。最常见的处理是在 Nginx 的 http 块里加上 charset utf-8,同时迁移脚本里把所有文件名统一转成拼音缩写或日期前缀,一次性杜绝这类问题。
4.3 版本号换算和统计清零的取舍
迁移时会碰到一个现实问题:第一版的下载记录只有 access log,没有真正的业务明细。要不要把历史 access log 解析后回填到 download_log?我试过一次,结论是得不偿失。原因有三层:第一层,access log 里的请求不代表成功下载,状态码 206、304 混在一起,过滤规则很难定;第二层,第一版没有 device_id,只能拿 IP 加 UA 凑,换一个 wifi 就会多算一个设备;第三层,真实运营场景里,老板只需要看「上线以来累计下载量」,老数据缺失并不会影响第二版运营决策。
所以我一般只从旧系统迁移应用和版本两张表的元数据,download_log 从第二版上线当天开始计算,并且在后台把「历史下载量」明确标注为从新系统开始统计。这个取舍要主动跟运营说清楚,否则对方对比新旧数据发现差异,会觉得迁移丢了数据,来回扯皮。
5. 一键安装版典型翻车:五个高频问题的定位与排查
5.1 安装脚本卡在「无法安装依赖」:内网源的三种替代
现象:脚本执行到 install_deps 时长时间卡住,最后报无法解析镜像源域名,或者 apt-get update 刷出一大片失败。
原因:目标机器虽是 Linux,但部署在客户内网,根本没有外网,yum/apt 源地址不可达;还有些老旧系统镜像里自带的源指向已经下线的 URL。
解决:先确认目标机器能不能访问公网,不能的话准备三样东西——内网镜像源地址、离线 rpm/deb 包目录、以及一键脚本里对应的离线分支。离线包要在同发行版、同大版本的机器上先用 yum downloader 或 apt-get download 准备好,然后打包传到目标机器,脚本检测到本地离线目录存在时直接跳过在线安装。
5.2 MySQL 首次连接失败:socket 认证与密码转义
现象:初始化脚本执行 mysql -uroot 时提示 access denied,或者后面用 DB_PASS 连库时报 syntax error。
原因:新装 MariaDB 在 CentOS 上默认 root 走 socket 认证,脚本直接用 TCP 方式连必然失败;另外如果密码生成时混入了 $ 或单引号,shell 拼接 SQL 时会被错误解析。
解决:root 连接统一走 socket,脚本里写成 mysql -uroot 不指定 -h,让客户端默认走本地 socket;密码生成只保留 A-Za-z0-9,从源头避免转义问题。不要把顺手抄来的随机密码生成命令原样拿来用,它生成的字符串可能带引号和美元符,进了 SQL 就是语法错误。
5.3 上传大包超时:nginx 和 PHP 的三处配置联动
现象:上传一个 1GB 的 IPA 或 APK,进度条走到 100% 后页面报 502 或 504,后台也没有显示新版本。
原因:这是最经典的「改了一处就以为改完」的翻车现场。Nginx 那边 client_max_body_size 改大了,但 PHP 的 upload_max_filesize 和 post_max_size 没改,文件被 PHP 拒收;或者 PHP 都收下来了,php-fpm 的安全超时又把正在处理中的请求掐断。
解决:三处配置必须同时改。
upload_max_filesize = 2048M post_max_size = 2048M request_terminate_timeout = 300第一、二行在 php.ini,第三行在 php-fpm 的 pool 配置里。改完记得 systemctl reload php-fpm,不是 restart nginx 就能生效。判断卡在哪一步,直接看 php-fpm 的慢日志,超时被掐断的请求都会留下记录。
5.4 下载统计重复计数:Range 请求与去重策略
现象:后台看某个版本的下载次数,比实际装机量高了好几倍,点开明细发现同一个 device_id 在几秒内出现几十条下载记录。
原因:Android 下载器普遍支持断点续传,一次下载会以多个 Range 请求发起,Nginx 返回 206;再加上用户手滑点了几次重试,每点一次又是一条记录。第一版依赖 access log 统计时还没这么明显,第二版把明细入库后,这个问题直接暴露在后台报表里。
解决:download_log 写入时先以 (app_id, device_id, version_id, 日期) 做去重,同一天内同一设备同一版本只记一次。统计报表时用 COUNT(DISTINCT device_id) 而不是 COUNT(*)。如果产品要求统计点击次数,就单独建一个 click_log,一个管点击量、一个管去重后的设备数,别混在一张表里。
5.5 微信内点击下载被拦截:浏览器引导与落地页
现象:运营把下载二维码发到微信群,安卓用户扫码后点下载没反应,iOS 用户直接显示打不开,以为平台坏了。
原因:微信内置浏览器对 APK 下载做了拦截,iOS 上更是禁止应用包直接下载,这是平台限制,不是服务器配置问题。
解决:下载落地页里检测微信 UA,命中就显示「点击右上角,选择在浏览器打开」,没命中才直接触发下载。
if (/MicroMessenger/i.test(navigator.userAgent)) { document.getElementById('open-in-browser').style.display = 'block'; }这个检测要放在页面头部尽早执行,避免微信里页面已经触发下载流程才提醒,用户会产生困惑。另外腾讯应用宝的链接虽然可以在微信内打开,但那属于第三方分发渠道,和自建平台是两码事,别混为一谈。
6. 进阶用法:给第二版加灰度分发与渠道统计,顺手做包完整性校验
6.1 用灰度规则验证更新接口
第二版的 update_rule 表已经预留了 gray_percent 和 fallback_version_code,但很多团队上线后根本没用过。灰度分发是运营后台里性价比最高的功能,它不需要客户端配合改代码,只要在服务端控制返回哪个版本。验证方式很简单:拿同一台测试机,分别传两个不同的 device_id 调用更新接口,一个哈希值落在灰度区间内、一个落在区间外,看返回的 version_code 是否符合预期。
curl -s 'http://dl.daxian.local/api/check_update?app_id=daxian&version_code=200' \ -H 'X-Device-Id: 10001'灰度比例从 5% 开始,观察半小时的崩溃记录和下载量,确认没问题再逐步调大到 50%、100%。这个功能一定要配 fallback_version_code,否则灰度一半发现新版有问题,连回滚的地方都没有。
6.2 渠道统计与包完整性校验
第二版上线后,我建议所有下载链接都带上 channel 参数,比如 channel=wechat、channel=offline,这样后台能用一条 SQL 看每个来源的转化情况:
SELECT channel, COUNT(DISTINCT device_id) AS uv FROM download_log WHERE app_id = 1 AND created_at >= '2025-01-01' GROUP BY channel;完整性校验则要落到每天的巡检上。app_version 表里存了 sha256,后台可以定时任务把 packages 目录下所有安装包和数据库里的哈希比对一次,发现不一致立刻告警。我第一版没做这个,结果运营自己用一份内容被改动过的包覆盖了原文件,线上用户装完崩溃了一整天。后来加了每天的 sha256 校验,再也没出过这种事。
一键安装版的定位从来不是「部署完就撒手」,而是让运营能自服务、让部署者有后悔药可吃。灰度、渠道、校验这三件事,是第二版真正值得投入的方向。希望帮到你。
本文还有配套的精品资源,点击获取