系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第12篇 · 下一篇:本批教学文章终点

系列:Han Menu 外卖系统实践 · PC-5 / PC-6 合篇

面向读者:已经理解前几篇的会话、版本化命令、订单与通知,希望继续学习报表口径、异步维护、前端资源恢复,以及真实生产代理和静态交付的开发者。

本篇依据合并交付提交 39853cedocs/PC5_CONTRACT.mddocs/PC6_CONTRACT.md 编写。代码展示实际关键实现,省略 import、外围方法或配置的片段不是独立完整文件。

这里讲的是管理端 PC-5、PC-6;后端 P5 支付与 P6 通知统计已在前文介绍。本篇合并经营分析与交付验收,不改写此前两篇后端文章。验收数量来自阶段记录,没有因撰写博客重新执行门禁,也不验证 Vditor 渲染。

1. 订单能处理、通知能恢复以后,还缺少什么

到 PC-4,员工已经可以登录管理端、维护资料、处理订单,并通过通知发现来单和催单。

管理员接下来关心的是另一组问题:

  • 今天完成了多少订单,实际收到多少钱,两者为什么不一样?
  • 退款还在处理中,是否已经算作支出?
  • 趋势图与销量排行同时加载,为什么仍可能来自不同统计版本?
  • 通知显示已投递,为什么员工阅读进度还没变化?
  • 统计重建等待很久后断网,能不能直接再点一次?
  • 开发服务器里一切正常,部署后为什么深链接、下载和 WSS 失效?
  • 发布新版本时,已经打开旧页面的员工还能不能加载旧分包?

PC-5 接入已有后端的经营查询与维护能力,PC-6 再把这些页面放到真实生产静态资源、TLS 和 Nginx 后面验收。

把这两个阶段合在一起,可以看清一条完整路径:先定义页面展示的事实,再验证它经过构建、网络、代理与发布之后仍然保持相同含义。

2. 经营分析与系统维护的权限边界

本阶段新增页面都只允许当前管理员访问。

页面 路由 能力
支付流水 /finance/payments/:id? 组合检索、分页、详情、关联退款
退款流水 /finance/refunds/:id? 组合检索、详情、原支付与订单跳转
经营分析 /reports 汇总、趋势、经营日账、销量、XLSX
资金对账 /finance/reconciliation 实收、退款、净收款、固定差异计数
安全审计 /settings/audit 固定事件、操作者、目标、结果与日期查询
系统维护 /settings/maintenance 投影状态与重建、通知轨迹与耗尽重投

路由与导航限制继续配合后端身份检查。普通员工仍能处理订单和阅读自己的通知,但不能通过链接进入资金或维护能力。

订单详情只给管理员提供支付与退款详情链接;通知中心也只给管理员提供通知投递轨迹入口。

这些链接是在权限基础上补齐工作路径,没有把所有登录员工都扩展成后台管理员。

3. 前端新增模块,后端不再重复造一套管理接口

PC-5 接入的是 P6 与 P7 前置已经存在的真实资源,没有新增业务 HTTP 接口或数据库迁移。

主要模块结构:

admin/src/modules/
├── finance/
│   ├── api/finance.ts
│   ├── model/filters.ts
│   └── pages/TransactionsPage.vue
├── reports/
│   ├── api/reports.ts
│   ├── model/reporting.ts
│   ├── pages/ReportsPage.vue
│   ├── pages/ReconciliationPage.vue
│   └── ui/
├── audit/
│   ├── api/audit.ts
│   └── pages/AuditPage.vue
└── maintenance/
    ├── api/maintenance.ts
    └── pages/MaintenancePage.vue

每个模块继续通过 index.ts 暴露页面入口。HTTP、错误、会话、分页和命令保护复用前文基础。

flowchart LR
  PAYMENT[支付模块持久化事实] --> FINANCE[支付与退款流水]
  FACTS[订单 顾客 收退款事实] --> PROJECTION[统计投影]
  PROJECTION --> REPORTS[经营分析与对账]
  IDENTITY[固定身份安全事件] --> AUDIT[安全审计]
  NOTICE[通知与投递尝试] --> MAINTAIN[通知诊断]
  ADMIN[管理员确认] --> REBUILD[版本化维护命令]
  REBUILD --> PROJECTION
  ADMIN --> REDELIVER[耗尽通知重投]
  REDELIVER --> NOTICE

图中查询与维护有不同入口。刷新报表不会重建投影,查询支付流水也不会悄悄发起渠道查单。

4. 支付流水的“刷新”,读取的是持久化事实

支付列表支持 PENDING、SUCCEEDED、CLOSED;退款列表支持 PENDING、SUCCEEDED。

订单编号、顾客编号和支付编号要求完整 UUID,筛选在服务器执行,分页继续采用后端零基页码。

列表与详情通过路由表达,打开或关闭详情保留筛选。支付详情能进入关联退款,退款详情能返回原支付或对应订单。

当前状态文字的实际映射:

