《互联网产品的客户端架构·交易、增长与平台化》11.2 售后申请的状态与证据

售后申请最常见的两个问题,是证据不齐导致来回补材料,和重复提交产生多张售后单。本节把申请、审核、通过或驳回、完成写成带角色约束的状态机,按原因定义必要与可选证据,用草稿标识与订单行双重判重,并复盘一次重复申请引发退款叠加的完整修复过程与回归结果。

售后申请不是一张表单

售后页看起来只是「选原因、填描述、传图片、点提交」,但它是交易端唯一一条由用户发起、由客服裁决、最终改动资金状态的链路。它同时要回答三个问题:

  1. 这张申请现在走到哪一步,谁能推进它?
  2. 这个原因需要哪些证据,缺哪一件不能提交?
  3. 同一订单行已经有一张进行中的申请时,第二张该不该被创建?

把这三个问题留给 UI 判断,后果是状态与按钮对不上、证据标准随客服心情变化、用户连点两次产生两张退款单。正确做法是把它们写成状态机、证据清单与判重规则。

状态机:申请、审核、通过或驳回、完成

售后单的状态不多,但每一步的推进者不同:

状态含义谁能推进到下一步
submitted用户已提交审核人接单,或用户取消
reviewing审核中审核人通过或驳回
approved已通过系统或审核人完成退款
rejected已驳回终态,用户可另建新申请
completed已完成终态
cancelled已取消终态

两个容易写错的地方。

第一,「通过」不等于「完成」。通过是审核结论,完成是退款实际到账或补发实际发出。把两者压成一个状态,用户会看到「已完成」但钱还没到。

第二,驳回必须带原因码。只把状态改成 rejected,用户不知道要补什么,下一次申请大概率还是同样的问题。原因码也是统计「哪类驳回最多」的基础。

证据清单按原因变化

不同售后原因需要的证据不一样。把「上传图片」做成可选,审核人就会反复要求补材料;把「聊天记录」做成必填,用户又会卡在找不到入口。

原因必要证据可选证据
质量问题商品照片视频、发票
未收到货物流记录聊天记录
发错商品商品照片物流记录、聊天记录
运输破损商品照片视频、发票
其他聊天记录商品照片

这张表的关键不是具体条目,而是**「必要」与「可选」必须分开**。必要证据决定能否提交,可选证据只影响处理速度。提交时如果可选证据的引用为空,应该提示并丢弃,而不是让整张申请失败;必要证据缺失或引用非法,则必须阻止提交。

证据还需要去重与冻结。同一张照片被选两次只保留一条;提交之后证据列表变成只读副本,端上再修改本地数组不能影响已提交的售后单。

重复申请的两种形态

「重复申请」不是一种问题,而是两种:

  • 同一草稿重复提交:用户连点两次,或网络超时后重试。处理方式是返回第一次创建的售后单,不再新建。
  • 同一订单行新建第二张:用户换个原因再提一次,或从另一个入口重新进入。处理方式是检查该订单行是否已有进行中的售后单,有则拒绝并展示原单。

两种判重的键不同:前者用草稿标识,后者用订单号加订单行。只做其中一种,都会留下缺口。已驳回或已取消的售后单不占用订单行,用户可以对同一商品重新申请。

一个可运行的售后单模块

下面的模块属于 L1:状态迁移、证据校验与判重都是纯函数,时间由参数传入。

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

export type CaseState = "draft" | "submitted" | "reviewing" | "approved" | "rejected" | "completed" | "cancelled";
export type ReasonCode = "quality" | "not-received" | "wrong-item" | "damaged" | "other";
export type EvidenceKind = "photo" | "video" | "logistics" | "chat" | "invoice";
export type Actor = "user" | "reviewer" | "system";

export interface Evidence { kind: EvidenceKind; ref: string; capturedAt: number }

export interface CaseDraft {
  draftId: string; orderId: string; lineId: string; reason: ReasonCode;
  requestedCents: number; description: string; evidence: Evidence[];
}

export interface CaseRecord extends CaseDraft {
  id: string; state: CaseState; createdAt: number; updatedAt: number; rejectionCode: string | null;
}

export interface EvidenceReport {
  complete: boolean; missing: EvidenceKind[]; optionalMissing: EvidenceKind[];
  invalid: string[]; duplicates: number;
}

export type SubmitCode = "ok" | "same_draft" | "active_case" | "invalid";

