1. 数据库设计
1.1 数据库选型
- 数据库:MySQL
- 理由:关系型数据库,支持复杂查询和事务处理,适合存储用户、匹配、聊天记录等结构化数据。
1.2 表结构设计
1.2.1 用户表(users)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
username | VARCHAR(50) | 用户名,唯一 |
password_hash | VARCHAR(255) | 密码哈希 |
| VARCHAR(100) | 邮箱,唯一 |
phone | VARCHAR(20) | 电话号码 |
skill_level | ENUM | 打球水平(初学者、进阶玩家、随便挥拍) |
interests | JSON | 兴趣标签 |
created_at | TIMESTAMP | 创建时间 |
updated_at | TIMESTAMP | 更新时间 |
1.2.2 匹配表(matches)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
user_id | BIGINT | 发起匹配的用户ID |
matched_user_id | BIGINT | 被匹配的用户ID |
status | ENUM | 匹配状态(待接受、已接受、已拒绝) |
created_at | TIMESTAMP | 创建时间 |
updated_at | TIMESTAMP | 更新时间 |
1.2.3 聊天记录表(messages)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
match_id | BIGINT | 所属匹配记录ID |
sender_id | BIGINT | 发送者用户ID |
receiver_id | BIGINT | 接收者用户ID |
message | TEXT | 消息内容 |
created_at | TIMESTAMP | 发送时间 |
1.2.4 评分表(ratings)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
match_id | BIGINT | 所属匹配记录ID |
rater_id | BIGINT | 评分者用户ID |
ratee_id | BIGINT | 被评分者用户ID |
rating | INT | 评分(1-5) |
comment | TEXT | 评论内容 |
created_at | TIMESTAMP | 评分时间 |
1.2.5 优惠券表(coupons)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
user_id | BIGINT | 所属用户ID |
code | VARCHAR(50) | 优惠券代码,唯一 |
type | ENUM | 优惠券类型(折扣、免费场次等) |
discount_amount | DECIMAL(10,2) | 折扣金额 |
is_used | BOOLEAN | 是否已使用 |
expires_at | TIMESTAMP | 过期时间 |
created_at | TIMESTAMP | 创建时间 |
updated_at | TIMESTAMP | 更新时间 |
1.2.6 积分表(points)
字段名 | 类型 | 描述 |
id | BIGINT | 主键,自增 |
user_id | BIGINT | 所属用户ID |
total_points | INT | 当前积分总数 |
history | JSON | 积分变动历史 |
created_at | TIMESTAMP | 创建时间 |
updated_at | TIMESTAMP | 更新时间 |
1.3 数据库关系图
users (1) <----> (N) matches <----> (N) messages
users (1) <----> (N) ratings
users (1) <----> (N) coupons
users (1) <----> (1) points
2. 后端开发(Spring Boot + MyBatis)
2.1 技术栈
- 框架:Spring Boot
- ORM 框架:MyBatis
- 安全:Spring Security, JWT
- 数据库:MySQL
- 实时通信:Spring WebSocket (STOMP协议)
- 构建工具:Maven
2.2 项目初始化
使用Spring Initializr创建项目,添加以下依赖:
- Spring Web
- MyBatis Framework
- Spring Security
- MySQL Driver
- Spring WebSocket
- Lombok (可选)
- Spring Boot DevTools (可选)
示例:pom.xml(Maven)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...><modelVersion>4.0.0</modelVersion><groupId>com.example</groupId><artifactId>badminton-social-app</artifactId><version>0.0.1-SNAPSHOT</version><name>Badminton Social App</name><description>羽毛球社交匹配小程序/H5</description><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.5</version><relativePath/> <!-- lookup parent from repository --></parent><dependencies><!-- Spring Boot Starter Dependencies --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- MyBatis Starter --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.2.0</version></dependency><!-- Spring Security --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!-- JWT --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><!-- MySQL Driver --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><!-- WebSocket --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><!-- Lombok (Optional) --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><!-- DevTools (Optional) --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</scope></dependency></dependencies><build><plugins><!-- Spring Boot Maven Plugin --><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
2.3 配置文件
application.properties
# 数据库配置
spring.datasource.url=jdbc:mysql://localhost:3306/badminton_social?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=your_password# MyBatis 配置
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.example.badminton_social_app.model# JWT 配置
jwt.secret=YourJWTSecretKey
jwt.expiration=86400000 # 1天的毫秒数# WebSocket 配置
spring.websocket.path=/ws
2.4 实体类设计
使用MyBatis不需要复杂的实体类注解,但为了方便,可以使用Lombok简化代码。
2.4.1 用户实体(User)
package com.example.badminton_social_app.model;import lombok.Data;import java.sql.Timestamp;@Data
public class User {private Long id;private String username;private String passwordHash;private String email;private String phone;private String skillLevel; // 使用字符串表示ENUMprivate String interests; // JSON字符串private Timestamp createdAt;private Timestamp updatedAt;
}
2.4.2 匹配实体(Match)
package com.example.badminton_social_app.model;import lombok.Data;import java.sql.Timestamp;@Data
public class Match {private Long id;private Long userId;private Long matchedUserId;private String status; // 使用字符串表示ENUMprivate Timestamp createdAt;private Timestamp updatedAt;
}
2.4.3 聊天记录实体(Message)
package com.example.badminton_social_app.model;import lombok.Data;import java.sql.Timestamp;@Data
public class Message {private Long id;private Long matchId;private Long senderId;private Long receiverId;private String message;private Timestamp createdAt;
}
2.4.4 评分实体(Rating)
package com.example.badminton_social_app.model;import lombok.Data;import java.sql.Timestamp;@Data
public class Rating {private Long id;private Long matchId;private Long raterId;private Long rateeId;private Integer rating;private String comment;private Timestamp createdAt;
}
2.4.5 优惠券实体(Coupon)
package com.example.badminton_social_app.model;import lombok.Data;import java.math.BigDecimal;
import java.sql.Timestamp;@Data
public class Coupon {private Long id;private Long userId;private String code;private String type; // 使用字符串表示ENUMprivate BigDecimal discountAmount;private Boolean isUsed;private Timestamp expiresAt;private Timestamp createdAt;private Timestamp updatedAt;
}
2.4.6 积分实体(Point)
package com.example.badminton_social_app.model;import lombok.Data;import java.sql.Timestamp;@Data
public class Point {private Long id;private Long userId;private Integer totalPoints;private String history; // JSON字符串private Timestamp createdAt;private Timestamp updatedAt;
}
2.5 Mapper 接口与 XML 映射
2.5.1 UserMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.Optional;@Mapper
public interface UserMapper {User findByUsername(@Param("username") String username);User findByEmail(@Param("email") String email);void insertUser(User user);User findById(@Param("id") Long id);void updateUser(User user);
}
XML 映射文件: src/main/resources/mapper/UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.UserMapper"><resultMap id="UserResultMap" type="com.example.badminton_social_app.model.User"><id property="id" column="id" /><result property="username" column="username" /><result property="passwordHash" column="password_hash" /><result property="email" column="email" /><result property="phone" column="phone" /><result property="skillLevel" column="skill_level" /><result property="interests" column="interests" /><result property="createdAt" column="created_at" /><result property="updatedAt" column="updated_at" /></resultMap><select id="findByUsername" parameterType="String" resultMap="UserResultMap">SELECT * FROM users WHERE username = #{username}</select><select id="findByEmail" parameterType="String" resultMap="UserResultMap">SELECT * FROM users WHERE email = #{email}</select><select id="findById" parameterType="Long" resultMap="UserResultMap">SELECT * FROM users WHERE id = #{id}</select><insert id="insertUser" parameterType="com.example.badminton_social_app.model.User">INSERT INTO users (username, password_hash, email, phone, skill_level, interests, created_at, updated_at)VALUES (#{username}, #{passwordHash}, #{email}, #{phone}, #{skillLevel}, #{interests}, NOW(), NOW())</insert><update id="updateUser" parameterType="com.example.badminton_social_app.model.User">UPDATE usersSETusername = #{username},password_hash = #{passwordHash},email = #{email},phone = #{phone},skill_level = #{skillLevel},interests = #{interests},updated_at = NOW()WHERE id = #{id}</update></mapper>
2.5.2 MatchMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.Match;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface MatchMapper {void insertMatch(Match match);List<Match> findByUserId(@Param("userId") Long userId);List<Match> findByMatchedUserId(@Param("matchedUserId") Long matchedUserId);Match findById(@Param("id") Long id);void updateMatchStatus(@Param("id") Long id, @Param("status") String status);
}
XML 映射文件: src/main/resources/mapper/MatchMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.MatchMapper"><resultMap id="MatchResultMap" type="com.example.badminton_social_app.model.Match"><id property="id" column="id" /><result property="userId" column="user_id" /><result property="matchedUserId" column="matched_user_id" /><result property="status" column="status" /><result property="createdAt" column="created_at" /><result property="updatedAt" column="updated_at" /></resultMap><insert id="insertMatch" parameterType="com.example.badminton_social_app.model.Match">INSERT INTO matches (user_id, matched_user_id, status, created_at, updated_at)VALUES (#{userId}, #{matchedUserId}, #{status}, NOW(), NOW())</insert><select id="findByUserId" parameterType="Long" resultMap="MatchResultMap">SELECT * FROM matches WHERE user_id = #{userId}</select><select id="findByMatchedUserId" parameterType="Long" resultMap="MatchResultMap">SELECT * FROM matches WHERE matched_user_id = #{matchedUserId}</select><select id="findById" parameterType="Long" resultMap="MatchResultMap">SELECT * FROM matches WHERE id = #{id}</select><update id="updateMatchStatus">UPDATE matchesSET status = #{status}, updated_at = NOW()WHERE id = #{id}</update></mapper>
2.5.3 MessageMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.Message;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface MessageMapper {void insertMessage(Message message);List<Message> findByMatchId(@Param("matchId") Long matchId);
}
XML 映射文件: src/main/resources/mapper/MessageMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.MessageMapper"><resultMap id="MessageResultMap" type="com.example.badminton_social_app.model.Message"><id property="id" column="id" /><result property="matchId" column="match_id" /><result property="senderId" column="sender_id" /><result property="receiverId" column="receiver_id" /><result property="message" column="message" /><result property="createdAt" column="created_at" /></resultMap><insert id="insertMessage" parameterType="com.example.badminton_social_app.model.Message">INSERT INTO messages (match_id, sender_id, receiver_id, message, created_at)VALUES (#{matchId}, #{senderId}, #{receiverId}, #{message}, NOW())</insert><select id="findByMatchId" parameterType="Long" resultMap="MessageResultMap">SELECT * FROM messages WHERE match_id = #{matchId} ORDER BY created_at ASC</select></mapper>
2.5.4 RatingMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.Rating;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface RatingMapper {void insertRating(Rating rating);List<Rating> findByRateeId(@Param("rateeId") Long rateeId);
}
XML 映射文件: src/main/resources/mapper/RatingMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.RatingMapper"><resultMap id="RatingResultMap" type="com.example.badminton_social_app.model.Rating"><id property="id" column="id" /><result property="matchId" column="match_id" /><result property="raterId" column="rater_id" /><result property="rateeId" column="ratee_id" /><result property="rating" column="rating" /><result property="comment" column="comment" /><result property="createdAt" column="created_at" /></resultMap><insert id="insertRating" parameterType="com.example.badminton_social_app.model.Rating">INSERT INTO ratings (match_id, rater_id, ratee_id, rating, comment, created_at)VALUES (#{matchId}, #{raterId}, #{rateeId}, #{rating}, #{comment}, NOW())</insert><select id="findByRateeId" parameterType="Long" resultMap="RatingResultMap">SELECT * FROM ratings WHERE ratee_id = #{rateeId} ORDER BY created_at DESC</select></mapper>
2.5.5 CouponMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.Coupon;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface CouponMapper {void insertCoupon(Coupon coupon);List<Coupon> findByUserId(@Param("userId") Long userId);Coupon findByCode(@Param("code") String code);void updateCoupon(Coupon coupon);
}
XML 映射文件: src/main/resources/mapper/CouponMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.CouponMapper"><resultMap id="CouponResultMap" type="com.example.badminton_social_app.model.Coupon"><id property="id" column="id" /><result property="userId" column="user_id" /><result property="code" column="code" /><result property="type" column="type" /><result property="discountAmount" column="discount_amount" /><result property="isUsed" column="is_used" /><result property="expiresAt" column="expires_at" /><result property="createdAt" column="created_at" /><result property="updatedAt" column="updated_at" /></resultMap><insert id="insertCoupon" parameterType="com.example.badminton_social_app.model.Coupon">INSERT INTO coupons (user_id, code, type, discount_amount, is_used, expires_at, created_at, updated_at)VALUES (#{userId}, #{code}, #{type}, #{discountAmount}, #{isUsed}, #{expiresAt}, NOW(), NOW())</insert><select id="findByUserId" parameterType="Long" resultMap="CouponResultMap">SELECT * FROM coupons WHERE user_id = #{userId} AND expires_at > NOW()</select><select id="findByCode" parameterType="String" resultMap="CouponResultMap">SELECT * FROM coupons WHERE code = #{code}</select><update id="updateCoupon" parameterType="com.example.badminton_social_app.model.Coupon">UPDATE couponsSETis_used = #{isUsed},updated_at = NOW()WHERE id = #{id}</update></mapper>
2.5.6 PointMapper
接口:
package com.example.badminton_social_app.mapper;import com.example.badminton_social_app.model.Point;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.Optional;@Mapper
public interface PointMapper {void insertPoint(Point point);Point findByUserId(@Param("userId") Long userId);void updatePoint(Point point);
}
XML 映射文件: src/main/resources/mapper/PointMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton_social_app.mapper.PointMapper"><resultMap id="PointResultMap" type="com.example.badminton_social_app.model.Point"><id property="id" column="id" /><result property="userId" column="user_id" /><result property="totalPoints" column="total_points" /><result property="history" column="history" /><result property="createdAt" column="created_at" /><result property="updatedAt" column="updated_at" /></resultMap><insert id="insertPoint" parameterType="com.example.badminton_social_app.model.Point">INSERT INTO points (user_id, total_points, history, created_at, updated_at)VALUES (#{userId}, #{totalPoints}, #{history}, NOW(), NOW())</insert><select id="findByUserId" parameterType="Long" resultMap="PointResultMap">SELECT * FROM points WHERE user_id = #{userId}</select><update id="updatePoint" parameterType="com.example.badminton_social_app.model.Point">UPDATE pointsSETtotal_points = #{totalPoints},history = #{history},updated_at = NOW()WHERE id = #{id}</update></mapper>
2.6 安全配置
2.6.1 JWT 工具类
创建一个用于生成和验证JWT的工具类。
package com.example.badminton_social_app.util;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;import java.util.Date;
import java.util.HashMap;
import java.util.Map;@Component
public class JwtUtil {@Value("${jwt.secret}")private String secret;@Value("${jwt.expiration}")private Long expiration;public String generateToken(String username) {Map<String, Object> claims = new HashMap<>();return Jwts.builder().setClaims(claims).setSubject(username).setIssuedAt(new Date(System.currentTimeMillis())).setExpiration(new Date(System.currentTimeMillis() + expiration)).signWith(SignatureAlgorithm.HS512, secret).compact();}public String extractUsername(String token) {return getClaims(token).getSubject();}public boolean validateToken(String token, String username) {final String extractedUsername = extractUsername(token);return (extractedUsername.equals(username) && !isTokenExpired(token));}private Claims getClaims(String token) {return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}private boolean isTokenExpired(String token) {return getClaims(token).getExpiration().before(new Date());}
}
2.6.2 UserDetailsService 实现
实现Spring Security的UserDetailsService
接口,用于加载用户详情。
package com.example.badminton_social_app.service;import com.example.badminton_social_app.mapper.UserMapper;
import com.example.badminton_social_app.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;import java.util.ArrayList;@Service
public class CustomUserDetailsService implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {User user = userMapper.findByUsername(username);if (user == null) {throw new UsernameNotFoundException("User not found with username: " + username);}return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPasswordHash(),new ArrayList<>());}
}
2.6.3 安全配置类
package com.example.badminton.config;import com.example.badminton.security.JwtRequestFilter;
import com.example.badminton.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration
@EnableWebSecurity
public class SecurityConfig extends org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter {@Autowiredprivate CustomUserDetailsService userDetailsService;@Autowiredprivate JwtRequestFilter jwtRequestFilter;@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 配置用户详情服务和密码编码器auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());}@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}// 暴露AuthenticationManager为Bean,以便在Controller中使用@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.csrf().disable().authorizeRequests().antMatchers("/api/v1/auth/**").permitAll() // 认证相关接口无需认证.anyRequest().authenticated() // 其他接口需要认证.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 不使用Session// 添加JWT过滤器http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);}
}
2.6.4 JWT 请求过滤器(继续)
继续完善 JwtRequestFilter.java 类,用于在每个请求中提取和验证JWT。
package com.example.badminton.security;import com.example.badminton.service.CustomUserDetailsService;
import com.example.badminton.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;@Component
public class JwtRequestFilter extends OncePerRequestFilter {@Autowiredprivate CustomUserDetailsService userDetailsService;@Autowiredprivate JwtUtil jwtUtil;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {final String authorizationHeader = request.getHeader("Authorization");String username = null;String jwt = null;// 提取JWT Tokenif (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {jwt = authorizationHeader.substring(7);try {username = jwtUtil.extractUsername(jwt);} catch (ExpiredJwtException e) {logger.warn("JWT Token已过期");} catch (Exception e) {logger.error("无法解析JWT Token");}}// 如果用户名不为空且当前安全上下文没有认证if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);// 验证Tokenif (jwtUtil.validateToken(jwt, userDetails)) {UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));// 设置认证信息到安全上下文SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);}}chain.doFilter(request, response);}
}
2.6.5 JWT 工具类(JwtUtil.java)
用于生成和验证JWT的工具类。
package com.example.badminton.util;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.security.core.userdetails.UserDetails;import java.util.Date;
import java.util.HashMap;
import java.util.Map;@Component
public class JwtUtil {@Value("${jwt.secret}")private String secret;@Value("${jwt.expiration}")private Long expiration;// 生成Tokenpublic String generateToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();// 可以在这里添加额外的claims,如角色等return Jwts.builder().setClaims(claims).setSubject(userDetails.getUsername()).setIssuedAt(new Date(System.currentTimeMillis())).setExpiration(new Date(System.currentTimeMillis() + expiration)).signWith(SignatureAlgorithm.HS512, secret).compact();}// 从Token中提取用户名public String extractUsername(String token) {return getClaims(token).getSubject();}// 验证Tokenpublic boolean validateToken(String token, UserDetails userDetails) {final String username = extractUsername(token);return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));}// 获取所有Claimsprivate Claims getClaims(String token) {return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}// 检查Token是否过期private boolean isTokenExpired(String token) {return getClaims(token).getExpiration().before(new Date());}
}
2.7 控制器实现
2.7.1 认证控制器(AuthController.java)
处理用户注册和登录请求。
package com.example.badminton.controller;import com.example.badminton.model.User;
import com.example.badminton.model.Point;
import com.example.badminton.payload.LoginRequest;
import com.example.badminton.payload.RegisterRequest;
import com.example.badminton.payload.JwtResponse;
import com.example.badminton.payload.ResponseMessage;
import com.example.badminton.repository.UserMapper;
import com.example.badminton.repository.PointMapper;
import com.example.badminton.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;import java.util.Optional;@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate UserMapper userMapper;@Autowiredprivate PointMapper pointMapper;@Autowiredprivate PasswordEncoder passwordEncoder;@Autowiredprivate JwtUtil jwtUtil;@PostMapping("/register")public ResponseEntity<?> registerUser(@RequestBody RegisterRequest registerRequest) {// 检查用户名是否存在Optional<User> existingUser = userMapper.findByUsername(registerRequest.getUsername());if (existingUser.isPresent()) {return ResponseEntity.badRequest().body(new ResponseMessage("用户名已存在,请选择其他用户名。"));}// 检查邮箱是否存在existingUser = userMapper.findByEmail(registerRequest.getEmail());if (existingUser.isPresent()) {return ResponseEntity.badRequest().body(new ResponseMessage("邮箱已注册,请使用其他邮箱。"));}// 创建新用户User user = new User();user.setUsername(registerRequest.getUsername());user.setPasswordHash(passwordEncoder.encode(registerRequest.getPassword()));user.setEmail(registerRequest.getEmail());user.setPhone(registerRequest.getPhone());user.setSkillLevel(registerRequest.getSkillLevel());user.setInterests(String.join(",", registerRequest.getInterests())); // 将List转换为逗号分隔的字符串userMapper.insertUser(user);// 初始化积分Point point = new Point();point.setUserId(user.getId());point.setTotalPoints(0);point.setHistory(""); // 可根据需求初始化pointMapper.insertPoint(point);return ResponseEntity.ok(new ResponseMessage("注册成功。"));}@PostMapping("/login")public ResponseEntity<?> createAuthenticationToken(@RequestBody LoginRequest loginRequest) throws Exception {try {authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));} catch (BadCredentialsException e) {return ResponseEntity.status(401).body(new ResponseMessage("无效的用户名或密码。"));}final UserDetails userDetails = userMapper.findByUsername(loginRequest.getUsername()).map(user -> org.springframework.security.core.userdetails.User.builder().username(user.getUsername()).password(user.getPasswordHash()).roles("USER") // 根据实际情况设置角色.build()).orElseThrow(() -> new Exception("用户未找到"));final String jwt = jwtUtil.generateToken(userDetails);return ResponseEntity.ok(new JwtResponse(jwt));}
}
2.7.2 请求与响应模型
定义请求和响应的Payload类。
package com.example.badminton.payload;import lombok.Data;import java.util.List;// 用户注册请求
@Data
public class RegisterRequest {private String username;private String password;private String email;private String phone;private SkillLevel skillLevel;private List<String> interests;
}// 用户登录请求
@Data
public class LoginRequest {private String username;private String password;
}// JWT响应
@Data
public class JwtResponse {private String jwt;public JwtResponse(String jwt) {this.jwt = jwt;}
}// 通用响应消息
@Data
public class ResponseMessage {private String message;public ResponseMessage(String message) {this.message = message;}
}// 打球水平枚举
public enum SkillLevel {初学者,进阶玩家,随便挥拍
}
2.7.3 Mapper接口与XML配置
使用MyBatis的Mapper接口和XML配置进行数据库操作。
2.7.3.1 用户Mapper(UserMapper.java)
package com.example.badminton.repository;import com.example.badminton.model.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.Optional;@Mapper
public interface UserMapper {void insertUser(User user);Optional<User> findByUsername(@Param("username") String username);Optional<User> findByEmail(@Param("email") String email);// 其他用户相关的数据库操作
}
2.7.3.2 用户Mapper XML(UserMapper.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton.repository.UserMapper"><insert id="insertUser" parameterType="com.example.badminton.model.User" useGeneratedKeys="true" keyProperty="id">INSERT INTO users (username, password_hash, email, phone, skill_level, interests, created_at, updated_at)VALUES (#{username}, #{passwordHash}, #{email}, #{phone}, #{skillLevel}, #{interests}, NOW(), NOW())</insert><select id="findByUsername" parameterType="string" resultType="com.example.badminton.model.User">SELECT * FROM users WHERE username = #{username}</select><select id="findByEmail" parameterType="string" resultType="com.example.badminton.model.User">SELECT * FROM users WHERE email = #{email}</select><!-- 其他SQL语句 --></mapper>
2.7.3.3 积分Mapper(PointMapper.java)
package com.example.badminton.repository;import com.example.badminton.model.Point;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.Optional;@Mapper
public interface PointMapper {void insertPoint(Point point);Optional<Point> findByUserId(@Param("userId") Long userId);// 其他积分相关的数据库操作
}
2.7.3.4 积分Mapper XML(PointMapper.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton.repository.PointMapper"><insert id="insertPoint" parameterType="com.example.badminton.model.Point" useGeneratedKeys="true" keyProperty="id">INSERT INTO points (user_id, total_points, history, created_at, updated_at)VALUES (#{userId}, #{totalPoints}, #{history}, NOW(), NOW())</insert><select id="findByUserId" parameterType="long" resultType="com.example.badminton.model.Point">SELECT * FROM points WHERE user_id = #{userId}</select><!-- 其他SQL语句 --></mapper>
2.7.3.5 匹配Mapper(MatchMapper.java)
package com.example.badminton.repository;import com.example.badminton.model.Match;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;
import java.util.Optional;@Mapper
public interface MatchMapper {void insertMatch(Match match);Optional<Match> findById(@Param("id") Long id);List<Match> findByUserId(@Param("userId") Long userId);List<Match> findByMatchedUserId(@Param("matchedUserId") Long matchedUserId);// 其他匹配相关的数据库操作
}
2.7.3.6 匹配Mapper XML(MatchMapper.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton.repository.MatchMapper"><insert id="insertMatch" parameterType="com.example.badminton.model.Match" useGeneratedKeys="true" keyProperty="id">INSERT INTO matches (user_id, matched_user_id, status, created_at, updated_at)VALUES (#{userId}, #{matchedUserId}, #{status}, NOW(), NOW())</insert><select id="findById" parameterType="long" resultType="com.example.badminton.model.Match">SELECT * FROM matches WHERE id = #{id}</select><select id="findByUserId" parameterType="long" resultType="com.example.badminton.model.Match">SELECT * FROM matches WHERE user_id = #{userId}</select><select id="findByMatchedUserId" parameterType="long" resultType="com.example.badminton.model.Match">SELECT * FROM matches WHERE matched_user_id = #{matchedUserId}</select><!-- 其他SQL语句 --></mapper>
2.7.3.7 消息Mapper(MessageMapper.java)
package com.example.badminton.repository;import com.example.badminton.model.Message;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface MessageMapper {void insertMessage(Message message);List<Message> findByMatchId(@Param("matchId") Long matchId);// 其他消息相关的数据库操作
}
2.7.3.8 消息Mapper XML(MessageMapper.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton.repository.MessageMapper"><insert id="insertMessage" parameterType="com.example.badminton.model.Message" useGeneratedKeys="true" keyProperty="id">INSERT INTO messages (match_id, sender_id, receiver_id, message, created_at)VALUES (#{matchId}, #{senderId}, #{receiverId}, #{message}, NOW())</insert><select id="findByMatchId" parameterType="long" resultType="com.example.badminton.model.Message">SELECT * FROM messages WHERE match_id = #{matchId} ORDER BY created_at ASC</select><!-- 其他SQL语句 --></mapper>
2.7.3.9 评分Mapper(RatingMapper.java)
package com.example.badminton.repository;import com.example.badminton.model.Rating;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;import java.util.List;@Mapper
public interface RatingMapper {void insertRating(Rating rating);List<Rating> findByRateeId(@Param("rateeId") Long rateeId);// 其他评分相关的数据库操作
}
2.7.3.10 评分Mapper XML(RatingMapper.xml)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.badminton.repository.RatingMapper"><insert id="insertRating" parameterType="com.example.badminton.model.Rating" useGeneratedKeys="true" keyProperty="id">INSERT INTO ratings (match_id, rater_id, ratee_id, rating, comment, created_at)VALUES (#{matchId}, #{raterId}, #{rateeId}, #{rating}, #{comment}, NOW())</insert><select id="findByRateeId" parameterType="long" resultType="com.example.badminton.model.Rating">SELECT * FROM ratings WHERE ratee_id = #{rateeId} ORDER BY created_at DESC</select><!-- 其他SQL语句 --></mapper>
2.8 服务层实现
2.8.1 用户服务(UserService.java)
package com.example.badminton.service;import com.example.badminton.model.User;
import com.example.badminton.repository.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;import java.util.Optional;@Service
public class UserService {@Autowiredprivate UserMapper userMapper;public Optional<User> findByUsername(String username) {return userMapper.findByUsername(username);}public Optional<User> findByEmail(String email) {return userMapper.findByEmail(email);}public void saveUser(User user) {userMapper.insertUser(user);}// 其他用户相关的服务方法
}
2.8.2 用户详情服务(CustomUserDetailsService.java)
实现 UserDetailsService
接口,用于加载用户详情。
package com.example.badminton.service;import com.example.badminton.model.User;
import com.example.badminton.repository.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
import org.springframework.security.core.userdetails.User.UserBuilder;
import org.springframework.security.core.userdetails.User;@Service
public class CustomUserDetailsService implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {Optional<com.example.badminton.model.User> userOpt = userMapper.findByUsername(username);if (!userOpt.isPresent()) {throw new UsernameNotFoundException("User not found with username: " + username);}com.example.badminton.model.User user = userOpt.get();// 构建Spring Security的User对象UserBuilder builder = User.withUsername(username);builder.password(user.getPasswordHash());builder.roles("USER"); // 根据实际情况设置角色return builder.build();}
}
2.9 登录流程
2.9.1 前端登录流程概述
- 用户在登录页面填写用户名和密码并提交。
- 前端发送
POST /api/v1/auth/login
请求,携带登录信息。 - 后端接收请求,验证用户凭证。
- 后端生成JWT Token并返回给前端。
- 前端存储Token(如在本地存储中),并在后续请求中携带Token。
- 用户登录成功后,前端跳转到首页或用户主页。
2.9.2 前端实现
以下示例基于 UniApp 和 Vuex 进行实现。
2.9.2.1 Vuex 认证模块(store/modules/auth.js)
// store/modules/auth.jsconst state = {token: uni.getStorageSync('token') || '',user: {}
}const mutations = {SET_TOKEN(state, token) {state.token = tokenuni.setStorageSync('token', token)},SET_USER(state, user) {state.user = user},CLEAR_AUTH(state) {state.token = ''state.user = {}uni.removeStorageSync('token')}
}const actions = {login({ commit }, payload) {return new Promise((resolve, reject) => {uni.request({url: 'https://yourapi.com/api/v1/auth/login',method: 'POST',data: payload,success: (res) => {if (res.statusCode === 200) {commit('SET_TOKEN', res.data.jwt)resolve(res)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},register({ commit }, payload) {return new Promise((resolve, reject) => {uni.request({url: 'https://yourapi.com/api/v1/auth/register',method: 'POST',data: payload,success: (res) => {if (res.statusCode === 200) {resolve(res)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},logout({ commit }) {commit('CLEAR_AUTH')}
}export default {namespaced: true,state,mutations,actions
}
2.9.2.2 登录页面(pages/login/login.vue)
<template><view class="container"><van-field v-model="username" label="用户名" placeholder="请输入用户名" /><van-field v-model="password" type="password" label="密码" placeholder="请输入密码" /><van-button type="primary" @click="login">登录</van-button><van-button type="default" @click="navigateToRegister">注册</van-button></view></template><script>
import { mapActions } from 'vuex'
export default {data() {return {username: '',password: ''}},methods: {...mapActions('auth', ['login']),async login() {if (this.username.trim() === '' || this.password.trim() === '') {uni.showToast({ title: '请输入用户名和密码', icon: 'none' })return}try {const response = await this.login({ username: this.username, password: this.password })uni.showToast({ title: '登录成功', icon: 'success' })// 获取用户信息后跳转到首页this.$router.push('/pages/home/home')} catch (error) {uni.showToast({ title: error, icon: 'none' })}},navigateToRegister() {this.$router.push('/pages/register/register')}}
}
</script><style scoped>
.container {padding: 20px;display: flex;flex-direction: column;gap: 20px;
}
</style>
2.10 Mapper接口与XML配置
2.10.1 Mapper接口
以下是MatchMapper、MessageMapper和RatingMapper的示例。已在前面的 2.7.3.5、2.7.3.7 和 2.7.3.9 部分定义。
2.10.2 XML配置
对应的XML配置也已在 2.7.3.5、2.7.3.7 和 2.7.3.9 部分定义。
2.11 前端接收消息
2.11.1 WebSocket 客户端配置
在前端,使用 SockJS 和 Stomp.js 实现WebSocket连接,并通过 Vuex 更新聊天消息。
2.11.1.1 安装依赖
确保已安装 SockJS 和 Stomp.js:
npm install sockjs-client stompjs
2.11.1.2 WebSocket 配置(main.js)
在 main.js 中配置WebSocket连接,并将Stomp客户端添加到Vue实例中,以便全局访问。
// main.js
import Vue from 'vue'
import App from './App'
import store from './store'
import router from './router'
import Vant from 'vant';
import 'vant/lib/index.css';
import SockJS from 'sockjs-client'
import Stomp from 'stompjs'Vue.use(Vant);Vue.config.productionTip = falseApp.mpType = 'app'const app = new Vue({store,router,...App
})
app.$mount()// WebSocket 连接
const socket = new SockJS('https://yourapi.com/ws') // 确保使用HTTPS
const stompClient = Stomp.over(socket)
stompClient.debug = null // 关闭调试信息stompClient.connect({}, frame => {console.log('Connected: ' + frame)// 订阅个人消息队列stompClient.subscribe(`/user/queue/messages`, message => {const receivedMessage = JSON.parse(message.body)// 触发Vuex中的聊天消息更新store.commit('chat/ADD_MESSAGE', receivedMessage)})
})// 将stompClient添加到Vue原型中,便于在组件中访问
Vue.prototype.$stompClient = stompClient
2.11.2 Vuex 聊天模块(store/modules/chat.js)
// store/modules/chat.jsconst state = {messages: []
}const mutations = {ADD_MESSAGE(state, message) {state.messages.push(message)},CLEAR_MESSAGES(state) {state.messages = []}
}const actions = {// 根据需要添加异步操作
}export default {namespaced: true,state,mutations,actions
}
2.11.3 聊天页面实现(pages/chat/chat.vue)
<template><view class="container"><van-nav-bar title="聊天" left-text="返回" @click-left="goBack" /><scroll-view class="chat-container" scroll-y :scroll-top="scrollTop"><view v-for="message in messages" :key="message.id" :class="{'sent': message.senderId === user.id, 'received': message.senderId !== user.id}"><text>{{ message.message }}</text><text class="timestamp">{{ formatTimestamp(message.createdAt) }}</text></view></scroll-view><view class="input-container"><van-field v-model="newMessage" placeholder="输入消息" /><van-button type="primary" @click="sendMessage">发送</van-button></view></view></template><script>
import { mapState, mapMutations } from 'vuex'export default {data() {return {matchId: null,newMessage: '',scrollTop: 0}},computed: {...mapState('chat', ['messages']),...mapState('auth', ['user'])},methods: {...mapMutations('chat', ['ADD_MESSAGE']),goBack() {uni.navigateBack()},formatTimestamp(timestamp) {const date = new Date(timestamp)const hours = date.getHours().toString().padStart(2, '0')const minutes = date.getMinutes().toString().padStart(2, '0')return `${hours}:${minutes}`},sendMessage() {if (this.newMessage.trim() === '') return// 发送消息到后端uni.request({url: 'https://yourapi.com/api/v1/messages',method: 'POST',header: {'Authorization': `Bearer ${this.$store.state.auth.token}`},data: {matchId: this.matchId,message: this.newMessage},success: res => {if (res.statusCode === 200) {this.newMessage = ''// 本地添加消息(可选)// this.ADD_MESSAGE({// id: res.data.id,// senderId: this.user.id,// receiverId: res.data.receiverId,// message: res.data.message,// createdAt: res.data.createdAt// })// 更新滚动位置this.scrollTop = this.messages.length * 100} else {uni.showToast({ title: res.data.message, icon: 'none' })}},fail: err => {uni.showToast({ title: '发送失败', icon: 'none' })}})}},onLoad(options) {this.matchId = options.matchId// 加载聊天记录uni.request({url: `https://yourapi.com/api/v1/messages/${this.matchId}`,method: 'GET',header: {'Authorization': `Bearer ${this.$store.state.auth.token}`},success: res => {if (res.statusCode === 200) {res.data.forEach(msg => {this.ADD_MESSAGE(msg)})// 更新滚动位置this.scrollTop = this.messages.length * 100} else {uni.showToast({ title: '加载聊天记录失败', icon: 'none' })}},fail: err => {uni.showToast({ title: '加载聊天记录失败', icon: 'none' })}})}
}
</script><style scoped>
.container {display: flex;flex-direction: column;height: 100%;
}.chat-container {flex: 1;padding: 10px;overflow-y: scroll;
}.sent {align-self: flex-end;background-color: #DCF8C6;padding: 10px;border-radius: 10px;margin-bottom: 5px;max-width: 70%;
}.received {align-self: flex-start;background-color: #FFFFFF;padding: 10px;border-radius: 10px;margin-bottom: 5px;max-width: 70%;
}.timestamp {font-size: 10px;color: #999;margin-top: 5px;display: block;
}.input-container {display: flex;padding: 10px;border-top: 1px solid #eee;align-items: center;gap: 10px;
}
</style>
2.12 WebSocket 实现
2.12.1 后端WebSocket配置
配置Spring Boot的WebSocket,以支持实时聊天功能。
2.12.1.1 WebSocketConfig.java
package com.example.badminton.config;import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {@Overridepublic void configureMessageBroker(MessageBrokerRegistry config) {// 配置消息代理,支持点对点消息config.enableSimpleBroker("/queue"); // 订阅前缀config.setApplicationDestinationPrefixes("/app"); // 应用前缀}@Overridepublic void registerStompEndpoints(StompEndpointRegistry registry) {// 注册WebSocket端点registry.addEndpoint("/ws").setAllowedOrigins("*") // 根据需求设置允许的域.withSockJS(); // 启用SockJS}
}
2.12.2 后端消息处理器
处理通过WebSocket发送的聊天消息。
2.12.2.1 ChatWebSocketController.java
package com.example.badminton.controller;import com.example.badminton.model.Message;
import com.example.badminton.model.Match;
import com.example.badminton.model.User;
import com.example.badminton.payload.ChatMessage;
import com.example.badminton.repository.MessageMapper;
import com.example.badminton.repository.MatchMapper;
import com.example.badminton.repository.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendToUser;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;import java.security.Principal;@Controller
public class ChatWebSocketController {@Autowiredprivate SimpMessagingTemplate messagingTemplate;@Autowiredprivate MessageMapper messageMapper;@Autowiredprivate MatchMapper matchMapper;@Autowiredprivate UserMapper userMapper;@MessageMapping("/chat.sendMessage") // 接收/app/chat.sendMessage的消息public void sendMessage(@Payload ChatMessage chatMessage, Principal principal) {// 获取发送者信息User sender = userMapper.findByUsername(principal.getName()).orElseThrow(() -> new RuntimeException("用户未找到"));// 获取匹配记录Match match = matchMapper.findById(chatMessage.getMatchId()).orElseThrow(() -> new RuntimeException("匹配记录未找到"));// 确定接收者User receiver = match.getMatchedUserId().equals(sender.getId()) ? userMapper.findById(match.getUserId()).orElseThrow(() -> new RuntimeException("接收者未找到")) :userMapper.findById(match.getMatchedUserId()).orElseThrow(() -> new RuntimeException("接收者未找到"));// 创建消息记录Message message = new Message();message.setMatchId(match.getId());message.setSenderId(sender.getId());message.setReceiverId(receiver.getId());message.setMessage(chatMessage.getContent());message.setCreatedAt(new java.sql.Timestamp(System.currentTimeMillis()));messageMapper.insertMessage(message);// 推送消息给接收者messagingTemplate.convertAndSendToUser(receiver.getUsername(), "/queue/messages", message);}
}
2.12.3 聊天消息模型(ChatMessage.java)
package com.example.badminton.payload;import lombok.Data;@Data
public class ChatMessage {private Long matchId;private String content;
}
2.12.4 前端WebSocket集成
2.12.4.1 前端WebSocket连接(main.js)
在前面的 2.11.1.2 WebSocket 配置 中,已通过 SockJS 和 Stomp.js 建立了WebSocket连接,并订阅了用户的消息队列。确保将 /user/queue/messages
与后端配置一致。
2.12.4.2 发送消息
在 聊天页面 中,通过 Uni.request 发送HTTP请求来发送消息,同时通过WebSocket接收消息。
<!-- 已在2.11.3 聊天页面实现中展示 -->
2.12.4.3 接收消息并更新Vuex
在 main.js 中,已通过 stompClient.subscribe
订阅了消息队列,并通过 store.commit('chat/ADD_MESSAGE', receivedMessage)
更新Vuex中的聊天消息。
2.13 完整的登录流程
2.13.1 前端登录流程
- 用户在登录页面填写用户名和密码并提交。
- 前端发送
POST /api/v1/auth/login
请求,携带登录信息。 - 后端验证用户凭证,生成JWT Token并返回给前端。
- 前端接收JWT Token,存储在本地(如Vuex和本地存储)。
- 用户登录成功后,前端跳转到首页或用户主页。
2.13.2 后端登录流程
- 接收
POST /api/v1/auth/login
请求,包含用户名和密码。 - 通过Spring Security的
AuthenticationManager
认证用户凭证。 - 如果认证成功,使用
JwtUtil
生成JWT Token。 - 将JWT Token作为响应返回给前端。
示例:AuthController.java 中的登录方法(继续)
@PostMapping("/login")
public ResponseEntity<?> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {try {authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));} catch (BadCredentialsException e) {return ResponseEntity.status(401).body(new ResponseMessage("无效的用户名或密码。"));}final User user = userMapper.findByUsername(loginRequest.getUsername()).orElseThrow(() -> new RuntimeException("用户未找到"));final UserDetails userDetails = org.springframework.security.core.userdetails.User.builder().username(user.getUsername()).password(user.getPasswordHash()).roles("USER") // 根据实际情况设置角色.build();final String jwt = jwtUtil.generateToken(userDetails);return ResponseEntity.ok(new JwtResponse(jwt));
}
3. 前端开发
3.1 技术栈
- 框架:Vue.js
- 跨平台:UniApp
- 状态管理:Vuex
- UI框架:Vant Weapp 或 uView UI
- 实时通信:SockJS, Stomp.js
- 构建工具:HBuilderX
3.2 项目初始化
使用HBuilderX创建一个UniApp项目,并安装所需的依赖。
2.9.2.1 Vuex 认证模块(store/modules/auth.js)
已在 2.9.2.1 部分定义。
3.3 页面与组件实现
3.3.1 注册页面(pages/register/register.vue)
<template><view class="container"><van-field v-model="username" label="用户名" placeholder="请输入用户名" /><van-field v-model="password" type="password" label="密码" placeholder="请输入密码" /><van-field v-model="confirmPassword" type="password" label="确认密码" placeholder="请确认密码" /><van-field v-model="email" label="邮箱" placeholder="请输入邮箱" /><van-field v-model="phone" label="电话" placeholder="请输入电话" /><van-picker v-model="skillLevel" :columns="skillLevels" title="选择打球水平" /><van-checkbox-group v-model="interests"><van-checkbox name="喜欢双打">喜欢双打</van-checkbox><van-checkbox name="热爱竞技">热爱竞技</van-checkbox><!-- 添加更多兴趣标签 --></van-checkbox-group><van-button type="primary" @click="register">注册</van-button><van-button type="default" @click="navigateToLogin">已有账户?登录</van-button></view></template><script>
import { mapActions } from 'vuex'
export default {data() {return {username: '',password: '',confirmPassword: '',email: '',phone: '',skillLevel: '',skillLevels: ['初学者', '进阶玩家', '随便挥拍'],interests: []}},methods: {...mapActions('auth', ['register']),async register() {if (this.password !== this.confirmPassword) {uni.showToast({ title: '密码不一致', icon: 'none' })return}if (this.username.trim() === '' || this.password.trim() === '' || this.email.trim() === '') {uni.showToast({ title: '请填写必填项', icon: 'none' })return}try {await this.register({username: this.username,password: this.password,email: this.email,phone: this.phone,skillLevel: this.skillLevel,interests: this.interests})uni.showToast({ title: '注册成功', icon: 'success' })this.$router.push('/pages/login/login')} catch (error) {uni.showToast({ title: error, icon: 'none' })}},navigateToLogin() {this.$router.push('/pages/login/login')}}
}
</script><style scoped>
.container {padding: 20px;display: flex;flex-direction: column;gap: 20px;
}
</style>
3.3.2 首页(pages/home/home.vue)
<template><view class="container"><van-nav-bar title="首页" right-text="注销" @click-right="logout" /><van-button type="primary" @click="navigateToMatch">开始匹配</van-button><van-list><van-cell v-for="match in matchHistory" :key="match.id" :title="match.matchedUser.username" :label="match.status" @click="viewMatchDetails(match.id)" /></van-list></view></template><script>
import { mapState, mapActions } from 'vuex'export default {data() {return {matchHistory: []}},computed: {...mapState('auth', ['user'])},methods: {...mapActions('matches', ['fetchMatchHistory']),...mapActions('auth', ['logout']),async fetchHistory() {try {const history = await this.fetchMatchHistory(this.user.id)this.matchHistory = history} catch (error) {uni.showToast({ title: '获取匹配历史失败', icon: 'none' })}},navigateToMatch() {this.$router.push('/pages/match/match')},viewMatchDetails(matchId) {this.$router.push(`/pages/matchDetails/matchDetails?matchId=${matchId}`)},async logout() {await this.logout()uni.showToast({ title: '已注销', icon: 'success' })this.$router.push('/pages/login/login')}},onLoad() {this.fetchHistory()}
}
</script><style scoped>
.container {padding: 20px;display: flex;flex-direction: column;gap: 20px;height: 100%;
}
</style>
3.3.3 匹配页面(pages/match/match.vue)
<template><view class="container"><van-nav-bar title="匹配" left-text="返回" @click-left="goBack" /><van-form @submit="submitMatch"><van-picker v-model="region" :columns="regions" title="选择区域" /><van-picker v-model="skillLevel" :columns="skillLevels" title="选择打球水平" /><van-datetime-picker v-model="selectedTime" type="datetime" title="选择时间" /><van-button type="primary" form-type="submit">查找匹配</van-button></van-form><van-list><van-cell v-for="user in matchedUsers" :key="user.id" :title="user.username" :label="user.skillLevel" @click="acceptMatch(user.id)" /></van-list></view></template><script>
import { mapActions, mapState } from 'vuex'export default {data() {return {region: '',regions: ['区域A', '区域B', '区域C'],skillLevel: '',skillLevels: ['初学者', '进阶玩家', '随便挥拍'],selectedTime: '',matchedUsers: []}},computed: {...mapState('auth', ['user'])},methods: {...mapActions('matches', ['findMatches', 'createMatch']),goBack() {uni.navigateBack()},async submitMatch() {if (this.region === '' || this.skillLevel === '') {uni.showToast({ title: '请填写匹配条件', icon: 'none' })return}try {const response = await this.findMatches({region: this.region,skillLevel: this.skillLevel,interests: this.user.interests})this.matchedUsers = response} catch (error) {uni.showToast({ title: '匹配失败', icon: 'none' })}},async acceptMatch(userId) {try {await this.createMatch({ matchedUserId: userId })uni.showToast({ title: '匹配请求已发送', icon: 'success' })} catch (error) {uni.showToast({ title: '发送匹配请求失败', icon: 'none' })}}}
}
</script><style scoped>
.container {padding: 20px;display: flex;flex-direction: column;gap: 20px;height: 100%;
}
</style>
3.3.4 匹配详情页面(pages/matchDetails/matchDetails.vue)
<template><view class="container"><van-nav-bar title="匹配详情" left-text="返回" @click-left="goBack" /><view class="details"><van-cell title="对方用户名" :value="matchedUser.username" /><van-cell title="打球水平" :value="matchedUser.skillLevel" /><van-cell title="兴趣标签" :value="matchedUser.interests" /></view><van-button type="primary" @click="navigateToChat">开始聊天</van-button><van-button type="danger" @click="rejectMatch">拒绝匹配</van-button></view></template><script>
import { mapActions, mapState } from 'vuex'export default {data() {return {matchId: null,matchedUser: {}}},computed: {...mapState('auth', ['user'])},methods: {...mapActions('matches', ['fetchMatchDetails', 'updateMatchStatus']),goBack() {uni.navigateBack()},async fetchDetails() {try {const details = await this.fetchMatchDetails(this.matchId)this.matchedUser = details.matchedUser} catch (error) {uni.showToast({ title: '获取匹配详情失败', icon: 'none' })}},navigateToChat() {this.$router.push(`/pages/chat/chat?matchId=${this.matchId}`)},async rejectMatch() {try {await this.updateMatchStatus({ matchId: this.matchId, status: '已拒绝' })uni.showToast({ title: '匹配已拒绝', icon: 'success' })this.goBack()} catch (error) {uni.showToast({ title: '拒绝匹配失败', icon: 'none' })}}},onLoad(options) {this.matchId = options.matchIdthis.fetchDetails()}
}
</script><style scoped>
.container {padding: 20px;display: flex;flex-direction: column;gap: 20px;height: 100%;
}
.details {display: flex;flex-direction: column;gap: 10px;
}
</style>
3.3.5 Vuex 匹配模块(store/modules/matches.js)
// store/modules/matches.jsconst state = {matchHistory: [],matchDetails: {}
}const mutations = {SET_MATCH_HISTORY(state, history) {state.matchHistory = history},SET_MATCH_DETAILS(state, details) {state.matchDetails = details},UPDATE_MATCH_STATUS(state, { matchId, status }) {const match = state.matchHistory.find(m => m.id === matchId)if (match) {match.status = status}}
}const actions = {fetchMatchHistory({ commit }, userId) {return new Promise((resolve, reject) => {uni.request({url: `https://yourapi.com/api/v1/matches/user/${userId}`,method: 'GET',header: {'Authorization': `Bearer ${uni.getStorageSync('token')}`},success: (res) => {if (res.statusCode === 200) {commit('SET_MATCH_HISTORY', res.data)resolve(res.data)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},findMatches({ commit }, payload) {return new Promise((resolve, reject) => {uni.request({url: `https://yourapi.com/api/v1/matches`,method: 'GET',data: payload,header: {'Authorization': `Bearer ${uni.getStorageSync('token')}`},success: (res) => {if (res.statusCode === 200) {resolve(res.data)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},createMatch({ commit }, payload) {return new Promise((resolve, reject) => {uni.request({url: `https://yourapi.com/api/v1/matches`,method: 'POST',data: payload,header: {'Authorization': `Bearer ${uni.getStorageSync('token')}`},success: (res) => {if (res.statusCode === 200) {resolve(res.data)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},fetchMatchDetails({ commit }, matchId) {return new Promise((resolve, reject) => {uni.request({url: `https://yourapi.com/api/v1/matches/${matchId}`,method: 'GET',header: {'Authorization': `Bearer ${uni.getStorageSync('token')}`},success: (res) => {if (res.statusCode === 200) {commit('SET_MATCH_DETAILS', res.data)resolve(res.data)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})},updateMatchStatus({ commit }, payload) {return new Promise((resolve, reject) => {uni.request({url: `https://yourapi.com/api/v1/matches/${payload.matchId}/status`,method: 'PUT',data: { status: payload.status },header: {'Authorization': `Bearer ${uni.getStorageSync('token')}`},success: (res) => {if (res.statusCode === 200) {commit('UPDATE_MATCH_STATUS', payload)resolve(res.data)} else {reject(res.data.message)}},fail: (err) => {reject(err)}})})}
}export default {namespaced: true,state,mutations,actions
}
4. 部署与上线
4.1 服务器配置
4.1.1 选择服务器
选择云服务提供商,如阿里云、腾讯云或AWS,配置适当的服务器规格(如CPU、内存、存储)。
4.1.2 安装必要的软件
- Java Runtime Environment (JRE): 运行Spring Boot应用
- Nginx: 作为反向代理和静态资源服务器
- MySQL: 数据库管理
- Node.js: 编译前端项目(如需要)
4.1.3 配置MyBatis SQL Session Factory
在 application.properties 中添加MyBatis配置:
# MyBatis配置
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.example.badminton.model
确保所有Mapper XML文件放在 src/main/resources/mapper
目录下。
4.1.4 部署Spring Boot应用
打包Spring Boot应用为可执行的JAR文件,并配置为后台服务运行。
mvn clean package
上传生成的 badminton-social-app.jar
到服务器,并通过以下命令启动:
nohup java -jar badminton-social-app.jar > app.log 2>&1 &
4.1.5 配置Nginx
配置Nginx作为前端静态资源服务器和后端API的反向代理。
nginx.conf 示例
server {listen 80;server_name yourdomain.com;# 静态资源location / {root /var/www/html; # 前端H5文件路径try_files $uri $uri/ /index.html;}# 后端APIlocation /api/ {proxy_pass http://localhost:8080/api/;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header Authorization $http_authorization;}# WebSocketlocation /ws/ {proxy_pass http://localhost:8080/ws/;proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "Upgrade";proxy_set_header Host $host;}
}
重启Nginx以应用配置:
sudo systemctl restart nginx
4.1.6 配置数据库
在服务器上安装并配置MySQL,创建数据库并导入表结构。
CREATE DATABASE badminton_social;USE badminton_social;-- 创建用户表
CREATE TABLE users (id BIGINT AUTO_INCREMENT PRIMARY KEY,username VARCHAR(50) NOT NULL UNIQUE,password_hash VARCHAR(255) NOT NULL,email VARCHAR(100) NOT NULL UNIQUE,phone VARCHAR(20),skill_level ENUM('初学者', '进阶玩家', '随便挥拍'),interests VARCHAR(255),created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);-- 创建匹配表
CREATE TABLE matches (id BIGINT AUTO_INCREMENT PRIMARY KEY,user_id BIGINT NOT NULL,matched_user_id BIGINT NOT NULL,status ENUM('待接受', '已接受', '已拒绝') DEFAULT '待接受',created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY (user_id) REFERENCES users(id),FOREIGN KEY (matched_user_id) REFERENCES users(id)
);-- 创建消息表
CREATE TABLE messages (id BIGINT AUTO_INCREMENT PRIMARY KEY,match_id BIGINT NOT NULL,sender_id BIGINT NOT NULL,receiver_id BIGINT NOT NULL,message TEXT,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (match_id) REFERENCES matches(id),FOREIGN KEY (sender_id) REFERENCES users(id),FOREIGN KEY (receiver_id) REFERENCES users(id)
);-- 创建评分表
CREATE TABLE ratings (id BIGINT AUTO_INCREMENT PRIMARY KEY,match_id BIGINT NOT NULL,rater_id BIGINT NOT NULL,ratee_id BIGINT NOT NULL,rating INT NOT NULL,comment TEXT,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (match_id) REFERENCES matches(id),FOREIGN KEY (rater_id) REFERENCES users(id),FOREIGN KEY (ratee_id) REFERENCES users(id)
);-- 创建优惠券表
CREATE TABLE coupons (id BIGINT AUTO_INCREMENT PRIMARY KEY,user_id BIGINT NOT NULL,code VARCHAR(50) NOT NULL UNIQUE,type ENUM('折扣', '免费场次') NOT NULL,discount_amount DECIMAL(10,2) NOT NULL,is_used BOOLEAN DEFAULT FALSE,expires_at TIMESTAMP,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY (user_id) REFERENCES users(id)
);-- 创建积分表
CREATE TABLE points (id BIGINT AUTO_INCREMENT PRIMARY KEY,user_id BIGINT NOT NULL UNIQUE,total_points INT DEFAULT 0,history TEXT,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY (user_id) REFERENCES users(id)
);
4.2 前端部署
4.2.1 编译前端项目
使用HBuilderX或CLI工具编译UniApp项目为H5。
# 使用HBuilderX GUI进行编译,或使用CLI命令
# 示例(假设使用HBuilderX命令行)
hbuilderx build:h5
4.2.2 上传前端文件到服务器
将编译后的H5文件上传到Nginx配置的静态资源路径(如 /var/www/html
)。
scp -r dist/build/h5/* user@yourserver:/var/www/html/
4.3 后端部署
4.3.1 部署Spring Boot应用
确保JAR文件已上传到服务器,并通过后台服务运行。
nohup java -jar badminton-social-app.jar > app.log 2>&1 &
4.3.2 配置数据库
在服务器上安装并配置MySQL,创建数据库并导入表结构。
CREATE DATABASE badminton_social;USE badminton_social;-- 导入表结构
-- 使用前面定义的SQL语句
4.3.3 配置环境变量
确保Spring Boot应用的 application.properties
文件中包含正确的数据库和JWT配置。
# 数据库配置
spring.datasource.url=jdbc:mysql://localhost:3306/badminton_social?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=your_password# MyBatis配置
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.example.badminton.model# JWT 配置
jwt.secret=YourJWTSecretKey
jwt.expiration=86400000 # 1天的毫秒数# WebSocket 配置
spring.websocket.path=/ws
4.4 测试与上线
4.4.1 系统整体测试
在生产环境中进行全面的功能测试,确保所有API和前端功能正常工作。
4.4.2 稳定性测试
使用压力测试工具(如JMeter)测试系统在高并发下的表现,确保稳定性。
4.4.3 安全性测试
检查系统是否存在常见的安全漏洞,如SQL注入、XSS、CSRF等,确保数据传输和存储的安全性。
4.4.4 客户培训与交付
- 提供详细的用户手册和技术文档。
- 组织培训会议,向客户演示系统功能和使用方法。
- 完成项目验收,确保客户满意。
5. 示例代码与技术细节
5.1 前端 API 调用示例
使用Vue的生命周期钩子和方法进行API调用。
获取匹配列表
methods: {async fetchMatches() {try {const response = await this.$store.dispatch('matches/findMatches', {region: this.region,skillLevel: this.skillLevel,interests: this.interests})this.matchedUsers = response} catch (error) {uni.showToast({ title: '获取匹配失败', icon: 'none' })}}
}
5.2 后端 API 实现示例
用户注册 API
@PostMapping("/register")
public ResponseEntity<?> registerUser(@RequestBody RegisterRequest registerRequest) {// 检查用户名和邮箱是否存在if (userMapper.findByUsername(registerRequest.getUsername()).isPresent()) {return ResponseEntity.badRequest().body(new ResponseMessage("用户名已存在,请选择其他用户名。"));}if (userMapper.findByEmail(registerRequest.getEmail()).isPresent()) {return ResponseEntity.badRequest().body(new ResponseMessage("邮箱已注册,请使用其他邮箱。"));}// 创建新用户User user = new User();user.setUsername(registerRequest.getUsername());user.setPasswordHash(passwordEncoder.encode(registerRequest.getPassword()));user.setEmail(registerRequest.getEmail());user.setPhone(registerRequest.getPhone());user.setSkillLevel(registerRequest.getSkillLevel());user.setInterests(String.join(",", registerRequest.getInterests()));userMapper.insertUser(user);// 初始化积分Point point = new Point();point.setUserId(user.getId());point.setTotalPoints(0);point.setHistory(""); // 根据需求初始化pointMapper.insertPoint(point);return ResponseEntity.ok(new ResponseMessage("注册成功。"));
}
用户登录 API
@PostMapping("/login")
public ResponseEntity<?> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {try {authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));} catch (BadCredentialsException e) {return ResponseEntity.status(401).body(new ResponseMessage("无效的用户名或密码。"));}final User user = userMapper.findByUsername(loginRequest.getUsername()).orElseThrow(() -> new RuntimeException("用户未找到"));final UserDetails userDetails = org.springframework.security.core.userdetails.User.builder().username(user.getUsername()).password(user.getPasswordHash()).roles("USER") // 根据实际情况设置角色.build();final String jwt = jwtUtil.generateToken(userDetails);return ResponseEntity.ok(new JwtResponse(jwt));
}
5.3 WebSocket 实现示例
后端消息发送
@Autowired
private SimpMessagingTemplate messagingTemplate;@MessageMapping("/chat.sendMessage")
public void sendMessage(@Payload ChatMessage chatMessage, Principal principal) {// 保存消息到数据库User sender = userMapper.findByUsername(principal.getName()).orElseThrow(() -> new UsernameNotFoundException("用户未找到"));Match match = matchMapper.findById(chatMessage.getMatchId()).orElseThrow(() -> new ResourceNotFoundException("匹配记录未找到"));User receiver = match.getMatchedUserId().equals(sender.getId()) ? userMapper.findById(match.getUserId()).orElseThrow(() -> new RuntimeException("接收者未找到")) :userMapper.findById(match.getMatchedUserId()).orElseThrow(() -> new RuntimeException("接收者未找到"));Message message = new Message();message.setMatchId(match.getId());message.setSenderId(sender.getId());message.setReceiverId(receiver.getId());message.setMessage(chatMessage.getContent());message.setCreatedAt(new java.sql.Timestamp(System.currentTimeMillis()));messageMapper.insertMessage(message);// 通过WebSocket推送消息给接收者messagingTemplate.convertAndSendToUser(receiver.getUsername(), "/queue/messages", message);
}
前端接收消息并更新Vuex
在 main.js 中,通过WebSocket订阅接收消息,并通过Vuex更新聊天消息。
// main.jsimport Vue from 'vue'
import App from './App'
import store from './store'
import router from './router'
import Vant from 'vant';
import 'vant/lib/index.css';
import SockJS from 'sockjs-client'
import Stomp from 'stompjs'Vue.use(Vant);Vue.config.productionTip = falseApp.mpType = 'app'const app = new Vue({store,router,...App
})
app.$mount()// WebSocket 连接
const socket = new SockJS('https://yourapi.com/ws') // 确保使用HTTPS
const stompClient = Stomp.over(socket)
stompClient.debug = null // 关闭调试信息stompClient.connect({}, frame => {console.log('Connected: ' + frame)// 订阅个人消息队列stompClient.subscribe(`/user/queue/messages`, message => {const receivedMessage = JSON.parse(message.body)// 触发Vuex中的聊天消息更新store.commit('chat/ADD_MESSAGE', receivedMessage)})
})// 将stompClient添加到Vue原型中,便于在组件中访问
Vue.prototype.$stompClient = stompClient