export function statusLabel(status: string | undefined, kind: Kind) {
  if (status === 'SUCCEEDED') return kind === 'payments' ? '支付成功' : '退款成功'
  if (status === 'PENDING') return kind === 'payments' ? '待支付' : '退款处理中'
  return status === 'CLOSED' ? '已关闭' : '状态未知'
}

退款 PENDING 不能显示“已退款”,即使订单取消操作已经受理。

这延续了后端管理查询的只读语义:GET 展示已经登记的支付与退款事实,不在读取时改变渠道状态,也不新增“手动标记支付成功”按钮。

金额与确认时刻来自服务端。当前还没有确认的字段显示缺失,不用浏览器时间填满详情。

5. 同一个日期选择器,可能对应两种完全不同的协议

流水和审计按事件时刻检索;报表按经营日期聚合。

场景 前端选择 提交给后端
支付、退款创建日期 上海日历起止日期 UTC 左闭右开时刻区间
审计发生日期 上海日历起止日期 UTC 左闭右开时刻区间
报表经营日期 含首尾的经营日 YYYY-MM-DD 的 from/to

例如选中 2026 年 9 月 22 日:

流水与审计:
  from = 2026-09-21T16:00:00.000Z
  to   = 2026-09-22T16:00:00.000Z
  from <= occurredAt/createdAt < to

经营报表:
  from = 2026-09-22
  to   = 2026-09-22
  包含这个经营日

报表不能把结束日再加一天当作相同语义传给后端,否则查询范围就扩大了。

实际报表区间验证:

export function period(from: string, to: string): Period {
  validDate(from)
  validDate(to)
  if (from > to || dayjs(to).diff(dayjs(from), 'day') > 365)
    throw new ApiProblem(400, '请选择包含首尾、不超过366天的日期区间')
  return { from, to }
}

首尾日期相差 365 天,包含两端就是 366 个经营日。因此这里判断的是 > 365,不是机械比较 366。

默认最近七天包含上海时区的今天,还提供最近三十天与自定义范围。今日经营尚未结束,页面会明确提示数据仍在变化。

6. 营业额、净收款与完成率为什么不能互相代替

报表最容易出现的错误,是把几个看起来都像“收入”的字段放在一起,却省略日期口径。

当前服务器字段含义:

指标 口径
营业额 turnover 当前已完成订单按 completedAt 所属经营日汇总
完成订单 completedOrders 按完成日计数,可能包含以前日期创建的订单
创建订单 submittedOrders 按订单创建日计数
创建群组完成率 在同一创建日期范围内,已完成数除以创建数
实收款 receivedAmount 按收款确认时刻归属经营日
退款 refundedAmount 按退款确认时刻归属经营日
累计顾客 包含查询起日之前已注册的历史事实

页面展示这些服务端字段,不从当前订单列表或图表点重新计算汇总。

用于理解语义的公式是:

NetReceived = ReceivedAmount - ConfirmedRefundAmount
CompletionRate = \frac{CompletedInCreationCohort}{SubmittedInCreationCohort}\times 100\%
AverageOrderValue = \frac{TurnoverByCompletionDate}{CompletedOrdersByCompletionDate}

零分母由后端按既有契约返回 0.00;前端只做展示格式化。公式解释指标,不是让浏览器再次计算一份结果。

用一个跨日例子理解差别

以下是解释口径的假设场景,不是测试数据库实际记录:

事件 时刻 金额
订单 A 创建并付款 9 月 21 日 40 元
订单 A 完成 9 月 22 日 40 元
订单 B 的退款确认,原付款发生在更早日期 9 月 22 日 60 元

只考虑这些事件,9 月 22 日营业额有 A 的 40 元;A 的实收发生在前一天;当天确认 B 的退款可使当日净收款为负。

负净收款不应被页面裁成零,也不应该被判定为“营业额算法一定错了”。它们本来就在描述不同事实。

群组完成率也不表示过去那天收盘时的历史截面。查询时订单状态继续演进,创建群组中已经完成的数量可能随之改变。

7. 趋势与销量并发加载,不等于来自同一个快照

经营汇总和销量由不同 HTTP 请求取得。

即使 Promise 同时发出,两个请求之间仍可能发生事件消费或投影重建。

sequenceDiagram
  participant UI as 报表页
  participant R as 报表服务
  participant E as 投影更新
  UI->>R: GET operations
  R-->>UI: generation=3,revision=80
  E->>R: 应用新的订单事实,revision=81
  UI->>R: GET sales
  R-->>UI: generation=3,revision=81
  UI->>UI: 保留各自数据,提示正在同步

实际比较函数:

export function sameProjection(left?: Metadata, right?: Metadata) {
  return (
    !!left &&
    !!right &&
    Number.isSafeInteger(left.generation) &&
    Number.isSafeInteger(left.revision) &&
    left.generation === right.generation &&
    left.revision === right.revision
  )
}

代际与修订号都完整且相等,才有依据认为两份结果对应同一投影位置。缺少元信息也不能默认相同。

