一个桌台管理系统,覆盖后端 API、Web 管理后台、微信小程序三端。这篇文章不讲业务功能列表,只讲技术选型背后的判断、架构层面的设计取舍,以及几个值得展开的工程细节。

整体架构

三端分离,各自独立部署:

  • 后端:Go + Gin,单二进制交付,PostgreSQL + Redis

  • Web 管理后台:Vue 3 Monorepo,pnpm workspaces,Vite 构建

  • 小程序端:Taro 4 + React 18,单代码库覆盖多平台

  • 部署:自研蓝绿部署服务,零停机切换

三端通过 RESTful API + WebSocket 通信。后端是唯一的数据权威源,前端和小程序都是纯展示层,不持有业务状态。

下图展示了三端之间的数据流向——所有通信必须经过后端,两个客户端之间不直接交互。

drawing-1780324261370

后端:Go 的工程化实践

为什么是 Go

选 Go 不是因为"快",而是因为部署简单。单二进制、无运行时依赖、交叉编译直接出 Linux amd64 产物,配合自研的蓝绿部署服务,整个发布流程就是"编译 → 上传 → 健康检查 → 切流量"四步。

分层但不过度抽象

代码按 handler → service → repository 三层组织,但没有引入 interface 做依赖反转。原因很实际:这不是一个需要换数据库的系统,PostgreSQL 就是最终答案。过度抽象只会增加跳转层数,对一个中等规模的项目来说,直接调用比"面向接口编程"更容易维护。

internal/
├── handler/       # 接收 HTTP 请求,校验参数,调用 service
├── service/       # 业务逻辑,事务编排
├── repository/    # GORM 查询,数据映射
├── model/         # 27 个实体定义
├── middleware/    # 横切关注点
└── scheduler/     # 定时任务

零依赖令牌桶限流

没有引入第三方限流库,而是用一个 60 行的内存令牌桶实现了 per-IP 和 per-Shop 两种限流策略。桶的清理靠惰性扫描——每 5 分钟检查一次,超过 10 分钟未活跃的桶直接删除,避免内存无限增长。

这个选择的判断依据是:系统的 QPS 在几千量级,单实例内存限流完全够用,不需要引入 Redis 分布式限流的复杂度。

幂等性中间件

写操作(POST/PUT)支持 X-Idempotency-Key 头。实现逻辑是:用 Redis SETNX 抢占 → 录制响应 → 缓存成功结果。关键设计决策有两个:

  1. 只缓存成功响应。失败时释放占位,允许客户端修正后重试。

  2. 按用户隔离 key。同一个 idempotency key 在不同用户之间互不冲突。

这比"所有请求都缓存"的方案更实用——网络抖动导致的重复提交被拦截,而业务错误不会被锁死。

WebSocket 实时推送

桌台状态变更通过 WebSocket 广播到所有连接的客户端。没有用消息队列做 pub/sub,而是进程内维护连接池。理由同上:单实例部署,进程内广播延迟最低,代码最简单。

广播逻辑加了一层 debouncer——同一个店铺 100ms 内的多次状态变更合并为一次推送。批量开台、连续结账这类操作不会产生推送风暴。

结构化并发

整个进程的并发模型围绕一个根 context 展开。signal.NotifyContext 捕获 SIGINT/SIGTERM,产生的 context 向下传递给所有后台 goroutine——7 个定时调度器、WebSocket 心跳、OSS 迁移任务、操作日志写入。

main()
├── ctx ← signal.NotifyContext(SIGINT, SIGTERM)
├── scheduler.StartOvertimeChecker(ctx)
├── scheduler.StartReservationReminder(ctx)
├── scheduler.Start...(ctx)  × 7
├── go srv.ListenAndServe()
└── <-ctx.Done()
    ├── srv.Shutdown(10s timeout)
    └── oplog.Shutdown()  // drain buffer

关键设计:每个 scheduler 内部用 select 同时监听 ctx.Done() 和 ticker,context 取消时 goroutine 自行退出,不需要外部 kill。定时任务还用 Redis 分布式锁做了互斥——虽然当前是单实例,但锁的存在让未来水平扩展时不会产生重复执行。

HTTP server 的优雅关闭给了 10 秒宽限期。正在进行的请求有时间完成,WebSocket 连接在读端超时后自然断开。整个退出流程是确定性的:先停止接收新请求,再等待存量完成,最后 flush 日志缓冲。

这种"context 树 + select 多路复用"的并发模式,是 Go 服务端的惯用写法,但很多项目做不到全链路贯通。这里的每一个 goroutine 都有明确的生命周期归属,不存在"启动了但没人管"的野生协程。

Web 管理后台:Monorepo 的工程收益

为什么是 Monorepo

项目拆成了 5 个包:admin 应用、playground 演示、UI 组件库、样式包、工具函数库。拆分的动机不是"微前端",而是强制解耦——UI 组件库不允许依赖业务代码,业务代码不允许直接操作 DOM 样式。pnpm workspaces 的严格依赖隔离天然保证了这一点。

web/
├── apps/admin/          # 业务应用
├── apps/playground/     # 组件展示
├── packages/ui/         # 布局系统 + 基础组件
├── packages/styles/     # 全局样式 token
└── packages/utils/      # 纯函数工具

oklch 色彩系统

