☰
基于Node.js和Vue的网上药店购物系统开发全攻略
2026/10/12 10:56:50 网站建设 项目流程

一直想找机会写一写这个项目——基于Node.js和Vue的网上药店购物管理系统。做电商类系统的人不少,但药品电商和普通商品电商完全不是一回事,从商品模型到订单流程,处处都是"特殊规则"。这个项目我从需求梳理到最终部署,前后折腾了一个多月,踩了不少坑,也沉淀了不少可复用的经验。这篇就按我的实际开发路线,把整个系统的设计思路、核心代码逻辑和那些文档里不会写的坑都梳理一遍,给打算做类似全栈项目(尤其是药品、医疗器械等有监管属性的电商系统)的朋友一个完整参考。

先说清楚这个系统到底能做什么:面向普通消费者的药品浏览、搜索、加购、下单,包含非处方药直接购买和处方药上传处方后审核购买两种流程;面向平台运营方的药品管理、库存管理、订单处理、处方审核、用户管理等后台功能;面向系统管理员的基础数据维护和统计概览。技术栈很明确:前端Vue全家桶,后端Node.js(Express框架),数据库MySQL,前后端通过RESTful API通信。

我默认看这篇文章的朋友有一定JavaScript基础,但未必完整做过全栈项目,所以讲的过程中涉及环境配置、工程结构这些基础点,我会把"为什么这么做"一起讲清楚。

1. 网上药店系统为什么选Node.js+Vue这套组合

1.1 药品电商的业务约束倒逼技术设计

网上药店系统和普通电商最大的差异在于:药品是特殊商品,受严格监管。这就带来几个硬性业务约束——

第一,商品必须分类管理,处方药和非处方药(OTC)的购买流程完全不同。非处方药用户可以直接下单,处方药必须先上传处方信息,经过平台审核通过后才能支付发货。这个"审核"环节就是整个系统最核心的业务状态机。

第二,药品信息字段远多于普通商品。除了常规的名称、价格、图片、描述,还需要生产厂家、批准文号、有效期、剂型、规格、用法用量、禁忌症等。而且药品存在"批次"概念,同一款药不同批次的效期不同,库存管理要能定位到具体批次。

第三,订单状态流转必须严谨。用户下单后,药师的审核介入会衍生出"待审核—审核通过/驳回—待发货—已发货—已完成/已取消"这样的复杂状态链,任何一个环节的权限、消息通知、页面展示都要对上。

这些约束直接决定了数据库表结构、接口设计和前端页面流转。我见过不少同类项目把药品系统做成普通商城的样子,处方药直接下单购买,那在真实业务里是行不通的。

1.2 技术选型的真实考量

为什么是Node.js而不是Spring Boot?为什么前端用Vue而不是React?这是我做技术选型时被问到最多的两个问题。

我当时的考量主要是三点:

  • 语言统一,上下文切换成本低。前后端都是JavaScript/TypeScript,一个人开发也好、小团队协作也好,不需要在Java和JS两套语法、两套工程体系之间来回跳。接口定义、数据结构、异常处理可以共用一套认知模型。
  • Node.js的异步I/O模型适合高并发读场景。药品商城的典型流量特征是"浏览多、下单少",Node.js单线程事件循环对这类读多写少的场景支撑得很稳,开发效率也高。
  • Vue对中小型后台管理系统极其友好。Vue的响应式数据绑定、组件化开发、Vue Router和Vuex/Pinia这套官方生态,做表单密集型的后台管理界面效率非常高。尤其是后台的药品管理、订单管理页面,大量表单和表格,Vue这种声明式渲染的写法比手动操作DOM省太多事。

当然,选Node.js也得接受它的短板。比如CPU密集场景(大量复杂计算、图像处理)不适合,工程上对代码规范要求更高(毕竟单线程,一个未捕获的异常可能拖垮整个进程)。我在项目里用PM2做进程守护,后面部署章节会讲。

