# 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

### Flutter unassigned uploads — September 11, 2026

The numbered legacy flow below predates the Flutter integrity/linking contract.
In particular, **do not apply its "any 2xx may delete audio" rule to Flutter**.
Flutter requires the validated durable response envelope and persists the outcome
before local cleanup. The additive unassigned lane behaves as follows:

- `GET /agent-api/agent-recordings/capabilities` advertises
  `data.unassigned_uploads: true`. It uses the same authenticated staff guard and
  recording rate limit. `/api` remains a compatibility alias.
- `POST /agent-api/agent-unassigned-recordings` accepts the existing multipart
  fields plus **required** `interaction_uuid` (immutable app capture UUID) and
  `audio_sha256`. `StoreUnassignedRecordingRequest` inherits audio-header, size,
  SN, filename and timestamp validation; the server hashes the actual file.
- `CallRecordingRepository::storeUnassignedBadge()` locks Admin, then the active
  owned BLE Device, then recording identity. It stores one ordinary visible
  `call_recordings` row with private audio, **no Lead and no fabricated call event**.
  Source is BLE Badge, direction/channel are **Unknown**, and metadata retains
  `unassigned_capture_uuid` and `badge_sn`. Physical-button capture does not prove
  that a call was incoming, or reveal its phone number.
- Short audio is retained and processed in this lane; it bypasses the legacy
  duration filter. Call history can show the salesperson and unmatched recording
  immediately, before transcription/analysis finishes. No click-event guessing is
  performed by this ingestion path. Existing CRM access controls still apply.
- Identical SN/filename/UUID/hash/size/duration/UTC timestamp retries return the
  same ID. Changed identity, deleted/ignored records, or another capture binding
  produce 409 without replacement. Badge ownership is rechecked after media
  storage. A duplicate is acknowledged only if its stored media object exists;
  missing server audio returns 409 and requires recovery, not client deletion.
- Later app assignment reuses the existing create/transition interaction API and
  deterministic upload linker, with the original capture UUID/file/hash. The
  existing row is linked, not duplicated; short audio stays visible. A competing
  CRM Lead selection or different interaction produces a conflict, not an
  overwrite. The initial unknown direction is preserved.

Rollout: deploy this backend before releasing the app. No migration, new provider
credentials, worker type or feature flag is required. In the app, automatic
unassigned upload also requires acceptance of recording disclosure **2026-09-11-v2**
for the current account. Old backends (capability 404), missing acceptance and
capability failures leave this lane disabled without blocking normal assigned
uploads. Recording notice changes must also be reflected in release/privacy copy.

The phone deliberately retains the unassigned audio after server acknowledgement.
Assigning a Lead requeues the same upload behind acknowledged interaction writes;
only the final linked acknowledgement allows local cleanup. This trades one extra
idempotent file transfer for reuse of the proven linking/outbox path. Assignment
is blocked during an in-flight or uncertain upload until that upload is confirmed.
There is no new CRM-to-phone assignment sync: a CRM-only assignment will not by
itself release the phone copy. Deleted/conflicting/missing-media cases preserve
local evidence for support rather than recreating deleted server data.

Regression coverage is in `AgentInteractionRecordingUploadTest`: short visible
capture, web history/UTC/Unknown label, integrity, ownership changes during storage,
duplicate and tombstone protection, later linking, conflicting Lead/interaction,
missing server media, and authenticated capability discovery. Queue fakes verify
dispatch, not live provider transcription. Device/provider acceptance remains a
release prerequisite; implementing this API is not a production deployment.

接收 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 需要回退 |
