# Agent Upload — YHY02 BLE 胸牌录音上传

**Portal:** Agent app (Android) · **Routes:** `agent-api.recordings.store` → `POST /api/agent-recordings` · **Auth:** JWT `api` guard（`auth:api` + `agent.staff` + `throttle:agent-recordings`）· **Nav:** 无独立 Manage 页面 —— 上传的录音直接落进 [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) 同一张列表，source 标签显示 **BLE Badge**

## What it does

接收 PropertyLab Agent app（Android）上传的通话录音，落进 Phone Call 模块。录音来自 **YHY02 BLE 胸牌**：手机通过 BLE 实时接收 AAC 流（或事后经设备 SoftAP + FTP 补传历史文件），拼成完整文件后 POST 到这里。

这是 dowayai 轮询的替代路径 —— 摄取之后的一切（存储、转写、分析、客户配对）与 dowayai 完全共用。两条路并存：`calls:poll-dowayai` 是"我们登录厂商云、拉取文件"；本条是"持手机的 App 主动推上来"（dowayai poll turned inside out）。下游无感知，唯一区别在 `call_recordings.source = SOURCE_BADGE_BLE (6)`。

## How it works

1. App 用 `POST /api/agent-auth/token`（email + password）换 JWT；过期后用 `/api/agent-auth/refresh` 续期（该端点故意不挂 `auth:api`，因为要接受已过期但仍在 `JWT_REFRESH_TTL` 内的 token）。
2. App `POST /api/agent-recordings`，multipart：`audio` / `device_sn` / `file_name` / `started_at` / `duration_seconds`。`device_sn` 是胸牌 MAC（**恰好 12 位 hex**，如 `C8478C5BA8E1`，大小写不敏感——控制器归一化为大写）；`file_name` 是设备侧相对路径（如 `20260818/131505.aac`）。
3. `EnsureAgentApiStaff`（`agent.staff`）复检 `isManageUser()` —— token 签发到上传之间角色可能被撤 → 401。
4. 控制器复检登录用户的 `admin` 行还在（token 到上传之间可能被解绑）→ 否则 422。
5. `Device::adminIdFor(TYPE_BADGE_BLE, $sn)` 解出胸牌归属 admin，**必须等于当前登录用户的 admin** → 否则 403（防止冒认他人胸牌上传录音）。
6. 幂等键 `external_id = "{device_sn}:{file_name}"`（设备文件名跨胸牌不唯一：两台设备可能同秒开录）→ `CallImportMapper::dedupeKey()` = `sha256(source:external_id)`。已存在 → 直接回 200 `duplicate`；**查找带 `withTrashed`**（与 dowayai 轮询一致）——被 Manage 删除的录音重放仍是 duplicate，不会撞唯一索引 500。SN 恰好 12 位 + file_name ≤ 80 保证 `external_id` 永远落在 varchar(100) 内。
7. 时长 < `DOWAYAI_MIN_DURATION_SECONDS`（60s）→ 建隐藏 stub（`is_ignored = true`、`pipeline_stage = done`、无音频、不进管道），回 201 `filtered` + `reason: too_short` —— 与 dowayai 轮询**同一个** `DowayaiIngestPolicy::acceptsDuration()` 谓词；stub 占住 dedupe_key，设备不会重传。
8. 否则 `MediaService::storeUpload()` 落私有 GCS（collection `call-audio`，media name 用 `{sn}_{file_name}` 反斜杠化）→ `createFromBadgeBle()` 建行（`pipeline_stage = queued`）→ `ProcessCallRecording::dispatch()`（转写/分析沿用既有管道）→ `ClickEventRecordingMatcher::matchRecording()`（click-event 配对为 suggestion；失败只记日志，**绝不拖垮已成功的上传**）。
9. 响应 `{success: true, data: {id, uuid, status, duplicate, [reason]}}`。**首次写入 201（`stored` / `filtered`），重放 200（`duplicate`）** —— 与 `agent-call-events` 同一契约：Kotlin 反序列化器要求 `data.id`，App 收到任一 2xx 即可删除设备上的文件。
10. 竞态：两次并发上传同一文件越过 SELECT → 唯一索引 `dedupe_key` 兜底，`UniqueConstraintViolationException` 捕获后按 duplicate 回 200（胜者行）；**败者刚存的 media 当场删除**（`MediaService::delete`，清理失败只记日志），不往 GCS 留孤儿文件。
11. 限流：`throttle:agent-recordings`（60/min per user，以 user id 为键、匿名回退 IP）—— 防死循环客户端，不防正常用量。