1.3 系统整体架构设计与目录规划

项目采用经典的前后端分离结构,前端和后端是两个独立工程:

pharmacy-web/ # 前端工程(Vue 3 + Vite + Pinia + Element Plus) pharmacy-server/ # 后端工程(Node.js + Express + MySQL + JWT)

后端目录按业务模块划分,而不是传统的按技术层划分。这是我做Node.js项目的一个习惯——一个业务模块自带路由、控制器、服务、数据模型,内聚性更强,多人协作不容易冲突:

pharmacy-server/ ├── app.js # 应用入口:中间件注册、路由挂载 ├── config/ # 数据库、JWT等配置文件 ├── routes/ # 路由定义:user.routes.js / product.routes.js ... ├── controllers/ # 控制器层:解析请求、调用服务、返回响应 ├── services/ # 业务逻辑层:订单状态机、处方审核、库存扣减 ├── models/ # 数据模型(Sequelize ORM) └── middlewares/ # 认证、角色权限、上传处理等中间件

前端目录则按"页面 + 组件 + 状态 + 请求"四个维度组织:

pharmacy-web/src/ ├── api/ # 对所有后端接口的封装 ├── assets/ # 静态资源 ├── components/ # 公共组件(图片上传、药品卡片等) ├── router/ # 路由配置 ├── stores/ # Pinia状态管理(用户、购物车、订单) ├── views/ # 页面级组件(首页/商品列表/购物车/订单/后台...) └── utils/ # 工具函数(格式化、鉴权等)

这个目录结构我实测下来很稳,单人或小团队都能快速定位代码,强烈建议照这个思路组织。

2. 开发环境搭建:Node.js安装、npm.ps1报错与工程初始化

2.1 Node.js版本选择与环境变量配置

网上药店系统的后端依赖不少npm包,不同包对Node.js版本的要求有差异,所以开发前先确定版本基线很重要。实测下来,Node.js 16.x / 18.x LTS版本做这个项目最稳,不建议一开始就上最新的odd版本(非LTS版本容易出现依赖兼容问题)。

安装过程不复杂,官网下载对应安装包,一路Next就行。但有两点必须注意:

第一,安装路径尽量不要带空格和中文。默认的C:\Program Files\nodejs虽然能用,但后续配置环境变量、命令行操作偶尔会在路径解析上出幺蛾子。我后来自用机改到了D:\dev\nodejs,清爽很多。

第二,安装完成后要确认环境变量是否配置正确。在命令行执行:

node -v npm -v

能正常输出版本号就说明node和npm的PATH都通了。如果提示"node不是内部或外部命令",需要手动把Node.js的安装目录加到系统环境变量Path中,同时加一个NODE_PATH指向全局node_modules目录,方便全局安装的模块被解析到。

另外说一句,npm在国内下载依赖经常慢到怀疑人生,我项目的.dependencies有几十个包,直接用官方源装一次要十几分钟。解决办法是换淘宝镜像(现在叫npmmirror),命令行执行:

npm config set registry https://registry.npmmirror.com/

改完再装依赖,速度直接起飞。如果你用的是pnpm或yarn,也有对应的镜像配置方式,思路一样。

2.2 排查"npm.ps1无法加载文件"报错的完整链路

在做这个项目的过程中,我在Windows环境下遇到一个非常典型的问题。刚装好Node.js,打开PowerShell窗口执行npm -v,结果报错:

npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。

第一次遇到的人基本都懵了,其实这跟Node.js本身没关系,是PowerShell的脚本执行策略在作祟。Windows的PowerShell默认禁止运行未经签名的脚本,而npm是一个.ps1脚本文件,自然被拦住了。

排查过程我按下面三步走:

第一步,先确认Node.js本身装好了。用cmd(命令提示符)打开一个窗口执行node -v,如果能正常输出版本,说明Node核心没问题,问题只出在PowerShell侧。这一步能快速切分问题边界。

第二步,查看当前的执行策略。在PowerShell里运行:

