本文记录了一个面向汽车后市场门店的综合服务管理系统的设计与实现过程。系统基于 Python Flask + SQLAlchemy 后端与原生响应式 SPA 前端,无 Node 构建链,覆盖车主档案、服务工单、会员 VIP、多渠道结算、车牌识别、NFC 刷卡收银与 App 用户端等完整业务闭环。文章聚焦架构决策与关键技术的工程落地,适合 Flask 全栈开发与门店 SaaS 系统设计的参考。
-
作者:donoot
-
版本:V0.06
-
时间:2026-08-07
-
协议:GPL-3.0
一、项目背景
汽车 4S 店与综合服务门店的日常运营涉及多个环节:客户进店识别、档案调取、服务开单、会员折扣、结算收银。市面上的 SaaS 系统往往功能繁杂且定制成本高,而门店真正需要的是一套轻量、可私有部署、贴合业务流的工具。
基于此,我设计并实现了这套「汽车 4S 综合服务管理系统」,目标:
-
轻量部署:单进程 Flask,无需 Node 构建链,
pip install+python run.py即可运行 -
多端覆盖:管理员后台(PC/移动)+ App 用户端(车主自助)
-
业务闭环:从进店识别到结算收银全链路打通
-
会员运营:基于累计充值的 VIP 自动升级 + 折扣体系
-
快速收银:NFC 实体卡刷牌即扣,零驱动依赖
二、技术栈选型
| 层 | 技术 | 选型理由 |
|---|---|---|
| Web 框架 | Flask 2.2 | 轻量、蓝图路由清晰、生态成熟 |
| ORM | SQLAlchemy 1.4 | 支持 with_for_update() 行级锁,事务控制精细 |
| 数据库 | MySQL 8 / SQLite | 生产用 MySQL,演示用 SQLite,代码零改动切换 |
| 前端 | 原生 HTML/CSS/JS | 无构建工具,部署只需静态文件;响应式适配多端 |
| 认证 | Flask Session + Werkzeug | 签名 Cookie + generate_password_hash,无需 JWT 复杂度 |
| 二维码 | qrcode + Pillow | 会员码、支付码 PNG 一键生成 |
| 摄像头 | MediaDevices API | 浏览器原生支持,无需插件 |
为什么不用 Vue/React? 门店系统交互复杂度中等,原生 JS + Hash 路由足以胜任,且消除了构建链依赖,运维成本极低。前后端同库部署,一次 git clone 即可运行。
三、系统架构
系统采用经典的三层架构,通过 Flask 应用工厂组织:
蓝图划分原则
按业务域而非技术层划分蓝图,每个蓝图职责单一:
-
auth_routes:管理员认证 -
customer_routes:车主档案 + VIP 设置 -
service_routes:服务项目与工单 -
payment_routes:结算、充值、扫码支付 -
lpr_routes:车牌识别 -
user_routes:App 用户端(独立于管理员) -
nfc_routes:NFC 收银 -
page_routes:页面渲染
服务层 services/ 承载核心业务逻辑(如余额扣减、VIP 计算),与路由解耦,便于单元测试与复用。
四、关键技术实现
4.1 余额并发安全:行级锁防超扣
门店收银场景下,多个收银员可能同时对同一车主操作(如同时结算两辆车的工单)。若不加锁,会出现「读-改-写」竞态导致余额丢失或超扣。
解决方案:所有涉及余额变动的操作,均使用 SELECT ... FOR UPDATE 行级锁 + 事务:
def settle_order(db, order, pay_type, amount=None):
# 锁定客户行,防止并发
cust = db.execute(
select(Customer).where(Customer.id == order.customer_id).with_for_update()
).scalar_one_or_none()
balance_before = cust.balance or Decimal("0.00")
if pay_type in ("余额", "NFC"):
if balance_before < pay_amount:
return False, f"余额不足:可用余额 {balance_before},需要 {pay_amount}", {}
cust.balance = balance_before - pay_amount
balance_after = cust.balance
# ... 写 Payment 流水
db.commit()
with_for_update() 在 MySQL/InnoDB 下会加 X 锁,确保事务内其他请求阻塞等待,从根本上避免并发超扣。同时记录 balance_before / balance_after 到流水表,形成完整审计链。
踩坑提示:SQLite 不支持真正的行级锁(
FOR UPDATE会被忽略),因此并发安全仅在生产 MySQL 环境下生效。演示用 SQLite 时应避免并发测试。
4.2 VIP 会员体系:派生式等级设计
会员 VIP 是门店运营的核心。需求:
-
累计充值达到门槛自动升级(VIP≥200元/8折、SVIP≥500元/7折、VVIP≥1000元/6折)
-
等级只升不降
-
管理员可手动指定等级(优先于自动)

