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 Step ①.5)。

:::

数据流概览

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

  员工 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):

字段类型说明
storeIdstring min(1)用于权限校验 + store-level filter
staffNamestring min(1) max(200)操作员工
closeResultstring min(1) max(100)11 值枚举之一(见 tasks-field-design.md closeResult 章节)
notestring max(1000) optional备注

权限校验

  1. 校验 user 对该 store 有访问权(not_found / forbidden / authorized)
  2. store.providerAccountIdaccount_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_atNOW()
closed_by_staff_namebody.staffName
close_resultbody.closeResult(11 值枚举)
close_notebody.note
updated_atNOW()

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_idtaskId
site_idproviderAccountId
event_type'task_closed'
actor_user_iduserId(JWT 解析)
actor_staff_namestaffName
metadatajsonb {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

字段
dueAtpayload.newDueAt
updatedAtNOW()

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'
entityIdString(task.taskId)
oldValue{dueAt: old_due_at}
newValue{dueAt: payload.newDueAt, reason: payload.reason}
actorNamepayload.staffName

Step ③:不更新 contacts

推迟 ≠ 互动,lastActivityAt 不变。

Step ④:不触发 Contact Analysis

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