Get-ExecutionPolicy

如果返回结果是Restricted,说明当前环境禁止运行任何脚本,这基本就是根因了。

第三步,修改执行策略。以管理员身份打开PowerShell,执行:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned

RemoteSigned的意思是:本地创建的脚本可以运行,从网络下载的脚本需要经过数字签名才能运行。这是兼顾安全和便利的最佳平衡点,不建议直接设置成Unrestricted。改完再执行npm -v就正常了。

这里要补充一个容易让人误会的细节:如果你用VS Code的终端跑npm,它默认用的也是PowerShell,同样会被策略拦,所以不是只有独立PowerShell窗口才需要改。另外,如果你确实不想动全局执行策略,也可以在VS Code里把默认终端切换成"命令提示符"来绕过,但治标不治本,后面还会遇到,不如一次性改掉。

2.3 Vite创建Vue项目与依赖安装

这个项目前端我用了Vite而不是Vue CLI(现已被官方标记为维护模式)。Vite的冷启动速度和热更新体验比Webpack时代的Vue CLI快一个量级,新项目直接用Vite是更合理的选择。

创建项目:

npm create vite@latest pharmacy-web # 选择 Vue 框架,然后选择 Vue 3 + JavaScript 或 TypeScript

模板生成后进入项目目录,安装基础依赖:

cd pharmacy-web npm install npm install vue-router@4 pinia element-plus axios dayjs

这里简单说一下每个依赖的作用,方便新手理解选型逻辑:

  • vue-router@4:前端路由,负责页面跳转和URL与组件映射。
  • pinia:Vue 3官方推荐的状态管理库,用来管理购物车、用户登录态、订单状态这类跨组件共享的数据。比Vuex更轻量,TypeScript支持也更好。
  • element-plus:组件库,后台管理页面的大量表格、表单、弹窗、消息提示直接用它,省去手写UI的时间。最终成品风格偏后台管理,Element Plus很契合。
  • axios:HTTP请求库,封装所有后端API调用。
  • dayjs:日期处理库,药品批次效期、订单时间的格式化和比较都用得到,比直接用Date对象省心。

依赖安装时会常规性跳出一些deprecated警告,只要不是红色严重的engine冲突,一般不用管。如果安装到一半报证书错误或ECONNRESET,多半是网络问题,把node_modules删了重装即可,也可以试试用yarn或pnpm。

前后端联调还需要一个跨域配置。我前端开发服务器用的是Vite默认的5173端口,后端接口跑在3000端口,直接请求必然触发浏览器的CORS拦截。解决办法是在vite.config.js里配置devServer代理:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })

这样前端代码里所有请求都写/api/xxx,开发时代理会把请求转发到后端3000端口,浏览器视角就是同源,干净利落。

3. 数据库设计与后端接口规划:药品表结构最关键

3.1 核心表结构设计

药店系统数据库我设计了十几张表,但真正撑起业务核心的是四张:商品表(药品表)、处方信息表、订单表和订单明细表。其余用户表、收货地址表、分类表、轮播图表、库存批次表基本都是常规电商设计。

药品表是整个系统的命门,字段不能照搬普通电商商品表。我最终落地的核心字段如下:

字段名类型说明
idint主键
namevarchar(100)药品通用名
generic_namevarchar(100)药品商品名/别名
category_idint分类(关联分类表,如感冒用药、消炎用药)
typetinyint药品类型:1=处方药 2=OTC(非处方药)
approval_numbervarchar(64)批准文号(国药准字)
manufacturervarchar(128)生产厂家
specificationvarchar(64)规格(如 0.25g*24粒)
dosage_formvarchar(32)剂型(胶囊/片剂/口服液等)
unitvarchar(16)销售单位(盒/瓶/袋)
pricedecimal(10,2)销售价格
stockint库存总量
usage_dosagetext用法用量说明
contraindicationstext禁忌症
imagevarchar(255)商品主图URL
statustinyint上下架状态:1=上架 0=下架
created_at / updated_atdatetime创建/更新时间