主题色不是用 hex 或 hsl 定义的,而是用 oklch。oklch 的优势在于感知均匀——同一亮度值在不同色相下看起来确实一样亮。这让"换一套主题色"变成了纯数学操作:提取主色的 hue 值,派生出 muted、accent、ring 等语义色,明暗模式各一套,全部通过 CSS 变量注入到 :root

运行时切换主题色时,不需要重新加载样式表,只需要重写十几个 CSS 变量。

View Transition 暗色切换

暗色模式切换用了 View Transition API。点击切换按钮时,以点击坐标为圆心,用 clip-path: circle() 做一个扩散动画,新主题从点击位置向外展开。降级方案是直接切换,不做动画。

这不是核心功能,但它说明了一个设计态度:交互细节值得打磨,前提是降级路径清晰。

五种布局模式的运行时切换

管理后台支持 sidebar、double-sidebar、top-nav、mixed、mixed-double 五种布局,运行时可切换。实现方式是一个统一的 Layout.vue 入口组件,根据 Pinia store 中的 layoutMode 值动态渲染不同的布局骨架。

布局状态持久化到 localStorage,刷新不丢失。主题初始化在 Vue 挂载前同步执行,避免了"先白屏再变暗"的闪烁问题。

路由自动发现

路由模块放在 router/modules/ 目录下,通过 import.meta.glob 自动导入,按 meta.order 排序。新增页面只需要在目录下加一个文件,不需要手动注册路由表。

小程序端:一套代码的多角色设计

Taro + React 的选择

Taro 4 让一套 React 代码同时编译到微信、支付宝、字节等多个小程序平台。选 React 而不是 Vue 的原因是:小程序端的状态管理需求更复杂(多角色切换、离线缓存、WebSocket 状态同步),Zustand 的 vanilla store 模式比 Pinia 更适合这种场景。

多角色状态机

同一个用户可能是顾客、店员、店主三种角色,而且可以在多家店担任不同角色。状态管理的核心挑战是:角色切换时,哪些状态要清除,哪些要保留?

解决方案是 Zustand vanilla store + 显式的 storage 同步。每个状态字段都有明确的持久化策略——token 全局保留,shopToken 跟随当前选中的店铺,切换店铺时自动清除旧的 shopToken。loadFromStorage 在应用启动时重建完整状态,而不是依赖框架的自动序列化。

这种"手动挡"的状态持久化比自动持久化插件更可控:你能精确决定"切换店铺"这个动作到底清除哪些缓存、保留哪些上下文。

组件数量与页面分离

小程序端有 45+ 页面和 100+ 组件。设计原则是:页面只做组件拼接,不包含可复用逻辑。一个 TableCard 组件在"桌台管理"和"开台选桌"两个场景中复用,页面只负责传入不同的 props 和回调。

这种分离在小程序端尤其重要——小程序的页面栈有层数限制,组件复用直接减少了页面数量。

部署:自研蓝绿发布

为什么不用 K8s

系统跑在单台服务器上,Docker Compose 管理容器。引入 Kubernetes 的运维成本远超收益。但零停机发布仍然是刚需——用户正在计时的桌台不能因为发版而断连。

蓝绿切换的实现

自研了一个 Go 编写的部署服务,核心流程:

  1. 上传新版本二进制到备用容器

  2. 启动备用容器,执行健康检查

  3. 健康检查通过后,重写 Nginx 反向代理配置,指向新端口

  4. Reload Nginx,流量切换到新容器

  5. 异步等待旧容器排空连接后停止

下图展示了这个流程的决策分支——健康检查是唯一的门控点。

drawing-1780324298584

如果健康检查失败,备用容器直接停止,线上不受影响。回滚就是把 Nginx 配置指回旧容器。

整个部署服务自带 Web UI,支持版本列表、一键部署、一键回滚。用 SQLite 记录部署历史。

跨端一致性

API 设计的统一性

后端对小程序和 Web 管理后台暴露同一套 API,通过 JWT 中的角色信息区分权限。没有为不同端写不同的接口——BFF 层的复杂度不值得引入。

认证体系

四种角色(admin、owner、staff、user)共用一套 JWT 签发机制,中间件按角色过滤。店员操作需要额外的 shopToken,通过用户 token 换取,绑定到具体店铺。这避免了"一个 token 能操作所有店铺"的权限泄露风险。

说在最后

这套系统的技术选型遵循一个原则:在当前规模下选择最简单的方案,但保持向上扩展的可能性

2026 年 5 月,平台总流水 90,780 元,累计开台 3,283 次,服务 35 家商户。这些数字说明系统已经在真实商业环境中跑起来了——不是 demo,不是 side project,是每天有人用、有钱过的生产系统。

感谢石女士。她最初提出了这个行业的需求痛点,在整个开发过程中持续输出需求与改进建议,并在产品上线后负责推广与宣传。技术只是工具,真正让系统活起来的是对场景的理解和对用户的触达。

单实例部署,但有蓝绿发布保证零停机。内存令牌桶限流,但接口签名兼容未来的分布式方案。进程内 WebSocket 广播,但消息格式标准化,迁移到 Redis pub/sub 只需要换一层适配。context 树管理所有 goroutine 生命周期,水平扩展时加一层 Redis 锁就能避免重复执行。

不是每个项目都需要微服务、Kubernetes、消息队列。在用户量级明确、团队规模有限的前提下,"够用且可控"比"先进但复杂"更有工程价值。技术选型的本质不是选最好的工具,而是选最匹配当前约束的工具——然后确保约束变化时,你能用最小代价切换。