一、开篇
面对面建群是一种基于位置的社交应用场景,允许用户在物理位置相近的情况下快速创建和加入临时群组。
本文将介绍如何使用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,输入账号登陆,创建群组