不一致时页面提示“数据正在同步”,保留真实内容,允许刷新后核对;不会改写销量去迎合汇总数字。

对账也保留自己的投影元信息。后台不同查询放在一个浏览器页面里,并不会因此自动获得跨请求事务一致性。

技术代际、修订和控制版本放在可展开诊断中,普通经营阅读主要看到业务值与更新时间。

8. 图表负责表达服务器数据,也有自己的资源生命周期

本次新增 ECharts 6.1.0,这是提交锁定版本,不表示读者阅读时的最新版本。

趋势区复用一个图表实例,在营业额、创建订单、新增顾客之间切换。按需引入折线、网格、提示、无障碍组件与 SVG 渲染器:

import { init, use, type EChartsType } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, AriaComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'

use([LineChart, GridComponent, TooltipComponent, AriaComponent, SVGRenderer])

数据点直接来自经营日账:

series: [
  {
    type: 'line',
    name: props.label,
    data: props.days.map((day) => day[props.metric] ?? null),
    smooth: false,
    symbolSize: 5,
    lineStyle: { width: 2 },
    areaStyle: { color: '#edf3ed' },
  },
],

这是 setOption 配置片段。缺少值保留为 null,不一律伪装成零;关闭平滑插值,避免曲线形状暗示日数据之外的变化。

容器变化时通过 ResizeObserver 感知,在下一帧 resize,避免观察回调同步改动 SVG 尺寸形成布局循环。

observer = new ResizeObserver(() => {
  cancelAnimationFrame(resizeFrame)
  resizeFrame = requestAnimationFrame(() => chart?.resize())
})
observer.observe(container.value!)

卸载时清理观察器、动画帧与图表:

onBeforeUnmount(() => {
  observer?.disconnect()
  cancelAnimationFrame(resizeFrame)
  chart?.dispose()
})

同一批 days 还用于经营日账表格。图表看趋势,表格读精确数值;切换指标不重新制造一套统计口径。

销量提供前 10、20、50、100 名,套餐按成交套餐计件,不重复累计其组成菜品。

9. 对账页的差异计数不能直接相加为“错误订单总数”

对账返回固定核对项,有些按选定日期,有些按全店当前状态。

核对项 范围
收款缺少订单 区间内收款
收款金额或引用不符 区间内收款
退款缺少原支付 区间内退款
退款订单或金额不符 区间内退款
已付款订单缺少收款事实 全店当前
已退款订单缺少退款事实 全店当前
待退款订单 全店当前,不按日期截断

同一业务对象可能命中多个核对项,事件同步延迟也可能产生暂时差异。

因此页面明确说明计数可能重叠,而不是把七行数字相加后命名为“全店异常订单数”。

接口只有计数,没有返回差异明细。页面提供支付、退款查询和维护入口,不画一个实际无法查询的差异明细下钻按钮。

投影重建可以修复可重建统计的派生状态,但不会把缺失的真实收款变出来,也不会直接修改订单或渠道资金。

10. XLSX 应下载后端工作簿,不在浏览器重新拼报表

P6 后端已经负责工作簿内容、统计快照、金额表达与文本单元格安全。PC-5 直接下载后端文件。

实际请求:

const result = await api.GET('/api/v1/reports/export', {
  params: { query },
  signal,
  parseAs: 'blob',
})
const blob = resource(result)
return validateWorkbook(blob, result.response)

导出仍通过统一客户端携带员工 Bearer,沿用 401 撤销、403 与 Problem 错误处理。

一个 Blob 不一定是 Excel 文件

代理可能错误返回 HTML,业务错误也可能是 JSON;即使状态码为 200,也不能直接保存成 .xlsx

实际验证:

export async function validateWorkbook(blob: Blob, response: Response): Promise<Blob> {
  const type = response.headers.get('Content-Type')?.split(';')[0]?.trim()
  if (type?.includes('json')) {
    let body: unknown
    try {
      body = JSON.parse(await blob.text())
    } catch {
      /* 错误正文不可解析时显示安全兜底。 */
    }
    throw problemFromResponse(body, response)
  }
  if (type !== xlsxType || !blob.size)
    throw new ApiProblem(502, '导出文件格式不正确,请稍后重试', 'INVALID_EXPORT')
  return blob
}

xlsxType 是标准 XLSX MIME。这个方法检查响应类型和非空内容,没有在浏览器中完整解析 ZIP 与工作簿结构;不能把它夸大成完整文件验证器。

下载也需要冻结请求上下文

点击时复制日期与当前会话代数:

const period = { ...range.value },
  epoch = sessionBridge.snapshot().generation
const blob = await reportsApi.export(period, controller.signal)
if (controller.signal.aborted || !sessionBridge.isCurrent(epoch)) return

下载期间禁用重复操作与日期切换,文件名使用同一份 period。页面卸载取消请求,旧账号迟到的文件不能在新账号页面自动触发下载。

