简介:基于SSM的客户管理系统是一套典型的Java Web实战项目,面向需要理解Spring、SpringMVC与MyBatis整合过程的开发者,以MVC设计模式实现客户信息与跟踪记录管理。前端使用HTML和jQuery构建交互页面,后端通过SpringMVC控制请求流转、Spring管理业务组件、MyBatis操作数据库,整体结构清晰,可用于课程设计、毕业设计或SSM入门练习。压缩包共64个文件、约6.14MB,以Java源码、XML配置、HTML页面、CSS样式、JS脚本及14张运行截图为主,并附有pom.xml和README说明,方便了解项目构建方式。项目采用标准Maven目录结构,管理员可维护客户信息增删改查和跟踪记录,通过Ajax与JSON实现异步交互,直接导入IDE即可运行,也能学习SSM整合与Mapper映射写法。目前已有110人学习下载,适合SSM开发者作为源码阅读和功能扩展的实用参考。
1. SSM客户管理系统:一个小团队的协作底座
大多数早期项目其实不是死在技术上,而是死在客户记录各自为政:今天谁跟进了哪家客户,合同谈到什么阶段,负责人是谁,全部依赖聊天记录和个人表格。基于SSM的客户管理系统,本质上就是把这些离散的客户信息收拢到一套统一的操作界面上。用户界面处理客户资料的增删改查,Spring管理业务对象的生命周期,SpringMVC暴露HTTP接口,MyBatis把对象和数据库表映射起来。这套组合虽然称不上新潮,但在内网办公、中小团队交付的落地性和可维护性上,至今依然是性价比靠前的方案。适合刚上手Java Web的开发者,也适合需要快速交付一个内部工具、又不希望被复杂框架绑定的团队。本文从数据建模到权限控制,完整走一遍核心链路。
2. 客户数据模型与SSM的职责划分
客户管理系统能不能用,第一关不是界面漂不漂亮,而是数据模型设计得是否贴近实际业务。把一个客户“管起来”,至少需要解决三个问题:客户本身的信息是什么、谁负责这个客户、对这个客户做过什么动作。三者对应到表结构上,就是客户表、用户表(或员工表)、跟进记录表。
2.1 客户表设计的必要字段
客户表是整个系统的核心聚合根,字段设计不仅要满足录入,还要给后续查询、统计、分配留好余地。建议的最小字段集如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int/bigint | 主键,自增 |
| customer_name | varchar(64) | 客户名称(公司名或个人姓名) |
| contact_person | varchar(32) | 联系人 |
| contact_phone | varchar(20) | 联系电话 |
| industry | varchar(32) | 所属行业 |
| source | varchar(32) | 客户来源(展会/转介绍/网络) |
| level | tinyint | 客户等级(1-A类,2-B类,3-C类) |
| owner_id | int | 当前归属人ID,关联用户表 |
| status | tinyint | 状态:0-未跟进,1-跟进中,2-已成交,3-已流失 |
| create_time / update_time | datetime | 创建与更新时间 |
关于owner_id,这个字段很容易在设计阶段被忽略,等到做客户分配功能时才补。建议一开始就加上,因为客户认领、销售业绩归属、数据权限过滤都依赖它。status字段不要用字符串,用数字字典,页面显示时再做映射,这样可以避免中文在不同字符集下引发排序和匹配问题。
2.1.1 为什么要把联系人和客户拆开
很多入门设计会把联系人直接做成客户表里的两三个字段,看起来省事,但一旦同一个客户有多个联系人,或者联系人发生变更,数据就会出现冗余和更新异常。正确做法是单独建一张contact表,通过customer_id关联客户表。跟进记录表则记录每次沟通内容、沟通方式、下次跟进时间,形成客户全生命周期的轨迹,后续统计转化率时这就是原始数据来源。三张表组合起来,才构成一个完整可用的客户管理底子。
2.2 SSM各层如何对上号
SSM是Spring、SpringMVC、MyBatis三个框架的组合,很多新手学框架时能独立写Demo,一旦组合起来就不知道每个类该放哪里。拿客户查询这个动作举例:
- JSP页面发起请求,SpringMVC的DispatcherServlet通过HandlerMapping找到对应的Controller方法。
- Controller从HttpServletRequest中取参数,调用Service接口,Service实现类里写业务逻辑(比如校验客户名是否重复、记录操作日志)。
- Service调用Mapper接口,Mapper接口由MyBatis动态代理生成实现类,SQL写在XML文件里。
- 查询结果逐层返回,页面用EL表达式或JSTL显示。
这里的关键分界线是:SQL只写数据存取,业务规则放Service,请求参数校验放Controller,JSP只做展示和简单格式化。个别团队把SQL散落在Service里,项目前期开发快,后期任何一个表结构调整都牵一发动全身,不建议这么做。
@Service public class CustomerServiceImpl implements CustomerService { @Autowired private CustomerMapper customerMapper; @Override public Customer getCustomerById(Integer id) { // 这里可以补充业务校验,例如记录查看日志 return customerMapper.selectByPrimaryKey(id); } }这段代码展示的是Service层的典型形态:使用@Service声明Spring Bean,通过@Autowired注入Mapper,核心业务方法里调用Mapper接口完成数据操作。getCustomerById返回的是持久化对象,Controller层可以直接使用,也可以用DTO再包一层,视项目复杂度而定。参数说明:如果返回的结果还要关联联系人列表,建议在这里拼装,而不是让Controller去查两次。
3. 从数据库到页面:MyBatis Mapper的实现细节
数据模型定好后,最实质的工作是把表操作转成MyBatis的Mapper接口和XML。这一步的代码量不会少,但都是机械劳动,真正有价值的是分页写法、条件拼接、关联查询的性能边界。这三个点掌握住,Mapper层基本不会给后续开发添堵。
3.1 Mapper接口与XML的三种参数传递方式
Mapper接口方法如果要传多个参数,不要写多个裸参数,可读性和扩展性都差。常见做法是三种:
- 单参数直接传,比如
selectByPrimaryKey(Integer id),XML中用#{id}取值。 - 多参数用
@Param注解命名,XML中用#{指定名称}匹配。 - 参数较多时直接封装成查询对象(QueryObject),比如
CustomerQuery,里面带分页页码、客户名称、状态等条件。
第三种做法在客户管理系统里最实用,因为客户列表的查询条件通常不止一个:按客户名模糊查、按归属人筛、按状态分组,如果每个条件都定义一个查询方法,Mapper里会堆出几十个近乎重复的SQL。用一个QueryObject统一接收,再用<where>标签动态拼接,一套SQL覆盖所有组合场景。
<select id="selectCustomerByCondition" resultType="com.example.pojo.Customer"> SELECT * FROM customer <where> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="ownerId != null"> AND owner_id = #{ownerId} </if> </where> ORDER BY create_time DESC </select>逻辑说明:<where>标签会自动去掉第一个多余的AND,省去手工拼接SQL时判断是否加WHERE的麻烦。<if>标签的作用是动态条件,查询对象里哪个属性有值,对应的条件才拼进SQL。参数说明:#{customerName}是预编译占位符,MyBatis会生成?占位符并传入参数,避免SQL注入;不要用${},它是字符串直接替换,用户输入一旦包含SQL片段就会出安全问题。
3.1.1 为什么不建议在这些场景用${}
页面传进来的排序字段名,如果直接拼到ORDER BY后面,确实${}比#{}好用,因为预编译占位符不能用在表名和列名位置。但正确的做法不是改用${},而是在Service层做字段白名单校验,只允许传入白名单内的字段名,非法值一律使用默认排序。客户管理系统里常见的导出排序、全字段搜索,都要走这个方案。
3.2 增删改操作需要关注的返回值和事务边界
写操作的Mapper方法返回值一定是int,表示受影响的行数。Service层拿到这个返回值后有两个用途:一是判断操作是否成功,0说明没有匹配到记录或数据未变更;二是用于事务回滚的判断依据。客户新增和跟进记录插入这两个动作要放到同一个事务里,@Transactional注解加在Service方法上,一旦第二步失败,第一步的客户数据不会被持久化。
@Transactional(rollbackFor = Exception.class) public void addCustomerWithTrack(Customer customer, String trackContent) { customerMapper.insertSelective(customer); CustomerTrack track = new CustomerTrack(); track.setCustomerId(customer.getId()); track.setContent(trackContent); customerTrackMapper.insertSelective(track); }参数说明:rollbackFor = Exception.class最关键,默认情况下Spring只对RuntimeException回滚,如果业务代码里抛出的是自定义的CheckedException(理论上就该抛出这种异常),不加这个参数事务不会回滚,已写入的数据就留在库里了。insertSelective是MyBatis生成的插入方法,只插入非空字段,避免null覆盖数据库默认值。
3.3 关联查询的性能边界
客户列表页经常需要同时展示客户名称、归属人和最新跟进时间,新手最容易的做法是一张SQL把三张表JOIN起来取数。数据量小没问题,数据量大了就会遇到性能拐点。常见做法是拆解查询路径:客户列表先只查customer表,归属人名称在页面用字典翻译,最新跟进时间用SQL子查询或者单独缓存。
SELECT c.id, c.customer_name, c.owner_id, c.status, (SELECT MAX(track.create_time) FROM customer_track track WHERE track.customer_id = c.id) AS last_track_time FROM customer c ORDER BY c.create_time DESC逻辑说明:子查询在customer表数据量万级以内时性能影响不大,因为customer_track表可以建一个复合索引(customer_id, create_time)。相比三表JOIN,这种写法每次只查主表,关联表只在需要时取数,页面的响应速度更稳定,也避免了大结果集下JOIN的内存消耗。如果后续数据量突破百万级,再把子查询换成冗余字段或定时统计表。
4. 客户列表页:Controller到底该写多少代码
客户管理系统里使用频率最高、也最能体现实战功底的,是客户列表页。它的边界感特别强:Controller写多了会膨胀,写少了页面拿不到合适的值。核心职责只有三件:接收参数、调Service凑数据、指定返回视图。
4.1 分页查询的标准写法
分页不要自己写LIMIT和OFFSET计算,虽然手动分页不复杂,但每个查询方法都写一遍偏移量,重复劳动没有价值。这里使用PageHelper插件,它通过拦截器在SQL执行前自动拼接LIMIT,业务代码里不需要出现任何分页逻辑。
@RequestMapping("/customer/list") public String list(CustomerQuery query, Model model) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<Customer> customers = customerService.queryCustomerList(query); PageInfo<Customer> pageInfo = new PageInfo<>(customers); model.addAttribute("pageInfo", pageInfo); return "customer/list"; }逻辑说明:PageHelper.startPage()只对下一条SQL语句生效,调用后紧接着必须是Mapper查询,中间不能有任何其他数据库操作,否则分页会作用在错误的SQL上。PageInfo是对分页数据的封装,除了数据列表,还有总条数、总页数、当前页、是否有上一页下一页等计算好了的属性,直接丢给页面循环渲染。参数说明:pageNum默认传1,pageSize默认传10,这两个值最好在Controller里做默认值兜底,避免用户手输一个字符串导致类型转换异常。
页面端渲染分页导航时,会用pageInfo.pageNum、pageInfo.pages、pageInfo.total这些属性拼HTML。注意页码连接要保留查询条件,比如用户筛选了状态为“跟进中”,翻到第3页后再点击第2页,搜索条件不能丢。做法是隐藏域携带查询参数提交,用GET方式拼接URL参数。使用POST提交列表查询有助于维持查询条件的可复制分享。
4.1.1 分页插件查总数为什么慢
PageHelper的count查询是自动生成的,当主查询SQL存在多表关联或者GROUP BY时,count语句可能没有被优化,导致列表页打开慢。排查方法是在MyBatis日志里看count SQL实际执行了什么。如果慢在count,可以用PageHelper.startPage(pageNum, pageSize, false)把count跳过,此时PageInfo里的total为0,手动写一个独立的count查询填上。
4.2 下拉框和字典数据的回显策略
客户列表页通常带筛选区,行业、来源、客户等级都是下拉框。下拉框的选项数据是相对固定的字典,不应该每次都去查数据库,更不建议写在JSP硬编码里。项目初期把字典表的数据在启动时加载进内存,存到application作用域或者一个静态Map里,页面用EL表达式直接输出。
编辑客户信息时,下拉框回显有一个常见坑:数据库里存的客户等级是数字1、2、3,页面下拉框当前选中项需要通过selected属性判断。用JSTL的<c:if>来判断即可。
<select name="level"> <c:forEach items="${levelMap}" var="entry"> <option value="${entry.key}" <c:if test="${customer.level == entry.key}">selected</c:if>> ${entry.value} </option> </c:forEach> </select>逻辑说明:levelMap是Controller传递给页面的字典集合,${customer.level}是当前客户对象的值,两者相等时输出selected属性,浏览器渲染后就是默认选中状态。参数说明:customer.level的类型要留意,如果数据库返回的是Byte或Integer,entry.key就要匹配成同类型,否则==比较为false,表单始终回显不了正确的下拉项。
4.3 新增和编辑用同一个页面
新增和编辑的共用是客户管理系统最常见的优化点,没必要写两个独立JSP。共用时Controller的add与edit方法都返回customer/form.jsp,但编辑要额外传递一个customer对象进Model。页面表单提交的action路径用/customer/save,save方法内部判断customer.id是否为空,决定调用insert还是update。
@RequestMapping("/customer/save") public String save(Customer customer) { if (customer.getId() == null) { customerService.addCustomer(customer); } else { customerService.updateCustomer(customer); } return "redirect:/customer/list"; }这段代码体现了实际项目里的惯例:判断主键是否为空来区分新增与编辑。表单页面非编辑场景下id字段是空字符串,SpringMVC在类型转换时会把空字符串转为null,因此这个判断是安全的。保存成功后用redirect:重定向,避免用户按F5刷新导致表单重复提交。参数说明:Customer是用@ModelAttribute自动封装的,要求表单字段名和Customer属性名保持一致,比如表单里的customerName、contactPerson要和类字段严格对应。
5. 客户分配与权限控制:SSM中的拦截器实战
客户管理系统如果只有单人使用,权限控制可以不做。但只要团队超过三个人,就涉及两个问题:谁能看到哪些客户、谁能把客户分配给谁。SSM的权限控制通常走拦截器方案,SpringMVC注册拦截器,在请求进入Controller之前校验登录状态和操作权限。不引入Spring Security这类重量级框架,是因为内部系统的权限模型比较简单,两三个角色加一个拦截器就够。
5.1 登录会话控制的最小实现
登录状态的保持依赖HttpSession。用户登录成功后,把用户ID和用户名放进session,后续请求在拦截器中检查session是否存在指定属性。这是一个任何SSM项目都要写的链路,而且必须放在Controller之前拦截请求。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }逻辑说明:preHandle在Handler方法执行前运行,返回false则中断请求,拦截器链上后续的执行器与视图渲染都不会进行。sendRedirect跳转登录页之后,用户从地址栏重新输入目标URL,登录成功后需要跳回原页面而不是固定的列表页,这就要在登录接口里把原始URL记录下来,放session里。参数说明:getSession().getAttribute如果返回null,不能直接向session写入跳转前地址,得在用户登录之前先把request.getRequestURI()存到一个临时参数里,登录成功后再取出拼接。
5.2 数据权限过滤
登录拦截只管“没登录不能用系统”,但客户数据本身的可见性还需要数据权限过滤。客户管理系统的常见规则是:普通用户只能看自己拥有(owner_id=当前登录用户ID)的客户,主管可以看全部客户的列表,并且可以执行客户分配操作。
数据权限的过滤不能靠前端隐藏入口,真正的防线在SQL层。查询客户列表时,Mapper的SQL必须动态拼上owner_id条件。用户角色通过session里的用户对象获取,Service层从session拿到当前用户ID,塞到查询条件里。
public List<Customer> queryMyCustomers(CustomerQuery query, Integer currentUserId, boolean isAdmin) { if (!isAdmin) { query.setOwnerId(currentUserId); } return customerMapper.selectCustomerByCondition(query); }逻辑说明:isAdmin判断在Service层做,避免Controller每个方法都写一遍。普通用户的query对象强制设置ownerId,这样即使页面被绕过,直接调接口也拿不到别人的客户数据。参数说明:这里的isAdmin不要存一个简单的布尔值,角色一旦扩展到三人以上,建议用角色编码字符串,比如"ADMIN"、"SALER"、"MANAGER",用hasRole("ADMIN")方式判断。
5.2.1 拦截器过滤范围
拦截器不能锁死所有路径,登录页、静态资源(JS、CSS、图片)、不需要鉴权的接口要放行。SpringMVC配置拦截器时用excludePathPatterns排除即可。注意放行顺序比匹配顺序优先级更高,mapping配置了/**,exclude配置了/login、/static/**、/css/**,常规写法就这样。
5.3 客户分配功能的前端实现
客户分配是主管的操作权限,页面通常是主管在列表页勾选多个客户,点击“分配”按钮,弹出归属人下拉框,选中某个销售后点确认,批量更新这些客户的owner_id。后端接收的是逗号拼接的ID字符串,在Service里一次性更新。
@Transactional public void assignCustomer(String customerIds, Integer newOwnerId) { if (StringUtils.isBlank(customerIds) || newOwnerId == null) { throw new ServiceException("分配参数不完整"); } List<Integer> ids = Arrays.stream(customerIds.split(",")) .map(Integer::valueOf).collect(Collectors.toList()); customerMapper.batchUpdateOwner(ids, newOwnerId); }逻辑说明:批量更新在主键IN条件中执行,为了保险,包上事务,分配是一个原子操作。另外,这不是简单改一条owner_id就完事的:分配成功后要向跟进记录表写入一条“客户由A转交给B”的记录,这样才能保留客户生命周期里的历史轨迹。参数说明:StringUtils如果用的是Apache Commons Lang的就注意isBlank和isEmpty的区别,isBlank会过滤空字符串,推荐使用。newOwnerId是归属人ID,前端下拉框绑定的value要和服务端用户表主键一致。
5.4 操作日志记录的一种轻量写法
内部系统的审计需求不会太强,但客户数据敏感,不建议完全裸奔。做法是单独建一张operation_log表,在Controller的写操作里调用日志Service记录谁在什么时间改了什么客户的关键字段。
常见做法是写一个方法:log("UPDATE", customerId, "客户名称: A->B", operatorId)。有精力的团队可以再用Spring AOP抽出一个@OpLog注解,落到方法上自动记录。不过AOP会降低代码可读性,对类名和方法签名都有约定要求,项目初期人工调用Logger更直观,排查问题时看代码就能明确知道日志从哪来的。
6. 客户数据导出:用SSM生成Excel文件的边界
客户管理系统做到后期,会频繁出现一个需求:把当前筛选条件下的客户列表导出成Excel。这类功能的实现方式很多,有直接用POI写文件的,有用EasyExcel的,也有在页面端把表格复制到剪贴板的。SSM项目的常见做法是用阿里开源的EasyExcel,API简洁,内存占用比原生POI小。重要的不是选哪个库,而是导出时响应头的设置和复杂表头的处理。
6.1 导出接口的实现
导出接口不跳视图,而是直接向HttpServletResponse写入文件字节流。关键点是Content-Disposition响应头,它决定了浏览器下载下来的文件名。文件名如果包含中文,必须做URL编码,否则会变成乱码或下载失败。
@RequestMapping("/customer/export") public void export(CustomerQuery query, HttpServletResponse response) throws IOException { List<Customer> customers = customerService.queryCustomerList(query); response.setContentType("application/vnd.ms-excel"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("客户数据", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), CustomerExcelVO.class) .sheet("客户列表") .doWrite(customers); }逻辑说明:attachment告诉浏览器这是一个需要下载的附件,不能直接在浏览器内打开。filename*与UTF-8''配合是RFC 5987规范写法,比直接拼filename=兼容性好。replaceAll("\\+", "%20")处理的是空格编码问题,URLEncoder会把空格转成+,但在HTTP头里空格应该编码为%20。参数说明:CustomerExcelVO是专门为导出设计的类,不能直接使用Customer实体,因为实体类里各种字段类型不一定和Excel期望的列格式匹配,而且实体类的字段很可能比导出列多。
6.2 导出大数据的排查思路
导出一两万条数据时,界面上看起来和一千条没有区别,但在大并发环境或者总数据量上来后,会有一类典型问题:用户点导出后请求久久不返回,或者返回后文件里数据不完整。排查思路按三部走:先看MySQL执行计划,确认没有因为分页插件影响而全表扫描;再看Controller里有没有把查询结果先转成JSON或一段大字符串滞留内存;最后看Tomcat的maxSwallowSize配置,避免响应流写一半被服务器截断。
如果导出数量和查询列表数量不一致,优先怀疑是查询逻辑里写了PageHelper.startPage,导致导出也被强行分页。解决方法是推进去一个pageSize=-1的特殊值,或者让导出方法走独立的Mapper查询,不经过带分页拦截的方法。
6.3 表格导出的验收清单
写完导出功能后,验证几步基本能全覆盖:
- 无数据时导出的是不是只有表头的空模板。
- 客户名称为空时Excel对应单元格是否为空白,而不是显示“null”。
- 状态字段是数字
1/2/3,导出后是否可读,建议导出前转换成中文状态描述。 - 筛选状态为“已成交”的客户,导出的文件里所有行都应该是已成交,不能用全量数据。
- 表中存在特殊字符(换行、逗号),Excel打开后是否错位或弹解析警告。
最后一点实际项目里经常踩中,客户名称包含英文逗号会导致CSV格式出错,xlsx格式没有这个问题但换行符会影响单元格高度。导出前对客户名称、地址这类自由文本做一次trim()和不可见字符过滤,能省去很多售后沟通成本。
本文还有配套的精品资源,点击获取