> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Flow 4: Studio API

**Lambda**: `studio-api`(studio-website-monorepo) · **触发**: 员工 UI 操作 · **状态**: 部分实施(2026-05-04)

::: details 实施溯源 + 落地状态(2026-05-04)
历史 doc 写"DEFERRED — studio-api 当前只连 DynamoDB,不连 Neon"已**过时**。当前状态:

- **已 Neon-connected**:用 `@neondatabase/serverless` HTTP driver 直接读写 Neon(raw SQL,不用 Drizzle helper)
- **已实施 4a Close**:UPDATE tasks 关闭 + 写 `task_events` 审计表
- **未实施 4a 完整原子 batch**:不写 `contact_timeline`、不更新 `contacts.lastActivityAt`、不发 SQS 给 contacts-analyzer
- **未实施 4b Postpone**:暂无 postpone 端点
- **额外端点**:reopen(重开 task)、note(改备注)、events、communications、list(读取)

Studio-api **额外**写入:`PUT /v3/calls/:id/staff` 修正 staff 归属时,写 `calls.original_staff_name` + `calls.amendment_history`(详见 [per-call-analysis-writes.md](/system-design/write-matrix/per-call-analysis-writes.md) Step ①.5)。

## :::

## 数据流概览

不调用 AI,所有值来自员工 UI 操作或系统硬编码。

```text
  员工 UI 操作 (Web)
       │
       ├─────────────────────────────┬─────────────────────┐
       │                             │                     │
  ┌────▼─────────┐              ┌────▼─────┐         ┌─────▼─────┐
  │ 4a Close     │              │ Reopen    │         │ 4b Postpone│
  │ ✅ 已实施    │              │ ✅ 已实施 │         │ ❌ 未实施  │
  └────┬─────────┘              └──────────┘         └───────────┘
       │
  ┌────▼─────────────────────────────────────┐
  │ Neon: UPDATE tasks                       │
  │   status='closed' + close_type           │
  │   closed_at=NOW() + closed_by_staff_name │
  │   close_result + close_note              │
  │   updated_at=NOW()                       │
  │ WHERE store-level filter (#214)          │
  └────┬─────────────────────────────────────┘
       │
  ┌────▼─────────────────────────────┐
  │ Neon: INSERT task_events         │
  │   eventType='task_closed'        │
  │   actorUserId + actorStaffName   │
  │   metadata={close_result, note}  │
  └──────────────────────────────────┘
       │
   ❌ 不写 contact_timeline (gap)
   ❌ 不更新 contacts.lastActivityAt (gap)
   ❌ 不发 SQS → contacts-analyzer (gap,daily cron 兜底)
```

***

## 4a. Close(关闭 Task)— ✅ 已实施

员工打完电话后从 UI 关闭 task,填 `closeResult` + `closeNote`。

**端点**:`PATCH /v2/tasks/close?taskId=<uuid>`

### 输入

**Query**:`taskId`(UUID)

**Body**(zod-validated):

| 字段            | 类型                          | 说明                                                                                                         |
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `storeId`     | `string` min(1)             | 用于权限校验 + store-level filter                                                                                |
| `staffName`   | `string` min(1) max(200)    | 操作员工                                                                                                       |
| `closeResult` | `string` min(1) max(100)    | 11 值枚举之一(见 [tasks-field-design.md](/product-design/v1/tasks-feature/tasks-field-design.md) closeResult 章节) |
| `note`        | `string` max(1000) optional | 备注                                                                                                         |

### 权限校验

1. 校验 user 对该 store 有访问权(`not_found` / `forbidden` / `authorized`)
2. 取 `store.providerAccountId` 作 `account_id` 过滤
3. 取该 store 拥有的电话号码列表,用于 store-level WHERE filter
4. 拼接 `WHERE store_phone IN (...) OR store_id = ?` — 防止跨 store 改 task(`retaintive/studio-website-monorepo#214` 修复)

### Step ①:UPDATE tasks(原子)

| 字段                     | 值                          |
| ---------------------- | -------------------------- |
| `status`               | `'closed'`                 |
| `close_type`           | `'manual_closed'`          |
| `closed_at`            | `NOW()`                    |
| `closed_by_staff_name` | `body.staffName`           |
| `close_result`         | `body.closeResult`(11 值枚举) |
| `close_note`           | `body.note`                |
| `updated_at`           | `NOW()`                    |

**WHERE 条件**:`task_id = $taskId AND account_id = $providerAccountId AND status = 'pending' AND <store-level filter>`。

**响应处理**:

- 0 行 → 检查 task 是否存在:不存在 → `404 NotFoundError`,存在但已关 → `409 ConflictError`
- 1 行 → 200 + 返回 `{taskId, status, closedAt}`

### Step ②:INSERT task\_events(审计表)

| 字段                 | 值                                  |
| ------------------ | ---------------------------------- |
| `task_id`          | `taskId`                           |
| `site_id`          | `providerAccountId`                |
| `event_type`       | `'task_closed'`                    |
| `actor_user_id`    | `userId`(JWT 解析)                   |
| `actor_staff_name` | `staffName`                        |
| `metadata`         | jsonb `{close_result, close_note}` |

> ⚠️ **当前不是原子 batch**:UPDATE tasks 和 INSERT task\_events 是两个独立 SQL 调用。前者成功后者失败 → task 已关但无 audit。后续若需原子化,改用 `db.batch()` 模式。

### 未实施的部分(gap)

| 操作                                                | 状态   | 影响                                                               |
| ------------------------------------------------- | ---- | ---------------------------------------------------------------- |
| INSERT `contact_timeline` (`task.status_changed`) | ❌ 未写 | 前端读 `contact_timeline` 看不到 manual close 事件,只能从 `task_events` 单独查 |
| UPDATE `contacts.lastActivityAt`                  | ❌ 未写 | contacts 列表排序拿不到员工互动时间,会显得过时                                     |
| SQS → contacts-analyzer(`source: 'task_close'`)   | ❌ 未发 | 关闭 task 不立刻触发 AI 重分析,等 daily 06:00 cron 兜底                       |

> 上述 gap 不阻塞功能(任务可关闭),但跟设计意图(原子 batch + 立即 re-analyze)有差距。

***

## 4b. Postpone(推迟 dueAt)— ❌ 未实施

当前没有 postpone 端点,以下是终态设计。

### 设计意图(待实施)

客户要求改约时间(如"周四再打给我"),员工推迟 `dueAt`,必须填 `reason`。

#### Step ①:UPDATE tasks

| 字段          | 值                  |
| ----------- | ------------------ |
| `dueAt`     | `payload.newDueAt` |
| `updatedAt` | `NOW()`            |

API 层校验 `status='pending'`,closed task 不可 postpone。

#### Step ②:INSERT contact\_timeline

公共字段(`contactPhone`/`franchiseId`/`accountId`/`storeId`/`eventCategory='task'`/`occurredAt=NOW()`/`actorType='staff'`)外:

| 字段           | 值                                                   |
| ------------ | --------------------------------------------------- |
| `eventType`  | `'task.due_at_changed'`                             |
| `entityType` | `'task'`                                            |
| `entityId`   | `String(task.taskId)`                               |
| `oldValue`   | `{dueAt: old_due_at}`                               |
| `newValue`   | `{dueAt: payload.newDueAt, reason: payload.reason}` |
| `actorName`  | `payload.staffName`                                 |

#### Step ③:不更新 contacts

推迟 ≠ 互动,`lastActivityAt` 不变。

#### Step ④:不触发 Contact Analysis

推迟不改变客户状态,不产生 Contact Analysis 需要的新信息。