export interface SubmitResult {
  ok: boolean; code: SubmitCode; record: CaseRecord | null;
  existing: CaseRecord | null; message: string;
}

export const STATE_LABELS: Record<CaseState, string> = {
  draft: "待提交", submitted: "已提交", reviewing: "审核中", approved: "已通过",
  rejected: "已驳回", completed: "已完成", cancelled: "已取消",
};

const EVIDENCE_ORDER: EvidenceKind[] = ["photo", "video", "logistics", "chat", "invoice"];

// 必要证据决定能否进入审核;可选证据只影响处理速度。
const REQUIRED_EVIDENCE: Record<ReasonCode, EvidenceKind[]> = {
  quality: ["photo"], "not-received": ["logistics"], "wrong-item": ["photo"],
  damaged: ["photo"], other: ["chat"],
};

const OPTIONAL_EVIDENCE: Record<ReasonCode, EvidenceKind[]> = {
  quality: ["video", "invoice"], "not-received": ["chat"],
  "wrong-item": ["logistics", "chat"], damaged: ["video", "invoice"], other: ["photo"],
};

const TRANSITIONS: Record<CaseState, CaseState[]> = {
  draft: ["submitted", "cancelled"], submitted: ["reviewing", "cancelled"],
  reviewing: ["approved", "rejected"], approved: ["completed"],
  rejected: [], completed: [], cancelled: [],
};

const ACTIVE_STATES: CaseState[] = ["submitted", "reviewing", "approved"];

function actorAllowed(from: CaseState, to: CaseState, actor: Actor): boolean {
  if (from === "draft" && to === "submitted") return actor === "user";
  if (from === "submitted" && to === "reviewing") return actor !== "user";
  if (from === "reviewing" && (to === "approved" || to === "rejected")) return actor === "reviewer";
  if (from === "approved" && to === "completed") return actor !== "user";
  if ((from === "draft" || from === "submitted") && to === "cancelled") return actor === "user";
  return false;
}

export function evidenceChecklist(reason: ReasonCode): {
  required: EvidenceKind[];
  optional: EvidenceKind[];
} {
  return {
    required: [...REQUIRED_EVIDENCE[reason]],
    optional: [...OPTIONAL_EVIDENCE[reason]],
  };
}

export function normalizeEvidence(items: Evidence[]): {
  items: Evidence[];
  invalid: string[];
  duplicates: number;
} {
  const invalid: string[] = [];
  const seen = new Set<string>();
  const normalized: Evidence[] = [];
  let duplicates = 0;
  for (const item of items) {
    const kind = EVIDENCE_ORDER.includes(item.kind) ? item.kind : null;
    const ref = item.ref.trim();
    const at = item.capturedAt;
    if (kind === null || ref === "" || !Number.isSafeInteger(at) || at <= 0) {
      invalid.push(`${item.kind}:${ref === "" ? "(空引用)" : ref}`);
      continue;
    }
    const key = `${kind}\u0000${ref}`;
    if (seen.has(key)) {
      duplicates += 1;
      continue;
    }
    seen.add(key);
    normalized.push({ kind, ref, capturedAt: at });
  }
  normalized.sort((left, right) => {
    const byKind = EVIDENCE_ORDER.indexOf(left.kind) - EVIDENCE_ORDER.indexOf(right.kind);
    if (byKind !== 0) return byKind;
    if (left.capturedAt !== right.capturedAt) return left.capturedAt - right.capturedAt;
    return left.ref < right.ref ? -1 : left.ref > right.ref ? 1 : 0;
  });
  return { items: normalized, invalid, duplicates };
}

export function evaluateEvidence(reason: ReasonCode, items: Evidence[]): EvidenceReport {
  const normalized = normalizeEvidence(items);
  const present = new Set(normalized.items.map((item) => item.kind));
  const missing = REQUIRED_EVIDENCE[reason].filter((kind) => !present.has(kind));
  const optionalMissing = OPTIONAL_EVIDENCE[reason].filter((kind) => !present.has(kind));
  return {
    complete: missing.length === 0 && normalized.invalid.length === 0,
    missing, optionalMissing, invalid: normalized.invalid, duplicates: normalized.duplicates,
  };
}

export function isActive(record: CaseRecord): boolean {
  return ACTIVE_STATES.includes(record.state);
}

export function findActiveCase(existing: CaseRecord[], draft: CaseDraft): CaseRecord | null {
  for (const record of existing) {
    if (isActive(record) && record.orderId === draft.orderId.trim() &&
      record.lineId === draft.lineId.trim()) {
      return record;
    }
  }
  return null;
}

