ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SSM框架的电影后台管理系统实战:从数据库设计到幂等性落地

基于SSM框架的电影后台管理系统实战:从数据库设计到幂等性落地 拿 Java 写一套电影后台管理系统听起来很像毕业设计里的标准作业但真正做下来才知道它其实是一个麻雀虽小、五脏俱全的 Web 工程。我最近基于 SSM 框架Spring SpringMVC MyBatis配合 MySQL从零搭建了一个可直接部署运行、能够支撑影院日常运营的电影后台管理 Web 系统覆盖影片管理、场次排片、分类管理、管理员权限、前端售票订单和运营数据统计。这套项目做完最大的收获不是“会写增删改查”而是弄清楚了框架整合的各类坑、数据表设计的取舍以及高并发场景下的幂等处理。不管你是正在准备 Java 面试的应届生还是刚进公司需要读懂 SSM 老项目、想快速上手的初级开发这篇实战记录都能帮你节省掉很多摸索时间。1. 项目总体设计与技术选型1.1 SSM 三剑客的分工逻辑很多新手接触这套系统时总把 SSM 当成一个整体来学结果被各种 XML 配置绕晕。实际上它是一套流水线Spring 负责管对象SpringMVC 负责接请求MyBatis 负责做数据库读写。Spring 是容器框架所有 Service、Mapper、DAO 对象都由它创建和组装依赖注入解决对象之间的解耦问题SpringMVC 是表现层框架前端发来的请求进来后由 DispatcherServlet 分发到对应的 Controller 方法参数绑定、视图渲染都在这一层完成MyBatis 则是持久层框架把 Java 方法和 SQL 语句通过 Mapper 映射文件关联起来把结果集自动映射成实体类。用一个生活化类比SpringMVC 是前台接待负责把顾客的需求HTTP 请求分配到不同窗口Spring 是后勤主管负责把人、物资Bean 对象安排到位MyBatis 是仓库管理员专门负责进库取货查数据和入库增删改。三者各管一段组合起来才是一个完整的 Web 请求处理链路。1.2 明明有 Spring Boot为什么还要用 SSM这个问题的答案放在两年前我会支支吾吾现在我可以明确说就是为了弄清楚框架底层。Spring Boot 的自动配置太舒服注解一点依赖一引项目就能跑。但很多开发者在 Boot 项目里不知道 DispatcherServlet 是怎么注册进去的、为什么配置文件叫 application.yml、MyBatis 的 SqlSessionFactory 是谁创建的。一旦遇到老项目维护或者面试官往深处问立刻露馅。SSM 项目的优势在于所有配置都是显式的web.xml、spring-mvc.xml、applicationContext.xml 每一行看得见摸得着。而且从求职角度说Java 后端面试里 SSM 是绝对高频话题“Spring 的 Bean 生命周期”“SpringMVC 的请求流程”“MyBatis 的 #{} 与 ${} 区别”这些知识点只有在手写配置的 SSM 项目中才会有强烈体感。先学 SSM 再切 Boot你会发现 Boot 那些约定大于配置的东西会容易理解得多。1.3 系统模块划分与业务流梳理电影后台管理系统核心是围绕“影片”和“场次”管理。我从业务角度拆成几个模块影片管理影片信息的增删改查封面图上传上映状态切换分类打标场次管理为某个影片在指定放映厅和时段创建场次决定售票排期分类管理影片类型、地区、年代等维度的维护用户权限后台管理员登录、会话校验、操作日志售票订单用户在侧购票后产生的订单数据统一管理、退改操作牵扯到前端售票的业务就会有订单表、订单明细表以及座位相关数据。整个系统的核心链路是管理员创建影片 - 配置影片分类和状态 - 创建放映厅和场次 - 前端用户下单 - 后台查看订单和统计数据。我建议在动手前先用笔把这条业务链画清楚别急着建表理清哪条数据先产生、哪条后产生建表时会省很多事。2. 数据库设计与关键表结构2.1 核心表一览10 张表的职责划分这套系统的数据库是整个项目的地基。我没用太多高深的建模理论就是按业务链拆。一共设计了 10 张核心表表名作用关键字段admin_user后台管理员username, password, salt, rolemovie影片信息主表movie_no, title, statuscategory影片分类name, type, sortmovie_category影片与分类关联表movie_id, category_idcinema_hall放映厅hall_no, name, seat_countschedule电影场次movie_id, hall_id, show_timecustomer前台注册用户mobile, passwordseat_order座位与订单关联schedule_id, seat_no, order_idorder_info购票订单主表order_no, customer_id, pay_statusoperation_log管理员操作日志admin_id, module, content, ip注意 movie 表里我专门设计了 movie_no 影片编号名称加业务唯一约束。因为真实系统中影片名称可能重复重名电影但编号必须唯一。这个字段同时是后续幂等控制的重要抓手。2.2 多对多关联与冗余字段的取舍在设计分类和影片的关系时我遇到第一个取取舍问题电影和分类是典型的多对多关系一部《流浪地球》既属于科幻又属于冒险。如果采用传统的三表movie、category、movie_category模型查询时要 join 一次列表页性能会有一定牺牲。如果直接冗余一个 category_name 字段那多分类情况就处理不了维护困难。解决方案是分类支持多选必须用关联表但考虑到列表页展示频繁我在 movie 表冗余了一个 category_ids 字段存逗号分隔的 ID查询时直接取出来拆开用只做展示后台编辑时仍然写关联表保证数据规范。这种“查询冗余加外键规范化”的做法是后台系统里特别常用的中间态方案。在电影管理这类读多写少的业务里收益非常明显。2.3 索引设计的基本盘索引不用多够用就行。我在电影管理里最常用的查询条件有两个状态 status 和上映日期 publish_date。所以给 movie 表建了组合索引 idx_status_publish(status, publish_date)查询“当前热映且按上映时间倒序排列”的影片时直接被索引覆盖。场次表 schedule 的高频查询条件是 movie_id 和 show_time因此建了联合索引 idx_movie_time(movie_id, show_time)。处理订单表时高频查询是“查某个用户的订单”我给 order_info 建了 (customer_id, create_time) 联合索引。尽量把排序字段放在索引末尾避免查询时产生 filesort。这里最核心的经验是不要看到字段就建单列索引要结合 WHERE 和 ORDER BY 条件优先建组合索引。3. SSM 整合配置实操从零搭建可运行项目3.1 依赖配置的版本陷阱SSM 项目用 Maven 管理依赖是最省心的但版本选择是第一个坑。切记不要用老教程里的 Spring 4 MyBatis 3.2 组合那套组合遇到 JDK 8 以上会出现各种兼容问题。我最终锁定的版本是Spring 5.1.20.RELEASE、MyBatis 3.5.5、mybatis-spring 2.0.6、MySQL 驱动 8.0.33JDK 用 1.8。这套组合经过大量项目验证稳定不出兼容问题。pom.xml 核心依赖如下dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.1.20.RELEASE/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.1.20.RELEASE/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.1.20.RELEASE/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.5/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.6/version /dependency /dependencies3.2 web.xml 与 SpringMVC 配置SSM 项目启动入口在 web.xml。这里面两个东西是必须的CharacterEncodingFilter 编码过滤器和 DispatcherServlet。字符编码过滤器一定要排在所有过滤器最前面否则 POST 请求的中文参数很容易乱码。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping /web-app3.3 Spring 与 MyBatis 的缝合Spring 和 MyBatis 整合的关键是 SqlSessionFactoryBean。它把数据源 DataSource 和 MyBatis 配置文件合在一起让 Spring 容器能够统一管理 SqlSession。我需要指定 scanned Mapper XML 文件路径classpath:mapper/*.xml。之后使用 MapperScannerConfigurer 扫描 DAO 接口包Spring 会自动为每个 Mapper 接口生成代理实现。核心 applicationContext.xml 配置context:property-placeholder locationclasspath:db.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver} / property nameurl value${jdbc.url} / property nameusername value${jdbc.username} / property namepassword value${jdbc.password} / property nameinitialSize value5 / property nameminIdle value5 / property namemaxActive value50 / /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource / property nameconfigLocation valueclasspath:mybatis-config.xml / property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.cinema.admin.dao / /bean这里有个高频踩坑点如果同时配置了 applicationContext.xml 同时还要用注解 MapperScan容易冲突二选一即可。我习惯用 MapperScannerConfigurer 全局配置代码里就有必要再写 Mapper。3.4 事务与 AOP 的落地配置事务是后台管理系统里绝不能省的东西尤其是订单操作用户退票、订单状态变更、座位释放如果中途抛异常必须整体回滚。我在 Spring 容器中配置了 DataSourceTransactionManager并开启注解驱动。bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource / /bean tx:annotation-driven transaction-managertransactionManager /在 Service 层凡是涉及到多个写操作的方法我都会加 Transactional(rollbackFor Exception.class)。有一点必须注意事务只对 Spring 管理的代理对象内部的 public 方法生效同一个类内部 A 方法调用自身 B 方法事务会失效这是新人最容易踩的坑。AOP 除了管事务我还用它做操作日志切面。后面 4.5 节会展示具体的切面代码。4. 核心业务功能实现与幂等设计4.1 电影多条件查询与分页电影列表页是整个后台最常用的页面正常运营人员每天要打开几十次。查询条件有分类、上映状态、影片名称关键字排序规则是按上映时间倒序。我用 PageHelper 做分页它本质上是一个 MyBatis 分页插件通过拦截器自动改写 SQL 生成 LIMIT 语句。分页参数直接用它的 PageInfo 封装省去手写 count 查询。dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.2.0/version /dependency注意一点PageHelper 是 ThreadLocal 机制调用 PageHelper.startPage 后紧接着执行的第一个查询方法会自动带上分页条件。如果中间夹杂了其他查询分页就会寄生在错误的 SQL 上。所以我的习惯是分页代码紧贴 mapper 调用语句中间不插入任何其他逻辑。如下PageHelper.startPage(pageNum, pageSize); ListMovieVO list movieMapper.selectMovieList(queryVO); PageInfoMovieVO pageInfo new PageInfo(list);4.2 登录会话与权限拦截后台管理系统的登录我没有引入 Spring Security因为对这个项目来说会显得过于重型。我采用 Session 拦截器的方式。用户登录成功后把管理员对象放入 Session写一个 LoginInterceptor拦截 /admin/** 路径下的所有请求。Os Session 里没有登录对象直接重定向到登录页。public class LoginInterceptor extends HandlerInterceptorAdapter { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute(loginAdmin) null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }登录密码不能明文存储。我用 MD5 加盐每个管理员注册时生成一个随机 salt存储密码时计算 MD5(password salt)。数据库中即便泄漏也无法直接看到明文。权限层面设计了角色字段1 超级管理员、2 运营、3 客服不同角色在前端菜单做差异化渲染后端仅对最核心的删除类操作做强校验。4.3 文件上传与图片管理影片海报上传是必经之路。SpringMVC 处理上传文件需要声明 MultipartResolver。我用 CommonsMultipartResolver设置单文件大小上限 10MB整个请求上限 20MB。实际项目中把文件直接写到工程目录的 upload 文件夹里是最简单的方式但有一个坑如果应用被重新部署Tomcat 临时目录和 work 目录会被清空历史图片会全部丢失。更稳的做法是将上传目录配置为服务器上的绝对路径比如 /data/cinema/poster/然后通过 Tomcat 虚拟目录映射或 nginx 静态映射将 /upload/poster/ 请求指向这个绝对路径。这样上传文件跟应用程序分离部署升级不丢数据。上传接口示例PostMapping(/admin/movie/posterUpload) public String uploadPoster(RequestParam(file) MultipartFile file, HttpServletRequest request) throws IOException { String uploadDir System.getProperty(cinema.upload.dir, /data/cinema/poster); File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } String filename UUID.randomUUID().toString().replace(-, ) _ file.getOriginalFilename(); file.transferTo(new File(dir, filename)); return /upload/poster/ filename; }4.4 幂等性设计防止重复提交的完整方案做这个电影后台系统时我最在意的一个问题就是幂等性。很多小系统上线后跑得也欢但一到关键场景就出事管理员网速慢双击了“添加影片”按钮结果库里多了两条相同数据票务接口被前端重试用户被扣两次款。这就是缺少幂等控制的典型症状。幂等Idempotent的意思很简单一个操作执行多次的结果和执行一次的结果完全一致。后台系统中最该做幂等的几个场景我理出来新增影片重复提交不应插入两条记录发布场次同一场次不应重复生成上架/下架操作重复上架不应产生多条操作日志订单支付回调重复通知不应重复改单我的解决方案分两层。第一层数据库层面业务唯一键 唯一索引。给 movie 表的 movie_no 建唯一索引 UNIQUE KEY uk_movie_no(movie_no)DB 层不允许重复编号出现。这样即便应用层没拦住数据库仍会拒绝第二条相同编号的数据这是兜底方案。第二层应用层面幂等拦截器。一般来说代码层我定义了一个 Idempotent 注解配合 SpringMVC 拦截器使用结合 Redis 的分布式锁实现防重。如果项目还没引入 Redis也可以用 ConcurrentHashMap 本地缓存实现但仅限于单机部署。实现思路是请求进入时生成一个幂等令牌 token存放在前端表单隐藏域或请求头里。拦截器检查这个 token 是否已被处理过如果是第一次则放行写入第二次再次到达则直接返回重复提示。核心注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { String key(); int expireSeconds() default 30; }拦截器实现public class IdempotentInterceptor extends HandlerInterceptorAdapter { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; Idempotent idempotent handlerMethod.getMethodAnnotation(Idempotent.class); if (idempotent ! null) { String token request.getHeader(X-Token); if (StringUtils.isBlank(token)) { token request.getParameter(token); } if (StringUtils.isBlank(token)) { throw new BizException(缺少幂等令牌); } String lockKey idempotent.key() : token; Boolean result redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(idempotent.expireSeconds())); if (Boolean.FALSE.equals(result)) { throw new BizException(请勿重复提交); } } } return true; } }创建影片接口上直接加注解Idempotent(key movie_add, expireSeconds 30) PostMapping(/admin/movie/add) public String addMovie(MovieAddDTO dto) { movieService.addMovie(dto); return success; }前端每次打开新增影片页时后台生成一个 uuid 作为 token 存在隐藏域提交时一并带上。这样即使运营人员狂点提交按钮后端也只会让第一个请求真正执行。这整套思路是我在实际生产经验里总结出来的放到任何表单提交和订单支付场景都能复用。4.5 操作日志切面实现后台管理系统必须留痕不然出了问题根本无法追责。我用 Spring AOP 做了一个操作日志切面凡是 Service 层被 OperationLog 注解标记的方法执行完都会记录操作人、操作模块、方法参数、耗时、IP 地址。切面代码模板Aspect Component public class OperationLogAspect { Around(annotation(com.cinema.admin.annotation.OperationLog)) public Object record(ProceedingJoinPoint pjp) throws Throwable { methodSignature ms (MethodSignature) pjp.getSignature(); OperationLog operationLog ms.getMethod().getAnnotation(OperationLog.class); long start System.currentTimeMillis(); Object result pjp.proceed(); long cost System.currentTimeMillis() - start; String content String.format(模块:%s, 耗时:%dms, 参数:%s, operationLog.module(), cost, Arrays.toString(pjp.getArgs())); operationLogService.save(content); return result; } }有一点需要提醒AOP 切面方法里不要做重数据库操作比如记录日志本身又去查询订单表这样会把接口耗时长抬升。日志内容保持轻量能说明谁在什么时间干了什么就行。5. 性能优化与安全加固5.1 慢SQL定位与优化实战上线后我最担心的是列表页越来越慢。在 MySQL 侧我打开慢查询日志slow_query_log ON slow_query_log_file /var/log/mysql/slow.log long_query_time 1上线运行一段时间后发现有一条 SQL 频繁出现根据用户 ID 查询近期订单。SELECT order_id, movie_id, schedule_id, pay_status, create_time FROM order_info WHERE customer_id 1001 ORDER BY create_time DESC LIMIT 20;执行计划显示它在 customer_id 主键上没有索引走了全表扫描在 create_time 上产生 filesort。原因就是 where 和 order 的字段没有收到一个组合索引里。加上 (customer_id, create_time) 联合索引后SQL 直接命中索引排序和过滤查询从 180 毫秒降到 8 毫秒。这个例子说明一个原则组合索引的字段顺序要与查询条件完全匹配条件字段在前排序字段在后。5.2 缓存设计方案后台系统的读请求远大于写请求这让我引入缓存非常自然。我用 Spring Cache 的 Cacheable 注解包了影片列表查询结果设过期时间 5 分钟。影片上下线和场次发布后需要手动调用 cache evict 刷新。这是最简单也最实用的缓存策略。一定注意缓存穿透和缓存雪崩问题。对于分页列表页如果缓存过期时间是统一的整点容易造成雪崩设置过期时间时加一个 1~2 分钟的随机偏移。对于频繁查询的空结果增加空值缓存防止恶意刷新把压力全打到数据库。5.3 安全防护三件套第一个是 SQL 注入这是 SSM 项目必须重视的点。MyBatis 中 #{} 使用预编译语句占位不拼接 SQL 字符串是安全的但也有人贪方便使用 ${}这样字符串直接拼接进 SQL存在注入漏洞。我的规则是所有条件值必须用 #{}只有极少数比如排序字段这种要动态拼接的才在代码层做白名单校验后使用 ${}。第二个是 XSS 攻击。后台管理系统如果允许在输入框里填脚本标签保存后再加载到页面就会被执行。我在保存电影简介、分类名称时使用 Jsoup 过滤器清洗内容移除 script 标签和 on 开头的 JavaScript 事件属性。这种过滤放在入口处统一处理比分散在各表单安全得多。第三个是 CSRF 跨站请求伪造。后台管理操作都是高权限请求攻击者只需诱导管理员访问一个恶意页面就能伪造表单提交。我在后台所有关键表单中加入随机的 csrfToken存入 session提交时校验一致才放行。Spring Security 自带这功能但用拦截器手动实现也非常轻量适合不想引入重量级框架的 SSM 项目。6. 部署上线与被坑实录6.1 Tomcat 部署与连接池配置项目打包成 war 后部署到 Tomcat我使用的环境是 JDK 1.8 Tomcat 8.5数据库用 MySQL 8。打包命令mvn clean package -DskipTestswar 包生成后放到 Tomcat 的 webapps 目录启动即可。不过连接池参数才是上线后的重点Druid 连接池的配置直接影响数据库连接稳定性。我推荐初始连接数 initialSize5、最小空闲连接数 minIdle5、最大活跃连接数 maxActive50并根据实际 Tomcat 并发线程数调整避免连接池突然被耗尽。连接 URL 上MySQL 8 驱动需要配置 serverTimezoneAsia/Shanghai否则时间和本地时间会差 8 小时。数据库连接配置我建议接入 JVM 环境变量而不直接写在 db.properties 里。在 Tomcat 启动脚本中传参很灵活比如 setenv.sh 里设置 MYSQL_URLSpring 配置文件中读取${MYSQL_URL}。这样避免不同环境的数据库密码泄露在代码仓库中。6.2 高频问题速查表我结合自己实际踩坑的经验整理了一张问题速查表基本覆盖 SSM 项目最常见的怪问题现象可能原因解决办法中文乱码编码过滤器缺失或顺序不对在 web.xml 最顶部配置 CharacterEncodingFilterforceEncodingtrue页面 404但 Controller 存在DispatcherServlet 拦截了静态资源用 mvc:resources 映射 /static/**或把静态资源放到 webjars 下Mapper 方法报 BindingExceptionMapper 接口扫描路径不对XML 的 namespace 错误检查 MapperScannerConfigurer 的 basePackage 和 Mapper XML 的 namespace 全限定类名事务不生效Service 内部方法自调用或 try/catch 吞掉异常通过外部代理调用或把 try/catch 去掉让事务管理器接住异常图片上传成功但无法访问文件存在 work 临时目录应用重启后被清空改用服务器绝对路径 nginx 静态映射MySQL 连接报 Public Key Retrieval is not allowedMySQL 8 默认认证插件要求公钥检索连接参数加 allowPublicKeyRetrievaltrue不建议在生产环境直接放开优先使用无此限制的认证方案PageHelper 分页数据错乱startPage 后混入了其他查询保证 startPage 与 mapper 查询中间无任何数据库操作最后顺便说一句这个项目后续扩展的方向很明确可以加入 Redis 做热点影片缓存引入 Spring Security 替代手写登录拦截器用 Docker 封装 Tomcat 和 MySQL 环境实现一键部署。后端技术选型虽然不像 Spring Boot 那么省心但把 SSM 这套链路打通后你会产生一种很踏实的掌控感明白了 Web 项目从前端请求到数据库落盘的完整路径。我个人做这种管理系统的最大体会是功能难点永远在于数据如何流转、边界能否兜住而不是代码写了多少行。幂等、事务、日志这些底层细节才是真正拉开普通项目和靠谱项目差距的地方。
返回列表