售后申请不是一张表单
售后页看起来只是「选原因、填描述、传图片、点提交」,但它是交易端唯一一条由用户发起、由客服裁决、最终改动资金状态的链路。它同时要回答三个问题:
- 这张申请现在走到哪一步,谁能推进它?
- 这个原因需要哪些证据,缺哪一件不能提交?
- 同一订单行已经有一张进行中的申请时,第二张该不该被创建?
把这三个问题留给 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
二十条用例分成四组:证据完整性、判重、角色与状态迁移、证据冻结。
一次「连点两次」的复盘
某交易客户端的售后提交按钮只在请求发出后置灰,请求返回前如果页面被切走再切回,按钮会恢复可点。用户以为没提交成功,又点了一次。
服务端没有按草稿标识判重,两次请求各创建了一张售后单。审核人看到两张一模一样的申请,分别通过,退款叠加了一次。复盘时发现三个问题:
- 端上没有为一次申请生成稳定的草稿标识,两次请求无法被识别为同一次。
- 服务端只按「订单行 + 原因」做弱判重,用户第二次改了几个字就被当成新申请。
- 审核列表没有提示「同一订单行存在多张进行中售后单」。
修复对应三点:提交前生成草稿标识并随请求发送;服务端按草稿标识幂等,同时按订单行拦截进行中的重复申请;审核列表增加重复提示。端上则把按钮状态与请求状态绑定,页面恢复时从持久化的提交快照还原,而不是默认回到可点。
这个复盘留下的规则是:判重不能只靠 UI 防抖,必须有一个可以跨请求、跨页面的稳定标识。
驳回不是删除
驳回后,售后单仍然要保留:用户需要看到驳回原因、证据与时间线,客服需要统计驳回分布,财务需要确认没有产生退款。端上常见的错误是驳回后把记录从列表里移除,用户以为申请丢了,又重新提交一次。
正确做法是保留记录并标记终态,同时在详情页给出「重新申请」入口。重新申请会创建新的草稿与新的售后单,而不是复活旧单。旧单的证据可以作为参考,但不能被新单直接修改。
小结
售后申请的核心不是表单字段,而是三件事:状态由谁推进、证据按原因需要什么、重复申请如何识别。状态机把角色写进迁移规则,证据清单把必要与可区分开,判重同时看草稿标识与订单行。
submitCase 负责判重与创建,evaluateEvidence 负责缺什么与哪里非法,transition 负责角色与终态。三者合起来,才能让「提交」变成一次可追溯的承诺。
阅读导航:上一节:11.1 客服入口与上下文携带 · 下一节:11.3 工单会话的离线与恢复 。
常见错误与边界
- 把「审核通过」直接显示成「退款完成」,用户等待到账时以为系统出错。
- 驳回只改状态不给原因码,用户不知道下一次要补什么。
- 必要证据与可选证据混在一起,审核人反复要求补材料。
- 只按 UI 防抖防重复提交,页面恢复后仍会发出第二次请求。
- 判重只看订单行不看草稿标识,连点两次仍可能创建两张单。
- 驳回后从列表删除记录,用户以为申请丢失并重新提交。
- 提交后仍持有证据数组引用,后续修改污染已提交的售后单。
- 允许同一订单行同时存在多张进行中的售后单,退款可能叠加。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。