尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Flask的废品回收系统设计:数据模型、状态机与权限控制实战

基于Flask的废品回收系统设计:数据模型、状态机与权限控制实战 简介基于Python Flask框架的废品回收系统设计源码面向有一定Python基础的开发者、高校学生及毕业设计人群解决废品回收业务线下管理低效、信息不透明的问题。系统支持用户注册登录、提交废品回收请求、在线查询回收进度同时为后台管理员提供废品分类、订单跟踪、用户管理等核心功能整体实现了一个轻量可运行的“互联网废品回收”闭环。压缩包共61个文件包含25个Python源代码、24个Python字节码、6个Protocol Buffers接口描述、1个SQL数据库脚本、YAML配置和Makefile构建脚本总大小仅223KB目录结构按app、api、models、services、third_party等模块划分登录认证、缓存、响应封装等基础能力均有独立封装。系统基于Flask采用RESTful API风格配合SQL数据模型完成前后端交互代码量适中、模块边界清晰适合通过阅读完整项目来掌握Flask蓝图、数据库建模、接口设计和缓存服务等关键知识点。截至目前已有337人学习浏览是一份适合课程设计、期末项目或Flask实战入门的参考源码。1. 废品回收系统用 Flask 写难点不在增删改查拿到“基于 Python Flask 框架的废品回收系统设计源码”这个标题多数人会以为它又是一个 CRUD 练习建几张表、写几个页面、能登录能下单就算完事。实际做过这类业务系统的人会告诉你废品回收和普通商城最大的区别在于——线上订单的金额是“预估”的真正的成交金额要等线下称重之后才能确定。这意味着订单状态、金额计算、角色权限都要围绕“线下动作回写”来设计稍不注意就会出现用户下单 5 公斤回收员上门称出 4.2 公斤两边对不上账的情况。Flask 适合做这类系统的原因也很直接它轻一个回收系统拆成订单、用户、结算三个模块完全不需要 Django 那种重型全家桶它灵活权限装饰器、蓝图、上下文都能按需组合不会被框架约束住。这篇博文按我自己落地这类系统的习惯从数据模型、核心接口、状态机、权限到本地验证完整走一遍新手能照着写老手可以对比一下自己在订单快照、状态迁移这些细节上有没有漏。2. 先立数据模型废品回收系统的 Flask-SQLAlchemy 表设计与字段选型2.1 为什么订单和订单条目必须拆成两张表一个回收订单可能同时包含报纸、纸箱、塑料瓶每类废品的重量和金额都不一样。如果只在订单表里放一个“总价”字段后续要查“这个用户这周卖了多少斤纸箱”就得拆字符串数据根本没法聚合。所以订单主表和订单明细表是必须拆开的这是这类系统最基本的设计决策。我一般这样划分orders表存订单的公共信息——用户、回收员、状态、下单时间order_items表存每一类废品的预估重量、实称重量、单价和金额。两张表通过order_id关联查询时一次性把明细带出来。这样设计之后报表、对账、价格追溯都有地方落。另外还有一个很多人容易忽略的点废品的回收价是会变的今天报纸 1.2 元/公斤下个月可能跌到 0.9 元。如果把单价硬编码在代码里改价要发版如果把单价放在废品类别表里历史订单的金额又会跟着变。正确的做法是在order_items表里冗余一个“单价快照”字段下单或者结算时把当时的单价抄过来之后价格怎么调都不影响历史数据。这个细节对做过的老手来说是常识但对第一次写这类系统的人基本是必踩的坑。2.2 废品回收系统核心表结构用户、类别、订单、明细先看代码下面是一个去掉非核心字段的精简模型直接可以跑from datetime import datetime from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) phone db.Column(db.String(20), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) role db.Column(db.String(20), defaultuser) # user / recycler / admin created_at db.Column(db.DateTime, defaultdatetime.now) class Category(db.Model): 废品类别价格是每公斤/每个的单价单位由 unit 字段决定 __tablename__ categories id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50), uniqueTrue, nullableFalse) # 报纸、纸箱、塑料瓶 price db.Column(db.Numeric(10, 2), nullableFalse) # 如 1.20 元/公斤 unit db.Column(db.String(10), defaultkg) # kg / 个 class Order(db.Model): __tablename__ orders id db.Column(db.Integer, primary_keyTrue) order_no db.Column(db.String(32), uniqueTrue, nullableFalse) user_id db.Column(db.Integer, db.ForeignKey(users.id)) recycler_id db.Column(db.Integer, db.ForeignKey(users.id), nullableTrue) status db.Column(db.String(20), defaultpending) # pending/assigned/weighed/done/cancelled estimated_total db.Column(db.Numeric(10, 2), default0) actual_total db.Column(db.Numeric(10, 2), default0) created_at db.Column(db.DateTime, defaultdatetime.now) finished_at db.Column(db.DateTime, nullableTrue) class OrderItem(db.Model): 订单明细预估数据由用户填实称数据由回收员上门后录入 __tablename__ order_items id db.Column(db.Integer, primary_keyTrue) order_id db.Column(db.Integer, db.ForeignKey(orders.id)) category_id db.Column(db.Integer, db.ForeignKey(categories.id)) estimated_weight db.Column(db.Numeric(10, 2), default0) actual_weight db.Column(db.Numeric(10, 2), default0) price_snapshot db.Column(db.Numeric(10, 2), nullableFalse) # 单价快照 amount db.Column(db.Numeric(10, 2), default0) # 单价 x 重量字段选型上有三个地方值得单独说一是金额和重量全部用Numeric(10, 2)而不是Float。浮点数在 Python 里有精度问题0.1 0.2 不等于 0.3做金额计算的系统用浮点就是给自己埋雷。Numeric在 SQLite 和 MySQL 里都映射为定点数或者 DECIMAL数据库层面保证精度。二是recycler_id允许为空。用户的回收订单刚创建时还没有回收员接单这个字段要等“接单”动作发生才写入。如果你把它设为非空订单创建接口就必须额外传一个回收员业务上说不通。三是price_snapshot设成了非空。下单动作发生时必须从类别表读出当前单价写入这个字段这是 2.1 里说的价格快照思想的具体落地。2.2.1 字段选型的两个注意点status字段用的是字符串而不是数字。数字状态码0、1、2在代码里可读性太差新人接手看到order.status 2完全不知道是什么状态而order.status weighed至少能猜到和称重有关。字符串状态配合常量类使用比数字状态好用得多。order_no订单号不要用自增 id 直接暴露给用户会泄露业务量也容易被遍历。我习惯用datetime.now().strftime(%Y%m%d%H%M%S) 用户id后四位生成一个可读字符串订单号部署后不需要额外依赖。3. 废品回收系统核心接口订单创建与称重结算的事务写法3.1 用 Flask 蓝图组织模块避免单文件无限膨胀Flask 项目最常见的坏味道是一个app.py里堆几百行路由。废品回收系统再怎么精简也有用户、订单、后台管理三块逻辑我一般会按蓝图拆成auth.py、orders.py、admin.py三个模块视图函数按业务域归位。蓝图注册的写法很固定在工厂函数里注册即可from flask import Flask from models import db from views.orders import orders_bp def create_app(): app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] sqlite:///recycle.db app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db.init_app(app) app.register_blueprint(orders_bp, url_prefix/api/v1) return appurl_prefix/api/v1是一个实用习惯。接口一旦上线客户端、小程序、管理后台都会依赖它后续如果要改接口路径有前缀就能平滑切换到 v2 而不是破坏线上。3.2 创建订单接口明细和主表必须在同一个事务里写下单接口是废品回收系统里最容易出现脏数据的地方。用户提交的 payload 是一个订单主信息加一个明细数组服务端要做的事是校验类别存在、读取当前单价、计算预估金额、同时写入orders和order_items两张表。任何一个步骤失败两张表都不能有残留数据。from flask import Blueprint, request, jsonify from datetime import datetime from models import db, Order, OrderItem, Category orders_bp Blueprint(orders, __name__) orders_bp.route(/orders, methods[POST]) def create_order(): 创建回收订单只信任 category_id 和 estimated_weight金额由服务端算 data request.get_json() items_data data.get(items, []) if not items_data: return jsonify({code: 400, msg: 订单明细不能为空}), 400 order Order( order_nodatetime.now().strftime(%Y%m%d%H%M%S) str(data[user_id])[-4:], user_iddata[user_id], statuspending, estimated_total0 ) db.session.add(order) db.session.flush() # 提前拿到 order.id供明细表引用 total 0 for item in items_data: category db.session.get(Category, item[category_id]) if not category: db.session.rollback() return jsonify({code: 400, msg: f类别 {item[category_id]} 不存在}), 400 est_weight float(item[estimated_weight]) amount round(est_weight * float(category.price), 2) db.session.add(OrderItem( order_idorder.id, category_idcategory.id, estimated_weightest_weight, price_snapshotfloat(category.price), amountamount )) total amount order.estimated_total round(total, 2) db.session.commit() return jsonify({code: 0, data: {order_id: order.id, order_no: order.order_no}})这段代码有三个逻辑点需要说明第一db.session.flush()的作用是把 ORM 对象同步到数据库并拿到自增主键但此时还没提交事务。如果后续任一明细写入失败rollback()会把主表和明细表一起回滚不会出现有头无尾的订单。这是“事务性写入”的关键不能省略flush直接拿order.id否则拿到的是None。第二前端传过来的只有重量金额完全是服务端算出来的。这里有一个安全原则永远不要信任客户端算好的总价。用户改一下请求体里的金额字段就能让订单总额变成 0.01 元这类漏洞在管理类系统里很常见。第三round(est_weight * float(category.price), 2)放在 Python 层做四舍五入。数据库层的 DECIMAL 乘法在某些引擎下行为不一致统一在应用层算好再入库行为可控。3.3 称重结算接口实称数据回写重新计价回收员上门后把秤上的实际重量录入系统。这个接口的运行逻辑是把每个明细的actual_weight更新同时用price_snapshot重新计算每行金额和订单总额。这里不能用最新的Category.price因为用户下单时页面上显示的是当时的单价结算却用新价格用户会投诉。orders_bp.route(/orders/int:order_id/weigh, methods[POST]) def weigh_order(order_id): 回收员回写实称重量金额按单价快照重新计算 data request.get_json() order db.session.get(Order, order_id) if not order or order.status ! assigned: return jsonify({code: 400, msg: 订单不存在或当前状态不可称重}), 400 actual_total 0 for item in data[items]: order_item db.session.get(OrderItem, item[item_id]) if not order_item or order_item.order_id ! order.id: return jsonify({code: 400, msg: 明细不存在或不属于该订单}), 400 actual_weight float(item[actual_weight]) order_item.actual_weight actual_weight order_item.amount round(actual_weight * order_item.price_snapshot, 2) actual_total order_item.amount order.actual_total round(actual_total, 2) order.status weighed order.finished_at datetime.now() db.session.commit() return jsonify({code: 0, data: {actual_total: order.actual_total}})这个接口的校验逻辑比创建订单更严格原因在于称重是一次“不可逆业务动作”。order.status ! assigned的判断保证了只有已接单的订单才能称重防止用户自己调接口把未接单订单改成已完成。校验order_item.order_id ! order.id则防止拼请求参数时把别人的明细塞进当前订单。这里有一个现实问题称重接口往往不是用户调的而是回收员在手机上操作的。这就要做角色权限控制不能谁拿到订单 id 都能改重量。下一章专门说权限和状态机的设计。4. 多角色权限与状态机Flask 回收系统的两种重要约束4.1 用装饰器做角色鉴权不引入额外依赖Flask 做权限控制不需要上 Flask-Login 或者 Flask-Security一个自定义装饰器就够。废品回收系统里有三种角色下单的用户、上门回收的回收员、管理后台的管理员。不同接口允许的角色不同比如“称重”必须回收员才能调“结算确认”必须用户才能调。一个轻量的角色装饰器长这样from functools import wraps from flask import request, jsonify, g from models import db, User def role_required(*roles): def decorator(fn): wraps(fn) def wrapper(*args, **kwargs): # 实际项目中 token 一般放在 Authorization 头 auth request.headers.get(Authorization, ) user db.session.get(User, int(auth)) if auth.isdigit() else None if not user or user.role not in roles: return jsonify({code: 403, msg: 无权限访问该接口}), 403 g.current_user user return fn(*args, **kwargs) return wrapper return decorator使用方式是在视图函数上叠加orders_bp.route(/orders/int:order_id/weigh, methods[POST]) role_required(recycler) def weigh_order(order_id): # 函数体与 3.3 节一致此处只演示装饰器使用 ...装饰器把角色判断逻辑从视图函数里抽离出来视图函数内部不用再关心“当前用户是谁、有没有权限”。g.current_user是 Flask 的上下文对象请求处理完成后自动销毁不存在跨请求串数据的问题。上面代码里用Authorization头直接传用户 id 是演示用的简化写法生产环境至少要换成签名过的 token但装饰器的结构是一样的——业务逻辑永远不应该自己处理“这个人是谁”。4.2 订单状态机用映射表约束流转路径而不是到处 if废品回收系统的订单从创建到完成状态流转是有方向的。用户不能把“已完成”的订单改回“待接单”回收员不能跳过“称重”直接把订单变成“已完成”。如果每个接口里都写if order.status xxx状态一多就会乱成一张蜘蛛网。我习惯在模型文件里定义一张状态迁移映射表TRANSITIONS { pending: {assign: assigned, cancel: cancelled}, assigned: {weigh: weighed, cancel: cancelled}, weighed: {confirm: done}, done: {}, cancelled: {}, }配合一个通用函数来做状态变更def transition_order(order, event): 按状态机执行迁移非法迁移抛 ValueError allowed TRANSITIONS.get(order.status, {}) if event not in allowed: raise ValueError(f订单状态[{order.status}]不允许执行[{event}]操作) order.status allowed[event] return order.status这样一来订单状态相关的所有合法路径都集中在一张表里。新的运营需求要加一个“用户申诉”状态只需要在TRANSITIONS里加一行映射而不是去所有接口里翻if判断。状态机映射表还有一个额外好处——测试时可以穷举所有状态和事件的组合代码覆盖率很容易做高。这里有读者会问为什么不直接用django-fsm这类库因为废品回收系统的状态就五六个用映射表已经足够清晰引入第三方库反而增加学习成本。状态超过 15 个、每个状态都有独立的进入和退出钩子时再去考虑状态机库不迟。4.3 权限和状态机的协作方式两个约束要配合起来才完整状态机管“订单能不能做这个操作”权限管“谁能做这个操作”。两者不能互相替代。一个订单处于assigned状态时称重操作是合法的但如果不是回收员来调权限装饰器会先拦住反过来即使是回收员也不能对pending状态的订单执行称重因为状态机不允许。烦恼的是有些初学者会把这两件事混在一起在权限装饰器里判断订单状态或者在状态机里判断角色。正确的做法是先过角色权限再过状态机。代码组织上装饰器负责前者transition_order负责后者职责边界很清楚。操作允许的角色前置状态后置状态创建订单user-pending接单recyclerpendingassigned称重recyclerassignedweighed确认完成user、adminweigheddone取消订单user、recyclerpending、assignedcancelled上表是一个可以直接抄进设计文档的状态流转矩阵开发时照着这张表写测试用例能覆盖到所有正常路径和非法路径。5. 本地跑通与验证Flask 回收系统的初始化、接口自测和两个实用技巧5.1 最小可运行步骤把代码下载或者拷贝到本地后第一步是建虚拟环境并安装依赖。这里以 Linux/macOS 为例python3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemyWindows 下激活命令换成venv\Scripts\activate其余相同。依赖只装flask和flask-sqlalchemy两个包就能跑通上面所有代码不引入多余的重型依赖。然后初始化数据库把表结构建出来flask --app run.py shell进入 shell 后执行db.create_all() # 插入几个废品类别作测试数据 db.session.add(Category(name报纸, price1.2, unitkg)) db.session.add(Category(name纸箱, price0.9, unitkg)) db.session.commit()run.py的写法是标准入口from app import create_app, db app create_app() if __name__ __main__: app.run(debugTrue)启动开发服务器flask --app run.py run --debug--debug模式会自动重载代码改动改完保存就能看到新效果开发期开着很舒服。但部署上线时一定要关掉否则任何访问报错都会把完整调用栈和部分源码返回到浏览器等于给攻击者送情报。5.2 用 curl 验证订单核心链路服务启动后用一个带明细的请求创建订单curl -X POST http://127.0.0.1:5000/api/v1/orders \ -H Content-Type: application/json \ -d {user_id: 1, items: [{category_id: 1, estimated_weight: 5.5}, {category_id: 2, estimated_weight: 3.0}]}如果前面代码没有改动返回的order_id就是接下来要操作的对象。称重接口的调用方式curl -X POST http://127.0.0.1:5000/api/v1/orders/1/weigh \ -H Content-Type: application/json \ -H Authorization: 2 \ -d {items: [{item_id: 1, actual_weight: 5.2}]}注意Authorization: 2表示以 id 为 2 的用户身份操作对应 4.1 节装饰器里的简化逻辑。自测时重点检查两件事第一称重接口传的重量和返回的金额是否按price_snapshot计算第二换一个非回收员身份调用称重接口是否返回 403。5.3 两个值得带进正式项目的习惯第一个习惯是给模型加to_dict()方法。视图函数里return jsonify({order: order.to_dict()})比手动拼字段清晰得多特别是订单带着明细列表的时候。字段少看不出差距字段到十几个的时候手拼字典的代码会占掉半个文件。第二个习惯是单价快照的显式注释。在OrderItem.price_snapshot字段的注释里写清楚“这个字段存下单时的单价不随分类价格调整变化”半年后回头看代码的人很可能就是你自己会感谢这句话。类似的还有状态字段的取值范围写清楚总比让后来者翻遍所有接口猜状态含义要省事得多。把db.create_all()换成 Flask-Migrate 管理表结构变更把这套代码部署到云服务器再把debug关掉一个真正能用的废品回收系统后端就落地了。本文还有配套的精品资源点击获取
返回列表