设计决策:等级不冗余存储,而是由 cumulative_recharge 字段派生计算。这样避免了「充值额变了但等级没更新」的双写不一致问题。
# app/services/vip.py
VIP_TIERS = [
(Decimal("1000"), "VVIP", Decimal("0.6")),
(Decimal("500"), "SVIP", Decimal("0.7")),
(Decimal("200"), "VIP", Decimal("0.8")),
]
def compute_vip_level(cumulative) -> str:
"""根据累计充值额返回应得等级名"""
cum = Decimal(str(cumulative or 0))
for threshold, name, _ in VIP_TIERS:
if cum >= threshold:
return name
return ""
def get_customer_level(customer) -> str:
"""当前生效等级:vip_override 非空则优先(管理员手动),否则按累计额自动判定"""
if (customer.vip_override or "").strip():
return customer.vip_override.strip()
return compute_vip_level(customer.cumulative_recharge or 0)
vip_override 字段空串表示「自动」,非空表示「手动覆盖」。这一设计让管理员既能临时给 VIP 待遇(如大客户),又能在清除后自动恢复规则判定。
充值升级检测:在 recharge() 中,先记录充值前等级,累加后比较,返回升级标记供前端 toast 提示:
def recharge(db, customer_id, amount, method="现金"):
cust = db.execute(
select(Customer).where(Customer.id == customer_id).with_for_update()
).scalar_one_or_none()
old_level = get_customer_level(cust) # 充值前等级
cust.cumulative_recharge = (cust.cumulative_recharge or 0) + amount
# ...
level, upgraded = apply_vip_upgrade(db, cust, old_level)
db.commit()
return True, "充值成功", {"balance": ..., "vip_level": level, "vip_upgraded": upgraded}
4.3 工单 VIP 快照:固化历史
车主下单时的 VIP 等级决定了折扣,但等级会随后续充值变化。若工单只存折后价,事后无法审计「当时是几折」。因此采用快照设计:
class ServiceOrder(Base):
vip_level_snapshot = Column(String(10), default="") # 下单时等级
vip_discount_snapshot = Column(Numeric(12, 2), default=0) # 本单 VIP 优惠总额
class ServiceOrderItem(Base):
original_price = Column(Numeric(12, 2), default=0) # 目录原价
price = Column(Numeric(12, 2), default=0) # VIP 折后单价
下单时同时写入原价、折后价、等级、优惠总额,无论后续等级如何变动,历史工单的折扣信息都可追溯。这是「时间维度数据」的典型处理手法。
4.4 会话隔离:管理员与 App 用户双域
系统有两类用户:门店员工(管理员/前台/技师)和车主(App 用户)。它们的权限模型完全不同,必须隔离:

# 管理员会话
session['uid'] = user.id
session['role'] = user.role
# App 用户会话
session['app_uid'] = app_user.id
两套会话 key 互不干扰,同一浏览器可同时登录管理员和 App 用户。权限装饰器也分两套:
def login_required(fn): # 管理员登录校验
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get('uid'):
return jsonify(code=401, msg="未登录"), 401
return fn(*args, **kwargs)
return wrapper
def app_login_required(fn): # App 用户登录校验
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get('app_uid'):
return jsonify(code=401, msg="未登录或会话已过期"), 401
return fn(*args, **kwargs)
return wrapper
页面路由也物理隔离:管理员走 /,App 用户走 /user,避免前端逻辑互相污染。
4.5 NFC 刷卡收银:键盘模拟式零驱动接入
门店希望用 NFC 实体卡实现「刷牌即扣」的快速收银。市面上的 USB HID 读卡器大多支持键盘模拟模式:刷牌时把卡 UID 当作键盘输入逐字符打出,末尾跟一个回车。

