一个桌台管理系统,覆盖后端 API、Web 管理后台、微信小程序三端。这篇文章不讲业务功能列表,只讲技术选型背后的判断、架构层面的设计取舍,以及几个值得展开的工程细节。
整体架构
三端分离,各自独立部署:
后端:Go + Gin,单二进制交付,PostgreSQL + Redis
Web 管理后台:Vue 3 Monorepo,pnpm workspaces,Vite 构建
小程序端:Taro 4 + React 18,单代码库覆盖多平台
部署:自研蓝绿部署服务,零停机切换
三端通过 RESTful API + WebSocket 通信。后端是唯一的数据权威源,前端和小程序都是纯展示层,不持有业务状态。
下图展示了三端之间的数据流向——所有通信必须经过后端,两个客户端之间不直接交互。
后端: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 抢占 → 录制响应 → 缓存成功结果。关键设计决策有两个:
只缓存成功响应。失败时释放占位,允许客户端修正后重试。
按用户隔离 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 编写的部署服务,核心流程:
上传新版本二进制到备用容器
启动备用容器,执行健康检查
健康检查通过后,重写 Nginx 反向代理配置,指向新端口
Reload Nginx,流量切换到新容器
异步等待旧容器排空连接后停止
下图展示了这个流程的决策分支——健康检查是唯一的门控点。
如果健康检查失败,备用容器直接停止,线上不受影响。回滚就是把 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、消息队列。在用户量级明确、团队规模有限的前提下,"够用且可控"比"先进但复杂"更有工程价值。技术选型的本质不是选最好的工具,而是选最匹配当前约束的工具——然后确保约束变化时,你能用最小代价切换。

参与讨论
(Participate in the discussion)
参与讨论