
去年帮一个美甲连锁店做预约系统需求本身不复杂客人不用再翻聊天记录问前台“有没有位置”前台也不用拿本子来回划店里的几个座位和几个美甲师在微信里一看便知。最后交付的方案就是标题里的这套组合——Python 写后端接口uniapp 做跨端前端编译成微信小程序给用户使用。这篇文章把整个“Python uniapp 微信小程序的美甲店铺座位预约系统”从业务拆解、数据库设计、后端并发控制、小程序页面开发到上线前必须处理的坑完整复盘一遍。准备做预约类项目的同学可以直接把这篇文章当成一份照着能抄的实战记录。真正动手才发现预约系统的核心难点不在增删改查而在“座位和时间段怎么建模”以及“两个人同时抢同一个座位怎么防冲突”。这两块想清楚了剩下的页面和接口都只是体力活。另外小程序端还有一堆跟业务无关、但你不处理就会翻车的兼容问题比如包体积超 2MB、导航栏高度错位、日志不打印之类的。文章会尽量把这些细节都覆盖到。1. 项目到底在解决什么问题1.1 美甲店预约的业务痛点美甲店这个场景很典型。店面不大座位多则六七个少则三四个每个座位背后其实对应一个美甲师的工作时长。客人预约的核心诉求是我今天这个时间段去能不能保证轮到我可以直接用椅子、不用等。在店铺端问题更尖锐——下午两点到四点往往是高峰同一时间段可能有五六拨客人想来但美甲师只有两位没有线上预约就只能靠电话和微信来回确认。纯人工的方式有三个明显的坑一是电话沟通时效差客人问一句“周六下午还有位置吗”前台要翻本子、问技师半天才回二是超卖A 客人电话订了 2 点的位置结果 B 客人到店坐下现场直接起冲突三是流失客人问了两次都没约上以后就不来了。预约系统的价值就是把这套线下确认流程搬到线上让客人自己看到什么时间空、什么座位空一键锁定。这类系统的逻辑不止美甲店适用。美发店、足疗店、自习室、桌球室、甚至宠物美容本质都是“资源 时间 人”的匹配。座位是资源美甲师是资源时间段也是资源预约就是把这些资源在时间轴上排好避免重叠。所以本文讲的建模方法和防并发思路你完全可以平移到其他行业。1.2 为什么是 Python uniapp 微信小程序选 Python 做后端不是因为性能多极致而是因为开发效率高、生态成熟。预约系统的后端接口量不大核心也就登录、店铺查询、座位查询、预约提交、取消、列表这些用 Python 写能控制在一两千行代码内后续维护也简单。Fas tAPI 或 Flask 都够用我后面会细说选型反正不需要上 Java 那套重框架。选 uniapp 是因为它一套代码可以编译到微信小程序、支付宝小程序、H5 和 App。美甲店老板可能今天想上微信小程序明天想开抖音小程序后端不用动前端多编译一次就行。uniapp 底层还是 Vue 语法前端同学上手没有学习成本这也是我敢接这种小项目的原因之一。选微信小程序倒不是因为它技术多先进而是用户不需要下载 App微信里搜一下或者扫码就能用对线下小店来说获客门槛最低。另外小程序还有订阅消息能力预约成功、服务开始前可以给用户推送提醒这是 H5 很难做到的功能。1.3 第一版功能边界MVP 版本的功能一定不能贪多。我当时和店主约法三章第一版只做这些用户侧微信授权登录、查看店铺列表、选择日期、查看座位空闲状态、选择时间段提交预约、查看/取消自己的预约。管理侧维护店铺和座位信息、查看预约记录、手工变更预约状态。支付、会员卡、营销优惠、美甲师排班这些一律放到二期。为什么因为一期引入支付就要涉及微信商户号、退款、对账审核复杂度直接翻倍排班又会牵扯到“不同美甲师负责不同座位”的多资源约束数据模型会变得很复杂。先用最简单模型把预约闭环跑通让店里真实用起来再根据反馈迭代这是做这类生意系统最稳妥的节奏。2. 整体架构与数据模型设计2.1 端到端系统架构整个系统可以拆成三块微信小程序客户端、Python 后端服务、数据库。客户端通过 HTTPS JSON 接口和后端通信后端直连 MySQL图片和静态资源丢到对象存储管理后台我顺手用 Flask 写了个极简页面或者直接用数据库客户端维护看店主的技术水平决定。这里要特别注意小程序端永远不要直接操作数据库所有数据都必须走后端接口。原因一方面是安全openid、预约记录这些不能暴露给客户端乱改另一方面是预约这种写操作必须在后端做并发控制客户端直接改库会把事务和锁全部绕过去超卖就是从这里来的。开发环境下本地调试小程序开发者工具里可以勾选“不校验合法域名”这样后端跑在 http://127.0.0.1:8000 也能联调。但真机预览和发布时请求域名必须是 HTTPS而且要在微信公众平台的后台配置 request 合法域名这个后面部署章节细说。2.2 数据库表设计预约系统的核心表我分成用户、店铺资源、预约记录三组。用户表存账号信息但不主动存太多敏感数据店铺资源包括店铺表和座位表预约记录表是业务核心。直接看表结构更直观表名关键字段说明userid, openid, nickname, avatar, phone, created_atopenid 唯一小程序登录后由后端写入shopid, name, address, business_hours, status状态字段控制店铺是否开放预约seatid, shop_id, name, type, is_activetype 可区分普通座、VIP 座等appointmentid, user_id, shop_id, seat_id, appoint_date, start_time, end_time, status, remark, created_at业务核心表存预约日期和起止时间appointment 表里的 status 我建议用四个状态pending 待确认、confirmed 已确认、completed 已完成、cancelled 已取消。虽然 MVP 阶段可以不做人工确认但状态字段必须预留后面店主如果要求“预约后管理员先确认”才不会改表动筋骨。设计上有一个容易被忽略的点预约记录表最好冗余一个 shop_id而不只是靠 seat_id 反查店铺。因为在“我的预约”列表里要展示店铺名称和地址如果只存 seat_id每次都要 JOIN 两张表。冗余字段会增加一点点数据一致性维护成本但换来的查询性能和维护便利是划算的。2.3 座位与时间段的核心建模预约的本质是“在某个座位的时间轴上占一段区间”。怎么建模最合理我试过两种方案一种是预生成固定时间槽表比如把每天从 10:00 到 20:00 切成 30 分钟一格每格一个记录被预约了就标记占用另一种就是直接存预约区间用时间段重叠判断来查冲突。我最后选了第二种原因是它更灵活。美甲服务时长不是固定的有人做基础护理 40 分钟有人做延长甲要两个小时预生成固定时间槽会导致明明中间空着 20 分钟但没法预约。直接存 start_time 和 end_time查询某个座位某天是否空闲只需要查 appointment 表里是否有时间重叠的记录。确定建模方式后还要定一个基础粒度。我用的最小单位是 30 分钟前端选择开始时间后端根据所选服务类型计算结束时间。比如一个座位某天已约了 10:00-11:00那查询接口返回的占用区间就是 [10:00, 11:00)新预约如果选 10:30 开始就会冲突选 11:00 开始就没问题。区间用左闭右开避免相邻预约在边界上互相误判。3. Python 后端实现接口与并发控制3.1 后端框架选型与环境准备Python 环境你只要装好 3.9 以上版本即可Windows 和 Mac 都先去官网下安装包勾选 Add to PATH。后端框架我推荐 FastAPI原因有三自带 OpenAPI 文档前端联调时可以少写一半文档基于 Pydantic请求参数校验特别方便异步支持好但即使你不用异步当普通同步框架写也完全没问题。项目依赖就几个fastapi、uvicorn、sqlalchemy、pymysql、python-jose、requests。用 pip 一键安装就行pip install fastapi uvicorn sqlalchemy pymysql python-jose requests我习惯的目录结构是 app/main.py 放应用入口app/api/ 放路由app/models/ 放 ORM 模型app/schemas/ 放 Pydantic 模型app/core/ 放配置和工具函数。这种分层不需要很重但至少让路由和模型分开不然两轮迭代之后自己都找不到代码。3.2 微信登录与会话管理小程序端调用 wx.login() 拿到一个临时 code把它传给我们后端的 /api/auth/login 接口后端拿 code 去微信的接口换 openid 和 session_key。这里最关键的一点openid 绝对不能由前端传来因为前端伪造很容易必须后端拿着 code 向微信服务器换取微信会保证这个 code 只能换一次而且对应小程序必须是自己的 appid。换完 openid 后我给用户生成自己的 token方案直接用 JWT。JWT 的好处是无状态后端不用存 session 表小程序每次请求带上 Authorization 头就行。有人会觉得 JWT 不如 session 安全但在这种体量的系统里只要把过期时间设短一点、密钥保管好完全够用。登录接口大概长这样app.post(/api/auth/login) async def login(req: LoginRequest): url https://api.weixin.qq.com/sns/jscode2session params { appid: settings.APP_ID, secret: settings.APP_SECRET, js_code: req.code, grant_type: authorization_code } resp requests.get(url, paramsparams).json() if errcode in resp: raise HTTPException(status_code401, detailresp.get(errmsg)) user db.query(User).filter(User.openid resp[openid]).first() if not user: user User(openidresp[openid], nickname微信用户) db.add(user) db.commit() token create_access_token({sub: str(user.id)}) return {token: token, user_id: user.id}设置过期时间时有个小细节小程序不是网页用户可能几周都不重新登录。token 有效期我设了 7 天快要过期的时候前端再静默调一次 wx.login 换新 token体验上用户完全无感知。3.3 预约核心接口的设计预约相关的接口一共四个必须想清楚每个接口的输入输出GET /api/shops 店铺列表返回店铺 id、名称、地址、营业时间。GET /api/shops/{shop_id}/seats?date2026-01-20 查询某店某天的座位列表每个座位附带该日已占用的时间段。POST /api/appointments 提交预约body 里带上 shop_id、seat_id、date、start_time、end_time。POST /api/appointments/{id}/cancel 取消预约仅允许本人操作。查询座位状态是使用频率最高的接口也是前端好用的关键。后端不能只返回“座位是否空闲”而要返回“每个座位在该日的已占用区间”前端才能把时间轴画出来。返回结构类似下面这样{ code: 0, data: [ { seat_id: 1, name: 1号桌, type: 普通, occupied: [ {start: 10:00, end: 11:00}, {start: 14:00, end: 15:30} ] } ] }提交预约的接口就一个核心校验逻辑目标座位上该日期是否已有时间重叠的预约。在接口里我会这样处理conflict db.query(Appointment).filter( Appointment.seat_id req.seat_id, Appointment.appoint_date req.date, Appointment.status.in_([pending, confirmed]), Appointment.start_time req.end_time, Appointment.end_time req.start_time ).first() if conflict: raise HTTPException(status_code400, detail该时间段已被预约)3.4 并发防冲突防止两个人同时抢同一个座位上面那个冲突查询单独看没什么问题但它不是并发安全的。想想这个场景两个客人同时提交预约后端开启两个线程/进程在同一时刻都查出“无冲突”然后都往 appointment 表插入数据这个座位就被约重了。这不是理论问题真实业务里早晚会遇到。解决并发冲突我的方案是在事务里加锁而不是只靠应用层的查询判断。以 MySQL 为例事务里先对目标座位行加悲观锁锁定期间别人只能等待然后在锁内再查冲突、再插入预约记录。伪代码如下from sqlalchemy import text with db.begin(): locked_seat db.execute( text(SELECT * FROM seat WHERE id :sid FOR UPDATE), {sid: req.seat_id} ).first() if not locked_seat: raise HTTPException(status_code404, detail座位不存在) conflict db.query(Appointment).filter(...).first() if conflict: raise HTTPException(status_code400, detail该时间段已被预约) db.add(Appointment(...)) db.commit()SELECT ... FOR UPDATE 会把这条座位记录锁住直到事务提交或回滚。这样第二个请求走到同一行时会阻塞等待等第一个事务提交完它再查冲突的时候就能查到刚插入的预约了。对美甲店这种日预约量几百单的系统行锁的并发能力绰绰有余。如果以后预约量暴涨不想用行锁可以改乐观锁思路appointment 表加一个 version 字段更新时校验 version版本不对就让客户端重试。但“可重试”对预约业务很不友好用户提交时不知道自己和别人抢同一个座位我更推荐直接把冲突控制在服务端事务里。还有一个细节数据库隔离级别如果用的是默认的可重复读插入冲突判断可能会出现间隙锁跨越比较稳妥的做法是确保 appoint_date、seat_id、start_time、end_time 这几个字段建联合索引优化锁的粒度。4. uniapp 小程序端开发实战4.1 项目创建与基础配置前端我直接用 HBuilderX 新建 uniapp 项目模板选 Vue3。这里有个小建议如果你是刚开始接触 uniapp不要一上来就上 TypeScript 和 Pinia 全家桶预约系统这种两三个核心页面的项目用默认的选项式或组合式 API 就够减少折腾。manifest.json 里必须填微信小程序配置appid 在微信公众平台注册小程序后获取。pages.json 配置页面路由和底部 tabBar我当时的 tabBar 是两个首页预约、我的预约。配置代码很简单{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 美甲预约 } }, { path: pages/appointment/appointment, style: { navigationBarTitleText: 选择座位 } }, { path: pages/mine/mine, style: { navigationBarTitleText: 我的预约 } } ], tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/mine/mine, text: 我的 } ] } }4.2 核心页面与交互实现首页的逻辑最简单进入后请求店铺列表渲染卡片点击跳转预约页并把 shop_id 带过去。预约页是整块的难点我拆成三个区域日期选择、座位选择、时间选择。日期选择我推荐用 scroll-view 做横向滚动每个日期一个 tab选中日期后重新请求该店的座位状态。座位选择要画成网格因为美甲店座位数量不多一个座位一张卡片卡片上用不同样式区分“空闲”“冲突”“已选”。时间选择则根据用户选的服务时长计算出可选开始时间比如营业时间 10:00-19:00服务时长 1 小时那可选开始时间就是 10:00 到 18:00 的每个整点。座位卡片的核心渲染逻辑大概是这样view v-forseat in seatList :keyseat.seat_id classseat-card :class{ seat-active: selectedSeatId seat.seat_id } clickselectSeat(seat) text{{ seat.name }}/text /view这里有一个很重要的交互细节用户先选座位再选时间段的时候要实时把该座位“已冲突”的时段置灰。也就是前端拿到座位占用区间后在渲染时间按钮时做一次区间重叠判断。不要等用户提交了再报错那体验太差了。4.3 与后端联调的关键细节小程序端我封装了一个 request.js统一处理 baseURL、token 注入、错误弹窗。真机预览时请求域名必须是配置在微信后台的合法域名而且必须 HTTPS。本地开发想快速看效果可以用微信开发者工具勾选“不校验合法域名”但发布前一定记得改回正式配置。登录态的逻辑是小程序启动时先检查本地有没有 token没有就调 wx.login 拿 code 请求后端登录接口拿到 token 后存 storage。每次请求前从 storage 读取 token写到请求头。如果后端返回 401前端再走一次静默登录流程换 token。我踩过一次比较典型的坑明明后端接口没问题真机上却一直请求失败。查了半天是微信小程序后台的“服务器域名”没有配置完整request 合法域名和 uploadFile 合法域名是分开的只配了请求域名没配上传域名导致图片上传一直挂。所以配置域名时把几个可能用到的域名类别一起配掉别只配一个。4.4 微信小程序适配细节微信小程序的坑很大一部分是原生组件和 CSS 不一致导致的。单选框 radio 在小程序里是原生组件层级会飘到普通 view 上面如果你用自定义弹窗会看到单选按钮不受 z-index 控制。所以我在项目里基本不用原生 radio直接用 view 加选中态视觉统一也好控制样式。顶部导航栏也是个经典痛点。默认导航栏在不同机型上高度不一样如果你要做自定义导航栏必须动态计算。uniapp 里可以用 uni.getSystemInfoSync 拿到状态栏高度再用 uni.getMenuButtonBoundingClientRect 拿到胶囊按钮位置两者结合计算出导航栏高度然后设置占位 view。这个计算逻辑做成公共方法所有自定义导航页面共用。列表加载更多也要注意onReachBottom 在小程序里触发时不能重复发请求。要加一个“正在加载中”的锁防止用户快速触发两次。我的做法是维护一个 isLoading 标志请求结束前不重复请求。另外分页参数 page 和 total 要后端返回前端判断当前页数据量小于每页条数时就显示“没有更多了”。5. 上线前必须处理的坑实录5.1 包体积超过 2MBuniapp 项目稍微加几个组件编译出来的微信小程序包经常直接超 2MB 限制。我当时遇到的情况是编译后 source size 2612KB超了 600 多 KB上传时直接被拒。处理思路是三步。第一步剔除无用依赖我当时发现引入了一个图表库但根本没用上删掉之后少了 300 多 KB。第二步压缩项目里的大图很多店长直接丢给我几张几 MB 的图片压缩到几十 KB 视觉上没区别。第三步还是不够的话就用分包加载把流量大的页面放到 subPackages主包只保留 tabBar 页面和公共代码。{ subPackages: [ { root: pagesAppointment, pages: [appointment/appointment] } ] }分包之后用户访问主包页面是不下载分包代码的只有预约页被打开时才加载体验提升也很明显。5.2 uniapp 不打印日志信息开发微信小程序时遇到过一个很诡异的问题console.log 有的手机上看不到微信开发者工具里也不输出。后来排查发现真机调试时日志默认可能打到 vConsole 里如果你用的是自定义调试基础库或开了一些隐私保护模式日志会被静默过滤。我一般先在开发者工具里切到“真机调试”模式边走边看控制台如果还是没有就在代码里加一个轻量 vConsole 组件把关键日志渲染到页面上。这个临时方案上线前要删掉不然用户会看到调试信息。另一个更常见的“不打印日志”原因是代码里用了某些语法真机的 JavaScript 引擎不支持直接抛错了但错误被吞掉。这种错误我建议用 try/catch 包住每个接口请求在 catch 里统一 uni.showToast 提示同时把错误信息写入 storage方便排查。5.3 顶部导航栏与安全区适配自定义导航栏除了要算高度还要注意 iPhone 底部横条的安全区。预约页的提交按钮如果固定定位在底部不给它留安全区 padding在 iPhone 上会被 home 条挡住。uniapp 里可以用 env(safe-area-inset-bottom) 处理.submit-bar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }还有小程序顶部下拉刷新的样式默认会有个圈和导航栏颜色不搭。pages.json 里可以配置 backgroundTextStyle 和 backgroundColor配合全局样式做成品牌色视觉上会专业很多。5.4 权限申请能不用就不用预约系统本来需要用户定位吗不需要。但很多人做的时候会顺手在 uniapp 里开启地图或位置权限结果微信审核时被拒。小程序审核对 userlocationbackground、watchPosition、后台运行监测这类权限审核很严如果功能上不需要一定不要提前申请。我当时为了避免误触发权限弹窗把所有 uni.getLocation 相关调用全部删掉彻底不给审核找理由。如果你的预约系统确实要根据距离排序店铺也要在小程序后台配置“位置信息”用途说明并在隐私协议里写清楚。5.5 微信认证与类目审核微信小程序个人主体很多功能受限尤其是支付必须企业主体才能开通。我做美甲店预约系统时含预约类目主体是公司需要在小程序后台完成微信认证费用是 300 元/年这个钱省不了。如果是个体工商户也可以用营业执照注册小程序经营范围匹配就没问题。类目选择上“生活服务 美业”一般够用。如果后续要接微信支付还要单独申请微信支付商户号签约后会有一个 mch_id和 appid 绑定。审核时最关键的是隐私协议和用户信息授权说明采集头像昵称、手机号都要在小程序后台填写对应用途否则提交审核大概率被驳回。6. 部署上线与后续扩展建议6.1 前后端部署流程后端部署我用一台云服务器配置不用太高2 核 4G 足够跑这个系统。流程是服务器装好 Python 环境用 uvicorn 启动 FastAPI前面套一层 Nginx 做 HTTPS 反向代理证书直接用免费版。MySQL 单独跑在云数据库或同机部署都行但一定要定时备份预约数据丢了对店是事故。小程序的发布流程是微信开发者工具上传代码 → 在微信公众平台版本管理里提交审核 → 审核通过后发布。这里有个体验细节上传时填写的版本号和备注建议和后端接口版本一一对应比如 v1.0.0 对应后端 tag免得哪天前后端版本对不上排查到怀疑人生。6.2 后续还能加什么功能第一版跑顺之后店主一定会提新需求排在最前面的通常是这几个第一美甲师排班把“座位空闲”和“技师空闲”做区分这需要在 appointment 里增加 staff_id约束升级为“同一座位不重叠且同一技师时间不重叠”。第二微信订阅消息预约成功和服务开始前各推送一次需要在用户预约时请求一次订阅授权。第三支付闭环用户先付定金或全款后端接入微信支付并处理回调。还有一些细节功能可以根据需要加比如预约时间 24 小时前取消自动释放座位、会员累计消费次数、经营数据日报。功能优先级我建议跟着投诉走比如店里频繁出现“座位约了但美甲师没准备”就优先做排班和提醒而不是先做花哨的营销功能。6.3 给同样在做预约系统的同学一个建议从需求确认到上线我最大的一条经验是宁可把项目边界划小也要把核心防冲突逻辑做扎实。预约系统的口碑完全建立在一个信任上——用户“点了预约就默认有位置”。只要出现一次超卖哪怕整个系统做得再漂亮也会被顾客拉进黑名单。反过来说如果座位状态、时间冲突、取消释放这些基础逻辑稳如老狗即使页面朴素一点店主都会愿意继续用下去。第二个建议是尽早把后端日志系统搭起来。用户说“我明明预约成功怎么到店说没有”你至少要能查出这个预约当时的创建时间、状态变迁、取消时间。我见过太多小项目出问题后连日志都没有只能靠猜。为每个关键接口打上结构化日志上线前设置好日志轮转这个成本极低但能救你无数次。这套系统整体做下来前后端加起来大概用了十几天时间。如果你也是一个人从头开始写第一版别追求大而全把“看座位、约座位、查预约”这条闭环跑通已经能解决店里 80% 的痛点。剩下的等用户真实用起来再说。