SpringBoot+Redis 优雅实现面对面建群功能
一、开篇
面对面建群是一种基于位置的社交应用场景,允许用户在物理位置相近的情况下快速创建和加入临时群组。
本文将介绍如何使用SpringBoot和Redis实现面对面建群,本示例基于Redis的各种数据结构存储应用数据。
二、技术栈
-
• Spring Boot 3.4.5
-
• Redis 5.0 (Jedis客户端)
-
• JWT (认证)
-
• HTML/CSS/JavaScript (前端)
-
• Bootstrap 5 (UI框架)
-
• Vue.js 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
publicclassRedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
privateint redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfigurationconfig=newRedisStandaloneConfiguration(redisHost, redisPort);
returnnewJedisConnectionFactory(config);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = newRedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(newStringRedisSerializer());
template.setValueSerializer(newJackson2JsonRedisSerializer<>(Object.class));
template.setHashKeySerializer(newStringRedisSerializer());
template.setHashValueSerializer(newJackson2JsonRedisSerializer<>(Object.class));
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate() {
StringRedisTemplatetemplate=newStringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory());
return template;
}
}
6.2 Redis键工具类
创建Redis键名工具类,统一管理键名生成规则:
package com.example.face2face.util;
publicclassRedisKeyUtil {
// 用户相关键
publicstatic String getUserKey(String userId) {
return"user:" + userId;
}
publicstatic String getUsernameKey(String username) {
return"username:" + username;
}
publicstatic String getUserGroupsKey(String userId) {
return"user:groups:" + userId;
}
// 位置相关键
publicstaticfinalStringUSER_LOCATIONS="user:locations";
publicstaticfinalStringGROUP_LOCATIONS="group:locations";
// 群组相关键
publicstatic String getGroupKey(String groupId) {
return"group:" + groupId;
}
publicstatic String getGroupMembersKey(String groupId) {
return"group:members:" + groupId;
}
publicstatic String getInvitationCodeKey(String code) {
return"invitation:code:" + code;
}
}
6.3 模型类
用户模型类
package com.example.face2face.model;
import java.io.Serializable;
import java.time.LocalDateTime;
publicclassUserimplementsSerializable {
private String id;
private String username;
private String password;
private String nickname;
private String avatar;
private LocalDateTime createTime;
// Constructors
publicUser() {
}
publicUser(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;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
publicvoidsetUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
publicvoidsetPassword(String password) {
this.password = password;
}
public String getNickname() {
return nickname;
}
publicvoidsetNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatar() {
return avatar;
}
publicvoidsetAvatar(String avatar) {
this.avatar = avatar;
}
public LocalDateTime getCreateTime() {
return createTime;
}
publicvoidsetCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
}
群组模型类
package com.example.face2face.model;
import java.io.Serializable;
import java.time.LocalDateTime;
publicclassGroupimplementsSerializable {
private String id;
private String name;
private String description;
private String creatorId;
privatedouble latitude;
privatedouble longitude;
private String invitationCode;
private LocalDateTime createTime;
private LocalDateTime expireTime;
// Constructors
publicGroup() {
}
publicGroup(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;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getName() {
return name;
}
publicvoidsetName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
publicvoidsetDescription(String description) {
this.description = description;
}
public String getCreatorId() {
return creatorId;
}
publicvoidsetCreatorId(String creatorId) {
this.creatorId = creatorId;
}
publicdoublegetLatitude() {
return latitude;
}
publicvoidsetLatitude(double latitude) {
this.latitude = latitude;
}
publicdoublegetLongitude() {
return longitude;
}
publicvoidsetLongitude(double longitude) {
this.longitude = longitude;
}
public String getInvitationCode() {
return invitationCode;
}
publicvoidsetInvitationCode(String invitationCode) {
this.invitationCode = invitationCode;
}
public LocalDateTime getCreateTime() {
return createTime;
}
publicvoidsetCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getExpireTime() {
return expireTime;
}
publicvoidsetExpireTime(LocalDateTime expireTime) {
this.expireTime = expireTime;
}
}
6.4 DTO类
请求DTO
// RegisterRequest.java
package com.example.face2face.dto.request;
publicclassRegisterRequest {
private String username;
private String password;
private String nickname;
private String avatar;
// Getters and Setters
public String getUsername() {
return username;
}
publicvoidsetUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
publicvoidsetPassword(String password) {
this.password = password;
}
public String getNickname() {
return nickname;
}
publicvoidsetNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatar() {
return avatar;
}
publicvoidsetAvatar(String avatar) {
this.avatar = avatar;
}
}
// LoginRequest.java
package com.example.face2face.dto.request;
publicclassLoginRequest {
private String username;
private String password;
// Getters and Setters
public String getUsername() {
return username;
}
publicvoidsetUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
publicvoidsetPassword(String password) {
this.password = password;
}
}
// LocationRequest.java
package com.example.face2face.dto.request;
publicclassLocationRequest {
privatedouble latitude;
privatedouble longitude;
// Getters and Setters
publicdoublegetLatitude() {
return latitude;
}
publicvoidsetLatitude(double latitude) {
this.latitude = latitude;
}
publicdoublegetLongitude() {
return longitude;
}
publicvoidsetLongitude(double longitude) {
this.longitude = longitude;
}
}
// CreateGroupRequest.java
package com.example.face2face.dto.request;
publicclassCreateGroupRequest {
private String name;
private String description;
privatedouble latitude;
privatedouble longitude;
// Getters and Setters
public String getName() {
return name;
}
publicvoidsetName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
publicvoidsetDescription(String description) {
this.description = description;
}
publicdoublegetLatitude() {
return latitude;
}
publicvoidsetLatitude(double latitude) {
this.latitude = latitude;
}
publicdoublegetLongitude() {
return longitude;
}
publicvoidsetLongitude(double longitude) {
this.longitude = longitude;
}
}
// JoinGroupRequest.java
package com.example.face2face.dto.request;
publicclassJoinGroupRequest {
private String invitationCode;
// Getters and Setters
public String getInvitationCode() {
return invitationCode;
}
publicvoidsetInvitationCode(String invitationCode) {
this.invitationCode = invitationCode;
}
}
响应DTO
// ApiResponse.java
package com.example.face2face.dto.response;
publicclassApiResponse<T> {
privateint code;
private String message;
private T data;
publicApiResponse() {
}
publicApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
publicstatic <T> ApiResponse<T> success(T data) {
returnnewApiResponse<>(200, "Success", data);
}
publicstatic <T> ApiResponse<T> success(String message, T data) {
returnnewApiResponse<>(200, message, data);
}
publicstatic ApiResponse<Void> success() {
returnnewApiResponse<>(200, "Success", null);
}
publicstatic ApiResponse<String> error(int code, String message) {
returnnewApiResponse<>(code, message, null);
}
// Getters and Setters
publicintgetCode() {
return code;
}
publicvoidsetCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
publicvoidsetMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
publicvoidsetData(T data) {
this.data = data;
}
}
// JwtResponse.java
package com.example.face2face.dto.response;
publicclassJwtResponse {
private String token;
private String id;
private String username;
private String nickname;
publicJwtResponse() {
}
publicJwtResponse(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;
}
publicvoidsetToken(String token) {
this.token = token;
}
public String getId() {
return id;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
publicvoidsetUsername(String username) {
this.username = username;
}
public String getNickname() {
return nickname;
}
publicvoidsetNickname(String nickname) {
this.nickname = nickname;
}
}
// UserResponse.java
package com.example.face2face.dto.response;
publicclassUserResponse {
private String id;
private String username;
private String nickname;
private String avatar;
private Double distance;
publicUserResponse() {
}
publicUserResponse(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;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
publicvoidsetUsername(String username) {
this.username = username;
}
public String getNickname() {
return nickname;
}
publicvoidsetNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatar() {
return avatar;
}
publicvoidsetAvatar(String avatar) {
this.avatar = avatar;
}
public Double getDistance() {
return distance;
}
publicvoidsetDistance(Double distance) {
this.distance = distance;
}
}
// GroupResponse.java
package com.example.face2face.dto.response;
import java.time.LocalDateTime;
import java.util.List;
publicclassGroupResponse {
private String id;
private String name;
private String description;
private UserResponse creator;
privatedouble latitude;
privatedouble longitude;
private String invitationCode;
private LocalDateTime createTime;
private Double distance;
private List<UserResponse> members;
privateint memberCount;
publicGroupResponse() {
}
// Getters and Setters
public String getId() {
return id;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getName() {
return name;
}
publicvoidsetName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
publicvoidsetDescription(String description) {
this.description = description;
}
public UserResponse getCreator() {
return creator;
}
publicvoidsetCreator(UserResponse creator) {
this.creator = creator;
}
publicdoublegetLatitude() {
return latitude;
}
publicvoidsetLatitude(double latitude) {
this.latitude = latitude;
}
publicdoublegetLongitude() {
return longitude;
}
publicvoidsetLongitude(double longitude) {
this.longitude = longitude;
}
public String getInvitationCode() {
return invitationCode;
}
publicvoidsetInvitationCode(String invitationCode) {
this.invitationCode = invitationCode;
}
public LocalDateTime getCreateTime() {
return createTime;
}
publicvoidsetCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public Double getDistance() {
return distance;
}
publicvoidsetDistance(Double distance) {
this.distance = distance;
}
public List<UserResponse> getMembers() {
return members;
}
publicvoidsetMembers(List<UserResponse> members) {
this.members = members;
}
publicintgetMemberCount() {
return memberCount;
}
publicvoidsetMemberCount(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
publicclassJwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken(String username, String userId) {
Map<String, Object> claims = newHashMap<>();
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(newDate())
.setExpiration(newDate(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) {
finalClaimsclaims= getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
finalDateexpiration= getExpirationDateFromToken(token);
return expiration.before(newDate());
}
public Boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
returnfalse;
}
}
}
// 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
publicclassJwtAuthInterceptorimplementsHandlerInterceptor {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@Override
publicbooleanpreHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {
StringauthHeader= request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
Stringtoken= authHeader.substring(7);
if (jwtTokenUtil.validateToken(token)) {
StringuserId= jwtTokenUtil.getUserIdFromToken(token);
Useruser= userService.getUserById(userId);
if (user != null) {
request.setAttribute("currentUser", user);
returntrue;
}
}
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
returnfalse;
}
}
// 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
publicclassWebConfigimplementsWebMvcConfigurer {
@Autowired
private JwtAuthInterceptor jwtAuthInterceptor;
@Override
publicvoidaddInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**");
}
}
6.6 异常处理
实现全局异常处理:
// AppException.java
package com.example.face2face.exception;
publicclassAppExceptionextendsRuntimeException {
privateint code;
publicAppException(String message) {
super(message);
this.code = 500;
}
publicAppException(int code, String message) {
super(message);
this.code = code;
}
publicintgetCode() {
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
publicclassGlobalExceptionHandler {
@ExceptionHandler(AppException.class)
public ResponseEntity<ApiResponse<String>> handleAppException(AppException e) {
return ResponseEntity
.status(HttpStatus.valueOf(e.getCode() / 100))
.body(newApiResponse<>(e.getCode(), e.getMessage(), null));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<String>> handleException(Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(newApiResponse<>(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
publicclassUserService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private LocationService locationService;
@Autowired
private ObjectMapper objectMapper;
privatefinalBCryptPasswordEncoderpasswordEncoder=newBCryptPasswordEncoder();
public User registerUser(RegisterRequest request) {
// Check if username already exists
StringexistingUserId= stringRedisTemplate.opsForValue().get(RedisKeyUtil.getUsernameKey(request.getUsername()));
if (existingUserId != null) {
thrownewAppException(400, "Username already exists");
}
// Create new user
StringuserId= UUID.randomUUID().toString();
Useruser=newUser();
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 = newHashMap<>();
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()) {
returnnull;
}
return mapToUser(userMap);
}
public User getUserByUsername(String username) {
StringuserId= stringRedisTemplate.opsForValue().get(RedisKeyUtil.getUsernameKey(username));
if (userId == null) {
returnnull;
}
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 = newArrayList<>();
for (var result : results) {
StringuserId= result.getContent().getName();
Useruser= getUserById(userId);
if (user != null) {
UserResponseuserResponse= 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) {
returnnull;
}
UserResponseresponse=newUserResponse();
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) {
Useruser=newUser();
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
publicclassAuthService {
@Autowired
private UserService userService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
privatefinalBCryptPasswordEncoderpasswordEncoder=newBCryptPasswordEncoder();
public User register(RegisterRequest request) {
return userService.registerUser(request);
}
public JwtResponse login(LoginRequest request) {
Useruser= userService.getUserByUsername(request.getUsername());
if (user == null) {
thrownewAppException(401, "Invalid username or password");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
thrownewAppException(401, "Invalid username or password");
}
Stringtoken= jwtTokenUtil.generateToken(user.getUsername(), user.getId());
returnnewJwtResponse(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
publicclassLocationService {
@Autowired
private StringRedisTemplate redisTemplate;
publicvoidupdateLocation(String key, String id, double latitude, double longitude) {
redisTemplate.opsForGeo().add(key, newPoint(longitude, latitude), id);
}
public GeoResults<GeoLocation<String>> getNearbyLocations(String key, double latitude, double longitude, double radius) {
Circlecircle=newCircle(newPoint(longitude, latitude), newDistance(radius, Metrics.KILOMETERS));
RedisGeoCommands.GeoRadiusCommandArgsargs= RedisGeoCommands.GeoRadiusCommandArgs
.newGeoRadiusArgs()
.includeDistance()
.sortAscending();
return redisTemplate.opsForGeo().radius(key, circle, args);
}
public Double getDistance(String key, String id1, String id2) {
Distancedistance= redisTemplate.opsForGeo().distance(key, id1, id2, Metrics.KILOMETERS);
return distance != null ? distance.getValue() : null;
}
publicdoublecalculateDistance(double lat1, double lon1, double lat2, double lon2) {
// 使用Haversine公式计算两点间的距离(单位:米)
doubleearthRadius=6371000; // 地球半径,单位:米
doubledLat= Math.toRadians(lat2 - lat1);
doubledLon= Math.toRadians(lon2 - lon1);
doublea= 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);
doublec=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
publicclassGroupService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private LocationService locationService;
@Autowired
private UserService userService;
privatestaticfinallongGROUP_EXPIRY_HOURS=24;
public GroupResponse createGroup(CreateGroupRequest request, User creator) {
// Generate unique group ID and invitation code
StringgroupId= UUID.randomUUID().toString();
StringinvitationCode= generateInvitationCode();
// Create group
Groupgroup=newGroup();
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 = newHashMap<>();
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
GroupResponseresponse=newGroupResponse();
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()) {
returnnull;
}
return mapToGroup(groupMap);
}
public GroupResponse getGroupResponse(String groupId) {
Groupgroup= getGroupById(groupId);
if (group == null) {
returnnull;
}
GroupResponseresponse=newGroupResponse();
response.setId(group.getId());
response.setName(group.getName());
response.setDescription(group.getDescription());
Usercreator= 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 = newArrayList<>();
for (var result : results) {
StringgroupId= result.getContent().getName();
Groupgroup= getGroupById(groupId);
if (group != null && group.getExpireTime().isAfter(LocalDateTime.now())) {
GroupResponseresponse=newGroupResponse();
response.setId(group.getId());
response.setName(group.getName());
response.setDescription(group.getDescription());
Usercreator= 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
LongmemberCount= 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 -> {
GroupResponseresponse=newGroupResponse();
response.setId(group.getId());
response.setName(group.getName());
response.setDescription(group.getDescription());
Usercreator= userService.getUserById(group.getCreatorId());
response.setCreator(userService.convertToUserResponse(creator));
response.setLatitude(group.getLatitude());
response.setLongitude(group.getLongitude());
response.setCreateTime(group.getCreateTime());
// Get member count
LongmemberCount= stringRedisTemplate.opsForSet().size(RedisKeyUtil.getGroupMembersKey(group.getId()));
response.setMemberCount(memberCount != null ? memberCount.intValue() : 0);
return response;
})
.collect(Collectors.toList());
}
publicbooleanjoinGroup(String groupId, String userId) {
Groupgroup= getGroupById(groupId);
if (group == null || group.getExpireTime().isBefore(LocalDateTime.now())) {
returnfalse;
}
List<Point> userPoints = locationService.getUserLocation(RedisKeyUtil.USER_LOCATIONS,userId);
if (userPoints.isEmpty()) {
thrownewAppException(400,"User location not found");
}
doubledistance= locationService.calculateDistance(
userPoints.get(0).getY(),userPoints.get(0).getX(),
group.getLatitude(), group.getLongitude()
);
if (distance > distanceThresholdMeters) {
thrownewAppException(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);
returntrue;
}
publicbooleanjoinGroupByInvitationCode(String invitationCode, String userId) {
StringgroupId= stringRedisTemplate.opsForValue().get(RedisKeyUtil.getInvitationCodeKey(invitationCode));
if (groupId == null) {
returnfalse;
}
return joinGroup(groupId, userId);
}
publicbooleanisGroupMember(String groupId, String userId) {
return Boolean.TRUE.equals(
stringRedisTemplate.opsForSet().isMember(RedisKeyUtil.getGroupMembersKey(groupId), userId));
}
private String generateInvitationCode() {
Stringchars="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuildercode=newStringBuilder();
Randomrandom=newRandom();
for (inti=0; i < 6; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
private Group mapToGroup(Map<Object, Object> groupMap) {
Groupgroup=newGroup();
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")
publicclassAuthController {
@Autowired
private AuthService authService;
@PostMapping("/register")
public ApiResponse<User> register(@RequestBody RegisterRequest request) {
Useruser= authService.register(request);
return ApiResponse.success(user);
}
@PostMapping("/login")
public ApiResponse<JwtResponse> login(@RequestBody LoginRequest request) {
JwtResponseresponse= 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")
publicclassLocationController {
@Autowired
private LocationService locationService;
@Autowired
private UserService userService;
@PostMapping("/update")
public ApiResponse<Void> updateLocation(
@RequestBody LocationRequest request,
HttpServletRequest httpRequest) {
UsercurrentUser= (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) {
UsercurrentUser= (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")
publicclassGroupController {
@Autowired
private GroupService groupService;
@PostMapping("/create")
public ApiResponse<GroupResponse> createGroup(
@RequestBody CreateGroupRequest request,
HttpServletRequest httpRequest) {
UsercurrentUser= (User) httpRequest.getAttribute("currentUser");
GroupResponsegroup= groupService.createGroup(request, currentUser);
return ApiResponse.success(group);
}
@GetMapping("/{groupId}")
public ApiResponse<GroupResponse> getGroup(
@PathVariable String groupId,
HttpServletRequest httpRequest) {
UsercurrentUser= (User) httpRequest.getAttribute("currentUser");
// Check if user is a member of the group
if (!groupService.isGroupMember(groupId, currentUser.getId())) {
thrownewAppException(403, "You are not a member of this group");
}
GroupResponsegroup= groupService.getGroupResponse(groupId);
if (group == null) {
thrownewAppException(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) {
UsercurrentUser= (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) {
UsercurrentUser= (User) httpRequest.getAttribute("currentUser");
booleanjoined= groupService.joinGroup(groupId, currentUser.getId());
if (!joined) {
thrownewAppException(400, "Failed to join group");
}
return ApiResponse.success(true);
}
@PostMapping("/join-by-code")
public ApiResponse<Boolean> joinByCode(
@RequestBody JoinGroupRequest request,
HttpServletRequest httpRequest) {
UsercurrentUser= (User) httpRequest.getAttribute("currentUser");
booleanjoined= groupService.joinGroupByInvitationCode(
request.getInvitationCode(), currentUser.getId());
if (!joined) {
thrownewAppException(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
publicclassFace2FaceApplication {
publicstaticvoidmain(String[] args) {
SpringApplication.run(Face2FaceApplication.class, args);
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
returnnewBCryptPasswordEncoder();
}
}
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('');
constlogin = 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));
}
};
constgoToRegister = () => {
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;
// 检查登录状态
constcheckAuth = () => {
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}`;
};
// 更新位置
constupdateLocation = () => {
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('您的浏览器不支持地理位置功能');
}
};
// 获取附近群组
constgetNearbyGroups = () => {
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;
}
};
// 获取我的群组
constgetMyGroups = () => {
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);
});
};
// 创建群组
constcreateGroup = () => {
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('创建群组失败');
});
};
// 加入群组
constjoinGroup = (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('加入群组失败');
});
};
// 通过邀请码加入
constjoinByCode = () => {
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('邀请码无效');
});
};
// 打开创建群组弹窗
constopenCreateGroupModal = () => {
createGroupModal.show();
};
// 格式化时间
constformatTime = (timeStr) => {
if (!timeStr) return'';
const date = newDate(timeStr);
return date.toLocaleString();
};
// 格式化距离
constformatDistance = (distance) => {
if (distance == null) return'未知';
if (distance < 1000) {
returnMath.round(distance) + '米';
} else {
return (distance / 1000).toFixed(1) + '公里';
}
};
// 退出登录
constlogout = () => {
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
constgetGroupId = () => {
const urlParams = newURLSearchParams(window.location.search);
return urlParams.get('id');
};
// 计算属性
const isCreator = computed(() => {
return user.value && group.value.creator &&
user.value.id === group.value.creator.id;
});
// 方法
constcheckAuth = () => {
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();
};
constloadGroupInfo = () => {
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;
});
};
constformatTime = (timeStr) => {
if (!timeStr) return'';
const date = newDate(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 登录另一个账号输入邀请码加群


更多推荐
所有评论(0)