《互联网产品的客户端架构·交易、增长与平台化》11.1 客服入口与上下文携带

用户在订单页点客服,却还要第三次报订单号;问题不在客服系统,而在入口没有携带最小上下文。本节把订单号、支付单、页面来源与最近错误码写成可校验的上下文对象,用入口白名单控制字段,用脱敏函数挡住手机号与邮箱,并复盘一次多标签页上下文串单的典型问题。

用户第三次报订单号时,客服入口已经输了

交易投诉的成本,往往在第一分钟就决定了。用户从订单详情页点「联系客服」,转人工之后却被问「请提供订单号」;从支付结果页进来,又被问「支付单号是多少」;从商品页进来,还得描述「我买的是哪个规格」。用户不会觉得这是流程严谨,只会觉得系统没有记忆。

更危险的不是体验,而是串单。客服拿着错误的订单号给出「已经退款」的结论,用户等了一周才发现退的是另一笔。上下文携带的目标不是「多传一点数据」,而是从哪个页面进来,就带够定位这一单所需的最少字段,并且只带这些字段。

上下文不是把订单对象塞进链接

最省事的做法是把整个订单对象序列化进客服链接。它有三个问题:链接会变得很长;订单里的收货地址、手机号会进入浏览器历史、日志与第三方统计;订单结构一变,旧链接全部失效。

上下文应该是一组指针,不是一份快照:

入口必须携带可以携带不携带
订单详情订单号支付单号、最近错误码金额、地址、商品明细
支付结果订单号、支付单号最近错误码渠道流水、银行卡信息
售后详情订单号最近错误码证据原图、聊天全文
商品详情商品标识最近错误码订单号(此时还没有订单)
购物车无最近错误码勾选明细、优惠券

这张表有两个判断依据。第一,客服能不能只靠这个字段打开正确的页面;第二,这个字段泄露后会不会造成比体验问题更大的损失。订单号与支付单号属于前者,收货地址与证据图片不属于。

最小集是一条白名单,不是一句约定

「只带必要的字段」写在文档里没有用,必须写成两张表:

  • ALLOWED:这个入口允许出现哪些字段。多传的字段在构造时直接丢弃。
  • REQUIRED:这个入口缺少哪些字段就无法定位。缺少时返回缺口列表,由 UI 提示用户从正确页面进入,而不是让客服猜。

两张表分开的原因是可恢复性不同。支付结果页缺少支付单号时,订单号仍然能定位订单,只是不能直接回答「这笔支付到哪一步」;订单详情页缺少订单号则完全无法工作。把「允许」与「必须」混成一张表,最后要么放行太多,要么把可恢复的场景也堵死。

错误码是唯一在所有入口都允许携带的字段,因为它回答的是「用户为什么现在找客服」,而不是「用户买的是什么」。但错误码只保留最近一条,不携带历史,也不携带服务端返回的整段错误文案。

隐私脱敏:展示层需要,不等于 URL 需要

客服工作台确实需要称呼用户、核对联系方式。但这不意味着手机号应该出现在客服链接里。正确做法是:链接只带订单号,客服端拿订单号向服务端换取已授权的联系方式;如果界面必须展示,也在端上先脱敏。

本节的模块提供两个脱敏函数:

  • maskPhone:十一位手机号保留前三后四,七到十位保留前二后二,更短的一律显示 ***。
  • maskEmail:保留本地部分前两位与完整域名,例如 alice@example.com 变成 al***@example.com。

脱敏不是加密,不能替代权限控制。它的作用是把「无意泄露」的概率降下来:日志、截图、客服转发的链接里不再出现完整号码。地址、身份证号、银行卡号则根本不应该进入上下文,连脱敏版本都不需要。

一个可运行的上下文模块

下面的模块属于 L1:只有纯函数,没有网络、定时器与全局状态。它做四件事:按入口白名单裁剪字段、校验 ID 与错误码、报告必填缺口、生成稳定的查询串。