成功后生成 Blob URL,通过临时 a 元素下载。下一次替换或页面卸载时 revokeObjectURL,避免一直占用对象引用。

11. 安全审计展示固定事件,不是浏览器日志查看器

审计接口返回身份模块已经登记的固定事件,例如:

  • 初始化管理员、登录、退出。
  • 创建员工、修改员工、员工或顾客状态变化。
  • 修改密码、拒绝授权、登录限流。

页面按事件类型、操作者、目标、成功与否、发生日期和分页查询。

没有把服务器日志文件直接读到浏览器,也不展示认证头、密码或请求正文。

export async function searchAudit(query: AuditFilter, signal?: AbortSignal) {
  return resource(
    await api.GET('/api/v1/management/audit-events', { params: { query }, signal }),
  )
}

固定事件便于按照业务含义检索,内部标识便于追踪对象。它不是“系统全部行为都有审计”的承诺,页面范围只覆盖后端实际登记的安全事件。

12. 通知诊断需要区分投递状态与个人阅读状态

PC-4 面向员工阅读,PC-5 的维护页面向管理员诊断投递。

投递状态包括 PENDING、IN_FLIGHT、DELIVERED、EXHAUSTED。最近最多一百条投递尝试可按已知通知 UUID 查询,展示真实连接成功/失败数量与失败分类。

DELIVERED 说明一次投递成功,不代表每名员工已经阅读,也不意味着对应订单已被接单。

通知流继续按 nextCursor 顺序翻页,只能返回本次已经浏览的上一页。没有总量就不伪造总页数,没有耗尽筛选接口就不声称当前页等于全店所有耗尽消息。

只输入 UUID 可以查询轨迹,但管理员不能因此手填一个 version 发起重投。写入前还需要真实通知状态和版本。

13. 没有单条通知详情接口时,怎样可靠地重新读取版本

从通知中心或维护流进入时,已经知道通知 id 与 sequence。

当前实现用该 sequence 前一个位置补查一条,再核对 UUID:

const result = await maintenanceApi.feed(old.sequence - 1, undefined, 1)
if (!alive || !sessionBridge.isCurrent(epoch) || noticeId.value !== old.id) return
const current = result.items?.[0]
if (current?.id !== old.id)
  throw new ApiProblem(502, '无法确认当前通知,请重新选择')
selected.value = current

这是 reloadNotice 的核心分支。它依据 P6 通知流的稳定序号契约定位原记录,不从查询参数或旧 UI 推断新版本。

如果记录身份无法对应,就不采用这个响应继续写入。

只有 EXHAUSTED 可以重投:

const { id, version } = revision(notice)
if (notice.deliveryStatus !== 'EXHAUSTED')
  throw new ApiProblem(409, '只有投递耗尽的通知可以重投')
return resource(
  await api.POST('/api/v1/notifications/{id}/redelivery', {
    params: { path: { id } },
    body: { version },
  }),
)

确认说明可能产生重复提醒、保留原通知与轨迹、不改变个人已读。成功提示是“已重新登记投递”,实际推送仍由服务端工作器执行。

409、网络错误和服务异常要求显式重读。维护页不 PUT 员工 receipt,也不会重建通知历史。

14. 投影重建是一条有版本的维护命令

页面显示初始化状态、更新时间、重建时间,以及订单、顾客、收退款记录数。

重建前先取得当前控制版本,并在确认框展示影响:从公开业务事实重建统计,可能持续一百八十秒,旧报表仍可读取,新的事件随后追平。

sequenceDiagram
  participant U as 管理员页面
  participant R as 投影服务
  participant F as 公开业务事实
  U->>R: GET 投影状态
  R-->>U: 控制版本与当前统计元信息
  U->>U: 冻结版本,确认影响
  U->>R: POST rebuild,携带version
  R->>F: 读取订单 顾客 收退款事实
  R->>R: 在既有事务边界内重建投影
  R-->>U: 返回真实新状态
  U->>U: 刷新报表与工作台查询

前端没有百分比进度接口,所以等待时只显示执行状态,不制造一条匀速涨到 99% 的进度条。

确认和执行期间阻止重复维护写入和普通离开;会话撤销仍允许安全退出,并销毁旧确认框。

投影与通知各自使用一个 useCommand,分别保留错误与重读保护。刷新通知轨迹不能顺手清掉一次尚未确认的投影重建结果。

15. 重建响应丢失,为什么刷新到同版本仍不能立刻重试

普通资料修改通常很快,投影重建则可能处于较长事务中。

浏览器断网后立即读取,服务器可能仍在重建,新事务还没提交,因此读取到旧版本。

这时“版本没变”不足以证明原请求已经失败。

页面记录 unknownVersion 与 unknownSince,实际恢复逻辑:

