
做办公用品采购系统的念头多半是从一箱打印纸开始的。我之前在一家创业公司做系统开发行政妹子每个月都在各个电商平台之间来回比价一箱A4纸的价格差个几块钱都要折腾半天更别提不同部门对硒鼓、笔芯、文件夹的需求五花八门。后来项目组内部就拍板干脆自己做一套“前后端分离的日常办公用品直售推荐系统”技术栈锁定为SpringBootVueMyBatisMySQL。这不是那种撑场面的大项目但胜在业务链路完整商品直售、库存管理、购物车、订单流转、搭配推荐一个不少用来学习前后端分离架构、练手部署、甚至作为简历上的项目经验都非常能打。这篇文章我把整个系统的设计思路、核心代码片段、数据库表结构、推荐模块的技术落地以及从零到一的部署Linux服务器并跑通的完整过程全部拆开说顺便附上那些文档里不会写的踩坑心得。如果你正准备做一个类似的“前后端分离直售推荐”方向的项目这篇内容可以直接抄作业。1. 系统定位与业务模块拆解一套直售系统应该长什么样做项目第一步不是写代码是先把业务范围圈清楚。办公用品直售推荐系统和普通电商系统有一个显著区别它不需要太复杂的营销体系但非常看重品类管理、库存精度和内部采购流程的留痕。我设计的这套系统使用者分三种角色对应三套完全不同的操作界面和权限边界。管理员管商品、管分类、管库存、管用户、管订单状态、管首页推荐位的配置。管理员是整个系统里唯一能修改商品上下架状态的人。采购员本质上就是普通登录用户他们浏览商品、搜索、加购、下单、查看自己的订单记录和物流状态。采购员还拥有一个额外的入口——在个人中心提交“采购申请单”这属于办公用品系统里比较独特的审批辅助功能。系统游客只能在门户页面浏览推荐商品列表可以查看商品详情但所有写操作都被拒之门外。功能模块上我把它拆成五个核心域模块域核心功能备注商品域商品分类、SPU/SKU、上下架、库存扣减支持多图展示规格参数用JSON存储便于扩展用户域注册、登录、JWT鉴权、个人信息密码加密存储不存明文交易域购物车、订单提交、订单列表、订单状态流转订单超时未支付自动取消释放库存推荐域首页热销榜、新品推荐、基于品类偏好的个性化推荐推荐结果数据落库缓存避免每次实时计算管理域商品管理、订单管理、用户管理、数据统计管理端接口和用户端接口完全独立通过角色权限拦截这个拆法决定了后续所有接口的设计粒度前端页面不会直接拼接多个接口的数据每个页面在后端都有一个聚合接口比如“首页门户数据接口”一次性返回轮播图、热销榜单和个性化推荐列表减少前端并发请求的压力。办公用品直售和服装、数码产品电商最大的不同在于复购率高、品类集中、决策链路上对价格不太敏感但对“缺货”极为敏感。所以整个系统设计时库存字段的准确性被提到了最高优先级下单扣库存、超时释放库存、手动取消回补库存这三个操作都必须在事务里完成。2. 前后端分离架构设计与接口管理为什么是SpringBootVueMyBatis这套组合标题里写的是“前后端分离”这不仅是项目结构上的前后端目录拆分更重要的是开发模式、部署形态和交互协议的三重分离。2.1 技术选型的底层逻辑我平时做内部管理系统比较多如果业务规模不超过中小型、日均并发几百SpringBootVueMyBatisMySQL这套组合无论在开发效率、维护友好度还是生态成熟度上都依然是最稳的选择。SpringBoot解决了Spring框架配置繁琐的问题起步依赖和自动配置极大降低了环境搭建成本。内嵌Tomcat打成一个jar包就能跑配合Docker或者systemd做进程守护非常方便。Vue采用前端工程化的方式管理使用Vue CLI或Vite构建配合Element Plus组件库表单、表格、弹窗这些后端管理系统中高频出现的组件开箱即用节省大量UI时间。MyBatis直售系统查询逻辑复杂报表统计、多条件商品筛选、订单列表分页这类动态SQL非常频繁MyBatis的灵活SQL控制是明显优于全自动ORM的。如果你之前只用过Spring Data JPA第一次用MyBatis可能会觉得SQL要手写很麻烦但用了三个月之后你会理解这种“半自动”的掌控感。MySQL存储业务结构化数据商品信息、用户表、订单表等配合事务支持保证订单和库存的数据一致性。社区版免费部署资料多坑少。2.2 项目目录结构设计后端我采用了Maven多模块还是单模块最终选择的是单模块多包结构因为项目规模控制在十张表左右多模块的拆分收益不明显但包的层次要清晰com.example.office ├── config // 跨域配置、自定义配置、拦截器注册 ├── controller // 控制层按模块分包admin, portal, order, user ├── service // 业务接口与实现类 ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体 ├── dto // 前端交互数据对象注意与entity分离 ├── common // 统一返回结果、异常处理、工具类 └── security // JWT拦截器、注解权限控制前后端分离项目中很多人喜欢直接把数据库实体丢给前端我强烈不建议这么做。实体类往往会包含status、createTime、updateTime这类前端不需要甚至不应该看到的字段而且一旦数据库表结构调整前端传参会跟着出问题。正确做法是定义DTO层作为前后端交互的“契约对象”。2.3 接口设计与统一返回结构系统里所有接口统一采用POSTJSON的交互方式因为这部分接口面向的是内部业务系统不需要刻意走RESTful风格来体现资源语义POST统一的请求包装更务实前端处理也更简单无需为GET、DELETE、PUT各自处理不同的传参格式。统一返回结构是这个项目能够顺利前后端联调的关键Data public class ResultT { private Integer code; // 200成功500业务失败401未授权 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 500; r.message msg; return r; } }前端封装的axios拦截器里只需要判断code字段然后做统一的响应处理。登录过期就是code为401前端直接清空本地token跳转登录页。2.4 跨域问题的处理前后端分离部署通常会跨不同端口SpringBoot中我推荐使用CorsFilter配置而不是在每个Controller上加CrossOrigin注解。后者一旦某个类忘了加注解前端就会比较痛苦地排查半天。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList(*)); config.setAllowedMethods(Arrays.asList(GET, POST, DELETE, PUT, OPTIONS)); config.setAllowedHeaders(Arrays.asList(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意当allowCredentials为true时allowedOrigins不能用*必须用setAllowedOriginPatterns或明确指定域名否则浏览器会直接拦截这个细节坑了不少人。3. 数据库表设计从商品SPU到订单状态机的完整链路直售系统的数据库设计说难不难但有几个容易忽略的问题产品规格如何设计、库存字段放在哪、订单状态怎么流转、推荐数据要不要落表。我这里给出一套实际可用、且带演示数据的核心表结构。3.1 商品与分类商品分类采用两级结构大类书写工具、纸品本册、桌面收纳、办公设备、耗材配件 小类直液式中性笔、墨水、A4复印纸、长尾夹等。用parent_id做自关联就能支持无限级分类但为了查询简单只用到两级。核心商品表CREATE TABLE tb_product ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL COMMENT 分类ID, name varchar(100) NOT NULL COMMENT 商品名称, sub_title varchar(200) DEFAULT COMMENT 卖点副标题, main_image varchar(255) DEFAULT COMMENT 主图地址, detail_images text COMMENT 详情图URL多个用逗号分隔, price decimal(10,2) NOT NULL COMMENT 销售价格, stock int NOT NULL DEFAULT 0 COMMENT 库存, brand varchar(50) DEFAULT COMMENT 品牌, sales_count int NOT NULL DEFAULT 0 COMMENT 累计销量, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个容易被忽略的字段是sub_title和sales_count。前者用于推荐模块的展示文案后者用于热销榜单排序每次下单成功后都会执行UPDATE tb_product SET sales_count sales_count 1推荐模块直接读这个字段比实时从订单表里统计聚合要快得多。3.2 购物车和订单购物车表比较常规user_id、product_id、quantity、checked是否勾选、create_time。但这里有个问题购物车里商品价格如果从页面传入会被恶意请求篡改所以后端在生成订单时必须重新从tb_product表读取价格购物车里的价格仅作为展示。订单表建议拆成主表和明细表CREATE TABLE tb_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, receiver_name varchar(50) NOT NULL, receiver_phone varchar(20) NOT NULL, receiver_address varchar(255) NOT NULL, pay_time datetime DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单状态流转是一个状态机待支付可以取消也可以支付已支付可以发货已发货可以确认完成。这里特别新增了一个自动取消逻辑下单后超过30分钟未支付状态改成已取消同时回补库存。实现方式不依赖定时任务抢锁用的Spring的Scheduled每分钟扫描一次。3.3 推荐数据落表推荐系统如果不落表每次用户打开首页都要实时算一次推荐结果数据库压力大不说响应还会慢上几百毫秒。我的做法是用定时任务每日凌晨计算一次“用户当日推荐池”存入推荐表用户访问时直接查表返回定时任务结束后清空旧数据。CREATE TABLE tb_recommend ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, product_id bigint NOT NULL, reason_type tinyint NOT NULL COMMENT 1热门 2新品 3品类偏好 4关联搭配, score decimal(10,4) NOT NULL COMMENT 推荐分数越大越靠前, create_date date NOT NULL, PRIMARY KEY (id), KEY idx_user_date (user_id, create_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4. 推荐模块的算法落地从热门榜到个性化推荐的思路转化标题中的“推荐系统”是核心亮点但考虑到实际运行环境我没有引入一套完整的Spark或向量化推荐而是采用了一个分层递进的规则权重推荐策略效果够用代码落地可读性也好。4.1 推荐逻辑的分层设计推荐模块分三层每层解决一个实际问题第一层基础热度榜。直接取sales_count降序、近30天有上架的商品结果全站一致。这个榜单解决冷启动问题新用户第一次进来也能看到大家普遍买什么。办公用品的品类集中特性带来了一个好处热门榜前五十名的商品基本覆盖了公司八成的采购需求。第二层品类偏好推荐。当一个用户有过下单记录之后我会统计他订单明细里覆盖的品类Top3比如某用户一年内购买过28次耗材配件类商品、13次纸品本册类系统就会找到同一品类下他还没买过、且销量不低的商品作为“猜你喜欢”这是整个推荐模块的核心。第三层关联搭配推荐。基于手动维护的规则表和购买组合分析比如买过A4纸的人下次大概率需要硒鼓买过中性笔的人大概率需要笔芯。这类补充推荐放在商品详情页下方目的是拉高客单价。4.2 核心代码基于品类偏好的推荐实现这个模块最核心的逻辑就是SQL里的排序和分组下面这段代码是推荐服务的主逻辑之一public ListProductVO recommendByCategory(Long userId, int limit) { // 1. 查出用户购买品类分布 top3 ListCategoryStat stats orderDao.countUserCategory(userId); if (stats.isEmpty()) { // 退化为热门榜查询 return productDao.listHotProducts(limit); } // 2. 按品类权重生成候选集 ListLong categoryIds stats.stream() .sorted((a, b) - Long.compare(b.getCount(), a.getCount())) .limit(3) .map(CategoryStat::getCategoryId) .collect(Collectors.toList()); ListProduct candidates productDao.listByCategoryIds(categoryIds, 2); // 3. 过滤掉用户已购买过的商品 ListLong purchasedProductIds orderDao.listPurchasedProductIds(userId); candidates.removeIf(p - purchasedProductIds.contains(p.getId())); // 4. 加权排序销量权重0.4 评论/浏览量权重0.3 新近上架权重0.3 return candidates.stream() .sorted(Comparator .comparingDouble((Product p) - 0.4 * p.getSalesCount() 0.3 * p.getViewCount() 0.3 * (System.currentTimeMillis() - p.getCreateTime().getTime()) / 86400000.0) .reversed()) .limit(limit) .map(product - convertToVO(product, 猜你喜欢)) .collect(Collectors.toList()); }这里有几个设计细节值得说明没直接用SQL算加权得分而是查出候选后内存排序。原因是候选商品数量不大每个品类取前几十条内存排序灵活好维护后续改权重系数不用动SQL。协同过滤算法在这个场景下不是最优解。办公用品采购决策理性用户偏好稳定单纯用“买了A的人还买了B”关联规则无法解释“为什么推荐这个”。品类权重算法看起来朴素但胜在产品组合特性与业务目标高度一致。推荐池每天定时刷新一次而不是实时计算。对于办公用品这种复购周期长的场景实时性要求并不高一天一算足够。每次用户点击首页直接查tb_recommend表按score降序返回毫秒级响应。4.3 附带的小优化冷启动和新品突围冷启动阶段怎么处理我的做法是新用户第一次登录时推荐结果退化为“热门销量榜当季新品榜”的混合队列。新品榜的加权方式略有调整把上架时间因子权重从0.3调高到0.5让一周内的新品有更多曝光机会同时通过后台管理页面可以手动把某件商品“置顶”到推荐位相当于人为干预的热度加权让运营有抓手。这套推荐虽然从算法含量上比不过大厂的深度学习排序但从业务适配度上来说它不是花架子是真的用上了代码量可控维护成本也低。如果未来数据量上来了推荐表里可以很平滑地加入“用户行为实时日志”再升级成实时推荐引擎架构上不用推翻重来。5. 部署验证从本地开发到Linux服务器的完整闭环我把这套系统部署到一台轻量云服务器上操作系统是Ubuntu 22.04内存2GCPU 2核完全跑得动前后端分离部署的形态是“Nginx托管Vue静态资源 反向代理SpringBoot接口”步骤如下。5.1 环境准备需要安装的软件清单和版本直接照抄实测兼容JDK 1.8安装时注意配好环境变量MySQL 5.7或8.0建议8.0字符集utf8mb4Node.js 16.20Nginx 1.24安装MySQL后执行数据库初始化脚本我习惯把建表和初始数据放在一个init.sql里用source /data/init.sql一次性导入比用GUI工具点来点去快得多。5.2 后端打包与启动SpringBoot工程的部署文件是一个可执行的jar包。执行打包命令mvn clean package -DskipTests打包产物在target/office-server.jar。我使用的启动命令是nohup java -jar /data/office/office-server.jar \ --spring.datasource.urljdbc:mysql://localhost:3306/office_db \ --spring.datasource.usernameroot \ --spring.datasource.passwordYOUR_PASSWORD \ --server.port8080 \ /data/office/start.log 21 几个容易被新手忽略的核心点用nohup方式启动日志都放到了start.log排查启动报错时先看这个文件。--server.port如果不在命令行显式指定取的是application.yml里的配置。端口千万别和Nginx冲突。Nginx自己占80后端接口我固定8080互不干扰。检查启动是否成功的命令是ps -ef | grep java或者curl http://127.0.0.1:8080/api/health。如果返回一个JSON说明后端已经就绪。5.3 前端构建与Nginx配置前端项目根目录执行npm install npm run build构建产物在dist/目录把dist下的所有文件上传到服务器/var/www/office-ui目录下面。然后修改Nginx配置文件/etc/nginx/sites-available/officeserver { listen 80; server_name your-domain.com; root /var/www/office-ui; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }保存后执行nginx -t检查语法然后systemctl reload nginx生效。几个实际部署中会出现的问题先给你打个预防针前端刷新404Vue Router使用history模式时刷新非首页路径会404。必须加try_files $uri $uri/ /index.html;这行是Vue单页应用部署的保命配置。接口404或502检查proxy_pass后面是否带了路径前缀。如果后端接口路径是/api/product/list而proxy_pass后面写的是http://127.0.0.1:8080/api/那么实际转发路径会变成/api/api/product/list必挂。正确写法是proxy_pass不带末尾路径只到端口号。Token跨域丢失前后端分离部署在同域下之后由于Nginx已经把前后端归到同一个域名和端口跨域问题反而不存在了CORS配置默认兜底就行。但如果cookie模式传登录态务必检查Access-Control-Allow-Credentials配置。5.4 项目默认数据与账号初始化SQL里我预置了一个管理员账号admin / admin123采购员测试账号testuser / test123首页默认有几十条办公用品商品数据类别覆盖书写工具、纸品本册、桌面收纳、办公设备等方便你部署后第一眼就能看到效果不用自己慢慢插入数据。6. 部署和运行中踩过的坑实测排错记录这部分写几个真实遇到的报错和排查过程都是常规文档不会告诉你的细节。6.1 时区问题导致的日期错乱MySQL 8.0默认时区和系统时区不一致时插入的create_time会是UTC时间比北京时间慢8小时。表现就是订单创建时间显示的是凌晨4点而实际是中午12点。解决方法是在JDBC连接串上追加参数?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf86.2 MyBatis分页插件失效我用了PageHelper做分页但偶尔出现查出来的总条数不对、甚至分页参数完全被忽略的情况。排查了一圈发现是Mapper XML的select里有嵌套子查询PageHelper在嵌套子查询存在时对某些复杂SQL的count统计会失效。建议做法分页查询单独拆分一个count查询避免依赖PageHelper自动生成count或者在需要分页的查询SQL上面加注释-- 保持前后端一致的count统计。6.3 内存不足导致构建失败前端npm run build时如果服务器只有500M可用内存Vue项目在压缩代码阶段会直接OOM报错信息是“JavaScript heap out of memory”。解决方案是临时增大Node内存上限再构建NODE_OPTIONS--max_old_space_size1024 npm run build6.4 推荐模块定时任务的重复执行每天凌晨的推荐池刷新任务如果在集群环境部署两个后端实例会导致同一条推荐数据插入两次。虽然不像支付掉单那么严重但会让tb_recommend表出现重复数据用户端同一商品重复展示。我的解决思路是先用DELETE FROM tb_recommend WHERE create_date ?清掉当天数据再插入新数据比加唯一索引简单得多但要保证定时任务在数据清洗之后再执行插入这个顺序必须天然正确。7. 个人实践心得和项目扩展建议这套系统我在本地完整开发再到云服务器稳定运行整个过程大概用了三周左右接近一半的时间花在“前后端联调”和“部署排错”上。要说最大的收获是理解了前后端分离项目中“接口契约”的重要性。后端定义的返回结构、分页格式、状态码含义、错误信息文案一套规范定好了前端写起来就不用来回来去改。如果这个项目作为你的简历项目或者毕业设计我建议从以下两个维度再扩展一下引入Redis缓存热门数据首页的商品分类列表、热门榜、推荐池这些都是读多写少的场景。引入Redis后缓存命中的时候接口响应从几百毫秒降到几十毫秒还能把定时刷新推荐池的时间间隔拉短到半小时一次。这是性价比很高的优化动作。增加一个简单的采购审批流办公用品场景独有的需求员工提交采购申请单主管审批通过后才生成正式订单。这比单纯再做一遍电商系统有辨识度得多也更能体现你对业务场景的理解。最后再分享一个个人习惯项目里的SQL连接配置、部署脚本、初始化数据文件我都维护在项目根目录的/docs和/sql目录里和源码一起提交。这样换电脑、换服务器、给同事演示都不用重新回忆“当时怎么配的”直接读文档就能复现一切。这个习惯从第一行代码到上线部署让我少走了数不清的弯路。