OTA(Over-The-Air)的核心矛盾:百万设备 + 大文件(几百 MB) + 灰度策略 + 防刷带宽 + 断点续传 + 安全签名 + 失败回滚。

  ---
  一、整体流程(大白话)

  【上传侧】
    研发推固件包 →服务端计算 SHA256 + 签名 + 生成差分包 →存对象存储 →入库

  【设备侧】
    ①设备启动 →携带 (device_id, model, current_version, region)/ota/check
    ②服务端命中策略:
         - 当前版本是否需要升级?
         - 灰度白名单/百分比?
         - 地域/机型/渠道匹配?
         - 是否优先发差分包(节省 90% 带宽)?
    ③返回:升级包 URL + 大小 + SHA256 + 签名 + 版本号 + 强制/可选
         ↓
    ④设备走 /ota/download(支持 Range 断点续传)
         - 服务端限流(令牌桶按 device 维度)
         - 走对象存储 CDN 直连 / 服务端代理
         ↓
    ⑤设备本地校验 SHA256 + 数字签名(防中间人篡改)
         ↓
    ⑥设备安装重启 →调 /ota/report 上报结果
         ↓
    ⑦服务端汇总成功率,异常率 > 阈值 →自动暂停灰度
         ↓
    ⑧全量发布 / 紧急回滚

  核心思想:策略匹配 + 差分包 + 断点续传 + 灰度自动熔断。

  ---
  二、最佳技术选型

  ┌────────────┬──────────────────────────────────┬─────────────────────────────────────────────┐
  │     层     │             库/技术              │                    原因                     │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 运行时     │ Swoole 6.x                       │ 协程 + sendfile 零拷贝                      │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 策略 DSL   │ symfony/expression-language      │ version < "1.2.3" and region in ["CN","HK"] │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 文件存储   │ league/flysystem + S3/OSS 适配器 │ 一套 API 切换本地/对象存储                  │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 大文件下载 │ Swoole Response::sendfile        │ 零拷贝,几 G 文件无内存压力                  │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 差分包     │ bsdiff/bspatch(shell 调用)       │ 业界标准,节省 80-95% 流量                   │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 设备认证   │ firebase/php-jwt                 │ 标准 JWT                                    │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 签名       │ openssl_sign / Ed25519           │ 固件签名防篡改                              │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 限流       │ Redis Lua 令牌桶                 │ 按 device_id 维度                           │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 热数据     │ Swoole\Table                     │ 版本/策略缓存                               │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 路由       │ nikic/fast-route                 │ 极快                                        │
  ├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
  │ 监控       │ Prometheus PHP                   │ 实时大盘                                    │
  └────────────┴──────────────────────────────────┴─────────────────────────────────────────────┘

  composer require swoole/ide-helper symfony/expression-language league/flysystem league/flysystem-aws-s3-v3
  firebase/php-jwt nikic/fast-route monolog/monolog

  ---
  三、完整代码

  1. 入口:Server 启动

  <?php
  // server.php
  declare(strict_types=1);
  require __DIR__ . '/vendor/autoload.php';

  use Swoole\Http\Server;
  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use App\OTA\Container;
  use App\OTA\Router;

  $c = Container::boot();

  $server = new Server('0.0.0.0', 9503, SWOOLE_PROCESS);
  $server->set([
      'worker_num'        => swoole_cpu_num() * 2,
      'task_worker_num'   => 8,
      'task_enable_coroutine' => true,
      'enable_coroutine'  => true,
      'max_request'       => 100000,
      'hook_flags'        => SWOOLE_HOOK_ALL,
      'open_http2_protocol'=> true,
      'send_yield'        => true,                  // 发包过快时自动让出协程
      'socket_buffer_size'=> 128 * 1024 * 1024,     // 大文件下载需要
      'buffer_output_size'=> 128 * 1024 * 1024,
      'package_max_length'=> 512 * 1024 * 1024,
      'http_compression'  => false,                 // 固件已压缩,别再压
      'enable_static_handler' => false,
      'upload_tmp_dir'    => '/tmp',
      'upload_max_filesize'=> '2G',
  ]);

  $server->on('WorkerStart', function($s, $wid) use ($c) {
      $c->initPools();
      $c->loadVersions();        // 版本+策略加载到内存
      $c->loadSigningKey();      // 加载固件签名私钥
  });

  $server->on('Request', function(Request $req, Response $res) use ($c, $server) {
      (new Router($c, $server))->dispatch($req, $res);
  });

  // Task:异步统计上报、灰度自动熔断
  $server->on('Task', function($s, $task) use ($c) {
      (new \App\OTA\ReportProcessor($c))->handle($task->data);
  });
  $server->on('Finish', fn() => null);

  $server->start();

  解释:
  - send_yield:发包速度超过客户端接收能力时自动协程让出,避免吃满内存
  - socket_buffer_size 128M:大文件下载需要更大缓冲
  - http_compression=false:固件包本身已经压缩(.zip/.bin),再 gzip 只会浪费 CPU
  - open_http2_protocol:H2 多路复用,移动端弱网体验好

  ---
  2. Container:连接池 + 内存表 + 签名密钥

  <?php
  // src/OTA/Container.php
  namespace App\OTA;

  use Swoole\Coroutine\Channel;
  use Swoole\Coroutine\Redis;
  use Swoole\Coroutine\MySQL;
  use Swoole\Table;
  use League\Flysystem\Filesystem;
  use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
  use Aws\S3\S3Client;

  class Container
  {
      public Table $versions;       // 所有可分发版本(共享)
      public Table $policies;       // 灰度策略(共享)
      public Table $grayStats;      // 灰度成功率统计(共享)
      public Channel $redisPool;
      public Channel $mysqlPool;
      public Filesystem $storage;   // 对象存储
      public string $signPrivKey;
      public string $signPubKey;

      public static function boot(): self
      {
          $c = new self();

          $c->versions = new Table(4096);
          $c->versions->column('app_id',     Table::TYPE_STRING, 64);
          $c->versions->column('version',    Table::TYPE_STRING, 32);
          $c->versions->column('build_no',   Table::TYPE_INT, 8);
          $c->versions->column('file_path',  Table::TYPE_STRING, 256);
          $c->versions->column('file_size',  Table::TYPE_INT, 8);
          $c->versions->column('sha256',     Table::TYPE_STRING, 64);
          $c->versions->column('signature',  Table::TYPE_STRING, 512);
          $c->versions->column('force',      Table::TYPE_INT, 1);
          $c->versions->column('min_from',   Table::TYPE_STRING, 32);  // 兼容的最低旧版本
          $c->versions->column('release_notes',Table::TYPE_STRING, 2048);
          $c->versions->column('status',     Table::TYPE_STRING, 16);  // gray/full/paused
          $c->versions->create();

          $c->policies = new Table(4096);
          $c->policies->column('version_key',Table::TYPE_STRING, 96);
          $c->policies->column('expression', Table::TYPE_STRING, 2048);
          $c->policies->column('percent',    Table::TYPE_INT, 4);
          $c->policies->create();

          $c->grayStats = new Table(4096);
          $c->grayStats->column('attempt',  Table::TYPE_INT, 8);
          $c->grayStats->column('success',  Table::TYPE_INT, 8);
          $c->grayStats->column('failed',   Table::TYPE_INT, 8);
          $c->grayStats->create();

          // 对象存储(可换 OSS/COS/MinIO,API 完全一致)
          $s3 = new S3Client([
              'version' => 'latest',
              'region'  => 'us-east-1',
              'endpoint'=> 'http://127.0.0.1:9000',  // MinIO 示例
              'use_path_style_endpoint' => true,
              'credentials' => ['key'=>'minio','secret'=>'minio123'],
          ]);
          $c->storage = new Filesystem(new AwsS3V3Adapter($s3, 'ota'));
          return $c;
      }

      public function initPools(): void
      {
          $this->redisPool = new Channel(64);
          for ($i=0;$i<64;$i++) {
              $r = new Redis(); $r->connect('127.0.0.1', 6379);
              $this->redisPool->push($r);
          }
          $this->mysqlPool = new Channel(16);
          for ($i=0;$i<16;$i++) {
              $db = new MySQL();
              $db->connect(['host'=>'127.0.0.1','user'=>'root','password'=>'','database'=>'ota']);
              $this->mysqlPool->push($db);
          }
      }

      public function loadVersions(): void
      {
          $this->withMySQL(function($db) {
              $rows = $db->query("SELECT * FROM versions WHERE status IN('gray','full')");
              foreach ($rows as $r) {
                  $key = $r['app_id'].':'.$r['version'];
                  $this->versions->set($key, [
                      'app_id'   => $r['app_id'],
                      'version'  => $r['version'],
                      'build_no' => (int)$r['build_no'],
                      'file_path'=> $r['file_path'],
                      'file_size'=> (int)$r['file_size'],
                      'sha256'   => $r['sha256'],
                      'signature'=> $r['signature'],
                      'force'    => (int)$r['force'],
                      'min_from' => $r['min_from'] ?? '',
                      'release_notes'=> $r['release_notes'] ?? '',
                      'status'   => $r['status'],
                  ]);
              }
              $rows = $db->query("SELECT * FROM upgrade_policies");
              foreach ($rows as $p) {
                  $this->policies->set((string)$p['id'], [
                      'version_key'=> $p['app_id'].':'.$p['version'],
                      'expression' => $p['expression'],
                      'percent'    => (int)$p['percent'],
                  ]);
              }
          });
      }

      public function loadSigningKey(): void
      {
          // 真生产从 KMS / Vault 拉,这里读文件演示
          $this->signPrivKey = file_get_contents('/etc/ota/ed25519.priv');
          $this->signPubKey  = file_get_contents('/etc/ota/ed25519.pub');
      }

      public function withRedis(callable $fn) {
          $r = $this->redisPool->pop();
          try { return $fn($r); } finally { $this->redisPool->push($r); }
      }
      public function withMySQL(callable $fn) {
          $db = $this->mysqlPool->pop();
          try { return $fn($db); } finally { $this->mysqlPool->push($db); }
      }
  }

  解释:
  - 三张 Swoole Table:版本元数据、灰度策略、灰度统计 ——全在共享内存,所有 worker 0 RTT 访问
  - Flysystem + S3 适配:无缝切换 MinIO / 阿里 OSS / AWS S3,代码不动
  - Ed25519 签名:比 RSA 快 10,签名只有 64 字节,移动端验证开销极小

  ---
  3. 路由

  <?php
  // src/OTA/Router.php
  namespace App\OTA;

  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use Swoole\Http\Server;

  class Router
  {
      public function __construct(private Container $c, private Server $server) {}

      public function dispatch(Request $req, Response $res): void
      {
          $uri = $req->server['request_uri'];
          try {
              match($uri) {
                  '/ota/check'    => (new CheckHandler($this->c))->handle($req, $res),
                  '/ota/download' => (new DownloadHandler($this->c))->handle($req, $res),
                  '/ota/report'   => (new ReportHandler($this->c, $this->server))->handle($req, $res),
                  '/ota/upload'   => (new UploadHandler($this->c))->handle($req, $res),
                  '/ota/rollback' => (new RollbackHandler($this->c))->handle($req, $res),
                  default         => $this->notFound($res),
              };
          } catch (\Throwable $e) {
              $res->status(500);
              $res->end(json_encode(['code'=>500,'msg'=>$e->getMessage()]));
          }
      }

      private function notFound(Response $res): void
      {
          $res->status(404);
          $res->end(json_encode(['code'=>404,'msg'=>'not found']));
      }
  }

  ---
  4. CheckHandler:版本检查(核心)

  <?php
  // src/OTA/CheckHandler.php
  namespace App\OTA;

  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

  class CheckHandler
  {
      private ExpressionLanguage $expr;

      public function __construct(private Container $c)
      {
          $this->expr = new ExpressionLanguage();
      }

      public function handle(Request $req, Response $res): void
      {
          $res->header('Content-Type','application/json');

          // 1. 设备身份验证(JWT)
          $deviceId = $this->verifyJwt($req);
          if (!$deviceId) {
              $res->status(401); $res->end(json_encode(['code'=>401,'msg'=>'unauthorized']));
              return;
          }

          $data = json_decode($req->rawContent(), true) ?? [];
          $ctx = [
              'device_id'  => $deviceId,
              'app_id'     => $data['app_id'] ?? '',
              'version'    => $data['current_version'] ?? '0.0.0',
              'build_no'   => (int)($data['build_no'] ?? 0),
              'model'      => $data['model'] ?? '',
              'os'         => $data['os'] ?? '',
              'region'     => $data['region'] ?? '',
              'channel'    => $data['channel'] ?? '',
              'rom_size_mb'=> (int)($data['rom_size_mb'] ?? 0),
          ];

          // 2. 找到该 app 最新的版本
          $target = $this->pickTargetVersion($ctx);
          if (!$target) {
              $res->end(json_encode(['code'=>0,'has_update'=>false]));
              return;
          }

          // 3. 灰度策略匹配
          if (!$this->matchPolicy($target, $ctx)) {
              $res->end(json_encode(['code'=>0,'has_update'=>false,'reason'=>'not_in_gray']));
              return;
          }

          // 4. 优先尝试差分包(节省 80%+ 带宽)
          $patch = $this->findPatch($ctx['app_id'], $ctx['version'], $target['version']);

          // 5. 生成下载令牌(防盗链,5 分钟内可下载)
          $downloadToken = $this->issueDownloadToken($deviceId, $target['version'], (bool)$patch);

          // 6. 统计灰度尝试数
          $key = $target['app_id'].':'.$target['version'];
          $stats = $this->c->grayStats->get($key) ?: ['attempt'=>0,'success'=>0,'failed'=>0];
          $this->c->grayStats->incr($key, 'attempt');

          $res->end(json_encode([
              'code'        => 0,
              'has_update'  => true,
              'force'       => (bool)$target['force'],
              'version'     => $target['version'],
              'build_no'    => $target['build_no'],
              'file_size'   => $patch['size'] ?? $target['file_size'],
              'sha256'      => $patch['sha256'] ?? $target['sha256'],
              'signature'   => $target['signature'],
              'is_patch'    => (bool)$patch,
              'download_url'=> "/ota/download?token={$downloadToken}",
              'release_notes' => $target['release_notes'],
          ]));
      }

      private function pickTargetVersion(array $ctx): ?array
      {
          $best = null;
          foreach ($this->c->versions as $key => $v) {
              if ($v['app_id'] !== $ctx['app_id']) continue;
              if ($v['status'] === 'paused') continue;
              // 旧版本太老,不在兼容窗口内 →跳过(或强制提示官网下载)
              if ($v['min_from'] && version_compare($ctx['version'], $v['min_from'], '<')) continue;
              if (version_compare($v['version'], $ctx['version'], '<=')) continue;
              if (!$best || version_compare($v['version'], $best['version'], '>')) $best = $v;
          }
          return $best;
      }

      private function matchPolicy(array $target, array $ctx): bool
      {
          $matched = false; $needMatch = false;
          foreach ($this->c->policies as $p) {
              if ($p['version_key'] !== $target['app_id'].':'.$target['version']) continue;
              $needMatch = true;
              try {
                  // 表达式 DSL:支持 model/region/channel/rom_size_mb 等组合
                  if ($this->expr->evaluate($p['expression'], $ctx)) {
                      // 百分比灰度:device_id 哈希 →0-99
                      $bucket = crc32($ctx['device_id']) % 100;
                      if ($bucket < $p['percent']) { $matched = true; break; }
                  }
              } catch (\Throwable $e) {
                  error_log("policy expr error: ".$e->getMessage());
              }
          }
          // 没有策略 + 全量发布 →默认匹配
          if (!$needMatch && $target['status'] === 'full') return true;
          return $matched;
      }

      private function findPatch(string $appId, string $from, string $to): ?array
      {
          return $this->c->withMySQL(function($db) use ($appId,$from,$to) {
              $stmt = $db->prepare(
                  "SELECT file_path,file_size AS size,sha256 FROM patches
                   WHERE app_id=? AND from_version=? AND to_version=? LIMIT 1"
              );
              $rows = $stmt->execute([$appId, $from, $to]);
              return $rows[0] ?? null;
          });
      }

      private function issueDownloadToken(string $deviceId, string $version, bool $isPatch): string
      {
          $token = bin2hex(random_bytes(16));
          $this->c->withRedis(fn($r) => $r->setEx(
              "ota:dl:$token", 300,
              json_encode(['device'=>$deviceId,'version'=>$version,'patch'=>$isPatch])
          ));
          return $token;
      }

      private function verifyJwt(Request $req): ?string
      {
          $auth = $req->header['authorization'] ?? '';
          if (!str_starts_with($auth, 'Bearer ')) return null;
          try {
              $payload = \Firebase\JWT\JWT::decode(
                  substr($auth, 7),
                  new \Firebase\JWT\Key('your-jwt-secret', 'HS256')
              );
              return $payload->device_id ?? null;
          } catch (\Throwable $e) { return null; }
      }
  }

  解释:
  - 匹配最新可升级版本用 version_compare,自动支持 1.2.3-beta 这类语义化版本
  - 灰度算法 = 表达式过滤 + 百分比哈希分桶:crc32(device_id) % 100 < percent,同一设备结果稳定(不会今天命中明天不命中)
  - 下载令牌:/check 返回带 token 的 URL,5 分钟有效,防止 URL 被分享出去吃流量
  - min_from:跨大版本升级时强制走全量包(差分包对不上)

  ---
  5. DownloadHandler:大文件下载(零拷贝 + 断点续传 + 限流)

  <?php
  // src/OTA/DownloadHandler.php
  namespace App\OTA;

  use Swoole\Http\Request;
  use Swoole\Http\Response;

  class DownloadHandler
  {
      public function __construct(private Container $c) {}

      public function handle(Request $req, Response $res): void
      {
          $token = $req->get['token'] ?? '';
          $info  = $this->c->withRedis(fn($r) => $r->get("ota:dl:$token"));
          if (!$info) {
              $res->status(403); $res->end('token expired'); return;
          }
          $info = json_decode($info, true);
          $deviceId = $info['device'];

          // 限流:同一设备 5 分钟最多 3 次下载
          if (!$this->rateLimit($deviceId)) {
              $res->status(429); $res->end('too many requests'); return;
          }

          // 取版本元数据
          [$appId, $ver] = explode(':', $info['version']);  // 简化:实际从 token 内取
          $version = $this->c->versions->get($info['version']) ?? null;
          if (!$version) { $res->status(404); $res->end('not found'); return; }

          $filePath = '/data/ota/' . basename($version['file_path']);  // 本地缓存路径
          // 真实场景:如果是 S3,先生成 presign URL 302 重定向;走内网则代理读
          if (!is_file($filePath)) {
              $this->downloadToCache($version['file_path'], $filePath);
          }

          $size = filesize($filePath);
          $start = 0; $end = $size - 1;

          // ★HTTP Range:断点续传
          $range = $req->header['range'] ?? '';
          if (preg_match('/bytes=(\d+)-(\d*)/', $range, $m)) {
              $start = (int)$m[1];
              $end   = $m[2] === '' ? $size - 1 : (int)$m[2];
              $res->status(206);
              $res->header('Content-Range', "bytes $start-$end/$size");
          }

          $res->header('Content-Type', 'application/octet-stream');
          $res->header('Content-Length', (string)($end - $start + 1));
          $res->header('Accept-Ranges', 'bytes');
          $res->header('X-OTA-SHA256', $version['sha256']);
          $res->header('X-OTA-Signature', $version['signature']);

          // ★Swoole sendfile:零拷贝,内核直接 socket 发,几 G 文件无 PHP 内存占用
          $res->sendfile($filePath, $start, $end - $start + 1);

          // 异步打点
          \Swoole\Coroutine::create(function() use ($deviceId, $info) {
              $this->c->withRedis(fn($r) => $r->incr("ota:dl_count:".date('Ymd')));
          });
      }

      private function rateLimit(string $deviceId): bool
      {
          return (bool)$this->c->withRedis(function($r) use ($deviceId) {
              $key = "ota:rl:$deviceId";
              $n = $r->incr($key);
              if ($n === 1) $r->expire($key, 300);
              return $n <= 3;
          });
      }

      private function downloadToCache(string $remotePath, string $localPath): void
      {
          $stream = $this->c->storage->readStream($remotePath);
          $fp = fopen($localPath, 'wb');
          stream_copy_to_stream($stream, $fp);
          fclose($fp);
      }
  }

  解释:
  - sendfile 是 OTA 服务的命脉:内核直接把文件丢给 socket,PHP 进程内存 0 占用,100MB 固件和 10KB 文件同等开销
  - HTTP Range 断点续传:移动网络断网重连必备,大文件没这个就是噩梦
  - 限流 3/5 分钟:防止恶意刷流量(OTA 是企业最贵的带宽支出之一)
  - 响应头携带 SHA256 + Signature:设备下载完立即校验

  ---
  6. ReportHandler:升级结果上报 + 自动熔断

  <?php
  // src/OTA/ReportHandler.php
  namespace App\OTA;

  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use Swoole\Http\Server;

  class ReportHandler
  {
      public function __construct(private Container $c, private Server $server) {}

      public function handle(Request $req, Response $res): void
      {
          $data = json_decode($req->rawContent(), true) ?? [];
          $key  = ($data['app_id']??'').':'.($data['version']??'');
          $ok   = ($data['result'] ?? '') === 'success';

          if ($ok) $this->c->grayStats->incr($key, 'success');
          else     $this->c->grayStats->incr($key, 'failed');

          // 异步落库 + 触发熔断判断
          $this->server->task([
              'type'      => 'report',
              'app_id'    => $data['app_id'] ?? '',
              'version'   => $data['version'] ?? '',
              'device_id' => $data['device_id'] ?? '',
              'result'    => $data['result'] ?? '',
              'error_msg' => $data['error_msg'] ?? '',
              'duration_ms'=> (int)($data['duration_ms'] ?? 0),
          ]);

          $res->end(json_encode(['code'=>0]));
      }
  }

  <?php
  // src/OTA/ReportProcessor.php —在 task 进程跑
  namespace App\OTA;

  class ReportProcessor
  {
      public function __construct(private Container $c) {}

      public function handle(array $data): void
      {
          $this->c->withMySQL(function($db) use ($data) {
              $stmt = $db->prepare(
                  "INSERT INTO upgrade_reports(app_id,version,device_id,result,error_msg,duration_ms,created_at)
                   VALUES(?,?,?,?,?,?,NOW())"
              );
              $stmt->execute([
                  $data['app_id'],$data['version'],$data['device_id'],
                  $data['result'],$data['error_msg'],$data['duration_ms']
              ]);
          });

          // ★灰度自动熔断:失败率超过 5% 且样本数 ≥100 →暂停发布
          $key = $data['app_id'].':'.$data['version'];
          $s = $this->c->grayStats->get($key);
          if ($s && ($s['success']+$s['failed']) >= 100) {
              $failRate = $s['failed'] / max($s['success']+$s['failed'], 1);
              if ($failRate > 0.05) {
                  $this->c->withMySQL(fn($db) =>
                      $db->query("UPDATE versions SET status='paused' WHERE app_id='{$data['app_id']}' AND
  version='{$data['version']}'")
                  );
                  $v = $this->c->versions->get($key);
                  if ($v) { $v['status']='paused'; $this->c->versions->set($key, $v); }
                  error_log("[ALERT] auto-pause $key, fail rate=$failRate");
                  // 触发飞书/钉钉告警(略)
              }
          }
      }
  }

  解释:
  - 失败率自动熔断是 OTA 最关键的安全网:新版本搞挂 5% 设备就自动停发,避免事故扩大
  - 不熔断的后果:可能凌晨 3 点把全网设备变砖,无法回滚(设备已经更新了)
  - Swoole Table 的 incr 是原子操作,跨 worker 累加无锁安全

  ---
  7. UploadHandler:研发上传新固件

  <?php
  // src/OTA/UploadHandler.php
  namespace App\OTA;

  use Swoole\Http\Request;
  use Swoole\Http\Response;

  class UploadHandler
  {
      public function __construct(private Container $c) {}

      public function handle(Request $req, Response $res): void
      {
          // 内部管理 API,需鉴权(略)
          $file = $req->files['firmware'] ?? null;
          if (!$file) { $res->status(400); $res->end('no file'); return; }

          $appId   = $req->post['app_id'] ?? '';
          $version = $req->post['version'] ?? '';
          $minFrom = $req->post['min_from'] ?? '';
          $tmpPath = $file['tmp_name'];

          // 1. 计算 SHA256
          $sha = hash_file('sha256', $tmpPath);

          // 2. Ed25519 签名(防中间人篡改 + 防设备执行非官方固件)
          $sig = '';
          sodium_crypto_sign_detached($sha, $this->c->signPrivKey);  // 简化
          $sig = base64_encode(sodium_crypto_sign_detached(
              file_get_contents($tmpPath),
              $this->c->signPrivKey
          ));

          // 3. 上传对象存储
          $remotePath = "firmware/$appId/$version.bin";
          $this->c->storage->writeStream($remotePath, fopen($tmpPath, 'rb'));

          // 4. 入库 + 同步内存
          $this->c->withMySQL(function($db) use ($appId,$version,$remotePath,$sha,$sig,$minFrom,$file) {
              $stmt = $db->prepare(
                  "INSERT INTO
  versions(app_id,version,build_no,file_path,file_size,sha256,signature,status,min_from,force,release_notes)
                   VALUES(?,?,?,?,?,?,?,'gray',?,0,'')"
              );
              $stmt->execute([
                  $appId, $version, time(), $remotePath, $file['size'],
                  $sha, $sig, $minFrom
              ]);
          });
          $this->c->loadVersions();

          // 5. 异步生成差分包(对所有兼容旧版本)
          \Swoole\Coroutine::create(function() use ($appId,$version,$tmpPath) {
              (new PatchBuilder($this->c))->buildPatchesFor($appId, $version, $tmpPath);
          });

          $res->end(json_encode([
              'code'=>0, 'sha256'=>$sha, 'signature'=>$sig, 'path'=>$remotePath
          ]));
      }
  }

  ---
  8. PatchBuilder:差分包生成(80% 带宽)

  <?php
  // src/OTA/PatchBuilder.php
  namespace App\OTA;

  class PatchBuilder
  {
      public function __construct(private Container $c) {}

      public function buildPatchesFor(string $appId, string $newVersion, string $newFilePath): void
      {
          // 取最近 5 个老版本,对每个生成差分包
          $olds = $this->c->withMySQL(fn($db) =>
              $db->query("SELECT version,file_path FROM versions
                          WHERE app_id='$appId' AND version<>'$newVersion'
                          ORDER BY build_no DESC LIMIT 5")
          );
          foreach ($olds as $o) {
              $oldLocal  = "/tmp/old_{$o['version']}.bin";
              $patchPath = "/tmp/patch_{$o['version']}_to_$newVersion.patch";
              $this->c->storage->writeStream($oldLocal, $this->c->storage->readStream($o['file_path']));

              // bsdiff 调用(业界标准差分算法,Chrome/Android OTA 都用它)
              $cmd = sprintf('bsdiff %s %s %s', escapeshellarg($oldLocal),
                  escapeshellarg($newFilePath), escapeshellarg($patchPath));
              exec($cmd, $out, $code);
              if ($code !== 0) continue;

              $sha = hash_file('sha256', $patchPath);
              $size = filesize($patchPath);
              $remote = "patches/$appId/{$o['version']}_to_$newVersion.patch";
              $this->c->storage->writeStream($remote, fopen($patchPath, 'rb'));

              $this->c->withMySQL(function($db) use ($appId,$o,$newVersion,$remote,$size,$sha) {
                  $stmt = $db->prepare(
                      "INSERT INTO patches(app_id,from_version,to_version,file_path,file_size,sha256)
                       VALUES(?,?,?,?,?,?)"
                  );
                  $stmt->execute([$appId, $o['version'], $newVersion, $remote, $size, $sha]);
              });
              unlink($oldLocal); unlink($patchPath);
          }
      }
  }

  解释:
  - bsdiff/bspatch 是 Google Chrome 增量更新、Android Recovery OTA、iOS App Thinning 用的同款算法
  - 典型效果:200MB 完整包 →5-20MB 差分包,90% 带宽
  - 服务端预先对最近 5 个版本生成 patch,覆盖 95%+ 设备
  - 设备拿差分包后:bspatch oldFirmware.bin newFirmware.bin patch.bin

  ---
  9. DDL

  CREATE TABLE versions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    app_id VARCHAR(64) NOT NULL,
    version VARCHAR(32) NOT NULL,
    build_no INT NOT NULL,
    file_path VARCHAR(256) NOT NULL,
    file_size BIGINT NOT NULL,
    sha256 CHAR(64) NOT NULL,
    signature VARCHAR(512) NOT NULL,
    force TINYINT DEFAULT 0,
    min_from VARCHAR(32) DEFAULT '',
    status ENUM('draft','gray','full','paused') DEFAULT 'draft',
    release_notes TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk(app_id, version)
  ) ENGINE=InnoDB;

  CREATE TABLE upgrade_policies (
    id INT PRIMARY KEY AUTO_INCREMENT,
    app_id VARCHAR(64), version VARCHAR(32),
    expression TEXT,          -- region in ['CN','HK'] and model startsWith 'Pixel'
    percent TINYINT DEFAULT 100
  );

  CREATE TABLE patches (
    id INT PRIMARY KEY AUTO_INCREMENT,
    app_id VARCHAR(64), from_version VARCHAR(32), to_version VARCHAR(32),
    file_path VARCHAR(256), file_size BIGINT, sha256 CHAR(64),
    UNIQUE KEY uk(app_id, from_version, to_version)
  );

  CREATE TABLE upgrade_reports (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    app_id VARCHAR(64), version VARCHAR(32), device_id VARCHAR(64),
    result ENUM('success','failed','cancelled'),
    error_msg VARCHAR(512), duration_ms INT,
    created_at DATETIME,
    KEY idx_ver(app_id, version, result),
    KEY idx_device(device_id)
  ) ENGINE=InnoDB;

  ---
  四、调用流程示例

  # 1. 设备检查升级
  curl -X POST http://127.0.0.1:9503/ota/check \
    -H "Authorization: Bearer <设备JWT>" \
    -d '{"app_id":"smart_lock","current_version":"1.2.0","build_no":120,
         "model":"SL-X1","os":"linux/arm64","region":"CN","channel":"oem-A","rom_size_mb":64}'

  # 返回
  {"code":0,"has_update":true,"force":false,"version":"1.3.0","build_no":130,
   "file_size":8500000,"sha256":"...","signature":"...","is_patch":true,
   "download_url":"/ota/download?token=abc...","release_notes":"修复指纹识别bug"}

  # 2. 下载(可断点续传)
  curl -H "Range: bytes=0-1048575" "http://127.0.0.1:9503/ota/download?token=abc..." -o patch.bin

  # 3. 上报结果
  curl -X POST http://127.0.0.1:9503/ota/report \
    -d '{"app_id":"smart_lock","version":"1.3.0","device_id":"dev-123",
         "result":"success","duration_ms":12500}'

  ---
  五、性能参考

  ┌──────────────────────┬─────────────────────────────────────────┐
  │         指标         │                  数值                   │
  ├──────────────────────┼─────────────────────────────────────────┤
  │ /ota/check QPS       │ 3-5w / 单机                             │
  ├──────────────────────┼─────────────────────────────────────────┤
  │ /ota/download 并发   │ 5000+ 长连接 / 单机(sendfile 零拷贝)    │
  ├──────────────────────┼─────────────────────────────────────────┤
  │ 平均节省带宽(差分包)80-95%                                  │
  ├──────────────────────┼─────────────────────────────────────────┤
  │ 灰度熔断响应时间     │ < 1 分钟(100 个失败上报后)              │
  ├──────────────────────┼─────────────────────────────────────────┤
  │ 内存占用             │ 每 worker ~100MB(不含 OS sendfile 缓存) │
  └──────────────────────┴─────────────────────────────────────────┘

  ---
  六、安全清单(必做)

  ┌────────────────┬────────────────────────────────────────────┐
  │      风险      │                    防御                    │
  ├────────────────┼────────────────────────────────────────────┤
  │ 设备伪造下载   │ JWT 设备身份 + 一次性下载 token            │
  ├────────────────┼────────────────────────────────────────────┤
  │ 中间人改包     │ Ed25519 签名,设备本地验证                  │
  ├────────────────┼────────────────────────────────────────────┤
  │ 老版本被打回   │ version_compare 严格 >,DB 唯一约束         │
  ├────────────────┼────────────────────────────────────────────┤
  │ URL 被分享盗刷 │ 下载 token 5 分钟过期                      │
  ├────────────────┼────────────────────────────────────────────┤
  │ 灰度名单被绕过 │ crc32(device_id) 稳定哈希,改不了           │
  ├────────────────┼────────────────────────────────────────────┤
  │ 同设备刷流量   │ Redis 限流 3/5 分钟                     │
  ├────────────────┼────────────────────────────────────────────┤
  │ 上传接口被滥用 │ 上传走内网管理 API + IP 白名单 + 双因子    │
  ├────────────────┼────────────────────────────────────────────┤
  │ 升级砖机       │ 失败率 5% 自动熔断 + 强制保留 N-1 版本回滚 │
  └────────────────┴────────────────────────────────────────────┘

  ---
  七、踩坑提示

  ┌───────────────────────────────┬────────────────────────────────────────────────────────┐
  │              坑               │                          解决                          │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ 大文件读到 PHP 内存 OOM       │ 必须用 sendfile,不要 readfile / echo file_get_contents │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ 同步生成差分包卡住上传        │ Coroutine::create 异步,或扔到 task 进程                │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ 灰度百分比抖动                │ 用 device_id 稳定哈希,别用 random                      │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ 老设备一直收不到推送          │ 加 min_from,大版本跨度强制走全量 + 引导官网升级        │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ Redis 主从切换导致 token 失效 │ token 写入主库,读主库;或用 Redis Cluster               │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ OSS 国内下载慢                │ CDN 回源 + 边缘缓存(check 接口返回 CDN URL)            │
  ├───────────────────────────────┼────────────────────────────────────────────────────────┤
  │ 设备时间不准 JWT 验签失败     │ 用 device_id 自签短 token,服务端校                     │
  └───────────────────────────────┴────────────────────────────────────────────────────────┘

  ---
  八、可扩展方向

  1. P2P 分发:大型 OTA 让设备间互传(类似 Telegram、Steam),省骨干带宽 50%+
  2. AB 双分区升级:Android A/B Partition 思路,升级失败自动回滚到 B 分区
  3. 强制窗口:某些固件必须在凌晨 2-5 点升,服务端按时间过滤
  4. 流量调度:check 接口根据 region 返回不同 CDN 节点(分省调度)
  5. 机器学习预测:基于历史成功率预测某机型升级风险,主动降级灰度比例
  6. 签名密钥轮转:支持多公钥并存,平滑替换
  7. Delta-on-Delta:连续小版本叠加差分,月级累积省带宽 99%

更多推荐