Skip to main content

1. Executive Summary

AOVIS Chatwoot 实例已在 GCP chatwoot-vm (us-central1-a) 部署完成,web widget 已通过 components/chatwoot-widget.tsx 嵌入 aovis.app 全站,聊天功能已打通。当前 widget 以匿名模式运行,客服在 Chatwoot 面板中只能看到浏览器指纹,无法识别用户身份、看不到订单、不知道订阅状态

本文档定义工程团队可直接开工的部署方案。核心目标:在 1-2 个 sprint 内,让已登录用户在 aovis.app 打开聊天时,Chatwoot 自动识别其身份并显示最基础的客户摘要、最近 direct order 摘要、cloud/cellular 订阅状态

当前阶段边界:

  • 只处理已登录用户
  • 只处理独立站 direct orders
  • Amazon 订单不纳入自动上下文链路
  • 不做设备遥测、不做 AI 自动答、不做自动退款
  • 当前客流量不大,允许后续逐步调试修正
  • 保修期:24 个月

2. Why the Current Goal Should Be Minimal Logged-In User Context Injection

明确判断:先做已登录用户业务上下文注入,不做 AI 知识库

为什么当前阶段应该先做上下文注入:

  1. AOVIS 是硬件 + 订阅 + 售后业务,80% 以上的客服工单需要查后台数据才能回答。没有上下文,人工客服每单需要手动去后台查用户身份和订单,AI 更是完全无法回答具体问题。

  2. 没有上下文的 AI 知识库只能回答通用 FAQ("如何安装摄像头"),但遇到具体用户的具体问题("我的订单 #AOV-20260401-003 为什么还没发货"),AI 没有任何数据支撑。

  3. 先有上下文再做 AI 的正确顺序:让人工客服先在 Chatwoot 中看到用户身份、订单、订阅 → 验证数据模型和字段映射是否正确 → 再接入知识库和 AI。

为什么当前阶段不需要把问题想得过于复杂:

  • 当前是内测阶段,客流量不大,允许逐步调试
  • 不需要做完美客服平台,只需要解决"已登录客户不再是匿名访客"
  • 字段可以先预留,未上线数据返回 null
  • 后续可以逐步修正和补强

为什么边界严格限定为"已登录用户 + direct orders":

  • 已登录用户有确定的站内账号 ID(Apple / Google / Email 登录后),可以精确关联到 User → Order → Subscription
  • Direct orders 在 AOVIS 后台有完整的 Order / OrderItem / Shipment 数据
  • Amazon 订单没有自动关联链路,需要额外开发,不纳入当前阶段

为什么匿名访客当前不需要数据库业务上下文:

  • 匿名访客没有确定的用户标识,无法关联到后台数据
  • 匿名访客作为普通 FAQ / 售前访客处理即可
  • 强制查数据库会产生无意义查询,增加系统负担

3. Recommended MVP Architecture

3.1 ASCII 架构图

Logged-in User on aovis.app
-> Chatwoot Widget (existing)
-> useSession() check
-> GET /api/support/context
-> Support Context API (new, Next.js route)
-> auth() session guard
-> Prisma query: User + Order + Subscription
-> Field mapping + minimal exposure filter
-> AOVIS Backend / PostgreSQL (Prisma)
-> User / CustomerProfile
-> Order / OrderItem / Shipment
-> Subscription / DataPlanPurchase
-> DeviceOwnership

Guest User
-> Chatwoot Widget (existing)
-> No business-context lookup
-> Standard FAQ / pre-sales flow

3.2 为什么当前阶段仍然不建议让 Chatwoot 直接连接业务主数据库

  1. 安全:Chatwoot 的 PostgreSQL 是独立的(pgvector/pgvector:pg16),直连意味着要在 GCP 上开放 AOVIS 主库端口或共享凭证。
  2. Schema 耦合:Chatwoot 的 contact attributes 是 flat key-value 结构,AOVIS 的 Prisma schema 是关系型深度嵌套。直接映射会产生冗余字段。
  3. 权限收敛:中间层可以做字段级过滤(不暴露 payment ID、完整地址),Chatwoot 直连则难以控制。
  4. 性能隔离:客服查询不应影响主站交易性能。

3.3 为什么中间层最适合做字段映射与最小暴露

中间层 Support Context API 的职责:

  • 验证身份(NextAuth session)
  • 从 Prisma 查询用户 → 订单 → 订阅
  • 字段映射:Prisma schema → Chatwoot flat attributes
  • 最小暴露过滤:移除敏感字段
  • 格式化:Decimal → string, DateTime → ISO, enum → readable label

3.4 为什么当前阶段要避免做复杂 webhook / 大规模实时同步

  • 当前客流量不大,对话创建时一次性写入 attributes 已覆盖 90% 场景
  • 复杂 webhook 增加维护成本和调试难度
  • 实时同步意味着每次客服查看对话都要触发 API 调用
  • 当前阶段目标是"先跑通",不是"做到完美"

3.5 为什么当前阶段更适合"页面加载后按登录状态拉一次最小上下文"

  • 简单可靠:用户打开聊天 → 检查登录状态 → 已登录则拉一次上下文 → 设置 Chatwoot attributes
  • 无需额外基础设施
  • 易于调试:每次打开聊天都是一次完整的上下文刷新
  • 后续可以在此基础上增加事件驱动更新

4. Phase-by-Phase Rollout Plan