处方药识别靠type=1来标记。用户在前端一旦选择的是处方药加入购物车,系统都必须强制走"上传处方"流程,这个约束在前端页面和后端下单接口都要双重校验,单靠任何一端都不可靠。

订单表则额外设计了一个prescription_id字段,记录这笔订单关联的处方审核单。注意,处方信息我单独建表而非仓储在订单表里,因为一个处方可能对应一次多盒药品,也可能被驳回后修改重新提交,独立成表才方便复用和维护审核历史。

处方表的字段设计:

字段名类型说明
idint主键
user_idint提交用户
image_urlvarchar(500)处方照片地址(可多张,用逗号分隔)
hospital_namevarchar(128)开方医院
doctor_namevarchar(32)医生姓名
diagnosisvarchar(255)诊断说明
statustinyint0=待审核 1=审核通过 2=审核驳回
review_remarkvarchar(255)审核备注(驳回原因)
created_at / updated_atdatetime创建/更新时间

3.2 RESTful接口清单

后端接口按资源划分,全部走/api前缀。实际开发中我把接口分成三组:用户端、后台管理端和通用接口。

用户端核心接口:

  • POST /api/auth/register、POST /api/auth/login:注册登录,登录成功签发JWT
  • GET /api/products:商品列表,支持关键词搜索、分类筛选、价格排序、分页
  • GET /api/products/:id:商品详情,含药品说明书信息
  • GET /api/categories:分类列表
  • POST /api/cart、PUT /api/cart/:id、DELETE /api/cart/:id:购物车增删改
  • GET /api/cart:购物车列表
  • POST /api/orders:创建订单(非处方药直接创建;处方药先创建处方单后创建订单)
  • GET /api/orders:我的订单列表
  • GET /api/orders/:id:订单详情
  • POST /api/orders/:id/cancel:取消订单
  • POST /api/prescriptions:处方的提交与图片上传
  • GET /api/prescriptions:我的处方列表

后台管理端接口:

  • POST /api/admin/products、PUT /api/admin/products/:id:药品新增/编辑
  • DELETE /api/admin/products/:id:删除药品(实际是软删除,置status=0)
  • GET /api/admin/orders:订单列表(按状态筛选)
  • PUT /api/admin/orders/:id/status:修改订单状态(发货等)
  • GET /api/admin/prescriptions:待审核处方列表
  • PUT /api/admin/prescriptions/:id/review:处方审核(通过/驳回)
  • GET /api/admin/statistics:销售统计概览

3.3 表格之间的关联关系与查询要点

设计好表后,最考验功底的是查询时的关联逻辑。比如订单列表页,前端要显示的不只是订单号,还需要药品名称和数量,这就要把orders、order_items、products三张表join起来查。我用的是Sequelize ORM,关联关系在models里定义清楚后,查询用include就能搞定:

// orders model 定义关联 Order.hasMany(OrderItem, { foreignKey: 'order_id' }); OrderItem.belongsTo(Product, { foreignKey: 'product_id' }); // 查询订单列表时带上明细和商品信息 const orders = await Order.findAll({ where: { user_id: userId }, include: [{ model: OrderItem, include: [Product] }], order: [['created_at', 'DESC']] });

这里有个性能上的细节提醒:如果订单量特别大,这种嵌套include会产生N+1查询问题。中小型系统中先不必过度优化,但可以在列表页用单独的聚合查询代替嵌套include,减少一次性加载的数据量。

4. 前端Vue核心功能实现:从路由设计到处方药购买流

4.1 路由规划与动态路由权限控制

前端路由我按"用户端"和"后台管理端"两块规划。用户端页面包括:首页、商品列表、商品详情、购物车、确认订单、我的订单、订单详情、处方上传、登录注册。后台管理端包括:仪表盘、药品管理、订单管理、处方审核、用户管理、分类管理。

