跳转至

Flow 4:studio-api 写入详情

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

实施溯源 + 落地状态(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):

字段 类型 说明
storeId string min(1) 用于权限校验 + store-level filter
staffName string min(1) max(200) 操作员工
closeResult string min(1) max(100) 11 值枚举之一(见 tasks-field-design.md closeResult 章节)
note string 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_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 需要的新信息。