# YHY02 BLE 胸牌 — 服务端接入实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.

**Goal:** 在 petav3 建立一条「Android agent app → HTTPS → petav3」的通话录音上传链路，让 YHY02 BLE 胸牌的录音能落进现有 Phone Call 模块，为后续下线 dowayai 轮询做好服务端准备。

**Architecture:** 复用现有 dowayai 摄取管道的后半段 —— `MediaService` 落 GCS → `CallRecordingRepository` 建行 → `ProcessCallRecording` 转写分析 → `ClickEventRecordingMatcher` 配客户，全部一行不改。新增的只有前半段：一个 JWT 保护的 multipart 上传端点替代原来的「轮询 + 下载」。**零 migration** —— `devices.type` / `call_recordings.source` 都是 tinyint，加常量即可。

> **2026-08-18 重新基线**：本计划最初的代码调研是在 `codex/xiaochengxu` 分支上做的，
> 实施分支却是从 `origin/master` 切的（领先 325 个 commit）。master 上已经有一整套
> agent-api 基础设施，因此 **Task 1 / Task 2 作废（跳过）**，**Task 9 已按既有约定重写**。
> Task 3-8、10 的假设已逐条对着实施分支复核通过。

**Tech Stack:** Laravel 11 + `tymon/jwt-auth ^2.3`（已在 composer 依赖里，`config/jwt.php` 已存在，只是没接线）+ PHPUnit 12 + 现有 `Src\Common\Services\MediaService`（GCS）。

**硬件事实来源：** [YHY02 项目状态文档](/docs/claude-session-prompt-yhy02-ble-badge-windows-handoff.md)。本计划**不依赖硬件**，全部可用假数据测通。

**已核实的既有 API**（写计划时逐个在代码库确认过，可直接使用）：
`App\Jobs\Calls\ProcessCallRecording` · `CallRecording::withIgnored()` ·
media disk 名为 `gcs`（`config/media.php:16`）· `User->profile()` HasOne ·
`Tests\Concerns\InteractsWithAdmin::actingAsAdmin()` ·
自定义 `gsc` provider 继承 `EloquentUserProvider`（所以 JWT `attempt()` 可用，
且它的「只放行 ACTIVE 账号」规则对 JWT 路径同样生效）。

---

## 背景：为什么是这个形状

现在生产在跑的是 dowayai：`PollDowayaiRecordings` 每 60 秒登录 dowayai.com → 列录音 → 下载 → 落库。
把它逐行拆开，接缝在这里：

```
遍历 TYPE_DOWAYAI devices          ← 换：上传请求里带 device_sn
DowayaiClient::listRecordings()    ← 换：手机端 BLE/FTP 取文件
existingExternalIds() 去重          ← 换：dedupe_key 唯一索引兜底
DowayaiIngestPolicy::skipReason()  ← 【保留复用】同样 <60s 过滤
DowayaiClient::downloadAudio()     ← 换：手机端 multipart 上传
━━━━━━━━━━━━ 分界线 ━━━━━━━━━━━━
MediaService::store()              ← 原样
createFromDowayai()                ← 复制成 createFromBadgeBle()
ProcessCallRecording::dispatch()   ← 原样
ClickEventRecordingMatcher         ← 原样
```

`ClickEventRecordingMatcher` 能原样活下来是关键：它按 `admin_id` + `called_at` 时间窗匹配
（`src/Call/Services/ClickEventRecordingMatcher.php:30-36`），**不依赖电话号码**，所以换设备不影响客户配对。

---

## 文件结构

**新建**

| 文件 | 职责 |
|---|---|
| `routes/api/agent.php` | agent app 的全部路由（登录 + 上传） |
| `app/Http/Controllers/Api/Agent/AuthController.php` | JWT 签发 / 刷新 |
| `app/Http/Requests/Api/Agent/LoginRequest.php` | 登录入参校验 |
| `app/Http/Controllers/Api/Agent/CallRecordingsController.php` | 录音上传落库 |
| `app/Http/Requests/Api/Agent/StoreCallRecordingRequest.php` | 上传入参校验 |
| `app/Rules/AudioFileContents.php` | 按文件头字节校验音频（finfo 对裸 ADTS 不可用，见 Task 7） |
| `docs/modules_handbook/manage/calls/agent-upload/readMe.md` | 模块手册 |

**修改**

| 文件 | 改动 |
|---|---|
| `config/auth.php` | 加 `api` guard（jwt driver） |
| `src/People/User.php` | `implements JWTSubject` + 两个方法 |
| `app/Http/Kernel.php` | 加 `agent-api` middleware group |
| `app/Providers/RouteServiceProvider.php` | 挂载 `routes/api/agent.php` |
| `src/Device/Device.php` | `TYPE_BADGE_BLE` + `TYPES` 加 `needs_secret` |
| `src/Call/CallRecording.php` | `SOURCE_BADGE_BLE` |
| `src/Call/Support/CallImportMapper.php` | `dedupeKey()` 加 BLE 分支 |
| `src/Call/Repositories/CallRecordingRepository.php` | `createFromBadgeBle()` |
| `app/Http/Controllers/Manage/Devices/DevicesController.php` | 2 处 `TYPE_DOWAYAI` → `needsSecret()` |
| `app/Http/Requests/Manage/Devices/StoreRequest.php` | 2 处同上 |
| `resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue` | `isDowayai` → `needsSecret` |
| `resources/js/Pages/Manage/Devices/Index.vue` | 同上 |

**不动**：`PollDowayaiRecordings.php` / `DowayaiClient.php` / `DowayaiIngestPolicy.php` /
`ClickEventRecordingMatcher.php` / 任何 migration。dowayai 那条路继续跑，双轨并存。

---

## Task 1: ~~JWT guard 接线~~ —— 已在 master 上完成，跳过

**2026-08-18 重新基线**：本计划最初的调研是在 `codex/xiaochengxu` 分支上做的，
而实施分支是从 `origin/master` 切的（领先 325 个 commit）。master 上的
commit `050ae7ad`（"feat(agent-api): JWT auth door for the Android companion app"，
2026-08-04）已经把这一步做完了：

- `config/auth.php` 已有 `api` guard（`driver: jwt`）
- `src/People/User` 已 `implements JWTSubject`，两个方法都在

**本 task 唯一保留的产物**：`tests/Feature/AgentApi/JwtGuardTest.php` —— 3 个
guard 层面的不变量测试（有效凭据签出的 token 能解回同一个 user、错密码签不出、
**被封账号签不出**）。既有的 `tests/Feature/AgentApi/AgentAuthTest.php` 是走
控制器/路由的，没有直接测 `auth('api')->attempt()`，所以这是真实的新覆盖。

已提交：`a5ac7265`（后续挪到 `tests/Feature/AgentApi/` 以对齐既有目录约定）。

## Task 2: ~~Agent API 路由面 + 登录/刷新~~ —— 已在 master 上完成，跳过

同上，master 上早已存在，而且比原计划设想的更完整：

| 已有的东西 | 位置 |
|---|---|
| 路由文件 | `routes/agent-api.php`（prefix `api`，name 前缀 `agent-api.`） |
| 挂载 | `RouteServiceProvider` 用**普通 `api` middleware group** 挂载 —— 不需要新建 `agent-api` group |
| 登录 | `POST /api/agent-auth/token` → `AgentApi\AgentAuthController@store`，限流 `throttle:agent-auth` |
| 刷新 | `POST /api/agent-auth/refresh` → `@refresh`（**故意不挂 `auth:api`**，理由与原计划一致） |
| 既有写端点 | `POST /api/agent-call-events` → `AgentApi\AgentCallEventsController@store`，中间件 `['auth:api', 'agent.staff']` |
| 员工资格复查 | `app/Http/Middleware/EnsureAgentApiStaff.php`（别名 `agent.staff`）—— JWT 存活期很长（7 天访问 + 一年刷新链），所以**每个请求**重新校验 manage 角色 |