Vue Router的路由配置中,最值得说的是路由守卫。这个系统有两种需要拦截的场景:一是未登录用户不能进后台管理页;二是普通用户不能以角色身份进入管理端。我在router/index.js中统一配置全局前置守卫:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') if (to.path.startsWith('/admin')) { if (!token) { next('/login') } else if (userInfo.role !== 'admin') { next('/') // 非管理员一律打回首页 } else { next() } } else if (to.path === '/login' && token) { next('/') // 已登录用户访问登录页,直接回首页 } else { next() } })

这个守卫的逻辑虽然简单,但非常核心。药品商城的管理端如果用纯前端路由控制,理论上完全可以被绕过,所以真正的鉴权必须在后端接口层再做一次。前端守卫解决的是"界面层体验",后端的JWT鉴权中间件解决的才是"数据层安全",两层缺一不可。

4.2 商品列表与搜索筛选实现

商品列表页几乎是电商系统的门面,这个项目的实现我选了Element Plus的el-card网格布局加el-pagination分页。搜索筛选是电商系统的核心交互,药品的筛选维度比普通商品多,包括:关键词(模糊搜索名称或厂家)、分类筛选、处方药/OTC筛选、价格排序。这些筛选条件我统一放在URL query参数中,好处是刷新页面状态不丢失,也方便分享链接。

搜索参数设计如下:

// 商品列表查询参数 const queryParams = reactive({ page: 1, pageSize: 12, keyword: '', categoryId: '', type: '', // 1=处方药 2=OTC sortBy: 'default' // default / price_asc / price_desc })

请求接口时通过axios的params传递。后端接收后动态拼接SQL条件:

// 商品列表服务层 const getProducts = async (query) => { const where = {} if (query.keyword) { where[Op.or] = [ { name: { [Op.like]: `%${query.keyword}%` } }, { generic_name: { [Op.like]: `%${query.keyword}%` } }, { manufacturer: { [Op.like]: `%${query.keyword}%` } } ] } if (query.categoryId) { where.category_id = query.categoryId } if (query.type) { where.type = query.type } // 排序和分页... }

这里补充讲一个细节:药品的"通用名"和"商品名"是两回事。比如通用名是"布洛芬缓释胶囊",商品名可能是"芬必得"。用户搜索时经常只记得其中一个,所以模糊搜索必须同时覆盖这两个字段,再加上厂家名,体验才完整。

4.3 购物车与下单流程的交互状态管理

购物车数据因为是跨页面共享的(商品详情页加购、导航栏购物车角标、购物车页面加减数量),我用Pinia来管理。一个容易踩坑的点:购物车数量角标的数据要和后端保持同步,不能只存内存。我登录后拉取一次购物车列表存到store,加购成功后立即调接口重新拉取购物车数量,保证角标显示准确。

加购逻辑:

// stores/cart.js (Pinia) export const useCartStore = defineStore('cart', { state: () => ({ items: [], totalCount: 0 }), actions: { async addToCart(productId, quantity) { const res = await api.post('/api/cart', { product_id: productId, quantity }) await this.fetchCart() }, async fetchCart() { const res = await api.get('/api/cart') this.items = res.data.items this.totalCount = res.data.items.reduce((sum, item) => sum + item.quantity, 0) } } })

下单流程是用户端最复杂的页面,我拆成了两步:第一步确认订单,选择收货地址、确认商品清单、优惠金额(这个项目先不做满减,但表结构预留了优惠字段);第二步提交订单,后端返回订单ID,页面跳转到"订单支付/等待审核"状态。

处方药的购物车加购有个特殊处理:处方药不能直接下单,购物车中标记为type=1的商品在结算时会被单独提示,要求先上传处方。我在购物车结算按钮的点击事件里做了校验:

