羽毛球匹配项目实施清单

1. 数据库设计

1.1 数据库选型

  • 数据库:MySQL
  • 理由:关系型数据库,支持复杂查询和事务处理,适合存储用户、匹配、聊天记录等结构化数据。

1.2 表结构设计

1.2.1 用户表(users)

字段名

类型

描述

id

BIGINT

主键,自增

username

VARCHAR(50)

用户名,唯一

password_hash

VARCHAR(255)

密码哈希

email

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 前端登录流程概述
  1. 用户在登录页面填写用户名和密码并提交。
  2. 前端发送 POST /api/v1/auth/login 请求,携带登录信息。
  3. 后端接收请求,验证用户凭证。
  4. 后端生成JWT Token并返回给前端。
  5. 前端存储Token(如在本地存储中),并在后续请求中携带Token。
  6. 用户登录成功后,前端跳转到首页或用户主页。
2.9.2 前端实现

以下示例基于 UniAppVuex 进行实现。

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接口

以下是MatchMapperMessageMapperRatingMapper的示例。已在前面的 2.7.3.52.7.3.72.7.3.9 部分定义。

2.10.2 XML配置

对应的XML配置也已在 2.7.3.52.7.3.72.7.3.9 部分定义。


2.11 前端接收消息

2.11.1 WebSocket 客户端配置

在前端,使用 SockJSStomp.js 实现WebSocket连接,并通过 Vuex 更新聊天消息。

2.11.1.1 安装依赖

确保已安装 SockJSStomp.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 配置 中,已通过 SockJSStomp.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 前端登录流程
  1. 用户在登录页面填写用户名和密码并提交。
  2. 前端发送 POST /api/v1/auth/login 请求,携带登录信息。
  3. 后端验证用户凭证,生成JWT Token并返回给前端。
  4. 前端接收JWT Token,存储在本地(如Vuex和本地存储)。
  5. 用户登录成功后,前端跳转到首页或用户主页。
2.13.2 后端登录流程
  1. 接收 POST /api/v1/auth/login 请求,包含用户名和密码。
  2. 通过Spring Security的 AuthenticationManager 认证用户凭证。
  3. 如果认证成功,使用 JwtUtil 生成JWT Token。
  4. 将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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值