// file: src/support/support-context.ts
// tier: L1
// verify: node --test test/support/support-context.test.ts

export type EntryPage =
  | "order-detail"
  | "payment-result"
  | "aftersale-detail"
  | "product-detail"
  | "cart"
  | "unknown";

export type ContextField = "orderId" | "paymentId" | "productId" | "errorCode";

export interface ContactInput {
  phone?: string;
  email?: string;
}

export interface SupportInput {
  entry: EntryPage;
  orderId?: string;
  paymentId?: string;
  productId?: string;
  errorCode?: string;
  contact?: ContactInput | null;
}

export interface MaskedContact {
  phone: string | null;
  email: string | null;
}

export interface SupportContext {
  entry: EntryPage;
  orderId: string | null;
  paymentId: string | null;
  productId: string | null;
  errorCode: string | null;
  contact: MaskedContact;
}

const ID_PATTERN = /^[A-Za-z0-9_-]{4,40}$/;
const ERROR_PATTERN = /^[A-Z0-9][A-Z0-9_-]{1,31}$/;

// 每个入口只允许携带渲染与定位所需的最少字段;多带的字段会被丢弃。
const ALLOWED: Record<EntryPage, ContextField[]> = {
  "order-detail": ["orderId", "paymentId", "errorCode"],
  "payment-result": ["orderId", "paymentId", "errorCode"],
  "aftersale-detail": ["orderId", "errorCode"],
  "product-detail": ["productId", "errorCode"],
  cart: ["errorCode"],
  unknown: ["errorCode"],
};

const REQUIRED: Record<EntryPage, ContextField[]> = {
  "order-detail": ["orderId"],
  "payment-result": ["orderId", "paymentId"],
  "aftersale-detail": ["orderId"],
  "product-detail": ["productId"],
  cart: [],
  unknown: [],
};

const FIELD_ORDER: ContextField[] = ["orderId", "paymentId", "productId", "errorCode"];

const LABELS: Record<ContextField, string> = {
  orderId: "订单",
  paymentId: "支付单",
  productId: "商品",
  errorCode: "错误码",
};

function cleanId(value: string | undefined): string | null {
  if (value === undefined) return null;
  const trimmed = value.trim();
  return ID_PATTERN.test(trimmed) ? trimmed : null;
}

export function sanitizeErrorCode(value: string | undefined): string | null {
  if (value === undefined) return null;
  const code = value.trim().toUpperCase();
  return ERROR_PATTERN.test(code) ? code : null;
}

export function maskPhone(value: string | undefined): string | null {
  if (value === undefined) return null;
  const digits = value.replace(/\D/g, "");
  if (digits.length === 0) return null;
  if (digits.length >= 11) {
    return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
  }
  if (digits.length >= 7) {
    return `${digits.slice(0, 2)}***${digits.slice(-2)}`;
  }
  return "***";
}

export function maskEmail(value: string | undefined): string | null {
  if (value === undefined) return null;
  const email = value.trim();
  const at = email.indexOf("@");
  if (at <= 0 || at === email.length - 1) return null;
  const local = email.slice(0, at);
  const domain = email.slice(at + 1);
  const head = local.slice(0, Math.min(2, local.length));
  return `${head}***@${domain}`;
}

export function allowedFields(entry: EntryPage): ContextField[] {
  return [...ALLOWED[entry]];
}

export function requiredFields(entry: EntryPage): ContextField[] {
  return [...REQUIRED[entry]];
}

export function buildSupportContext(input: SupportInput): SupportContext {
  const allowed = new Set(ALLOWED[input.entry]);
  const pick = (field: ContextField, value: string | null): string | null =>
    allowed.has(field) ? value : null;
  return {
    entry: input.entry,
    orderId: pick("orderId", cleanId(input.orderId)),
    paymentId: pick("paymentId", cleanId(input.paymentId)),
    productId: pick("productId", cleanId(input.productId)),
    errorCode: pick("errorCode", sanitizeErrorCode(input.errorCode)),
    contact: {
      phone: maskPhone(input.contact?.phone),
      email: maskEmail(input.contact?.email),
    },
  };
}