async function reloadProjection() {
  if (busy.value) return
  const result = await projection.refetch()
  if (result.error || !result.data) return
  if (
    unknownVersion.value !== undefined &&
    result.data.version === unknownVersion.value &&
    Date.now() - unknownSince.value < 195_000
  )
    return
  unknownVersion.value = undefined
  writeError.value = null
}
核对结果 页面行为
读取失败或没有状态 保持阻塞
仍为原版本,距提交不足 195 秒 可能仍在事务中,保持阻塞
读取到版本变化 接受新的服务端事实,解除未知结果保护
已过 195 秒且成功读取状态 允许用户基于读取结果重新确认

经过一段本地时间,不会自动再次 POST。即使到达等待窗口,也必须先成功读取,再由用户明确决定。

409 则按冲突路径刷新状态、重新确认。它与“网络断了,服务器是否还在执行”是不同的恢复理由。

16. 超时边界需要从后端一直核对到代理

普通请求不应因为投影重建而全部延长。

统一客户端只对特定资源调整等待:

const timeoutMs =
  request.method === 'POST' && url.pathname === '/api/v1/reports/projection/rebuild'
    ? 195_000
    : url.pathname === '/api/v1/reports/export'
      ? 60_000
      : 15_000

本次设计的等待关系:

180s\ \text{后端重建事务上限} <195s\ \text{前端重建等待} <210s\ \text{代理读写超时配置}

这说明配置留出的等待空间,不意味着三个超时都是等价的“端到端总时长计时器”。尤其代理读写超时对应其网络读写等待语义,不能把它当作事务提交证明。

前端 abort 也不会回滚已经到达服务器的命令,所以未知结果保护仍然必须存在。

到这里,PC-5 已经接通真实查询和维护。PC-6 接下来检查:这套行为通过生产构建和反向代理后是否仍然成立。

17. 分包失败时,恢复页面自己不能也在失败分包里

业务页面按模块懒加载,有利于控制主入口体积。但发布切换或网络中断时,动态 import 可能失败。

如果只给当前页面加 try/catch,首次深链接就可能还没挂载任何业务组件,根本没有机会展示恢复按钮。

实际路由包装器把恢复组件随主入口静态加载:

import type { Component } from 'vue'
import PageLoadFailure from '../layouts/PageLoadFailure.vue'

export function recoverPage(loader: () => Promise<{ default: Component }>) {
  return async () => {
    try {
      return (await loader()).default
    } catch {
      return PageLoadFailure
    }
  }
}

失败时返回一个已经可用的组件,使首次路由也能结束启动等待,显示“重新加载此页”。

恢复由用户主动触发,明确提示刷新会清除尚未保存输入,不自动 reload 草稿页面,也不要求清除登录会话。

如果连主入口脚本也没加载呢

Vue 组件无法处理“Vue 本身尚未启动”的情况。

index.html 的 app 容器里预置原生文字与刷新链接;正常挂载后替换,脚本不可用时仍有入口。

<h1>正在载入商家管理…</h1>
<p>若持续无法载入,请检查网络后重新加载。</p>
<a href="">重新加载</a>

这是原生占位内容摘录,不是另一个运行中的 Vue 组件。

18. 图表失败应该保留报表已经取得的内容

ECharts 单独分包加载。图表资源失败不意味着汇总、日账和下载能力都失效。

实际异步组件:

const TrendChart = defineAsyncComponent(() =>
  import('../ui/TrendChart.vue')
    .then((module) => module.default)
    .catch(() => TrendUnavailable),
)

TrendUnavailable 提示可以继续查日账或下载,提供用户主动重新加载入口,并告知页面重载后日期恢复默认值。

flowchart TD
  HTML[原生 HTML] --> ENTRY{主入口可用}
  ENTRY -->|否| LINK[原生刷新链接]
  ENTRY -->|是| ROUTE{路由分包可用}
  ROUTE -->|否| FALLBACK[主入口中的恢复组件]
  ROUTE -->|是| REPORT[经营页面与查询数据]
  REPORT --> CHART{图表分包可用}
  CHART -->|是| DRAW[显示趋势]
  CHART -->|否| TABLE[保留汇总 日账 下载]

恢复入口放在哪一层,取决于哪一层仍然可以执行。不能让每一层都依赖出错的那份资源。

验收记录中图表独立分包仍约 505 kB,保留 Vite 默认体积提示,没有提高阈值来让提示消失。按需导入是明确的优化措施,不是“包体积已经没有问题”的证明。

19. 开发代理通过,不代表生产代理语义正确

PC-6 使用生产 dist、固定 Nginx 镜像、TLS 和真实后端进行第二轮浏览器验收,不使用 Vite 替代生产代理。

部署与验收共用 admin/deploy/nginx.conf.template 及同一生成器。

最基本的路由区分:

请求 正确处理
/orders/某UUID 等页面深链接 回退 index.html,由前端路由处理
缺失的 /assets/xxx.js 404,不能返回 HTML
/api/v1/... 真实后端响应,不走 SPA 回退
/index.html 不缓存
已存在的哈希 assets 长期 immutable 缓存

如果所有 404 都回退 index,缺失 JavaScript 会收到一份 HTML,报表下载也可能得到一个文件名正确、内容却是页面的假工作簿。

