ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue+MyBatis+MySQL实现前后端分离CRM系统实战

SpringBoot+Vue+MyBatis+MySQL实现前后端分离CRM系统实战

1. 为什么CRM系统要选前后端分离这套组合

1.1 CRM系统到底在管什么

很多同学一听到"客户关系管理系统"就觉得是个大词,其实落到实际业务里,它解决的事情非常具体:客户信息不丢、跟进过程有记录、商机转化看得见、销售业绩算得清。

我这次要做的系统,核心使用场景是这样的——一个公司在内部给销售和客服团队使用,大家登录系统后能看到自己名下的客户,可以新增客户、搜索客户、记录每一次电话或面谈的跟进内容,还能把客户分配给别人跟进。管理员可以看到所有客户和跟进情况,并根据成交状态做一些简单的统计。说白了,CRM系统把"客户资料+销售动作"这两件事在一个网页里管起来。

一开始我也犹豫过,这套系统用传统单体渲染的方式也能做,比如JSP加SpringMVC,页面直接在后端拼接。但考虑后续要增加移动端、要多个前端同事并行开发、要独立部署,最后决定走前后端分离。前端只负责渲染和交互,后端只提供JSON接口和数据校验,两边约定好接口格式就能各干各的。

1.2 技术选型对比:为什么是SpringBoot+Vue+MyBatis+MySQL

技术选型这件事,最重要的是匹配项目规模和团队熟悉度。我见过不少项目上来就上微服务、上消息队列,结果一个几百人的内部系统搞得比电商平台还复杂,纯属给自己找麻烦。这个CRM系统是中小型管理软件,用户量几十到几百人,数据量百万以内,用一套成熟的主流单体架构完全够用。

具体选型逻辑是这样的:

组件选型理由
后端框架SpringBoot起步依赖干净,内置Tomcat,不用写一堆XML配置,一个main方法直接跑起来
前端框架Vue组件化开发适合管理后台这种页面重复度高的场景,数据驱动视图,改起来效率高
持久层MyBatisSQL由开发者自己掌控,多表联查和统计报表写起来直观,方便针对复杂查询做优化
数据库MySQL普及率最高、生态成熟、运维资料多,客户量和数据量在这个量级完全够用
鉴权方案JWT前后端分离下天然合适,无状态、不占服务端Session,扩展部署方便

对比另外一个常见方案用Spring Data JPA,它对单表CRUD确实很省事,但CRM系统里统计报表、多条件分页查询特别多,JPA的复杂查询反而难写,不如MyBatis的XML里直接写SQL来得直接。Vue这边,管理后台配合Element UI或Element Plus组件库,表格、表单、弹窗这些高频组件开箱即用,开发速度非常可观。

1.3 这套方案适合谁、不适合谁

说句公道话,这套技术栈最适合的场景是:学生毕设、企业内部管理工具、创业公司初版业务系统。这类系统需求变化快、页面交互不极端复杂、用户量可控,用SpringBoot+Vue+MyBatis+MySQL的黄金组合,一两个开发人员两三个月就能交付,后续维护也不会太难。

但如果你的目标是做一个真正支撑大规模用户的高并发平台,单靠这套组合就不够看了。数据库要拆分、缓存要引入、服务要拆分,那是另一个维度的架构问题。技术选型最关键的是认清自己处在什么阶段,不要为了"技术先进"而去过度设计。这个CRM项目的定位就是实用、可靠、容易复现,把核心业务跑通比什么都重要。

2. 动手之前先想清楚业务:CRM的表结构设计

2.1 业务模块划分和页面规划

很多新手一上来就建表,结果做到一半发现表结构撑不起需求返工,特别痛苦。我习惯先把业务流程梳理一遍,把页面画出来,再反推需要哪些表和字段。这个CRM系统我拆成了五个核心模块:

  • 客户管理:客户列表、客户公海、客户分配、客户详情
  • 联系人管理:一个客户下面挂多个联系人,记录不同对接人信息
  • 跟进记录:每次沟通的时间、内容、下一步计划,形成时间线
  • 商机与成交:记录潜在机会的金额、阶段、预计成交时间
  • 数据看板:按销售统计客户数量、跟进次数、成交转化率

