做了这么多年管理系统我越来越觉得贸易行业是最需要CRM、但最容易被CRM坑的一个领域。早先接触一家做建材出口的贸易公司二十几个销售每人手里几百个客户但所有客户资料都散落在Excel和微信聊天记录里。业务员离职客户资源跟着蒸发老板想统计某个区域上个月的成交率得让助理手工加一周的班。后来我基于SpringBootVueMyBatisMySQL这套组合从零给他们搭了一套贸易行业CRM系统把客户、报价、订单、回款、跟进记录全部管了起来。这篇文章我把整个项目的落地过程拆开讲清楚包括业务需求怎么梳理、数据表怎么设计、MyBatis的分页缓存拦截器怎么用、Vue前端有哪些关键实现、上线前还有哪些坑。如果你正好准备自己开发一套CRM或者正在犹豫买SaaS还是自研这篇文章应该能帮你少走不少弯路。1. 贸易行业的业务痛点决定了这不是一个普通CRM在动手写代码之前我花了整整两周泡在那家贸易公司的业务部门里旁听他们开早会、看销售怎么报价、跟单员怎么催货、财务怎么对账。这个过程比写代码重要得多因为行业CRM的行业二字恰恰体现在这些具体的业务动作上。1.1 贸易业务怎么跑从询盘到回款的完整链路贸易行业的业务流程通常是这样一条链业务员通过各种渠道拿到询盘把客户信息记下来然后开始反复报价、议价。如果客户有合作意向就进入打样、寄样、确认订单的环节。订单确认后跟单员介入负责跟进工厂生产进度、安排验货、协调物流。货发出去了还有报关、交单、催收货款直到货款到账整个流程才算闭环。这里有个制造业和贸易行业本质的区别制造业的CRM往往围绕设备或工单转而贸易行业的核心是订单生命周期。一笔订单可能跨好几个月中间经历十几次报价调整、多次样品寄送、无数封往来邮件所有过程都必须留痕。如果系统里的客户详情页点进去只有一行联系人和一个电话那这个CRM基本就是废的。1.2 通用SaaS CRM和自研系统的真实差距当时公司管理层其实先购买了某款知名SaaS CRM的团队版年费不低用了三个月后业务部门怨声载道。原因集中在几个方面一是报价逻辑对不上。贸易行业的报价不是简单的单价乘数量还涉及汇率换算、退税率、海运费分摊、最低起订量甚至不同的付款方式对应不同的价格。通用CRM的报价模块通常只是一个小表格根本撑不起这种复杂的计算。二是跟单环节缺失。通用CRM把重点放在销售漏斗上但贸易公司的业务员把订单签下来只是开始后面的生产进度、交期确认、验货报告这些内容跟单员同样需要录入和查看。通用CRM没有这个场景跟单员只能继续用微信和Excel系统就成了一个边缘工具。三是字段和数据结构的约束。贸易客户有明确的行业属性、目标市场、年采购量、主要品类通用CRM的字段池压根覆盖不了自定义字段又限制了数量。这也是很多公司在用SaaS产品时遇到的核心矛盾SaaS的优势是便宜、上线快、免运维但它的功能边界是平台定的。你没法要求SaaS厂商为了你一家公司的行业特性去改产品逻辑。而自研系统的优势恰恰是灵活、可控、贴着业务走代价是需要投入开发资源。像这种贸易公司业务模式相对成熟、流程相对固定自研一套CRM反而比长期买SaaS更划算。1.3 需求梳理业务部门提了二十条需求最后收敛成五个模块和业务部门开了四轮需求评审会他们最初提了大概二十多条需求比如邮件自动归档、客户公海自动分配、报关单对接等等。这些需求乍看都合理但落地时发现不少属于低频场景或者第三方系统该干的事。我最后做了减法把核心需求收敛成五个模块客户管理客户主数据、联系人、归属关系、公海池、去重。商机与报价管理商机阶段推进、报价单审批、历史报价版本留存。订单与跟单订单状态流转、生产进度登记、发货信息记录。跟进记录每一次电话、邮件、拜访的结构化记录和时间线展示。数据看板按销售、区域、品类的业绩统计和转化分析。这个收敛过程其实就是把想要的功能和必要的能力分开。很多CRM项目死掉不是因为技术不行而是因为从一开始就把边界划错了。2. 技术选型的底层逻辑SpringBootVue是怎么定下来的需求定了接下来是选型。项目标题里直接锁定了SpringBootVueMyBatisMySQL这套组合在2025年的国内企业级开发里依然是绝对主流。但我还是想拆一下背后的选型逻辑因为很多新手项目搭到一半就卡壳往往是没想明白每个组件到底承担什么责任。2.1 后端框架对比SpringBoot为什么比SSH和Python系更顺手后端框架当时主要对比了三个方向传统SSHStrutsSpringHibernate、Python系的Django/Flask、以及SpringBoot。SSH在2015年前后是主流但现在基本退出了新项目的选型清单原因是配置地狱。一个SSH项目光是XML配置就能写几百行写业务代码的时间还没调配置的时间多。SpringBoot为什么能取代它核心是约定大于配置——SpringBoot把大部分基础配置都做成了自动装配开发者只需要在pom文件里引入依赖再配一个application.yml就够了。Python系的Django和Flask在开发效率和灵活度上确实不错尤其是小团队快速出活。但考虑到这个CRM系统后续要对接电子面单、报关接口、企业微信Java生态在金融级接口、消息队列、事务管理上的成熟度仍然更高。再加上公司现有的运维体系里对JVM应用的监控比较完善所以后端锁定了SpringBoot。2025年这个时间点我用的是SpringBoot 2.7.x版本Java 8。你可能会问为什么不用SpringBoot 3.x和Java 17这里有一个很现实的原因MyBatis和很多第三方starter在3.x版本下虽然也兼容但部分老版本的依赖升级会带来额外的适配工作量。对于这个量级的CRM项目2.7.x足够稳定同时社区资料最多遇到问题搜索一下就能找到答案。2.2 前端为什么不选ReactVueElement UI的团队适配性前端在Vue和React之间也犹豫过。坦率说React的生态和灵活性目前是第一梯队但在国内企业级中后台管理系统这个领域VueElement UI的组合在开发效率上有压倒性优势。原因很简单CRM系统里80%以上是增删改查页面也就是列表、表单、弹窗、详情。Element UI把这些组件都封装好了布局栅格、表格分页、表单校验、日期选择器都是开箱即用。Vue 3的组合式API让逻辑复用变得很干净——比方说我要给一个成交客户列表和一个潜在客户列表共用同一套分页查询逻辑用组合式函数抽出来两个页面各自调用就行。另外还有一个团队因素这个项目后续的维护人员对Vue的熟悉度更高。技术选型不是给自己选而是给团队和对未来负责的人选。如果选了一个团队里没人熟的技术后面维护成本会成倍上升。第一版前端我用了Vue 3 Vite Element Plus。Vite相比Webpack最大的感知差异就是启动速度大型项目里Webpack冷启动可能要等几十秒Vite基本秒开开发体验直接起飞。2.3 数据库选型MySQL在这种规模下的边界在哪数据库是最没悬念的。MySQL 8.0是当前最稳妥的选择InnoDB引擎支持事务、行级锁、崩溃恢复对CRM这种事务密集型的业务非常合适。这套系统预估三年的数据规模客户数据2万条左右商机10万条订单5万条跟进记录50万条。这点数据量对MySQL来说完全是轻载只要索引建对了普通单表千万级以下都不会有明显性能问题。真正需要考虑的是未来扩展。如果这家公司后面要把库存管理也做进来数据量翻几倍MySQL依然能扛。除非做到多租户SaaS级别的海量数据才需要引入分库分表或者TiDB这类分布式方案。对于企业自用的系统没必要一上来就上分布式架构过度设计比性能不足更可怕。3. 表结构设计把这十张核心表想清楚系统就成了一半数据库表设计是CRM系统的地基。我见过很多项目因为表结构没想清楚后面每加一个功能就改一次表改到后面外键关系一团乱麻。这里分享一下我的核心设计思路。3.1 客户主数据与联系人唯一性和去重是底线客户主数据是CRM的核心资产我把客户表和联系人表拆成两张表。客户表存公司维度的信息比如客户名称、行业、所在国家、客户等级、来源渠道联系人表存个人维度的信息比如姓名、职位、电话、邮箱、微信一个客户可以关联多个联系人。这两张表在设计上最容易犯的错是不做唯一性约束。贸易公司同一个客户很可能被两个业务员重复录入后面就会产生抢单纠纷。我在客户表上加了客户名称的唯一索引同时在代码层面做了一个简易的去重校验——录入客户时自动按名称去匹配已有客户如果匹配到相似度超过阈值的就提示业务员手动确认是否重复。3.2 商机、报价、订单、回款钱和货的闭环这几张表是贸易CRM和通用CRM区别最大的地方。商机表记录潜在客户的需求比如产品品类、预计年采购量、目标价格区间、决策进度。商机阶段一般分为初步接触、需求确认、报价中、谈判、赢单/输单。报价单表我采用了「主表明细表」的设计。主表存报价单号、客户ID、商机ID、币种、汇率、报价日期、有效期、审批状态明细表存每一个报价产品的名称、规格、数量、单价、折扣、税点。为什么要拆两张表因为一笔报价单可能包含多个产品如果把所有信息塞在主表里字段会冗余得没法看。更重要的是后续如果要统计某个产品的历史成交价格从明细表里查会方便得多。订单表和报价单表结构类似但增加了交期的概念。贸易订单最怕的就是交期延误所以订单表里我加了计划交货日期、实际交货日期、延期天数三个字段后面做数据看板时可以直接计算准时交付率。回款表记录每一笔订单的收款情况包括应收金额、已收金额、收款日期、收款方式、剩余应收。做这个表的难点在于一笔订单可能分多次收款所以我又加了一个回款计划的概念把预付款、尾款按比例拆成多条记录。3.3 跟进记录和时间线销售过程的数据资产跟进记录表是我当初坚持要做的重点。每次业务员和客户打完电话、发完邮件、见完面都要在系统里记一条跟进记录。表结构很简单客户ID、跟进类型电话/邮件/拜访/其他、跟进内容、下一步计划、下次跟进时间、跟进人。但这里有个关键设计我在客户详情页用时间线组件把跟进记录、报价记录、订单状态变更、回款记录全部串在一起。这样点进一个客户的详情页从第一次接触到最近一次跟进整条业务脉络一目了然比单一维度的跟进记录有价值得多。这个时间线数据怎么来不是靠业务员额外去填而是系统在报价单审批通过、订单状态变更、回款登记成功时自动往操作日志表里写一条记录。这种无感记录的方式不会增加业务员的操作负担却能让时间线自动丰富起来。3.4 部门、角色、数据权限三条线的权限模型CRM系统的权限设计是个容易被忽视但上线后最容易出问题的点。我用的是经典的RBAC模型用户归属于部门部门有层级关系角色定义用户能做什么比如查看、新增、编辑、删除、审批数据权限定义用户能看到哪些数据。数据权限我实现了四个级别本人数据只能看到自己创建的记录本部门数据能看到本部门所有成员的记录本部门及下属部门数据能看到部门树下面所有部门的记录全部数据通常只给老板和管理员这个权限模型在后面的MyBatis拦截器部分会具体讲实现方式。这里先记住一个原则权限判断不能散落在Service层到处写不然每写一个接口都要提心吊胆有没有漏掉过滤条件。4. MyBatis的实战姿势分页、缓存、拦截器一个都不能少MyBatis是这套系统里工作量最集中的地方。三个关键词直接对应三个高频场景PageHelper处理表格分页、二级缓存处理高频查询、拦截器处理数据权限自动注入。这些坑我都踩过下面逐个说。4.1 分页插件数据量上来后PageHelper的正确用法CRM系统里最常用的操作就是列表查询每个列表都要分页。如果不用分页插件你需要手写limit语句还要单独写一条count查询麻烦不说还容易漏掉条件导致分页和总数对不上。PageHelper处理这个问题非常优雅。最基础的用法是这样PageHelper.startPage(pageNum, pageSize); ListCustomer customerList customerMapper.selectByCondition(condition); PageInfoCustomer pageInfo new PageInfo(customerList);调用startPage之后紧接着的第一次Mapper查询会被自动拦截PageHelper会生成limit语句同时自动执行一条count查询所有分页参数封装在PageInfo里。这里有一个非常重要、也是我踩过坑的地方startPage必须紧跟Mapper查询中间不能插入其他查询。如果两个语句之间插了一个查询用户列表的操作PageHelper会把分页参数错误地作用到那个无关的查询上导致数据错乱。所以我在代码规范里明确要求分页查询必须封装成独立的方法方法内第一行调用startPage下面紧跟着就是目标查询。另外一个注意点是排序。很多翻页翻到后面数据顺序乱了原因就是order by的字段不是唯一索引。比如只按创建时间排序如果同一秒内有多条数据MySQL的排序结果可能是不稳定的。解决方法是order by后面追加主键作为第二排序条件这样翻页数据就稳定了。4.2 二级缓存这个开关不是开了就完事MyBatis有一级缓存和二级缓存。一级缓存默认开启作用域是SqlSession但在SpringBoot里每次数据库操作都独立使用SqlSession所以一级缓存基本可以忽略。二级缓存作用域是Mapper级别可以把查询结果序列化后存到内存里。我在做数据看板接口的时候用了二级缓存。看板数据的特点是查询比较复杂、耗时较高但数据变更频率低比如本月各业务员的成交金额汇总每天早上更新一次就够了。这类接口加二级缓存非常合适。但二级缓存有一个大坑如果在查询中关联了多张表而这些表的任何一张有修改缓存并不会自动失效。举个例子订单查询的缓存里包含了客户表的数据但如果有某个客户的名称被修改了订单查询的缓存依然是旧的。要解决这个问题必须让涉及的所有Mapper都共享同一个缓存命名空间。MyBatis的cache-ref标签可以用来实现mapper namespacecom.example.mapper.OrderMapper cache-ref namespacecom.example.mapper.CustomerMapper/ /mapper实操中我更倾向保守方案只对配置字典表、产品分类表这类几乎不变的基础数据开二级缓存涉及核心业务数据的查询宁可不缓存也不能更新不及时。因为CRM里的数据一致性非常敏感业务员刚改完一个客户的联系方式刷新页面看到的还是老号码这种体验会直接摧毁信任。4.3 拦截器实战用MyBatis拦截器做数据权限自动注入数据权限是CRM系统的核心需求但如果在每个Mapper查询里手动拼接数据权限条件代码会很啰嗦而且容易遗漏。MyBatis拦截器让我可以在SQL执行前自动注入权限条件。实现思路是实现Interceptor接口拦截Executor的query方法通过反射获取SQL语句在where子句中自动拼接create_by IN (...)这样的权限条件。以下是一个简化的示例Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement (MappedStatement) invocation.getArgs()[0]; // 通过注解或XML中的id判断该Mapper是否需要数据权限 if (needPermission(mappedStatement.getId())) { // 修改BoundSql中的SQL拼上数据权限条件 } return invocation.proceed(); } }这个方案要处理的细节不少怎么判断哪些Mapper需要权限我的做法是定义一个DataPermission注解在需要做权限过滤的Mapper方法上标注拦截器里通过判断注解来决定是否注入SQL。这样可以避免无差别的SQL修改带来性能损耗。权限条件不是查一次就好。比如用户所属部门发生了调动他看到的客户列表范围就应该跟着变。所以注入的SQL条件必须实时计算不能缓存。拼接SQL的时候要非常小心。如果原SQL里已经有了where条件就要拼接and如果没有就要拼接where。还需要考虑别名问题直接拼字段名可能会导致字段不明确。我在生产库踩过一次坑就是检查了所有核心SQL的别名之后才敢上线。4.4 动态SQL复杂筛选条件怎么组织才不失控贸易CRM的列表查询条件往往是动态组合的。客户列表可能要按客户名称、行业、所在国家、客户等级、创建时间范围任意组合筛选订单列表可能要按订单状态、销售员、产品品类、订单金额范围筛选。如果用Java代码来拼接SQL字符串可读性会非常差。MyBatis的where、if、choose标签可以优雅地解决这个问题。比如select idselectByCondition resultMapCustomerResultMap SELECT * FROM customer where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testindustry ! null and industry ! AND industry #{industry} /if if testcountry ! null and country ! AND country #{country} /if if testlevel ! null AND level #{level} /if if teststartDate ! null AND created_at gt; #{startDate} /if /where ORDER BY created_at DESC, id DESC /selectwhere标签的妙处在于如果所有条件都为空它不会生成where关键字如果有任意条件生效它会自动去掉多余的and避免SQL语法错误。这种写法让筛选逻辑全部收拢在XML文件里Java端的Service代码只需要构造条件对象传递进去逻辑清晰也好测试。5. Vue前端落地列表页、跟进时间线和权限控制前端这块我用的Vue 3 Vite Element Plus Pinia Vue Router。这里只挑几个最核心、最值得分享的实现细节。5.1 项目初始化Vite比Webpack香在哪用Vite创建Vue 3项目非常省事npm create vitelatest crm-frontend -- --template vue cd crm-frontend npm install npm install element-plus element-plus/icons-vue axios pinia vue-router相比WebpackVite最大的优势在于开发服务器的启动速度和热更新速度。Webpack冷启动一个中型项目可能要30秒起Vite基本上1秒内就绪。热更新在Vite里是按模块编译修改一个组件的样式页面几乎是瞬时时变的。这个体感差异在长时间开发中的效率提升是实打实的。5.2 表格组件封装把重复的查询逻辑收敛起来CRM里几乎所有页面都是同一套模式顶部是搜索表单中间是表格底部是分页器。如果每个页面都重新写一套搜索、查表、分页的逻辑代码重复度太高了。我封装了一个CrudTable组件把加载数据、处理分页、处理搜索的逻辑收敛到组件内部。子组件只需要传入查询API和列配置就能渲染出完整的表格页面。这样写的好处是页面代码量大大减少而且表格行为是一致的用户体验统一。这个封装的最终效果是新增一个简单的列表页面只需要写不到100行代码其中大部分是配置字段名和列标题。开发效率提高了非常多。5.3 跟进时间线用Timeline还原销售的每一步动作客户详情页是销售看得最多的页面。这里的核心组件是Element Plus的el-timeline它天然适合展示时间线。我的实现逻辑是进入客户详情页时调用一个聚合接口一次性返回这个客户的所有跟进记录、报价记录、订单变更、回款记录按时间倒序排列渲染成时间线。每条时间线节点我设计成两个部分左侧是时间节点右侧是一张卡片。卡片里显示动作类型、操作人、操作内容。如果是报价动作直接显示报价金额和审批状态如果是回款动作显示收款金额和收款方式。这个页面的体验直接决定了业务员愿不愿意每天打开系统。如果打开客户详情页看到的是冷冰冰的信息堆砌没人愿意用。时间线的价值在于它把一个客户的生命历程还原出来了销售能快速回顾跟这个客户从开始到现在发生了什么。5.4 按钮级权限自定义指令v-permission的实现后端做了数据权限前端还要做按钮级权限某个用户如果只有查看权限页面上的新增客户编辑按钮就不应该出现。我实现了一个自定义指令// main.js app.directive(permission, { mounted(el, binding) { const requiredPermission binding.value; const userPermissions useUserStore().permissions; if (!userPermissions.includes(requiredPermission)) { el.parentNode el.parentNode.removeChild(el); } } });页面里这样用el-button v-permissioncustomer:add typeprimary新增客户/el-button el-button v-permissioncustomer:edit编辑/el-button这个方案比v-if判断更优雅的地方在于权限判断逻辑被封装起来页面模板非常干净。如果后续要改成无权限时禁用按钮而不是隐藏只需要修改这一处指令逻辑就行。6. 上线前要处理的三件大事慢查询、事务边界和越权漏洞开发完成不等于能上线。我在把系统部署到测试环境后又花了整整一周做性能和安全加固。这一部分是整个项目里问题最密集、也是最有参考价值的环节。6.1 MySQL慢查询优化排序字段和模糊搜索的坑系统在测试环境跑了一周后我发现有两个接口响应速度越来越慢。用慢查询日志定位后发现一个是客户列表按创建时间排序时全表扫描另一个是客户名称的模糊搜索导致索引失效。先看排序问题。客户表有2万多条数据ORDER BY created_at DESC配合LIMIT 10MySQL仍然需要先全表排序再取前10条。解决办法是给created_at加上索引并在order by后面追加id DESC作为第二排序条件保证了排序稳定性。模糊搜索的问题更经典。LIKE %关键词%这种写法因为前置百分号的存在MySQL无法使用B-tree索引。2万条数据的全表扫描对MySQL本身不算什么但加上count查询和权限过滤后性能下降就很明显了。在这个数据量级下我的处理方案是把精确匹配优先。客户名称的搜索改成先做精确匹配再做前缀匹配LIKE 关键词%最后才做模糊匹配LIKE %关键词%并对这几种结果分别打分排序。这样既解决了索引问题查询结果的相关性也更好。如果后续数据量增长到百万级就需要引入Elasticsearch或者MySQL全文索引了。6.2 事务边界报价、订单、回款怎么保证一致性贸易CRM里最需要事务保证的是订单创建和回款登记这两个场景。比如创建订单的时候同时要做三件事插入订单主表、插入订单明细表、把商机状态更新为已赢单。这三件事必须在一个事务里任何一步失败都要全部回滚否则就会出现订单明细缺失但订单主表存在的数据脏状态。我用的是Spring的Transactional注解Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateRequest request) { // 1. 插入订单主表 // 2. 批量插入订单明细 // 3. 更新商机状态 // 4. 写入操作日志 return orderId; }注意rollbackFor必须指定因为Spring默认只对RuntimeException回滚如果代码里抛的是受检异常不加这个参数事务是不会回滚的。回款登记也是一样回款表插入一条记录后要同步更新订单表的已收金额字段。这里我用了一个更稳妥的做法不在回款插入时去更新订单表而是通过一个独立的recalculateReceivedAmount(orderId)方法每次查询订单详情时实时计算已收金额。这样做的好处是永远不会因为多笔回款并发登记导致金额计算错误。6.3 越权与注入贸易数据的安全红线贸易公司的客户资料和报价都是商业机密权限漏洞是不可接受的。我做了安全测试重点关注三类问题一是ID越权。很多列表接口的详情操作都通过ID参数查询如果后端没有做权限校验用户把客户ID改成别人的客户ID就能看到别人的客户资料。我在所有详情接口的Service层都加了数据权限校验确保只能查询当前用户有权限的客户。二是SQL注入。好消息是MyBatis的#{}预编译机制天然免疫SQL注入只要规范不用${}拼接SQL就行。我在代码评审中特别强调禁止在XML中使用${}全部使用#{}。少数需要动态排序场景排序字段必须做白名单校验。三是XSS攻击。比如客户名称字段用户在输入框里写入一段带script标签的内容如果没有处理这段脚本会在其他用户查看时执行。我写了一个全局过滤器对上传请求中的内容进行HTML转义同时前端也做了输入校验双管齐下。7. 复盘与迭代这套CRM真正给贸易公司带来了什么系统上线三个月后我回访了那家贸易公司拿到了几个比较直观的反馈。业务员再也不用翻Excel找客户了客户详情页点进去就能看到所有往来记录管理层每周一早上打开数据看板就能看到上周的成交金额和商机推进情况不用再让助理手工汇总。仅仅是把报价历史留下来这一点就避免了大量因为之前给你报过什么价格扯皮的场景。这次项目的核心经验我总结成几条比较朴素的原则先搞懂业务再写代码CRM系统的复杂度往往不在技术而在对业务的理解程度。自研系统不是拼技术多新而是把基本功能做得足够好用、足够贴合场景。MyBatis的分页、缓存、拦截器这些基础设施用得好是倍增器用不好就是埋雷。数据权限不能靠自觉必须通过框架层的拦截机制保证不漏。最后还有一个小建议如果你也是一个人开发这种CRM项目一定要把时间线日志、操作日志这类看不见的功能当回事。这些功能前期不显眼后期是判断系统有没有真正用起来的核心依据。系统不怕功能简单怕的是用过一段时间之后历史数据一盘散沙那就失去了上CRM的初衷了。