静态资源配置摘录:

location ^~ /assets/ {
  try_files $uri =404;
  add_header Cache-Control "public, max-age=31536000, immutable";
  add_header X-Content-Type-Options nosniff always;
}

location / {
  add_header Cache-Control no-store always;
  add_header X-Content-Type-Options nosniff always;
  add_header X-Frame-Options DENY always;
  add_header Referrer-Policy same-origin always;
  try_files $uri $uri/ /index.html;
}

完整模板还单独配置 index、API、隐藏文件与内部资源边界,上述片段不能替代整份 nginx.conf。

20. 代理不能重发业务命令,也不能用错误 HTML 覆盖 Problem

PC-1—PC-5 已经坚持写命令不自动重试。如果反向代理擅自重发一次 POST,这条约束仍然会在另一层被破坏。

实际代理配置明确关闭上游重试:

proxy_connect_timeout 5s;
proxy_read_timeout 210s;
proxy_send_timeout 210s;
proxy_buffering off;
proxy_next_upstream off;
proxy_intercept_errors off;
error_page 502 504 =503 @api_unavailable;

后端业务错误和 XLSX 保持原样;代理自身无法连接上游时,由固定命名位置返回 503 Problem JSON。

这样统一错误组件能继续解释“服务暂不可用”,不会收到一个无法解析的 Nginx HTML 错误页。

模板还保留 Bearer、Origin 与 WebSocket 子协议,配置 HTTP/1.1 Upgrade,让 PC-4 的一次性票据握手穿过真实 WSS 代理。

上传请求上限为六 MiB,用于容纳 multipart 总请求;业务图片仍由前后端按五 MiB 文件限制校验。这两个大小不应被误认为互相矛盾。

21. 可信代理和日志边界也是交付的一部分

当前部署文档针对同机代理与单进程后端:后端绑定回环,只信任明确的回环代理地址。

Nginx 覆盖客户端可伪造的转发信息:

proxy_set_header Host $http_host;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;

这与后端信任范围一起,支持登录限流使用实际客户端来源。若改成多层代理或跨主机部署,需要重新定义信任链,不能原样照搬单机前提。

访问日志采用不含查询串的 $uri

log_format safe '$remote_addr $request_method $uri $status $request_time';

不记录认证头和请求正文;普通上游故障使用状态与耗时诊断,避免默认错误日志带出完整敏感请求行。这减少特定日志暴露,不代表整个运维环境已经不存在任何敏感信息。

商品图片的公开地址也要能被浏览器通过 HTTPS 访问。预签名 URL 的生成地址与实际访问地址必须一致,不能签完后再用字符串替换主机名。

22. 配置生成器为什么不直接对整个模板做环境变量替换

Nginx 自己使用 $uri$remote_addr 等变量。粗暴替换环境变量,可能把这些表达式一起改坏。

当前生成器只替换明确的双花括号占位符:

return template.replace(/\{\{(\w+)\}\}/g, (_, name) => {
  if (!(name in values)) throw new Error('未知占位符')
  return values[name]
})

listen、backend、域名、静态根和证书路径分别验证,拒绝换行或配置语法注入。

证书必须成对,公开绑定必须有 TLS:

if (!!certificate !== !!key) throw new Error('TLS证书和私钥必须同时提供')
if (listen.startsWith('0.0.0.0:') && !certificate)
  throw new Error('对外监听必须配置HTTPS证书')

本次固定 Nginx 1.30.5-alpine 及镜像摘要,记录在 admin/deploy/runtime.json。版本是当前交付选择,不在文章里宣称永久适合所有环境。

生成后仍执行 nginx -t。参数通过正则检查,只能证明输入满足生成器规则;真实 Nginx 解析才检查完整配置是否可用。

23. 发布顺序要保护已经打开的旧页面

页面已经加载旧 index,就可能在稍后访问一个尚未加载的旧业务分包。

如果发布时先删光旧 assets,再放新文件,这些仍在营业中的页面会突然找不到旧分包。

当前部署流程:

flowchart LR
  VERIFY[校验交付包] --> UNPACK[解包到独立版本目录]
  UNPACK --> ASSETS[复制新哈希 assets 并保留旧文件]
  ASSETS --> NEXT[写入 index.html.next]
  NEXT --> SWITCH[原子替换 index.html]
  SWITCH --> CHECK[核对登录 WSS 写入 下载]

原子切换发生在同一静态根内的入口文件替换。复制 assets 时不使用删除旧文件的同步选项。

新访客拿到新入口,已打开的旧页面仍可取得旧分包。恢复页是故障处理的一层,正确保留旧资源则能提前减少这类失败。

回滚恢复上一版静态入口,旧 assets 已保留,不修改数据库、订单或资金。

这要求上一版前端仍兼容当前后端契约。本次后端契约没有变化,但未来后端演进时,需要重新核对兼容范围。

24. 交付包需要记录来源,也需要证明具体文件内容

pnpm release 先执行前端 verify,再按白名单打包。