这意味着后端无需任何驱动或 SDK,前端只需一个隐藏 input 监听回车:
// 前端:隐藏 input 接读卡器输入
const input = document.getElementById('nfcInput');
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const uid = input.value.trim();
if (uid) doNfcLookup(uid); // 触发查询
input.value = '';
}
});
input.focus(); // 持续聚焦等待刷牌
后端提供三个接口完成闭环:
# 绑定:管理员把 card_uid 绑定到 customer_id
POST /api/nfc/bind {card_uid, customer_id}
# 查询:刷牌后查车主信息
GET /api/nfc/lookup?card_uid=xxx
# 扣款:两种模式
POST /api/nfc/pay {card_uid, amount, order_id?}
/api/nfc/pay 支持两种场景:
-
带 order_id:复用
settle_order(pay_type="NFC")结算指定工单 -
不带 order_id:调用
nfc_direct_pay直接扣余额写流水(快速收银)
def nfc_direct_pay(db, customer_id, amount, card_uid=""):
cust = db.execute(
select(Customer).where(Customer.id == customer_id).with_for_update()
).scalar_one_or_none()
if cust.balance < amount:
return False, "余额不足", {}
cust.balance -= amount
pay = Payment(pay_type="NFC", amount=amount, customer_id=customer_id,
balance_before=..., balance_after=cust.balance, ...)
db.add(pay)
db.commit()
return True, "支付成功", {"trade_no": pay.trade_no, "balance": str(cust.balance)}
这种「键盘模拟 + 隐藏 input + 回车监听」的方案,让 NFC 接入成本几乎为零,任何 USB HID 读卡器即插即用。
4.6 双数据库适配:演示与生产零切换
通过 DB_TYPE 环境变量,同一套代码在 SQLite(本地演示)和 MySQL(生产)间无缝切换:
# app/db.py
def _build_engine_url():
if os.getenv("DB_TYPE", "sqlite") == "mysql":
return (f"mysql+mysqlconnector://{user}:{pwd}@{host}:{port}/{db}"
f"?charset=utf8mb4")
return "sqlite:///car4s.db"
配合 Base.metadata.create_all(),首次启动自动建表,新开发者 git clone 后即可运行,大幅降低上手门槛。
五、数据库设计要点
系统共 10 张表,核心关系如下:
sys_user(系统用户) customer(车主)─┬─ vehicle(车辆) ├─ nfc_card(NFC 卡绑定) ├─ app_user(App 用户绑定) ├─ recharge_record(充值记录) └─ payment(支付流水) service_item(服务项目目录) service_order(工单)─ service_order_item(工单明细) lpr_log(车牌识别日志)

Decimal 而非 Float
所有金额字段使用 Numeric(12, 2)(Python 端 Decimal),避免浮点精度问题:
balance = Column(Numeric(12, 2), nullable=False, default=0)
0.1 + 0.2 == 0.3 在浮点下为 False,但在 Decimal 下为 True——账务系统必须用 Decimal。
冗余快照字段
service_order_item.service_item_name 故意冗余存储项目名,而非只存 service_item_id 外键。原因是服务项目目录可能被修改或删除,但历史工单的项目名必须保持不变。这是「反范式换稳定性」的典型取舍。
六、前端 SPA 实践
前端采用原生 JS + Hash 路由实现轻量 SPA,核心是一个简易路由表:
const routes = {
'#/dashboard': { title: '工作台', render: viewDashboard },
'#/customers': { title: '车主档案', render: viewCustomers },
'#/orders': { title: '服务工单', render: viewOrders },
'#/payments': { title: '结算收银', render: viewPayments },
'#/nfc': { title: 'NFC 收银', render: viewNfc },
'#/lpr': { title: '车牌识别', render: viewLpr },
};
function router() {
const route = routes[location.hash || '#/dashboard'];
document.getElementById('pageTitle').textContent = route.title;
route.render();
}
window.addEventListener('hashchange', router);
每个视图函数负责拉取数据并渲染 HTML,状态管理极简。响应式布局通过 CSS 媒体查询实现,移动端侧边栏自动收起为抽屉。

