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 知识库
为什么当前阶段应该先做上下文注入:
-
AOVIS 是硬件 + 订阅 + 售后业务,80% 以上的客服工单需要查后台数据才能回答。没有上下文,人工客服每单需要手动去后台查用户身份和订单,AI 更是完全无法回答具体问题。
-
没有上下文的 AI 知识库只能回答通用 FAQ("如何安装摄像头"),但遇到具体用户的具体问题("我的订单 #AOV-20260401-003 为什么还没发货"),AI 没有任何数据支撑。
-
先有上下文再做 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 直接连接业务主数据库
- 安全:Chatwoot 的 PostgreSQL 是独立的(
pgvector/pgvector:pg16),直连意味着要在 GCP 上开放 AOVIS 主库端口或共享凭证。 - Schema 耦合:Chatwoot 的 contact attributes 是 flat key-value 结构,AOVIS 的 Prisma schema 是关系型深度嵌套。直接映射会产生冗余字段。
- 权限收敛:中间层可以做字段级过滤(不暴露 payment ID、完整地址),Chatwoot 直连则难以控制。
- 性能隔离:客服查询不应影响主站交易性能。
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 和等待
$chatwootready,依赖数组为空,只执行一次 - Effect 2(同步):只负责在
status/session变化时同步用户身份和 attributes setConversationCustomAttributes降级策略:如果该 API 不可用或调用失败,Phase 0 仍能保证 contact-level summary(用户身份 + 客户摘要)正常显示,不阻塞整体功能- Guest 清理策略:优先使用 Chatwoot 官方
reset()或logout()机制;仅在两者都不可用时才回退到setUser({})
4.0.3 Chatwoot 侧配置
- 登录
https://support.aovis.app→ Settings → Inbox → AOVIS → Configuration - 在 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)
交付物:
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 创建。
| Field | Type | Phase 0 | Source | Notes |
|---|---|---|---|---|
customer_id | text | MUST | User.id | 唯一标识,用于后续关联 |
customer_reference | text | SHOULD | User.customerReference | 网站 profile 卡片中的人工可读引用码 |
email_verified | checkbox | SHOULD | User.emailVerified | 登录邮箱是否已验证 |
phone | text | SHOULD | CustomerProfile.phone 或 defaultShippingPhone | 与 profile summary 对齐的联系电话 |
global_region | text | MUST | CustomerProfile.country | 网站展示口径中的 Global Region,默认 United States |
state | text | MUST | CustomerProfile.region | 网站展示口径中的 State |
language | text | SHOULD | CustomerProfile.language | 客户偏好语言 |
marketing_opt_in | checkbox | SHOULD | CustomerProfile.marketingOptIn | 市场营销订阅偏好 |
shipping_recipient_name | text | SHOULD | CustomerProfile.defaultShippingName | 默认收货人姓名 |
shipping_phone | text | SHOULD | CustomerProfile.defaultShippingPhone | 默认收货电话 |
shipping_address | text | SHOULD | formatted defaultShipping* | 给客服看的完整收货地址摘要 |
shipping_address_line1 | text | SHOULD | CustomerProfile.defaultShippingLine1 | 收货地址第一行 |
shipping_address_line2 | text | SHOULD | CustomerProfile.defaultShippingLine2 | 收货地址第二行 |
shipping_city | text | SHOULD | CustomerProfile.defaultShippingCity | 收货城市 |
shipping_state | text | SHOULD | CustomerProfile.defaultShippingState | 收货州/省 |
shipping_postal_code | text | SHOULD | CustomerProfile.defaultShippingPostalCode | 收货邮编 |
shipping_country | text | SHOULD | CustomerProfile.defaultShippingCountry | 收货国家 |
order_count | number | MUST | Order.count() where channel=direct | 判断购买历史 |
cloud_subscription_status | text | MUST | Subscription.status | active / inactive / past_due / cancelled / expired |
cellular_plan_status | text | MUST | DataPlanPurchase.expiresAt vs now | active / expired / inactive |
devices_count | number | SHOULD | DeviceOwnership.count() | 判断设备持有量 |
account_created_at | date | SHOULD | User.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 的业务上下文",每次新对话创建时写入。
| Field | Type | Phase 0 | Source | Notes |
|---|---|---|---|---|
sales_channel_context | text | MUST | derived | direct(当前阶段固定值,预留 amazon / unknown) |
latest_order_number | text | MUST | Order.orderNumber | 客服第一眼要看 |
latest_order_status | text | MUST | Order.status | PENDING / PAID / FULFILLED / CANCELLED / FAILED / REFUNDED |
latest_order_date | date | MUST | Order.createdAt | 判断时效性 |
latest_order_sku | text | MUST | OrderItem.skuSnapshot | 产品识别 |
latest_order_total | text | MUST | Order.totalAmount | 客服可读展示金额,如 $149.99,不含支付内部信息 |
latest_shipment_status | text | MUST | Shipment.status | PENDING / SHIPPED / DELIVERED / CANCELED |
latest_tracking_number | text | RESERVED | Shipment.trackingNumber | 允许 null,未来功能可能尚未上线 |
warranty_status | text | MUST | calculated | 见下方说明 |
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/空值:
| Field | Type | Phase 0 | Source | Why Reserved |
|---|---|---|---|---|
latest_tracking_number | text | RESERVED | Shipment.trackingNumber | 物流 tracking 功能可能尚未完全上线,允许 null |
处理 null 的方式:
- 后端返回
null(不是空字符串"") - Chatwoot UI 会显示为空或
- - 客服看到 null 就知道该功能尚未上线或数据缺失
- 不需要在前端做特殊处理
5.4 Out of Scope for the Current Phase
以下字段当前阶段不接入,留给后续阶段:
| Field | Why 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_reason | Phase 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: inactive 或 expired,立即确认问题,引导续费或排查 |
| 用户可能不知道自己在说什么 | 精准定位问题 |
Scenario 5:我的 cellular plan 是否有效
用户说:"我的摄像头连不上网了。"
| 无上下文 | 有上下文 |
|---|---|
| 客服需要逐个排查:是否买了套餐?是否过期? | 客服看到 cellular_plan_status: expired,直接引导续费 |
| 多轮问答 | 一步定位 |
Scenario 6:我要确认自己买的是哪个 SKU
用户说:"我买的是哪个型号?"
| 无上下文 | 有上下文 |
|---|---|
| 客服需要问购买时间 → 去后台查 → 回复 | 客服看到 latest_order_sku: NEXA-PRIME-4K,直接回复 |
| 需要用户提供更多信息 | 信息已知 |
Scenario 7:我现在还在不在保修期内
用户说:"我的设备还在保修期吗?"
| 无上下文 | 有上下文 |
|---|---|
| 客服需要查订单日期 → 手动计算 → 回复 | 客服看到 warranty_status: in_warranty 或 out_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/contextAPI 调用 - Chatwoot 不显示任何业务上下文
- 作为普通 FAQ / 售前访客处理
- 客服看到的是一个匿名访客,按标准售前流程处理
已登录用户:
- 触发
/api/support/contextAPI 调用 - Chatwoot 显示客户摘要、订单摘要、订阅状态
- 客服可以看到用户身份、订单、订阅,按售后流程处理
7. Engineering Implementation Checklist
7.1 Frontend
-
修改
components/chatwoot-widget.tsx:- 引入
useSessionfromnext-auth/react - 拆分为两个 useEffect:
- Effect 1:Chatwoot SDK 初始化 +
$chatwootready 检测(依赖数组为空,只执行一次) - Effect 2:session 变化时同步用户身份和 attributes(依赖
[status, session, syncUserAndAttributes])
- Effect 1:Chatwoot SDK 初始化 +
-
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
认证
| Endpoint | Auth Method | Who Can Access |
|---|---|---|
GET /api/support/context | NextAuth 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/contextAPI 调用 - 优先使用 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% 的场景不需要这些复杂数据
- 增加维护复杂度和调试难度
- 当前阶段目标是"先跑通最小闭环",不是"展示所有数据"
核心原则:先让已登录用户不再是匿名访客,后续再逐步补强。每一步都建立在上一步已验证的基础上,不跳步,不假设。