const handleCheckout = () => { const hasPrescriptionDrug = cartItems.some(item => item.product.type === 1) if (hasPrescriptionDrug) { // 跳到处方上传页,携带处方药商品ID router.push({ path: '/prescription/upload', query: { products: JSON.stringify(prescriptionDrugs.map(i => i.product_id)) } }) } else { router.push('/order/confirm') } }

4.4 处方上传页面的前端交互细节

处方上传页是整个系统的"特色模块",交互上要处理好三件事:图片上传、表单填写、提交后状态反馈。我用Element Plus的el-upload组件实现处方图上传,接口为POST /api/upload(upload中间件基于multer实现)。

上传成功后拿到图片URL,用户填写医院名称、医生姓名、诊断说明,然后提交。提交成功后页面不能跳走就完了,必须给出明确的"待审核"状态提示,并引导用户去"我的处方"查看审核进度。因为处方审核是异步的,用户可能提交完一两天才等到结果,这个体验链路必须完整。

图片上传这块有个细节坑:el-upload默认用axios发请求,但Element Plus内部封装的请求不会自动带上你在axios实例上配置的JWT请求头,导致后端鉴权中间件直接403。解决办法是在el-upload的before-upload钩子里手动把token塞到请求头,或者统一用http-request属性自定义上传逻辑。我采用的是在on-success回调中拿URL,但上传请求本身需要配置headers:

<el-upload action="/api/upload" :headers="{ Authorization: `Bearer ${token}` }" :on-success="handleUploadSuccess" list-type="picture-card" >

5. 后端Node.js业务逻辑:处方审核状态机与库存扣减

5.1 JWT认证与角色权限中间件

后端接口的安全基石是JWT(JSON Web Token)。用户登录成功后,服务端生成一个有效期24小时的token返回给前端,前端每次请求在Authorization: Bearer <token>头中携带。后端写一个中间件统一解析校验:

// middlewares/auth.js const jwt = require('jsonwebtoken') const { JWT_SECRET } = require('../config') const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '未登录或登录已过期' }) } const token = authHeader.split(' ')[1] try { const decoded = jwt.verify(token, JWT_SECRET) req.userId = decoded.userId req.userRole = decoded.role next() } catch (error) { return res.status(401).json({ code: 401, message: 'token无效或已过期' }) } } const adminMiddleware = (req, res, next) => { if (req.userRole !== 'admin') { return res.status(403).json({ code: 403, message: '无权限访问' }) } next() }

JWT_SECRET建议单独放在.env环境变量文件中,不要硬编码在代码里,也不建议落库。因为JWT可以无状态校验,服务端连session存储都省了,这在Node.js后端实现上很省事。

5.2 处方审核的完整状态流转实现

处方审核是业务核心中的核心,它的状态流转我定义成一个清晰的状态机,后端服务层用switch方法集中处理。

用户提交处方(status=0待审核)后,管理员在后台看到待审核列表,点开能看到处方图片和用户填写的诊断信息。管理员操作有两个分支:审核通过(status=1),此时关联的订单状态由"待审核"变为"待付款";审核驳回(status=2),必须填写驳回原因,同时前端用户的处方列表中能看到驳回原因,用户可重新提交。

这里的关键实现是"处方状态变化如何联动订单状态变化"。我的做法是在处方审核的Service层中使用事务(sequelize.transaction),一次性更新处方表、订单表:

// services/prescriptionService.js const reviewPrescription = async (prescriptionId, reviewResult, reviewRemark) => { const transaction = await sequelize.transaction() try { // 1. 更新处方审核状态 await Prescription.update( { status: reviewResult === 'approve' ? 1 : 2, review_remark: reviewRemark }, { where: { id: prescriptionId }, transaction } ) // 2. 如果审核通过,将关联的处方药订单状态改为"待付款" if (reviewResult === 'approve') { await Order.update( { status: 'pending_payment' }, { where: { prescription_id: prescriptionId, status: 'pending_review' }, transaction } ) } // 3. 如果驳回,自动取消关联订单 if (reviewResult === 'reject') { await Order.update( { status: 'canceled' }, { where: { prescription_id: prescriptionId, status: 'pending_review' }, transaction } ) } await transaction.commit() } catch (error) { await transaction.rollback() throw error } }

事务是这里绝对不能省的一步。如果不使用事务,万一处方审核成功但订单更新失败,用户会看到"处方已通过但订单还在审核中"的矛盾状态,这在药品业务里属于严重事故。

5.3 下单时的库存扣减与超卖防护

药品库存是敏感数据,不能出现超卖——用户下了单但实际没货。我这里采用了下单即锁库存策略:用户在确认订单提交时,后端事务内先检查库存充足性,充足则扣除库存、创建订单;库存不足则整单返回失败。

实现上要注意,先检查再扣减之间存在时间窗口,如果并发两个请求同时读到库存为1,然后同时扣减,就可能都成功造成超卖。解决方法是使用Sequelize的原子更新:

// 原子扣减库存,受影响行数为0说明库存不足 const [affectedCount] = await Product.update( { stock: sequelize.literal('stock - ' + quantity) }, { where: { id: productId, stock: { [Op.gte]: quantity } // 条件:库存 >= 购买数量 } } ) if (affectedCount === 0) { throw new Error('库存不足或商品已下架') }

WHERE stock >= quantity这个条件配合原子自减,在数据库层面保证了并发安全。这个方案比"先SELECT再UPDATE"更简洁,也不用引入悲观锁和分布式锁,适合中小系统的并发量级。

5.4 订单状态机的集中管理

订单状态整个生命周期我在后端定义了一组常量并写了完整的状态流转逻辑:

// 订单状态常量 const ORDER_STATUS = { PENDING_REVIEW: 'pending_review', // 待审核(处方药) PENDING_PAYMENT: 'pending_payment', // 待付款 PAID: 'paid', // 已付款 PENDING_SHIPMENT: 'pending_shipment', // 待发货(付款后其实可合并为待发货) SHIPPED: 'shipped', // 已发货 COMPLETED: 'completed', // 已完成 CANCELED: 'canceled' // 已取消 }

后端所有修改订单状态的操作必须走同一个Service方法,前端任何地方不能直接改状态。这样可以确保业务规则不被绕过。比如"用户取消订单"和"管理员驳回订单"虽然都把订单置为canceled,但前者要释放库存,后者要通知用户,两者的后续动作不同,逻辑都收敛在Service层里最安全。

6. 前后端联调、部署上线与项目复盘

6.1 RFR跨域问题与常见的联调报错

开发环境联调阶段,除了CORS跨域(前面用Vite代理解决了),我遇到最频繁的报错是请求成功但数据渲染不出来。这类问题80%出在响应数据结构不一致上。我前后端约定了一套统一的响应格式,大幅减少了这类问题:

// 后端统一响应格式 { code: 0, // 0=成功 非0=业务错误 message: 'success', data: { ... } }

后端所有的Controller都返回这个结构,前端axios实例封装一个响应拦截器,统一取data并处理code:

// axios封装 service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { Message.error(res.message) return Promise.reject(new Error(res.message)) } return res }, (error) => { Message.error(error.response?.data?.message || '网络请求失败') return Promise.reject(error) } )

这样前端轮询订单状态时就不用到处写try判断结构,业务代码清爽很多。如果你是自己做全栈练习,强烈建议一开始就约定好这个响应结构,后面能少踩无数坑。

6.2 部署到云服务器:PM2 + Nginx反向代理

开发验收通过后,我把系统部署到一台云服务器上(Ubuntu 20.04,2核4G配置)。

后端部署几步走:

  1. 服务器安装Node.js 18 LTS(用nvm安装,方便切换版本)。
  2. 把pharmacy-server代码上传,执行npm install --production安装生产依赖。
  3. 配置.env文件中的数据库连接信息和JWT_SECRET。
  4. 用PM2启动Node进程:
npm install -g pm2 pm2 start app.js --name pharmacy-server pm2 save pm2 startup

PM2是Node.js项目的进程守护神器:崩溃自动重启、日志收集、开机自启全搞定。实测跑了两个月从没出现过进程挂掉的情况。这里说一个新手容易忽略的点:本地开发用node app.js,上线必须用PM2。如果不幸发生未捕获异常导致进程退出,PM2会立即拉起服务,流量几乎无感知;裸跑node进程挂了就是真挂了,要等人发现再重启,这对线上业务是不可接受的。

前端部署:

  1. 执行npm run build生成dist目录。
  2. 服务器安装Nginx,配置静态文件服务加反向代理:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/pharmacy-web/dist; index index.html; # 解决Vue Router刷新404问题 location / { try_files $uri $uri/ /index.html; } # API反向代理到Node后端 location /api/ { proxy_pass http://127.0.0.1:3000; 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模式必需配置,否则刷新非首页路由(如/product/123)时Nginx会直接404。
  • /api/位置的proxy_pass反向代理把前端请求转发给本机3000端口的Node进程。注意proxy_pass后面不要带路径,否则容易把URL前缀吃掉,导致后端收到的路径与预期不符。

6.3 上线后的真实数据回顾与改进方向

项目上线后,我看了前后一个月的数据,整体运行平稳。系统目前的业务量不大,单机Node+MySQL扛住完全没有压力,响应时间在50ms到200ms之间,主要瓶颈不在并发而在MySQL查询效率。目前已经做了两个索引优化:商品表(name, category_id)联合索引、订单表(user_id, created_at)联合索引。如果后续用户量上来,优先考虑引入Redis做商品热数据的缓存,把高频查询的负担从数据库层卸掉。

从技术债的角度看,有几个我在复盘时认为可以做得更好的地方:

  • 处方审核目前是人工操作,未来可以预留OCR识别处方的接口,辅助药师快速提取关键信息。
  • 药品效期管理目前还没有自动化预警功能,真实药店系统必须要有效期追踪模块,建议加一个每天扫描即将过期批次的任务(node-cron定时任务),库存不足和效期预警同时触发。
  • 支付环节目前是模拟支付(直接切换状态),如果要接真实支付,建议用微信支付/支付宝的JSAPI或Native支付,后端只需要增加一个支付回调通知接口,确认回调签名后更新订单状态即可。
  • 前端可以补充ESLint和Prettier规范,代码量大了之后这能省不少review的精力。

6.4 给新手的几点总结性建议

最后说点实在的。做这个项目最大的收获倒不是代码量,而是"业务规则驱动系统设计"这件事。药品电商的特殊规则,把普通商城系统的每个环节都逼得更严谨:数据字段更全、状态流转更严、权限控制更细。如果你只是照着普通电商demo抄,永远碰不到这些点,也就无法真正理解"为什么系统要这么设计"。

具体建议有三条:

第一,先画清楚业务状态图再写代码。处方审核状态、订单状态、库存变化之间的联动关系,宁可花一整天梳理清楚画成图,也比写到一半返工强。我当时就是因为下单状态和订单状态没理清,前后端联调时来回改了两轮。

第二,前后端接口文档一定要先定义好。哪怕只是一个小项目,用Apifox或者直接写一个Markdown接口清单,标注清每个字段的命名、类型、格式,前后端各写各的也能保证联调顺畅。字段命名不一致是我踩过最蠢的坑。

第三,不要把技术栈学死。很多人问我"学Vue还是React""学Node还是Java",我的答案是先把一个完整项目做完,过程中你会自然理解这些选型的理由,到时候换栈也就是几天的适应成本。技术栈只是工具,业务模型和工程思维才是这个项目真正留给你的资产。

如果你正在做类似的电商管理系统,尤其是药品、食品这些带监管属性的行业项目,欢迎按这篇文章的思路动手试一遍。整个项目我拆成前端、后端、数据库三块,任何一块单独拿出来都是很好的简历亮点。过程中遇到具体问题,也欢迎随时来交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询