ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask+SQLite简易订单系统开发:事务、库存与避坑实践

Flask+SQLite简易订单系统开发:事务、库存与避坑实践 简介一份基于Python Web开发的简易订单系统毕业设计源码包适合计算机专业学生用于课程设计、毕业设计参考也可作为Python Web入门的完整案例。项目采用典型MVC分层结构涵盖用户注册登录、订单创建、支付等业务流程源码包含模型定义、视图函数、HTML模板、配置文件及初始化SQL等便于理解Django或Flask框架下的后端逻辑与前后端交互。压缩包共95个文件以Python脚本31个py、HTML页面15个html、JavaScript脚本12个js、CSS样式7个css为主另有图片、GIF演示动图、配置文件、Markdown文档和SQL数据库脚本等整体仅216KB小巧完整。已有128人学习适合初学者快速剖析一个真实系统的代码组织与软件工程实践。通过阅读该源码可以掌握路由处理、模板渲染、数据库交互、用户认证等关键知识点并学习到依赖管理、部署脚本和文档编写等工程习惯是提升Web开发能力的实用案例。1. 简易订单系统意味着什么Python Web 开发最该练的闭环毕业设计题目里挂着“简易”两个字其实是好消息。它把 Python Web 开发的核心闭环压缩到了最小可运行范围数据建模、表单提交与校验、订单状态流转、基础查询统计四件事刚好串起一条从后端到模板渲染的完整链路。这个题目能解决的真实痛点是很多新手学完 Python 语法和 Flask 教程后依然不知道一个能上答辩台的项目该长什么样。它适合正在选题的学生也适合想在一周内跑通第一个 Web 项目、但又不想一上来就碰微服务和中台的开发者。难点不在“写出来”而在写出来之后能否讲清楚每一个设计决策和每一处边界处理。2. 技术选型与项目骨架为什么 Flask SQLite 是这份设计的最优解拿到题目先别急着写代码。第一步是确认技术栈和项目结构这一步选错了后面所有功能都要跟着返工。简易订单系统最稳的组合是 Flask 内置 sqlite3而非 Django 或 Flask SQLAlchemy 全套。原因下面拆开说。2.1 框架选型Flask 相比 Django 的取舍Django 自带 admin 后台听起来开箱即用很诱人但它在答辩环节会变成劣势。面试老师问“这个后台是怎么实现的”你只能说“框架自带的”一段代码都指不出来。Flask 不一样路由自己写、数据库操作自己写、模板渲染自己写每一个功能都能对应到具体代码行解释起来站得住脚。另一个现实因素是代码量。Flask 写一个订单创建的完整接口连模板带路由大概 60 行Django 要配 settings、配 ORM 模型、配 admin 注册光初始化就超过这个数。毕业设计的时间预算通常只有两到四周Flask 的学习曲线在第四天基本就平了Django 第四天还在跟中间件和 migrations 纠缠。数据库方面我一般会直接选 Python 内置的sqlite3模块而不是 SQLAlchemy ORM。原因有两层一是减少依赖pip install flask之后什么都不用再装环境问题少一个二是裸 SQL 写出来的查询更直白SELECT就是SELECT答辩时不用解释 ORM 的 session 生命周期。如果指导老师硬性要求 ORM那再换 SQLAlchemy 也不难视图层逻辑基本不用动只改数据访问层。提示选型的原则是“可解释性优先”。毕业设计的代码不需要生产级优雅需要的是每行都能讲出为什么。2.2 四张表的设计商品、客户、订单、订单明细简易订单系统至少需要四张表。很多新手只建三张商品、客户、订单把购买明细直接塞进订单表的一个文本字段里这是第一个坑。订单和商品是多对多关系——一个订单包含多个商品一个商品出现在多个订单里——必须用中间表承接。建表脚本如下文件命名为schema.sql-- schema.sql PRAGMA foreign_keys ON; CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price INTEGER NOT NULL, -- 单位分避免浮点误差 stock INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT UNIQUE NOT NULL, address TEXT DEFAULT , created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, -- 业务单号用户可见 customer_id INTEGER NOT NULL, total_amount INTEGER NOT NULL, -- 单位分 status TEXT NOT NULL DEFAULT pending, -- pending/paid/shipped/done/cancelled created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), FOREIGN KEY (customer_id) REFERENCES customers(id) ); CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, price INTEGER NOT NULL, -- 下单时的成交单价商品改价不影响历史订单 FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (product_id) REFERENCES products(id) );关键决策在字段类型上。price和total_amount用 INTEGER 存“分”不是 REAL 存“元”。Python 的浮点乘法在金融场景里会累积误差——19.9 * 3在二进制浮点里结果是59.699999999999996存进去再查出来就是说不清的血泪教训。把单位定成“分”所有计算都是整数运算显示的时候再除以 100。order_items.price单独存一份“下单时成交价”也很重要。商品价格会调整但历史订单的金额不能跟着变。这是订单系统里“快照”思想的最小实现答辩时主动讲这一点比背十个概念都加分。2.3 项目目录结构与运行方式解压 zip 后通常看到的是一个标准 Flask 工程目录结构保持如下即可order_system/ ├── app.py # Flask 入口与所有路由 ├── schema.sql # 建表脚本 ├── init_db.py # 初始化数据库执行 schema.sql ├── requirements.txt # 依赖清单 ├── static/ │ ├── css/style.css │ └── js/order.js └── templates/ ├── base.html # 公共模板含导航栏 ├── index.html # 首页仪表盘 ├── products.html # 商品列表 ├── product_form.html # 商品新增/编辑 ├── customers.html # 客户列表 ├── order_create.html # 下单页 └── orders.html # 订单列表与详情init_db.py是独立脚本运行时初始化数据库# init_db.py import sqlite3 conn sqlite3.connect(orders.db) with open(schema.sql, r, encodingutf-8) as f: conn.executescript(f.read()) conn.commit() conn.close() print(数据库初始化完成)运行方式就两条命令pip install flask python init_db.py python app.py浏览器访问http://127.0.0.1:5000即可。这里有个小细节schema.sql里第一行PRAGMA foreign_keys ON;必须在同一个连接里执行才生效。如果你的代码里get_db()是每个请求新建连接那么init_db.py的连接一关闭外键约束就失效了。要保证运行时的连接也执行一次这个 PRAGMA后面在app.py里会处理。3. 核心功能落地从商品录入到订单状态流转骨架搭好后开始写三个核心模块商品管理、下单事务、订单查询。这一章是系统的灵魂代码可以直接抄但每段之后的逻辑说明要读透——答辩时老师问的就是这些。3.1 商品管理列表、新增、编辑一条链路商品模块是所有订单操作的数据基础。路由设计遵循 REST 风格用同一个 URL 区分 GET 和 POST# app.py 部分代码 from flask import Flask, render_template, request, redirect, url_for, flash import sqlite3 app Flask(__name__) app.secret_key your-secret-key # flash 消息需要 def get_db(): conn sqlite3.connect(orders.db) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) # 每个连接都开外键 return conn app.route(/products) def product_list(): conn get_db() products conn.execute( SELECT id, name, price, stock FROM products ORDER BY id DESC ).fetchall() conn.close() return render_template(products.html, productsproducts) app.route(/products/add, methods[GET, POST]) def product_add(): if request.method POST: name request.form.get(name, ).strip() price request.form.get(price, ) stock request.form.get(stock, 0) if not name or not price: flash(商品名称和价格不能为空) return redirect(url_for(product_add)) conn get_db() conn.execute( INSERT INTO products (name, price, stock) VALUES (?, ?, ?), (name, int(float(price) * 100), int(stock)) # 元转分 ) conn.commit() conn.close() flash(商品添加成功) return redirect(url_for(product_list)) return render_template(product_form.html)逻辑说明价格从表单拿到的是用户输入的“元”进数据库前乘以 100 转成“分”。这里用int(float(price) * 100)是先转浮点再截断对毕业设计够用了更严谨的做法是用正则校验格式后拆分元角分但在简易系统里属于过度设计。库存字段用int(stock)强转如果前端传了非数字字符串会抛ValueError页面直接 500——放在避坑章节里细说。商品编辑是相同的套路多一个WHERE id ?条件把INSERT换成UPDATE即可。模板里用base.html的导航栏统一入口!-- base.html 片段 -- nav a href{{ url_for(index) }}首页/a a href{{ url_for(product_list) }}商品管理/a a href{{ url_for(customer_list) }}客户管理/a a href{{ url_for(order_create) }}新建订单/a a href{{ url_for(order_list) }}订单列表/a /nav {% with messages get_flashed_messages() %} {% if messages %} ul{% for msg in messages %}li{{ msg }}/li{% endfor %}/ul {% endif %} {% endwith %} {% block content %}{% endblock %}3.2 创建订单一次事务里完成四件事下单是整个系统最核心的路径涉及库存扣减、订单头插入、明细插入、金额汇总四步。这四步必须在一个数据库事务里完成否则会出现“订单建了但库存没扣”或“扣了库存却查不到订单”的数据不一致。app.route(/order/create, methods[GET, POST]) def order_create(): if request.method POST: customer_id request.form.get(customer_id, typeint) product_ids request.form.getlist(product_id, typeint) quantities request.form.getlist(quantity, typeint) if not customer_id or not product_ids: flash(请选择客户和商品) return redirect(url_for(order_create)) conn get_db() try: order_no ORD time.strftime(%Y%m%d%H%M%S) str(random.randint(1000, 9999)) total 0 # 第一步插入订单头先拿到 order_id cur conn.execute( INSERT INTO orders (order_no, customer_id, total_amount) VALUES (?, ?, 0), (order_no, customer_id) ) order_id cur.lastrowid # 第二步逐条扣库存条件更新保证不超卖 for pid, qty in zip(product_ids, quantities): if qty 0: continue cur conn.execute( UPDATE products SET stock stock - ? WHERE id ? AND stock ?, (qty, pid, qty) ) if cur.rowcount 0: raise ValueError(f商品 ID {pid} 库存不足) # 读取当前成交价并插入明细 row conn.execute( SELECT price FROM products WHERE id ?, (pid,) ).fetchone() conn.execute( INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (?, ?, ?, ?), (order_id, pid, qty, row[price]) ) total row[price] * qty # 第三步回填订单总额 conn.execute( UPDATE orders SET total_amount ? WHERE id ?, (total, order_id) ) conn.commit() except Exception as e: conn.rollback() flash(f下单失败{e}) return redirect(url_for(order_create)) finally: conn.close() flash(订单创建成功) return redirect(url_for(order_detail, order_idorder_id)) # GET 请求渲染下单页选择客户和商品 conn get_db() customers conn.execute(SELECT id, name FROM customers).fetchall() products conn.execute(SELECT id, name, price, stock FROM products).fetchall() conn.close() return render_template(order_create.html, customerscustomers, productsproducts)这段代码要重点讲三个地方。第一是UPDATE products SET stock stock - ? WHERE id ? AND stock ?这个条件更新把“查库存”和“扣库存”合成一条原子 SQL天然防止并发超卖。如果你写成“先 SELECT stock判断够不够再 UPDATE”在高并发下两个请求同时读到库存 1都会判定“够”最后库存变成 -1。毕业设计虽然并发量不大但这个写法体现的是数据库思维答辩时值得主动指出来。第二是cur.lastrowid拿到新插入订单的自增主键。SQLite 的AUTOINCREMENT字段在插入后可以通过 cursor 的lastrowid获取这就解决了“订单明细需要订单 ID但订单 ID 是插入后才生成”的时序问题。第三是total在 Python 侧累加而不是在 SQL 里用SUM算。因为你本来就要逐条扣库存和取价格顺手累加最直接。total_amount先插 0明细插完再UPDATE回填这也是事务内操作中途任何一步失败都会rollback不会留下总金额是 0 的半成品订单。注意random.randint(1000, 9999)生成订单号后缀。同一秒内并发超过 9000 个订单才会撞号毕业设计完全够用生产环境建议换雪花算法。这个点可以在论文“不足与展望”里写一句。3.3 订单列表与状态流转查询要友好状态要收敛订单列表页是使用频率最高的页面查询条件至少要有“按客户查”和“按状态查”两个维度。状态字段用字符串常量收敛在五个值里pending待支付、paid已支付、shipped已发货、done已完成、cancelled已取消。app.route(/orders) def order_list(): status request.args.get(status, ) keyword request.args.get(keyword, ).strip() conn get_db() sql (SELECT o.id, o.order_no, c.name AS customer_name, o.total_amount, o.status, o.created_at FROM orders o JOIN customers c ON o.customer_id c.id WHERE 11) params [] if status: sql AND o.status ? params.append(status) if keyword: sql AND c.name LIKE ? params.append(f%{keyword}%) sql ORDER BY o.id DESC orders conn.execute(sql, params).fetchall() conn.close() return render_template(orders.html, ordersorders)状态流转的更新操作建议单独写一个接口而不是在列表页直接改字段app.route(/order/int:order_id/status/string:new_status) def order_update_status(order_id, new_status): allowed {pending, paid, shipped, done, cancelled} if new_status not in allowed: flash(非法状态值) return redirect(url_for(order_list)) conn get_db() conn.execute(UPDATE orders SET status ? WHERE id ?, (new_status, order_id)) conn.commit() conn.close() flash(订单状态已更新) return redirect(url_for(order_list))这里做了一层白名单校验防止 URL 里随意传一个xxx把状态字段污染成脏数据。订单明细查询用JOIN把三张表串起来按order_id过滤这里不展开了。下一步要解决的是这堆代码在实际运行中必然会冒出来的问题。4. 避坑指南简易订单系统最常见的五个翻车点以下五条全部来自真实运行反馈每一条我都见过不止一次。它们不会同时出现但出现一条就够折腾半天的。按“现象 → 原因 → 解决”的顺序写方便排查时对照。4.1 时间少了 8 小时SQLite 时区陷阱现象订单列表里created_at显示的时间比北京时间慢 8 小时上午创建的单子显示成前一天凌晨。原因SQLite 的datetime(now)返回的是 UTC 时间不是本地时间。schema.sql建表默认值写的是datetime(now)所有插入操作没显式传时间落库的全是 UTC。解决把默认值改成datetime(now, localtime)同时在 SELECT 出来时再格式化一次作为双重保障。# 查询时格式化不依赖数据库默认值 from datetime import datetime def format_time(ts): if not ts: return return datetime.strptime(ts, %Y-%m-%d %H:%M:%S).strftime(%Y-%m-%d %H:%M)模板里显示格式化后的字符串不再直接用原始字段。这一步的教训是时间字段的生成方式必须在建表阶段就统一上线后再改默认值存量数据全要洗一遍。4.2 金额对不上浮点运算的锅现象下单三件单价 19.9 元的商品订单总金额显示 59.69 或 59.70而不是 59.70。对账怎么都对不上。原因数据库字段用了 REAL 存价格Python 侧用float累加。19.9 * 3在二进制浮点里不等于59.7这是 IEEE 754 的固有问题不是随机 bug。解决把表结构的价格字段全部改成 INTEGER 存“分”Python 侧统一用整数运算。前端显示时除以 100。表单提交时在视图层做一次转换def yuan_to_cents(price_str): try: return int(round(float(price_str) * 100)) except (ValueError, TypeError): return 0顺带一提round要用银行家舍入还是四舍五入在简易系统里不用纠结round的默认行为对分单位影响微乎其微。但如果你在论文里写了“系统支持精确金额计算”最好把这一条作为已知限制写进“不足与展望”。4.3 重复提交订单刷新页面的双重写入现象创建订单后轻轻按一下 F5浏览器提示“确认重新提交表单”点确认后在订单列表里多出一条一模一样的订单。原因下单接口是 POST成功处理后直接render_template返回了页面。此时 URL 仍然是/order/create刷新会重发 POST于是执行了两次插入。解决采用 Post/Redirect/Get 模式。POST 处理成功后不要渲染页面而是redirect到订单详情页或订单列表页。上面的下单代码已经用了redirect(url_for(order_detail, order_idorder_id))这就是标准解法。另一个防御手段是在订单表上给order_no加UNIQUE约束即使重复提交第二次插入会因为唯一键冲突直接报错不会产生脏数据。4.4 并发下单库存变负先查后更不等于安全现象商品库存剩 1 件两个用户同时下单系统接受了两个订单库存变成了 -1。原因代码写成了“先 SELECT stockPython 判断是否大于零再 UPDATE”两步之间有时间窗口。并发场景下两个请求都通过了判断。解决把判断和更新合并成一条条件更新 SQLcur conn.execute( UPDATE products SET stock stock - ? WHERE id ? AND stock ?, (quantity, product_id, quantity) ) if cur.rowcount 0: raise ValueError(库存不足)stock stock - ?是原子操作WHERE stock ?是乐观锁的 SQL 表达。rowcount为 0 表示更新没匹配到行也就是库存不够事务回滚。4.5 模板里显示 59.7 而不是 59.70格式化细节现象订单总金额 59.70 元显示成 59.7客户界面看起来不专业打印出来更是难看。原因模板里直接输出了total_amount没有做金额格式化。前端显示层缺少“分转元 保留两位小数”的封装。解决在app.py里注册一个模板过滤器app.template_filter(format_amount) def format_amount(cents): return f{cents / 100:.2f}模板里这样用td{{ order.total_amount | format_amount }}/td这个过滤器会输出59.70不会再出现掉精度和掉位数的问题。如果项目里商品价格有“单价 0.10 元”的场景这个过滤器更必不可少。5. 从“能跑”到“能答辩”给简易订单系统加两个进阶能力系统的基本功能已经完整但如果直接拿去做答辩很可能被问“你的系统有什么安全措施”或“怎么证明它是对的”。最后两个技巧能显著提升完成度。5.1 基于 Session 的登录权限控制简易系统不需要 JWT 和权限框架用 Flask 内置的session加一个装饰器就是最干净的实现from functools import wraps from flask import session def login_required(f): wraps(f) def wrapper(*args, **kwargs): if not session.get(user_id): flash(请先登录) return redirect(url_for(login)) return f(*args, **kwargs) return wrapper然后在需要保护的路由上加login_required。用户表用一张users表甚至直接写死在配置里都行毕业设计阶段关键是讲清楚“会话保持”的原理登录成功后把user_id写进 sessionFlask 会把这个值签名后写入浏览器 Cookie后续请求带着 Cookie 来session.get(user_id)就能拿到用户身份。5.2 按日闭合的订单统计与验证三板斧统计功能是答辩时的加分项。用一条GROUP BY就能实现“近 30 天每日订单金额”app.route(/stats) login_required def stats(): conn get_db() rows conn.execute( SELECT date(created_at) AS d, COUNT(*) AS cnt, SUM(total_amount) AS total FROM orders WHERE status ! cancelled GROUP BY d ORDER BY d DESC LIMIT 30 ).fetchall() conn.close() return render_template(stats.html, rowsrows)做验证时我习惯按三板斧走第一重启服务后重复创建订单确认订单号和金额稳定第二用浏览器开发者工具或 curl 直接 POST 非法状态值确认接口返回重定向而不是 500第三把数据库文件用sqlite3 orders.db命令行打开跑SELECT COUNT(*) FROM orders;和PRAGMA integrity_check;确认没有僵尸数据和外键断裂。这三步做完系统才算真正可交付。我自己的习惯是每完成一个模块就手动跑一遍这组检查绝不攒到最后。不然临答辩前一夜对着满屏sqlite3.OperationalError找 bug那种感觉比掉进黑洞还绝望。希望这份笔记帮你在做这个设计时少走几段弯路。本文还有配套的精品资源点击获取
返回列表