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; // 使用字符串表示ENUM
private 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; // 使用字符串表示ENUM
private 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; // 使用字符串表示ENUM
private 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 mapper
PUBLIC "-//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 users
SET
username = #{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 mapper
PUBLIC "-//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 matches
SET 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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 coupons
SET
is_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 mapper
PUBLIC "-//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 points
SET
total_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 {
@Autowired
private UserMapper userMapper;
@Override
public 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 {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 配置用户详情服务和密码编码器
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 暴露AuthenticationManager为Bean,以便在Controller中使用
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected 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 {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// 提取JWT Token
if (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);
// 验证Token
if (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;
// 生成Token
public 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();
}
// 验证Token
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
// 获取所有Claims
private 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 {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserMapper userMapper;
@Autowired
private PointMapper pointMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private 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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 mapper
PUBLIC "-//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 {
@Autowired
private 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 {
@Autowired
private UserMapper userMapper;
@Override
public 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.js
const state = {
token: uni.getStorageSync('token') || '',
user: {}
}
const mutations = {
SET_TOKEN(state, token) {
state.token = token
uni.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 = false
App.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.js
const 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 {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 配置消息代理,支持点对点消息
config.enableSimpleBroker("/queue"); // 订阅前缀
config.setApplicationDestinationPrefixes("/app"); // 应用前缀
}
@Override
public 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 {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private MessageMapper messageMapper;
@Autowired
private MatchMapper matchMapper;
@Autowired
private 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.matchId
this.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.js
const 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;
}
# 后端API
location /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;
}
# WebSocket
location /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.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 = false
App.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