2026/10/17 9:25:50

从零搭建商城购物系统:用户登录、购物车与订单模块全流程拆解

从零搭建商城购物系统:用户登录、购物车与订单模块全流程拆解 1. 从零搭建商城购物系统一个全栈项目的骨架拆解做电商类项目最怕的不是功能多而是功能之间的数据流理不清。用户登录注册、商品浏览、购物车管理、订单结算这四个模块看起来各管一摊实际上背后有一条非常清晰的数据主线用户身份 → 商品选择 → 临时存储 → 最终交易。我见过不少新手朋友一上来就猛写页面结果购物车加购之后刷新就丢数据订单提交了库存没扣减用户登录状态换个页面就失效——根子都在于没有先把这条主线想明白。这篇文章要聊的就是如何从零构建一个结构清晰、逻辑自洽的商城购物系统。核心覆盖四个模块用户登录注册、购物页面、购物车页面、订单页面。适合有一定前端基础、想完整走一遍电商项目流程的开发者也适合正在做课程设计或毕业项目的同学参考。我会把每个模块的关键设计决策、实操步骤、踩坑经验都摊开来讲尽量做到你照着做就能跑通。整个系统的技术选型不设硬性限制前端可以用原生HTML/CSS/JavaScript也可以用Vue或React后端可以用Node.js、Python Flask/Django、Java Spring Boot甚至PHP都行。关键不在于用什么框架而在于数据怎么存、状态怎么管、页面怎么跳。下面我会以最常见的“前后端分离”思路为主线穿插说明不同技术栈下的等价实现方式。2. 系统整体架构与数据流设计2.1 四个模块的职责边界与协作关系先把四个模块的职责说清楚不然后面写代码容易互相打架。用户模块负责三件事注册新账号、验证登录凭证、维持登录状态。它产出的核心数据是“当前是谁在操作”这个身份标识要贯穿后续所有页面。购物页面负责展示商品列表和商品详情。它从数据库读取商品信息名称、价格、库存、图片、描述渲染成用户能浏览的界面。用户在这里决定“买什么”。购物车页面负责暂存用户选中的商品。它要记录“哪个用户、选了哪些商品、各买多少件”。购物车的数据可以存在后端数据库也可以存在前端本地存储两种方案各有优劣后面会详细对比。订单页面负责把购物车里的东西变成一笔正式交易。它要生成订单记录、扣减库存、清空购物车、展示订单状态。这是整个系统里数据一致性要求最高的环节。四个模块的数据流是这样的用户登录后拿到身份凭证 → 在购物页面浏览商品 → 点击加购写入购物车 → 在购物车页面调整数量 → 提交订单生成交易记录。每一步都依赖前一步的数据产出所以数据结构的定义必须在前端开发之前就定好。2.2 前后端分离下的接口规划如果采用前后端分离架构建议先把接口列表列出来前后端开发人员可以并行工作。以下是一套最简可用的接口设计接口路径请求方式功能说明关键参数/api/registerPOST用户注册username, password, email/api/loginPOST用户登录username, password/api/logoutPOST退出登录token/api/productsGET获取商品列表page, pageSize, keyword/api/products/:idGET获取商品详情id/api/cartGET获取购物车列表userId/api/cart/addPOST加入购物车userId, productId, quantity/api/cart/updatePUT修改购物车数量cartItemId, quantity/api/cart/removeDELETE删除购物车项cartItemId/api/ordersPOST创建订单userId, cartItemIds, address/api/ordersGET获取订单列表userId, status/api/orders/:idGET获取订单详情id这套接口设计遵循RESTful风格每个接口只做一件事。实际开发中可以根据需要合并或拆分但原则是前端需要什么数据后端就提供什么接口不要让前端做太多数据组装的工作。2.3 数据库表结构设计要点不管用什么数据库核心表就那么几张。这里以关系型数据库为例说明用户表usersid, username, password_hash, email, created_at。密码绝对不能明文存储必须用bcrypt或argon2做哈希。我见过有人直接把密码存成明文这种项目一旦被拖库后果不堪设想。商品表productsid, name, description, price, stock, image_url, category_id, created_at。价格字段建议用整数存“分”避免浮点数精度问题。比如19.9元存成1990。购物车表cart_itemsid, user_id, product_id, quantity, created_at。这里有个设计决策购物车是存数据库还是存前端存数据库的好处是换设备也能同步坏处是每次操作都要请求后端。存前端localStorage的好处是响应快坏处是换设备就没了。我的建议是如果项目要求不高先用localStorage实现后期再迁移到数据库。订单表ordersid, user_id, total_amount, status, address, created_at。status字段用枚举值表示订单状态比如0待支付、1已支付、2已发货、3已完成、4已取消。订单明细表order_itemsid, order_id, product_id, quantity, price_at_purchase。注意这里要记录“购买时的价格”因为商品价格可能会变订单里的价格必须锁定。提示表结构设计阶段多花一小时后面写代码能省一天。特别是字段类型和约束条件一定要想清楚再动手。3. 用户登录注册模块的完整实现3.1 注册功能的实操步骤与密码安全处理注册功能看起来简单但有几个细节容易翻车。第一步是前端表单验证。用户名长度限制比如3到20个字符、密码强度要求至少8位包含字母和数字、邮箱格式校验这些都要在提交之前做一遍。但前端验证只是提升用户体验后端必须再做一遍同样的验证因为前端代码用户可以随意修改。第二步是后端接收数据后的处理流程。以Node.js Express为例const bcrypt require(bcrypt); const saltRounds 10; app.post(/api/register, async (req, res) { const { username, password, email } req.body; // 1. 验证参数完整性 if (!username || !password || !email) { return res.status(400).json({ error: 参数不完整 }); } // 2. 检查用户名是否已存在 const existing await db.query(SELECT id FROM users WHERE username ?, [username]); if (existing.length 0) { return res.status(409).json({ error: 用户名已被占用 }); } // 3. 密码哈希 const passwordHash await bcrypt.hash(password, saltRounds); // 4. 写入数据库 await db.query( INSERT INTO users (username, password_hash, email) VALUES (?, ?, ?), [username, passwordHash, email] ); res.status(201).json({ message: 注册成功 }); });这里的关键点是bcrypt的saltRounds参数。它决定了哈希计算的迭代次数值越大越安全但越慢。10是一个比较平衡的选择计算一次大约100毫秒用户几乎无感但暴力破解的成本会急剧上升。3.2 登录状态维持Session与Token的选型对比登录成功后怎么让服务器“记住”用户两种主流方案Session方案服务器生成一个sessionId存在内存或Redis里通过Cookie发给浏览器。浏览器每次请求自动带上Cookie服务器根据sessionId找到对应的用户信息。优点是实现简单缺点是服务器需要存储session分布式部署时要考虑session共享。Token方案服务器生成一个加密字符串JWT里面包含用户ID和过期时间发给前端。前端每次请求在Header里带上这个Token服务器解密验证。优点是服务器不需要存储状态天然支持分布式缺点是Token一旦签发就无法主动失效除非引入黑名单机制。我的建议是小型项目用Session中大型项目用Token。如果只是课程设计或练手项目Session足够用而且更容易理解和调试。// Session方案示例Express express-session app.use(session({ secret: your-secret-key, resave: false, saveUninitialized: false, cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24小时 })); app.post(/api/login, async (req, res) { const { username, password } req.body; const users await db.query(SELECT * FROM users WHERE username ?, [username]); if (users.length 0) { return res.status(401).json({ error: 用户名或密码错误 }); } const user users[0]; const match await bcrypt.compare(password, user.password_hash); if (!match) { return res.status(401).json({ error: 用户名或密码错误 }); } req.session.userId user.id; req.session.username user.username; res.json({ message: 登录成功, username: user.username }); });注意这里有个安全细节用户名不存在和密码错误返回的错误信息应该完全一样。如果分别提示“用户不存在”和“密码错误”攻击者就能通过错误信息枚举出哪些用户名是有效的。3.3 登录拦截与路由守卫的配置方法未登录用户不能访问购物车和订单页面这是基本要求。实现方式取决于前端架构如果是服务端渲染比如Express EJS模板可以在路由中间件里检查sessionfunction requireLogin(req, res, next) { if (!req.session.userId) { return res.redirect(/login); } next(); } app.get(/cart, requireLogin, (req, res) { // 渲染购物车页面 });如果是前后端分离Vue/React可以在路由配置里加守卫// Vue Router示例 router.beforeEach((to, from, next) { const isLoggedIn localStorage.getItem(token); if (to.meta.requiresAuth !isLoggedIn) { next(/login); } else { next(); } });注意前端的路由守卫只是提升用户体验后端接口必须独立做权限校验。我见过有人只做了前端拦截结果用户直接调接口就能拿到数据这是典型的安全漏洞。4. 购物页面与购物车模块的核心逻辑4.1 商品列表渲染与分页加载策略商品列表页的核心是“怎么把数据库里的商品高效地展示给用户”。如果商品只有几十个一次性查出来渲染就行。但如果商品上千上万就必须分页。分页有两种常见模式传统分页和滚动加载。传统分页是页面底部有页码按钮用户点击跳转滚动加载是用户往下滑快到底部时自动加载下一页。电商类项目推荐滚动加载体验更流畅。后端分页查询的SQL大致是这样SELECT id, name, price, image_url, stock FROM products WHERE status 1 ORDER BY created_at DESC LIMIT ? OFFSET ?;其中LIMIT是每页条数OFFSET是偏移量。比如每页20条第3页就是LIMIT 20 OFFSET 40。这里有个性能坑当OFFSET很大时查询会变慢因为数据库要扫描并跳过前面所有行。优化方案是用“游标分页”即记录上一页最后一条的ID下一页从那个ID之后开始查。前端渲染时商品卡片通常包含图片、名称、价格、加购按钮。图片建议用懒加载只加载可视区域内的图片能显著提升首屏速度。4.2 购物车数据结构设计与本地存储方案购物车的数据结构设计直接影响后续所有操作。最核心的字段是商品ID、数量、加入时的价格。我建议的数据结构如下// localStorage中的购物车数据 { userId_123: [ { productId: 1001, name: 商品A, price: 2990, image: /images/a.jpg, quantity: 2, addedAt: 1700000000000 }, { productId: 1002, name: 商品B, price: 5990, image: /images/b.jpg, quantity: 1, addedAt: 1700000001000 } ] }用userId作为key可以支持多用户在同一浏览器上分别维护购物车。每个商品项记录加入时的价格是为了在购物车页面展示“加入时价格”和“当前价格”的对比如果价格变了可以提示用户。加购操作的逻辑是先查购物车里有没有这个商品有就数量加一没有就新增一条。这里要注意库存校验——如果商品库存只有3件用户想加5件应该提示库存不足。function addToCart(userId, product, quantity) { const cartKey cart_${userId}; let cart JSON.parse(localStorage.getItem(cartKey)) || []; const existingIndex cart.findIndex(item item.productId product.id); if (existingIndex -1) { const newQuantity cart[existingIndex].quantity quantity; if (newQuantity product.stock) { return { success: false, message: 库存不足 }; } cart[existingIndex].quantity newQuantity; } else { if (quantity product.stock) { return { success: false, message: 库存不足 }; } cart.push({ productId: product.id, name: product.name, price: product.price, image: product.image, quantity: quantity, addedAt: Date.now() }); } localStorage.setItem(cartKey, JSON.stringify(cart)); return { success: true }; }4.3 购物车增删改查的交互细节与边界处理购物车页面的交互看起来简单但边界情况特别多。我整理了一个速查表操作正常情况边界情况处理方式增加数量数量1已达库存上限按钮置灰提示“已达最大库存”减少数量数量-1数量为1时再减弹出确认框“确定要删除吗”删除商品移除该项购物车只剩这一项删除后显示空购物车提示全选所有项勾选购物车为空全选按钮置灰结算跳转订单页未勾选任何商品提示“请先选择商品”还有一个容易被忽略的点购物车商品的价格应该实时更新。用户三天前加购的商品今天来看可能已经降价了。实现方式是在购物车页面加载时批量查询这些商品的最新价格如果和加入时不一致就更新显示并给出提示。async function refreshCartPrices(userId) { const cartKey cart_${userId}; let cart JSON.parse(localStorage.getItem(cartKey)) || []; if (cart.length 0) return cart; const productIds cart.map(item item.productId); const latestProducts await fetch(/api/products/batch?ids${productIds.join(,)}) .then(res res.json()); const priceMap {}; latestProducts.forEach(p { priceMap[p.id] p; }); cart cart.map(item { const latest priceMap[item.productId]; if (latest) { return { ...item, price: latest.price, priceChanged: latest.price ! item.price, stock: latest.stock }; } return { ...item, unavailable: true }; }); localStorage.setItem(cartKey, JSON.stringify(cart)); return cart; }实操心得购物车页面每次加载都做一次价格刷新虽然多了一次网络请求但能避免用户结算时发现价格不对的尴尬。这个体验优化成本很低收益很高。5. 订单模块的事务处理与状态管理5.1 创建订单的完整流程与库存扣减逻辑订单创建是整个系统里最复杂的环节因为它涉及多张表的写入和库存的扣减必须保证要么全部成功要么全部失败。这就是事务的作用。完整流程如下接收前端传来的购物车项ID列表和收货地址查询这些购物车项的详细信息商品ID、数量、价格校验每个商品的库存是否充足计算订单总金额开启数据库事务插入订单主记录插入订单明细记录扣减商品库存删除已下单的购物车项提交事务app.post(/api/orders, requireLogin, async (req, res) { const { cartItemIds, address } req.body; const userId req.session.userId; const connection await db.getConnection(); try { await connection.beginTransaction(); // 1. 查询购物车项 const cartItems await connection.query( SELECT * FROM cart_items WHERE id IN (?) AND user_id ?, [cartItemIds, userId] ); if (cartItems.length 0) { throw new Error(购物车项不存在); } // 2. 校验库存并计算总价 let totalAmount 0; for (const item of cartItems) { const product await connection.query( SELECT price, stock FROM products WHERE id ? FOR UPDATE, [item.product_id] ); if (product[0].stock item.quantity) { throw new Error(商品库存不足); } totalAmount product[0].price * item.quantity; } // 3. 插入订单 const orderResult await connection.query( INSERT INTO orders (user_id, total_amount, status, address) VALUES (?, ?, 0, ?), [userId, totalAmount, address] ); const orderId orderResult.insertId; // 4. 插入订单明细并扣减库存 for (const item of cartItems) { await connection.query( INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase) VALUES (?, ?, ?, ?), [orderId, item.product_id, item.quantity, item.price] ); await connection.query( UPDATE products SET stock stock - ? WHERE id ?, [item.quantity, item.product_id] ); } // 5. 删除购物车项 await connection.query( DELETE FROM cart_items WHERE id IN (?) AND user_id ?, [cartItemIds, userId] ); await connection.commit(); res.json({ orderId, totalAmount, message: 订单创建成功 }); } catch (error) { await connection.rollback(); res.status(400).json({ error: error.message }); } finally { connection.release(); } });这里有个关键细节查询库存时用了FOR UPDATE。这是数据库的行级锁能防止并发下单导致的超卖。假设两个用户同时买最后一件商品没有FOR UPDATE的话两个事务可能都读到库存为1然后都扣减结果库存变成-1。加了FOR UPDATE之后第二个事务会等第一个事务提交后才能读取此时库存已经是0了就会抛出“库存不足”。5.2 订单状态流转与前端展示逻辑订单状态不是随便改的必须按照业务规则流转。一个典型的电商订单状态机如下当前状态可流转到触发条件0 待支付1 已支付用户完成支付0 待支付4 已取消用户取消或超时未支付1 已支付2 已发货商家发货2 已发货3 已完成用户确认收货1 已支付4 已取消商家取消需退款前端展示时不同状态对应不同的操作按钮。待支付状态显示“去支付”和“取消订单”已支付状态显示“提醒发货”已发货状态显示“确认收货”已完成状态显示“再次购买”。function renderOrderActions(order) { const actions []; switch (order.status) { case 0: actions.push({ text: 去支付, action: pay }); actions.push({ text: 取消订单, action: cancel }); break; case 1: actions.push({ text: 提醒发货, action: remind }); break; case 2: actions.push({ text: 确认收货, action: confirm }); break; case 3: actions.push({ text: 再次购买, action: rebuy }); break; case 4: actions.push({ text: 删除订单, action: delete }); break; } return actions; }5.3 订单列表的分页查询与筛选实现订单列表页通常需要支持按状态筛选和分页。后端接口设计如下SELECT o.id, o.total_amount, o.status, o.created_at, GROUP_CONCAT(oi.product_id) as product_ids FROM orders o LEFT JOIN order_items oi ON o.id oi.order_id WHERE o.user_id ? AND (? IS NULL OR o.status ?) GROUP BY o.id ORDER BY o.created_at DESC LIMIT ? OFFSET ?;前端展示时每个订单卡片显示订单号、下单时间、总金额、状态标签和商品缩略图。点击卡片进入订单详情页展示完整的商品列表、收货地址和状态流转时间线。提示订单号建议用“日期随机数”的格式生成比如202401011200001234既保证唯一性又方便人工识别。不要用自增ID直接暴露给用户那样会泄露业务量信息。6. 实操中常见的坑与排查技巧6.1 登录状态丢失的三种典型原因原因一Cookie的SameSite属性。现代浏览器默认将Cookie的SameSite设为Lax跨域请求时不会携带Cookie。如果前端和后端部署在不同域名下需要在设置Cookie时显式指定SameSiteNone; Secure。原因二Token过期时间设置过短。有些开发者把JWT的过期时间设为5分钟用户刚登录没多久就失效了。建议设为24小时同时实现刷新Token机制。原因三前端请求没有带上凭证。使用fetch时默认不发送Cookie需要手动设置credentials: include。使用axios时需要设置withCredentials: true。// fetch示例 fetch(/api/cart, { credentials: include }); // axios示例 axios.defaults.withCredentials true;6.2 购物车数据不一致的排查思路购物车数据不一致通常表现为加购后数量不对、删除后刷新又出现、换页面后购物车空了。排查步骤打开浏览器开发者工具查看Application面板下的LocalStorage确认数据是否真的写入了检查加购逻辑中是用的push还是直接赋值有没有覆盖已有数据检查是否有多个地方同时操作购物车导致互相覆盖如果是存后端的检查接口是否返回了最新的购物车数据我遇到过一个典型案例用户在商品详情页加购后购物车图标上的数量没更新。原因是购物车数量是从LocalStorage读取的但加购操作写入了LocalStorage后没有触发页面重新渲染。解决方案是加购成功后手动更新一下购物车数量的显示或者用状态管理工具如Vuex、Redux统一管理。6.3 订单提交失败的错误码与用户提示对照表错误码含义用户提示排查方向4001购物车为空请先选择商品检查cartItemIds是否为空4002库存不足商品XXX库存不足查询商品当前库存4003商品已下架商品XXX已下架检查商品status字段4004地址为空请填写收货地址检查address参数5001数据库错误系统繁忙请稍后重试查看后端日志5002事务回滚订单创建失败检查事务中的每一步实操心得错误提示要具体到哪个商品出了问题不要只说“库存不足”。用户面对几十个商品不知道是哪个缺货会很抓狂。后端返回错误时带上商品名称前端直接展示。6.4 性能优化减少不必要的网络请求商城系统里有很多重复请求可以合并。比如购物车页面每次修改数量都请求一次后端用户快速点击时会产生大量请求。解决方案是防抖处理用户停止操作300毫秒后才发送请求。function debounce(fn, delay) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; } const updateCartQuantity debounce(async (cartItemId, quantity) { await fetch(/api/cart/update, { method: PUT, headers: { Content-Type: application/json }, body: JSON.stringify({ cartItemId, quantity }) }); }, 300);另一个优化点是商品图片。建议使用CDN加速并且根据展示尺寸生成不同分辨率的缩略图。列表页用200x200的小图详情页用800x800的大图不要一张原图走天下。7. 项目扩展方向与个人经验分享这套商城系统的基础版本跑通之后可以往几个方向扩展。支付模块是最自然的延伸可以接入模拟支付流程订单状态从待支付变为已支付。商品搜索可以加上关键词匹配和分类筛选后端用LIKE查询或全文索引实现。用户中心可以增加收货地址管理、个人信息修改、密码重置等功能。后台管理则是另一个大模块包括商品上架、订单处理、数据统计等。我个人在做这类项目时最大的体会是先把数据流画清楚再写代码。我早期做项目时喜欢直接打开编辑器就写写到一半发现购物车的数据结构不对又要回头改数据库表改完表又要改接口改完接口又要改前端连锁反应特别痛苦。后来我养成了一个习惯拿一张纸把用户从进入网站到完成下单的每一步画出来每一步需要什么数据、数据从哪来、存到哪里全部标清楚。这张纸画完了代码写起来就是填空。另一个经验是不要追求一步到位。第一版能跑通就行购物车先用localStorage支付先用模拟的订单状态先只做两三个。等核心流程验证没问题了再逐步替换和增强。我见过太多人一开始就想做“完整版”结果卡在某个细节上迟迟出不来成品最后不了了之。最后分享一个调试小技巧在开发阶段可以在后端加一个日志中间件把每个请求的URL、参数、返回状态都打印出来。这样前端说“接口报错了”你一眼就能看到是哪个接口、什么参数、什么错误不用来回猜。app.use((req, res, next) { const start Date.now(); res.on(finish, () { console.log(${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms); }); next(); });这个日志在开发阶段非常有用上线前记得关掉或者改成只记录错误级别的日志避免日志文件膨胀。