SpringBoot实现面对面建群(基于Redis)

一、开篇

面对面建群是一种基于位置的社交应用场景,允许用户在物理位置相近的情况下快速创建和加入临时群组。

本文将介绍如何使用SpringBoot和Redis实现面对面建群,本示例基于Redis的各种数据结构存储应用数据。

二、技术栈

  • Spring Boot 3.4.5
  • Redis 5.0 (Jedis客户端)
  • JWT (认证)
  • HTML/CSS/JavaScript (前端)
  • Bootstrap 5 (UI框架)
  • Vue.js 3 (前端框架)

三、系统设计

整个系统基于SpringBoot框架,遵循MVC架构,后端数据存储完全依赖Redis。

系统主要分为以下几个模块:

1. 用户模块:处理用户注册、登录和认证
2. 位置服务模块:处理用户位置信息上报和获取附近用户
3. 群组模块:负责群组的创建、查询和加入

四、Redis数据结构设计

由于不使用传统数据库,我们需要设计Redis的数据结构来存储所有应用数据:

1. 用户信息存储

  • Hash结构:user:{userId} - 存储用户详细信息
  • String结构:username:{username} - 用于用户名查找对应的userId

2. 位置信息存储

  • GEO结构:user:locations - 存储用户ID和对应的地理位置

3. 群组信息存储

  • Hash结构:group:{groupId} - 存储群组详细信息
  • GEO结构:group:locations - 存储群组ID和对应的地理位置
  • Set结构:group:members:{groupId} - 存储群组成员ID
  • String结构:invitation:code:{code} - 邀请码到群组ID的映射

4. 用户群组关系存储

  • Set结构:user:groups:{userId} - 存储用户加入的群组ID

五、项目结构

src/main/java/com/example/face2face/
├── Face2FaceApplication.java
├── config/
│   ├── RedisConfig.java
│   ├── WebConfig.java
├── controller/
│   ├── AuthController.java
│   ├── GroupController.java
│   ├── LocationController.java
├── dto/
│   ├── request/
│   │   ├── CreateGroupRequest.java
│   │   ├── JoinGroupRequest.java
│   │   ├── LocationRequest.java
│   │   ├── LoginRequest.java
│   │   ├── RegisterRequest.java
│   ├── response/
│   │   ├── ApiResponse.java
│   │   ├── GroupResponse.java
│   │   ├── JwtResponse.java
│   │   ├── UserResponse.java
├── exception/
│   ├── GlobalExceptionHandler.java
│   ├── AppException.java
├── interceptor/
│   ├── JwtAuthInterceptor.java
├── model/
│   ├── Group.java
│   ├── User.java
├── service/
│   ├── AuthService.java
│   ├── GroupService.java
│   ├── LocationService.java
│   ├── UserService.java
├── util/
│   ├── JwtTokenUtil.java
│   ├── RedisKeyUtil.java

六、核心功能实现

6.1 Redis配置

首先配置Redis连接:

package com.example.face2face.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
        return new JedisConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory());
        return template;
    }
}

6.2 Redis键工具类

创建Redis键名工具类,统一管理键名生成规则:

package com.example.face2face.util;

public class RedisKeyUtil {
    // 用户相关键
    public static String getUserKey(String userId) {
        return "user:" + userId;
    }

    public static String getUsernameKey(String username) {
        return "username:" + username;
    }

    public static String getUserGroupsKey(String userId) {
        return "user:groups:" + userId;
    }

    // 位置相关键
    public static final String USER_LOCATIONS = "user:locations";
    public static final String GROUP_LOCATIONS = "group:locations";

    // 群组相关键
    public static String getGroupKey(String groupId) {
        return "group:" + groupId;
    }

    public static String getGroupMembersKey(String groupId) {
        return "group:members:" + groupId;
    }

    public static String getInvitationCodeKey(String code) {
        return "invitation:code:" + code;
    }
}

6.3 模型类

用户模型类
package com.example.face2face.model;

import java.io.Serializable;
import java.time.LocalDateTime;

public class User implements Serializable {
    private String id;
    private String username;
    private String password;
    private String nickname;
    private String avatar;
    private LocalDateTime createTime;

    // Constructors
    public User() {
    }

    public User(String id, String username, String password, String nickname, String avatar, LocalDateTime createTime) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.nickname = nickname;
        this.avatar = avatar;
        this.createTime = createTime;
    }

    // Getters and Setters
    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public String getAvatar() {
        return avatar;
    }

    public void setAvatar(String avatar) {
        this.avatar = avatar;
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }

    public void setCreateTime(LocalDateTime createTime) {
        this.createTime = createTime;
    }
}
群组模型类
package com.example.face2face.model;

import java.io.Serializable;
import java.time.LocalDateTime;

public class Group implements Serializable {
    private String id;
    private String name;
    private String description;
    private String creatorId;
    private double latitude;
    private double longitude;
    private String invitationCode;
    private LocalDateTime createTime;
    private LocalDateTime expireTime;

    // Constructors
    public Group() {
    }

    public Group(String id, String name, String description, String creatorId, 
                 double latitude, double longitude, String invitationCode, 
                 LocalDateTime createTime, LocalDateTime expireTime) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.creatorId = creatorId;
        this.latitude = latitude;
        this.longitude = longitude;
        this.invitationCode = invitationCode;
        this.createTime = createTime;
        this.expireTime = expireTime;
    }

    // Getters and Setters
    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getCreatorId() {
        return creatorId;
    }

    public void setCreatorId(String creatorId) {
        this.creatorId = creatorId;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }

    public String getInvitationCode() {
        return invitationCode;
    }

    public void setInvitationCode(String invitationCode) {
        this.invitationCode = invitationCode;
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }

    public void setCreateTime(LocalDateTime createTime) {
        this.createTime = createTime;
    }

    public LocalDateTime getExpireTime() {
        return expireTime;
    }

    public void setExpireTime(LocalDateTime expireTime) {
        this.expireTime = expireTime;
    }
}

6.4 DTO类

请求DTO
// RegisterRequest.java
package com.example.face2face.dto.request;

public class RegisterRequest {
    private String username;
    private String password;
    private String nickname;
    private String avatar;

    // Getters and Setters
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public String getAvatar() {
        return avatar;
    }

    public void setAvatar(String avatar) {
        this.avatar = avatar;
    }
}

