--- todos: - id: "dep" content: "在 php82 容器执行 composer require symfony/lock:7.4.9" status: pending - id: "factory" content: "新增 app/support/OtaLockFactory.php 封装 symfony LockFactory + RedisStore(用 support\\Redis::connection()->client())" status: pending - id: "refactor" content: "重构 OtaCheckLogic 锁方法:acquire(false) 重试+SERVICE_BUSY,release(),删除手写 SET/Lua 与 support\\Redis 依赖" status: pending - id: "verify" content: "运行 phpunit + php -l + 完成门禁,确认全绿" status: pending isProject: false --- # 用 symfony/lock 7.4.9 替换 OTA 分布式锁 ## 目标与范围 - 仅替换 [app/logic/OtaCheckLogic.php](app/logic/OtaCheckLogic.php) 中的锁实现(`withDeviceFirmwareLock` / `acquireLock` / `releaseLock`)。 - 业务流程(先查后建/复用、§7.5 幂等)、错误码 `SERVICE_BUSY`、TTL/重试参数语义保持不变。 - 单测/集成测试结构不变(仍依赖 docker `redis`,跳过守卫不变)。 ## 为什么可行 - `php82` = 8.2.24,满足 symfony/lock 7.4(PHP ≥ 8.2)。 - symfony `RedisStore` 接受原生 `\Redis`,可由 `support\Redis::connection()->client()` 取得(见 `vendor/illuminate/redis/Connections/Connection.php:81` 的 `client()`)。 - symfony `Lock` 自带唯一 token + 所有者校验的 `release()`,可删除手写 Lua 释放脚本。 ```mermaid flowchart LR Logic[OtaCheckLogic] -->|create resource| Factory[OtaLockFactory] Factory -->|RedisStore| RawRedis["support Redis connection client (\\Redis)"] Logic -->|"acquire(false) 重试 / release()"| LockObj[Symfony Lock] ``` ## 改动点 ### 1. 依赖 - 在 `php82` 容器内执行(dev-environment 规则,禁止宿主机 php/composer): `docker exec -w /app/www/ai-device/ota php82 composer require symfony/lock:7.4.9` - 结果会更新 [composer.json](composer.json) / `composer.lock` / `vendor`。 ### 2. 新增 `app/support/OtaLockFactory.php` - 职责:封装 symfony 锁与 phpredis 客户端的装配,返回可用的 `LockInterface`。 - 每次调用基于「当前」连接构建 `RedisStore`(不缓存客户端,规避 webman 连接池跨请求换连接导致的陈旧句柄): ```php use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\Store\RedisStore; use support\Redis; final class OtaLockFactory { /** 基于当前 redis 连接创建一个带 TTL 的命名分布式锁 */ public static function create(string $resource, float $ttlSeconds): LockInterface { $store = new RedisStore(Redis::connection()->client()); return (new LockFactory($store))->createLock($resource, $ttlSeconds); } } ``` ### 3. 重构 [app/logic/OtaCheckLogic.php](app/logic/OtaCheckLogic.php) - 删除 `use support\Redis;`;新增 `use Symfony\Component\Lock\LockInterface;` 与 `use app\support\OtaLockFactory;`。 - 保留常量 `LOCK_KEY_PREFIX` / `LOCK_TTL_SECONDS` / `LOCK_MAX_RETRY` / `LOCK_RETRY_INTERVAL_US`。 - `withDeviceFirmwareLock`: ```php $lock = OtaLockFactory::create(self::LOCK_KEY_PREFIX . $deviceId . ':' . $firmwareId, (float) self::LOCK_TTL_SECONDS); $this->acquireOrFail($lock); try { return $criticalSection(); } finally { $this->releaseQuietly($lock); } ``` - `acquireOrFail(LockInterface $lock)`:循环 `LOCK_MAX_RETRY` 次调用 `$lock->acquire(false)`,成功 return;否则 `usleep(LOCK_RETRY_INTERVAL_US)`;用尽后 `Log::warning` + `throw new BusinessException(OtaErrorCode::SERVICE_BUSY)`。 - `releaseQuietly(LockInterface $lock)`:`try { $lock->release(); } catch (Throwable) { Log::warning(...) }`(释放失败不影响主流程,TTL 兜底)。 - 删除手写 token 生成与 Lua 释放脚本(`acquireLock`/`releaseLock` 旧实现)。 ## 验证 - `docker exec -w /app/www/ai-device/ota php82 ./vendor/bin/phpunit`(含 `OtaCheckLogicTest` 真连 redis 验证加锁/幂等)。 - 对改动文件容器内 `php -l`。 - 运行完成门禁 `~/.cursor/skills/slot-backend-completion-report/scripts/report.sh`。 ## 备注 / 取舍 - 可选:将 [composer.json](composer.json) 的 `"php": ">=8.1"` 提升为 `>=8.2` 以与 symfony/lock 对齐(非必需,运行时已是 8.2)。 - 备选方案:不新增 `OtaLockFactory`,把装配内联为 `OtaCheckLogic` 私有方法;本方案选独立 support 类以隔离基础设施、便于复用与替换 Store。 - 阻塞策略:沿用 `acquire(false)` 有界重试而非 `acquire(true)` 无限阻塞,避免阻塞 webman worker,并保留 `SERVICE_BUSY` 行为。