**音频格式**：设备产出 AAC-LC / ADTS / 16 kHz / 单声道 / ≈32.4 kbps。AAC 在 Gemini 和 Deepgram 支持列表，**不需要转码**。50 分钟约 11.6 MB；应用侧上限 `CallRecording::MAX_UPLOAD_KB = 102400`（100 MB）。

**为什么不用 `mimes:` 校验**：PHP 的 finfo 把裸 ADTS AAC 误判成 `application/x-geoswath-rdf`（真实设备实测），基于 MIME 的规则会拒绝每一次真实上传。改用 `App\Rules\AudioFileContents` 读文件头前 12 字节，识别 ADTS AAC / MP3 / WAV / Ogg / M4A。

**双轨说明**：本模块与 [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) 共用全部下游（Media / 转写 / 分析 / 配对 / 页面），唯一不同在摄取入口与 `source` 标签。

## Related files

**Backend — Controllers**
- [app/Http/Controllers/AgentApi/AgentRecordingsController.php](/app/Http/Controllers/AgentApi/AgentRecordingsController.php) — `store()`：admin 复检 → 归属校验 → 幂等 → stub/落库 → 派发管道；`respond()` 统一响应形状
- [app/Http/Controllers/AgentApi/AgentAuthController.php](/app/Http/Controllers/AgentApi/AgentAuthController.php) — JWT 签发 / 刷新（`agent-auth/token` + `agent-auth/refresh`，沿用）

**Backend — Requests & Rules**
- [app/Http/Requests/AgentApi/StoreRecordingRequest.php](/app/Http/Requests/AgentApi/StoreRecordingRequest.php) — `audio`（required + file + max:MAX_UPLOAD_KB + AudioFileContents）、`device_sn`（**正则 `^[0-9A-Fa-f]{12}$`**，即 MAC，大小写不敏感）、`file_name`（≤80）、`started_at`（date）、`duration_seconds`（0–86400）
- [app/Rules/AudioFileContents.php](/app/Rules/AudioFileContents.php) — 文件头字节识别音频（绕开 finfo 对裸 ADTS AAC 的误判）

**Backend — Models / Repositories / Support**
- [src/Device/Device.php](/src/Device/Device.php) — `TYPE_BADGE_BLE = 3`、`TYPES[needs_secret] = false`、`needsSecret()`、`adminIdFor()`
- [src/Call/CallRecording.php](/src/Call/CallRecording.php) — `SOURCE_BADGE_BLE = 6`、`MAX_UPLOAD_KB`、pipeline 常量
- [src/Call/Repositories/CallRecordingRepository.php](/src/Call/Repositories/CallRecordingRepository.php) — `createFromBadgeBle()`（`BADGE_BLE_CREATE` 白名单写入，事务内建行；`lead_id` 等越权字段被丢弃）
- [src/Call/Support/CallImportMapper.php](/src/Call/Support/CallImportMapper.php) — `dedupeKey()` 的 BLE 分支（external-id 键控，避免同秒撞键）
- [src/Call/Support/DowayaiIngestPolicy.php](/src/Call/Support/DowayaiIngestPolicy.php) — `acceptsDuration()` / `SKIP_TOO_SHORT`（与 dowayai 轮询共用的时长过滤）
- [src/Call/Services/ClickEventRecordingMatcher.php](/src/Call/Services/ClickEventRecordingMatcher.php) — click-event 配对 suggestion（失败不阻断上传）
- [src/Common/Services/MediaService.php](/src/Common/Services/MediaService.php) — `storeUpload()` → 私有 GCS（collection `call-audio`）。共享服务手册：[Media](/docs/modules_handbook/shared/media/readMe.md)