**后续 task 必须沿用这套约定，不要另起炉灶**：控制器放
`app/Http/Controllers/AgentApi/`，Form Request 放 `app/Http/Requests/AgentApi/`，
路由加进 `routes/agent-api.php`，受保护端点一律带 `['auth:api', 'agent.staff']`。

## Task 3: `Device::TYPE_BADGE_BLE` + 凭据能力位

现在 Devices 模块到处在判断「是不是 dowayai」来决定要不要密码。
BLE 胸牌是第三种类型、同样不需要密码，所以把判断从「是哪一家」改成「需不需要凭据」。

**Files:**
- Modify: `src/Device/Device.php`
- Test: `tests/Unit/Device/DeviceTypeTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Unit/Device/DeviceTypeTest.php`：

```php
<?php

namespace Tests\Unit\Device;

use PHPUnit\Framework\TestCase;
use Src\Device\Device;

class DeviceTypeTest extends TestCase
{
    public function test_every_type_declares_whether_it_needs_a_secret(): void
    {
        foreach (Device::TYPES as $id => $meta) {
            $this->assertArrayHasKey('needs_secret', $meta, "type {$id} is missing needs_secret");
            $this->assertIsBool($meta['needs_secret']);
        }
    }

    public function test_only_dowayai_needs_a_secret(): void
    {
        $this->assertTrue(Device::needsSecret(Device::TYPE_DOWAYAI));
        $this->assertFalse(Device::needsSecret(Device::TYPE_BADGE));
        $this->assertFalse(Device::needsSecret(Device::TYPE_BADGE_BLE));
    }

    public function test_an_unknown_type_needs_no_secret(): void
    {
        $this->assertFalse(Device::needsSecret(999));
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Unit/Device/DeviceTypeTest.php
```

期望：FAIL，`Undefined constant Src\Device\Device::TYPE_BADGE_BLE`

- [x] **Step 3: 改 Device 模型**

`src/Device/Device.php`：

1. 类注释里的类型说明改为：

```php
 *   - TYPE_BADGE     → identifier is the yhy WiFi smart-badge serial (device_sn) — F2f.
 *   - TYPE_DOWAYAI   → identifier is the dowayai account login — Phone Call.
 *   - TYPE_BADGE_BLE → identifier is the YHY02 BLE badge SN (== its MAC, e.g.
 *                      C8478C5BA8E1) — Phone Call, uploaded by the agent app.
```

2. 常量与 TYPES 改为：

```php
    public const TYPE_BADGE = 1;
    public const TYPE_DOWAYAI = 2;
    public const TYPE_BADGE_BLE = 3;

    /**
     * `needs_secret` says whether registering this type requires a credential.
     * It is DATA, not a branch: a dowayai account is polled with a password, a
     * badge pushes/uploads to us and has none. Both the Devices form and its
     * validation read this instead of testing for one specific vendor, so a
     * fourth device type is a row here rather than four new if-statements.
     *
     * @var array<int, array{name: string, color: string, needs_secret: bool}>
     */
    public const TYPES = [
        self::TYPE_BADGE => ['name' => 'Badge', 'color' => 'brand', 'needs_secret' => false],
        self::TYPE_DOWAYAI => ['name' => 'Dowayai', 'color' => 'sky', 'needs_secret' => true],
        self::TYPE_BADGE_BLE => ['name' => 'BLE Badge', 'color' => 'violet', 'needs_secret' => false],
    ];
```

3. 在 `hasSecret()` 之前加：

```php
    /**
     * Whether registering a device of this type requires a credential.
     *
     * @param int $type
     * @return bool
     */
    public static function needsSecret(int $type): bool
    {
        return (bool) (self::TYPES[$type]['needs_secret'] ?? false);
    }
```

- [x] **Step 4: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Unit/Device/DeviceTypeTest.php
```

期望：`OK (3 tests)`

- [x] **Step 5: 提交**

```bash
git add src/Device/Device.php tests/Unit/Device/DeviceTypeTest.php
git commit -m "feat(devices): add the BLE badge type and a needs-secret capability flag"
```

---

## Task 4: Devices 模块改为凭据驱动

把 4 处硬编码的 `TYPE_DOWAYAI` 判断换成 `needsSecret()` / `needs_secret`。
纯重构 —— dowayai 的行为必须一字不变。

**Files:**
- Modify: `app/Http/Controllers/Manage/Devices/DevicesController.php`（store + update 各一处）
- Modify: `app/Http/Requests/Manage/Devices/StoreRequest.php`（rules + withValidator 各一处）
- Modify: `resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue`
- Modify: `resources/js/Pages/Manage/Devices/Index.vue`
- Test: `tests/Feature/Devices/DeviceRegistrationTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Feature/Devices/DeviceRegistrationTest.php`：

```php
<?php

namespace Tests\Feature\Devices;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Src\Device\Device;
use Src\People\Admin;
use Src\People\User;
use Tests\Concerns\InteractsWithAdmin;
use Tests\TestCase;

/**
 * Registering a device is gated on the TYPE's capability, not on the vendor's
 * name: a type that needs a credential must prove one, a type that does not
 * must never be asked for it.
 */
class DeviceRegistrationTest extends TestCase
{
    use RefreshDatabase;
    use InteractsWithAdmin;

    private function makeSalesAdmin(): Admin
    {
        $user = User::create(['email' => 'sales@example.com', 'password' => Hash::make('password')]);

        return Admin::create(['user_id' => $user->id]);
    }

    public function test_a_ble_badge_registers_without_a_secret(): void
    {
        $this->actingAsAdmin();
        $admin = $this->makeSalesAdmin();

        $this->post('/manage/devices', [
            'admin_id' => $admin->id,
            'type' => Device::TYPE_BADGE_BLE,
            'identifier' => 'C8478C5BA8E1',
        ])->assertSessionHasNoErrors();

        $device = Device::where('identifier', 'C8478C5BA8E1')->first();
        $this->assertNotNull($device);
        $this->assertSame($admin->id, $device->admin_id);
        $this->assertFalse($device->hasSecret());
    }

    public function test_a_ble_badge_never_stores_a_secret_even_if_one_is_posted(): void
    {
        $this->actingAsAdmin();
        $admin = $this->makeSalesAdmin();

        $this->post('/manage/devices', [
            'admin_id' => $admin->id,
            'type' => Device::TYPE_BADGE_BLE,
            'identifier' => 'C8478C5BA8E2',
            'secret' => 'should-be-ignored',
        ])->assertSessionHasNoErrors();

        $this->assertFalse(Device::where('identifier', 'C8478C5BA8E2')->first()->hasSecret());
    }

    public function test_a_dowayai_account_still_requires_a_secret(): void
    {
        $this->actingAsAdmin();
        $admin = $this->makeSalesAdmin();

        $this->post('/manage/devices', [
            'admin_id' => $admin->id,
            'type' => Device::TYPE_DOWAYAI,
            'identifier' => 'someone@example.com',
        ])->assertSessionHasErrors('secret');
    }