Phase 0:内测阶段最小登录用户上下文注入(当前重点)

目标:已登录用户在 aovis.app 打开聊天时,Chatwoot 自动识别其身份并显示最基础的客户摘要、最近 direct order 摘要、订阅状态。

范围

  • 前端 widget 在用户已登录时调用 Chatwoot.setUser()Chatwoot.setCustomAttributes()
  • 后端新增 GET /api/support/context 端点,返回当前登录用户的支持上下文
  • Chatwoot 侧配置 contact attributes 和 conversation attributes 接收字段
  • 未登录用户不触发任何 API 调用

工程动作

4.0.1 后端:新增 Support Context API

新建文件:app/api/support/context/route.ts

重要说明:以下 Prisma 查询示例仅供参考,必须以当前 aovis.app 实际 prisma/schema.prisma 为准。字段名、关联关系、枚举值可能随迭代变化,实现时请以实际 schema 生成后的 Prisma Client 类型为准。

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

const WARRANTY_MONTHS = 24;

function calcWarrantyStatus(orderDate: Date): string {
const now = new Date();
const warrantyEnd = new Date(orderDate);
warrantyEnd.setMonth(warrantyEnd.getMonth() + WARRANTY_MONTHS);
if (now <= warrantyEnd) return "in_warranty";
return "out_of_warranty";
}

function formatDisplayAmount(decimalValue: any): string {
// Convert Prisma Decimal to a customer-readable amount string
// e.g., Decimal("149.99") -> "$149.99"
const num = Number(decimalValue);
if (isNaN(num)) return "N/A";
return `$${num.toFixed(2)}`;
}

export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
}

const userId = session.user.id;

// 1. User basic info
const user = await prisma.user.findUnique({
where: { id: userId },
include: { customerProfile: true },
});
if (!user) {
return NextResponse.json({ error: "user not found" }, { status: 404 });
}

// 2. Direct order count
const orderCount = await prisma.order.count({
where: { userId, channel: "direct" },
});

// 3. Latest direct order
const latestOrder = await prisma.order.findFirst({
where: { userId, channel: "direct" },
orderBy: { createdAt: "desc" },
include: {
items: { include: { productVariant: true } },
shipments: true,
product: true,
},
});

// 4. Cloud subscription status
const cloudSubscriptions = await prisma.subscription.findMany({
where: { userId },
include: { product: true },
});
const activeCloud = cloudSubscriptions.find(
(s) => s.status === "ACTIVE" && s.product.productType === "SERVICE"
);
const cloudSubscriptionStatus = activeCloud ? "active" : "inactive";

// 5. Cellular data plan status
const latestDataPlan = await prisma.dataPlanPurchase.findFirst({
where: { userId },
orderBy: { createdAt: "desc" },
});
let cellularPlanStatus: string;
if (latestDataPlan?.expiresAt && latestDataPlan.expiresAt > new Date()) {
cellularPlanStatus = "active";
} else if (latestDataPlan) {
cellularPlanStatus = "expired";
} else {
cellularPlanStatus = "inactive";
}

// 6. Device count
const devicesCount = await prisma.deviceOwnership.count({
where: { userId },
});

// Contact-level attributes
const contactAttributes = {
customer_id: user.id,
customer_reference: user.customerReference || null,
email_verified: Boolean(user.emailVerified),
phone: user.customerProfile?.defaultShippingPhone || user.customerProfile?.phone || null,
global_region: user.customerProfile?.country?.trim() || "United States",
state: user.customerProfile?.region || null,
language: user.customerProfile?.language || null,
marketing_opt_in: Boolean(user.customerProfile?.marketingOptIn),
order_count: orderCount,
cloud_subscription_status: cloudSubscriptionStatus,
cellular_plan_status: cellularPlanStatus,
devices_count: devicesCount,
account_created_at: user.createdAt.toISOString(),
shipping_recipient_name: user.customerProfile?.defaultShippingName || null,
shipping_phone: user.customerProfile?.defaultShippingPhone || null,
shipping_address: null,
shipping_address_line1: user.customerProfile?.defaultShippingLine1 || null,
shipping_address_line2: user.customerProfile?.defaultShippingLine2 || null,
shipping_city: user.customerProfile?.defaultShippingCity || null,
shipping_state: user.customerProfile?.defaultShippingState || null,
shipping_postal_code: user.customerProfile?.defaultShippingPostalCode || null,
shipping_country: user.customerProfile?.defaultShippingCountry || null,
};

// Conversation-level attributes (latest direct order only)
let conversationAttributes: Record<string, string | number | boolean | null> = {};
if (latestOrder) {
const shipment = latestOrder.shipments[0];
conversationAttributes = {
sales_channel_context: "direct",
latest_order_number: latestOrder.orderNumber || null,
latest_order_status: latestOrder.status,
latest_order_date: latestOrder.createdAt.toISOString(),
latest_order_sku: latestOrder.items[0]?.skuSnapshot || null,
latest_order_total: formatDisplayAmount(latestOrder.totalAmount),
latest_shipment_status: shipment?.status || null,
latest_tracking_number: shipment?.trackingNumber || null,
warranty_status: calcWarrantyStatus(latestOrder.createdAt),
};
}

return NextResponse.json({
contact_attributes: contactAttributes,
conversation_attributes: conversationAttributes,
});
} catch (error) {
console.error("[Support Context API] Error:", error);
// Return empty attributes so chat is not blocked
return NextResponse.json({
contact_attributes: {},
conversation_attributes: {},
});
}
}

