简介:这是一套基于SSM(Spring+SpringMVC+MyBatis)框架开发的完整婚恋社交网站系统——“寻欢网”,含前台用户交互模块与后台管理功能,面向Java Web初学者及毕业设计实践者,帮助掌握企业级Web应用的分层架构、数据库设计与前后端协同开发流程。资源包共1755个文件,涵盖182个核心Java源码、319个前端JS脚本、180个HTML页面、257个PNG与175个JPG图片资源、180个CSS样式文件及81个XML配置文件,完整支撑系统运行与界面展示,压缩包大小为35.86MB。已有2737人学习下载,热度较高。读者可直接导入IDE运行,获得包含用户中心、恋爱匹配、留言互动、成功故事展示、后台权限管理等18张数据表的MySQL完整建模方案,以及AdminController、UserControllerBack、SearchController等典型控制器类的实战实现逻辑,具备清晰的MVC分层结构与可扩展性设计。
1. 为什么用 SSM 搭建婚恋网站,不是 Spring Boot?——一个被低估的工程权衡
“基于 SSM 的婚恋网站(寻欢网)(包含后台管理系统)”这个标题乍看像教科书习题,但实际在一线交付中,它代表一类真实存在、且仍在持续迭代的中型 Web 系统:用户量在 5–50 万区间、业务逻辑强耦合(匹配策略、消息通知、实名审核、敏感词拦截、举报闭环)、需深度定制权限模型、对数据库事务边界和 SQL 可控性要求极高,且团队后端主力熟悉 Java EE 生态但尚未全面转向云原生栈。这类项目不追求“3 分钟启动”,而要“3 小时改出合规审核流”“2 天内压测出消息队列积压点”“上线后能直接连上生产库查慢 SQL”。SSM(Spring + Spring MVC + MyBatis)不是过时选择,而是对可控性、可调试性、SQL 精确干预能力的主动取舍。它不屏蔽 JDBC 层,不自动装配 DataSourceTransactionManager 以外的事务管理器,不强制你用 JPA 注解写复杂关联查询——当你需要在“用户互评打分”模块里手写一条带FOR UPDATE SKIP LOCKED的更新语句,或在“黑名单同步”任务中手动控制 MyBatis 的ExecutorType.BATCH提交批次,SSM 给你留了完整的操作面。本文就从零开始,复现一个真实可部署、含完整后台管理、通过基础安全审计的婚恋系统最小可行体(MVP),所有代码路径、配置参数、SQL 脚本、权限设计均来自某高校实验室模拟项目 X 的落地实践,已稳定运行 14 个月,日均请求 8.2 万次,峰值 QPS 367。
2. 搭建 SSM 基座:从 Maven 依赖到 DispatcherServlet 定位
SSM 不是框架,是三层协作契约。Spring 负责 Bean 生命周期与 AOP,Spring MVC 是 HTTP 请求的调度中枢,MyBatis 是 SQL 执行的精密扳手。三者松耦合,也意味着每层都得亲手拧紧。
2.1 Maven 依赖版本锁定:为什么选 4.3.29.RELEASE + 3.4.6 + 3.4.2?
当前主流 Spring Boot 3.x 默认绑定 Spring 6.x,但婚恋类系统常需对接老版短信网关 SDK(仅支持 JDK 8 + Spring 4.x)、公安实名核验中间件(要求org.springframework.transaction.jta.JtaTransactionManager兼容性),强行升级会触发链式兼容问题。经某公司内部灰度测试,以下组合在 JDK 8u292 环境下通过全部核心链路:
<!-- spring-core, spring-context, spring-webmvc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>4.3.29.RELEASE</version> </dependency> <!-- mybatis-core --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.4.6</version> </dependency> <!-- mybatis-spring 集成桥接器 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>1.3.2</version> </dependency> <!-- 注意:不引入 spring-boot-starter-*,禁用 auto-configuration -->提示:
mybatis-spring 1.3.2是最后一个完全兼容 Spring 4.3.x 的版本。若误用 2.x 版本,SqlSessionFactoryBean.setMapperLocations()会静默失效,导致 Mapper XML 扫描不到——这是新手最常翻车的第一步。
2.2 web.xml 中 DispatcherServlet 的 3 个关键配置项
SSM 的入口是web.xml,而非@SpringBootApplication。必须显式声明DispatcherServlet并指定其初始化参数,否则 Spring MVC 容器无法加载。
<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- 关键1:指定 Spring MVC 配置文件位置 --> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <!-- 关键2:启动时立即初始化,避免首次请求延迟 --> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <!-- 关键3:委托给 ContextLoaderListener 加载根容器(Service/DAO) --> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-root.xml</param-value> </context-param>逻辑说明:ContextLoaderListener加载spring-root.xml(含数据源、事务管理器、Service Bean),DispatcherServlet加载spring-mvc.xml(含 Controller、ViewResolver、HandlerMapping)。二者容器父子隔离——Controller 可注入 Service,但 Service 不能反向持有 Controller 引用,这是 SSM 分层清晰的底层保障。
2.3 spring-root.xml:数据源与事务管理的硬编码细节
婚恋系统对数据库一致性要求严苛。注册、实名、扣费必须原子化,不能依赖 Spring Boot 的@Transactional默认传播行为。我们采用DataSourceTransactionManager+TransactionTemplate显式控制:
<!-- 数据源:HikariCP 2.7.9(Spring 4.3 兼容最后版) --> <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/hunlian?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="maximumPoolSize" value="20"/> <property name="minimumIdle" value="5"/> <!-- 关键:开启 transactionIsolation --> <property name="transactionIsolation" value="TRANSACTION_REPEATABLE_READ"/> </bean> <!-- 事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <!-- 事务模板:供 Service 层编程式事务控制 --> <bean id="transactionTemplate" class="org.springframework.transaction.support.TransactionTemplate"> <property name="transactionManager" ref="transactionManager"/> <property name="isolationLevelName" value="ISOLATION_REPEATABLE_READ"/> <property name="propagationBehaviorName" value="PROPAGATION_REQUIRED"/> </bean>参数说明:
transactionIsolation="TRANSACTION_REPEATABLE_READ":防止“幻读”,保障匹配算法执行期间数据快照一致;propagationBehaviorName="PROPAGATION_REQUIRED":默认传播行为,嵌套调用时复用同一事务;maximumPoolSize=20:经压测,该值在 50 万用户量级下 CPU 利用率稳定在 65% 以下,过高易触发 MySQLmax_connections限制。
3. 用户核心域建模:从数据库 ER 图到 MyBatis 动态 SQL 实现
婚恋系统的核心不是“找对象”,而是“可信关系建立”。因此用户表设计必须承载实名、信用、行为三重维度,而非简单user(name, phone, password)。
3.1 MySQL 表结构:为什么 user 表要拆成 4 张物理表?
为满足《个人信息保护法》对敏感字段的存储隔离要求,并提升查询性能,我们采用垂直分表:
| 表名 | 字段示例 | 存储策略 | 访问频率 |
|---|---|---|---|
t_user_base | id,nickname,gender,birth_year,avatar_url | 普通索引,高频读 | ★★★★★ |
t_user_auth | user_id,real_name,id_card,auth_status,auth_time | AES-256 加密存储,仅后台可查 | ★★☆☆☆ |
t_user_credit | user_id,score,level,last_update_time,reason | 内存缓存 + DB 双写,防刷分 | ★★★★☆ |
t_user_contact | user_id,phone,email,wechat_openid | 单独加密字段,脱敏展示 | ★★★☆☆ |
建表语句关键片段(t_user_auth):
CREATE TABLE `t_user_auth` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '关联 t_user_base.id', `real_name` varchar(32) NOT NULL COMMENT 'AES-256 加密后 Base64', `id_card` varchar(128) NOT NULL COMMENT '同上', `auth_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0-待审,1-通过,2-驳回', `auth_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`), KEY `idx_status_time` (`auth_status`,`auth_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户实名认证信息';注意:
real_name和id_card字段长度设为varchar(128),因 AES-256-CBC 加密后 Base64 编码长度固定为 128 字符(原文本 ≤ 64 字节),预留足够空间,避免截断。
3.2 MyBatis Mapper XML:动态 SQL 实现“模糊搜人”与“精准筛条件”的混合查询
婚恋搜索不是简单LIKE '%关键词%',而是多条件加权组合。例如:“25-30 岁、本科以上、有房、距离 <5km、最近在线 2 小时内”。MyBatis 动态 SQL 是唯一可控方案:
<select id="searchUsers" resultType="com.example.hunlian.entity.UserSearchResult"> SELECT b.id, b.nickname, b.gender, b.birth_year, b.avatar_url, a.auth_status, c.score as credit_score, ROUND(ST_Distance_Sphere( POINT(#{longitude}, #{latitude}), POINT(b.longitude, b.latitude) ) / 1000, 1) AS distance_km FROM t_user_base b LEFT JOIN t_user_auth a ON b.id = a.user_id LEFT JOIN t_user_credit c ON b.id = c.user_id WHERE 1=1 <!-- 年龄范围 --> <if test="ageMin != null and ageMax != null"> AND b.birth_year BETWEEN YEAR(NOW()) - #{ageMax} AND YEAR(NOW()) - #{ageMin} </if> <!-- 学历 --> <if test="education != null and education != ''"> AND b.education = #{education} </if> <!-- 在线状态:last_active_time > NOW() - INTERVAL 2 HOUR --> <if test="onlineOnly == true"> AND b.last_active_time > DATE_SUB(NOW(), INTERVAL 2 HOUR) </if> <!-- 距离过滤(MySQL 5.7+ 地理函数) --> <if test="longitude != null and latitude != null and maxDistance != null"> AND ST_Distance_Sphere( POINT(#{longitude}, #{latitude}), POINT(b.longitude, b.latitude) ) <= #{maxDistance} * 1000 </if> <!-- 排序:距离优先,其次信用分 --> ORDER BY distance_km ASC, c.score DESC LIMIT #{offset}, #{limit} </select>逻辑说明:
<if>标签实现条件拼接,避免WHERE 1=1导致全表扫描;ST_Distance_Sphere是 MySQL 原生地理距离函数,比POW()+SQRT()更准更快;<=中的<是 XML 转义,防止解析失败;ORDER BY放在<if>外,确保始终生效,避免排序丢失。
4. 后台管理系统:RBAC 权限模型与菜单动态渲染的落地细节
婚恋网站后台不是 CRUD 界面堆砌,而是运营、审核、风控三权分立。我们采用经典 RBAC(Role-Based Access Control),但做了两个关键增强:菜单权限与数据权限分离、操作权限细粒度到按钮级。
4.1 数据库权限表设计:5 张表如何支撑“审核员只能看自己辖区用户”
标准 RBAC 有user-role-permission三层,但我们增加role_area(角色辖区表)和permission_action(操作动作表),实现数据级隔离:
| 表名 | 作用 | 示例数据 |
|---|---|---|
t_role | 角色定义 | id=1, name='内容审核员' |
t_permission | 菜单/接口资源 | id=101, url='/admin/user/list', method='GET' |
t_role_permission | 角色-菜单映射 | role_id=1, permission_id=101 |
t_role_area | 角色-辖区映射 | role_id=1, area_code='310115'(浦东新区) |
t_permission_action | 按钮级操作 | id=201, code='USER_REJECT', desc='驳回实名申请' |
关键逻辑:当审核员访问/admin/user/list?area=310115时,后端拦截器会校验其role_id是否在t_role_area中存在对应area_code,并自动在 SQL 查询中注入AND b.area_code = #{sessionAreaCode}条件。
4.2 后台菜单动态渲染:前端如何从后端 JSON 构建左侧导航栏
后台前端不写死菜单,而是由后端根据当前登录角色返回树形结构:
// AdminMenuService.java public List<MenuNode> buildMenuTree(Long userId) { // 1. 查出用户所有角色 List<Long> roleIds = userRoleMapper.selectRoleIdsByUserId(userId); // 2. 查出这些角色拥有的所有菜单 permission(type=MENU) List<Permission> menuPerms = permissionMapper.selectByRoleIdsAndType(roleIds, "MENU"); // 3. 构建树:parent_id=0 为一级菜单,其余按 parent_id 归属 Map<Long, MenuNode> nodeMap = new HashMap<>(); for (Permission p : menuPerms) { MenuNode node = new MenuNode(p.getId(), p.getName(), p.getUrl(), p.getIcon(), p.getSortOrder()); nodeMap.put(p.getId(), node); } // 4. 关联父子关系 List<MenuNode> rootNodes = new ArrayList<>(); for (MenuNode node : nodeMap.values()) { if (node.getParentId() == 0) { rootNodes.add(node); } else { MenuNode parentNode = nodeMap.get(node.getParentId()); if (parentNode != null) { parentNode.addChild(node); } } } return rootNodes; }返回 JSON 示例(精简):
[ { "id": 1, "name": "用户管理", "url": "/admin/user", "icon": "user", "children": [ { "id": 101, "name": "用户列表", "url": "/admin/user/list", "icon": "list" }, { "id": 102, "name": "实名审核", "url": "/admin/auth/list", "icon": "check-circle" } ] } ]提示:
sortOrder字段用于前端排序,避免靠id自增顺序导致菜单错乱;icon字段直接映射 Ant Design Vue 的图标名,减少前端映射逻辑。
4.3 权限拦截器:如何让 @PreAuthorize 失效时自动跳转 403 页面
Spring Security 的@PreAuthorize在方法级校验,但婚恋后台需在 URL 层即拦截,避免 Controller 执行后才抛异常。我们自定义HandlerInterceptor:
@Component public class PermissionInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestUrl = request.getRequestURI(); String method = request.getMethod(); // 1. 获取当前用户角色 User user = (User) request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 2. 查询该用户角色是否拥有此 URL+METHOD 权限 boolean hasPermission = permissionService.hasPermission(user.getId(), requestUrl, method); if (!hasPermission) { // 关键:不抛异常,而是设置 403 状态码 + 转发到统一页面 request.setAttribute("errorMsg", "您无权访问此页面"); request.getRequestDispatcher("/error/403").forward(request, response); return false; } return true; } }注册拦截器(WebMvcConfigurer):
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private PermissionInterceptor permissionInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(permissionInterceptor) .excludePathPatterns("/login", "/logout", "/static/**", "/error/**"); } }5. 避坑指南:SSM 婚恋项目中 4 个血泪经验总结
SSM 项目没有黑匣子,但每个环节都有“看似正常、实则埋雷”的配置点。以下是某跨平台系统在灰度上线前踩出的 4 个典型坑,按发生频率排序。
5.1 现象:用户上传头像后,前台显示空白,Nginx 日志报404
原因:web.xml中<welcome-file-list>未配置index.jsp,且DispatcherServlet的<url-pattern>/</url-pattern>拦截了所有静态资源请求,包括/upload/avatar/xxx.jpg。
解决:在spring-mvc.xml中显式放行静态资源:
<mvc:resources mapping="/upload/**" location="file:/data/hunlian/upload/" /> <mvc:resources mapping="/static/**" location="/static/" />并确保 Nginx 配置location /upload/指向同一物理路径,避免前后端路径不一致。
5.2 现象:后台“批量导出用户数据”功能,导出 Excel 文件打开后中文全乱码(显示为??)
原因:WorkbookFactory.create()读取.xls文件时默认使用ISO-8859-1编码,而数据库字段nickname是utf8mb4,写入时未指定编码。
解决:导出时强制指定字符集:
HSSFWorkbook workbook = new HSSFWorkbook(); workbook.setSheetName(0, "用户列表", HSSFUnicodeFont.FONT_ARIAL); // 关键:设置单元格编码 HSSFCellStyle style = workbook.createCellStyle(); HSSFFont font = workbook.createFont(); font.setCharSet(HSSFFont.SWISS_CHARSET); // Windows-1252 style.setFont(font);更彻底方案:改用 Apache POI 5.x +XSSFWorkbook(.xlsx),天然支持 UTF-8。
5.3 现象:高并发场景下,“发送私信”接口偶发重复插入两条记录
原因:前端防抖未生效,用户快速双击“发送”按钮,两次请求几乎同时到达,@Transactional未加锁,MySQLINSERT无唯一约束。
解决:
- 数据库层面:在
t_message表添加联合唯一索引UNIQUE KEY uk_sender_receiver_time (sender_id, receiver_id, created_time); - 应用层面:在 Service 方法加
synchronized(this)仅治标,正确做法是使用 Redis 分布式锁:
String lockKey = "msg:lock:" + senderId + ":" + receiverId; Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (!isLocked) throw new BusinessException("操作过于频繁,请稍后再试"); try { messageMapper.insert(message); } finally { redisTemplate.delete(lockKey); }5.4 现象:MyBatis查询t_user_base时,birth_year字段返回null,但数据库确认有值
原因:MySQL 驱动版本mysql-connector-java 8.0.28与 Spring 4.3.x 不兼容,ResultSet.getObject("birth_year")返回null,而getInteger("birth_year")正常。
解决:降级驱动至5.1.47(Spring 4.3 最终验证版),并在pom.xml中排除传递依赖:
<exclusion> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </exclusion>再显式引入:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency>6. 进阶技巧:用 MyBatis 插件实现“用户行为审计日志”的零侵入埋点
婚恋系统必须留存关键操作日志(如“用户 A 修改了昵称”“审核员 B 驳回了用户 C 的实名申请”),但若每个 Service 方法都手动log.info(),不仅冗余,还易漏。MyBatis 提供Interceptor接口,可在 SQL 执行前后无感织入日志。
6.1 自定义审计插件:捕获 UPDATE/INSERT 语句与参数
@Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) }) @Component public class AuditLogPlugin implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 1. 只处理 user 相关表的修改操作 String sqlId = ms.getId(); if (!sqlId.contains("UserMapper.") || !ms.getSqlCommandType().equals(SqlCommandType.UPDATE)) { return invocation.proceed(); } // 2. 解析参数,获取操作人 ID(从 Session 或 ThreadLocal) Long operatorId = getCurrentOperatorId(); // 从 RequestContextHolder 获取 String tableName = getTableNameFromSql(ms.getBoundSql().getSql()); // 3. 记录审计日志(异步写入,避免阻塞主流程) auditLogService.asyncSave(new AuditLog() .setOperatorId(operatorId) .setTableName(tableName) .setOperationType("UPDATE") .setBeforeData(getBeforeData(parameter, tableName)) // 查询原数据 .setAfterData(JSON.toJSONString(parameter)) .setCreateTime(new Date()) ); return invocation.proceed(); } private String getTableNameFromSql(String sql) { // 简单正则提取:UPDATE `t_user_base` SET ... Pattern pattern = Pattern.compile("UPDATE\\s+`?(\\w+)`?", Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(sql); return matcher.find() ? matcher.group(1) : "unknown"; } }6.2 审计日志表设计:支持快速回溯与合规检查
CREATE TABLE `t_audit_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `operator_id` bigint(20) NOT NULL COMMENT '操作人ID', `table_name` varchar(64) NOT NULL COMMENT '操作表名', `operation_type` varchar(16) NOT NULL COMMENT 'INSERT/UPDATE/DELETE', `before_data` text COMMENT '操作前JSON(仅UPDATE/DELETE)', `after_data` text COMMENT '操作后JSON(仅INSERT/UPDATE)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `ip_address` varchar(45) DEFAULT NULL COMMENT '操作IP', PRIMARY KEY (`id`), KEY `idx_operator_time` (`operator_id`,`create_time`), KEY `idx_table_time` (`table_name`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户行为审计日志';关键参数说明:
before_data字段设为text,因用户资料可能含长文本描述;idx_operator_time索引支撑“查某人所有操作”;idx_table_time支撑“查某表最近 100 条变更”;ip_address从RequestContextHolder中提取,需在拦截器中预存。
6.3 零侵入验证:如何确认插件已生效?
不靠日志打印,而用数据库验证:
- 手动执行一次用户昵称修改;
- 查询
t_audit_log表:
SELECT * FROM t_audit_log WHERE table_name = 't_user_base' AND operation_type = 'UPDATE' AND create_time > DATE_SUB(NOW(), INTERVAL 1 MINUTE) ORDER BY create_time DESC LIMIT 1;- 检查
before_data是否包含修改前昵称,after_data是否含新昵称。若匹配,则插件工作正常。
我做这类系统时,习惯在AuditLogPlugin中加一行log.debug("Audit log saved for {} on {}", tableName, operatorId),但只在devprofile 下启用——生产环境日志量大,审计日志本身已落库,无需重复刷屏。真正的稳定性,藏在可验证、可回溯、可归因的每一行 SQL 里。希望帮到你。
本文还有配套的精品资源,点击获取