    public function test_ble_badge_attribution_resolves_by_sn(): void
    {
        $this->actingAsAdmin();
        $admin = $this->makeSalesAdmin();

        Device::create([
            'admin_id' => $admin->id,
            'type' => Device::TYPE_BADGE_BLE,
            'identifier' => 'C8478C5BA8E1',
            'is_active' => true,
        ]);

        $this->assertSame($admin->id, Device::adminIdFor(Device::TYPE_BADGE_BLE, 'C8478C5BA8E1'));
        // A serial registered as a BLE badge must not resolve as a WiFi badge.
        $this->assertNull(Device::adminIdFor(Device::TYPE_BADGE, 'C8478C5BA8E1'));
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Feature/Devices/DeviceRegistrationTest.php
```

期望：前两个 FAIL（BLE 类型会被 `secret` required 规则挡下 / secret 被存进去）。

- [x] **Step 3: 改 StoreRequest**

`app/Http/Requests/Manage/Devices/StoreRequest.php`：

`rules()` 里 `secret` 那条改为：

```php
            // The account credential. Required only for a type that declares it
            // needs one (see Device::TYPES.needs_secret) — a dowayai account is
            // polled with a password; a badge uploads to us and has none.
            'secret' => [
                Rule::requiredIf(fn () => Device::needsSecret((int) $this->input('type'))),
                'nullable', 'string', 'max:191',
            ],
```

`withValidator()` 的第一个 guard 改为：

```php
            if (! Device::needsSecret((int) $this->input('type'))) {
                return;
            }
```

（其余不动 —— 目前唯一 `needs_secret => true` 的类型就是 dowayai，所以这段
`DowayaiClient::probe` 预检行为完全不变。）

- [x] **Step 4: 改 DevicesController**

`app/Http/Controllers/Manage/Devices/DevicesController.php`，`store()` 和 `update()` 里
那两处相同的 if 各自改为：

```php
        // Only a type that declares it needs a credential carries one; never
        // store one on a badge.
        if (Device::needsSecret((int) $request->input('type'))) {
            $data['device']['secret'] = $request->input('secret');
        }
```

`testConnection()` 里的 guard 改为：

```php
        if (! Device::needsSecret($device->type)) {
            flash()->error('Only an account with stored credentials can be tested — a badge uploads to us and has no login.');

            return back();
        }
```

- [x] **Step 5: 改前端**

`resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue`，把

```js
const isDowayai = computed(() => props.types[form.type]?.name === 'Dowayai');
```

改为

```js
// Credential-driven, not vendor-driven: the backend says which types carry one.
const needsSecret = computed(() => Boolean(props.types[form.type]?.needs_secret));
```

然后把该文件内其余所有 `isDowayai` 引用（`watch`、`needsTest`、模板里的 `v-if`、label 三元、
按钮 title）统一改成 `needsSecret`。

`resources/js/Pages/Manage/Devices/Index.vue`，把

```js
const isDowayai = (row) => props.types[row.type]?.name === 'Dowayai';
```

改为

```js
// Only a device with stored credentials has anything to test.
const canTest = (row) => Boolean(props.types[row.type]?.needs_secret);
```

并把模板里 `isDowayai(row)` 改成 `canTest(row)`。

- [x] **Step 6: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Feature/Devices/DeviceRegistrationTest.php tests/Feature/F2f/BackfillDeviceAttributionTest.php
```

期望：全部 PASS。第二个测试文件是回归保护 —— 确认 badge 注册与归属回填没被这次重构改坏。

- [x] **Step 7: 前端构建通过**

```bash
npm run build
```

期望：构建成功，无 `isDowayai is not defined` 之类错误。

- [x] **Step 8: 提交**

```bash
git add app/Http/Controllers/Manage/Devices/DevicesController.php app/Http/Requests/Manage/Devices/StoreRequest.php resources/js/Pages/Manage/Devices/Partials/DeviceFormModal.vue resources/js/Pages/Manage/Devices/Index.vue tests/Feature/Devices/DeviceRegistrationTest.php
git commit -m "refactor(devices): gate the credential field on the type capability"
```

---

## Task 5: `CallRecording::SOURCE_BADGE_BLE`

**Files:**
- Modify: `src/Call/CallRecording.php`
- Test: `tests/Unit/Call/CallRecordingSourceTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Unit/Call/CallRecordingSourceTest.php`：

```php
<?php

namespace Tests\Unit\Call;

use PHPUnit\Framework\TestCase;
use Src\Call\CallRecording;

class CallRecordingSourceTest extends TestCase
{
    public function test_the_ble_badge_source_is_a_new_value_not_a_reused_one(): void
    {
        $this->assertSame(6, CallRecording::SOURCE_BADGE_BLE);
        $this->assertNotSame(CallRecording::SOURCE_DOWAYAI, CallRecording::SOURCE_BADGE_BLE);
    }

    public function test_every_source_has_ui_metadata(): void
    {
        $this->assertArrayHasKey(CallRecording::SOURCE_BADGE_BLE, CallRecording::SOURCES);
        $this->assertSame('BLE Badge', CallRecording::SOURCES[CallRecording::SOURCE_BADGE_BLE]['name']);
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Unit/Call/CallRecordingSourceTest.php
```

期望：FAIL，`Undefined constant ... SOURCE_BADGE_BLE`

- [x] **Step 3: 加常量**

`src/Call/CallRecording.php`，`SOURCE_TEST = 5;` 之后加：

```php
    // The YHY02 BLE badge, uploaded by the agent app. A NEW value rather than
    // reusing SOURCE_DOWAYAI: during the migration both feed the same list, and
    // "which device produced this" has to stay answerable afterwards.
    public const SOURCE_BADGE_BLE = 6;
```

`SOURCES` 数组末尾加：

```php
        self::SOURCE_BADGE_BLE => ['name' => 'BLE Badge', 'color' => 'violet'],
```

- [x] **Step 4: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Unit/Call/CallRecordingSourceTest.php
```

期望：`OK (2 tests)`

- [x] **Step 5: 提交**

```bash
git add src/Call/CallRecording.php tests/Unit/Call/CallRecordingSourceTest.php
git commit -m "feat(calls): add the BLE badge recording source"
```

---

## Task 6: `dedupeKey()` 的 BLE 分支（防撞键）

`CallImportMapper::dedupeKey()` 目前**只有** `SOURCE_DOWAYAI` 走 `external_id` 分支；
其它 source 退化成 `hash(source:caller:callee:calledAt)`。BLE 行没有 caller/callee，
于是两台胸牌同一秒开录会算出**同一个 `dedupe_key`**，而该列有唯一索引 —— 第二条被静默丢弃。

**Files:**
- Modify: `src/Call/Support/CallImportMapper.php:84-101`
- Test: `tests/Unit/Call/CallImportMapperDedupeTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Unit/Call/CallImportMapperDedupeTest.php`：

```php
<?php

namespace Tests\Unit\Call;

use PHPUnit\Framework\TestCase;
use Src\Call\CallRecording;
use Src\Call\Support\CallImportMapper;

class CallImportMapperDedupeTest extends TestCase
{
    public function test_two_ble_badges_starting_in_the_same_second_do_not_collide(): void
    {
        $a = CallImportMapper::dedupeKey(
            CallRecording::SOURCE_BADGE_BLE, 'C8478C5BA8E1:20260818/131505.aac', null, null, '2026-08-18 13:15:05'
        );
        $b = CallImportMapper::dedupeKey(
            CallRecording::SOURCE_BADGE_BLE, 'C8478C5BA8E2:20260818/131505.aac', null, null, '2026-08-18 13:15:05'
        );

        $this->assertNotSame($a, $b);
    }

    public function test_the_same_ble_file_always_yields_the_same_key(): void
    {
        $args = [CallRecording::SOURCE_BADGE_BLE, 'C8478C5BA8E1:20260818/131505.aac', null, null, '2026-08-18 13:15:05'];

        $this->assertSame(
            CallImportMapper::dedupeKey(...$args),
            CallImportMapper::dedupeKey(...$args)
        );
    }

    public function test_dowayai_keying_is_unchanged(): void
    {
        $this->assertSame(
            hash('sha256', CallRecording::SOURCE_DOWAYAI . ':' . 'abc123'),
            CallImportMapper::dedupeKey(CallRecording::SOURCE_DOWAYAI, 'abc123', null, null, null)
        );
    }

    public function test_a_source_without_an_external_id_still_falls_back_to_the_phone_key(): void
    {
        $this->assertSame(
            hash('sha256', CallRecording::SOURCE_MANUAL . ':60123:60456:2026-08-18 13:15:05'),
            CallImportMapper::dedupeKey(CallRecording::SOURCE_MANUAL, null, '60123', '60456', '2026-08-18 13:15:05')
        );
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Unit/Call/CallImportMapperDedupeTest.php
```

期望：第一个 test FAIL（两个 key 相同）。

- [x] **Step 3: 改 dedupeKey**

`src/Call/Support/CallImportMapper.php`，把 docblock 和方法体改为：

```php
    /**
     * dedupe_key: any source that carries its own external id keys on
     * (source:external_id); everything else keys on
     * (source:callerDigits:calleeDigits:called_at). source uses the int CONST.
     *
     * The external-id branch is NOT dowayai-only. A BLE badge row has no caller
     * or callee digits, so the fallback would reduce to (source:::called_at) and
     * two badges that start recording in the same second would compute the SAME
     * key — and dedupe_key is UNIQUE, so the second upload would be silently
     * dropped. Its external id is "{device_sn}:{folder}/{file}", which is unique
     * per device.
     *
     * @param int         $source
     * @param string|null $externalId
     * @param string|null $callerDigits
     * @param string|null $calleeDigits
     * @param string|null $calledAt      Y-m-d H:i:s
     * @return string
     */
    public static function dedupeKey(int $source, ?string $externalId, ?string $callerDigits, ?string $calleeDigits, ?string $calledAt): string
    {
        $keyedOnExternalId = [
            CallRecording::SOURCE_DOWAYAI,
            CallRecording::SOURCE_BADGE_BLE,
        ];

        if (in_array($source, $keyedOnExternalId, true) && $externalId !== null && $externalId !== '') {
            return hash('sha256', $source . ':' . $externalId);
        }

        return hash('sha256', $source . ':' . $callerDigits . ':' . $calleeDigits . ':' . $calledAt);
    }
```

- [x] **Step 4: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Unit/Call/CallImportMapperDedupeTest.php tests/Unit/CallImportMapperTest.php
```

期望：全部 PASS。第二个文件是现有的回归保护。

- [x] **Step 5: 提交**

```bash
git add src/Call/Support/CallImportMapper.php tests/Unit/Call/CallImportMapperDedupeTest.php
git commit -m "fix(calls): key BLE badge dedupe on external id to avoid same-second collisions"
```

---

## Task 7: 音频内容校验规则

**这一步存在的理由**：PHP 的 `finfo` 把裸 ADTS AAC（设备实际产出的格式）误判成
`application/x-geoswath-rdf`。实测：

```
capture.aac  → application/x-geoswath-rdf
capture.mp3  → audio/mpeg
```

Laravel 的 `mimes:` 和 `mimetypes:` 都走这条猜测，所以**任何基于 MIME 的规则都会拒绝掉每一次真实上传**。
改为按文件头字节校验。

**Files:**
- Create: `app/Rules/AudioFileContents.php`
- Test: `tests/Unit/Rules/AudioFileContentsTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Unit/Rules/AudioFileContentsTest.php`：

```php
<?php

namespace Tests\Unit\Rules;

use App\Rules\AudioFileContents;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;

/**
 * PHP's finfo reports raw ADTS AAC — which is exactly what the YHY02 badge
 * produces — as "application/x-geoswath-rdf", so Laravel's mimes/mimetypes
 * rules reject every real upload. This rule reads the actual header bytes.
 */
class AudioFileContentsTest extends TestCase
{
    private function upload(string $bytes, string $name): UploadedFile
    {
        $path = tempnam(sys_get_temp_dir(), 'aud');
        file_put_contents($path, $bytes);

        return new UploadedFile($path, $name, null, null, true);
    }

    private function passes(UploadedFile $file): bool
    {
        return Validator::make(['audio' => $file], ['audio' => [new AudioFileContents()]])->passes();
    }

    public function test_it_accepts_raw_adts_aac(): void
    {
        // ff f1 60 40 = ADTS, MPEG-4, no CRC, AAC-LC, 16 kHz, mono — the exact
        // header the YHY02 emits.
        $this->assertTrue($this->passes($this->upload("\xFF\xF1\x60\x40" . str_repeat("\x00", 400), 'x.aac')));
    }

    public function test_it_accepts_mp3(): void
    {
        $this->assertTrue($this->passes($this->upload("\xFF\xFB" . str_repeat("\x00", 400), 'x.mp3')));
        $this->assertTrue($this->passes($this->upload('ID3' . str_repeat("\x00", 400), 'x.mp3')));
    }

    public function test_it_accepts_wav_and_ogg_and_m4a(): void
    {
        $this->assertTrue($this->passes($this->upload('RIFF' . str_repeat("\x00", 4) . 'WAVE' . str_repeat("\x00", 400), 'x.wav')));
        $this->assertTrue($this->passes($this->upload('OggS' . str_repeat("\x00", 400), 'x.ogg')));
        $this->assertTrue($this->passes($this->upload(str_repeat("\x00", 4) . 'ftyp' . str_repeat("\x00", 400), 'x.m4a')));
    }

    public function test_it_rejects_a_non_audio_file(): void
    {
        $this->assertFalse($this->passes($this->upload('%PDF-1.4' . str_repeat("\x00", 400), 'x.pdf')));
    }

    public function test_it_rejects_an_empty_file(): void
    {
        $this->assertFalse($this->passes($this->upload('', 'x.aac')));
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Unit/Rules/AudioFileContentsTest.php
```

期望：FAIL，`Class "App\Rules\AudioFileContents" not found`

- [x] **Step 3: 写规则**

创建 `app/Rules/AudioFileContents.php`：

```php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;

/**
 * Validate an uploaded audio file by its HEADER BYTES, not by its MIME type.
 *
 * Laravel's `mimes:` / `mimetypes:` rules both resolve through PHP's finfo,
 * and finfo reports RAW ADTS AAC — precisely what the YHY02 BLE badge produces
 * — as "application/x-geoswath-rdf". Measured on a real device capture:
 *
 *     capture.aac  -> application/x-geoswath-rdf
 *     capture.mp3  -> audio/mpeg
 *
 * So a MIME-based rule rejects every genuine upload. Reading the first bytes is
 * both correct for this format and a stronger check than trusting a header the
 * client controls.
 */
class AudioFileContents implements ValidationRule
{
    /** @var int How many leading bytes to inspect. */
    private const PROBE_BYTES = 12;

    /**
     * @param string  $attribute
     * @param mixed   $value
     * @param Closure $fail
     * @return void
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! $value instanceof UploadedFile || ! $value->isValid()) {
            $fail('The :attribute must be a valid uploaded file.');

            return;
        }

        $head = (string) file_get_contents($value->getRealPath(), false, null, 0, self::PROBE_BYTES);

        if (! $this->looksLikeAudio($head)) {
            $fail('The :attribute is not a recognised audio file (expected AAC/ADTS, MP3, WAV, Ogg or M4A).');
        }
    }

    /**
     * @param string $head First bytes of the file.
     * @return bool
     */
    private function looksLikeAudio(string $head): bool
    {
        if (strlen($head) < 4) {
            return false;
        }

        // ADTS AAC and MPEG audio both start with an 11-bit frame sync (FF Ex/Fx).
        if ($head[0] === "\xFF" && (ord($head[1]) & 0xE0) === 0xE0) {
            return true;
        }

        // ID3-tagged MP3.
        if (str_starts_with($head, 'ID3')) {
            return true;
        }

        // RIFF/WAVE.
        if (str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WAVE') {
            return true;
        }

        // Ogg (Vorbis/Opus).
        if (str_starts_with($head, 'OggS')) {
            return true;
        }

        // ISO-BMFF (m4a/mp4): 4-byte size then 'ftyp'.
        if (substr($head, 4, 4) === 'ftyp') {
            return true;
        }

        return false;
    }
}
```

- [x] **Step 4: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Unit/Rules/AudioFileContentsTest.php
```

期望：`OK (6 tests)`

- [x] **Step 5: 用真实设备录音验证**

如果本机有 `~/ble-probe/capture.aac`（真机抓下来的 22 秒录音），跑一次真实校验：

```bash
php -r '
require "vendor/autoload.php";
$app = require "bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$f = new Illuminate\Http\UploadedFile("/Users/dadadineiyou/ble-probe/capture.aac", "capture.aac", null, null, true);
$v = Illuminate\Support\Facades\Validator::make(["audio" => $f], ["audio" => [new App\Rules\AudioFileContents()]]);
echo $v->passes() ? "真实设备录音 通过\n" : "真实设备录音 被拒 —— 规则有问题\n";'
```

期望：`真实设备录音 通过`。若文件不存在则跳过本步。

- [x] **Step 6: 提交**

```bash
git add app/Rules/AudioFileContents.php tests/Unit/Rules/AudioFileContentsTest.php
git commit -m "feat(calls): validate uploaded audio by header bytes instead of mime"
```

---

## Task 8: `CallRecordingRepository::createFromBadgeBle()`

按 GUIDELINES §2「所有写操作走 Repository、包在 `DB::transaction` 里」，
且沿用该类现有的「一个摄取路径一个方法 + 一个白名单常量」模式
（`MANUAL_CREATE` / `DOWAYAI_CREATE` 已经是这个形状）。

**Files:**
- Modify: `src/Call/Repositories/CallRecordingRepository.php`
- Test: `tests/Feature/Call/CreateFromBadgeBleTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Feature/Call/CreateFromBadgeBleTest.php`：

```php
<?php

namespace Tests\Feature\Call;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Src\Call\CallRecording;
use Src\Call\Repositories\CallRecordingRepository;
use Tests\TestCase;

class CreateFromBadgeBleTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_a_row_with_only_whitelisted_fields(): void
    {
        $rec = (new CallRecordingRepository())->createFromBadgeBle(['call_recording' => [
            'source' => CallRecording::SOURCE_BADGE_BLE,
            'status' => CallRecording::STATUS_NEW,
            'pipeline_stage' => CallRecording::PIPELINE_QUEUED,
            'external_id' => 'C8478C5BA8E1:20260818/131505.aac',
            'admin_id' => null,
            'callee_name' => 'Kexin',
            'duration_seconds' => 181,
            'called_at' => '2026-08-18 13:15:05',
            'media_id' => null,
            'dedupe_key' => str_repeat('c', 64),
            'is_ignored' => false,
            // Not on the whitelist — must be dropped, not written.
            'lead_id' => 999999,
        ]]);

        $this->assertSame(CallRecording::SOURCE_BADGE_BLE, $rec->source);
        $this->assertSame('C8478C5BA8E1:20260818/131505.aac', $rec->external_id);
        $this->assertSame(181, $rec->duration_seconds);
        $this->assertNull($rec->lead_id, 'lead_id is not on the whitelist and must be dropped');
        $this->assertNotNull($rec->uuid);
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Feature/Call/CreateFromBadgeBleTest.php
```

期望：FAIL，`Call to undefined method ...::createFromBadgeBle()`

- [x] **Step 3: 加方法**

`src/Call/Repositories/CallRecordingRepository.php`，在 `createFromDowayai()` 之后加：

```php
    /**
     * Fields a BLE-badge upload may set on create. Same shape as
     * {@see self::DOWAYAI_CREATE} because both land the same kind of row — kept
     * as its own list so the two ingest paths can diverge without one silently
     * widening the other's surface.
     *
     * @var array<int, string>
     */
    private const BADGE_BLE_CREATE = [
        'call_recording.source', 'call_recording.status', 'call_recording.pipeline_stage',
        'call_recording.external_id', 'call_recording.admin_id', 'call_recording.callee_name',
        'call_recording.duration_seconds', 'call_recording.called_at',
        'call_recording.media_id',
        'call_recording.dedupe_key', 'call_recording.is_ignored',
    ];

    /**
     * Create a recording uploaded by the agent app from a YHY02 BLE badge. The
     * audio is stored (GCS) by the controller first; this is the plain DB write.
     *
     * @param array<string, mixed> $input
     * @return CallRecording
     * @throws \Throwable
     */
    public function createFromBadgeBle(array $input): CallRecording
    {
        $data = data_only($input, self::BADGE_BLE_CREATE);

        return DB::transaction(fn () => CallRecording::create($data['call_recording']))->refresh();
    }
```

- [x] **Step 4: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Feature/Call/CreateFromBadgeBleTest.php
```

期望：`OK (1 test)`

- [x] **Step 5: 提交**

```bash
git add src/Call/Repositories/CallRecordingRepository.php tests/Feature/Call/CreateFromBadgeBleTest.php
git commit -m "feat(calls): add the BLE badge repository create path"
```

---

## Task 9: 上传端点（照既有 AgentApi 约定重写）

**2026-08-18 重写说明**：原计划打算新建 `routes/api/agent.php` + `agent-api`
middleware group + `Api\Agent\` 命名空间。**全部作废** —— master 上已有一整套
agent-api 约定（见 Task 2），这个端点必须长成 `AgentCallEventsController` 的样子。

**契约：**

```
POST /api/agent-recordings
Authorization: Bearer <jwt>
Content-Type: multipart/form-data

audio             file    拼接完成的完整录音（AAC/MP3/WAV/Ogg/M4A）
device_sn         string  BLE 胸牌 SN，等于其 MAC，如 C8478C5BA8E1
file_name         string  设备上的相对路径，如 20260818/131505.aac
started_at        string  ISO8601，App 已按设备时区换算
duration_seconds  int     秒
```

响应沿用 `AgentCallEventsController` 的信封与状态码约定
（**首次写入 201、重放 200**，body 都是 `{success, data:{...}}`）：

```json
201 {"success":true,"data":{"id":12,"uuid":"...","status":"stored","duplicate":false}}
200 {"success":true,"data":{"id":12,"uuid":"...","status":"duplicate","duplicate":true}}
201 {"success":true,"data":{"id":13,"uuid":"...","status":"filtered","duplicate":false,"reason":"too_short"}}
```

App 端规则：**任何 2xx 都表示"服务端已收下，可以删除设备上的文件"**。

**Files:**
- Create: `app/Http/Requests/AgentApi/StoreRecordingRequest.php`
- Create: `app/Http/Controllers/AgentApi/AgentRecordingsController.php`
- Modify: `routes/agent-api.php`
- Modify: `app/Providers/AppServiceProvider.php`（限流器 `agent-recordings`）
- Test: `tests/Feature/AgentApi/AgentRecordingUploadTest.php`

- [x] **Step 1: 写失败的测试**

创建 `tests/Feature/AgentApi/AgentRecordingUploadTest.php`：

```php
<?php

namespace Tests\Feature\AgentApi;

use App\Jobs\Calls\ProcessCallRecording;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Src\Auth\Role;
use Src\Call\CallRecording;
use Src\Device\Device;
use Src\People\Admin;
use Src\People\User;
use Tests\TestCase;

/**
 * The agent app's recording upload. Three invariants carry it:
 *   - a recording is attributed to the admin who OWNS that badge, and nobody
 *     can upload for a badge they do not own;
 *   - the same file uploaded twice yields ONE row and a 2xx both times, so the
 *     app can always delete the device-side file after any 2xx;
 *   - a clip too short to be a conversation is filed as a hidden stub, exactly
 *     as the dowayai poll does, so it is never re-uploaded.
 */
class AgentRecordingUploadTest extends TestCase
{
    use RefreshDatabase;

    private User $agent;
    private Admin $admin;

    protected function setUp(): void
    {
        parent::setUp();
        Storage::fake('gcs');
        Queue::fake();

        $this->agent = $this->makeStaff('agent@example.com');
        $this->admin = Admin::create(['user_id' => $this->agent->id]);

        Device::create([
            'admin_id' => $this->admin->id,
            'type' => Device::TYPE_BADGE_BLE,
            'identifier' => 'C8478C5BA8E1',
            'is_active' => true,
        ]);
    }

    /**
     * The agent surface is staff-only (EnsureAgentApiStaff re-checks the manage
     * role on every request), so a test user must actually hold one.
     */
    private function makeStaff(string $email): User
    {
        Role::findOrCreate(Role::ADMIN, 'web');
        $user = User::create(['email' => $email, 'password' => Hash::make('secret123')]);
        $user->syncRoles([Role::ADMIN]);

        return $user;
    }

    private function tokenFor(string $email): string
    {
        return auth('api')->attempt(['email' => $email, 'password' => 'secret123']);
    }

    private function aac(): UploadedFile
    {
        $path = tempnam(sys_get_temp_dir(), 'aac');
        // ff f1 60 40 = ADTS / MPEG-4 / no CRC / AAC-LC / 16 kHz / mono —
        // the exact header the YHY02 badge emits.
        file_put_contents($path, "\xFF\xF1\x60\x40" . str_repeat("\x00", 4096));

        return new UploadedFile($path, '131505.aac', null, null, true);
    }

    private function payload(array $overrides = []): array
    {
        return array_merge([
            'audio' => $this->aac(),
            'device_sn' => 'C8478C5BA8E1',
            'file_name' => '20260818/131505.aac',
            'started_at' => '2026-08-18T13:15:05+08:00',
            'duration_seconds' => 181,
        ], $overrides);
    }

    private function upload(array $overrides = [], ?string $token = null)
    {
        return $this->post('/api/agent-recordings', $this->payload($overrides), [
            'Authorization' => 'Bearer ' . ($token ?? $this->tokenFor('agent@example.com')),
            'Accept' => 'application/json',
        ]);
    }

    public function test_it_stores_the_recording_and_attributes_it_to_the_badge_owner(): void
    {
        $this->upload()
            ->assertStatus(201)
            ->assertJson(['success' => true, 'data' => ['status' => 'stored', 'duplicate' => false]]);

        $rec = CallRecording::first();
        $this->assertSame(CallRecording::SOURCE_BADGE_BLE, $rec->source);
        $this->assertSame('C8478C5BA8E1:20260818/131505.aac', $rec->external_id);
        $this->assertSame($this->admin->id, $rec->admin_id);
        $this->assertSame(181, $rec->duration_seconds);
        // started_at is +08:00; called_at is stored UTC.
        $this->assertSame('2026-08-18 05:15:05', $rec->called_at->toDateTimeString());
        $this->assertNotNull($rec->media_id);

        Queue::assertPushed(ProcessCallRecording::class);
    }

    public function test_uploading_the_same_file_twice_yields_one_row_and_two_2xx(): void
    {
        $this->upload()->assertStatus(201)->assertJson(['data' => ['duplicate' => false]]);
        $this->upload()->assertStatus(200)->assertJson(['data' => ['status' => 'duplicate', 'duplicate' => true]]);

        $this->assertSame(1, CallRecording::withIgnored()->count());
    }

    public function test_a_clip_shorter_than_the_minimum_is_filed_as_a_hidden_stub(): void
    {
        $this->upload(['duration_seconds' => 5])
            ->assertStatus(201)
            ->assertJson(['data' => ['status' => 'filtered', 'reason' => 'too_short']]);

        $rec = CallRecording::withIgnored()->first();
        $this->assertTrue((bool) $rec->is_ignored);
        $this->assertNull($rec->media_id);
        Queue::assertNotPushed(ProcessCallRecording::class);
    }

    public function test_uploading_for_a_badge_you_do_not_own_is_403(): void
    {
        $other = $this->makeStaff('other@example.com');
        Admin::create(['user_id' => $other->id]);

        $this->upload([], $this->tokenFor('other@example.com'))->assertStatus(403);
        $this->assertSame(0, CallRecording::withIgnored()->count());
    }

    public function test_an_unregistered_badge_is_403(): void
    {
        $this->upload(['device_sn' => 'DEADBEEF0000'])->assertStatus(403);
    }

    public function test_a_staff_user_without_an_admin_row_is_422(): void
    {
        $this->makeStaff('noadmin@example.com');

        $this->upload([], $this->tokenFor('noadmin@example.com'))->assertStatus(422);
    }

    public function test_without_a_token_it_is_401(): void
    {
        $this->post('/api/agent-recordings', $this->payload(), ['Accept' => 'application/json'])
            ->assertStatus(401);
    }

    public function test_a_non_audio_file_is_422(): void
    {
        $path = tempnam(sys_get_temp_dir(), 'pdf');
        file_put_contents($path, '%PDF-1.4' . str_repeat("\x00", 400));

        $this->upload(['audio' => new UploadedFile($path, 'x.pdf', null, null, true)])
            ->assertStatus(422);
    }
}
```

- [x] **Step 2: 跑测试确认失败**

```bash
vendor/bin/phpunit tests/Feature/AgentApi/AgentRecordingUploadTest.php
```

期望：FAIL，全部 404（路由不存在）。

- [x] **Step 3: 加限流器**

`app/Providers/AppServiceProvider.php` 的 `boot()`，跟既有的 `agent-auth` 限流器放一起：

```php
        // Uploads are authenticated and idempotent, so the lane is generous — it
        // exists to stop a looping client, not to ration normal use. Keyed on the
        // user so one rep's retry storm cannot starve the rest.
        RateLimiter::for('agent-recordings', function (Request $request) {
            return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
        });
```

若该文件尚无 `RateLimiter` / `Limit` / `Request` 的 use 语句，按需补上。

- [x] **Step 4: 加路由**

`routes/agent-api.php`，在 `agent-call-events` 那条之后加：

```php
    // One row per uploaded BLE-badge recording; idempotent on
    // (device_sn, file_name). A replay answers 200 with the existing row.
    Route::post('agent-recordings', 'AgentApi\AgentRecordingsController@store')
        ->middleware(['auth:api', 'agent.staff', 'throttle:agent-recordings'])
        ->name('recordings.store');
```

- [x] **Step 5: 建 StoreRecordingRequest**

创建 `app/Http/Requests/AgentApi/StoreRecordingRequest.php`：

```php
<?php

namespace App\Http\Requests\AgentApi;

use App\Rules\AudioFileContents;
use Diver\Http\Requests\FormRequest;
use Src\Call\CallRecording;

/**
 * Validation for POST /api/agent-recordings.
 *
 * NOTE on the audio rule: `mimes:` / `mimetypes:` are deliberately NOT used.
 * Both resolve through PHP's finfo, which reports the raw ADTS AAC the YHY02
 * badge produces as "application/x-geoswath-rdf" — measured on a real device
 * capture — so a MIME rule would reject every genuine upload.
 * {@see AudioFileContents} reads the header bytes instead.
 */
class StoreRecordingRequest extends FormRequest
{
    /**
     * Device ownership is enforced in the controller, where a 403 can be told
     * apart from a 422: a badge that belongs to someone else is an
     * authorisation answer, not a malformed request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, mixed>>
     */
    public function rules(): array
    {
        return [
            'audio' => ['required', 'file', 'max:' . CallRecording::MAX_UPLOAD_KB, new AudioFileContents()],
            // The badge SN is its MAC: 12 hex chars, no separators (C8478C5BA8E1).
            'device_sn' => ['required', 'string', 'max:64'],
            // Device-side relative path, e.g. "20260818/131505.aac".
            'file_name' => ['required', 'string', 'max:80'],
            'started_at' => ['required', 'date'],
            'duration_seconds' => ['required', 'integer', 'min:0', 'max:86400'],
        ];
    }
}
```

- [x] **Step 6: 建 AgentRecordingsController**

创建 `app/Http/Controllers/AgentApi/AgentRecordingsController.php`：

```php
<?php

namespace App\Http\Controllers\AgentApi;

use App\Http\Controllers\Controller;
use App\Http\Requests\AgentApi\StoreRecordingRequest;
use App\Jobs\Calls\ProcessCallRecording;
use Carbon\Carbon;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Src\Call\CallRecording;
use Src\Call\Repositories\CallRecordingRepository;
use Src\Call\Services\ClickEventRecordingMatcher;
use Src\Call\Support\CallImportMapper;
use Src\Call\Support\DowayaiIngestPolicy;
use Src\Common\Services\MediaService;
use Src\Device\Device;

/**
 * BLE-badge recording ingest for the Android agent app.
 *
 * This is the dowayai poll turned inside out: instead of us signing into a
 * vendor cloud and downloading, the phone that already holds the audio posts
 * it here. Everything AFTER the audio lands is identical to
 * PollDowayaiRecordings — same ingest policy, same MediaService, same pipeline
 * job, same click-event pairing — so the two sources stay indistinguishable
 * downstream.
 *
 * Retries are expected (flaky mobile networks), so the write is idempotent on
 * (device_sn, file_name): a replay answers 200 with the existing row, only a
 * first write answers 201 — the same contract as AgentCallEventsController.
 */
class AgentRecordingsController extends Controller
{
    /**
     * @param CallRecordingRepository $recordings
     * @param MediaService            $media
     */
    public function __construct(
        protected CallRecordingRepository $recordings,
        protected MediaService $media
    ) {
    }

    /**
     * Store one recording uploaded from a YHY02 BLE badge.
     *
     * @param StoreRecordingRequest $request
     * @return JsonResponse
     * @throws \Throwable
     */
    public function store(StoreRecordingRequest $request): JsonResponse
    {
        // Login already refuses accounts without an Admin row, but the row can
        // vanish between token mint and upload — re-check per request.
        $admin = $request->user()->admin;

        if (! $admin) {
            return response()->json(['message' => 'Your account has no agent (admin) record — ask an administrator to link one.'], 422);
        }

        $deviceSn = (string) $request->input('device_sn');
        $fileName = (string) $request->input('file_name');

        // A badge belongs to exactly one salesperson. Authenticating proves who
        // you are; this proves the recording is yours to upload. Without it any
        // agent could post audio attributed to a colleague's badge.
        if (Device::adminIdFor(Device::TYPE_BADGE_BLE, $deviceSn) !== $admin->id) {
            return response()->json(['message' => 'This badge is not registered to you.'], 403);
        }

        // "{sn}:{path}" — the device-side file name alone is NOT unique across
        // badges (two devices can start recording in the same second), and
        // dedupe_key is UNIQUE.
        $externalId = $deviceSn . ':' . $fileName;
        $dedupeKey = CallImportMapper::dedupeKey(CallRecording::SOURCE_BADGE_BLE, $externalId, null, null, null);

        $existing = CallRecording::withIgnored()->where('dedupe_key', $dedupeKey)->first();

        if ($existing !== null) {
            return $this->respond($existing, 'duplicate', true, 200);
        }

        $duration = (int) $request->input('duration_seconds');
        $calledAt = Carbon::parse($request->input('started_at'))->utc()->toDateTimeString();
        $salesperson = $request->user()->profile
            ? $request->user()->profile->full_name
            : $request->user()->email;

        $row = [
            'source' => CallRecording::SOURCE_BADGE_BLE,
            'status' => CallRecording::STATUS_NEW,
            'external_id' => $externalId,
            'admin_id' => $admin->id,
            'callee_name' => $salesperson,
            'duration_seconds' => $duration,
            'called_at' => $calledAt,
            'dedupe_key' => $dedupeKey,
        ];

        // Same predicate the dowayai poll writes by: a clip too short to be a
        // conversation becomes a hidden stub — no audio, no pipeline — so it is
        // recorded as "seen" and never uploaded again.
        if (! DowayaiIngestPolicy::acceptsDuration($duration)) {
            $stub = $this->recordings->createFromBadgeBle(['call_recording' => array_merge($row, [
                'pipeline_stage' => CallRecording::PIPELINE_DONE,
                'is_ignored' => true,
            ])]);

            return $this->respond($stub, 'filtered', false, 201, DowayaiIngestPolicy::SKIP_TOO_SHORT);
        }

        $stored = $this->media->storeUpload(null, $request->file('audio'), [
            'collection' => 'call-audio',
            'name' => str_replace('/', '_', $externalId),
        ]);

        try {
            $recording = $this->recordings->createFromBadgeBle(['call_recording' => array_merge($row, [
                'pipeline_stage' => CallRecording::PIPELINE_QUEUED,
                'media_id' => $stored->id,
            ])]);
        } catch (UniqueConstraintViolationException $e) {
            // Two uploads of the same file raced past the SELECT above. The
            // unique index is the real arbiter; answer as a duplicate so the app
            // still gets its 2xx and deletes the device-side file.
            $winner = CallRecording::withIgnored()->where('dedupe_key', $dedupeKey)->firstOrFail();

            return $this->respond($winner, 'duplicate', true, 200);
        }

        ProcessCallRecording::dispatch($recording->id);

        try {
            app(ClickEventRecordingMatcher::class)->matchRecording($recording);
        } catch (\Throwable $e) {
            // Pairing is a guess, never a fact — a failure here must not fail an
            // upload that already succeeded.
            Log::warning('[AgentRecordings] click-event pairing failed', [
                'external_id' => $externalId,
                'error' => $e->getMessage(),
            ]);
        }

        return $this->respond($recording, 'stored', false, 201);
    }

    /**
     * The companion app's deserializer requires data.id — a 2xx without it
     * fails in Kotlin and the device retries forever (see
     * AgentCallEventsController for the same constraint).
     *
     * @param CallRecording $recording
     * @param string        $status
     * @param bool          $duplicate
     * @param int           $httpStatus
     * @param string|null   $reason
     * @return JsonResponse
     */
    private function respond(CallRecording $recording, string $status, bool $duplicate, int $httpStatus, ?string $reason = null): JsonResponse
    {
        $data = [
            'id' => $recording->id,
            'uuid' => $recording->uuid,
            'status' => $status,
            'duplicate' => $duplicate,
        ];

        if ($reason !== null) {
            $data['reason'] = $reason;
        }

        return response()->json(['success' => true, 'data' => $data], $httpStatus);
    }
}
```

- [x] **Step 7: 跑测试确认通过**

```bash
vendor/bin/phpunit tests/Feature/AgentApi/AgentRecordingUploadTest.php
```

期望：`OK (8 tests)`

- [x] **Step 8: 跑全量测试确认没打破别的**

```bash
vendor/bin/phpunit
```

期望：全绿（可能有既有的 PHPUnit deprecation 警告，那是环境层面的，与本改动无关）。

- [x] **Step 9: 提交**

```bash
git add routes/agent-api.php app/Providers/AppServiceProvider.php app/Http/Requests/AgentApi/StoreRecordingRequest.php app/Http/Controllers/AgentApi/AgentRecordingsController.php tests/Feature/AgentApi/AgentRecordingUploadTest.php
git commit -m "feat(agent-api): accept BLE badge recording uploads"
```

## Task 10: 模块手册 + 部署清单

按 `CLAUDE.md` 的要求，新模块必须有 handbook 文档，遵循
[docs/modules_handbook/README.md](/docs/modules_handbook/README.md) 的三段式结构。

**Files:**
- Create: `docs/modules_handbook/manage/calls/agent-upload/readMe.md`

- [x] **Step 1: 写文档**

创建 `docs/modules_handbook/manage/calls/agent-upload/readMe.md`：

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

## What it does

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

这是 dowayai 轮询的替代路径 —— 摄取之后的一切（存储、转写、分析、客户配对）与 dowayai 完全共用。

## 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/v1/call-recordings`，multipart：
   `audio` / `device_sn` / `file_name` / `started_at` / `duration_seconds`。
3. 控制器用 `Device::adminIdFor(TYPE_BADGE_BLE, $sn)` 解出归属 admin，
   **并校验它等于当前登录用户的 admin** —— 否则 403。
4. 幂等键 `external_id = "{device_sn}:{file_name}"` → `CallImportMapper::dedupeKey()`；
   已存在则直接回 `duplicate`。
5. 时长不足 `DOWAYAI_MIN_DURATION_SECONDS`（60s）→ 建隐藏 stub（无音频、不进管道），
   回 `filtered`。
6. 否则 `MediaService::storeUpload()` 落 GCS → `createFromBadgeBle()` 建行 →
   `ProcessCallRecording::dispatch()` → `ClickEventRecordingMatcher::matchRecording()`。
7. 一律回 200（`stored` / `duplicate` / `filtered`）—— App 收到任一 200 即可删除设备上的文件。

**音频格式**：设备产出 AAC-LC / ADTS / 16 kHz / 单声道 / ≈32.4 kbps。
AAC 在 Gemini 和 Deepgram 的支持列表里，**不需要转码**。

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

## Related files

**Backend**
- `app/Http/Controllers/Api/Agent/AuthController.php` — JWT 签发 / 刷新
- `app/Http/Controllers/Api/Agent/CallRecordingsController.php` — 上传落库
- `app/Http/Requests/Api/Agent/LoginRequest.php`
- `app/Http/Requests/Api/Agent/StoreCallRecordingRequest.php`
- `app/Rules/AudioFileContents.php` — 按文件头字节校验音频
- `src/Call/Repositories/CallRecordingRepository.php` — `createFromBadgeBle()`
- `src/Call/Support/CallImportMapper.php` — `dedupeKey()` 的 external-id 分支
- `src/Call/Support/DowayaiIngestPolicy.php` — 复用的时长过滤
- `src/Device/Device.php` — `TYPE_BADGE_BLE`、`needsSecret()`、`adminIdFor()`
- `src/Call/CallRecording.php` — `SOURCE_BADGE_BLE`

**Config**
- `config/auth.php` — `api` guard（jwt driver）
- `config/jwt.php` — 需 `php artisan jwt:secret` 生成 `JWT_SECRET`

**Routes**
- `routes/api/agent.php`（由 `RouteServiceProvider::mapAgentApiRoutes()` 以
  `agent-api` middleware group 挂载）

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

**Tests**
- `tests/Feature/Api/Agent/JwtGuardTest.php`
- `tests/Feature/Api/Agent/AuthEndpointTest.php`
- `tests/Feature/Api/Agent/CallRecordingUploadTest.php`
- `tests/Feature/Call/CreateFromBadgeBleTest.php`
- `tests/Feature/Devices/DeviceRegistrationTest.php`
- `tests/Unit/Call/CallImportMapperDedupeTest.php`
- `tests/Unit/Call/CallRecordingSourceTest.php`
- `tests/Unit/Device/DeviceTypeTest.php`
- `tests/Unit/Rules/AudioFileContentsTest.php`

## 部署清单

| 项 | 动作 |
|---|---|
| **JWT 密钥** | 生产 `.env` 执行一次 `php artisan jwt:secret`。**不要**把本地密钥同步过去 |
| **上传体积** | nginx `client_max_body_size ≥ 20M`；PHP `upload_max_filesize` / `post_max_size` ≥ 20M。50 分钟录音约 11.6 MB |
| **缓存** | `php artisan config:clear && php artisan cache:clear && php artisan route:clear` |
| **队列** | 转写走既有 lane，无新 worker |
| **设备注册** | Manage → Devices 给每台胸牌建一行：type = BLE Badge，identifier = SN（如 `C8478C5BA8E1`），绑定销售 |
| **回滚** | 本改动纯新增 + 一处重构，dowayai 轮询不受影响。回滚只需 revert，无 migration 需要回退 |
```

- [x] **Step 2: 提交**

```bash
git add docs/modules_handbook/manage/calls/agent-upload/readMe.md
git commit -m "docs(calls): add the agent upload module handbook"
```

---

## 完成标准

- [x] `vendor/bin/phpunit` 全绿
- [x] `npm run build` 成功
- [x] `php artisan route:list --path=api/agent` 列出 4 条路由（token / refresh / call-events / recordings）
- [ ] 用 curl 走通一次真实上传（需要本地 `capture.aac`）：

```bash
TOKEN=$(curl -s -X POST http://petav3.test/api/agent-auth/token \
  -H 'Accept: application/json' \
  -d 'email=你的邮箱&password=你的密码' | php -r 'echo json_decode(file_get_contents("php://stdin"))->data->token;')

curl -i -X POST http://petav3.test/api/agent-recordings \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' \
  -F "audio=@$HOME/ble-probe/capture.aac" \
  -F 'device_sn=C8478C5BA8E1' \
  -F 'file_name=20260818/131505.aac' \
  -F 'started_at=2026-08-18T13:15:05+08:00' \
  -F 'duration_seconds=181'
```

期望：首次 `HTTP/1.1 201` + `{"status":"stored",...}`；重放 `HTTP/1.1 200` + `{"status":"duplicate",...}`。
**前提是先在 Manage → Devices 里
把 `C8478C5BA8E1` 注册给你自己的 admin**，否则会得到 403（这本身也是个有效验证）。

---

## 不在本计划范围内

- **Android 端**（BLE 扫描/连接、FEE4 实时流接收、FEE6 历史同步、SoftAP+FTP、本地队列、断点续传）—— 另起一个计划
- **dowayai 下线**（删 `PollDowayaiRecordings` / `DowayaiClient` / config 段）—— 等灰度跑通、观察一个月之后
- **Devices 列表页显示设备类型筛选** —— 现有筛选已按 type 工作，新类型自动出现