页面规划直接参考常见的管理后台布局:左侧菜单、顶部导航、中间内容区。客户列表页是整个系统的门面,必须有搜索条件区(关键词、状态、负责人、行业)、操作按钮区和结果表格区。点击客户名进入详情页,详情页里嵌入联系人面板、跟进时间线和商机Tab。

2.2 核心数据表设计

表设计直接决定后面开发顺不顺手。我把表结构分成三部分:用户与权限、客户与联系人、跟进与商机。

用户表就是系统登录账号,只保留最核心的字段:

CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt密文', real_name VARCHAR(50) COMMENT '姓名', role_id INT COMMENT '角色:1管理员 2销售 3客服', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';

密码字段必须用BCrypt加密存储,这是底线,系统上线后如果有人拖库拿到的是明文密码,那可是重大安全事故。

客户表是整个系统的核心,字段设计上有一个关键点:owner_id表示当前负责销售的ID,为空就是未分配,也就是放在"公海"里的客户,任何销售都可以认领。这样做的好处是天然支持客户公海机制,新录入的客户可以先进公海,再由管理员或者销售自己抢购认领。

CREATE TABLE customer ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL COMMENT '客户名称', phone VARCHAR(20) COMMENT '联系电话', industry VARCHAR(50) COMMENT '所属行业', source VARCHAR(20) COMMENT '来源:转介绍/广告/展会/官网', level VARCHAR(10) COMMENT '级别:A潜在 B意向 C高意向 D成交', owner_id INT COMMENT '负责人ID,空为公海', status TINYINT DEFAULT 0 COMMENT '0公海 1跟进中 2已成交 3已流失', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_owner (owner_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';

注意update_time用ON UPDATE CURRENT_TIMESTAMP,这样只要客户信息变更,时间字段自动更新,排序"最近更新的客户"时直接用这个字段就行,省去手动维护。

跟进记录表要记录"谁在什么时间对哪个客户做了什么",这样回溯线索时有理有据:

CREATE TABLE follow_up ( id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL COMMENT '客户ID', user_id INT NOT NULL COMMENT '跟进人ID', content VARCHAR(1000) NOT NULL COMMENT '跟进内容', next_time DATETIME COMMENT '下次跟进时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_customer (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟进记录表';

商机表则记录了跟单过程中发现的潜在成交机会,它是从客户里抽象出来的一个"希望"字段:归哪个客户、预计金额多大、目前处于什么阶段。阶段我设计了几个枚举值:初次接触、需求确认、方案报价、商务谈判、赢单、输单,作用是把销售漏斗可视化,方便管理层看每个月的预估业绩。

2.3 权限模型与客户数据隔离

权限模型我用的是最简单可行的RBAC——一张用户表加一个role_id字段就够。管理员能看所有客户、所有跟进记录,能分配客户;销售只能看自己的客户和公海客户;客服的权限再收敛一些,比如只能查看和新增跟进,不能分配客户。按钮级别的权限控制,前端通过路由守卫和后端接口校验配合完成,不需要引入复杂的权限框架。

但这里有个容易疏忽的点:后端接口必须做数据隔离校验。销售请求客户列表时,后端要根据当前登录用户的ID去过滤,不能让他把别人的客户也查出来。我的做法是在Controller层用一个@LoginUser注解拿到当前用户信息,Service层判断:管理员直接全查,销售加上owner_id = 当前用户的条件,公海客户owner_id IS NULL就放给所有人查。这个逻辑如果不写清楚,系统等于裸奔。

3. SpringBoot后端:分层架构、JWT鉴权和MyBatis实践

3.1 项目分层与统一返回结构

后端项目我按照标准的四层结构来组织:Controller负责接收参数、Service负责业务处理、Mapper负责数据库访问、Entity/VO负责数据载体。实战中还会把VO和Entity分开,因为Entity对应数据库表结构,直接返回给前端往往多出很多不需要的字段,而且字段名也不友好。

统一返回结构是前后端协作的第一步。我定义了一个Result类,所有接口都返回这个结构的JSON:

public class Result<T> { private Integer code; // 200成功,401未登录,500异常 private String msg; // 提示信息 private T data; // 业务数据 }

前端拿到响应后先看code,code为200才处理data,否则直接弹出msg里的错误信息。这个约定一旦定下来,前后端双方都省心,不需要每个接口单独讨论返回什么格式。

统一返回还不够,还要配全局异常处理。用@RestControllerAdvice捕获业务异常和系统异常,转成Result.error返回。这样即使代码里漏了异常处理,也不会把一堆Java堆栈信息直接暴露给前端,既难看不安全。

3.2 JWT登录鉴权与拦截器

登录逻辑本身不复杂:前端提交用户名密码,后端校验BCrypt密文,通过后签发JWT返回给前端。前端把token存到localStorage里,之后每个请求都在header里带上。

JWT的核心代码封装:

// 登录接口简化版 @PostMapping("/login") public Result<String> login(@RequestBody LoginDTO dto) { User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error("账号已被停用"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRoleId()); return Result.success(token); }

拦截器负责校验token:从请求头拿到token,解析出用户信息,放进ThreadLocal。后续业务代码任意位置通过UserContext.getUserId()就能拿到当前登录用户ID,不用在每个接口里重复解析。这个设计看着简单,却是整个数据隔离的基础,客户归属判断全靠它。

有一个实战经验:拦截器放行名单必须单独配置。登录接口、静态资源、前端部署时预检请求OPTIONS都要放行,否则前端一调接口就报跨域错误。我在一个项目里就吃过这个亏,前端加了一堆CORS配置还是访问不通,排查半天发现是拦截器把OPTIONS请求给拦了。

3.3 MyBatis动态SQL与多表联查

MyBatis的XML写法是这套技术栈里最见功夫的地方。客户列表的分页多条件查询,我直接用动态SQL组合条件:

<select id="selectCustomerPage" resultType="com.example.crm.vo.CustomerVO"> SELECT c.*, u.real_name AS ownerName FROM customer c LEFT JOIN sys_user u ON c.owner_id = u.id <where> <if test="keyword != null and keyword != ''"> AND (c.name LIKE CONCAT('%', #{keyword}, '%') OR c.phone LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND c.status = #{status} </if> <if test="ownerId != null"> AND c.owner_id = #{ownerId} </if> <if test="publicPool != null and publicPool == true"> AND c.owner_id IS NULL </if> </where> ORDER BY c.update_time DESC </select>

凡是用户输入的搜索关键词,一律用#{}预编译传参,禁止用${}直接拼接,这是防SQL注入的底线。LIKE语句里用CONCAT拼接%号,目的是让数据库把整个条件当作参数绑定,而不是把关键字直接嵌进SQL字符串。

分页虽然可以用PageHelper插件一行搞定,但我更推荐在SQL里手动limit实现。原因很简单:PageHelper在复杂多表查询时偶尔出现count语句生成错误,而且它会自动改写SQL,排查问题的时候不容易定位。手动分页就是在Mapper方法里加两个参数offset和size,SQL末尾LIMIT #{offset}, #{size},逻辑透明、完全可控,配合一个简单的PageResult结构就能返回total和records。

3.4 后端开发中的几个关键心得

第一,实体字段命名。数据库下划线命名(owner_id),Java实体用驼峰(ownerId),在MyBatis配置文件里开启map-underscore-to-camel-case=true,结果自动映射,省去一大片resultMap。

第二,参数校验不能省。新增客户时字段的必填校验、手机号格式校验,后端必须做一遍,不能完全依赖前端。我用Spring的@Validated注解加自定义校验规则,同时也在Service层做业务校验一次,双保险。

第三,接口设计遵循RESTful风格但不过度纠结。我按资源设计URL:/api/customer、/api/follow-up、/api/user。GET查列表、POST新增、PUT修改、DELETE删除,一眼就能看懂。列表接口统一支持page、size、keyword几个通用参数,减少前端对接成本。

4. Vue前端:页面组织、数据绑定和接口对接

4.1 前端项目结构和axios封装

前端我选择Vue3加Element Plus。如果团队还在用Vue2,那一套写法也完全兼容这套思路。项目结构按照管理后台的惯例组织,每个模块的代码在独立的目录里,找起来非常方便:

src/ api/ // 接口请求封装 router/ // 路由配置 store/ // 全局状态管理 views/ // 页面组件 login/ // 登录页 customer/ // 客户管理 follow-up/ // 跟进记录 dashboard/ // 数据看板 system/ // 用户管理 components/ // 公共组件 utils/ // 工具函数

axios封装是整个前端的流量入口,非常重要。我在封装里统一做了三件事:携带token、处理业务错误、处理登录超时:

// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request

这样一个request.js封装好,所有页面的接口调用都走它,不需要每个页面各自处理错误弹窗和路由跳转,前端代码能少写三分之一。

4.2 客户管理页的完整实现思路

客户列表页是前端工作量最大的页面,拆解下来无非四块:搜索表单、数据表格、分页组件、新增编辑弹窗。

搜索区放一个表单,支持客户名称/电话模糊搜索,状态下拉(公海/跟进中/已成交/已流失),负责人下拉(拉取用户列表)。点"查询"按钮重新拉第一页数据,点"重置"清空条件。

表格列我展示了客户名称、电话、行业、级别、负责人、状态、最近跟进时间、最后跟进内容这些关键信息。这里有一个重要的用户体验细节:状态字段用标签展示,客户等级用颜色区分热度,一眼扫过去就能知道哪些客户值得重点跟进。

新增和编辑共用一个弹窗表单,表单校验规则用Element Plus的rules配置,比如客户名称必填、手机号格式校验。提交成功后刷新列表、关闭弹窗、清空表单,三步走完。

客户详情页则做得更有交互感:顶部是客户基本信息和操作按钮(分配、编辑、转为成交),下面一个左右布局或Tab切换,左边放联系人列表,右边放跟进时间线。时间线我是用Element Plus的el-timeline组件画的,每条跟进记录显示跟进人、内容、时间和下次跟进提醒,客户的整个跟进过程一目了然。

4.3 跟进记录录入与数据看板

跟进记录的录入非常高频,销售每次打电话、见完客户都要快速记一笔。我做成了一个独立的弹窗,在客户列表操作区和客户详情页都能打开,字段只有两个:跟进内容(必填)和下次跟进时间(选填),录入成本降到最低。

数据看板我用ECharts做了几个图表:按负责人统计客户数量(柱状图)、各状态客户占比(饼图)、近六个月的跟进趋势(折线图)、销售漏斗(按商机阶段)。后端提供对应的统计接口,前端拿数据直接渲染。图表类组件记得在组件卸载时销毁实例,否则页面来回切换会出现内存泄漏和图表重复渲染的问题。

前端还有一块容易忽略:路由权限。我在router里配置了meta.roles字段,路由守卫里读取当前登录用户的角色,没有权限的直接重定向到首页。这个方案对中小系统够用,但注意前端控制只是体验层面的,真正安全还是要后端接口兜底。

5. 前后端联调最容易翻车的几个细节

5.1 跨域配置

前后端分离第一个拦路虎就是跨域。开发环境我推荐用Vite或Vue CLI的devServer代理解决,前端开发服务器把/api开头的请求转发到后端8080端口,浏览器里看到的是同源请求,根本不存在跨域问题。配置非常简单:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

但生产环境如果也依赖跨域,问题就复杂了。我一般建议生产环境用Nginx反向代理同源部署,也就是前端静态文件和接口都通过域名访问,Nginx把/api转发到后端服务。这样前后端在浏览器视角看完全是同一个服务,跨域问题彻底消失。两种方案同时保留,开发环境用代理、生产环境用Nginx,这是最省心的组合。

5.2 时间格式和空值处理

联调时最容易出稀奇古怪问题的就是时间。SpringBoot默认的LocalDateTime序列化结果是带T的ISO格式,比如2024-05-06T14:30:00,前端直接展示简直没法看。我的处理方式是在配置里统一格式:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }

空值对前端交互影响也很大。比如最新跟进内容字段在数据库里是NULL,直接返回给前端,表格显示空白还算能忍,但如果前端代码没做判空直接去调toString或者length,直接白屏。我在后端统一把可能为空的字符串字段返回空字符串,前端再配合做兜底显示,双保险。

5.3 分页参数与状态码约定

分页接口的入参出参一定要在开发前就和前端约定清楚。我用的是page(从1开始)、size,返回结构是{ total, records }。前端拿到total算总页数,用records填充表格。这里特别提醒:有的前端组件库分页组件吐出来的页码是0开始的,有的后端分页插件起始页又是0或1,一定要确认在哪个环节从哪里减1加1,否则会出现"第二页和第一页数据一样"这种诡异问题。

状态码的约定也要花几分钟定清楚。我的约定是:200代表业务成功、401代表未登录或token过期、403代表没有权限、500代表服务器异常。特别要注意的是,登录超时不能只用后端状态码表示,前端响应拦截器里必须主动捕获401并跳转登录页,否则用户会看到接口报错,而不是被引导重新登录。

5.4 主键ID精度丢失

这是联调时一个非常隐蔽的坑。数据库自增主键INT类型一般没问题,但如果你用了雪花算法或者MyBatis-Plus的ASSIGN_ID生成19位Long型主键,前端JavaScript的Number类型是没办法精确表示这么大的数字的。比如ID是1891234567890123456,前端拿到后被转成1891234567890123500,修改数据时把错误的ID传给后端,后端查不到数据,接口报错却又说不清原因。

解决办法有三个:一是ID用数据库自增的INT类型,在小规模系统里根本不会有问题;二是如果非要雪花ID,后端把主键序列化成字符串返回;三是前端处理时统一按字符串处理。我建议这种中小型CRM系统直接用数据库自增ID就够了,安全可靠又简单,不需要为了"看起来高级"引入分布式ID方案。

6. 从0到1部署上线:jar包和Nginx的完整过程

6.1 服务器环境准备

部署环境我以一台云服务器为例,操作系统用比较常见的Linux发行版,最低配置2核4G,这个配置跑小型CRM系统绰绰有余。需要装的东西有:JDK(Java 8以上版本)、MySQL(8.0或5.7)、Nginx,以及git(方便拉取代码)。

MySQL安装完成后有两个必做的配置:字符集要设为utf8mb4,因为客户备注和跟进内容里可能有生僻字或表情符号,utf8mb4才能完整存储;时区要设置为Asia/Shanghai,否则SpringBoot连数据库时时间字段容易出现差8小时的问题。

数据库初始化这一步,直接把项目里的sql脚本导入即可。我习惯把建库、建表、初始admin账号、字典数据全部写在一个init.sql里,部署时一个命令搞定:

mysql -uroot -p < /opt/crm-sql/init.sql

6.2 后端打包与启动

后端打包非常简单,在项目根目录执行:

mvn clean package -DskipTests

target目录下会生成一个crm-server.jar,这个jar包含了所有依赖,只要服务器有JDK就能跑。上传到服务器后,我用一个专门的启动脚本管理进程:

#!/bin/bash JAR_NAME=/opt/crm-server/crm-server.jar LOG_FILE=/opt/crm-server/logs/crm.log case "$1" in start) nohup java -jar $JAR_NAME --spring.profiles.active=prod \ --server.port=8080 >> $LOG_FILE 2>&1 & echo "started" ;; stop) pid=$(ps -ef | grep $JAR_NAME | grep -v grep | awk '{print $2}') if [ -n "$pid" ]; then kill $pid; fi ;; restart) $0 stop; sleep 2; $0 start ;; esac

生产环境的数据库连接信息和开发环境不同,我用application-prod.yml单独放一套生产配置,启动时通过--spring.profiles.active=prod指定。这样开发环境默认配置、生产环境prod配置互不干扰,也防止把测试库连接串误带到生产。

6.3 前端构建与Nginx反向代理

前端构建前先确认环境变量:如果是静态打包,把接口baseURL写成相对路径/api,让请求走Nginx代理。构建命令:

npm install npm run build

构建完成后生成dist目录,把它整个上传到服务器的/opt/crm-web/dist目录。Nginx配置是部署的重头戏,既要托管前端静态文件,又要代理后端接口:

server { listen 80; server_name your-domain.com; gzip on; gzip_types text/plain text/css application/json application/javascript; root /opt/crm-web/dist; index index.html; # 前端history路由:找不到文件时回退到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; } # 静态资源缓存 location /assets/ { expires 7d; add_header Cache-Control "public"; } }

注意proxy_pass后面如果带路径写法不同,这里我写的是不带路径的地址,请求会原样转发到后端,因为后端接口本身就是/api开头的。如果写成http://127.0.0.1:8080/,就会把/api前缀剥掉,那后端Controller的@RequestMapping("/api/customer")就对不上了。这个细节我一开始也踩过坑,部署完发现404,浪费了不少时间排查。

配置改完后reload:

nginx -t nginx -s reload

6.4 部署后的验证流程与常见故障排查

部署完成后不要急着宣布上线,按下面的顺序自己完整走一遍:浏览器打开域名看到登录页;登录admin账号;新增一个测试客户,看保存是否成功;打开客户详情,录一条跟进记录;刷新页面,看列表和详情是否从后端正常拉取;退出登录,访问受保护页面,确认被重定向到登录页。

如果哪一步不通,按这个思路排查:

现象可能原因处理方式
页面打不开Nginx没启动或端口被防火墙挡看nginx状态,检查80端口监听和防火墙策略
页面能找到但接口全部500后端没启动或数据库连不上查看后端日志文件,重点看数据库连接和端口占用
接口全部404Nginx代理路径配置错误核对location /api和proxy_pass的组合写法
登录成功后刷新又跳登录token没存或路由守卫误判检查localStorage读写和路由守卫的登录判断逻辑
数据中文乱码数据库字符集不是utf8mb4检查MySQL配置和建库语句的字符集
上传的静态页面更新了,线上还是旧版浏览器缓存或Nginx缓存构建时文件名带hash,并配置/assets/缓存策略

还有一条我吃了很多亏才得到的经验:上线后看日志的第一优先级是后端日志文件不是前端控制台。前端报的错误往往是一句"Network Error",除了说明网络不通或者后端挂了之外没有太多信息,真正的根因都在后端日志里,比如SQL执行错误、空指针、端口冲突,看一眼就能定位。

整个系统做完,从业务梳理到数据库设计,从后端接口到前端页面,从本地联调到服务器部署,我最大的感受是:这类管理系统最大的技术门槛其实不在某个框架的某个高级特性,而在于把每一个基础环节做得干净利落。接口返回统一、权限校验落到实处、SQL能看懂能优化、部署流程可重复,这些"基本功"的排列组合,就是一套可靠系统的全部秘密。如果你也要做类似的系统,别急着堆功能,先把客户数据模型和接口约定敲定,后面的开发速度会快很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进