**Backend — Middleware / Jobs**
- [app/Http/Middleware/EnsureAgentApiStaff.php](/app/Http/Middleware/EnsureAgentApiStaff.php) — `agent.staff` 别名：每请求复检 `isManageUser()` → 401
- [app/Jobs/Calls/ProcessCallRecording.php](/app/Jobs/Calls/ProcessCallRecording.php) — 转写/分析管道入口（与 dowayai 共用）

**Config / Providers**
- [config/auth.php](/config/auth.php) — `api` guard（jwt driver、users provider）
- [config/jwt.php](/config/jwt.php) — tymon/jwt-auth；生产需 `php artisan jwt:secret` 生成 `JWT_SECRET`
- [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) — `RateLimiter::for('agent-recordings')`（60/min per user）
- [app/Providers/RouteServiceProvider.php](/app/Providers/RouteServiceProvider.php) — 挂载 `routes/agent-api.php`（plain `api` 组）

**Routes**
- [routes/agent-api.php](/routes/agent-api.php) — `POST api/agent-recordings`（`auth:api` + `agent.staff` + `throttle:agent-recordings`），同文件另有 `agent-auth/token` / `agent-auth/refresh` / `agent-call-events`

**Migrations**
- 无。`devices.type` 与 `call_recordings.source` 都是 tinyint，新增的是常量。

**Tests**
- [tests/Feature/AgentApi/AgentRecordingUploadTest.php](/tests/Feature/AgentApi/AgentRecordingUploadTest.php) — 8 个：happy path 落库归属性 / 重复上传幂等（1 行 + 两个 2xx）/ 短录音 stub / 非本人胸牌 403 / 未注册胸牌 403 / 无 admin 的 staff 422 / 无 token 401 / 非音频 422
- [tests/Unit/Rules/AudioFileContentsTest.php](/tests/Unit/Rules/AudioFileContentsTest.php) — 文件头识别：ADTS AAC / MP3 / WAV / Ogg / M4A 通过，PDF / 空文件拒绝
- [tests/Feature/Call/CreateFromBadgeBleTest.php](/tests/Feature/Call/CreateFromBadgeBleTest.php) — 白名单写入（越权字段被丢弃）
- [tests/Unit/Device/DeviceTypeTest.php](/tests/Unit/Device/DeviceTypeTest.php) · [tests/Feature/Devices/DeviceRegistrationTest.php](/tests/Feature/Devices/DeviceRegistrationTest.php) — BLE 类型 + 免密注册
- [tests/Unit/Call/CallImportMapperDedupeTest.php](/tests/Unit/Call/CallImportMapperDedupeTest.php) · [tests/Unit/Call/CallRecordingSourceTest.php](/tests/Unit/Call/CallRecordingSourceTest.php) — BLE dedupe / source 常量
- [tests/Feature/AgentApi/AgentAuthTest.php](/tests/Feature/AgentApi/AgentAuthTest.php) — JWT 端点（沿用，上传依赖）

## 部署清单

| 项 | 动作 |
|---|---|
| **JWT 密钥** | 生产 `.env` 执行一次 `php artisan jwt:secret`。**不要**把本地密钥同步过去 |
| **上传体积** | nginx `client_max_body_size ≥ 100M`；PHP `upload_max_filesize` / `post_max_size` ≥ 100M —— 与应用侧上限 `MAX_UPLOAD_KB = 102400`（100 MB）对齐，否则大文件会在应用校验前被 413 拒掉。50 分钟录音约 11.6 MB，留足余量 |
| **缓存** | `php artisan config:clear && php artisan cache:clear && php artisan route:clear` |
| **队列** | 转写走既有 lane，无新 worker |
| **设备注册** | Manage → Devices 给每台胸牌建一行：type = **BLE Badge**（violet），identifier = SN（即 MAC，如 `C8478C5BA8E1`），绑定销售（admin）。`needs_secret = false`，无密码字段 |
| **回滚** | 本改动纯新增（+ 一处 dedupe 键控重构），dowayai 轮询不受影响。回滚只需 revert，无 migration 需要回退 |
