1. 为什么做这个项目:业务场景与功能拆解
前后端分离的华强北商城二手手机管理系统,SpringBoot做后端、Vue做前端、MyBatis负责数据库访问、MySQL存数据,这几个词连在一起,基本就是目前中小型管理信息系统最经典的组合。我在接手这个需求时,第一反应不是急着写代码,而是先想清楚一件事:二手手机交易和普通商品交易到底差在哪里。
普通商城的商品是标准品,一个SKU对应一个固定价格、固定规格,下单、支付、发货流程相对固定。二手手机不一样,它的核心难点在于“非标”:同样一款机型,因为成色不同、是否过保、是否有维修记录、电池健康度不同,价格能差出好几百甚至上千元。华强北这类市场里,货物周转速度快、价格波动频繁,商铺之间还在不停地调货,系统的核心诉求就变成了“把非标商品的状态管理清楚”:库存状态、质检状态、上下架状态、订单履约状态,任何一个环节对不上,都会引发纠纷。
这个项目解决的正是这类问题。它不是一个教学用的简单CRUD,而是一个具备真实业务背景的完整闭环系统,覆盖了从商品录入、成色定价、会员下单、订单审核到售后处理的完整流程。前端有商城门户和后台管理两套界面,后端按业务模块拆分成多个接口模块,数据库表结构围绕“商品—订单—会员—商户”四条主线展开。
什么人适合看这篇内容?如果你是刚学完SpringBoot和Vue基础、想找个完整项目练手的学生,这个系统能帮你把零散知识点串起来;如果你在企业里做内部管理系统,想参考一套成熟的前后端分离脚手架,那这里的目录结构、统一响应体、JWT鉴权、动态路由设计,至少能帮你省下两三天踩坑时间。我不打算只把源码目录贴一遍,而是会把我当时做技术决策时的思考过程写出来,为什么选这套方案、哪些地方容易翻车、上线前要改哪些配置,这些才是比源码本身更值钱的东西。
1.1 二手手机交易场景的特殊性
先聊业务,再聊技术。二手手机商城和卖新机的商城在功能表面上看差不多,都是商品列表、商品详情、购物车、订单结算,但深入一层就能发现几个明显的差异点。
第一个是价格体系复杂。新机的价格是厂家定的,区域代理之间差几十块已经是极限了;二手手机的价格跟行情走,同一天上午和下午的回收价、出货价都可能不一样。系统里不能只存一个固定的“销售价”,还得预留市场价、成本价、活动价多套价格字段,方便管理员随时调价。
第二个是商品属性不标准。新机只需要品牌、型号、存储容量、颜色这几个属性就能定位一款商品,二手手机还需要屏幕状态、边框磕碰、电池效率、是否拆修、配件是否齐全、有无ID锁这些维度。这些属性必须在商品模型里独立建字段,不能靠“备注”糊弄,否则商品列表筛选用起来非常痛苦。
第三个是订单履约链路长。二手订单经常要经过“买家下单→商户确认库存→质检复核→打包发货→买家验机→确认收货→售后跟踪”这些环节,每个环节都可能产生异常。比如库存明明显示有货,实际挑机时发现屏幕有细微划痕没录进去,这时候就得走改价或者取消流程。系统里的订单状态不能只设计成“待付款、已付款、已发货、已完成”四态,要更细。
第四个是角色分工必须要分清楚。华强北的典型场景是档口老板管商品、客服管订单、财务管对账,管理员统一管理。如果一套后台所有人都用同一个权限,数据安全和操作责任都说不清楚。所以系统里我一开始就把管理员和普通商户分开,菜单、按钮、接口三层都做了权限控制,后面接入真实业务时不用返工。
1.2 功能模块规划与页面结构
整个系统最终拆成了两大端:商城门户端和后台管理端。这个拆分既是业务需要,也是前后端分离架构的自然结果,前端项目分成两个工程入口,后端把接口统一暴露给两端调用。
商城门户端给买家使用,核心页面包括:首页(推荐商品与分类入口)、商品列表页(品牌、成色、价格区间筛选)、商品详情页(多图展示、成色参数、质检报告)、购物车、订单结算页、个人中心(订单列表、收货地址管理、退换货入口)。实际开发中我把列表和详情做得比较重,因为二手手机买家最在意的就是机况描述是否透明,图片和参数信息必须展示完整。
后台管理端给运营人员使用,模块包括:控制台(今日订单数、成交金额、待发货数量等统计卡片)、商品管理(商品录入、编辑、上下架、批量调价)、分类与品牌管理(机型字典维护)、订单管理(订单列表、详情、发货、取消审核)、会员管理(用户列表、状态禁用)、地址管理、系统管理(管理员账号、角色权限、操作日志)。其中商品管理和订单管理是后台使用频率最高的两个模块,页面交互要尽量高效,列表支持多条件筛选和分页,操作按钮不能藏得太深。
这套页面结构并不算复杂,但它是从真实业务流程中梳理出来的。比如“操作日志”这个模块,很多人做项目时会忽略,但二手手机交易纠纷率不低,谁在什么时候把价格改成了多少、谁把订单状态跳过了,都需要有迹可循。日志模块占用的开发量不大,但这是项目是不是“能用”和“好用”之间的一个分水岭。
1.3 技术选型为什么是这套组合
技术选型我几乎没有犹豫,直接定了SpringBoot + Vue + MyBatis + MySQL这套组合。不是因为它最先进,恰恰是因为它最稳。
后端用SpringBoot,理由很简单:生态成熟,内置Tomcat,自动装配省掉了大量XML配置,个人开发者和中小团队都能很快上手。SpringBoot的starter机制让引入依赖变得非常干净,添加一个Web功能只需要在pom.xml里加一个dependency,不用自己手动管理一堆通用配置。而且Spring社区的文档量巨大,项目中遇到任何异常,基本都能搜索到对应解法。
持久层用MyBatis而不是Spring Data JPA,是我基于这个项目的业务特点做的决定。二手商城的查询需求非常灵活,商品列表要根据品牌、成色、价格区间、有无拆修等多个条件组合筛选,订单模块要关联用户表、商品表、物流信息进行多表查询。MyBatis的SQL可以完全手写,动态SQL标签能根据条件拼装查询语句,这在复杂查询场景下比JPA的自动映射更直接可控。再者,MyBatis对现有开发者的吸引力一直很强,招聘市场对这门技术的需求量大,项目里用MyBatis也方便其他人接手维护。
前端选Vue,因为Vue的中文社区资料多、学习曲线平缓、单文件组件开发效率高。这个项目用了Vue2版本的成熟生态,配合Vue Router和Vuex,页面路由和数据状态管理都有了标准方案。项目对双向绑定和数据响应式的需求很强,比如后台商品编辑表单、购物车数量调整,Vue在这些场景下写起来很省心。
数据库用MySQL,则是因为使用者广泛、运维资料多、事务和锁机制成熟,ToB风格的管理系统用MySQL几乎不会出格。整套组合的思路是:不追求技术上的炫技,追求的是可靠、可维护、可招人接手。项目做到一半,你就能体会到,选一个社区生态繁荣的保守组合,比选一个个人喜欢但社区冷门的技术栈要幸福得多。
2. 数据库设计:二手手机特有的表结构
数据库设计是这个项目里最值得花时间的一步。很多初学者盯着一堆表不知道从哪下手,我的经验是:先理清业务实体之间的关系,再逐表设计字段。这个项目的核心实体其实就几个:用户、分类、商品、订单、订单明细、购物车、地址、操作日志。二手手机特有的细节,主要集中在商品表和订单表上,这两张表设计好了,后面的代码写起来就顺。
2.1 核心表结构总览
我先把整个项目的核心表列出来,这样你在看到代码之前就对数据模型有个整体概念。
| 表名 | 说明 | 核心字段 |
|---|---|---|
| user | 前台用户/后台管理员共用 | id, username, password, phone, role, status, create_time |
| category | 商品分类 | id, name, parent_id, sort_order |
| brand | 品牌表 | id, name, logo, status |
| product | 商品表 | id, category_id, brand_id, title, subtitle, cover_img, images, price, original_price, condition_grade, battery_health, is_locked, is_repair, ensure_status, stock, status, description, create_time, update_time |
| product_sku | 商品SKU表(可选,用于同机不同配置) | id, product_id, spec_desc, price, stock |
| cart | 购物车表 | id, user_id, product_id, quantity, checked, create_time |
| orders | 订单主表 | id, order_no, user_id, merchant_id, total_amount, pay_amount, pay_type, pay_status, logistics_company, logistics_no, status, remark, create_time, pay_time, ship_time, confirm_time |
| order_item | 订单明细表 | id, order_id, product_id, product_title, product_img, price, quantity, subtotal |
| address | 收货地址表 | id, user_id, receiver, phone, province, city, district, detail, is_default |
| operation_log | 操作日志表 | id, user_id, module, action, content, ip, create_time |
这张表里,user表我用了一个字段role来区分用户类型,比如0表示普通买家、1表示管理员、2表示商户,避免建一堆表。实际业务中如果角色权限再复杂,可以把角色拆成单独的表,但在这个项目里一个字段足够。
product表字段较多,其中condition_grade(成色等级)、battery_health(电池效率)、is_repair(是否拆修)、ensure_status(保修状态)这几个字段是二手手机业务特有的。设计时要注意,机况相关的字段建议用单独字段而不是塞到一个“参数”JSON里,因为列表页要做条件筛选,JSON字段没法用SQL高效查询。
订单主表与明细表分离,是电商表设计的铁律。订单主表存储订单级别的信息,比如总金额、支付状态、收货人;明细表存储每个商品项快照。为什么明细表要存快照字段(product_title、product_img、price),而不是直接关联商品表的ID查询?因为商品价格和标题后期可能修改,如果订单明细实时关联商品表,历史订单显示的价格和商品就可能对不上,明细表把下单那一刻的数据固化下来,才是完整的交易记录凭证。
2.2 商品表与机型规格设计
商品表是整个商城信息的核心。针对二手手机,我单独设计了几个字段来承载非标规格,你在建表时可以按下面的SQL来参考,关键字段都加了注释。
CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID', category_id INT NOT NULL COMMENT '所属分类ID', brand_id INT NOT NULL COMMENT '品牌ID', title VARCHAR(255) NOT NULL COMMENT '商品标题,如 iPhone 12 国行 128G', subtitle VARCHAR(255) DEFAULT '' COMMENT '副标题,如 95新 全原装', cover_img VARCHAR(255) NOT NULL COMMENT '封面图URL', images TEXT COMMENT '商品轮播图,JSON数组字符串', price DECIMAL(10,2) NOT NULL COMMENT '销售价', original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价/划线价', cost_price DECIMAL(10,2) DEFAULT NULL COMMENT '成本价,仅后台可见', condition_grade TINYINT NOT NULL DEFAULT 5 COMMENT '成色等级,99新=1到充新=6', battery_health INT DEFAULT NULL COMMENT '电池效率百分比,如92表示92%', is_locked TINYINT DEFAULT 0 COMMENT '是否有ID锁,0无1有', is_repair TINYINT DEFAULT 0 COMMENT '是否拆修,0无1有', ensure_status TINYINT DEFAULT 0 COMMENT '保修状态,0无保修1店铺保2官方保', stock INT NOT NULL DEFAULT 0 COMMENT '库存数量', sales INT NOT NULL DEFAULT 0 COMMENT '销量', status TINYINT NOT NULL DEFAULT 1 COMMENT '上下架状态,0下架1上架', is_delete TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', description TEXT COMMENT '详细描述', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手手机商品表';这里有个容易忽略的点,就是我加了is_delete逻辑删除字段。为什么不用DELETE直接删记录?因为订单明细里的快照字段在逻辑上还和商品有关联,如果物理删除商品,后面统计报表、订单查询都会出现“商品已不存在”的尴尬。逻辑删除让数据永远保留,列表页过滤掉is_delete=1的记录即可,这是ERP管理系统里最常见的做法。
成色等级我用TINYINT存数字而不是字符串。很多人喜欢直接存“99新”“95新”“充新”这种文本,短期看直观,长期看有几个问题:排序困难(充新应该排在哪?)、筛选条件不好写、后期如果要改等级名称就得UPDATE全表。用数字字典存储,前端根据数字映射成文本展示,这是一种反向规范化的取舍,值得学习。
图片字段images我存了JSON字符串。看到这里有人会质疑,为什么不用独立的商品图片表?对于单表数据量在十万以内的系统,JSON存多图是完全可行且简洁的方案,读取的时候解析一次就行;如果图片数量多、访问频繁、需要做图片管理,那再拆独立表也不迟。这个项目按JSON处理,给当前场景降低了复杂度。
2.3 订单状态机设计
订单表里的status字段,我定义了一串数字状态,每个状态的含义如下:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 用户已提交订单但未完成支付 |
| 1 | 待确认 | 已支付,等待商户确认库存并发货 |
| 2 | 待发货 | 商户确认库存,已发货或准备发货 |
| 3 | 已发货 | 物流已发出,等待买家收货 |
| 4 | 已完成 | 买家确认收货,订单完成 |
| 5 | 已取消 | 用户或管理员取消订单 |
| 6 | 退款中 | 售后申请已提交,等待处理 |
| 7 | 已退款 | 退款完成 |
为什么把普通电商的“待发货”拆成了“待确认”和“待发货”?这是专门为二手手机业务设计的。普通新机下单后可以直接打单发货,二手手机则要先确认库存和机况是否和描述一致,确认后再发货。待确认状态给商户留了一个缓冲带,发现问题可以直接电话沟通或发起取消,极大地降低了履约纠纷。实测下来这个状态在真实场景中每天都会用到。
订单号生成也不建议用数据库自增ID直接给用户看,生成长订单号更合适。我用了“yyyyMMddHHmmss + 6位随机数”的格式,既能保证一定的唯一性,也方便用户向客服报单号时快速口头阅读。
2.4 索引与事务注意点
表设计完之后有几个索引细节必须留意。商品表的热门查询场景通常是状态+分类+品牌+价格排序,所以建议建一个联合索引(keyword在后续开发中可以考虑做全文索引):
ALTER TABLE product ADD INDEX idx_status_category_brand (status, category_id, brand_id); ALTER TABLE product ADD INDEX idx_create_time (create_time); ALTER TABLE orders ADD INDEX idx_user_id (user_id); ALTER TABLE orders ADD INDEX idx_order_no (order_no); ALTER TABLE order_item ADD INDEX idx_order_id (order_id);订单表按user_id索引,是因为个人中心查“我的订单”非常高频;order_no索引用于客服查询。注意不要把索引加得过多,写多的系统索引太多会拖慢插入和更新速度,够用就好。
事务方面,用户下单时涉及多个表的写操作:扣减商品库存、生成订单主表记录、生成订单明细记录、清空购物车对应项。这四步必须在一个事务里执行,任何一步失败都要整体回滚,否则会出现库存扣了但没有订单,或者订单生成了但库存没扣的脏数据。Spring里用@Transactional注解标记下单方法是最简洁的写法,但要注意事务失效的几个坑,比如方法被同类内部调用、异常被catch吞掉没有抛出RuntimeException等,后面在踩坑章节我会详细讲。
3. 后端核心实现:SpringBoot + MyBatis落地细节
后端代码是整个系统的大脑,也是工作量最重的部分。我严格按照分层思想组织代码,Controller只做参数接收和结果返回,Service层写业务逻辑,Mapper层负责数据库操作。这样做的好处是职责清晰,后续维护时定位问题非常快。
3.1 分层结构与统一响应体
项目包结构如下:
com.market ├── controller # 前端控制器,接口入口 ├── service # 业务逻辑层 │ └── impl # 业务实现类 ├── mapper # MyBatis数据访问层 ├── entity # 实体类,对应数据库表 ├── dto # 数据传输对象,用于接收前端参数 ├── vo # 视图对象,用于返回前端结果 ├── common # 通用类,如统一响应、常量 ├── config # 配置类,如拦截器、跨域配置 ├── utils # 工具类,如JWT工具、日期工具 └── exception # 自定义异常与全局异常处理Controller层的接口全部返回统一的Result对象,结构如下:
public class Result<T> { private Integer code; // 2008成功,其他为失败 private String message; // 提示信息 private T data; // 数据体 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }统一响应体的价值在前后端分离项目中格外突出。有了这个Result,前端axios响应拦截器只需要判断code是否为200,是的话取data操作DOM,不是的话弹全局错误提示,不需要在每个接口里写一套error处理逻辑。状态码我建议用200/500这类简单数字,没必要完全照抄HTTP标准状态码,内部系统传递一个业务状态码就够了。
3.2 登录鉴权与接口安全
后台接口不能裸奔,必须要做登录校验。我这里采用了比较经典的JWT方案。用户登录成功后,后端生成一个带用户ID和角色信息的token返回给前端,前端存储token并在每次请求的header里携带,后端通过拦截器统一校验。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("token"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } // 解析token,校验签名 Claims claims = JwtUtil.parseToken(token); // 将用户ID存入request,供后续业务使用 request.setAttribute("userId", claims.get("userId")); return true; } }配置好拦截器后,还需要在WebMvcConfig里注册它,并指定放行路径,比如登录接口、商品列表接口、商品详情接口都不能拦截,否则用户没登录连商品都看不了。注册拦截器的代码如下:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/product/**", "/error"); } }这里有一个非常关键的细节:拦截器放行了/api/product/**,意味着任何人都能调商品列表和详情接口。如果你希望某些数据必须登录才能看,就需要在Service层二次校验当前请求里的userId,不要指望拦截器一拦了事。业务层面的权限判断,永远不能只靠路由拦截来兜底。
密码存储方面,绝不能明文保存,我用的是BCrypt加密。BCrypt的特点是同一条明文密码每次生成的hash值都不同,而且自带盐值,破解成本高,Spring Security中包含这个工具类,即使不引入整个Security框架也可以单独使用。登录校验时用matches方法比对明文和hash是否匹配。
3.3 商品与订单接口设计要点
商品列表接口是后台访问量最大的接口,设计时直接决定用户体验。我是这样设计的,请求参数包括:pageNum(页码)、pageSize(每页条数)、keyword(搜索关键词)、categoryId、brandId、priceMin、priceMax、conditionGrade、sortType(按最新/价格/销量排序)。
后端Service层对应逻辑是:先根据条件拼装动态SQL查询列表,再单独查询总条数完成分页。这个接口要特别注意排序字段白名单问题,如果前端把排序字段名直接拼到SQL里,会存在被注入SQL的风险。我的处理方式是:前端传sortType的枚举值(如1表示最新、2表示价格升序、3表示价格降序、4表示销量降序),后端用switch转换为自己拼接的ORDER BY片段,绝对不直接拼接前端传过来的字符串。
订单相关的接口设计里,下单是核心。参考代码如下:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 幂等性判断,防止用户重复提交 String orderToken = dto.getOrderToken(); if (stringRedisTemplate.hasKey(orderToken)) { throw new BusinessException("请勿重复提交订单"); } stringRedisTemplate.opsForValue().set(orderToken, "1", 30, TimeUnit.MINUTES); // 计算订单总金额(注意必须从数据库读取价格,不能信任前端传的价格) BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> itemList = new ArrayList<>(); for (OrderItemDTO item : dto.getItems()) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } if (product.getStock() < item.getQuantity()) { throw new BusinessException("商品库存不足"); } totalAmount = totalAmount.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); // 构建订单明细快照 OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductTitle(product.getTitle()); orderItem.setProductImg(product.getCoverImg()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setSubtotal(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); itemList.add(orderItem); // 扣减库存 productMapper.decreaseStock(product.getId(), item.getQuantity()); } // 生成订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setStatus(0); orderMapper.insert(order); // 批量插入订单明细 orderItemMapper.batchInsert(order.getId(), itemList); // 清空购物车中已下单的商品 cartMapper.deleteByUserIdAndProductIds(dto.getUserId(), dto.getProductIds()); return buildOrderVO(order); }注意几个细节:计算金额时价格必须从数据库读取,绝对不能信前端传来的商品单价,否则用户改一下请求体就能低价买货,这是电商系统的安全底线;库存扣减SQL应该是原子操作的UPDATE语句,形如UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},靠数据库行锁保证并发安全。
3.4 MyBatis多表联查与SQL优化
商品列表接口我用了动态SQL来拼装查询条件,这是MyBatis的精髓。核心SQL片段如下:
<select id="selectProductPage" resultType="com.market.vo.ProductVO"> SELECT p.id, p.title, p.subtitle, p.cover_img, p.price, p.original_price, p.condition_grade, p.battery_health, p.sales, b.name AS brand_name FROM product p LEFT JOIN brand b ON p.brand_id = b.id <where> p.is_delete = 0 <if test="keyword != null and keyword != ''"> AND (p.title LIKE CONCAT('%', #{keyword}, '%') OR p.subtitle LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="brandId != null"> AND p.brand_id = #{brandId} </if> <if test="priceMin != null"> AND p.price >= #{priceMin} </if> <if test="priceMax != null"> AND p.price <= #{priceMax} </if> <if test="conditionGrade != null"> AND p.condition_grade = #{conditionGrade} </if> <if test="status != null"> AND p.status = #{status} </if> </where> ORDER BY <choose> <when test="sortType == 1">p.create_time DESC</when> <when test="sortType == 2">p.price ASC</when> <when test="sortType == 3">p.price DESC</when> <when test="sortType == 4">p.sales DESC</when> <otherwise>p.create_time DESC</otherwise> </choose> </select>注意在XML里小于号要写成< 和 >,否则会被XML解析器当成标签报错。尽量用 标签而不是手动拼WHERE 1=1,前者会自动处理条件为空时去掉冗余的WHERE关键字,生成的SQL更干净。
商品列表和详情这种读多写少的接口,如果数据量上来了,第一优化手段是加Redis缓存。我建议把热门商品详情缓存到Redis,key设计为product:detail:{id},缓存10到30分钟。注意缓存更新时效:管理员后台编辑商品后要主动删除对应缓存,否则用户看到的还是旧数据。这套“被动失效”策略在管理系统中比“设置定时刷新”更精准、更常见。
4. 前端Vue实现:页面与接口联调
前端项目我分成了两个工程:前台商城端和管理后台端。相同点是都用Vue2 + Vue Router + Vuex + ElementUI/Ant Design Vue,不同点是商城端更注重列表筛选和商品展示,后台端更注重表格表单的交互效率。这里把前端的关键实现思路拆开讲。
4.1 项目结构与路由设计
前端src目录结构如下:
src ├── api # 接口定义目录 │ ├── product.js # 商品相关接口 │ ├── order.js # 订单相关接口 │ └── user.js # 用户相关接口 ├── router │ └── index.js # 路由配置 ├── store │ └── index.js # Vuex状态管理 ├── views │ ├── home # 首页 │ ├── product # 商品列表与详情 │ ├── cart # 购物车 │ ├── order # 订单相关页面 │ ├── user # 个人中心 │ └── admin # 后台管理页面 ├── components # 通用组件 ├── utils │ └── request.js # axios封装 └── App.vue路由设计上,前台路由用默认的history模式,后台路由加了一个/mgmt前缀作为后台模块的访问入口。为了做登录控制,我注册了全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); return; } if (to.path.startsWith('/mgmt') && !token) { next('/login'); return; } next(); });后台管理页面除了登录校验,还要做角色校验,只允许role为admin的用户进入。做法是在meta里配置roles数组,守卫里判断当前用户角色是否包含该值,不匹配就redirect到403页面。菜单权限和按钮权限同理,前端菜单渲染时根据角色数组过滤,按钮用自定义指令判断是否显示。前端权限控制的意义不是安全,而是体验,真正的接口安全一定要靠后端。
4.2 axios封装与接口管理
axios封装这步几乎每个项目都要做,我的request.js核心逻辑如下:
import axios from 'axios'; import { Message } from 'element-ui'; import router from '@/router'; const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }); // 请求拦截器:统一携带token service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['token'] = token; } return config; }, error => { return Promise.reject(error); }); // 响应拦截器:统一处理业务状态码 service.interceptors.response.use(response => { const res = response.data; if (res.code !== 200) { Message.error(res.message || '请求出错'); if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(new Error(res.message)); } return res.data; }, error => { Message.error(error.message || '网络连接失败'); return Promise.reject(error); }); export default service;企业项目里,接口定义和调用分离很重要。我在api目录下按业务域名拆分模块,一个业务模块一个文件,比如API/product.js文件内统一导出:getProductList(params)、getProductDetail(id)、updateProductStatus(data)等。页面组件里只import对应函数,不直接写URL字符串,这样做的好处是后端接口路径变更时,只需要改api目录里一个文件,不用全局搜索替换。
baseURL我放在环境变量文件.env.development里配置为http://localhost:8080/api,.env.production里配置为/api。这样开发环境走代理,生产环境走Nginx反向代理,前端代码本身不需要改任何东西。
4.3 商品列表与详情页实现要点
商品列表页是整个商城门户的门面,开发时注意几个点。
第一,筛选条件联动。品牌下拉框的数据来自brand接口,成色条件来自常量字典,价格区间用两个输入框组合。点击筛选时重新调用商品列表接口,并用watch监听筛选条件变化自动触发。这里要避免一个坑:每次筛选都全量刷新,会让用户觉得页面卡顿。我的做法是分页组件独立维护currentPage,筛选变化时重置currentPage为1再请求。
第二,图片处理。二手手机的封面图和详情图往往比较多,页面加载时不能一次性全塞进来。我在商品卡片组件里用vue-lazyload做懒加载,通过给img标签加v-lazy指令替代src属性,滚动到可视区域才加载图片,列表页性能提升明显。
第三,成色信息的展示。详情页里,成色等级、电池健康度、是否拆修、是否有锁这几个字段要放在显眼位置,并且用图标和颜色区分,比如电池健康度高于90用绿色、低于80用黄色提示。原因很简单,买家最关心机况,展示得越透明,下单转化率越高。
订单结算页的核心是金额计算,购物车里勾选的商品自动汇总总金额,前端只是实时展示,真正下单时后端会重新校验价格并把最终金额返回给前端。
4.4 后台管理页面与权限控制
后台管理页面我用了管理后台组件库,商品管理页表格展示、搜索表单、分页组件、新增编辑弹窗这四大块是标配。
商品管理里有一个比较高效的交互:批量调价。管理员从表格勾选多个商品后,点击“批量调价”按钮,弹窗里输入调价比例或固定金额,前端把选中的ID数组和调价参数传给后端,后端在一个事务里循环更新并把新价格返回。这个功能看似简单,实际极大提升了运营效率,二手手机行情波动大,每天可能要对几十上百台机器调价,一个个改会疯掉的。
订单管理页面要注意操作按钮的状态控制。根据订单当前状态动态渲染可进行的操作,比如待确认状态只显示“确认发货”和“取消订单”按钮,已发货状态只显示“查看物流”按钮。按钮置灰逻辑写在前端只是让界面清晰,后端接口同样要校验状态是否允许该操作,两端双重校验。
角色权限方面,我在后台路由表里配了meta.roles,例如admin用户能访问系统管理页,普通商户只能访问商品和订单页。路由守卫里判断当前用户角色,没有权限时跳转403页面。按钮级权限用了一个自定义指令v-permission="'product:delete'",如果用户角色没有该权限码,指令就把对应按钮从DOM里移除。这套方案在中小系统里够用且直观。
5. 本地部署与上线教程
这部分应该是标题党最想看的核心内容,源码拿到手第一步就是让它跑起来。我把从零开始部署的完整过程写一遍,你照着操作就行。无论你用的是Windows还是Mac,流程都一样。
5.1 环境准备与初始化
本地运行这套系统,需要准备的软件如下:
| 软件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 1.8及以上 | 运行SpringBoot项目 |
| Maven | 3.6及以上 | 后端依赖管理 |
| MySQL | 5.7及以上 | 数据库 |
| Node.js | 14及以上 | 前端构建工具 |
| IDE | IDEA或VSCode | 开发调试 |
| Navicat | 任意版本 | 数据库连接管理 |
安装顺序建议:先MySQL,再JDK,再Maven,再Node。用Navicat连接本机MySQL,执行源码包里的shop.sql脚本,把数据库和数据表一次性创建好。sql里我如果没有写创建数据库的语句,你就要手动先建一个名为second_hand_shop的库,默认字符集utf8mb4,然后再运行sql脚本。执行完后确认一下表数量是否和文档一致,表名都对得上,就说明导入成功。
5.2 数据库导入与后端启动
拿到源码后,找到后端根目录,里面应该有一个application.yml配置文件。关键配置项如下:
server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/second_hand_shop?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.market.entity这里最容易出错的是时区配置。MySQL连接串里的serverTimezone=Asia/Shanghai一定要加上,否则Java 8以上的驱动会报时区错误。密码也要改成你自己MySQL的root密码。
配置改好后,在项目根目录打开终端命令,执行启动:
mvn clean package -DskipTests如果本地没有安装Maven命令,可以用IDEA打开后端项目,等Maven下载完依赖,直接在run按钮启动。启动成功的标志是控制台出现“Started Application”字样。这里建议先用maven package打包,因为后面部署jar包时你也会用到这个命令。
5.3 前端启动与登录验证
数据库导入、后端启动成功之后,再启动前端。前端源码在web目录或者叫front目录里,进入目录执行:
npm install npm run servenpm install的过程比较漫长,首次安装可能要几分钟,依赖下载慢的话建议配置一下npm镜像源再执行。启动成功后终端会打印一个地址,一般是http://localhost:8081,因为后端已经占了8080端口,Vue cli默认会往后找一个空闲端口。用浏览器打开这个地址,如果能看到商城门户首页,说明前后端联调已经走通了七七八八。
验证登录功能时,可以用源码自带的初始账号,管理员账号一般在SQL脚本里初始化过,比如用户名admin,密码123456。登录成功后,浏览器开发者工具里能看到token存储在localStorage中,并且后续请求的请求头都带着这个token,说明整体鉴权链路正常。
5.4 生产环境打包与Nginx配置
本地跑通只是第一步,上线可没那么简单。生产环境我采用前后端分离部署的经典模式:前端构建成静态文件由Nginx托管,后端打成jar包用Java命令运行,Nginx再把/api开头的请求反向代理到后端端口。
前端打包命令:
npm run build打包产物在dist目录,里面的文件就是纯静态资源。把dist目录的内容上传到服务器,放到一个指定目录,比如/opt/www/market。
后端打包在5.2里已经做过,target目录下会生成一个market.jar文件。上传到服务器后用如下命令后台启动:
nohup java -jar market.jar --spring.profiles.active=prod > market.log 2>&1 &prod配置文件和本地不同,主要是数据库地址换成了线上地址,端口保持8080不变。日志重定向到market.log方便排查问题。
Nginx配置参考如下:
server { listen 80; server_name your.domain.com; client_max_body_size 50m; root /opt/www/market/dist; index index.html; # 解决Vue history路由刷新404 location / { try_files $uri $uri/ /index.html; } # 反向代理后端接口 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 图片资源缓存 location ~ .*\.(jpg|jpeg|gif|png|webp)$ { expires 7d; } }最值得注意的是try_files这行配置。Vue的history模式路由在单页面应用里看起来像真实路径,比如/mgmt/product,但服务器上实际没有这个目录。刷新页面时Nginx如果找不到对应文件,不配置try_files就会返回404。配置了try_files后,所有路径都回退到index.html,再由前端路由接管,刷新就不会白屏了。
另外有个容易踩的坑:location /api/的proxy_pass末尾带了/api/,后面不带也可以,但一旦写了带路径的proxy_pass,记住路径拼接规则差异很大。上面的写法会把原始请求路径完整转发给后端,即前端请求/api/product/list,后端收到的是/api/product/list,和后端context-path正好对上。这个细节配错,会出现接口404。
6. 踩坑实录与常见问题速查
这部分是我在实际部署和运行这个项目时遇到过的真实问题,全写在下面,你遇到类似报错直接对照复制解决方案,能省下大量排查时间。
6.1 前端接口跨域报错
现象:浏览器控制台出现“No 'Access-Control-Allow-Origin' header is present on the requested resource”,请求失败。
原因分析:开发环境下前端运行在8081端口,后端运行在8080端口,端口不同就构成跨域。浏览器出于安全策略会拦截跨域请求,解决办法常见有两种。
解决方案一,在后端加全局跨域配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }解决方案二,在Vue项目的vue.config.js里配置开发代理:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }我的建议是开发环境用代理方案,因为生产环境本身就是Nginx同源代理,保持一致更自然。如果用了代理方案,axios的baseURL直接写成'/api',不要再写成本机地址。
6.2 MyBatis查询结果字段映射不上
现象:接口返回的数据里,某些字段为null,但数据库里明明有值。
原因分析:MyBatis默认开启驼峰转下划线映射吗?默认是不开启的。数据库里的create_time字段无法自动映射到Java实体类的createTime属性,除非配置文件里开启map-underscore-to-camel-case。
解决方案:
mybatis: configuration: map-underscore-to-camel-case: true如果你的项目不想全局开启,也可以在Mapper XML里显式写ResultMap,把数据库列和实体属性一一对应。但既然整个项目都遵循下划线命名规范,全局开启驼峰映射是最省事的方案。这个配置我最初漏掉时,列表接口返回的所有时间字段全是null,排查了很久才发现是映射问题。
6.3 前端打包后路由刷新404
现象:本地开发时路由切换正常,部署到Nginx后,进入某个子页面刷新一下就白屏或者404。
原因分析:这就是前面Nginx配置里提到的history路由刷新问题。Vue Router的history模式依赖HTML5 History API,路由切换是在前端完成的,但服务器没有这些物理路径,刷新时服务器尝试找对应文件找不到就404了。
解决方案:在Nginx配置的location /块中加上try_files $uri $uri/ /index.html,让所有路由都回退到index.html。如果你用的是Apache服务器,对应配置是FallbackResource /index.html。另外还有一个替代方案:前端改用hash模式,URL带#号,不会触发刷新404,但不美观,个人不建议。
6.4 数据库中文乱码
现象:商品录入后,中文标题在后台显示正常,但在前端页面显示乱码;或者SQL文件导入后中文全部成了问号。
原因分析:字符集不一致。数据库连接串没有指定characterEncoding,或者表结构创建时用了latin1。
解决方案:保证三层字符集一致。数据库连接串加?useUnicode=true&characterEncoding=utf8mb4;建库语句用DEFAULT CHARSET=utf8mb4;SQL导入工具导入时选择utf8mb4字符集。utf8mb4比utf8多了对emoji表情的支持,手机商品描述里写表情字符时不会报错,所以推荐在MySQL 5.5以上版本优先用utf8mb4。
6.5 后端jar启动内存不足
现象:服务器上执行java -jar market.jar后,进程启动一会儿就被系统杀掉,日志里出现OutOfMemoryError。
原因分析:1G内存的小服务器上,JVM默认堆内存可能过大,而且nohup启动方式如果日志输出不重定向到文件,终端关闭后进程也可能被挂断。
解决方案:启动时显式指定堆内存大小,并在后台运行:
nohup java -Xms256m -Xmx512m -jar market.jar --spring.profiles.active=prod > market.log 2>&1 &-Xms是初始堆大小,-Xmx是最大堆大小。生产环境建议根据服务器实际内存来定,通常256m到512m对于这种规模的管理系统完全够用。注意日志一定要重定向到文件里,否则nohup默认输出到nohup.out也能看,但文件会无限增长,建议定期清理。
6.6 下单后库存没有扣减或重复扣减
现象:并发测试时,用户同时下单同一件商品,数据库库存出现负数,或者同一订单被创建了两条。
原因分析:扣减库存的SQL没有做条件和锁控制,或者下单接口被并发重复请求。
解决方案:扣库存SQL必须写成条件更新,参考如下:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这条SQL执行后返回受影响行数,如果返回0说明库存不足,直接抛异常回滚事务。注意这里不先查库存再更新,因为这个过程存在时间窗口,两个请求同时查到库存1,都执行UPDATE,库存可能变成负数。条件更新依靠数据库的行锁串行化写入,从根本上避免超卖。订单幂等性方面,前端提交订单时生成一个一次性token,后端通过Redis判断该token是否被使用过,用过的直接拒绝,这个我在前面下单代码示例里已经展示了。
7. 项目经验沉淀与后续扩展方向
这个系统做完之后,我自己复盘了一下,觉得有几个经验可以分享给正在做同类项目的人。
第一,技术栈一定要服务于业务。这个项目如果一开始就引入微服务、分布式事务、消息队列,开发周期至少翻三倍,而且对二手手机商城这个体量来说毫无必要。单体应用加合理分层,在数据量不大、并发不高的阶段是最优解。遇到性能瓶颈再针对热点模块优化,比如给商品详情加缓存、给订单状态用队列解耦,而不是一上来就全副武装。
第二,数据库设计是项目的灵魂,代码反而好写。我见过太多项目做到一半发现表结构不合理,代码越写越别扭,最后只能推倒重来。这次我花了大半天时间把表结构反复调整,包括成色字段用数字字典、订单明细存快照、商品加逻辑删除标记,这些决策在后来的编码阶段帮了大忙,几乎没因为数据问题返工。
第三,权限设计不用做太复杂。RBAC(角色-权限模型)在这个项目里已经用上了,后台通过用户角色控制菜单和按钮,前台买家和管理员天然分成两个域。如果业务规模再扩大,可以引入更细粒度的策略规则,但就目前这个场景,过度设计反而是负担。
这个项目的源码结构、统一响应体、JWT鉴权、MyBatis分页查询技术,可以直接抽取出来,作为下一个管理类项目的起步脚手架。后续扩展方向我建议优先做三件事:一是引入Redis缓存热点商品和用户Token,提升并发能力;二是对接对象存储,把商品图片从本地路径改为云存储,服务器部署会轻松很多;三是把支付模块从虚拟支付升级为真实支付渠道,前端需要增加支付回调页面,后端要对接支付回调验签逻辑。每一步扩展都不会伤筋动骨,因为这个项目的基础结构足够健壮。
最后再分享一个小技巧:如果拿到源码后想快速在里面加一个新功能,最稳妥的路径是顺着现有代码仿写一遍。比如要加一个“品牌管理”页面,你就去看“分类管理”是怎么从数据库到Controller再到Vue页面跑通的,照着同样的模式改一改,比从零自己摸索要快得多。项目代码不怕重复,最怕乱改约定。保持命名的统一、分层的统一、返回格式的统一,这个系统才能一直维持在一个好维护的状态。