从零重构外卖系统(十三):从经营分析到生产交付,怎样让管理端的数据、维护与发布都可核对
系列:Han Menu 外卖系统实践 · 从第一篇开始
上一篇:第12篇 · 下一篇:本批教学文章终点
系列:Han Menu 外卖系统实践 · PC-5 / PC-6 合篇
面向读者:已经理解前几篇的会话、版本化命令、订单与通知,希望继续学习报表口径、异步维护、前端资源恢复,以及真实生产代理和静态交付的开发者。
本篇依据合并交付提交
39853ce、docs/PC5_CONTRACT.md与docs/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 | 按退款确认时刻归属经营日 |
| 累计顾客 | 包含查询起日之前已注册的历史事实 |
页面展示这些服务端字段,不从当前订单列表或图表点重新计算汇总。
用于理解语义的公式是:
零分母由后端按既有契约返回 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
本次设计的等待关系:
这说明配置留出的等待空间,不意味着三个超时都是等价的“端到端总时长计时器”。尤其代理读写超时对应其网络读写等待语义,不能把它当作事务提交证明。
前端 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.md、docs/PC6_CONTRACT.md。 - 报表口径与下载:
admin/src/modules/reports/model/reporting.ts、admin/src/modules/reports/api/reports.ts。 - 维护命令与恢复:
admin/src/modules/maintenance/pages/MaintenancePage.vue。 - 页面分包恢复:
admin/src/app/router/page-loader.ts。 - 生产模板与生成器:
admin/deploy/nginx.conf.template、admin/deploy/render-nginx.mjs。 - 产物检查与打包:
admin/scripts/check-dist.mjs、admin/scripts/package-release.mjs。 - 真实浏览器验收:
admin/e2e/operations.spec.ts、admin/e2e-production/delivery.spec.ts。 - 部署和视觉记录:
admin/DEPLOYMENT.md、admin/design-qa-pc5.md、admin/design-qa-pc6.md。
系列:Han Menu 外卖系统实践 · 从第一篇开始
上一篇:第12篇 · 下一篇:本批教学文章终点