function validateDraft(draft: CaseDraft): string[] {
  const errors: string[] = [];
  if (draft.draftId.trim() === "") errors.push("草稿标识为空");
  if (draft.orderId.trim() === "") errors.push("订单号为空");
  if (draft.lineId.trim() === "") errors.push("订单行为空");
  if (!Number.isSafeInteger(draft.requestedCents) || draft.requestedCents <= 0) {
    errors.push("申请金额必须是正整数分");
  }
  const description = draft.description.trim();
  if (description.length > 500) errors.push("描述超过 500 字");
  if (draft.reason === "other" && description.length < 10) {
    errors.push("其他原因至少填写 10 字描述");
  }
  return errors;
}

// 同一草稿重复提交返回原单;同一订单行已有进行中的售后则拒绝新建。
export function submitCase(
  draft: CaseDraft,
  existing: CaseRecord[],
  now: number,
  options: { idFactory?: (draft: CaseDraft, now: number) => string } = {},
): SubmitResult {
  const errors = validateDraft(draft);
  const sameDraft = existing.find((record) => record.draftId === draft.draftId.trim());
  if (sameDraft !== undefined) {
    return {
      ok: false, code: "same_draft", record: null, existing: sameDraft,
      message: "该草稿已经提交,不能重复创建",
    };
  }
  const active = findActiveCase(existing, draft);
  if (active !== null) {
    return {
      ok: false, code: "active_case", record: null, existing: active,
      message: `该订单行已有进行中的售后单 ${active.id}`,
    };
  }
  const report = evaluateEvidence(draft.reason, draft.evidence);
  if (!report.complete) {
    if (report.missing.length > 0) errors.push(`缺少必要证据:${report.missing.join(",")}`);
    if (report.invalid.length > 0) errors.push(`无效证据:${report.invalid.join(",")}`);
  }
  if (errors.length > 0) {
    return { ok: false, code: "invalid", record: null, existing: null, message: errors.join(";") };
  }
  const normalized = normalizeEvidence(draft.evidence).items;
  const id = options.idFactory?.(draft, now) ??
    `as-${draft.orderId.trim()}-${draft.lineId.trim()}-${now}`;
  const record: CaseRecord = {
    id, draftId: draft.draftId.trim(), orderId: draft.orderId.trim(),
    lineId: draft.lineId.trim(), reason: draft.reason,
    requestedCents: draft.requestedCents, description: draft.description.trim(),
    evidence: normalized, state: "submitted", createdAt: now, updatedAt: now,
    rejectionCode: null,
  };
  return { ok: true, code: "ok", record, existing: null, message: "售后申请已提交" };
}

export function canTransition(from: CaseState, to: CaseState, actor: Actor): boolean {
  return TRANSITIONS[from].includes(to) && actorAllowed(from, to, actor);
}

// 迁移返回新对象;驳回必须带原因码,证据始终保持提交时的副本。
export function transition(
  record: CaseRecord,
  to: CaseState,
  at: number,
  actor: Actor,
  rejectionCode: string | null = null,
): CaseRecord {
  if (!canTransition(record.state, to, actor)) {
    throw new Error(`不允许 ${record.state} -> ${to}(${actor})`);
  }
  if (to === "rejected" && (rejectionCode === null || rejectionCode.trim() === "")) {
    throw new Error("驳回必须给出原因码");
  }
  return {
    ...record,
    evidence: record.evidence.map((item) => ({ ...item })),
    state: to,
    updatedAt: at,
    rejectionCode: to === "rejected" ? rejectionCode?.trim() ?? null : null,
  };
}

export function visibleState(state: CaseState): string {
  return STATE_LABELS[state];
}

四个决定值得说明。

submitCase 先判重,再校验证据。 用户连点两次时,第二次应该直接看到「已提交」,而不是先被证据校验拦住。判重返回原单,UI 可以据此跳转到详情页。

证据校验区分 missing、invalid 与 duplicates。 缺照片和照片引用为空是两种错误:前者让用户去拍,后者让用户重新选择。把它们合并成一句「证据不完整」,用户只能反复试。

transition 接收角色,而不是只接收目标状态。 用户不能把 reviewing 改成 approved,系统也不能替用户取消。把角色写进函数签名,比在 UI 里隐藏按钮可靠。

transition 返回新对象并复制证据。 售后单一旦提交就是事实,后续状态变化不能回头修改证据数组。复制成本很低,能避免一类很难查的共享引用问题。