// LoginRequest.java
package com.example.face2face.dto.request;

public class LoginRequest {
    private String username;
    private String password;

    // Getters and Setters
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

// LocationRequest.java
package com.example.face2face.dto.request;

public class LocationRequest {
    private double latitude;
    private double longitude;

    // Getters and Setters
    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }
}

// CreateGroupRequest.java
package com.example.face2face.dto.request;

public class CreateGroupRequest {
    private String name;
    private String description;
    private double latitude;
    private double longitude;

    // Getters and Setters
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }
}

// JoinGroupRequest.java
package com.example.face2face.dto.request;

public class JoinGroupRequest {
    private String invitationCode;

    // Getters and Setters
    public String getInvitationCode() {
        return invitationCode;
    }

    public void setInvitationCode(String invitationCode) {
        this.invitationCode = invitationCode;
    }
}
响应DTO
// ApiResponse.java
package com.example.face2face.dto.response;

public class ApiResponse<T> {
    private int code;
    private String message;
    private T data;

    public ApiResponse() {
    }

    public ApiResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "Success", data);
    }

    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(200, message, data);
    }

    public static ApiResponse<Void> success() {
        return new ApiResponse<>(200, "Success", null);
    }

    public static ApiResponse<String> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }

    // Getters and Setters
    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

// JwtResponse.java
package com.example.face2face.dto.response;

public class JwtResponse {
    private String token;
    private String id;
    private String username;
    private String nickname;

    public JwtResponse() {
    }

    public JwtResponse(String token, String id, String username, String nickname) {
        this.token = token;
        this.id = id;
        this.username = username;
        this.nickname = nickname;
    }

    // Getters and Setters
    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }
}

// UserResponse.java
package com.example.face2face.dto.response;

public class UserResponse {
    private String id;
    private String username;
    private String nickname;
    private String avatar;
    private Double distance;

    public UserResponse() {
    }

    public UserResponse(String id, String username, String nickname, String avatar) {
        this.id = id;
        this.username = username;
        this.nickname = nickname;
        this.avatar = avatar;
    }

    // Getters and Setters
    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public String getAvatar() {
        return avatar;
    }

    public void setAvatar(String avatar) {
        this.avatar = avatar;
    }

    public Double getDistance() {
        return distance;
    }

    public void setDistance(Double distance) {
        this.distance = distance;
    }
}

// GroupResponse.java
package com.example.face2face.dto.response;

import java.time.LocalDateTime;
import java.util.List;

public class GroupResponse {
    private String id;
    private String name;
    private String description;
    private UserResponse creator;
    private double latitude;
    private double longitude;
    private String invitationCode;
    private LocalDateTime createTime;
    private Double distance;
    private List<UserResponse> members;
    private int memberCount;

    public GroupResponse() {
    }

    // Getters and Setters
    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public UserResponse getCreator() {
        return creator;
    }

    public void setCreator(UserResponse creator) {
        this.creator = creator;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }

    public String getInvitationCode() {
        return invitationCode;
    }

    public void setInvitationCode(String invitationCode) {
        this.invitationCode = invitationCode;
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }

    public void setCreateTime(LocalDateTime createTime) {
        this.createTime = createTime;
    }

    public Double getDistance() {
        return distance;
    }

    public void setDistance(Double distance) {
        this.distance = distance;
    }

    public List<UserResponse> getMembers() {
        return members;
    }

    public void setMembers(List<UserResponse> members) {
        this.members = members;
    }

    public int getMemberCount() {
        return memberCount;
    }

    public void setMemberCount(int memberCount) {
        this.memberCount = memberCount;
    }
}

6.5 JWT认证

实现简单的JWT认证机制,包括JWT工具类和认证拦截器:

// JwtTokenUtil.java
package com.example.face2face.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;
import java.util.function.Function;

@Component
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String generateToken(String username, String userId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    public String getUserIdFromToken(String token) {
        return getClaimFromToken(token, claims -> claims.get("userId", String.class));
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    public Boolean validateToken(String token) {
        try {
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }
}

// JwtAuthInterceptor.java
package com.example.face2face.interceptor;

import com.example.face2face.model.User;
import com.example.face2face.service.UserService;
import com.example.face2face.util.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class JwtAuthInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String authHeader = request.getHeader("Authorization");
        
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            
            if (jwtTokenUtil.validateToken(token)) {
                String userId = jwtTokenUtil.getUserIdFromToken(token);
                User user = userService.getUserById(userId);
                
                if (user != null) {
                    request.setAttribute("currentUser", user);
                    return true;
                }
            }
        }
        
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return false;
    }
}

// WebConfig.java
package com.example.face2face.config;