4.0.2 前端:Widget 身份同步

修改文件:components/chatwoot-widget.tsx

架构变更说明:Chatwoot SDK 初始化与用户/属性同步拆分为两个独立 useEffect,避免将 SDK 加载逻辑和 session 状态耦合在同一 effect 中。

'use client';

import { useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';

export default function ChatwootWidget() {
const { data: session, status } = useSession();
const chatwootReadyRef = useRef(false);

// --- Effect 1: Initialize Chatwoot SDK (runs once) ---
useEffect(() => {
const token = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN;
const host = process.env.NEXT_PUBLIC_CHATWOOT_HOST || 'https://support.aovis.app';

if (!token) return;

(window as any).chatwootSettings = {
hideMessageBubble: false,
position: 'right',
locale: 'en',
type: 'standard',
};

(function(d, t) {
var g: any = d.createElement(t);
var s: any = d.getElementsByTagName(t)[0];
g.src = host + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
g.onload = function() {
(window as any).chatwootSDK.run({
websiteToken: token,
baseUrl: host,
});

const waitForChatwoot = setInterval(() => {
if ((window as any).$chatwoot) {
clearInterval(waitForChatwoot);
chatwootReadyRef.current = true;
}
}, 200);
setTimeout(() => clearInterval(waitForChatwoot), 5000);
};
s.parentNode.insertBefore(g, s);
})(document, "script");
}, []);

// --- Effect 2: Sync user identity + attributes when session changes ---
const syncUserAndAttributes = useCallback(async () => {
const cw = (window as any).$chatwoot;
if (!cw || !chatwootReadyRef.current) return;

if (status === 'authenticated' && session?.user) {
// 1. Set user identity
cw.setUser({
identifier: session.user.id,
name: session.user.name || '',
email: session.user.email || '',
});

// 2. Fetch support context and set attributes
try {
const res = await fetch('/api/support/context');
if (res.ok) {
const ctx = await res.json();

// Contact-level attributes: always set
if (ctx.contact_attributes && Object.keys(ctx.contact_attributes).length > 0) {
cw.setCustomAttributes(ctx.contact_attributes);
}

// Conversation-level attributes: best-effort with fallback
// If setConversationCustomAttributes is unavailable or fails,
// Phase 0 still succeeds with contact-level summary
if (ctx.conversation_attributes && Object.keys(ctx.conversation_attributes).length > 0) {
try {
if (typeof cw.setConversationCustomAttributes === 'function') {
cw.setConversationCustomAttributes(ctx.conversation_attributes);
}
} catch (convErr) {
console.warn(
'[Chatwoot] Conversation attributes fallback (contact-level still active):',
convErr
);
}
}
}
} catch (e) {
console.warn('[Chatwoot] Failed to load support context:', e);
}
} else if (status === 'unauthenticated') {
// Guest user: do NOT query database.
// Prefer Chatwoot's official reset/clear mechanism if available;
// fall back to setUser({}) only if reset is not exposed.
if (typeof cw.reset === 'function') {
cw.reset();
} else if (typeof cw.logout === 'function') {
cw.logout();
} else {
// Last resort: clear user identity
cw.setUser({});
}
}
}, [status, session]);

useEffect(() => {
if (chatwootReadyRef.current) {
syncUserAndAttributes();
}
}, [status, session, syncUserAndAttributes]);

return null;
}

拆分说明

  • Effect 1(初始化):只负责加载 Chatwoot SDK 和等待 $chatwoot ready,依赖数组为空,只执行一次
  • Effect 2(同步):只负责在 status / session 变化时同步用户身份和 attributes
  • setConversationCustomAttributes 降级策略:如果该 API 不可用或调用失败,Phase 0 仍能保证 contact-level summary(用户身份 + 客户摘要)正常显示,不阻塞整体功能
  • Guest 清理策略:优先使用 Chatwoot 官方 reset()logout() 机制;仅在两者都不可用时才回退到 setUser({})

4.0.3 Chatwoot 侧配置

  1. 登录 https://support.aovis.app → Settings → Inbox → AOVIS → Configuration
  2. 在 Settings → Custom Attributes 中创建以下 contact attributes:
    • customer_id (text)
    • customer_reference (text)
    • email_verified (checkbox)
    • phone (text)
    • global_region (text)
    • state (text)
    • language (text)
    • marketing_opt_in (checkbox)
    • account_created_at (date)
    • order_count (number)
    • devices_count (number)
    • cloud_subscription_status (text)
    • cellular_plan_status (text)
    • shipping_recipient_name (text)
    • shipping_phone (text)
    • shipping_address (text)
    • shipping_address_line1 (text)
    • shipping_address_line2 (text)
    • shipping_city (text)
    • shipping_state (text)
    • shipping_postal_code (text)
    • shipping_country (text)
  3. 创建以下 conversation attributes:
    • sales_channel_context (text)
    • latest_order_number (text)
    • latest_order_status (text)
    • latest_order_date (date)
    • latest_order_sku (text)
    • latest_order_total (text)
    • latest_shipment_status (text)
    • latest_tracking_number (text)
    • warranty_status (text)

交付物

  • app/api/support/context/route.ts 新文件
  • components/chatwoot-widget.tsx 修改
  • Chatwoot custom attributes 配置完成
  • 已登录用户打开聊天时,Chatwoot 侧边栏显示客户摘要

风险

  • Chatwoot SDK 的 setUser() HMAC 校验当前阶段不启用,Phase 1 补上
  • setConversationCustomAttributes 可能在某些 Chatwoot 版本中不可用 → 已通过降级策略处理
  • 预留字段(如 latest_tracking_number)可能为 null,Chatwoot UI 会显示为空

明确不做什么

  • 不做设备遥测数据
  • 不做实时查询
  • 不做匿名访客画像
  • 不做自动回复/AI
  • 不做退款/售后决策
  • 不做 Amazon 订单关联

Phase 1:字段补强 + 知识库 / 人工客服增强

目标:在 Phase 0 上下文可用基础上,开启 HMAC 校验,录入知识库,增强人工客服效率。

范围

  • Chatwoot HMAC 身份校验开启
  • FAQ 知识库录入 Chatwoot Agent Bot
  • 客服侧增加"刷新上下文"按钮
  • 客服侧增加"查看完整订单详情"链接(跳转到 aovis.app admin)
  • 事件驱动属性更新(订单状态变更时更新 Chatwoot attributes)

工程动作

  • 在 Chatwoot Settings → Inbox → AOVIS → Configuration 中开启 HMAC 校验
  • 后端 GET /api/support/context 增加 HMAC 签名生成
  • 前端 setUser 时传入 identifier_hash
  • 录入 FAQ 到 Chatwoot Agent Bot
  • 在现有 webhook handler(Stripe order/subscription)中追加 Chatwoot API 调用

交付物

  • HMAC 校验开启
  • Agent Bot 配置完成 + 知识库录入
  • 客服侧"刷新"按钮
  • 事件驱动属性更新

风险

  • HMAC key 需要安全存储(环境变量,不提交)
  • 知识库质量直接影响 AI 回答准确度,需要客服团队参与审核

明确不做什么

  • 不做 AI 自动回复(只做知识库辅助人工)
  • 不做 tool calling
  • 不做自动工单分类

Phase 2:后续 AI / tool calling 扩展

目标:在上下文和知识库就绪后,接入 AI 自动应答和 tool calling。

范围

  • Chatwoot Agent Bot 接入 LLM
  • Tool calling 对接 Support Context API(复用 Phase 0 端点)
  • 设备状态查询端点
  • 蜂窝套餐用量查询端点
  • 自动意图识别和工单分类
  • 升级规则(AI 无法处理时自动转人工)

工程动作

  • 新增 GET /api/support/devices 端点
  • 新增 GET /api/support/cellular 端点
  • Chatwoot Agent Bot → 接入 LLM API
  • 定义 tools:order_lookup, subscription_lookup, device_status, cellular_plan
  • 配置升级规则

交付物

  • AI Agent 可自动回答带上下文的 Tier-1 问题
  • Tool calling 可查询订单/订阅/设备/蜂窝状态
  • 自动升级机制

明确不做什么

  • 不做 AI 自动退款/赔付决策
  • 不做 AI 直接修改数据库
  • 不做全自动客服(保留人工兜底)

5. Minimum Data Model for Chatwoot

5.1 Contact Attributes

Contact attributes 描述"这个用户是谁",在用户打开聊天时写入。 其中 email / name 由 Chatwoot 的 setUser() 内建身份字段负责,不作为 custom attribute 创建。

FieldTypePhase 0SourceNotes
customer_idtextMUSTUser.id唯一标识,用于后续关联
customer_referencetextSHOULDUser.customerReference网站 profile 卡片中的人工可读引用码
email_verifiedcheckboxSHOULDUser.emailVerified登录邮箱是否已验证
phonetextSHOULDCustomerProfile.phonedefaultShippingPhone与 profile summary 对齐的联系电话
global_regiontextMUSTCustomerProfile.country网站展示口径中的 Global Region,默认 United States
statetextMUSTCustomerProfile.region网站展示口径中的 State
languagetextSHOULDCustomerProfile.language客户偏好语言
marketing_opt_incheckboxSHOULDCustomerProfile.marketingOptIn市场营销订阅偏好
shipping_recipient_nametextSHOULDCustomerProfile.defaultShippingName默认收货人姓名
shipping_phonetextSHOULDCustomerProfile.defaultShippingPhone默认收货电话
shipping_addresstextSHOULDformatted defaultShipping*给客服看的完整收货地址摘要
shipping_address_line1textSHOULDCustomerProfile.defaultShippingLine1收货地址第一行
shipping_address_line2textSHOULDCustomerProfile.defaultShippingLine2收货地址第二行
shipping_citytextSHOULDCustomerProfile.defaultShippingCity收货城市
shipping_statetextSHOULDCustomerProfile.defaultShippingState收货州/省
shipping_postal_codetextSHOULDCustomerProfile.defaultShippingPostalCode收货邮编
shipping_countrytextSHOULDCustomerProfile.defaultShippingCountry收货国家
order_countnumberMUSTOrder.count() where channel=direct判断购买历史
cloud_subscription_statustextMUSTSubscription.statusactive / inactive / past_due / cancelled / expired
cellular_plan_statustextMUSTDataPlanPurchase.expiresAt vs nowactive / expired / inactive
devices_countnumberSHOULDDeviceOwnership.count()判断设备持有量
account_created_atdateSHOULDUser.createdAt判断新老用户

为什么推荐 cloud_subscription_status / cellular_plan_status 这种 status 字段而不是 boolean:

当前阶段虽然只有 active / inactive 两种状态,但后续会扩展到:

  • Cloud subscription: active / past_due / cancelled / expired / trialing
  • Cellular plan: active / expired / inactive

使用 status 字段可以:

  • 区分"从未购买"(inactive)和"已过期"(expired),客服处理方式不同
  • 区分"当前有效"(active)和"逾期"(past_due),客服可以主动提醒续费
  • 为后续扩展预留空间,不需要改字段类型

5.2 Conversation Attributes (Direct Orders Only)

Conversation attributes 描述"最近一笔 direct order 的业务上下文",每次新对话创建时写入。

FieldTypePhase 0SourceNotes
sales_channel_contexttextMUSTderiveddirect(当前阶段固定值,预留 amazon / unknown
latest_order_numbertextMUSTOrder.orderNumber客服第一眼要看
latest_order_statustextMUSTOrder.statusPENDING / PAID / FULFILLED / CANCELLED / FAILED / REFUNDED
latest_order_datedateMUSTOrder.createdAt判断时效性
latest_order_skutextMUSTOrderItem.skuSnapshot产品识别
latest_order_totaltextMUSTOrder.totalAmount客服可读展示金额,如 $149.99,不含支付内部信息
latest_shipment_statustextMUSTShipment.statusPENDING / SHIPPED / DELIVERED / CANCELED
latest_tracking_numbertextRESERVEDShipment.trackingNumber允许 null,未来功能可能尚未上线
warranty_statustextMUSTcalculated见下方说明

latest_order_total 定义:此字段为客服可读的展示金额,不是原始 Prisma Decimal 字符串。后端通过 formatDisplayAmount()Decimal("149.99") 格式化为 $149.99,方便客服一眼识别。不包含 payment intent ID、Stripe charge ID 等支付内部信息。

Warranty 计算逻辑

const WARRANTY_MONTHS = 24;

function calcWarrantyStatus(orderDate: Date): string {
const now = new Date();
const warrantyEnd = new Date(orderDate);
warrantyEnd.setMonth(warrantyEnd.getMonth() + WARRANTY_MONTHS);
if (now <= warrantyEnd) return "in_warranty";
return "out_of_warranty";
}

保修期计算锚点(Phase 0 规则):当前阶段按 direct order 的 Order.createdAt(订单创建日期)+ 24 个月计算。后续如业务规则调整(例如改为按发货日期 shipment.shippedAt 或签收日期计算),只需修改计算锚点,字段名 warranty_status 和枚举值 in_warranty / out_of_warranty / unknown 保持不变。

warranty_status 推荐枚举值

  • in_warranty — 订单日期起 24 个月内
  • out_of_warranty — 订单日期起超过 24 个月
  • unknown — 无订单数据时返回

不推荐 not_started:保修期从订单日期开始计算,不存在"未开始"的状态。如果用户有订单,保修期就已经开始;如果没有订单,返回 unknown

5.3 Reserved / Null-Allowed Fields

以下字段在 Phase 0 中预留位置,允许返回 null/空值:

FieldTypePhase 0SourceWhy Reserved
latest_tracking_numbertextRESERVEDShipment.trackingNumber物流 tracking 功能可能尚未完全上线,允许 null

处理 null 的方式

  • 后端返回 null(不是空字符串 ""
  • Chatwoot UI 会显示为空或 -
  • 客服看到 null 就知道该功能尚未上线或数据缺失
  • 不需要在前端做特殊处理

5.4 Out of Scope for the Current Phase

以下字段当前阶段不接入,留给后续阶段:

FieldWhy Out of Scope
Amazon 订单自动关联数据没有自动关联链路,需要额外开发,不纳入当前阶段
device_online_status秒级变化,Chatwoot attributes 会迅速过期;需要 IoT platform 对接
last_heartbeat高频更新,不适合 flat attributes;需要实时查询
iccid敏感信息,需要脱敏展示;Phase 2 按需查询
signal_strength实时遥测,非客服必需;增加 Chatwoot 数据库负担
plan_usage实时变化,按需查询;Phase 2 通过专用端点获取
firmware_version变化频率低,但当前阶段不需要;Phase 1 可考虑加入 contact attributes
activation_logs数据量大,需要专用界面展示;Phase 2
AI intent / escalation_reasonPhase 2 AI 功能,当前阶段不需要

为什么这些字段当前阶段不值得做

  • 设备遥测数据变化频率高,Chatwoot flat attributes 不适合存储高频变化数据
  • 当前阶段客服 90% 的场景不需要看 signal strength、last heartbeat
  • 增加 Chatwoot 数据库负担和维护复杂度
  • 当前阶段目标是"先让客服界面不再匿名",不是"展示所有数据"
  • 这些字段应留给 Phase 2 通过实时查询端点按需获取

6. Top Support Scenarios and Why This MVP Matters

Scenario 1:我已经下单了,为什么还没发货

用户说:"我上周下的单,怎么还没发货?"

无上下文有上下文
客服:"请问您的订单号是?" → 用户报单号 → 客服去后台查 → 回到聊天回复客服一眼看到 latest_order_number, latest_order_status: PAID, latest_shipment_status: PENDING,直接回复
耗时:3-5 分钟耗时:30 秒

Scenario 2:我收到了货,但激活不了

用户说:"我收到摄像头了,但激活不了。"

无上下文有上下文
客服不知道用户是否真的已购买、购买的是什么型号客服看到 latest_order_sku: NEXA-PRIME-4K, latest_shipment_status: DELIVERED, devices_count: 0,判断是新客首次激活,直接发送激活指南
需要确认订单 → 确认型号 → 再指导一步到位

Scenario 3:我的 tracking number 在哪里

用户说:"我的订单发货了吗?有 tracking number 吗?"

无上下文有上下文
客服需要去后台查订单 → 查物流 → 回复用户客服看到 latest_shipment_status: SHIPPED, latest_tracking_number: 1Z999AA10123456784(或 null),直接回复
多步骤查询一目了然

Scenario 4:我为什么看不到云存储

用户说:"我的云存储不能用了。"

无上下文有上下文
客服:"请问您有订阅云存储吗?"客服看到 cloud_subscription_status: inactiveexpired,立即确认问题,引导续费或排查
用户可能不知道自己在说什么精准定位问题

Scenario 5:我的 cellular plan 是否有效

用户说:"我的摄像头连不上网了。"

无上下文有上下文
客服需要逐个排查:是否买了套餐?是否过期?客服看到 cellular_plan_status: expired,直接引导续费
多轮问答一步定位

Scenario 6:我要确认自己买的是哪个 SKU

用户说:"我买的是哪个型号?"

无上下文有上下文
客服需要问购买时间 → 去后台查 → 回复客服看到 latest_order_sku: NEXA-PRIME-4K,直接回复
需要用户提供更多信息信息已知

Scenario 7:我现在还在不在保修期内

用户说:"我的设备还在保修期吗?"

无上下文有上下文
客服需要查订单日期 → 手动计算 → 回复客服看到 warranty_status: in_warrantyout_of_warranty,直接回复
手动计算自动计算

Scenario 8:我想退款 / 售后咨询

用户说:"我要退款。"

无上下文有上下文
客服需要查订单日期、金额、渠道、是否在保修期客服看到 latest_order_date, warranty_status, latest_order_total, sales_channel_context: direct,直接判断是否符合退款条件
多步骤查询一目了然

注意:当前阶段退款/售后由人工处理,AI 不自动决策。

Scenario 9:新客首次安装问题

用户说:"怎么安装这个摄像头?"

无上下文有上下文
客服不知道用户是刚收到货还是已经用了一段时间客服看到 account_created_at(最近注册)+ order_count: 1 + latest_shipment_status: DELIVERED,判断是新客,发送新手安装指南
可能给出过于复杂或过于简单的回答精准匹配用户阶段

Scenario 10:未登录访客 vs 已登录用户的差异处理

未登录访客

  • 不触发 /api/support/context API 调用
  • Chatwoot 不显示任何业务上下文
  • 作为普通 FAQ / 售前访客处理
  • 客服看到的是一个匿名访客,按标准售前流程处理

已登录用户

  • 触发 /api/support/context API 调用
  • Chatwoot 显示客户摘要、订单摘要、订阅状态
  • 客服可以看到用户身份、订单、订阅,按售后流程处理

7. Engineering Implementation Checklist

7.1 Frontend

  • 修改 components/chatwoot-widget.tsx

    • 引入 useSession from next-auth/react
    • 拆分为两个 useEffect
      • Effect 1:Chatwoot SDK 初始化 + $chatwoot ready 检测(依赖数组为空,只执行一次)
      • Effect 2:session 变化时同步用户身份和 attributes(依赖 [status, session, syncUserAndAttributes]
    • status === 'authenticated' 时调用 fetch('/api/support/context')
    • 调用 cw.setUser(String(session.user.id), { name, email })
    • 调用 cw.setCustomAttributes(ctx.contact_attributes)
    • 调用 cw.setConversationCustomAttributes(ctx.conversation_attributes)带 try/catch 降级,失败不影响 contact-level summary)
    • status === 'unauthenticated' 时优先调用 cw.reset()cw.logout(),两者都不可用时才回退到 cw.setUser({})
    • 添加 console.warn 错误日志(不阻塞主流程)
    • 确保 useSession 在 layout 层级可用(检查 SessionProvider 是否已包裹)
  • 匿名访客不触发任何 API 调用

  • 用户登出时通过 Chatwoot 官方机制清理身份

7.2 Backend

  • 新建 app/api/support/context/route.ts

    • 使用 auth() from @/auth 做 session 校验
    • 查询 User + CustomerProfile
    • 查询最近 1 条 direct order(channel: "direct",含 items, shipments, product
    • 查询 Subscription 列表,判断 cloud subscription status
    • 查询最近 1 条 DataPlanPurchase,判断 cellular plan status
    • 查询 DeviceOwnership.count()
    • 字段映射 + 最小暴露过滤
    • 保修期计算(Phase 0 规则:订单日期 + 24 个月,后续可改锚点但不改字段名)
    • latest_order_total 格式化为客服可读展示金额(如 $149.99),不是原始 Decimal 字符串
    • 返回 { contact_attributes, conversation_attributes }
    • 错误处理:catch 返回空 attributes(不阻塞聊天)
  • 新建 lib/support-context.ts(共享逻辑,可选)

    • calcWarrantyStatus(orderDate: Date): string
    • formatDisplayAmount(decimalValue: any): string
    • 常量 WARRANTY_MONTHS = 24
  • Phase 1 新增:

    • HMAC 签名生成逻辑
    • 事件驱动属性更新(在现有 Stripe webhook handler 中追加 Chatwoot API 调用)
  • Phase 2 新增:

    • app/api/support/devices/route.ts
    • app/api/support/cellular/route.ts
    • IoT platform 对接
    • EIoTClub API 对接

7.3 Chatwoot Configuration

  • 登录 https://support.aovis.app
  • Settings → Custom Attributes → 创建以下 contact attributes:
    • customer_id (text)
    • customer_reference (text)
    • email_verified (checkbox)
    • phone (text)
    • global_region (text)
    • state (text)
    • language (text)
    • marketing_opt_in (checkbox)
    • account_created_at (date)
    • order_count (number)
    • devices_count (number)
    • cloud_subscription_status (text)
    • cellular_plan_status (text)
    • shipping_recipient_name (text)
    • shipping_phone (text)
    • shipping_address (text)
    • shipping_address_line1 (text)
    • shipping_address_line2 (text)
    • shipping_city (text)
    • shipping_state (text)
    • shipping_postal_code (text)
    • shipping_country (text)
  • 创建以下 conversation attributes:
    • sales_channel_context (text)
    • latest_order_number (text)
    • latest_order_status (text)
    • latest_order_date (date)
    • latest_order_sku (text)
    • latest_order_total (text)
    • latest_shipment_status (text)
    • latest_tracking_number (text)
    • warranty_status (text)
  • Phase 1:开启 HMAC 校验 → 获取 hmac_key → 写入 .env.local / .env.production

7.4 Sync Strategy

当前阶段:页面加载后按登录状态拉一次最小上下文

用户打开 aovis.app 聊天
→ useSession 检查登录状态
→ 如果已登录:GET /api/support/context
→ cw.setUser() + cw.setCustomAttributes() + cw.setConversationCustomAttributes()(best-effort)
→ 完成
→ 如果未登录:cw.reset() / cw.logout() / cw.setUser({})(按优先级),不查数据库

为什么当前阶段只需要"页面加载后拉一次":

  • 当前客流量不大,对话创建时一次性写入 attributes 已覆盖 90% 场景
  • 简单可靠,易于调试
  • 无需额外基础设施
  • 每次打开聊天都是一次完整的上下文刷新

为什么当前阶段不需要复杂实时同步:

  • 复杂 webhook 增加维护成本和调试难度
  • 实时同步意味着每次客服查看对话都要触发 API 调用
  • 当前阶段目标是"先跑通",不是"做到完美"
  • 后续 Phase 1 可以在此基础上增加事件驱动更新

如何处理预留字段为空/null 的情况:

  • 后端返回 null(不是空字符串 ""
  • Chatwoot UI 会显示为空或 -
  • 客服看到 null 就知道该功能尚未上线或数据缺失
  • 不需要在前端做特殊处理

7.5 Security and Minimal Exposure

认证

EndpointAuth MethodWho Can Access
GET /api/support/contextNextAuth session已登录用户(只能查自己的)

最小信息暴露

Phase 0 不暴露的字段

  • Payment.providerPaymentId(Stripe payment intent ID)
  • ShippingAddress 完整地址对象原文(只暴露 AOVIS profile 中已有的 shipping fields 与完整摘要,不暴露支付/内部订单元数据)
  • SimCard.iccid 完整号码
  • User.customerReference 内部引用码
  • 任何 Stripe API key、webhook secret
  • 设备 UUID

环境变量

需要在 .env.local / .env.production 中新增(Phase 1):

# Chatwoot HMAC (Phase 1)
CHATWOOT_HMAC_KEY=<from-chatwoot-inbox-settings>

已登录用户处理

  • 通过 auth() 验证 NextAuth session
  • 只能查询自己的数据(where: { userId: session.user.id }
  • 返回最小必要字段

未登录用户处理

  • 不触发 /api/support/context API 调用
  • 优先使用 Chatwoot 官方 reset() / logout() 清理身份
  • 作为普通 FAQ / 售前访客处理

8. Risks, Anti-Patterns, and What Not to Do Now

反模式 1:一开始就做大而全客服中台

为什么不适合当前阶段

  • AOVIS 已经有独立站后台(admin),不需要在 Chatwoot 中重建一套 CRM
  • Chatwoot 的定位是"对话管理平台",不是 CRM
  • 大而全 CRM 需要 3-6 个月开发,而 Phase 0 只需要 1-2 周
  • 过度工程化会推迟 AI 知识库的上线时间

正确做法:Chatwoot 只展示最小必要上下文,详细信息跳转到 aovis.app admin 查看。

反模式 2:一开始就考虑 Amazon 自动订单关联

为什么不适合当前阶段

  • Amazon 订单没有自动关联链路到 AOVIS 后台用户
  • 需要额外的订单匹配逻辑(email 匹配?手动关联?)
  • 增加复杂度和维护成本
  • 当前阶段客流量不大,Amazon 订单客服可以手动处理

正确做法:当前阶段只处理 direct orders,Amazon 订单留给后续阶段。

反模式 3:让 Chatwoot 直接连接业务主数据库

为什么不适合当前阶段

  • 安全:需要在 GCP 上开放 PostgreSQL 端口或共享凭证
  • Schema 耦合:Chatwoot 的 flat attributes 无法映射 Prisma 的关系型深度嵌套
  • 性能:客服查询影响主站交易
  • 维护:每次 AOVIS schema 变更都要同步改 Chatwoot 侧

正确做法:通过 Support Context API 中间层做字段映射和权限收敛。

反模式 4:匿名访客也强行查业务上下文

为什么不适合当前阶段

  • 匿名访客没有确定的用户标识,无法关联到后台数据
  • 强制查数据库会产生无意义查询,增加系统负担
  • 匿名访客作为普通 FAQ / 售前访客处理即可

正确做法:未登录用户不触发 API 调用,优先使用 Chatwoot 官方 reset() / logout() 清理身份。

反模式 5:一开始就接设备遥测

为什么不适合当前阶段

  • 设备 online status 每分钟变化,Chatwoot attributes 会迅速过期
  • 遥测数据量大,不适合 flat key-value 存储
  • 客服 90% 的场景不需要看 signal strength、last heartbeat
  • 增加 Chatwoot 数据库负担

正确做法:遥测数据走 Phase 2 实时查询端点,按需展示。

反模式 6:一开始就做 AI 自动答

为什么不适合当前阶段

  • AI 没有用户数据支撑,只能回答通用 FAQ
  • 遇到具体问题 AI 无法回答
  • 用户会收到无效回复,损害品牌信任
  • 需要先验证上下文数据准确性

正确做法:先让上下文可用 → 人工客服验证数据准确性 → 再接入 AI。

反模式 7:一开始就做自动退款 / 自动售后决策

为什么不适合当前阶段

  • 涉及金钱的操作需要人工确认
  • AI 幻觉可能导致错误退款
  • Stripe 争议处理需要人工判断
  • 合规风险

正确做法:退款/售后由人工处理,AI 只做信息查询和 FAQ 回答。

反模式 8:因为字段未来可能变化,就什么都不敢先做

为什么不适合当前阶段

  • 当前阶段客流量不大,允许后续逐步调试修正
  • 字段可以先预留,未上线数据返回 null
  • 先跑通最小闭环,再逐步补强
  • 完美主义会推迟上线时间

正确做法:先做最小可用版本,后续根据实际使用情况调整。


9. Final Recommendation

AOVIS 当前阶段最合理的优先级顺序

Phase 0 (1-2 weeks) Phase 1 (2-3 weeks) Phase 2 (4-6 weeks)
───────────────────── ───────────────────── ─────────────────────
已登录用户身份注入 HMAC 校验 AI Agent + LLM
Customer summary 知识库录入 Tool Calling
最新 direct order 摘要 客服侧刷新按钮 设备状态查询
Latest order total 客服侧详情链接 蜂窝套餐查询
Shipment status 事件驱动属性更新 自动意图分类
Tracking number 预留位 FAQ 辅助人工 自动升级规则
24个月保修状态
Cloud/cellular subscription
未登录用户不查数据库
Amazon 订单不自动注入

当前阶段的最小闭环

已登录用户打开 aovis.app 聊天
→ useSession 确认已登录
→ GET /api/support/context
→ Chatwoot 显示:
- 用户是谁(customer_id, email, name)
- 客户摘要(order_count, devices_count, global_region, state, shipping_address, cloud_subscription_status, cellular_plan_status)
- 最近 direct order 摘要(order_number, status, date, sku, total, shipment_status, tracking_number, warranty_status)
→ 客服看到以上信息,开始对话
→ 完成

当前阶段明确包括

  • 已登录用户身份注入
  • Customer summary
  • 最新 direct order 摘要
  • Latest order total(客服可读展示金额,如 $149.99
  • Shipment status
  • Tracking number 预留位(允许 null)
  • 24 个月保修状态(Phase 0 规则:订单日期 + 24 个月,后续可改锚点但不改字段名)
  • Cloud/cellular subscription summary
  • 未登录用户不查数据库
  • Amazon 订单不自动注入

当前阶段明确不包括

  • 设备深度遥测
  • 自动退款/自动售后决策
  • AI 自动回复
  • 匿名访客画像
  • 实时数据轮询
  • Amazon 订单关联

为什么当前阶段的目标不是"完美",而是"先让客服界面不再匿名"

  • 当前是内测阶段,客流量不大,允许逐步调试
  • 客服现在面对的是匿名访客,每单需要手动查后台
  • 最小上下文注入可以立即提升客服效率 3-5 倍
  • 后续可以在此基础上逐步补强字段和功能
  • 完美主义会推迟上线时间

为什么 direct order 的 total / shipment / tracking 占位 / warranty status 应该进入当前阶段

  • latest_order_total:当前阶段已有数据(Order.totalAmount),格式化为客服可读展示金额(如 $149.99),不是原始 Decimal 字符串
  • latest_shipment_status:当前阶段已有数据(Shipment.status),客服需要知道发货状态
  • latest_tracking_number:预留位,允许 null。即使当前未完全上线,预留字段位置可以避免后续改字段名
  • warranty_status:当前阶段可计算(按 Order.createdAt + 24 个月),客服经常需要判断保修状态

为什么 Amazon 和遥测等复杂能力应明确后置

  • Amazon 订单没有自动关联链路,需要额外开发,不纳入当前阶段
  • 设备遥测数据变化频率高,Chatwoot flat attributes 不适合存储
  • 当前阶段客服 90% 的场景不需要这些复杂数据
  • 增加维护复杂度和调试难度
  • 当前阶段目标是"先跑通最小闭环",不是"展示所有数据"

核心原则:先让已登录用户不再是匿名访客,后续再逐步补强。每一步都建立在上一步已验证的基础上,不跳步,不假设。