交付内容:

交付包/
├── dist/
├── deploy/
│   ├── nginx.conf.template
│   ├── render-nginx.mjs
│   └── runtime.json
├── DEPLOYMENT.md
├── release.json
└── SHA256SUMS

脚本运行于 admin,产物位于 admin/.local/releases/。不复制后端 .env、测试资源、node_modules 或整个仓库。

为什么只有提交号不够

实际来源记录:

const revision = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
  encoding: 'utf8',
}).trim()
const dirty = !!execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim()

release.json 保存 revision、dirty、构建时刻与 Node 版本。如果工作区有未提交内容,包名也带 dirty,不能声称所有文件都来自那一个 HEAD。

dirty 是工作区状态提示,不是未提交补丁的完整归档。要精确识别本次交付文件,还需要内容哈希。

包内 SHA256SUMS 对交付文件逐一计算,包外 .sha256 再校验压缩包本身。部署时先检查压缩包,再解包核对内部文件。

哈希用于完整性与内容比对,不是发布者身份签名,也不单独保证可重复构建。

25. 静态产物检查应该读取 dist,而不只检查源码配置

源码中写了“生产不包含开发组件”,仍然需要检查实际输出。

check-dist.mjs 遍历 dist,检查入口资源存在、拒绝符号链接、源码映射、隐藏文件和源代码/证书文件,并扫描已知开发入口和服务端配置标识。

for (const match of index.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g))
  await lstat(resolve(root, '.' + match[1]))

这段检查保证 index 中匹配到的 assets 引用确实存在。文件名与内容规则继续检查不该出现的资源。

它不是一个能发现任何未知密钥的通用扫描器,也不证明所有动态分包都会执行成功。因此还需要实际生产浏览器测试。

白名单打包、输出扫描、来源元信息、文件哈希和运行验收分别提供不同证据,没有一个步骤能替代其余所有步骤。

26. 生产浏览器验收为什么再启动一轮后端和两个代理

原有四十五项开发服务浏览器回归保留,生产测试另建独立 schema、启动后端与两个临时 Nginx。

用途 地址或端口
生产样式正常代理 HTTPS 回环 15174,连接测试后端 18081
不可达上游代理 HTTPS 回环 15175,指向未启动的 18089
后端 使用本轮独立临时 schema

第二个代理专门证明上游无法连接时返回固定 503 Problem,而不是只在浏览器里 mock 一份理想错误响应。

临时证书用于回环验收,只有测试浏览器忽略自签名错误。正式部署继续要求可信 TLS 证书,没有关闭实际应用的证书校验。

容器只挂载生产静态目录、测试配置和临时证书,不注入数据库或支付密钥。镜像按 runtime.json 中固定摘要运行。

两个 Playwright 配置都明确 SIGTERM 与三十秒退出窗口,脚本先关闭相关进程,再清理本轮 schema、对象前缀、证书与代理容器。

不复用或停止开发服务器,也不拿开发管理员修改密码或构造业务事实。

27. 真正应该通过生产代理验证哪些行为

九项生产浏览器测试覆盖的不是九张静态截图,而是一组跨层行为。

验收内容 证明什么
TLS、缓存、深链接、静态 404、API 错误 Nginx 没有混淆页面与业务资源
深链接登录、恢复与键盘跳转 生产入口、身份守卫和可访问操作可用
真实 WSS 与显式已读 升级头、Origin、子协议与阅读协议一起工作
分类写入、订单履约至 COMPLETED 真实版本化写操作穿过代理后仍成立
路由分包首次加载及站内失败 恢复组件不依赖失败页面本身
主入口脚本失败 原生 HTML 仍提供刷新入口
断网、401 和登录 429 会话撤销、恢复与 Retry-After 保持一致
三档桌面布局与开发页隔离 生产页面布局和路由范围正确
图表失败、真实 XLSX 与日账 局部资源故障没有吞掉可用业务数据

主入口、路由与图表故障通过精确资源拦截注入;其余真实 API、代理和 Socket 行为继续走生产测试栈。不能把所有场景统称为没有任何故障注入的纯网络验收。

十七个主要页面在 1366、1440、1920 三档宽度执行布局断言,共五十一次页面检查。五十一次是布局检查次数,不应再当作五十一个独立浏览器测试加到总数里。

表格允许内部滚动,页面没有整体横向溢出;键盘“跳到主要内容”能聚焦主体,登录按钮也有明确可访问名称。

这些是具体的可访问性改善和验收,不是完整无障碍标准认证。

28. PC-5 与 PC-6 的测试数字怎样放在一篇里说明

两个阶段使用同一个最终提交,但验收记录有先后。

阶段记录 后端 前端单元 部署配置 开发浏览器 生产浏览器
PC-5 155 72 45
PC-6 最终 155 74 2 45 9

PC-6 最终浏览器测试共五十四项,即四十五项原回归加九项生产验收。不能把两个阶段表格中的累计数量相加。