import com.example.face2face.interceptor.JwtAuthInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtAuthInterceptor jwtAuthInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtAuthInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/auth/**");
    }
}

6.6 异常处理

实现全局异常处理:

// AppException.java
package com.example.face2face.exception;

public class AppException extends RuntimeException {
    private int code;

    public AppException(String message) {
        super(message);
        this.code = 500;
    }

    public AppException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}

// GlobalExceptionHandler.java
package com.example.face2face.exception;

import com.example.face2face.dto.response.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AppException.class)
    public ResponseEntity<ApiResponse<String>> handleAppException(AppException e) {
        return ResponseEntity
                .status(HttpStatus.valueOf(e.getCode() / 100))
                .body(new ApiResponse<>(e.getCode(), e.getMessage(), null));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<String>> handleException(Exception e) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ApiResponse<>(500, e.getMessage(), null));
    }
}

6.7 服务实现

用户服务
package com.example.face2face.service;

import com.example.face2face.dto.request.RegisterRequest;
import com.example.face2face.dto.response.UserResponse;
import com.example.face2face.exception.AppException;
import com.example.face2face.model.User;
import com.example.face2face.util.RedisKeyUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class UserService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private LocationService locationService;

    @Autowired
    private ObjectMapper objectMapper;

    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    public User registerUser(RegisterRequest request) {
        // Check if username already exists
        String existingUserId = stringRedisTemplate.opsForValue().get(RedisKeyUtil.getUsernameKey(request.getUsername()));
        if (existingUserId != null) {
            throw new AppException(400, "Username already exists");
        }

        // Create new user
        String userId = UUID.randomUUID().toString();
        User user = new User();
        user.setId(userId);
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setNickname(request.getNickname());
        user.setAvatar(request.getAvatar() != null ? request.getAvatar() : "default.png");
        user.setCreateTime(LocalDateTime.now());

        // Save user to Redis
        Map<String, Object> userMap = new HashMap<>();
        userMap.put("id", user.getId());
        userMap.put("username", user.getUsername());
        userMap.put("password", user.getPassword());
        userMap.put("nickname", user.getNickname());
        userMap.put("avatar", user.getAvatar());
        userMap.put("createTime", user.getCreateTime().toString());

        redisTemplate.opsForHash().putAll(RedisKeyUtil.getUserKey(userId), userMap);
        stringRedisTemplate.opsForValue().set(RedisKeyUtil.getUsernameKey(user.getUsername()), userId);

        return user;
    }

    
    public User getUserById(String id) {
        Map<Object, Object> userMap = redisTemplate.opsForHash().entries(RedisKeyUtil.getUserKey(id));
        if (userMap.isEmpty()) {
            return null;
        }

        return mapToUser(userMap);
    }

    
    public User getUserByUsername(String username) {
        String userId = stringRedisTemplate.opsForValue().get(RedisKeyUtil.getUsernameKey(username));
        if (userId == null) {
            return null;
        }

        return getUserById(userId);
    }

    
    public List<UserResponse> getNearbyUsers(double latitude, double longitude, double radius) {
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = locationService.getNearbyLocations(
                RedisKeyUtil.USER_LOCATIONS, latitude, longitude, radius);

        List<UserResponse> nearbyUsers = new ArrayList<>();
        for (var result : results) {
            String userId = result.getContent().getName();
            User user = getUserById(userId);
            if (user != null) {
                UserResponse userResponse = convertToUserResponse(user);
                userResponse.setDistance(result.getDistance().getValue());
                nearbyUsers.add(userResponse);
            }
        }

        return nearbyUsers;
    }

    
    public List<UserResponse> getGroupMembers(String groupId) {
        Set<String> memberIds = stringRedisTemplate.opsForSet().members(RedisKeyUtil.getGroupMembersKey(groupId));
        if (memberIds == null || memberIds.isEmpty()) {
            return Collections.emptyList();
        }

        return memberIds.stream()
                .map(this::getUserById)
                .filter(Objects::nonNull)
                .map(this::convertToUserResponse)
                .collect(Collectors.toList());
    }

    
    public UserResponse convertToUserResponse(User user) {
        if (user == null) {
            return null;
        }

        UserResponse response = new UserResponse();
        response.setId(user.getId());
        response.setUsername(user.getUsername());
        response.setNickname(user.getNickname());
        response.setAvatar(user.getAvatar());
        return response;
    }

    private User mapToUser(Map<Object, Object> userMap) {
        User user = new User();
        user.setId((String) userMap.get("id"));
        user.setUsername((String) userMap.get("username"));
        user.setPassword((String) userMap.get("password"));
        user.setNickname((String) userMap.get("nickname"));
        user.setAvatar((String) userMap.get("avatar"));
        user.setCreateTime(LocalDateTime.parse((String) userMap.get("createTime")));
        return user;
    }
}
认证服务
package com.example.face2face.service;

import com.example.face2face.dto.request.LoginRequest;
import com.example.face2face.dto.request.RegisterRequest;
import com.example.face2face.dto.response.JwtResponse;
import com.example.face2face.exception.AppException;
import com.example.face2face.model.User;
import com.example.face2face.service.UserService;
import com.example.face2face.util.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    @Autowired
    private UserService userService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    
    public User register(RegisterRequest request) {
        return userService.registerUser(request);
    }

    
    public JwtResponse login(LoginRequest request) {
        User user = userService.getUserByUsername(request.getUsername());
        if (user == null) {
            throw new AppException(401, "Invalid username or password");
        }

        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new AppException(401, "Invalid username or password");
        }

        String token = jwtTokenUtil.generateToken(user.getUsername(), user.getId());
        return new JwtResponse(token, user.getId(), user.getUsername(), user.getNickname());
    }
}
位置服务
package com.example.face2face.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.*;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class LocationService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    
    public void updateLocation(String key, String id, double latitude, double longitude) {
        redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), id);
    }

    
    public GeoResults<GeoLocation<String>> getNearbyLocations(String key, double latitude, double longitude, double radius) {
        Circle circle = new Circle(new Point(longitude, latitude), new Distance(radius, Metrics.KILOMETERS));
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
                .newGeoRadiusArgs()
                .includeDistance()
                .sortAscending();

        return redisTemplate.opsForGeo().radius(key, circle, args);
    }

    
    public Double getDistance(String key, String id1, String id2) {
        Distance distance = redisTemplate.opsForGeo().distance(key, id1, id2, Metrics.KILOMETERS);
        return distance != null ? distance.getValue() : null;
    }
    
    public double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
        // 使用Haversine公式计算两点间的距离(单位:米)
        double earthRadius = 6371000; // 地球半径,单位:米

        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);

        double a = Math.sin(dLat/2) * Math.sin(dLat/2) +
                Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
                        Math.sin(dLon/2) * Math.sin(dLon/2);

        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));

        return earthRadius * c;
    }
    
}
群组服务
// GroupService.java
package com.example.face2face.service;

import com.example.face2face.dto.request.CreateGroupRequest;
import com.example.face2face.dto.response.GroupResponse;
import com.example.face2face.dto.response.UserResponse;
import com.example.face2face.exception.AppException;
import com.example.face2face.model.Group;
import com.example.face2face.model.User;
import com.example.face2face.service.LocationService;
import com.example.face2face.service.UserService;
import com.example.face2face.util.RedisKeyUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class GroupService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private LocationService locationService;

    @Autowired
    private UserService userService;

    private static final long GROUP_EXPIRY_HOURS = 24;

    
    public GroupResponse createGroup(CreateGroupRequest request, User creator) {
        // Generate unique group ID and invitation code
        String groupId = UUID.randomUUID().toString();
        String invitationCode = generateInvitationCode();

        // Create group
        Group group = new Group();
        group.setId(groupId);
        group.setName(request.getName());
        group.setDescription(request.getDescription());
        group.setCreatorId(creator.getId());
        group.setLatitude(request.getLatitude());
        group.setLongitude(request.getLongitude());
        group.setInvitationCode(invitationCode);
        group.setCreateTime(LocalDateTime.now());
        group.setExpireTime(LocalDateTime.now().plusHours(GROUP_EXPIRY_HOURS));

        // Save group to Redis
        Map<String, Object> groupMap = new HashMap<>();
        groupMap.put("id", group.getId());
        groupMap.put("name", group.getName());
        groupMap.put("description", group.getDescription());
        groupMap.put("creatorId", group.getCreatorId());
        groupMap.put("latitude", group.getLatitude());
        groupMap.put("longitude", group.getLongitude());
        groupMap.put("invitationCode", group.getInvitationCode());
        groupMap.put("createTime", group.getCreateTime().toString());
        groupMap.put("expireTime", group.getExpireTime().toString());

        redisTemplate.opsForHash().putAll(RedisKeyUtil.getGroupKey(groupId), groupMap);
        stringRedisTemplate.opsForValue().set(
                RedisKeyUtil.getInvitationCodeKey(invitationCode), 
                groupId,
                java.time.Duration.ofHours(GROUP_EXPIRY_HOURS)
        );

        // Save group location
        locationService.updateLocation(
                RedisKeyUtil.GROUP_LOCATIONS,
                groupId,
                group.getLatitude(),
                group.getLongitude()
        );

        // Creator joins the group
        joinGroup(groupId, creator.getId());

        // Return response
        GroupResponse response = new GroupResponse();
        response.setId(group.getId());
        response.setName(group.getName());
        response.setDescription(group.getDescription());
        response.setCreator(userService.convertToUserResponse(creator));
        response.setLatitude(group.getLatitude());
        response.setLongitude(group.getLongitude());
        response.setInvitationCode(group.getInvitationCode());
        response.setCreateTime(group.getCreateTime());
        response.setMemberCount(1);

        return response;
    }

    
    public Group getGroupById(String groupId) {
        Map<Object, Object> groupMap = redisTemplate.opsForHash().entries(RedisKeyUtil.getGroupKey(groupId));
        if (groupMap.isEmpty()) {
            return null;
        }

        return mapToGroup(groupMap);
    }

    
    public GroupResponse getGroupResponse(String groupId) {
        Group group = getGroupById(groupId);
        if (group == null) {
            return null;
        }

        GroupResponse response = new GroupResponse();
        response.setId(group.getId());
        response.setName(group.getName());
        response.setDescription(group.getDescription());
        
        User creator = userService.getUserById(group.getCreatorId());
        response.setCreator(userService.convertToUserResponse(creator));
        
        response.setLatitude(group.getLatitude());
        response.setLongitude(group.getLongitude());
        response.setInvitationCode(group.getInvitationCode());
        response.setCreateTime(group.getCreateTime());
        
        // Get members
        List<UserResponse> members = userService.getGroupMembers(groupId);
        response.setMembers(members);
        response.setMemberCount(members.size());

        return response;
    }

    
    public List<GroupResponse> getNearbyGroups(double latitude, double longitude, double radius) {
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = locationService.getNearbyLocations(
                RedisKeyUtil.GROUP_LOCATIONS, latitude, longitude, radius);

        List<GroupResponse> nearbyGroups = new ArrayList<>();
        for (var result : results) {
            String groupId = result.getContent().getName();
            Group group = getGroupById(groupId);
            
            if (group != null && group.getExpireTime().isAfter(LocalDateTime.now())) {
                GroupResponse response = new GroupResponse();
                response.setId(group.getId());
                response.setName(group.getName());
                response.setDescription(group.getDescription());
                
                User creator = userService.getUserById(group.getCreatorId());
                response.setCreator(userService.convertToUserResponse(creator));
                
                response.setLatitude(group.getLatitude());
                response.setLongitude(group.getLongitude());
                response.setCreateTime(group.getCreateTime());
                response.setDistance(result.getDistance().getValue());
                
                // Get member count
                Long memberCount = stringRedisTemplate.opsForSet().size(RedisKeyUtil.getGroupMembersKey(groupId));
                response.setMemberCount(memberCount != null ? memberCount.intValue() : 0);
                
                nearbyGroups.add(response);
            }
        }

        return nearbyGroups;
    }

    
    public List<GroupResponse> getUserGroups(String userId) {
        Set<String> groupIds = stringRedisTemplate.opsForSet().members(RedisKeyUtil.getUserGroupsKey(userId));
        if (groupIds == null || groupIds.isEmpty()) {
            return Collections.emptyList();
        }

        return groupIds.stream()
                .map(this::getGroupById)
                .filter(group -> group != null && group.getExpireTime().isAfter(LocalDateTime.now()))
                .map(group -> {
                    GroupResponse response = new GroupResponse();
                    response.setId(group.getId());
                    response.setName(group.getName());
                    response.setDescription(group.getDescription());
                    
                    User creator = userService.getUserById(group.getCreatorId());
                    response.setCreator(userService.convertToUserResponse(creator));
                    
                    response.setLatitude(group.getLatitude());
                    response.setLongitude(group.getLongitude());
                    response.setCreateTime(group.getCreateTime());
                    
                    // Get member count
                    Long memberCount = stringRedisTemplate.opsForSet().size(RedisKeyUtil.getGroupMembersKey(group.getId()));
                    response.setMemberCount(memberCount != null ? memberCount.intValue() : 0);
                    
                    return response;
                })
                .collect(Collectors.toList());
    }

    
    public boolean joinGroup(String groupId, String userId) {
        Group group = getGroupById(groupId);
        if (group == null || group.getExpireTime().isBefore(LocalDateTime.now())) {
            return false;
        }

List<Point> userPoints = locationService.getUserLocation(RedisKeyUtil.USER_LOCATIONS,userId);
        if (userPoints.isEmpty()) {
            throw new AppException(400,"User location not found");
        }

        double distance = locationService.calculateDistance(
                userPoints.get(0).getY(),userPoints.get(0).getX(),
                group.getLatitude(), group.getLongitude()
        );

        if (distance > distanceThresholdMeters) {
            throw new AppException(400,"You are too far from the group creator");
        }

        // Add user to group members
        stringRedisTemplate.opsForSet().add(RedisKeyUtil.getGroupMembersKey(groupId), userId);
        
        // Add group to user's groups
        stringRedisTemplate.opsForSet().add(RedisKeyUtil.getUserGroupsKey(userId), groupId);
        
        return true;
    }

    
    public boolean joinGroupByInvitationCode(String invitationCode, String userId) {
        String groupId = stringRedisTemplate.opsForValue().get(RedisKeyUtil.getInvitationCodeKey(invitationCode));
        if (groupId == null) {
            return false;
        }

        return joinGroup(groupId, userId);
    }

    
    public boolean isGroupMember(String groupId, String userId) {
        return Boolean.TRUE.equals(
                stringRedisTemplate.opsForSet().isMember(RedisKeyUtil.getGroupMembersKey(groupId), userId));
    }

    private String generateInvitationCode() {
        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuilder code = new StringBuilder();
        Random random = new Random();
        for (int i = 0; i < 6; i++) {
            code.append(chars.charAt(random.nextInt(chars.length())));
        }
        return code.toString();
    }

    private Group mapToGroup(Map<Object, Object> groupMap) {
        Group group = new Group();
        group.setId((String) groupMap.get("id"));
        group.setName((String) groupMap.get("name"));
        group.setDescription((String) groupMap.get("description"));
        group.setCreatorId((String) groupMap.get("creatorId"));
        group.setLatitude(Double.parseDouble(groupMap.get("latitude").toString()));
        group.setLongitude(Double.parseDouble(groupMap.get("longitude").toString()));
        group.setInvitationCode((String) groupMap.get("invitationCode"));
        group.setCreateTime(LocalDateTime.parse((String) groupMap.get("createTime")));
        group.setExpireTime(LocalDateTime.parse((String) groupMap.get("expireTime")));
        return group;
    }
}

6.8 控制器实现

// AuthController.java
package com.example.face2face.controller;

import com.example.face2face.dto.request.LoginRequest;
import com.example.face2face.dto.request.RegisterRequest;
import com.example.face2face.dto.response.ApiResponse;
import com.example.face2face.dto.response.JwtResponse;
import com.example.face2face.model.User;
import com.example.face2face.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthService authService;

    @PostMapping("/register")
    public ApiResponse<User> register(@RequestBody RegisterRequest request) {
        User user = authService.register(request);
        return ApiResponse.success(user);
    }

    @PostMapping("/login")
    public ApiResponse<JwtResponse> login(@RequestBody LoginRequest request) {
        JwtResponse response = authService.login(request);
        return ApiResponse.success(response);
    }
}

// LocationController.java
package com.example.face2face.controller;

import com.example.face2face.dto.request.LocationRequest;
import com.example.face2face.dto.response.ApiResponse;
import com.example.face2face.dto.response.UserResponse;
import com.example.face2face.model.User;
import com.example.face2face.service.LocationService;
import com.example.face2face.service.UserService;
import com.example.face2face.util.RedisKeyUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.List;

@RestController
@RequestMapping("/api/location")
public class LocationController {

    @Autowired
    private LocationService locationService;

    @Autowired
    private UserService userService;

    @PostMapping("/update")
    public ApiResponse<Void> updateLocation(
            @RequestBody LocationRequest request,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        locationService.updateLocation(
                RedisKeyUtil.USER_LOCATIONS,
                currentUser.getId(),
                request.getLatitude(),
                request.getLongitude()
        );
        
        return ApiResponse.success();
    }

    @GetMapping("/nearby-users")
    public ApiResponse<List<UserResponse>> getNearbyUsers(
            @RequestParam double latitude,
            @RequestParam double longitude,
            @RequestParam(defaultValue = "100") double radius,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        List<UserResponse> nearbyUsers = userService.getNearbyUsers(latitude, longitude, radius);
        
        // Remove current user from the list
        nearbyUsers.removeIf(user -> user.getId().equals(currentUser.getId()));
        
        return ApiResponse.success(nearbyUsers);
    }
}

// GroupController.java
package com.example.face2face.controller;

import com.example.face2face.dto.request.CreateGroupRequest;
import com.example.face2face.dto.request.JoinGroupRequest;
import com.example.face2face.dto.response.ApiResponse;
import com.example.face2face.dto.response.GroupResponse;
import com.example.face2face.exception.AppException;
import com.example.face2face.model.User;
import com.example.face2face.service.GroupService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.List;

@RestController
@RequestMapping("/api/groups")
public class GroupController {

    @Autowired
    private GroupService groupService;

    @PostMapping("/create")
    public ApiResponse<GroupResponse> createGroup(
            @RequestBody CreateGroupRequest request,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        GroupResponse group = groupService.createGroup(request, currentUser);
        
        return ApiResponse.success(group);
    }

    @GetMapping("/{groupId}")
    public ApiResponse<GroupResponse> getGroup(
            @PathVariable String groupId,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        
        // Check if user is a member of the group
        if (!groupService.isGroupMember(groupId, currentUser.getId())) {
            throw new AppException(403, "You are not a member of this group");
        }
        
        GroupResponse group = groupService.getGroupResponse(groupId);
        if (group == null) {
            throw new AppException(404, "Group not found");
        }
        
        return ApiResponse.success(group);
    }

    @GetMapping("/nearby")
    public ApiResponse<List<GroupResponse>> getNearbyGroups(
            @RequestParam double latitude,
            @RequestParam double longitude,
            @RequestParam(defaultValue = "500") double radius) {
        
        List<GroupResponse> nearbyGroups = groupService.getNearbyGroups(latitude, longitude, radius);
        
        return ApiResponse.success(nearbyGroups);
    }

    @GetMapping("/my-groups")
    public ApiResponse<List<GroupResponse>> getMyGroups(HttpServletRequest httpRequest) {
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        List<GroupResponse> userGroups = groupService.getUserGroups(currentUser.getId());
        
        return ApiResponse.success(userGroups);
    }

    @PostMapping("/{groupId}/join")
    public ApiResponse<Boolean> joinGroup(
            @PathVariable String groupId,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        boolean joined = groupService.joinGroup(groupId, currentUser.getId());
        
        if (!joined) {
            throw new AppException(400, "Failed to join group");
        }
        
        return ApiResponse.success(true);
    }

    @PostMapping("/join-by-code")
    public ApiResponse<Boolean> joinByCode(
            @RequestBody JoinGroupRequest request,
            HttpServletRequest httpRequest) {
        
        User currentUser = (User) httpRequest.getAttribute("currentUser");
        boolean joined = groupService.joinGroupByInvitationCode(
                request.getInvitationCode(), currentUser.getId());
        
        if (!joined) {
            throw new AppException(400, "Invalid invitation code");
        }
        
        return ApiResponse.success(true);
    }
}

6.9 应用配置

// Application.java
package com.example.face2face;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@SpringBootApplication
public class Face2FaceApplication {

    public static void main(String[] args) {
        SpringApplication.run(Face2FaceApplication.class, args);
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

application.properties配置:

# Server configuration
server.port=8080

# Redis configuration
spring.redis.host=localhost
spring.redis.port=6379

face2face.distanceThresholdMeters: 500


# JWT configuration
jwt.secret=face2faceSecretKey
jwt.expiration=86400

七、前端实现

7.1 登录页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录 - 面对面建群</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
</head>
<body class="bg-light">
    <div id="app" class="container">
        <div class="row justify-content-center mt-5">
            <div class="col-md-6">
                <div class="card shadow">
                    <div class="card-header bg-primary text-white">
                        <h4 class="mb-0">登录</h4>
                    </div>
                    <div class="card-body">
                        <form @submit.prevent="login">
                            <div class="mb-3">
                                <label for="username" class="form-label">用户名</label>
                                <input v-model="username" type="text" class="form-control" id="username" required>
                            </div>
                            <div class="mb-3">
                                <label for="password" class="form-label">密码</label>
                                <input v-model="password" type="password" class="form-control" id="password" required>
                            </div>
                            <div class="d-grid gap-2">
                                <button type="submit" class="btn btn-primary">登录</button>
                                <button type="button" class="btn btn-outline-secondary" @click="goToRegister">注册账号</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.global.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script>
        const { createApp, ref } = Vue;

        createApp({
            setup() {
                const username = ref('');
                const password = ref('');

                const login = async () => {
                    try {
                        const response = await axios.post('/api/auth/login', {
                            username: username.value,
                            password: password.value
                        });
                        
                        if (response.data.code === 200) {
                            const data = response.data.data;
                            localStorage.setItem('token', data.token);
                            localStorage.setItem('user', JSON.stringify({
                                id: data.id,
                                username: data.username,
                                nickname: data.nickname
                            }));
                            
                            window.location.href = '/index.html';
                        } else {
                            alert(response.data.message || '登录失败');
                        }
                    } catch (error) {
                        alert('登录失败: ' + (error.response?.data?.message || error.message));
                    }
                };

                const goToRegister = () => {
                    window.location.href = '/register.html';
                };

                return {
                    username,
                    password,
                    login,
                    goToRegister
                };
            }
        }).mount('#app');
    </script>
</body>
</html>

7.2 主页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>面对面建群</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
    <style>
        .group-card {
            transition: all 0.3s ease;
        }
        .group-card:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 20px rgba(0,0,0,0.1);
        }
    </style>
</head>
<body>
    <div id="app">
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <div class="container">
                <a class="navbar-brand" href="#">面对面建群</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarNav">
                    <ul class="navbar-nav me-auto">
                        <li class="nav-item">
                            <a class="nav-link active" href="#">首页</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="#" @click="updateLocation">刷新位置</a>
                        </li>
                    </ul>
                    <div class="d-flex">
                        <span class="navbar-text me-3">
                            欢迎, {{ user.nickname || user.username }}
                        </span>
                        <button class="btn btn-outline-light" @click="logout">退出</button>
                    </div>
                </div>
            </div>
        </nav>
        <div class="container mt-4">
            <div class="row">
                <div class="col-md-8">
                    <div class="card mb-4">
                        <div class="card-header d-flex justify-content-between align-items-center">
                            <h5 class="mb-0">附近的群组</h5>
                            <button class="btn btn-primary" @click="openCreateGroupModal">
                                <i class="bi bi-plus-circle"></i> 创建群组
                            </button>
                        </div>
                        <div class="card-body">
                            <div v-if="loading" class="text-center py-5">
                                <div class="spinner-border text-primary" role="status">
                                    <span class="visually-hidden">Loading...</span>
                                </div>
                                <p class="mt-3">加载中...</p>
                            </div>
                            <div v-else-if="nearbyGroups.length === 0" class="text-center py-5">
                                <i class="bi bi-search" style="font-size: 3rem;"></i>
                                <p class="mt-3">没有找到附近的群组</p>
                                <button class="btn btn-outline-primary" @click="getNearbyGroups">
                                    <i class="bi bi-arrow-clockwise"></i> 刷新
                                </button>
                            </div>
                            <div v-else class="row">
                                <div v-for="group in nearbyGroups" :key="group.id" class="col-md-6 mb-3">
                                    <div class="card group-card h-100">
                                        <div class="card-body">
                                            <h5 class="card-title">{{ group.name }}</h5>
                                            <p class="card-text text-muted small">{{ group.description }}</p>
                                            <div class="d-flex justify-content-between align-items-center">
                                                <div>
                                                    <span class="badge bg-info">
                                                        <i class="bi bi-geo-alt"></i> {{ formatDistance(group.distance) }}
                                                    </span>
                                                    <span class="badge bg-secondary ms-1">
                                                        <i class="bi bi-people"></i> {{ group.memberCount }}人
                                                    </span>
                                                </div>
                                                <button class="btn btn-sm btn-primary" @click="joinGroup(group)">
                                                    加入群组
                                                </button>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="col-md-4">
                    <div class="card mb-4">
                        <div class="card-header">
                            <h5 class="mb-0">我的群组</h5>
                        </div>
                        <div class="card-body">
                            <div v-if="myGroups.length === 0" class="text-center py-4">
                                <i class="bi bi-people" style="font-size: 2rem;"></i>
                                <p class="mt-2">您还没有加入任何群组</p>
                            </div>
                            <div v-else>
                                <div v-for="group in myGroups" :key="group.id" class="mb-3 border-bottom pb-2">
                                    <h6>{{ group.name }}</h6>
                                    <div class="d-flex justify-content-between align-items-center">
                                        <small class="text-muted">
                                            <i class="bi bi-clock"></i> {{ formatTime(group.createTime) }}
                                        </small>
                                        <a :href="'/group.html?id=' + group.id" class="btn btn-sm btn-outline-primary">
                                            <i class="bi bi-info-circle"></i> 详情
                                        </a>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                    
                    <div class="card">
                        <div class="card-header">
                            <h5 class="mb-0">通过邀请码加入</h5>
                        </div>
                        <div class="card-body">
                            <div class="input-group">
                                <input v-model="invitationCode" type="text" class="form-control" placeholder="输入邀请码">
                                <button class="btn btn-primary" @click="joinByCode">加入</button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        
        <!-- 创建群组弹窗 -->
        <div class="modal fade" id="createGroupModal" tabindex="-1">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">创建面对面群组</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                    </div>
                    <div class="modal-body">
                        <form @submit.prevent="createGroup">
                            <div class="mb-3">
                                <label class="form-label">群组名称</label>
                                <input v-model="newGroup.name" type="text" class="form-control" required>
                            </div>
                            <div class="mb-3">
                                <label class="form-label">群组描述</label>
                                <textarea v-model="newGroup.description" class="form-control" rows="3"></textarea>
                            </div>
                            <div class="d-grid">
                                <button type="submit" class="btn btn-primary">创建群组</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.global.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script>
        const { createApp, ref, onMounted } = Vue;

        createApp({
            setup() {
                const user = ref({});
                const nearbyGroups = ref([]);
                const myGroups = ref([]);
                const invitationCode = ref('');
                const newGroup = ref({
                    name: '',
                    description: '',
                    latitude: 0,
                    longitude: 0
                });
                const loading = ref(false);
                let createGroupModal;
                
                // 检查登录状态
                const checkAuth = () => {
                    const token = localStorage.getItem('token');
                    const userStr = localStorage.getItem('user');
                    
                    if (!token || !userStr) {
                        window.location.href = '/login.html';
                        return;
                    }
                    
                    user.value = JSON.parse(userStr);
                    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
                };
                
                // 更新位置
                const updateLocation = () => {
                    loading.value = true;
                    if (navigator.geolocation) {
                        navigator.geolocation.getCurrentPosition(position => {
                            const latitude = position.coords.latitude;
                            const longitude = position.coords.longitude;
                            
                            newGroup.value.latitude = latitude;
                            newGroup.value.longitude = longitude;
                            
                            axios.post('/api/location/update', {
                                latitude: latitude,
                                longitude: longitude
                            }).then(() => {
                                getNearbyGroups();
                            }).catch(error => {
                                console.error('Update location failed:', error);
                                loading.value = false;
                                alert('更新位置失败');
                            });
                        }, error => {
                            console.error('Geolocation error:', error);
                            loading.value = false;
                            alert('无法获取您的位置,请确保已授权位置权限');
                        });
                    } else {
                        loading.value = false;
                        alert('您的浏览器不支持地理位置功能');
                    }
                };
                
                // 获取附近群组
                const getNearbyGroups = () => {
                    loading.value = true;
                    if (newGroup.value.latitude && newGroup.value.longitude) {
                        axios.get('/api/groups/nearby', {
                            params: {
                                latitude: newGroup.value.latitude,
                                longitude: newGroup.value.longitude,
                                radius: 500 // 500米范围内
                            }
                        }).then(response => {
                            if (response.data.code === 200) {
                                nearbyGroups.value = response.data.data;
                            } else {
                                alert(response.data.message || '获取附近群组失败');
                            }
                            loading.value = false;
                        }).catch(error => {
                            console.error('Get nearby groups failed:', error);
                            loading.value = false;
                            alert('获取附近群组失败');
                        });
                    } else {
                        loading.value = false;
                    }
                };
                
                // 获取我的群组
                const getMyGroups = () => {
                    axios.get('/api/groups/my-groups')
                        .then(response => {
                            if (response.data.code === 200) {
                                myGroups.value = response.data.data;
                            }
                        }).catch(error => {
                            console.error('Get my groups failed:', error);
                        });
                };
                
                // 创建群组
                const createGroup = () => {
                    if (!newGroup.value.latitude || !newGroup.value.longitude) {
                        alert('请先更新您的位置');
                        return;
                    }
                    
                    axios.post('/api/groups/create', newGroup.value)
                        .then(response => {
                            if (response.data.code === 200) {
                                createGroupModal.hide();
                                myGroups.value.push(response.data.data);
                                alert('群组创建成功,邀请码: ' + response.data.data.invitationCode);
                                
                                // 重置表单
                                newGroup.value.name = '';
                                newGroup.value.description = '';
                            } else {
                                alert(response.data.message || '创建群组失败');
                            }
                        }).catch(error => {
                            console.error('Create group failed:', error);
                            alert('创建群组失败');
                        });
                };
                
                // 加入群组
                const joinGroup = (group) => {
                    axios.post(`/api/groups/${group.id}/join`)
                        .then(response => {
                            if (response.data.code === 200) {
                                alert('成功加入群组');
                                if (!myGroups.value.some(g => g.id === group.id)) {
                                    myGroups.value.push(group);
                                }
                            } else {
                                alert(response.data.message || '加入群组失败');
                            }
                        }).catch(error => {
                            console.error('Join group failed:', error);
                            alert('加入群组失败');
                        });
                };
                
                // 通过邀请码加入
                const joinByCode = () => {
                    if (!invitationCode.value) {
                        alert('请输入邀请码');
                        return;
                    }
                    
                    axios.post('/api/groups/join-by-code', {
                        invitationCode: invitationCode.value
                    }).then(response => {
                        if (response.data.code === 200) {
                            alert('成功加入群组');
                            getMyGroups();
                            invitationCode.value = '';
                        } else {
                            alert(response.data.message || '邀请码无效');
                        }
                    }).catch(error => {
                        console.error('Join by code failed:', error);
                        alert('邀请码无效');
                    });
                };
                
                // 打开创建群组弹窗
                const openCreateGroupModal = () => {
                    createGroupModal.show();
                };
                
                // 格式化时间
                const formatTime = (timeStr) => {
                    if (!timeStr) return '';
                    const date = new Date(timeStr);
                    return date.toLocaleString();
                };
                
                // 格式化距离
                const formatDistance = (distance) => {
                    if (distance == null) return '未知';
                    if (distance < 1000) {
                        return Math.round(distance) + '米';
                    } else {
                        return (distance / 1000).toFixed(1) + '公里';
                    }
                };
                
                // 退出登录
                const logout = () => {
                    localStorage.removeItem('token');
                    localStorage.removeItem('user');
                    window.location.href = '/login.html';
                };
                
                onMounted(() => {
                    checkAuth();
                    createGroupModal = new bootstrap.Modal(document.getElementById('createGroupModal'));
                    updateLocation();
                    getMyGroups();
                });
                
                return {
                    user,
                    nearbyGroups,
                    myGroups,
                    invitationCode,
                    newGroup,
                    loading,
                    updateLocation,
                    getNearbyGroups,
                    getMyGroups,
                    createGroup,
                    joinGroup,
                    joinByCode,
                    openCreateGroupModal,
                    formatTime,
                    formatDistance,
                    logout
                };
            }
        }).mount('#app');
    </script>
</body>
</html>

7.3 群组详情页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>群组详情 - 面对面建群</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
</head>
<body>
    <div id="app">
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <div class="container">
                <a class="navbar-brand" href="#">{{ group.name || '群组详情' }}</a>
                <div class="d-flex text-white align-items-center">
                    <span v-if="isCreator" class="badge bg-light text-primary me-2">创建者</span>
                    <a href="/" class="btn btn-outline-light btn-sm">
                        <i class="bi bi-house"></i> 返回首页
                    </a>
                </div>
            </div>
        </nav>
        <div class="container mt-4">
            <div v-if="loading" class="text-center py-5">
                <div class="spinner-border text-primary" role="status">
                    <span class="visually-hidden">Loading...</span>
                </div>
                <p class="mt-3">加载中...</p>
            </div>
            <div v-else class="row">
                <div class="col-md-8">
                    <div class="card mb-4">
                        <div class="card-header bg-light">
                            <h5 class="mb-0">群组信息</h5>
                        </div>
                        <div class="card-body">
                            <h4 class="card-title">{{ group.name }}</h4>
                            <p class="card-text">{{ group.description || '暂无描述' }}</p>
                            <div class="d-flex justify-content-between align-items-center">
                                <div>
                                    <span class="badge bg-info">
                                        <i class="bi bi-people"></i> {{ group.memberCount }}位成员
                                    </span>
                                    <span class="badge bg-secondary ms-2">
                                        <i class="bi bi-clock"></i> {{ formatTime(group.createTime) }}创建
                                    </span>
                                </div>
                                <div v-if="isCreator" class="d-flex">
                                    <button class="btn btn-outline-primary btn-sm">
                                        <i class="bi bi-share"></i> 邀请码: {{ group.invitationCode }}
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="card">
                        <div class="card-header bg-light">
                            <h5 class="mb-0">群组成员</h5>
                        </div>
                        <div class="card-body">
                            <div class="row">
                                <div v-for="member in group.members" :key="member.id" class="col-md-4 mb-3">
                                    <div class="card h-100">
                                        <div class="card-body">
                                            <div class="d-flex align-items-center">
                                                <div class="flex-shrink-0">
                                                    <i class="bi bi-person-circle" style="font-size: 2rem;"></i>
                                                </div>
                                                <div class="flex-grow-1 ms-3">
                                                    <h6 class="mb-0">{{ member.nickname }}</h6>
                                                    <small class="text-muted">{{ member.username }}</small>
                                                </div>
                                                <div v-if="member.id === group.creator?.id">
                                                    <span class="badge bg-warning text-dark">创建者</span>
                                                </div>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="col-md-4">
                    <div class="card mb-4">
                        <div class="card-header bg-light">
                            <h5 class="mb-0">位置信息</h5>
                        </div>
                        <div class="card-body">
                            <p>
                                <i class="bi bi-geo-alt"></i> 
                                纬度: {{ group.latitude.toFixed(6) }}<br>
                                <i class="bi bi-geo-alt"></i>
                                经度: {{ group.longitude.toFixed(6) }}
                            </p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.global.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script>
        const { createApp, ref, computed, onMounted } = Vue;

        createApp({
            setup() {
                // 状态
                const user = ref(null);
                const group = ref({
                    id: null,
                    name: '加载中...',
                    description: '',
                    members: [],
                    creator: null,
                    createTime: null
                });
                const loading = ref(true);
                
                // 从URL中获取群组ID
                const getGroupId = () => {
                    const urlParams = new URLSearchParams(window.location.search);
                    return urlParams.get('id');
                };
                
                // 计算属性
                const isCreator = computed(() => {
                    return user.value && group.value.creator && 
                           user.value.id === group.value.creator.id;
                });
                
                // 方法
                const checkAuth = () => {
                    const token = localStorage.getItem('token');
                    const userStr = localStorage.getItem('user');
                    
                    if (!token || !userStr) {
                        window.location.href = '/login.html';
                        return;
                    }
                    
                    user.value = JSON.parse(userStr);
                    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
                    
                    // 加载群组信息
                    loadGroupInfo();
                };
                
                const loadGroupInfo = () => {
                    const groupId = getGroupId();
                    if (!groupId) {
                        window.location.href = '/index.html';
                        return;
                    }
                    
                    loading.value = true;
                    axios.get(`/api/groups/${groupId}`)
                        .then(response => {
                            if (response.data.code === 200) {
                                group.value = response.data.data;
                            } else {
                                alert(response.data.message || '加载群组信息失败');
                                window.location.href = '/index.html';
                            }
                            loading.value = false;
                        })
                        .catch(error => {
                            console.error('Failed to load group info:', error);
                            alert('加载群组信息失败');
                            window.location.href = '/index.html';
                            loading.value = false;
                        });
                };
                
                const formatTime = (timeStr) => {
                    if (!timeStr) return '';
                    const date = new Date(timeStr);
                    return date.toLocaleString();
                };
                
                onMounted(() => {
                    checkAuth();
                });
                
                return {
                    user,
                    group,
                    loading,
                    isCreator,
                    formatTime
                };
            }
        }).mount('#app');
    </script>
</body>
</html>

八、效果演示

8.1 访问注册页面注册两个账号

访问 http://localhost:8080/register.html,输入账号信息进行注册

8.2 登录建群

访问 http://localhost:8080/login.html,输入账号登陆,创建群组

8.3 登陆另一个账号输入邀请码加群

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值