周末市集摊主抢位置全靠手速?我用Python加微信小程序做了个摊位预约系统
这事儿得从头说起。上个月帮朋友打理一个周末文创市集,发现了一个特别头疼的场景:几十个摊位,每次开市前摊主都在微信群里接龙抢位置,手快有手慢无,还有好几个摊主反复改主意,最后实际到场的人和登记表完全对不上,主办方统计起来一团乱麻。
当时我就想,能不能花点时间做一个摊位预约系统,让摊主自己在小程序里挑位置、提交预约,主办方在后台审核管理。后端用Python,前端用微信小程序,因为这两样我相对熟,而且对这类轻量级业务来说完全够用。从需求梳理到上线试运行,前后大概两周,目前已经在两个小型市集上跑通了。
这篇文章就把完整的思路、表结构设计、关键代码、踩坑记录一次性写清楚,给同样想做预约类小程序的朋友一个参考。
1. 项目整体设计与技术选型
1.1 需求场景与核心痛点
先说清楚这个系统到底解决什么问题。摊位预约的本质,是把“人工接龙+线下登记”变成“线上选位+自动排期”,核心参与者有三类:访客/摊主(在小程序端浏览摊位、提交预约)、主办方管理员(在后台维护摊位、审核预约)、系统本身(处理时间冲突、状态流转、统计报表)。
实际运营中有几个痛点是必须要解决的:
- 摊位位置可视化。摊主选位时希望看到“哪个区、第几号、旁边是谁”,纯文字列表体验很差,小程序端用Canvas简单画一个平面图就能解决。
- 时间冲突校验。同一个摊位同一时间段不能被预约两次,这是系统的核心逻辑,也是最容易出bug的地方。
- 预约状态多变。待支付、已确认、已取消、已过期、已入场,状态之间怎么流转,谁有权限改,这些如果不在设计阶段定清楚,后期改起来会很痛苦。
1.2 为什么用Python + Flask而不是Django
后端我选的是Flask而不是Django,理由很直接:项目体量小,接口就十几个,Flask的轻量和灵活更适合快速迭代。Django自带Admin、ORM、Migration这些重型武器,但对于这个场景属于杀鸡用牛刀,而且Django的学习和调试成本对新手不太友好。
Flask配合SQLAlchemy做ORM,配合蓝图(Blueprint)做模块划分,够用且结构清楚。如果你完全没接触过Flask,把它理解成“一个能处理HTTP请求的Python脚本集合”就行——你定义好URL规则,写好对应的处理函数,返回JSON给前端调用。
1.3 为什么前端选微信小程序而不是H5或App
这个决策基本不用犹豫。摊主和访客都是C端用户,让他们装App不现实;H5虽然免安装,但微信小程序在用户触达上有天然优势——用户搜一下、或者主办方分享个卡片就能打开,用完即走,不需要下载,也不需要关注。再加上小程序提供完整的登录态、支付、订阅消息能力,做预约类应用是天生契合的。
我见过一些团队用H5套壳做类似功能,结果在支付环节被卡住,因为H5调起微信支付的条件更苛刻。原生小程序直接使用wx.requestPayment,流程顺畅得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与后端核心实现
2.1 数据表结构设计
这个项目我设计了四张核心表:用户表、摊位表、场次表、预约单表。每个表单独拆开说。
用户表(users)
字段不多,但有一个点值得注意:微信小程序的openid是用户的唯一标识,不要用自增ID做主键来关联业务数据,而是要把openid作为逻辑外键贯穿整个系统。
sql复制CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(64) NOT NULL UNIQUE,
nickname VARCHAR(64),
avatar_url VARCHAR(255),
phone VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
摊位表(stalls)
关键字段是区域、编号、位置坐标(用于前端画图展示)、价格、状态。位置坐标用简单的x/y比例值,而不是经纬度,因为在小程序里的摊位平面图是二维坐标系,x/y表示摊位在画布上的相对位置最简单。
sql复制CREATE TABLE stalls (
id INT AUTO_INCREMENT PRIMARY KEY,
zone VARCHAR(16) NOT NULL COMMENT '区域,如A区/B区',
stall_no VARCHAR(16) NOT NULL COMMENT '摊位编号,如A-01',
pos_x DECIMAL(5,2) COMMENT '在平面图中的x坐标比例 0-100',
pos_y DECIMAL(5,2) COMMENT '在平面图中的y坐标比例 0-100',
daily_price DECIMAL(10,2) NOT NULL DEFAULT 0,
status TINYINT DEFAULT 1 COMMENT '1可用 0停用',
UNIQUE KEY uk_zone_no (zone, stall_no)
);
场次表(sessions)
很多预约系统只做了“按天”的维度,但如果一个摊位一天分上午场和下午场,玩法就变了。所以单独拆一个场次表,每个场次关联一个日期和时段,预约单挂在场次下面。
sql复制CREATE TABLE sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(64) NOT NULL COMMENT '场次名称,如端午市集Day1上午',
session_date DATE NOT NULL,
time_slot VARCHAR(16) NOT NULL COMMENT 'AM/PM/FULL',
start_time TIME,
end_time TIME,
status TINYINT DEFAULT 1
);
预约单表(appointments)
这是系统的核心表。stall_id + session_id唯一约束是防冲突的关键,status字段管理状态流转,order_no是给用户看的业务编号。
sql复制CREATE TABLE appointments (
id INT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
user_id INT NOT NULL,
stall_id INT NOT NULL,
session_id INT NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0待支付 1已确认 2已取消 3已过期 4已完成',
total_fee DECIMAL(10,2) DEFAULT 0,
contact_name VARCHAR(32),
contact_phone VARCHAR(20),
remark VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
paid_at DATETIME,
UNIQUE KEY uk_stall_session (stall_id, session_id)
);
这里有一个很容易踩的坑:uk_stall_session这个唯一键并不是在所有情况下都适用。如果一个摊位在某个场次被预约了但订单取消了,这个唯一键会阻止新用户重新预约同一个摊位。所以严谨的做法是“部分唯一索引”,但MySQL不支持部分索引,我的解决方案是在状态字段上做文章:只有当status IN (0, 1)(待支付或已确认)时才校验唯一性,这个在代码层处理,而不是依赖数据库约束。
2.2 环境搭建与依赖安装
后端我用的Python 3.10,依赖清单如下:
txt复制Flask==2.3.3
flask-cors==4.0.0
Flask-SQLAlchemy==3.0.5
PyMySQL==1.1.0
requests==2.31.0
Werkzeug==2.3.7
安装没什么特别的,pip install -r requirements.txt就行。如果你本地还没装Python,先去官网下载3.10及以上版本,安装时记得勾选“Add Python to PATH”,这一步漏了后面命令行跑不起来会很懵。
初始化数据库用SQLAlchemy的db.create_all()就行,但这里我建议一开始就用Flask-Migrate管理表结构变更。项目迭代过程中加了两次字段,没有迁移工具的时候手动ALTER TABLE虽然也能改,但多人协作时容易出问题,生产环境不建议这么干。
2.3 微信登录态处理
微信小程序端的登录流程是:小程序调用wx.login()获取临时code,发送到后端,后端用code换openid和session_key。我封装了一个登录接口:
python复制@app.route('/api/auth/login', methods=['POST'])
def login():
code = request.json.get('code')
appid = current_app.config['WX_APPID']
secret = current_app.config['WX_SECRET']
resp = requests.get(
'https://api.weixin.qq.com/sns/jscode2session',
params={
'appid': appid,
'secret': secret,
'js_code': code,
'grant_type': 'authorization_code'
},
timeout=5
)
data = resp.json()
if 'openid' not in data:
return jsonify({'code': 400, 'msg': '登录失败: ' + data.get('errmsg', '')})
openid = data['openid']
user = User.query.filter_by(openid=openid).first()
if not user:
user = User(openid=openid, nickname='微信用户')
db.session.add(user)
db.session.commit()
# 生成会话token
token = generate_token(openid)
return jsonify({'code': 0, 'data': {'token': token, 'user': user.to_dict()}})
这里要注意,requests.get一定要加timeout参数,我之前没加,有一次微信接口响应超时,整个请求卡住了几十秒,前端一直转圈,体验极差。
2.4 预约接口与冲突校验
预约接口是系统的核心。用户提交预约时,后端要做三步校验:用户是否存在、摊位是否存在且可用、该摊位在场次下是否已被占用。
python复制@app.route('/api/appointments', methods=['POST'])
@login_required
def create_appointment():
user = g.user
data = request.get_json()
stall_id = data.get('stall_id')
session_id = data.get('session_id')
contact_name = data.get('contact_name', '')
contact_phone = data.get('contact_phone', '')
stall = Stall.query.get(stall_id)
session = Session.query.get(session_id)
if not stall or stall.status != 1:
return jsonify({'code': 400, 'msg': '摊位不存在或已停用'})
if not session or session.status != 1:
return jsonify({'code': 400, 'msg': '场次不存在或已关闭'})
# 关键:冲突校验
conflict = Appointment.query.filter(
Appointment.stall_id == stall_id,
Appointment.session_id == session_id,
Appointment.status.in_([0, 1]) # 待支付和已确认都算占用
).first()
if conflict:
return jsonify({'code': 400, 'msg': '该摊位此场次已被预约'})
# 生成订单号
order_no = generate_order_no()
appt = Appointment(
order_no=order_no,
user_id=user.id,
stall_id=stall_id,
session_id=session_id,
status=0, # 待支付
total_fee=stall.daily_price,
contact_name=contact_name,
contact_phone=contact_phone
)
db.session.add(appt)
db.session.commit()
return jsonify({'code': 0, 'data': appt.to_dict()})
这里有一个并发问题要提醒:如果两个用户同时提交同一个摊位同一个场次,filter判断都通过,然后都写入数据库,就会产生两条有效预约。解决方式是加行级锁,用SQLAlchemy的with_for_update():
python复制conflict = Appointment.query.filter(
Appointment.stall_id == stall_id,
Appointment.session_id == session_id,
Appointment.status.in_([0, 1])
).with_for_update().first()
这一步在小型项目里可能不会被触发,但市集开场前半小时是预约高峰期,并发上来了确实会出问题。建议从一开始就把锁加上,成本很低,收益很大。
2.5 订单超时自动取消
用户提交预约后如果一直不支付,就会占用摊位不让别人约。我的方案是:待支付订单超过15分钟自动转成“已过期”。
实现方式不推荐用定时任务轮询,最简单可靠的是在查询时判断:
python复制def expire_timeout_appointments():
timeout = datetime.now() - timedelta(minutes=15)
expired = Appointment.query.filter(
Appointment.status == 0,
Appointment.created_at < timeout
).all()
for appt in expired:
appt.status = 3 # 已过期
if expired:
db.session.commit()
这个函数在每次查询可用摊位列表前调用一次,或者在用户提交预约前调用一次,就能保证过期的订单被及时清理。不用Celery,不用APScheduler,对小项目来说这就是最务实的方案。
3. 小程序端开发要点
3.1 页面架构
小程序端我设计了四个主页面:首页(摊位平面图 + 场次选择)、摊位详情页、我的预约页、个人中心页。底部TabBar两个入口:首页和“我的”。
首页的核心是摊位平面图。我用Canvas画了一个简化版的摊位布局,每个摊位是一个矩形,空闲的绿色、被占用的灰色、自己预约的橙色。点击矩形跳转到详情页。
绘制逻辑不复杂,核心代码就是循环遍历摊位列表,按pos_x和pos_y换算成画布坐标:
javascript复制const ctx = this.canvas.getContext('2d')
stalls.forEach(stall => {
const x = stall.pos_x / 100 * canvasWidth
const y = stall.pos_y / 100 * canvasHeight
ctx.fillStyle = stall.status === 1 ? '#4CAF50' : '#9E9E9E'
ctx.fillRect(x, y, stallWidth, stallHeight)
// 绘制摊位编号
ctx.fillStyle = '#fff'
ctx.font = '10px sans-serif'
ctx.fillText(stall.stall_no, x + 2, y + 12)
})
这里有一个体验细节:如果摊位数量超过50个,一次性把所有摊位画上去会有点卡。优化方案是只绘制当前可视区域的摊位,或者把Canvas换成scroll-view+position: absolute的DOM方案,后者在iOS上的滚动体验更顺手。
3.2 登录与Token管理
小程序端登录我封装了一个request工具,统一在请求头携带token,遇到401就重新登录:
javascript复制const request = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: BASE_URL + url,
method,
data,
header: { 'Authorization': 'Bearer ' + token },
success: (res) => {
if (res.statusCode === 401) {
// token失效,重新登录
login().then(() => {
// 重新发起请求
})
} else {
resolve(res.data)
}
},
fail: reject
})
})
}
这里说一个我遇到的坑:小程序真机调试时,wx.request请求如果域名没有配置到后台的“request合法域名”里,会直接报errno: 600001之类的错误。开发工具里可以勾选“不校验合法域名”,但真机预览不行,必须在微信公众平台后台配置域名白名单。而且这个域名必须是HTTPS的,不能用IP,不能用端口号,这几个限制卡了我不少时间。域名配置完成之后大概过几分钟才生效,不是立即生效,别在那傻等。
3.3 预约流程与状态展示
预约流程完整走一遍:
- 用户选择场次,首页摊位平面图的颜色会对应变化(哪些已被约一目了然)。
- 点击绿色摊位,进入详情页,显示摊位号、价格、位置示意图、联系方式输入框。
- 提交预约,后端创建订单,返回待支付状态。
- 支付(如果接入了微信支付),或者直接确认(免费摊位不需要支付)。
- 在“我的预约”页面看到自己的预约列表,状态用不同标签展示:待支付/已确认/已取消/已过期。
这里有一个产品层面的取舍:我做的第一版是纯免费预约,不需要支付。后来发现免费预约的取消率特别高,摊主随手一约,到点了不来,位置就浪费了。后来改成了“预约时支付1元保证金,到场签到后退还”,取消率明显下降。如果你的场景也是免费预约,建议至少加一个手机号验证,能在一定程度上过滤掉无效预约。
3.4 订阅消息通知
用户预约成功后、以及主办方审核通过后,最好给用户发一条微信订阅消息提醒。这个功能用的是小程序的requestSubscribeMessage接口:
javascript复制wx.requestSubscribeMessage({
tmplIds: ['模板ID1', '模板ID2'],
success: (res) => {
// 用户同意了订阅,后端就可以在特定时机推送消息
}
})
要注意的是,小程序订阅消息有“一次性”限制——用户同意一次,你只能给他推一次消息。所以最好在用户完成预约动作时同时请求订阅,然后用这个配额去推送“审核通过”或“预约成功”的通知,不要浪费在无关消息上。
4. 常见问题与排查技巧实录
4.1 真机调试网络请求失败
这个问题在热词里出现了很多次:net::ERR_CONNECTION_RESET,真机请求后端接口直接失败。排查思路按优先级排列:
- 第一步:确认后端服务能公网访问。本地
127.0.0.1肯定不行,必须部署到云服务器。 - 第二步:确认域名是HTTPS且证书有效。小程序强制要求HTTPS,自签名证书也不行。
- 第三步:确认微信公众平台后台已配置request合法域名,且域名不带端口。
- 第四步:确认服务器安全组和防火墙放行了443端口。
我自己踩过的坑是第三步,域名配置后刷了几次都不生效,后来发现是配置时没点“保存”按钮,白等了十分钟。
4.2 获取用户信息失败
小程序获取用户头像昵称,新版本中wx.getUserProfile和wx.getUserInfo的返回已经不像以前那么顺手了,经常拿不到真实的头像昵称。我的方案是:不强制获取,让用户在“个人中心”自己填写或修改昵称。摊主填联系方式时再收集手机号,这样既符合微信的隐私规范,又不会卡住流程。
具体来说,新版本直接用<button open-type="chooseAvatar">和<input type="nickname">来获取用户头像和昵称,这是官方推荐的方案,比调接口稳定得多。
4.3 并发预约导致超卖
这个问题前面提过,用with_for_update()行级锁解决。这里补充一个排查技巧:在本地压测时用apache-bench或者简单的Python脚本模拟20个并发请求,如果出现两条成功创建的订单,说明锁没生效。
python复制# 简单的并发测试脚本
import threading
import requests
def book():
resp = requests.post('http://localhost:5000/api/appointments', json={
'stall_id': 1,
'session_id': 3,
'contact_name': 'test',
'contact_phone': '13800138000'
})
print(resp.json())
threads = []
for i in range(20):
t = threading.Thread(target=book)
threads.append(t)
t.start()
for t in threads:
t.join()
这个脚本我留着当回归测试用,每次改完appointment相关代码就跑一遍,防止逻辑退化。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
真机请求返回ERR_CONNECTION_RESET |
域名未配置、HTTPS证书无效、端口没放开 | 依次检查域名白名单、证书、安全组 |
用户登录后openid为空 |
code已过期、AppSecret配置错误 |
wx.login()的code有效期只有5分钟,确认后立即传给后端 |
| 摊位平面图不显示 | Canvas宽高未设置、数据未加载完成就绘制 | 在wx.nextTick回调里绘制,不要直接写在onLoad |
| 预约提交后提示“已被预约” | 存在待支付或已确认的冲突记录 | 在管理后台清理超时未支付的订单 |
| 订阅消息发送失败 | 模板ID错误、用户未同意订阅 | 在wx.requestSubscribeMessage回调里检查errMsg |
| 管理后台统计报表数据不对 | 时区问题导致日期偏移 | 数据库连接字符串里加上serverTimezone=Asia/Shanghai |
4.5 管理后台的轻量实现
主办方管理后台我也做了,但没有单独做一个管理端小程序,而是做了一个简单的Web端,用Flask的模板渲染。功能就三个:摊位管理(增删改查+停用)、场次管理、预约单列表(支持按状态筛选和导出Excel)。
导出Excel用openpyxl库,很轻量:
python复制from openpyxl import Workbook
def export_appointments():
wb = Workbook()
ws = wb.active
ws.title = '预约单'
ws.append(['订单号', '摊位', '场次', '联系人', '电话', '状态', '提交时间'])
appts = Appointment.query.all()
for appt in appts:
ws.append([
appt.order_no,
appt.stall.stall_no,
appt.session.title,
appt.contact_name,
appt.contact_phone,
appt.status_text,
appt.created_at.strftime('%Y-%m-%d %H:%M')
])
output = BytesIO()
wb.save(output)
# 返回文件流
主办方收到Excel后可以直接用来排物料、核对摊位,这个功能虽然简单,但实际用起来比在后台里看数据要方便得多,因为线下操作时拿着表格更顺手。
5. 上线部署与运营实战
5.1 服务器部署方案
我用的部署方案是:Nginx + Gunicorn + Flask + MySQL,运行在Ubuntu云服务器上。选这个组合是因为它足够标准、文档多、出了问题容易查到解决方案。
Gunicorn的启动配置:
bash复制gunicorn -w 4 -b 127.0.0.1:5000 app:app
4个worker进程对这个小项目来说搓搓有余。Nginx负责HTTPS证书和反向代理,把443端口的请求转发到5000端口。
MySQL单独用云数据库还是部署在同一台服务器上?我建议小项目就装在同一台上,省成本,够用。但如果你的市集是常态化的、数据量会持续增长,那就用云数据库,备份和运维省心很多。
5.2 数据备份与定时任务
预约数据虽然量不大,但丢了对主办方是灾难。我在服务器上加了一个cron定时任务,每天凌晨3点备份数据库到OSS:
bash复制0 3 * * * mysqldump -u root -pXXX stall_booking > /backup/stall_$(date +\%Y\%m\%d).sql
保留最近30天的备份文件,用一段简单的shell脚本清理旧的。这个习惯是从一次手滑删数据之后养成的,那次教训太深刻了,从此备份不敢省。
5.3 市集现场的运营细节
系统上线后有一件事特别值得说:在市集开场前,主办方最好用管理后台把当天所有预约单导出来,对照表格检查一遍,给未到场的摊主打个电话确认。我朋友第一次用这个系统时,因为没有人工确认环节,结果有3个摊主预约了但没来,位置空着浪费了。后来在预约须知里加了“预确认”机制——预约成功后主办方在后台点一下“确认”,这个点击动作其实就相当于人工巡检,也能起到过滤作用。
另外在摊位平面图上,已确认和待支付的摊位最好用不同的颜色标识(比如深绿和浅绿),这样主办方在后台看全局时能快速判断哪些位置还有风险,提前联系摊主确认。
5.4 后续可扩展方向
这套系统的架子搭好之后,扩展性其实挺好的。有几个方向值得做:
- 接入微信支付,预约时直接线上支付摊位费,减少现场收款的工作量。
- 增加评价系统,摊主可以给主办方打分,形成市集的口碑数据。
- 增加数据分析页面,统计每个区域的预约热度、摊主的回头率、市集的人流高峰时段。
- 做成多市集模式,一个后台管理多个市集活动,每个市集独立摊位布局和定价。
比如区域热度分析这块,只要在预约单表里关联摊位和场次,就能很容易统计出哪个区域的摊位预约率最高,哪个价位的摊位最受欢迎,这些数据对下次市集的定价和区域规划非常有参考价值。
小结与使用体验
用了几个星期这个系统,我个人的体会是:预约类系统的难点不在于代码写得多花哨,而在于把流程想清楚,把状态管好。很多时候出bug不是因为某个接口写错了,而是因为状态流转没考虑周全——比如用户取消后位置没有及时释放,比如超时订单没有清理导致摊位被无效占用。
另外在做这类小程序项目时,一定要先搞清楚微信平台的规则限制(登录、支付、订阅消息、域名白名单),这些限制会直接影响你的架构设计,最好在动手写代码之前就确认清楚。
最后再分享一个小技巧:开发阶段一定把后端的日志打全,尤其是每次请求的参数、返回的异常信息,都记录下来。小程序端出问题了,很多情况你没法直接调试用户手机,只能靠日志定位,日志全面一点能省下大量沟通时间。