PC-5 的单元与真实浏览器重点包括:日期边界、独立投影快照、导出 Problem、固定审计、耗尽重投、重建 409 与响应丢失。

PC-6 再补分包恢复、配置生成与真实生产栈。根门禁记录为零失败、零跳过,格式、Checkstyle、模块边界、类型、契约一致性和静态产物检查均通过。

PC-5 首次验收曾因本机中间件停止而受阻,恢复既有服务后完成验证。它没有通过跳过真实数据库测试来获得成功结果。

付款、退款和耗尽通知的部分历史事实由隔离测试脚本准备,不调用真实支付宝,也没有增加应用模拟支付接口。真实查询、重建、重投与下载均继续调用后端。

29. 本地生产栈验收、打包、公网部署是三个不同结果

常用命令及范围:

# 后端、前端静态检查、开发与生产浏览器完整门禁。
./scripts/verify.sh

# 前端静态检查、单元与配置测试、构建、产物检查。
pnpm --dir admin verify

# 已具备JAR、dist和隔离环境时,执行生产浏览器验收。
./scripts/with-env.sh pnpm --dir admin test:production

# 再次执行前端verify,然后生成交付包。
pnpm --dir admin release

pnpm release 不自动替代根目录全部后端与浏览器门禁;它执行的是前端 verify 与打包。CI 配置先跑完整验证,再调用打包脚本并上传产物。

生产验收需要 Linux、Podman 或 Docker、OpenSSL、Chromium 与独立测试环境。开发中的 Vite 仍然用于开发,不作为正式站点发布。

PC-6 文档保留了验收时“代码未提交、交付包为 dirty”的记录;随后代码已落在本篇依据的 39853ce 提交中。后来提交成功不会自动改变先前交付包的来源与哈希。

阶段记录没有执行公网部署或远程 CI,也没有声称远程分支保护已生效。本篇据此描述本地已验证交付能力,不把配置文件存在写成线上服务已经上线。

30. 管理端交付之后,哪些约束还应继续保持

PC-1 到 PC-6 建立的是一条完整管理端工作路径:身份经过验证,资料写入携带版本,订单操作冻结上下文,通知按连续事实恢复,报表明确口径,维护结果可以核对,静态交付可以校验和回滚。

以后增加新的业务页面,不需要重新复制一套认证或错误处理;但每个新指标、新命令和新发布环境仍然需要说明自己的事实来源与失败语义。

Flutter 工程和移动 SDK 联调仍未包含在这次管理端交付里,支付宝仍是既定沙箱范围。本阶段没有把桌面生产栈验收扩展成移动端或正式商户支付验收。

读者练习

练习一:跨日经营。 昨天下单付款、今天完成的订单,分别影响哪一天的创建订单、实收与营业额?为什么不能用“今日完成数 / 今日创建数”替代创建群组完成率?

练习二:不同投影。 汇总来自 (generation=3, revision=80),销量来自 (3,81)。同时完成两个 Promise 能否证明同一快照?页面应显示什么?

练习三:假工作簿。 导出返回 HTTP 200、JSON Problem。只设置 .xlsx 文件名会怎样?当前 MIME 与非空校验还没有证明哪些内容?

练习四:维护结果未知。 重建提交二十秒后断网,刷新状态仍是原版本。为什么不能立刻再提交?超过 195 秒是否可以不读取状态直接重发?

练习五:通知重投。 已知通知 UUID 却没有 sequence 和版本,为什么只能先查轨迹?重投成功会改变哪些事实,又不会改变哪些员工阅读状态?

练习六:分包恢复。 主入口、路由分包与图表分包分别失败时,恢复入口应该依赖哪一层已加载资源?为什么不能统一自动刷新?

练习七:发布顺序。 用户打开旧首页十分钟后才访问报表,期间刚发布新版。先删除旧 assets 会造成什么?保留旧入口和旧 assets 怎样支持静态回滚?

练习八:交付来源。 release.json 有提交号但 dirty=true,文件哈希能证明什么?它是否等于补丁归档、发布者签名或可重复构建证明?

实现对照

  • 阶段契约:docs/PC5_CONTRACT.mddocs/PC6_CONTRACT.md
  • 报表口径与下载:admin/src/modules/reports/model/reporting.tsadmin/src/modules/reports/api/reports.ts
  • 维护命令与恢复:admin/src/modules/maintenance/pages/MaintenancePage.vue
  • 页面分包恢复:admin/src/app/router/page-loader.ts
  • 生产模板与生成器:admin/deploy/nginx.conf.templateadmin/deploy/render-nginx.mjs
  • 产物检查与打包:admin/scripts/check-dist.mjsadmin/scripts/package-release.mjs
  • 真实浏览器验收:admin/e2e/operations.spec.tsadmin/e2e-production/delivery.spec.ts
  • 部署和视觉记录:admin/DEPLOYMENT.mdadmin/design-qa-pc5.mdadmin/design-qa-pc6.md

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第12篇 · 下一篇:本批教学文章终点