1. 系统整体设计与技术选型
1.1 为什么选 Flask + Vue 这个组合
我现在手头维护过十几个用 Python 写的业务系统,如果要给“中小型进销存管理系统”这类项目做一个最稳妥的默认组合,我大概率还是会选 Flask + Vue + MySQL。这不是说这个技术栈最“潮流”,而是它在开发效率、学习门槛、部署复杂度三者之间拿到了一个很实际的平衡点。
先说 Flask。相比 FastAPI,Flask 的生态更老、沉淀更厚,网上随便搜都能找到完整的踩坑记录,这对做业务系统来说很重要。进销存系统本质上是个 CRUD 密集型的应用——商品管理、供应商管理、入库单、销售单、库存台账,这些功能没有特别复杂的异步或者流式处理需求,用 Flask 的同步请求模型完全够用。Flask-SQLAlchemy 做 ORM 映射、Flask-JWT-Extended 做登录鉴权、Flask-CORS 处理跨域,这几个库配合起来非常顺手。
Vue 这边我用的是 Vue 3 + Vite + Pinia + Vue Router 这套组合。选 Vue 而不是 React,主要是考虑到进销存这类后台管理系统的开发模式非常固定——左侧菜单、顶部面包屑、中间内容区,表格加表单的 CRUD 页面占了绝大多数。Vue 的模板语法和双向绑定在这种场景下写起来最省事,而且 Element Plus 组件库简直就是为后台管理系统量身定做的,表格、分页、对话框、表单校验这些需求直接拿来用。
MySQL 没什么好纠结的,业务数据是强事务、强一致性的场景,入库出库必须保证库存量准确,MySQL 的 InnoDB 引擎和事务机制在这里就是正确答案。
1.2 模块规划:进销存系统到底拆成几块
进销存系统听起来功能很多,拆开看其实就五大块:基础资料、采购进货、销售出库、库存管理、系统管理。我一开始做系统设计的时候,没有急着写代码,而是先按业务角色把功能模块画了一遍,这步很关键。
基础资料管理包含商品分类、商品档案、供应商档案、客户档案。商品档案是核心,需要考虑条码、规格、单位、进价、售价、预警库存这些字段。采购进货模块处理进货入库,采购单创建之后审核,审核通过后自动增加库存并生成库存流水。销售出库模块反过来,销售单创建、审核,审核后扣减库存,同时记录销售流水。库存管理模块包括库存查询、库存流水、库存预警,还有一个比较重要的功能——盘点,盘点差异要能生成盘盈盘亏单并调整库存。系统管理就是用户管理、角色权限、操作日志。
系统给我的直观感受是,进销存项目的难点不在单个功能难写,而在于模块之间数据状态的一致性。采购单审核之后库存要变,销售单退货之后库存要回调,这些业务规则如果不在设计阶段想清楚,后面联调的时候会疯狂返工。
2. 数据库设计:进销存的命根子
2.1 核心表结构设计与外键关系
数据库表设计是进销存系统里最不能省功夫的环节。我见过太多半路翻车的项目,大多是表设计时字段命名混乱、外键关系没理清、金额字段用了 Float,最后对账对不上。这里把核心表结构整理出来,直接可以抄作业。
第一张表是用户表sys_user,字段包括 id、username、password(存哈希值)、real_name、role_id、status。密码必须加密存储,哪怕系统是内网使用的,也不能明文存密码,用 Werkzeug 自带的密码哈希函数就行。
第二张核心表是商品表product,设计的时候要注意几个关键字段:category_id外键关联商品分类表,product_code是商品编码或条码,product_name商品名称,specification规格型号,unit计量单位,purchase_price进货价,sale_price零售价,stock_quantity当前库存,warning_stock库存预警线。这里有个容易踩坑的地方:stock_quantity是冗余字段,它应该等于所有入库流水之和减去所有出库流水之和,但不能每次都实时计算,所以要存量字段。这个字段必须配合库存流水表做幂等更新。
第三块是流水类表,包括stock_flow(库存流水表)和business_order(业务单据主表)。库存流水表记录每一笔库存变动,字段包括 product_id、change_type(进货入库/销售出库/退货入库/盘盈/盘亏)、change_quantity(正数入库、负数出库)、before_stock、after_stock、related_order_no、create_time、create_by。这个表设计好了,后面做库存追溯、财务对账就有了依据。
下面贴出核心建表 SQL(精简版):
-- 商品分类表 CREATE TABLE `category` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `parent_id` int DEFAULT 0 COMMENT '父级分类ID', `sort_order` int DEFAULT 0 COMMENT '排序', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表'; -- 商品表 CREATE TABLE `product` ( `id` int NOT NULL AUTO_INCREMENT, `category_id` int NOT NULL COMMENT '分类ID', `product_code` varchar(50) NOT NULL COMMENT '商品编码', `product_name` varchar(100) NOT NULL COMMENT '商品名称', `specification` varchar(100) DEFAULT NULL COMMENT '规格', `unit` varchar(20) DEFAULT NULL COMMENT '单位', `purchase_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '进货价', `sale_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '销售价', `stock_quantity` int NOT NULL DEFAULT 0 COMMENT '当前库存', `warning_stock` int DEFAULT 10 COMMENT '预警库存', `status` tinyint DEFAULT 1 COMMENT '状态 1启用 0停用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_code` (`product_code`), KEY `idx_category_id` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; -- 库存流水表 CREATE TABLE `stock_flow` ( `id` int NOT NULL AUTO_INCREMENT, `product_id` int NOT NULL, `change_type` varchar(20) NOT NULL COMMENT '类型: PURCHASE_IN/SALE_OUT/RETURN_IN/CHECK_IN/CHECK_OUT', `change_quantity` int NOT NULL COMMENT '变动数量,入库为正,出库为负', `before_stock` int NOT NULL COMMENT '变动前库存', `after_stock` int NOT NULL COMMENT '变动后库存', `business_no` varchar(50) NOT NULL COMMENT '业务单号', `remark` varchar(200) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `create_by` varchar(50) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`), KEY `idx_business_no` (`business_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';2.2 金额字段设计经验:Decimal 是底线
我强烈建议金额字段一律使用decimal(10,2)。有的同学刚上手时图省事用 Float 存金额,后面做统计报表时就会感受到什么叫“浮点数陷阱”——0.1 + 0.2 不等于 0.3 这件事在数据库中同样存在,对账对不平排查起来非常痛苦。
表字段的类型选择我个人习惯遵循这个原则:状态类的用 tinyint,数量类的用 int,金额类的用 decimal,时间类的用 datetime(带时区要求的用 timestamp)。字符串统一 utf8mb4,排序规则用 utf8mb4_general_ci,这个排序规则对中文支持友好,查询效率也高。
外键要不要在数据库层面强制建立,这个业内一直有争议。我的经验是在开发阶段不加物理外键,而是通过代码层的逻辑约束来保证数据一致性。原因有二:一是物理外键会让批量导入数据、删除数据变得很繁琐;二是业务系统经常有“逻辑删除”的需求,物理外键在逻辑删除场景下会产生额外的麻烦。所以表设计上只保留逻辑关联字段和索引,外键约束在实际开发中靠事务和代码保证。
3. 后端 Flask 核心 API 实现
3.1 项目初始化与蓝图模块化
Flask 项目如果所有路由都写在 app.py 里,代码很快就会膨胀到没法维护。进销存业务涉及的接口数量少说也有六十个以上,我一开始就用Blueprint 蓝图做了模块化拆分。
项目结构大致这样:
supermarket_erp/ ├── app.py # 入口文件,创建 app 对象 ├── config.py # 配置文件(数据库连接、JWT 密钥等) ├── requirements.txt # 依赖清单 ├── models/ # ORM 模型 │ ├── __init__.py │ ├── user.py │ ├── product.py │ ├── category.py │ ├── stock_flow.py │ └── order.py ├── api/ # 蓝图路由 │ ├── __init__.py │ ├── auth.py # 登录、用户管理 │ ├── product_api.py # 商品相关接口 │ ├── category_api.py # 分类相关接口 │ ├── stock_api.py # 库存相关接口 │ └── order_api.py # 采购和销售单据接口 ├── utils/ # 通用工具函数 │ ├── __init__.py │ ├── response.py # 统一响应格式 │ └── decorators.py # 权限校验装饰器 └── logs/ # 日志目录在app.py里注册蓝图才是关键的部分:
from flask import Flask from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager from config import Config db = SQLAlchemy() jwt = JWTManager() def create_app(): app = Flask(__name__) app.config.from_object(Config) db.init_app(app) jwt.init_app(app) CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) # 注册蓝图 from api.auth import auth_bp from api.product_api import product_bp from api.category_api import category_bp from api.stock_api import stock_bp from api.order_api import order_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(product_bp, url_prefix='/api/product') app.register_blueprint(category_bp, url_prefix='/api/category') app.register_blueprint(stock_bp, url_prefix='/api/stock') app.register_blueprint(order_bp, url_prefix='/api/order') return app if __name__ == '__main__': app = create_app() app.run(host='0.0.0.0', port=5000, debug=True)清晰的路由前缀对应清晰的模块边界,前端联调时看接口路径就知道是哪个模块的接口,排查问题效率能提升很多。CORS 配置要特别注意,开发时可以先放开,但生产环境一定要限定具体的跨域来源,否则会有安全风险。
3.2 JWT 登录鉴权与统一响应封装
进销存系统是多人使用的,有管理员、采购员、销售员、仓库管理员这些角色,登录鉴权和权限控制是必须的。我这里用flask-jwt-extended做 Token 鉴权。登录接口校验用户名密码,校验通过后签发 JWT Token,后续所有业务接口在请求头带Authorization: Bearer <token>即可。
用户密码校验用的是 Werkzeug 的check_password_hash。用户登录成功后,在 JWT 的额外 claims 里塞入用户 ID 和角色 ID,之后每个受保护接口都能通过get_jwt_identity()拿到当前用户身份,做权限判断:
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity @auth_bp.route('/login', methods=['POST']) def login(): data = request.get_json() username = data.get('username') password = data.get('password') user = User.query.filter_by(username=username).first() if not user or not check_password_hash(user.password, password): return jsonify({'code': 40001, 'message': '用户名或密码错误'}), 200 if user.status != 1: return jsonify({'code': 40002, 'message': '账号已被禁用'}), 200 access_token = create_access_token( identity=user.id, additional_claims={'role_id': user.role_id, 'username': user.username} ) return jsonify({'code': 20000, 'message': '登录成功', 'data': {'token': access_token, 'username': user.username}})统一响应格式是我个人很坚持的一点。后端接口不管成功失败,返回 JSON 都遵循{code, message, data}这个结构。code 为 20000 表示成功,其他码表示业务异常。这样前端可以用 axios 拦截器统一处理响应状态,不用每个页面重复判断。
权限这块做一个简单的装饰器就够了:
def role_required(*roles): def decorator(fn): @wraps(fn) @jwt_required() def wrapper(*args, **kwargs): claims = get_jwt() if claims.get('role_id') not in roles: return jsonify({'code': 40300, 'message': '无权限访问'}), 200 return fn(*args, **kwargs) return wrapper return decorator3.3 核心业务接口:采购入库与销售出库怎么保证库存准确
进销存系统最核心的逻辑就是单据审核后变更库存,同时记录库存流水。这块我讲细一点。
采购入库接口的逻辑:前端提交采购单(包含商品 ID、进货数量、进货单价),后端先在一个事务里做三件事——查询商品当前库存、计算新的库存量、更新商品库存;然后插入库存流水记录;最后记录采购订单。这三步必须在一个事务里,任何一步失败都要回滚,否则就会出现库里库存和流水对不上的情况。
@order_bp.route('/purchase/in', methods=['POST']) @jwt_required() def purchase_in(): data = request.get_json() items = data.get('items', []) if not items: return jsonify({'code': 40010, 'message': '进货商品列表不能为空'}), 200 business_no = generate_business_no('PO') try: for item in items: product = Product.query.filter_by(id=item['product_id']).with_for_update().first() if not product: raise Exception(f"商品ID {item['product_id']} 不存在") quantity = int(item['quantity']) if quantity <= 0: raise Exception("进货数量必须大于0") before_stock = product.stock_quantity after_stock = before_stock + quantity product.stock_quantity = after_stock flow = StockFlow( product_id=product.id, change_type='PURCHASE_IN', change_quantity=quantity, before_stock=before_stock, after_stock=after_stock, business_no=business_no, remark=data.get('remark', '') ) db.session.add(flow) db.session.commit() return jsonify({'code': 20000, 'message': '进货入库成功', 'data': {'business_no': business_no}}) except Exception as e: db.session.rollback() return jsonify({'code': 50000, 'message': str(e)}), 200注意这里使用了with_for_update(),也就是 MySQL 的行级锁。多用户同时操作同一个商品时,不加锁就会出现超卖或库存变负的问题。虽然进销存系统的并发量通常不高,但这一点我在实际项目里吃过亏,所以必须强调。
销售出库接口的逻辑相反,校验库存足够后扣减库存,出库流水数量为负值。退货入库则重新加回库存,并记录退货原因。整个系统的库存台账就是这样通过流水表完整保留下来,想要知道任何时点库存变化,查流水表一目了然。
4. 前端 Vue 页面体系与接口对接
4.1 Vue 3 + Vite 环境搭建与项目结构
前端部分我用的是 Vue 3 + Vite。相比 Vue CLI,Vite 的启动速度和构建速度都有质的提升,热更新也跟手得多。前台开发时改一行代码,浏览器秒级刷新,这种体验对开发效率的影响真的很大。
创建项目直接跑:
npm create vue@latest这个命令会引导你选择需要的特性,我一般勾选 Vue Router、Pinia、ESLint 这三项。项目创建完再装 Element Plus 和 Axios:
npm install element-plus npm install axios前端项目的目录结构我也保持模块化:
src/ ├── api/ # 接口请求封装 │ ├── request.js # axios 实例 │ ├── auth.js │ ├── product.js │ └── order.js ├── router/ # 路由配置 │ └── index.js ├── store/ # Pinia 状态管理 │ └── user.js ├── views/ # 页面组件 │ ├── Login.vue │ ├── Dashboard.vue │ ├── product/ProductList.vue │ ├── product/ProductEdit.vue │ ├── stock/StockList.vue │ ├── stock/StockFlow.vue │ └── order/PurchaseOrder.vue └── layout/ └── MainLayout.vue # 后台主框架4.2 关键页面实现:商品管理与库存查询
商品管理页是标准的表格 CRUD 页面。左侧放分类树,右侧放商品列表。分类树用 Element Plus 的el-tree组件,商品列表用el-table,工具栏放搜索框、新增、导入导出按钮。分页用el-pagination,每页默认 20 条。
我直接说几个实现时容易踩的细节:
一是搜索条件要带防抖。用户在搜索框输入商品名称时,不应该每敲一个字就发一次请求,至少要做 300ms 的防抖,输入结束再请求接口。不然写一半,页面上会连续打出好几个待选建议列表。
二是表格列渲染要格式化。金额字段在el-table-column里可以用:formatter方法保留两位小数,库存字段低于预警线时给该行添加高亮 class,这样预警效果一眼可见,比看数字判断直观多了。
三是编辑弹窗的表单校验。原价降不降不重要,但必填字段、数字范围这些校验必须做。Element Plus 的el-form配 rules 校验规则,在el-form-item的 prop 上绑定字段名,提交时validate()一下,不通过就不发请求。
库存流水页面就简单一些,顶部按商品、日期范围过滤,中间一个表格按时间倒序展示流水记录,列包括时间、业务类型、变动数量、变动前后库存、业务单号、操作人。这个页面配合商品库存列表,基本能回答老板大部分“这个东西到底还有多少、进出记录怎么样”的问题。
4.3 动态路由与 Pinia 状态管理
进销存系统涉及多个操作角色,不同角色的菜单权限不同。我这里前端做了动态路由——登录成功后根据当前用户角色去拿菜单权限列表,再动态往路由表里加路由。
Pinia 状态管理主要放在用户信息、菜单权限、全局公共数据这三类。用户信息在登录成功后就存起来,菜单权限在路由守卫里获取。主导航栏和侧边栏根据 store 里的菜单数据渲染,好处是:切换账号时不用刷新整个页面,菜单会自动跟着变了。
// store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', username: '', roleId: null, menus: [] }), actions: { setLoginInfo(data) { this.token = data.token this.username = data.username this.roleId = data.role_id localStorage.setItem('token', data.token) }, logout() { this.token = '' this.username = '' this.roleId = null this.menus = [] localStorage.removeItem('token') } } })路由守卫的逻辑也很直观:
router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path !== '/login' && !userStore.token) { next('/login') } else { next() } })4.4 axios 拦截器封装:Token 注入与统一错误处理
前端请求封装最关键的是 axios 拦截器。一个是请求拦截器,每次请求自动把 Token 加到请求头;一个是响应拦截器,统一处理后端返回的 code、处理 401 Token 过期自动跳登录页。
// api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/store/user' import router from '@/router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers['Authorization'] = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 20000) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { const userStore = useUserStore() userStore.logout() router.push('/login') } ElMessage.error('网络请求异常,请稍后重试') return Promise.reject(error) } ) export default request这段代码是后台系统前端的骨架,所有页面走这个封装的请求实例,全局的登录失效处理、错误提示自动就带上了,不用每个页面重复写。
5. 前后端联调与生产部署
5.1 手机端/局域网访问与 Vue 代理配置
开发模式下,前端跑在 5173 端口,后端跑在 5000 端口,两者互相独立。如果直接让前端页面请求http://localhost:5000/api/xxx,会产生跨域问题。解决方案有两种:一是后端开 CORS,二是在 Vite 里配置代理。
开发环境我更推荐用 Vite 代理,前端代码里所有请求都写相对路径/api,由 Vite 开发服务器把请求转发给后端。这样后端也省事,前端代码部署到生产环境后,也不用改任何接口地址。Vite 配置如下:
// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, host: true, proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } })注意这里的host: true很关键。如果只是本地自己开发调试,不加也行;但如果你像做员工培训、演示,需要局域网内手机或同事电脑访问你电脑上的页面,这个配置就必须打开。
5.2 Nginx 部署配置与 MySQL 定时备份
生产环境部署的话,前端打包后是纯粹的静态文件,可以直接丢给 Nginx。前端记住一件事:生产环境同样不希望换接口地址,所以 Nginx 要配一个反向代理,把/api开头的请求转发给 Flask 后端服务。
一个基础但实用的 Nginx 配置是这样:
server { listen 80; server_name your_domain_or_ip; # 前端静态文件 root /var/www/supermarket_erp/dist; index index.html; # 前端路由 history 模式必需 location / { try_files $uri $uri/ /index.html; } # 后端 API 反向代理 location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最容易漏掉的是try_files $uri $uri/ /index.html;这一行。Vue Router 用的 history 模式,如果不配置这行,用户点击页面内跳转没问题,但刷新或直接输入某个子页面 URL,就会出现 404。
MySQL 定时备份也是上线第一件事。建议写一个简单的脚本,每天凌晨通过 crontab 跑任务导出 SQL,保留最近 7 天:
#!/bin/bash BACKUP_DIR=/data/backup/mysql DATE=$(date +%Y%m%d) mysqldump -u erp_user -p'密码' supermarket_erp > $BACKUP_DIR/erp_$DATE.sql find $BACKUP_DIR -mtime +7 -name "*.sql" -exec rm -f {} \;生产环境备份这件事,我有一次因为磁盘满了没注意,备份任务连续失败一周,后来数据库被误操作删表了才发现手里只有一周前的数据,损失非常大。从那以后我上线任何系统,第一件事永远是确认备份脚本在跑。
5.3 局域网部署的注意事项
如果只是超市内部使用,不暴露到公网,其实部署很简单:一台普通电脑装 MySQL、Python 环境、Nginx,前端打包后的dist目录放到指定位置就行。员工浏览器访问的地址就是后端机器在局域网里的 IP。
我个人会额外注意两点:
一是 MySQL 的配置优化。进销存系统加国产报表查询,把max_connections调大一些,innodb_buffer_pool_size根据机器内存调,默认的 128M 在数据量上来之后查询会明显变慢。
二是防火墙放行端口。后端跑 5000 端口,前端 80 端口,有时候系统启动了一切正常,但局域网其他机器就是访问不了,大概率是防火墙没放行。自己电脑直接访问localhost当然没问题,一换 IP 访问就卡住。很多新手在这里浪费时间。
6. 实操中遇到的典型问题与解决记录
6.1 并发扣库存导致数据不一致
这个问题我之前在开发环境里几乎测不出来,因为一直是单用户操作。后来让店里两个收银员同时卖同一个商品,对账发现库存少了。排查下来是销售出库接口没有加行锁,两台收银电脑同时提交销售单,后一个请求读到的库存还是旧的,覆盖了前一个请求的结果。
解决办法就是上面说的with_for_update()行级锁。加锁后同一时刻只有一个事务能修改该商品的库存,另一个事务会等待锁释放后再读最新值。这个坑非常隐蔽,如果不在代码层面处理,上线后会在某个偶然时刻让你对账对到抓狂。
6.2 时间字段混乱导致统计报表偏差
开发阶段我用的是datetime字段,存进去之后查出来看着都正常。后来发现有些接口查询今天的销售单,总和总数对不上。查来查去发现是时区问题,Python 的datetime.now()默认取的是本地时间,MySQL 默认用系统的时区,服务器时间如果不是中国标准时间,存进去的数据就会偏移。
解决方案是统一在config.py里设置:
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'pool_size': 10, 'pool_recycle': 3600, 'connect_args': {} }同时 MySQL 连接字符串 URL 里加上?charset=utf8mb4,时间字段统一在 SQLAlchemy 模型里用datetime类型,程序内部全部用datetime.now()生成,这样保证所有写入的时间用的都是后端进程的时区,统计报表才不会有偏差。
6.3 前端页面表格大数据量渲染卡顿
商品数量上万后,在el-table里一次性渲染全部数据,页面明显卡顿,滚动都抖。这个问题主要发生在盘点库存和商品列表页面。解决办法是后端接口强制分页,前端表格配合分页组件,每页 20 条,切页时才请求数据。
如果商品数量很大同时还要搜索过滤,可以考虑在数据库层面做 LIKE 查询然后分页。前端不要一次性把全量数据 load 到内存里再自己 filter。这条经验不值钱,但真的能省下很多性能排查的时间。
6.4 接口响应慢与 N+1 查询问题
开发完商品列表接口时,测了一下性能,居然要 2 秒多才返回。一看日志,问题出在 SQLAlchemy 的关联查询上。商品表格里显示分类名称,最自然的写法是在循环里查分类表,结果几百条商品数据,一个接口发了几百条 SQL 查询。
这就是典型的 N+1 查询问题。解决办法是用joinedload或者subqueryload一次性把关联数据查出来:
products = Product.query.options(db.joinedload(Product.category)).paginate(...)这样 SQL 查询次数从 N+1 变成 1+1,接口耗时从 2 秒降到 200 毫秒以内。接口开发过程中,一个重要的习惯是观察 SQLAlchemy 的查询日志,看到大量重复 SQL 就要意识到关联查询没优化。
7. 实际操作中的补充经验与后续扩展方向
最后想聊一点个人实际操作中的体会,供后面接手你项目的同事参考。
第一,进销存这类系统的后端接口,宁可多写几个小的专用接口,也不要为了省事写一个万能接口。比如商品列表和商品下拉选项,虽然返回的都是商品数据,但一个需要分页过滤,一个只需要简单的 id 和名称,接口连返回字段都不一样。拆开写,前端代码会更清晰,后端也不用每次都做无谓的查询。
第二,权限模型的复杂度要匹配实际使用场景。我见过很多进销存项目一开始就把权限表设计得非常复杂,菜单权限、按钮权限、数据权限三层,结果实际使用的时候店里只有老板和一个收银员两个账号,复杂权限模型根本用不上。设计权限系统前,先问清楚实际会有多少角色、角色差异是什么,够用就好。
这个系统后续还可以扩展的方向,我个人建议优先考虑这几个:多门店支持(把门店维度加进商品、单据、库存里)、供应商对账和客户账期管理(进销存加了应收应付就是半个财务系统)、基于销售数据的补货建议(结合预警库存和近 30 天销售速度自动生成采购建议单)。每一步扩展都建立在现有基础数据和流水表结构之上,前提是表设计阶段没有偷懒,把冗余字段和流水台账都做扎实。
用 Flask + Vue 做这类系统,代码量不大,整个链路也不复杂,真正决定系统好不好用的从来不是框架本身,而是业务逻辑是否严谨、数据结构是否考虑周全。照着上面这套结构和实操经验走,再做不好不太可能,祝你顺利。