测试与真实输出

本机运行结果如下:

$ node --test test/support/aftersale-case.test.ts
✔ 证据完整的质量问题可以提交 (0.7315ms)
✔ 质量问题缺少照片时拒绝提交 (0.12025ms)
✔ 未收到货用物流记录满足必要证据 (0.382791ms)
✔ 其他原因描述过短时拒绝提交 (0.076625ms)
✔ 其他原因描述足够且附聊天记录时可以提交 (0.079916ms)
✔ 证据引用为空时阻止提交 (0.073083ms)
✔ 重复证据按种类与引用去重 (0.084833ms)
✔ 同一草稿重复提交返回原单 (0.072542ms)
✔ 同一订单行已有进行中售后时拒绝新建 (0.088375ms)
✔ 已驳回的售后不再占用订单行 (0.201ms)
✔ 审核人可以驳回但必须给出原因码 (0.229167ms)
✔ 用户不能代替审核人通过申请 (0.077583ms)
✔ 审核通过后由系统完成退款 (0.055916ms)
✔ 完成与取消都是终态 (0.065791ms)
✔ 用户可以在提交后取消申请 (0.06525ms)
✔ 提交时复制证据,外部修改不影响售后单 (0.0695ms)
✔ 申请金额必须是正整数分 (0.051834ms)
✔ 查找进行中的售后单按订单与订单行匹配 (0.054375ms)
✔ 证据清单区分必要与可选 (0.070916ms)
✔ 状态展示覆盖申请到完成的全过程 (0.043459ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.78025

二十条用例分成四组:证据完整性、判重、角色与状态迁移、证据冻结。

一次「连点两次」的复盘

某交易客户端的售后提交按钮只在请求发出后置灰,请求返回前如果页面被切走再切回,按钮会恢复可点。用户以为没提交成功,又点了一次。

服务端没有按草稿标识判重,两次请求各创建了一张售后单。审核人看到两张一模一样的申请,分别通过,退款叠加了一次。复盘时发现三个问题:

  1. 端上没有为一次申请生成稳定的草稿标识,两次请求无法被识别为同一次。
  2. 服务端只按「订单行 + 原因」做弱判重,用户第二次改了几个字就被当成新申请。
  3. 审核列表没有提示「同一订单行存在多张进行中售后单」。

修复对应三点:提交前生成草稿标识并随请求发送;服务端按草稿标识幂等,同时按订单行拦截进行中的重复申请;审核列表增加重复提示。端上则把按钮状态与请求状态绑定,页面恢复时从持久化的提交快照还原,而不是默认回到可点。

这个复盘留下的规则是:判重不能只靠 UI 防抖,必须有一个可以跨请求、跨页面的稳定标识。

驳回不是删除

驳回后,售后单仍然要保留:用户需要看到驳回原因、证据与时间线,客服需要统计驳回分布,财务需要确认没有产生退款。端上常见的错误是驳回后把记录从列表里移除,用户以为申请丢了,又重新提交一次。

正确做法是保留记录并标记终态,同时在详情页给出「重新申请」入口。重新申请会创建新的草稿与新的售后单,而不是复活旧单。旧单的证据可以作为参考,但不能被新单直接修改。

小结

售后申请的核心不是表单字段,而是三件事:状态由谁推进、证据按原因需要什么、重复申请如何识别。状态机把角色写进迁移规则,证据清单把必要与可区分开,判重同时看草稿标识与订单行。

submitCase 负责判重与创建,evaluateEvidence 负责缺什么与哪里非法,transition 负责角色与终态。三者合起来,才能让「提交」变成一次可追溯的承诺。

阅读导航:上一节:11.1 客服入口与上下文携带 · 下一节:11.3 工单会话的离线与恢复 。

常见错误与边界

  • 把「审核通过」直接显示成「退款完成」,用户等待到账时以为系统出错。
  • 驳回只改状态不给原因码,用户不知道下一次要补什么。
  • 必要证据与可选证据混在一起,审核人反复要求补材料。
  • 只按 UI 防抖防重复提交,页面恢复后仍会发出第二次请求。
  • 判重只看订单行不看草稿标识,连点两次仍可能创建两张单。
  • 驳回后从列表删除记录,用户以为申请丢失并重新提交。
  • 提交后仍持有证据数组引用,后续修改污染已提交的售后单。
  • 允许同一订单行同时存在多张进行中的售后单,退款可能叠加。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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