export function contextGaps(context: SupportContext): ContextField[] {
  return REQUIRED[context.entry].filter((field) => context[field] === null);
}

export function isComplete(context: SupportContext): boolean {
  return contextGaps(context).length === 0;
}

// 链接里只放定位用的 ID 与错误码;联系方式只用于客服端展示,不进入 URL。
export function toQuery(context: SupportContext): string {
  const pairs: Array<[string, string]> = [["entry", context.entry]];
  for (const field of FIELD_ORDER) {
    const value = context[field];
    if (value !== null) pairs.push([field, value]);
  }
  return pairs
    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
    .join("&");
}

// 同一订单旅程内跳转时补齐缺失字段;商品等不同对象不会继承订单号。
export function mergeContext(previous: SupportContext, next: SupportContext): SupportContext {
  const allowed = new Set(ALLOWED[next.entry]);
  const fill = (field: ContextField): string | null => {
    if (!allowed.has(field)) return null;
    return next[field] ?? previous[field];
  };
  return {
    entry: next.entry,
    orderId: fill("orderId"),
    paymentId: fill("paymentId"),
    productId: fill("productId"),
    errorCode: next.errorCode ?? previous.errorCode,
    contact: {
      phone: next.contact.phone ?? previous.contact.phone,
      email: next.contact.email ?? previous.contact.email,
    },
  };
}

export function contextSummary(context: SupportContext): string {
  const parts = [`来源:${context.entry}`];
  for (const field of FIELD_ORDER) {
    const value = context[field];
    if (value !== null) parts.push(`${LABELS[field]}:${value}`);
  }
  const gaps = contextGaps(context);
  if (gaps.length > 0) parts.push(`缺少:${gaps.join(",")}`);
  return parts.join(" · ");
}

四个函数值得单独解释。

buildSupportContext 先裁剪再校验。 如果先校验再裁剪,一个本不该出现在商品页的订单号会先通过格式校验,然后被静默丢掉,调用方却以为它被带上了。先裁剪的语义更明确:这个入口根本不认识这个字段。

contextGaps 与 isComplete 分开。 UI 需要知道「缺什么」才能给出可操作的提示,而不是只得到一个布尔值。支付结果页缺支付单号时,可以引导用户回到支付结果页;缺订单号时,只能让用户重新进入订单。

toQuery 有固定字段顺序,且不包含联系方式。 固定顺序让链接可比较、可缓存、可写测试;排除联系方式让「展示需要」与「传输需要」彻底分开。脱敏后的号码也不进链接,因为一旦进了链接,它就会被历史记录与日志收集。

mergeContext 只在同一订单旅程内补齐字段。 从订单详情跳到售后详情,订单号应该保留;从订单详情跳到商品详情,订单号必须丢掉,否则用户会以为正在咨询当前商品,客服打开的却是上一单。

测试与真实输出

本机运行结果如下:

$ node --test test/support/support-context.test.ts
✔ 订单详情入口只携带订单号 (1.542792ms)
✔ 支付结果入口缺少支付单时报告缺口 (0.162292ms)
✔ 商品入口不会携带订单号 (0.071291ms)
✔ 购物车入口只保留最近错误码 (0.085625ms)
✔ 非法错误码被丢弃 (0.0905ms)
✔ 错误码统一大写并保留连字符 (0.06625ms)
✔ 非法订单号不会被带入上下文 (0.093167ms)
✔ 十一位手机号保留前三后四 (0.074125ms)
✔ 较短手机号只保留首尾 (0.062292ms)
✔ 空手机号或非法输入返回 null (0.095458ms)
✔ 邮箱只保留域名与本地部分前两位 (0.070917ms)
✔ 非法邮箱返回 null (0.047542ms)
✔ 联系方式在上下文里已经是脱敏值 (0.05075ms)
✔ 必填字段表按入口返回副本 (0.049125ms)
✔ 允许字段表只含定位字段与错误码 (0.0555ms)
✔ 链接查询串顺序稳定且跳过空字段 (0.109875ms)
✔ 链接不包含脱敏联系方式 (0.065416ms)
✔ 同订单旅程跳转时补齐订单号 (0.094375ms)
✔ 商品入口不会继承上一页的订单号 (0.04675ms)
✔ 摘要列出携带字段与缺失字段 (0.117916ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 137.487333

二十条用例覆盖三类边界:入口白名单、ID 与错误码校验、脱敏与链接生成。它们不验证客服系统是否真的按这些字段打开页面,那属于端到端契约测试。

一次「上下文串单」的复盘

某交易客户端把「当前订单」放在一个全局对象里:进入订单详情时写入,客服按钮读取。上线后出现一类投诉:用户明明咨询的是 A 订单,客服却一直在说 B 订单。

复盘发现,用户在两个标签页里各打开了一笔订单。标签页 A 先写入订单 1001,标签页 B 后写入订单 1002;用户回到标签页 A 点客服,按钮读到的却是最后写入的 1002。更糟的是,客服链接里还带着从全局对象拼出来的手机号,链接被转发到外部群之后,号码一起泄露了。

修复分三步:

  1. 客服入口在点击瞬间从当前页面的路由参数构造上下文,不再读全局对象。
  2. 订单详情与售后详情把订单号列为必填,缺失时不生成客服链接,而是提示「请从订单页进入」。
  3. 链接只保留 ID 与错误码,联系方式改由客服端按权限查询并脱敏展示。

这个复盘留下的规则是:上下文必须在入口处构造,不能从某个「最近一次」的全局状态里读。 全局状态在单标签页里看起来永远正确,在多标签页、返回栈与恢复场景里却不可靠。

上下文什么时候应该失效

上下文不是越久越好。四种情况必须重建或清空:

  • 页面切换到另一笔订单时,重新构造,不做字段合并。
  • 售后申请提交成功后,入口从「申请售后」变成「查看售后」,此时应携带售后单标识,而不是继续带支付单号。
  • 错误码在一次成功请求后清空,避免客服看到已经解决的旧错误。
  • 用户退出登录或切换账号时,清空所有本地上下文,避免把上一账号的订单带给下一账号。

这四条的共同点是:上下文的生命周期跟着页面事实走,不跟着组件实例走。

小结

客服入口的价值不在于「能打开聊天窗口」,而在于让用户不必重复描述已经发生过的事。上下文只携带定位所需的 ID 与最近错误码,按入口白名单裁剪,按必填表报告缺口,联系方式在展示层脱敏且不进入链接。

buildSupportContext 负责裁剪与校验,contextGaps 负责可恢复性提示,toQuery 负责稳定序列化,mergeContext 负责同一订单旅程内的字段补齐。它们都很小,但把「客服应该知道什么」从口头约定变成了可测试的接口。

阅读导航:上一节:10.3 实验污染与互斥 · 下一节:11.2 售后申请的状态与证据 。

常见错误与边界

  • 把整个订单对象序列化进客服链接,地址与手机号进入浏览器历史。
  • 从全局「当前订单」读取上下文,多标签页下串单。
  • 只做字段校验不做入口白名单,商品页带上了不相干的订单号。
  • 把「允许携带」当成「必须携带」,支付结果页缺少支付单号时也拒绝进入客服。
  • 脱敏后的手机号仍然放进查询串,日志里依旧能看到可关联信息。
  • 错误码保留完整历史,客服看到的是已经解决的旧错误。
  • 同一页面切换到另一笔订单时继续复用旧上下文。
  • 退出登录后不清空上下文,下一账号继承上一账号的订单信息。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查