七、部署与运维
本地演示
pip install -r requirements.txt python run.py :: 浏览器打开 http://127.0.0.1:8000/
生产部署
-
配置
.env指向 MySQL -
用 gunicorn(Linux)或 waitress(Windows)作 WSGI 服务器
-
Nginx 反向代理 + 静态资源缓存
-
定期备份 MySQL
升级旧库
V0.06 新增了字段,旧库需 ALTER TABLE:
ALTER TABLE customer ADD COLUMN cumulative_recharge DECIMAL(12,2) NOT NULL DEFAULT 0;
ALTER TABLE customer ADD COLUMN vip_override VARCHAR(10) DEFAULT '';
ALTER TABLE service_order ADD COLUMN vip_level_snapshot VARCHAR(10) DEFAULT '';
ALTER TABLE service_order ADD COLUMN vip_discount_snapshot DECIMAL(12,2) NOT NULL DEFAULT 0;
ALTER TABLE service_order_item ADD COLUMN original_price DECIMAL(12,2) NOT NULL DEFAULT 0;
新表 app_user、nfc_card 由应用自动建表。
八、工程化实践
冒烟测试
项目内置冒烟测试脚本,覆盖核心流程:
# scripts/smoke_test.py 风格
def check(name, cond, detail=""):
print(f"[{'PASS' if cond else 'FAIL'}] {name} {detail}")
# 验证 VIP 门槛
check("VIP门槛 200元=VIP", compute_vip_level(200) == "VIP")
check("VIP折扣 VVIP 100→60", discount_price(100, "VVIP") == Decimal("60.00"))
# 验证充值升级
ok, msg, data = recharge(db, cust.id, Decimal("250"))
check("充值后升级VIP", data["vip_level"] == "VIP" and data["vip_upgraded"])
# 验证 NFC 扣款
ok, msg, data = nfc_direct_pay(db, cust.id, Decimal("50"))
check("NFC扣款成功", ok)
V0.06 版本 29 项冒烟测试全部通过,确保核心业务逻辑正确。
配置管理
敏感配置通过 .env 注入,代码中用 os.getenv 读取,.env.example 提供模板,.env 加入 .gitignore 不入库。SECRET_KEY、数据库密码等绝不硬编码。
九、版本规划
-
当前版本:V0.06
-
版本规则:每新增功能或架构改动,版本号递增 0.01
后续规划
-
接入真实微信/支付宝支付异步通知(替换当前扫码支付模拟)
-
接入厂商 LPR SDK(替换当前车牌识别模拟)
-
工单导出 Excel、经营数据报表
-
App 用户端微信小程序封装
-
多门店支持与数据隔离
十、总结
本系统的实践证明了几个观点:
-
Flask 依然能打:在中小型业务系统场景下,Flask + 原生 SPA 的开发效率与维护成本优于重型框架组合
-
派生优于存储:能由源数据计算得出的状态(如 VIP 等级)就不要冗余存储,避免双写不一致
-
快照固化历史:涉及时间维度的业务数据(如下单折扣),必须用快照字段留存,不能依赖实时计算
-
键盘模拟降本:NFC 读卡器、扫码枪等外设采用键盘模拟模式,可让软件接入成本趋近于零
-
行级锁保账务:任何涉及余额的操作,
SELECT FOR UPDATE是底线
整套系统已以 GPL-3.0 协议开源,欢迎同行交流指正。
项目信息
名称:汽车 4S 综合服务管理系统
作者:donoot
版本:V0.06
时间:2026-08-07
协议:GPL-3.0
技术栈:Python 3.11 / Flask 2.2 / SQLAlchemy 1.4 / MySQL 8 / 原生 SPA

&spm=1001.2101.3001.5002&articleId=163593752&d=1&t=3&u=960fc233809c4fb3b5080e2871db7199)
348

被折叠的 条评论
为什么被折叠?



