从零重构外卖系统(十一):订单作业怎样连接快照、履约确认与异步退款
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · PC-3
面向读者:已经理解 PC-1 的会话隔离与 PC-2 的版本化写入,希望学习真实订单工作流、路由状态、可见轮询和异步故障恢复的开发者。
本篇依据 PC-3 提交
9ba3db9与docs/PC3_CONTRACT.md编写。代码展示实际关键实现,省略 import、组件模板或外围方法的片段不是独立完整工程。本阶段交付订单中心、订单详情、接单与配送操作,以及工作台待办闭环。通知中心和 WebSocket 在下一阶段接入;资金、报表和运维页面仍未交付。文章引用阶段验收结果,没有重新运行测试或验证 Vditor 渲染。
1. 订单页为什么比普通管理表格更容易写错
PC-2 的商品编辑通常从一份草稿开始,最后提交一次资料修改。
订单页面的情况不同:员工正在看详情时,顾客可能取消,另一位员工可能接单,支付或退款结果也可能到达。
订单是一份持续变化的业务事实,而且某些变化需要外部支付渠道确认。
于是,几个看起来合理的页面实现都会出问题:
- 点“接单”就先把状态改成待配送。
- 取消接口返回 200,就显示退款成功。
- 详情一直自动刷新,确认框提交时使用最新 version。
- 订单显示已取消,就永远停止核对退款状态。
- 工作台只查今天的待接单,跨日未处理订单从入口消失。
- 列表没返回电话,就逐行请求详情,把地址资料全部拉到浏览器。
PC-3 围绕这些实际竞争场景组织页面,而不是先做一张有按钮的表格,再逐个补异常。
2. 页面权限与业务动作先建立对应关系
订单作业允许 ADMIN 和 STAFF,顾客档案仍只对 ADMIN 开放。
| 入口 | 能力 | 权限 |
|---|---|---|
/orders |
组合筛选、服务端分页、进入详情 | ADMIN / STAFF |
/orders/:id |
刷新可恢复的订单详情 | ADMIN / STAFF |
| 详情操作区 | 接单、拒单、取消、配送、完成 | ADMIN / STAFF,服务端再次检查 |
| 工作台待办 | 跳转对应状态订单、查看待接前五条 | ADMIN / STAFF |
| 顾客详情关联订单 | 按顾客 UUID 进入订单列表 | ADMIN |
本阶段没有管理员创建订单、模拟支付、任意退款金额、打印、分配配送员或自由文本取消原因。
paymentId 与 refundId 可以展示和复制,但不会链接到还未交付的资金页面。
权限决定谁可以发起尝试,订单状态与资源版本决定该次尝试是否还能成立。两个维度都必须检查。
3. orders 模块怎样与工作台协作
模块内部主要结构:
admin/src/modules/orders/
├── api/orders.ts
├── model/
│ ├── order-filter.ts
│ ├── order-state.ts
│ └── use-order-detail.ts
├── pages/OrdersPage.vue
├── ui/
│ ├── OrderDetailPanel.vue
│ └── AwaitingOrders.vue
└── index.ts
API 适配负责请求契约;筛选模型负责把输入变成有效查询;状态模型描述可见动作和显示文案;详情组合函数负责读取、确认、写入与取消的生命周期。
工作台通过 orders/index.ts 使用 AwaitingOrders,不直接进入订单模块内部调用一组零散方法。
flowchart LR
WORK[workspace 工作台] --> PUBLIC[orders 公开入口]
PUBLIC --> LIST[AwaitingOrders]
PAGE[OrdersPage] --> FILTER[筛选与路由模型]
PAGE --> DETAIL[详情生命周期]
DETAIL --> API[订单 API]
LIST --> API
API --> HTTP[统一认证客户端]
前端没有复制后端聚合来执行付款或退款规则。这里的状态表服务于界面可见性,服务端领域规则仍然是最终依据。
4. 列表查询的含义,需要精确到“是哪一个手机号”
订单列表使用 GET /api/v1/management/orders,支持状态、完整订单 UUID、顾客 UUID、收货手机号、创建时刻和分页。
所有条件在数据库中按 AND 组合。默认不限制日期,普通列表每页二十条,可切换五十条。
| 条件 | 实际含义 |
|---|---|
| orderId | 完整订单 UUID 精确匹配 |
| customerId | 下单顾客的内部标识 |
| phone | 成交时收货快照中的电话 |
| from/to | 订单创建时刻的左闭右开区间 |
| status | 当前订单状态 |
顾客账号手机号、地址簿当前手机号与历史订单收货电话可能不同。
如果用户替家人点餐,用当前顾客手机号检索收货电话,就会漏掉这份订单。页面标签因此明确写“收货手机号”。
列表只显示摘要已有字段,不逐行补查姓名、电话、菜品与支付信息。进入某一订单详情时再读取对应快照。
5. 非法筛选必须阻止查询,不能悄悄变成全部订单
如果用户输入了八位短 UUID,页面不能把它当成空值,然后发出一个无条件列表请求。
这样虽然“有数据了”,却完全背离用户输入,还会扩大本次读取范围。
实际筛选编译先做校验:
if (value.status && !Object.hasOwn(statuses, value.status))
throw new ApiProblem(400, '订单状态不合法')
if ([value.orderId, value.customerId].some((id) => id && !uuidPattern.test(id)))
throw new ApiProblem(400, '请输入完整有效的订单或顾客 UUID')
if (value.phone && !/^\+?[1-9][0-9]{6,14}$/.test(value.phone))
throw new ApiProblem(400, '请输入完整收货手机号')
这是 compileFilter 的片段,value 是去除输入首尾空白后的对象。
日期还要检查实际日历值,起日不能晚于结束日期;路由分页检查非负整数和允许的 size,非法值显示错误。
列表查询显式依赖这个错误状态:
enabled: computed(() => !filterError.value),
界面可以把 UUID 缩略成前八位与后四位,但复制和请求始终使用完整值。显示便利不应改变资源身份。
6. 草稿筛选、已应用筛选与路由,是三份不同职责的状态
用户输入到一半,不代表已经决定应用新条件。
页面使用 draft 保存正在填写的输入,applied 保存上次明确查询的条件,URL 保存可恢复的非电话条件。
flowchart LR
INPUT[筛选输入 draft] -->|点击查询并校验| APPLIED[applied]
APPLIED --> REQUEST[真实 API 查询]
APPLIED --> URL[URL 中的状态 UUID 日期 分页]
URL -->|刷新或后退| RESTORE[恢复可序列化条件]
RESTORE --> APPLIED
自动刷新只使用 applied,不把用户刚输入一半的电话号码发送出去,也不覆盖正在填写的 draft。
手机号保留在当前页面内存,不写入 URL、查询键或持久化缓存。刷新页面后,它不会从 URL 自动恢复。
实际查询键:
const query = useQuery({
queryKey: computed(() => [
'orders-list', scope, filterRevision.value, page.value, size.value,
]),
enabled: computed(() => !filterError.value),
queryFn: ({ signal }) =>
ordersApi.list(compileFilter(applied.value), page.value, size.value, signal),
gcTime: 0,
})
scope 隔离本次页面实例,filterRevision 代表应用筛选的变化;公共前缀 orders-list 又允许订单写入后统一使相关查询失效。
这没有把 UUID 变成不敏感数据,也没有让手机号从网络请求里消失。它表达的是页面对 URL 和缓存键的具体保留策略。
7. 日期边界和工作台待办不能混为一谈
订单筛选沿用上海日历日期转 UTC 时刻的规则:起日零点包含,结束日期次日零点排除。
转换结束日期的实际代码:
to: value.toDate
? dayjs
.tz(dayjs(value.toDate).add(1, 'day').format('YYYY-MM-DD'), 'Asia/Shanghai')
.toISOString()
: undefined,
这是返回筛选对象的片段。未选择日期时,from/to 都不传,不暗中补“今天”。
工作台有今日摘要,也有全店当前待办。这两个概念不同:
| 工作台信息 | 时间口径 |
|---|---|
| 今日经营摘要 | 服务端经营日期对应的统计 |
| 当前待接单、待配送、配送中 | 当前还需要处理的订单,跨所有日期 |
| 待接订单前五条 | 权威订单列表中的当前 PAID 订单 |
前一天留下的待办不应该因为午夜已过而从工作入口消失。
8. 详情放进路由,才有真正的刷新与后退语义
如果详情只存在于 selectedOrder 变量,刷新页面就会丢失选择,也无法分享准确的站内目标。
PC-3 使用 /orders/:id 表达当前详情,query 保存列表筛选与分页。
打开详情的实际代码:
async function open(id: string) {
if (!busy.value)
await router.push({
path: `/orders/${id}`,
query: filterQuery(applied.value, page.value, size.value),
})
}
关闭详情时回到 /orders,继续携带这些列表参数。浏览器后退也可以恢复先前的非电话筛选。
宽度至少 1366 时,详情与列表并排;较窄桌面使用抽屉。两种布局使用同一份详情生命周期,不维护两套互相漂移的接单逻辑。
确认或提交期间,普通详情切换与路由离开被 busy 保护。会话失效仍允许安全退出。
9. 历史订单为什么不能展示当前商品和地址
顾客下单之后,商品可能改名、调价、下架,收货地址也可能被删除。
订单详情显示的是成交快照:商品名称、规格、单价、数量、小计、套餐组成、总金额和收货信息。
flowchart TD
CATALOG[当前目录] -->|结算时生成快照| ORDER[订单快照]
ADDRESS[结算时收货地址] -->|复制收货事实| ORDER
ORDER --> DETAIL[管理端详情]
EDIT[后续商品或地址修改] --> CATALOG
图中详情的数据来源只有订单快照。后续修改不会沿着实时目录查询反向覆盖历史订单。
付款前金额显示为“订单金额”;只有 paidAt 已存在时才显示“实付金额”。页面不自行拆出接口没有提供的运费、优惠或包装费。
订单电话默认掩码,切换或关闭详情后恢复掩码。完整资料只在当前详情明确展开。
10. 时间线要来自服务端时刻,不能靠状态补造
实际时间线函数:
export function timeline(order: OrderDetail) {
return [
{ label: '已下单', time: order.createdAt },
{ label: '支付成功', time: order.lifecycle?.paidAt },
{ label: '商家接单', time: order.lifecycle?.acceptedAt },
{ label: '开始配送', time: order.lifecycle?.deliveredAt },
{ label: '订单完成', time: order.lifecycle?.completedAt },
{ label: '订单取消', time: order.cancelledAt },
].filter((value) => !!value.time)
}
这里的 deliveredAt 按当前后端契约表达开始配送,完成对应 completedAt,不能只凭英文名称自行换成送达时间。
过滤掉没有值的节点,意味着“服务器未提供这个时刻”,不意味着事件一定从未发生。尤其不能用浏览器当前时间补一个退款成功节点。
催单次数、最近催单时刻、固定取消原因与独立退款状态,也都依据服务端详情展示。
11. 动作表应该完整,也应该对未知状态关闭写入口
本阶段状态与动作对应关系:
| 状态 | 页面文字 | 合法动作 |
|---|---|---|
| UNPAID | 待付款 | 只读 |
| PAID | 待接单 | 接单、拒单并退款、取消并退款 |
| ACCEPTED | 待配送 | 开始配送、取消并退款 |
| DELIVERING | 配送中 | 确认完成 |
| COMPLETED | 已完成 | 只读 |
| CANCELLING | 取消处理中 | 读取进度 |
| REFUNDING | 退款处理中 | 读取进度 |
| CANCELLED | 已取消 | 只读,继续展示独立退款状态 |
实际函数很短:
export function availableActions(status?: string): OrderAction[] {
switch (status) {
case 'PAID':
return ['acceptance', 'rejection', 'cancellation']
case 'ACCEPTED':
return ['delivery', 'cancellation']
case 'DELIVERING':
return ['completion']
default:
return []
}
}
未知状态不会获得默认接单按钮。后端新增状态时,旧前端可以显示未知并保持只读,而不是猜测一个最相近的操作。
商家不能取消未付款订单,也不能在开始配送后继续通过本阶段商家取消动作处理它。
这一表只决定前端何时提供入口。用户直接调用 API 时,后端仍会检查身份、版本和聚合状态。
12. 确认框必须冻结用户实际确认的版本
假设员工看到订单版本 4,点击接单打开确认框。另一位员工此时已经取消订单,轮询读到了版本 5。
如果确认框点击确定时从响应式变量里随手取最新 version,就可能把“我确认刚才那份订单”变成“我确认后来自动变化的订单”。
实际代码在打开确认框之前保存快照标识:
const snapshot = revision(order.value!),
epoch = generation,
session = sessionBridge.snapshot().generation
confirming.value = true
确认内容展示完整订单编号与业务影响。确认和提交期间停止详情轮询,禁止对这份订单发起第二个写命令。
确认返回后再校验归属:
confirming.value = false
if (!confirmed || disposed || generation !== epoch || !sessionBridge.isCurrent(session))
return
然后使用被冻结的 id/version:
const updated = await ordersApi.act(snapshot.id, action, snapshot.version)
sequenceDiagram
participant A as 员工A页面
participant B as 员工B
participant S as 订单服务
A->>S: GET 订单
S-->>A: PAID,version=4
A->>A: 打开确认框,冻结版本4
B->>S: 按版本4接单
S-->>B: ACCEPTED,新版本
A->>S: 仍按版本4提交接单
S-->>A: 409
A->>A: 保留详情,阻止重复写入
A->>S: 用户显式重新读取
S-->>A: 返回真实状态与可重新判断的版本
页面暂停轮询不会锁住服务器。并发写入仍然可能发生,version 正是用于发现这种变化。
13. 命令只发送意图与版本,不发送想象中的最终状态
订单动作 API 的构造片段:
const options = { params: { path: { id } }, body: { version } }
switch (action) {
case 'acceptance':
return resource(await api.POST('/api/v1/management/orders/{id}/acceptance', options))
case 'rejection':
return resource(await api.POST('/api/v1/management/orders/{id}/rejection', options))
case 'cancellation':
return resource(await api.POST('/api/v1/management/orders/{id}/cancellation', options))
case 'delivery':
return resource(await api.POST('/api/v1/management/orders/{id}/delivery', options))
case 'completion':
return resource(await api.POST('/api/v1/management/orders/{id}/completion', options))
}
这是 ordersApi.act 方法内部实现。请求只有 {version},动作由资源路径表达。
不提交 {status: 'COMPLETED'} 这种任意状态修改,也不附带前端决定的退款金额或支付成功标识。
成功后使用返回的新详情,再使订单列表和工作台查询失效。即使订单已经离开当前筛选,也保留更新后的详情,让员工确认操作结果。
例如在“待接单”列表接单成功后,该行可能从列表消失,但右侧详情应继续显示“待配送”,而不是突然关闭造成“到底接上没有”的疑问。
14. 拒单与取消为什么只能先显示“已提交”
订单服务已经受理取消,不代表外部渠道退款已经完成。
实际成功提示依据响应状态选择:
message.success(
updated.status === 'REFUNDING' || updated.status === 'CANCELLING'
? '已提交,等待服务端确认'
: `${actions[action].label}成功`,
)
退款状态独立显示 NONE、PENDING、SUCCEEDED,对应无需退款、退款处理中、已退款。
可以用两个维度理解详情:
例如 (CANCELLED, PENDING) 并不必然矛盾:订单已经取消,迟到付款仍可能需要继续退款。
前端不能因为订单终态就隐藏正在处理的退款,更不能把拒单按钮点下去的时刻当作钱已退回顾客账户的时刻。
支付、退款的渠道请求和结果验签仍由 P5 后端处理;本阶段前端没有直接连接支付宝,也没有模拟支付成功入口。
15. 写入冲突和响应丢失,都会把详情置于保护状态
订单操作失败后,详情生命周期保留当前内容并设置 blocked。
与 PC-2 按错误类别计算 needsReload 相比,这里的订单写操作失败处理更保守:失败后要求显式重新读取,重新确定当前动作。
自动详情读取入口首先检查:
if (busy.value || !id.value || (blocked.value && !explicit)) return
只有显式读取成功,才解除保护:
order.value = value
if (explicit) {
writeError.value = null
blocked.value = false
}
这里不能让十五秒轮询偷偷清除 blocked。否则用户刚看到“结果未知”,按钮稍后又自动恢复,容易误以为原操作确定失败。
stateDiagram-v2
[*] --> Reading
Reading --> Ready: 详情完整
Ready --> Confirming: 用户选择动作
Confirming --> Ready: 取消确认
Confirming --> Writing: 确认且上下文未变
Writing --> Ready: 返回有效新详情
Writing --> Blocked: 冲突或写入失败
Blocked --> Reading: 用户显式重新读取
这张图是页面命令生命周期,不是订单领域状态机。
16. 详情请求需要两层异步归属保护
PC-1 的会话代数防止旧账号响应污染新账号。但同一个员工快速打开 A、B 两个订单,还需要页面自身的请求代数。
读取核心片段:
controller?.abort()
controller = new AbortController()
const epoch = ++generation
loading.value = true
loadError.value = null
try {
const value = await ordersApi.detail(id.value, controller.signal)
if (epoch !== generation || disposed) return
if (value.id !== id.value || !Number.isSafeInteger(value.version))
throw new ApiProblem(502, '订单详情或版本不完整,请重新读取')
order.value = value
// 此处省略显式重读成功后清除写入保护的分支。
}
这是 try 主体摘录,完整方法还包含 catch/finally。取消信号尽快停止旧请求,generation 则拒绝无法及时取消的迟到结果。
需要检查响应 id 与当前路由 id 相同,也需要有效版本。不能只因为响应是一个对象就开放操作区。
| 失败情况 | 当前详情处理 |
|---|---|
| 暂时网络或服务故障 | 保留已知内容,显示错误,禁用写入 |
| 403 / 404 | 清除已经无权读取或不存在的详情 |
| 旧请求被取消 | 不显示为当前业务错误 |
| 切换订单后旧响应到达 | 忽略旧结果 |
保留旧内容不代表继续相信它可以用于修改。显示与可写是两个独立判断。
17. 十五秒轮询怎样避免重叠与后台空转
本阶段尚未连接通知流,列表、工作台和活动订单详情通过可见轮询核对状态。
共享 useVisiblePoll 使用 setTimeout 调度,同时维护 running:
async function tick() {
if (disposed || running || document.visibilityState !== 'visible') return
schedule()
if (!enabled()) return
running = true
try {
await task()
} finally {
running = false
schedule()
}
}
schedule 会清理旧计时器,在页面仍可见且没有卸载时安排下一轮,默认间隔十五秒。
这样不把一个很慢的查询叠成多个并发轮询。页面隐藏时暂停,恢复可见时立即核对;卸载时清理计时器和 visibilitychange 监听。
十五秒是客户端核对策略,不是订单状态同步延迟的严格上界,也不代表所有标签页都在后台持续请求。
终态停止轮询还要检查退款
实际函数:
export function shouldPoll(order: OrderDetail) {
return (
['UNPAID', 'PAID', 'ACCEPTED', 'DELIVERING', 'CANCELLING', 'REFUNDING'].includes(
order.status || '',
) || order.lifecycle?.refundStatus === 'PENDING'
)
}
完成和取消终态默认停止详情轮询,但只要独立退款仍为 PENDING,就继续核对。
详情确认中、写入中、正在读取或冲突后未重读时,轮询也不会覆盖用户当前上下文。
18. 工作台投影与权威订单短暂不同,应该如实呈现
工作台摘要来自 reporting 投影,待接订单列表来自 ordering 的权威查询。
实际待接列表:
const query = useQuery({
queryKey: ['orders-list', 'awaiting'],
queryFn: ({ signal }) => ordersApi.list({ status: 'PAID' }, 0, 5, signal),
})
这里没有日期过滤,也没有根据统计数字在浏览器伪造五条订单。
投影还未消费最新事件时,摘要可能短暂落后。前端应展示真实更新时间和真实订单,不自行加减统计,让两个来源“看起来一致”。
订单列表也不能用前五条长度代表全店待办总量。
如果工作台投影暂不可用,仍提供直接进入订单中心的入口。查看经营摘要失败,不应该把员工履约路径一并封死。
19. 又一次 OpenAPI 同名覆盖,说明契约也需要测试
PC-3 发现订单摘要与报表汇总都使用 Summary 名称,生成文档时发生覆盖,导致 History.items 引用了报表字段。
前端如果据此生成类型,可能得到“订单列表项有营业额汇总,却没有订单摘要字段”的错误结构。
修复把订单应用 DTO 明确命名为 OrderSummary,并验证 History.items 的引用和字段集,再重新生成前端契约类型。
JSON 字段、URL、数据库与领域规则不变,没有新增迁移。
PC-2 的 StatusChange 和本阶段的 Summary 说明:检查 /v3/api-docs 能否返回 200 远远不够,还要验证关键资源真的指向正确 schema。
20. 用可控时序验证确认框,而不只验证按钮点击
一个有效的测试需要让确认框停在那里,再主动触发第二次点击和详情刷新。
实际测试片段:
it('确认期间不刷新版本,重复点击只发一次命令', async () => {
let approve!: () => void
mocks.confirm.mockImplementation((options) => (approve = options.onOk))
const { model } = setup()
await flushPromises()
const command = model.act('acceptance')
expect(model.busy.value).toBe(true)
await model.act('acceptance')
await model.load()
expect(mocks.detail).toHaveBeenCalledTimes(1)
approve()
await command
expect(mocks.act).toHaveBeenCalledExactlyOnceWith(first, 'acceptance', 4)
expect(model.order.value?.status).toBe('ACCEPTED')
})
setup、first 和 mocks 由测试文件的装配代码提供。这里关注的是行为:确认期间没有新详情读取,最终命令仍带版本 4。
其余用例主动制造这些顺序:
- A 订单先请求,B 订单先返回,最后才让 A 返回。
- 409 后触发自动读取,确认它不能解除保护。
- 确认框打开后改变订单 id,再点确认,不能发出原命令。
- 退款受理返回 REFUNDING/PENDING,只显示等待确认。
- 详情返回 404 后,再次尝试动作,不能继续写。
这些测试比“mock 请求立即成功,然后按钮变灰”更能覆盖真实竞争。
21. 真实浏览器测试里的付款事实,不等于新的渠道实付验收
本阶段真实浏览器测试继续运行后端 JAR 和独立 PostgreSQL 临时 schema。
为了稳定覆盖八种状态与履约竞争,测试脚本通过专用 _test 连接准备历史付款等事实,只允许访问运行器登记的随机 pc1_ schema。
这不是业务 HTTP 模拟支付接口,脚本也不进入生产构建。
测试能证明这些真实持久化状态下的查询、权限、版本竞争和履约行为;不能据此声称浏览器已经通过支付宝完成了新一轮沙箱实付。
渠道创建、签名、查询、通知和退款仍由 P5 既有测试覆盖。
| 浏览器场景 | 验证重点 |
|---|---|
| 普通员工完整履约 | 从工作台进入,接单、配送、完成并刷新恢复 |
| 拒单与取消 | 真实受理结果不显示伪退款成功 |
| 二十一条订单分页 | 服务端总量与跨页结果 |
| 组合筛选 | 完整 UUID、成交快照电话、日期、状态 |
| 两次版本竞争 | 陈旧命令被拒绝,重读后才恢复操作 |
| 响应丢失 | 服务端可能已执行,客户端不重复写 |
| 跨日待办 | 工作台入口不隐式限制今天 |
| 可见详情刷新 | 读到其他员工更新,不恢复失效动作 |
2026-09-20 阶段最终记录:155 项后端测试、40 项前端单元测试、25 项真实浏览器测试全部通过,无跳过。
统一入口仍为 ./scripts/verify.sh,覆盖格式、Checkstyle、架构、类型、契约一致性和生产构建。设计记录见 admin/design-qa-pc3.md。
该轮浏览器记录到一次 ResizeObserver 通知,业务与可视区域断言通过,没有过滤异常或降低门禁;远程 CI 尚未执行。文章不把这份阶段记录描述成刚刚重新运行的结果。
22. 订单可操作以后,下一步怎样接入实时提示
到 PC-3,员工已经能从当前待办进入具体订单,依据快照确认动作,并在并发和未知结果下恢复。
下一阶段要减少发现新订单和催单的等待时间,但 WebSocket 不会改变这里的规则:通知到达后仍要读取权威订单,旧通知不能恢复旧动作,收到提醒也不能自动接单。
读者练习
练习一:冻结版本。 确认框打开后,另一员工已经接单。为什么应该让旧版本请求冲突,而不是静默换成新版本后再执行?
练习二:取消与退款。 (CANCELLED, PENDING) 应该显示什么?什么条件下可以停止详情轮询?
练习三:结果未知。 接单已提交,但浏览器收不到响应。为什么自动轮询不能悄悄解除写入保护?用户明确重读后应该看到哪些变化?
练习四:快照电话。 顾客修改账号手机号和地址簿之后,用哪个电话仍能找到旧订单?详情应该重新查询地址簿吗?
练习五:跨日待办。 工作台“今日订单”与“当前待接单”各自有什么日期口径?为什么不能共用一个默认今天的过滤器?
练习六:通知来了。 假设收到一条五分钟前的来单通知,但该订单已经完成。通知应直接设置 PAID,还是打开详情重新读取?
下一篇进入 PC-4,区分实时唤醒、HTTP 连续补查与员工显式已读,并把这些资源纳入完整会话生命周期。
实现对照
- 阶段契约:
docs/PC3_CONTRACT.md。 - 筛选与状态:
admin/src/modules/orders/model/order-filter.ts、order-state.ts。 - 详情生命周期:
admin/src/modules/orders/model/use-order-detail.ts。 - 可见轮询:
admin/src/shared/lib/use-visible-poll.ts。 - 工作台待接列表:
admin/src/modules/orders/ui/AwaitingOrders.vue。 - 真实浏览器验收:
admin/e2e/orders.spec.ts。
系列:Han Menu 外卖系统实践 · 从第一篇开始