
企业办公用品做直售还要带推荐系统这个需求放在两年前我大概率会劝对方别折腾直接套一套电商开源框架改改算了。但真把需求聊透之后会发现办公用品采购和普通电商完全是两回事下单的人不一定是用东西的人采购频次高但单值低SKU看着不多但规格属性特别碎再加上企业内部的审批流、部门预算、常用清单这些东西硬套通用商城反而别扭。这也是我最终选择用 SpringBoot Vue MyBatis MySQL 自己搭一套完整源码的原因——不是为了炫技而是这套组合在“企业级日常办公用品直售推荐系统”这个场景下性价比最高、可控性最强也最容易让后续接手的人快速上手。这篇文章不打算只贴目录结构和代码片段我会把整个系统的设计思路、数据库建模、后端接口、前端页面、推荐逻辑、以及我在联调阶段踩过的坑完整串一遍。适合两类人看一是准备做类似企业采购系统但还没定技术方案的开发者二是已经拿到这套源码、想读懂它并二次开发的兄弟。无论你属于哪一种我希望你看完能搞清楚的不只是“怎么跑起来”而是“为什么这么做”。1. 从需求到系统边界办公用品直售推荐系统到底要解决什么1.1 不只是“电商 推荐”而是企业采购场景下的三个特殊约束很多人一看到“直售推荐系统”就开始想协同过滤、用户画像、深度学习那一套。但企业办公用品这个场景推荐算法的复杂度远没有互联网电商那么高真正的难点在业务约束上。第一个约束是下单人与使用人分离。行政下单可能是给整个部门采购前端录入的收货地址是公司但商品最终分发给具体员工。所以系统里不能只有用户表还得有“下单人、收货部门、领用人”这类维度的区分。第二个约束是高频小额订单。办公用品的复购率极高A4纸、中性笔、文件夹是典型的“定期消耗品”这意味着系统要支持快速重复下单、历史订单一键再来一单而不是每次都要从头逛一遍。第三个约束是库存与价格的敏感性。办公用品毛利不高直售模式价格波动频繁库存可能因为批量采购临时缺货推荐位如果推了一个没货的商品对采购人员来说就是干扰。基于这三个约束系统的核心功能模块被拆成了五块商品管理、用户与权限、购物车与订单、库存管理、推荐引擎。推荐逻辑不用做得多聪明能根据用户的历史采购记录和当前浏览行为把高频复购品、同部门常用品、当前有库存的替代品推到首页就够用了。1.2 功能模块划分与角色权限设计企业级系统最忌讳“一张用户表走天下”角色决定了你能看到什么、能操作什么所以我在建模阶段就把用户体系分成三端后台管理员端负责商品上下架、分类维护、库存调整、订单审核、数据统计。企业采购员端浏览商品、发起下单、查看推荐位、管理自己的常用清单常购列表。普通员工端可选主要是领用申请和查看商品不直接参与下单在本套源码中这一端的权限可以通过菜单按钮级别的路由控制来实现。权限设计上我用的是基于角色的访问控制模型前端根据用户角色动态生成路由菜单后端在SpringBoot拦截器中校验接口权限。比如/admin/**开头的接口只允许ADMIN角色访问/order/**允许PURCHASER和ADMIN访问普通员工只能访问商品和推荐接口。这里有一个经验很多小项目把权限判断全部放在前端导致接口可以直接被Postman调用访问这是企业级项目的大忌。后端必须做一次兜底校验。2. 技术栈选型复盘为什么是SpringBootVueMyBatisMySQL这套组合2.1 后端选型的核心逻辑选SpringBoot的理由不用多说约定优于配置、内置Tomcat、生态成熟最关键的是这套系统的业务复杂度决定了我们不需要微服务那一套重型武器。一个单体SpringBoot应用加上合理的模块划分完全足够支撑数百家企业用户的并发量。SpringBoot的版本选择我建议用2.7.x不要一上来就追SpringBoot 3.x。原因很实际3.x是基于JDK 17的很多老员工本机的JDK还是8而且MyBatis、Shiro、JWT等一堆周边依赖对Jakarta EE的迁移不一定做过适配。如果是自己玩新项目用3.x没毛病但做“企业级管理系统源码”这种要交付给别人部署的东西兼容性永远比尝鲜重要。JDK 1.8 SpringBoot 2.7.x MyBatis 3.5.x这个组合是我在实际交付中被验证过最稳的。2.2 前端与数据库选型的关键细节前端选择Vue的原因很简单组件化开发效率高生态里Element UI或者Element Plus对后台管理系统的支持太成熟了。你要做一个商品表格、弹窗表单、分页列表几乎不用自己造轮子。这套源码里前端用的是Vue 2 Element UI Vue Router Vuex为什么不是Vue 3不是不会而是管理系统源码的受众里Vue 2的存量项目依然巨大而且Element UI的文档和踩坑资料比Element Plus全得多。如果你准备自己新起项目用Vue 3 Element Plus完全没问题逻辑层代码可以平移。数据库选MySQL更没什么悬念这套架构里MySQL承担的是核心业务数据存储不涉及海量日志、不涉及高并发写入用MySQL简单可靠、部署成本低、DBA好招。需要说明的是我在设计时遵循的是“单库多表、业务表与推荐日志表分离”的原则不至于让推荐相关的数据查询拖累核心交易链路。2.3 这套组合在企业项目里的适配边界我得说句公道话这套组合不是万能的。如果你要做的是一套真正的多租户SaaS商城并且未来可能要支撑上千家企业的并发那SpringBoot单体会遇到瓶颈MySQL单库也会吃力。但“企业日常办公用品直售推荐系统”这个需求真实场景往往是企业内部私有化部署甚至要装在政务内网或者公司服务器上用户量就是几百人、几千人并发量极低。把架构做复杂才是风险一套清晰简单的技术栈反而方便后期维护。3. MySQL建模把“办公用品”变成可推荐的数据结构3.1 核心表设计与字段说明数据库建模是这套系统里最值得花时间的一步。我先把核心表列出来再逐个讲为什么这样设计。表名用途关键字段user用户表id, username, password, role, department_idcategory商品分类表id, name, parent_idproduct商品表id, category_id, name, sku_code, spec, unit, price, stock, statuscart_item购物车表id, user_id, product_id, quantity, checkedorder_info订单表id, order_no, user_id, department_id, total_price, status, create_timeorder_item订单明细表id, order_id, product_id, quantity, price, subtotalrecommend_log推荐日志表id, user_id, product_id, score, source, create_timeclick_log浏览行为日志表id, user_id, product_id, action, create_time这里面最容易忽略的字段是product表的spec。办公用品的规格属性特别重要A4纸要是没写清“70g/500张/包”用户根本不敢下单同样是中性笔0.5mm和0.7mm放在一个列表里不区分售后一定爆。所以我单独设计了spec字段和unit字段单位前端在商品名称后面统一展示规格。3.2 商品、库存与价格的推荐相关设计推荐系统要做起来光靠一张商品表是不够的。我在商品表上增加了两个字段is_recommend后台推荐标记和sales_count销量统计。调用推荐接口时优先取后台运营手动标记的商品再按销量和用户历史行为补位。这里有一个关键设计价格字段必须用DECIMAL不能用FLOAT或DOUBLE。MySQL里浮点数做金额运算会有精度丢失的问题一份报价单算出来多出一分钱在企业采购对账时就是事故。我在order_item里存下单时的快照价格而不是实时去查商品表这样如果商品后续涨价历史订单依然能正确对账。库存字段可以简单用整数存stock但要注意超卖问题。由于这套系统的并发量不高我采用“下单前查询库存并校验扣减库存使用乐观锁version字段”的方式防止两个采购员同时下单把最后一件商品买走。如果用悲观锁或分布式锁反而会增加系统复杂度。3.3 索引与查询优化让推荐列表不拖慢订单流程企业办公用品的商品量不会特别大几千条SKU顶天了但订单数据和浏览日志数据会快速增长。索引设计上要瞄准高频SQLproduct表category_id加普通索引status加索引因为商品列表页常用WHERE status1 AND category_id?查询。order_info表user_id加索引create_time加索引订单列表按用户和时间排序会很频繁。click_log表(user_id, product_id)加联合索引推荐算法要按用户捞点击记录。recommend_log表(user_id, source)加联合索引用户首页加载时按来源查推荐列表。另外浏览日志这类数据要有定期清理机制我一般用事件调度器保留最近90天的日志避免表无限膨胀。推荐算法的实时性要求不高近90天的行为足够训练出稳定的偏好了。4. SpringBootMyBatis后端实现从登录鉴权到订单闭环4.1 目录结构与统一响应设计后端目录结构我习惯这样拆既保证分包清晰又不至于过度设计com.office.supply ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── common │ ├── Result.java │ ├── PageResult.java │ └── exception ├── config │ ├── WebConfig.java │ ├── MybatisPlusConfig.java (如果集成了MP) │ └── InterceptorConfig.java └── utils └── JwtUtil.java统一响应体ResultT是必须的不然前端接收每个接口都要判断不同格式。我定义的结构是{ code: 200, message: success, data: ... }code为200表示成功401表示未认证500表示服务器异常。前端的axios拦截器统一处理code不需要每个页面自己写状态判断。4.2 商品直售与订单流程的核心接口商品列表接口是最常用的我提供两个一个供前端分页列表用一个供推荐位用。分页接口参数包括pageNum、pageSize、categoryId、keyword返回PageResult里面包含总记录数、总页数和当前页数据。MyBatis分页我直接用PageHelper插件一行代码搞定比手写limit再查count方便得多。订单流程的设计要模仿真实电商的完整闭环而不是只做个下单接口。状态机我用的是待付款(0) - 已付款/待发货(1) - 已发货(2) - 已完成(3) | 取消订单(-1)企业采购通常不是实时在线支付而是线下财务打款后由管理员在后台标记付款状态所以我给订单加了status字段并提供cancelOrder、payOrder、shipOrder、confirmOrder四个操作接口。下单时同时写入order_info和order_item两张表必须用Transactional保证原子性库存扣减失败则整体回滚。createOrder接口的核心逻辑大致是从购物车或直接购买参数中获取商品ID和数量。遍历商品查询当前价格和库存校验库存充足。计算总金额写入订单主表状态为待付款。批量写入订单明细快照价格。扣减库存使用UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}防止超卖。返回订单号前端跳转到订单详情页。4.3 推荐逻辑的实现基于用户行为的协同过滤简化版这套系统的推荐引擎没有引入复杂的机器学习框架我用的是“基于用户的简单协同过滤 规则补位”策略实现在Service层里完全可控。实现思路分三步第一步统计用户对商品类别的偏好分。从order_item和click_log中按商品分类聚合统计某用户点击和购买最多的分类购买权重是3点击权重是1。第二步寻找相似用户。找出与当前用户有相同购买或点击行为最多的其他用户将这些用户购买过的、当前用户没买过的商品作为候选推荐池。第三步规则补位。候选推荐池按销量降序排列并过滤掉库存为0、已下架的商品每天最多推荐10个商品输出到首页推荐位。伪代码结构如下public ListProductVO getRecommendList(Long userId) { // 1. 获取用户偏好分类 ListLong favCateIds getFavCategoryIds(userId); // 2. 获取相似用户购买过的商品 ListProduct candidates getCandidatesBySimilarUser(userId, favCateIds); // 3. 补充热门商品 if (candidates.size() 10) { candidates.addAll(getHotProducts(10 - candidates.size())); } // 4. 去重、过滤下架/无库存 return filterAndConvert(candidates); }这套简单逻辑在办公用品场景下效果反而不错因为企业采购的决策周期短、复购特征明显用户这次买了A4纸下次大概率还想买A4纸和一个品牌的签字笔不需要太复杂的模型。4.4 MyBatis映射与动态SQL实战MyBatis的使用有几个点值得展开说。第一个是Mapper XML文件的路径配置很多新手在这里栽跟头。在application.yml里要配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.office.supply.entity第二个是动态SQL。商品列表接口的查询条件是不确定的用户可能只传分类、可能只搜关键词、也可能两个都传所以我在ProductMapper.xml里写动态SQLselect idpageQuery resultTypeProductVO SELECT * FROM product where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR sku_code LIKE CONCAT(%, #{keyword}, %)) /if AND status 1 /where ORDER BY create_time DESC /select注意这里用where标签会自动处理掉多余的AND比手动拼WHERE 11干净得多。另外LIKE查询量大的时候要注意索引失效问题不过办公用品SKU量小影响可以忽略。第三个是结果映射。商品表字段是sku_code实体类是skuCode只要开启map-underscore-to-camel-case: trueMyBatis会自动完成下划线到驼峰的映射不需要为每个实体都写ResultMap。这个配置强烈建议开启。5. Vue前端实现从商品货架到“猜你喜欢”5.1 项目初始化与路由、状态管理前端我用的Vue CLI脚手架创建项目依赖安装Element UI、Axios、Vue Router、Vuex。项目结构大致是src ├── api (按模块封装的axios接口) ├── assets ├── components (通用组件如分页、弹窗) ├── router ├── store (Vuex) ├── views │ ├── admin/ (后台管理页面) │ ├── purchase/ (采购员页面) │ └── login.vue └── utils (请求封装、token存取)路由设计上我用动态路由配合后端权限。登录成功后前端根据返回的角色字段过滤掉无权访问的路由表再加入router.addRoutes。这样处理比在每个页面里写if判断要优雅得多且菜单也能跟着路由自动生成。Vuex里主要维护三个状态userInfo用户信息、cartNum购物车商品总数、permissionRoutes当前用户可见路由。购物车数量在每次加入购物车、下单成功后更新全局统一展示在顶栏。5.2 核心页面拆解商品列表、购物车、下单商品列表页是用户停留时间最长的页面。我的实现要点是左侧分类树右侧商品卡片网格顶部搜索框。分类树直接递归渲染后端返回的分类列表支持两级分类。每个商品卡片展示商品图、名称、规格、单位、现价、剩余库存库存小于10的时候红色提示“库存紧张”点击加入购物车按钮后调addCart接口。购物车页要做两个核心功能批量勾选和合计金额计算。勾选状态保存在本地cartItems数组的checked字段中合计金额用计算属性自动更新computed: { totalPrice() { return this.cartItems .filter(item item.checked) .reduce((sum, item) sum item.price * item.quantity, 0); } }下单页我做了“立即购买”和“购物车结算”两个入口。两者最终都调同一个createOrder接口不同点只是商品列表来源不同。下单成功后跳转订单详情页订单详情里展示商品快照清单、总金额、状态流转按钮。这里特别做了一个“再次购买”按钮点击后把订单明细里的商品重新加入购物车这是办公用品高频复购场景下最实用的功能。5.3 推荐位的接入与前端展示策略推荐位我放在首页顶部叫“为企业推荐”下面放一排商品卡片。这个推荐位的展示策略有几个细节值得说第一推荐接口返回的数据结构要和商品列表一致前端不需要为推荐位单独写一套组件复用同一个ProductCard组件即可。第二推荐位商品展示要加“推荐理由”标签比如“根据你的常购分类推荐”“部门热销”因为办公用品采购人员对纯算法的推荐天然不信任给出理由能提升点击率。第三推荐接口要容忍失败。如果推荐接口报错或返回空前端要自动隐藏整个推荐模块不影响商品列表正常浏览。我们封装请求时单独处理这组接口超时时间设短一些毕竟推荐内容属于锦上添花不能拖慢首屏。前端调用推荐接口的示例getRecommendList(userId).then(res { if (res.code 200 res.data.length 0) { this.recommendProducts res.data; } else { this.showRecommend false; } }).catch(() { this.showRecommend false; });6. 联调过程中我踩过的坑与验证方法6.1 跨域、端口与打包静态资源的三个经典问题前后端分离开发时Vue跑在8080端口SpringBoot跑在8081端口第一个撞上的就是跨域。我在后端统一配置了CORS而不是在前端用代理解决。因为前端代理只对开发环境有效到了生产环境如果依然用Nginx部署前后端分离配置成本更高。所以我用SpringBoot的WebMvcConfigurer实现跨域支持Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }第二个坑是Vue打包后的静态资源放不进SpringBoot。我最终的做法是把前端打包产物dist里的文件复制到SpringBoot的src/main/resources/static目录下再把SpringBoot的application.yml里配置好首页跳转spring: mvc: view: suffix: .html这样部署时只需一个jar包直接运行就能访问管理系统对交付场景来说最省事。如果你的前路由用的是history模式而不是hash模式还要在SpringBoot里配置一个“所有非api路径都转发到index.html”的兜底Controller否则刷新页面会404。用hash模式可以完美避开这个问题我在源码里默认用的hash模式。第三个坑是端口冲突。很多人的服务器上已经跑了MySQL、Nginx、其他SpringBoot服务我启动本系统前会先检查端口占用。常用端口改为server.port: 8081MySQL用默认3306。6.2 数据库时区、JSON序列化与MyBatis映射的隐性bug联调时最容易出现的怪问题有几个我在源码交付文档里也明确写了注意事项。第一是数据库时区问题。MySQL 8.x默认时区比中国时间差8小时或者报The server time zone value的错我在JDBC连接串上明确加了参数jdbc:mysql://localhost:3306/office_supply?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二是JSON序列化循环引用。如果商品实体里有Category对象订单实体里有OrderItem列表列表里的商品再关联分类就可能出现无限递归。我的处理方式是实体类中不直接嵌套关联对象所有关联查询都写在*VO里VO是扁平结构。这比在关联字段上加JsonIgnore要清晰也不容易误伤字段。第三是MyBatis返回值为0却无报错。插入订单明细时如果主键不是自增而是用了自定义订单号比如order_no是ORD202506011001MyBatis默认的useGeneratedKeys会失效。我的处理是订单主键用数据库自增订单号在Service层生成后单独存一个order_no字段不依赖数据库生成。这样订单表主键稳定订单号又能支持自定义规则。6.3 整体验证流程与性能观察系统最终跑通后我把验证流程沉淀成了一套可直接执行的清单这样无论是同事接手还是客户验收都能照着测。管理员登录后台新增分类和商品上架两个测试商品一个设置推荐标记一个不设置。采购员登录前台通过分类和搜索找到商品加入购物车并下单确认库存减少。换一个没有购买记录的账号登录查看首页推荐位是否正常展示热门商品模拟点击商品后再刷新看推荐内容是否更新。后台对订单进行付款和发货操作前端订单状态同步更新。同时用两个账号下单同一件库存只剩1件的商品验证其中一个下单成功另一个提示库存不足。性能上面我压测过300并发下的商品列表和订单提交接口响应时间基本都在200ms以内。这个结果符合预期因为这套系统的瓶颈不在应用层而在MySQL的单库存储能力上。日常办公用品系统的数据量增长很慢按每天500单、每单10个明细来算一年也就180万条明细数据MySQL完全扛得住。如果未来数据量真的涨上来优先方案是给order_item表做按月分表或者归档历史订单而不是一上来就引入分布式中间件。整个项目做完我最大的体会是企业级管理系统的复杂度从来不在于某个孤立的技术点而在于所有模块之间的衔接是否考虑到真实业务场景。商品规格、订单快照、库存防超卖、推荐理由展示这些细节单独看都不起眼合在一起才构成一个能真正落地使用的系统。如果你拿这套源码二次开发我建议先看懂订单状态机和库存扣减逻辑这两个地方是整个系统的业务核心动它们之前一定要想清楚会牵连哪些接口。