diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..79a8816 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +config.stopbubbling = true; diff --git a/timeline-file-service/src/main/java/com/timeline/file/controller/FileController.java b/timeline-file-service/src/main/java/com/timeline/file/controller/FileController.java index a4bce2e..263793d 100644 --- a/timeline-file-service/src/main/java/com/timeline/file/controller/FileController.java +++ b/timeline-file-service/src/main/java/com/timeline/file/controller/FileController.java @@ -3,6 +3,7 @@ package com.timeline.file.controller; import com.timeline.file.service.FileService; import com.timeline.file.vo.ImageInfoVo; import com.timeline.common.response.ResponseEntity; +import com.timeline.common.utils.UserContextUtils; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.utils.IOUtils; @@ -32,6 +33,19 @@ public class FileController { fileService.createBucketIfNotExist(); return "create default bucket success"; } + + @PostMapping("/bucket/{userId}") + public ResponseEntity createBucketForUser(@PathVariable String userId) throws Throwable { + fileService.createUserBucket(userId); + return ResponseEntity.success("bucket created for user " + userId); + } + + @PostMapping("/bucket/self") + public ResponseEntity createBucketForCurrentUser() throws Throwable { + String userId = UserContextUtils.getCurrentUserId(); + fileService.createUserBucket(userId); + return ResponseEntity.success("bucket created for current user"); + } @GetMapping("/get-upload-url/{fileName}") public ResponseEntity getUploadUrl(@PathVariable String fileName) throws Throwable { String uploadUrl = fileService.generateUploadUrl(fileName); @@ -77,7 +91,8 @@ public class FileController { fileService.saveFileMetadata(imageInfoVo); // 2. 关联图片和 StoryItem - fileService.associateImageWithStoryItem(imageInfoVo.getInstanceId(), storyItemId, "9999"); + String userId = UserContextUtils.getCurrentUserId(); + fileService.associateImageWithStoryItem(imageInfoVo.getInstanceId(), storyItemId, userId); return ResponseEntity.success("上传并绑定成功"); } @@ -105,9 +120,9 @@ public class FileController { return ResponseEntity.success("图片已从故事项中移除"); } @GetMapping("/image/list") - public ResponseEntity getImagesListByOwnerId(@SpringQueryMap ImageInfoVo imageInfoVo) throws Throwable { - imageInfoVo.setOwnerId("9999"); - Map images = fileService.getImagesListByOwnerId(imageInfoVo); + public ResponseEntity> getImagesListByOwnerId(@SpringQueryMap ImageInfoVo imageInfoVo) throws Throwable { + imageInfoVo.setOwnerId(UserContextUtils.getCurrentUserId()); + Map images = fileService.getImagesListByOwnerId(imageInfoVo); return ResponseEntity.success(images); } @DeleteMapping("/image/{imageInstanceId}") diff --git a/timeline-file-service/src/main/java/com/timeline/file/service/FileService.java b/timeline-file-service/src/main/java/com/timeline/file/service/FileService.java index 0c2b883..c7baaaf 100644 --- a/timeline-file-service/src/main/java/com/timeline/file/service/FileService.java +++ b/timeline-file-service/src/main/java/com/timeline/file/service/FileService.java @@ -13,6 +13,7 @@ import java.util.Map; @Service public interface FileService { void createBucketIfNotExist() throws Throwable; + void createUserBucket(String userId) throws Throwable; String generateUploadUrl(String fileName) throws Throwable; String generateDownloadUrl(String fileName) throws Throwable; void saveFileMetadata(ImageInfoVo imageInfoVo); @@ -27,5 +28,5 @@ public interface FileService { InputStream fetchImageLowRes(String instanceId) throws Throwable; - Map getImagesListByOwnerId(ImageInfoVo imageInfoVo); + Map getImagesListByOwnerId(ImageInfoVo imageInfoVo); } diff --git a/timeline-file-service/src/main/java/com/timeline/file/service/impl/FileServiceImpl.java b/timeline-file-service/src/main/java/com/timeline/file/service/impl/FileServiceImpl.java index ace77dc..56fec7b 100644 --- a/timeline-file-service/src/main/java/com/timeline/file/service/impl/FileServiceImpl.java +++ b/timeline-file-service/src/main/java/com/timeline/file/service/impl/FileServiceImpl.java @@ -5,6 +5,7 @@ import com.timeline.common.exception.CustomException; import com.timeline.common.response.ResponseEnum; import com.timeline.common.utils.CommonUtils; import com.timeline.common.utils.PageUtils; +import com.timeline.common.utils.UserContextUtils; import com.timeline.file.config.MinioConfig; import com.timeline.file.dao.CommonRelationMapper; import com.timeline.file.dao.FileHashMapper; @@ -41,12 +42,23 @@ public class FileServiceImpl implements FileService { private CommonRelationMapper commonRelationMapper; @Autowired private FileHashMapper fileHashMapper; - @Autowired public FileServiceImpl(MinioClient minioClient, MinioConfig minioConfig) { this.minioClient = minioClient; this.minioConfig = minioConfig; } + private String currentUserId() { + String userId = UserContextUtils.getCurrentUserId(); + if (userId == null || userId.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return userId; + } + + private String userBucket(String userId) { + return (minioConfig.getBucketName() + "-" + userId).toLowerCase(); + } + @Override public void createBucketIfNotExist() throws Throwable { try { @@ -61,22 +73,42 @@ public class FileServiceImpl implements FileService { } } + @Override + public void createUserBucket(String userId) throws Throwable { + String bucket = userBucket(userId); + try { + boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); + if (!found) { + log.info("bucket不存在,为用户{}创建bucket:{}", userId, bucket); + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); + } + } catch (MinioException e) { + log.error("MinIO 操作失败:", e); + throw new CustomException(500, "创建bucket失败"); + } + } + @Override public String generateUploadUrl(String fileName) throws Throwable { + String userId = currentUserId(); + String bucket = userBucket(userId); + createUserBucket(userId); return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(fileName).build() ); } @Override public String generateDownloadUrl(String fileName) throws Throwable { + String userId = currentUserId(); + String bucket = userBucket(userId); return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(fileName).build() ); } @@ -91,7 +123,7 @@ public class FileServiceImpl implements FileService { imageInfo.setContentType(imageInfoVo.getContentType()); imageInfo.setSize(imageInfoVo.getSize()); imageInfo.setUploadTime(LocalDateTime.now()); - imageInfo.setUserId("9999"); + imageInfo.setUserId(currentUserId()); imageInfoMapper.insert(imageInfo); } catch (Exception e) { log.error("保存图片元数据失败", e); @@ -114,15 +146,16 @@ public class FileServiceImpl implements FileService { } else { // 不存在其他image_info使用则删除 MinIO 中的对象 - log.info("删除 MinIO 中的对象:{}", imageInfo.getObjectKey()); + String bucket = userBucket(imageInfo.getUserId()); + log.info("删除 MinIO 中的对象:{} from bucket {}", imageInfo.getObjectKey(), bucket); minioClient.removeObject(RemoveObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(imageInfo.getObjectKey()) .build()); // 删除低分辨率图像 log.info("删除 MinIO 中的低分辨率对象:{}", CommonConstants.LOW_RESOLUTION_PREFIX + imageInfo.getObjectKey()); minioClient.removeObject(RemoveObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(CommonConstants.LOW_RESOLUTION_PREFIX + imageInfo.getObjectKey()) .build()); } @@ -168,8 +201,18 @@ public class FileServiceImpl implements FileService { ArrayList urls = new ArrayList<>(); for (String imageInstanceId : imageIds) { - String objectKey = imageInfoMapper.selectObjectKeyById(imageInstanceId); - String url = this.generateDownloadUrl(objectKey); + ImageInfo info = imageInfoMapper.selectByInstanceId(imageInstanceId); + if (info == null) { + continue; + } + String bucket = userBucket(info.getUserId()); + String url = minioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(bucket) + .object(info.getObjectKey()) + .build() + ); urls.add(url); } return urls; @@ -183,6 +226,9 @@ public class FileServiceImpl implements FileService { String lowResolutionObjectKey = CommonConstants.LOW_RESOLUTION_PREFIX + hash + suffix; log.info("上传图片的ObjectKey值为:{}", objectKey); List hashByFileHash = fileHashMapper.getFileHashByFileHash(hash); + String userId = currentUserId(); + String bucket = userBucket(userId); + createUserBucket(userId); // 2. 保存元数据到 MySQL ImageInfo imageInfo = new ImageInfo(); imageInfo.setInstanceId(IdUtils.randomUuidUpper()); @@ -190,14 +236,14 @@ public class FileServiceImpl implements FileService { imageInfo.setImageName(image.getOriginalFilename()); imageInfo.setContentType(image.getContentType()); imageInfo.setSize(image.getSize()); - imageInfo.setUserId("9999"); + imageInfo.setUserId(userId); imageInfo.setUploadTime(LocalDateTime.now()); if (hashByFileHash != null && !hashByFileHash.isEmpty()) { log.info("当前文件已存在,不进行minio文件上传"); } else { // 1. 上传到 MinIO minioClient.putObject(PutObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(objectKey) .stream(image.getInputStream(), image.getSize(), -1) .contentType(image.getContentType()) @@ -212,7 +258,7 @@ public class FileServiceImpl implements FileService { ByteArrayInputStream lowResInputStream = new ByteArrayInputStream(lowResOutputStream.toByteArray()); minioClient.putObject(PutObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(lowResolutionObjectKey) .stream(lowResInputStream, lowResInputStream.available(), -1) .contentType(image.getContentType()) @@ -236,8 +282,10 @@ public class FileServiceImpl implements FileService { if (objectKey == null) { throw new CustomException(ResponseEnum.NOT_FOUND_ERROR); } + ImageInfo imageInfo = imageInfoMapper.selectByInstanceId(instanceId); + String bucket = userBucket(imageInfo.getUserId()); return minioClient.getObject(GetObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(objectKey) .build()); } @@ -249,26 +297,29 @@ public class FileServiceImpl implements FileService { throw new CustomException(ResponseEnum.NOT_FOUND_ERROR); } String lowResObjectKey = CommonConstants.LOW_RESOLUTION_PREFIX + objectKey; + ImageInfo imageInfo = imageInfoMapper.selectByInstanceId(instanceId); + String bucket = userBucket(imageInfo.getUserId()); // 优先返回低分辨率版本,如果不存在则返回原图 - if (doesObjectExist(lowResObjectKey)) { + if (doesObjectExist(bucket, lowResObjectKey)) { return minioClient.getObject(GetObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(lowResObjectKey) .build()); } else { log.warn("低分辨率版本不存在,返回原图: {}", objectKey); return minioClient.getObject(GetObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(objectKey) .build()); } } @Override - public Map getImagesListByOwnerId(ImageInfoVo imageInfoVo) { + public Map getImagesListByOwnerId(ImageInfoVo imageInfoVo) { HashMap map = new HashMap<>(); map.put("ownerId", imageInfoVo.getOwnerId()); - Map resultMap = PageUtils.pageQuery(imageInfoVo.getCurrent(), imageInfoVo.getPageSize(), ImageInfoMapper.class, "selectListByOwnerId", + @SuppressWarnings("unchecked") + Map resultMap = (Map) PageUtils.pageQuery(imageInfoVo.getCurrent(), imageInfoVo.getPageSize(), ImageInfoMapper.class, "selectListByOwnerId", map, "list"); return resultMap; } @@ -278,10 +329,10 @@ public class FileServiceImpl implements FileService { * @param objectKey 对象键 * @return true表示存在,false表示不存在 */ - private boolean doesObjectExist(String objectKey) { + private boolean doesObjectExist(String bucket, String objectKey) { try { minioClient.statObject(StatObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucket) .object(objectKey) .build()); return true; diff --git a/timeline-file-service/src/main/resources/application.properties b/timeline-file-service/src/main/resources/application.properties index 2633cae..4b4f4f0 100644 --- a/timeline-file-service/src/main/resources/application.properties +++ b/timeline-file-service/src/main/resources/application.properties @@ -1,12 +1,12 @@ spring.application.name=timeline.file -spring.datasource.url=jdbc:mysql://8.137.148.196:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true +spring.datasource.url=jdbc:mysql://59.80.22.43:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true spring.datasource.username=root -spring.datasource.password=your_password +spring.datasource.password=WoCloud@9ol7uj spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # MinIO ?? -minio.endpoint=http://8.137.148.196:9000 -minio.accessKey=dasdqq22211AAdsda2 -minio.secretKey=2123sda2AADDsa4 +minio.endpoint=http://59.80.22.43:9000 +minio.accessKey=9ttSGjvQxek2uKKlhpqI +minio.secretKey=12CaKew53tu94tgyDLoqAwAq32iDuz3SWW0O1hex minio.bucketName=timeline-test # MyBatis ?? @@ -20,3 +20,11 @@ server.port=30002 spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB + +# Ensure UTF-8 encoding for logs and web layer to avoid garbled Chinese output +logging.charset.console=UTF-8 +logging.charset.file=UTF-8 +server.tomcat.uri-encoding=UTF-8 +server.servlet.encoding.charset=UTF-8 +server.servlet.encoding.enabled=true +server.servlet.encoding.force=true \ No newline at end of file diff --git a/timeline-gateway-service/src/main/java/com/timeline/gateway/TimelineGatewayApplication.java b/timeline-gateway-service/src/main/java/com/timeline/gateway/TimelineGatewayApplication.java index d5170bc..6260e33 100644 --- a/timeline-gateway-service/src/main/java/com/timeline/gateway/TimelineGatewayApplication.java +++ b/timeline-gateway-service/src/main/java/com/timeline/gateway/TimelineGatewayApplication.java @@ -3,9 +3,12 @@ package com.timeline.gateway; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.context.annotation.ComponentScan; -@SpringBootApplication +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +@ComponentScan(basePackages = {"com.timeline"}) @EnableDiscoveryClient public class TimelineGatewayApplication { public static void main(String[] args) { diff --git a/timeline-gateway-service/src/main/java/com/timeline/gateway/config/WebSocketConfig.java b/timeline-gateway-service/src/main/java/com/timeline/gateway/config/WebSocketConfig.java new file mode 100644 index 0000000..4e6d401 --- /dev/null +++ b/timeline-gateway-service/src/main/java/com/timeline/gateway/config/WebSocketConfig.java @@ -0,0 +1,22 @@ +package com.timeline.gateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient; +import org.springframework.web.reactive.socket.client.WebSocketClient; +import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter; +import reactor.netty.http.client.HttpClient; + +@Configuration +public class WebSocketConfig { + + @Bean + public WebSocketHandlerAdapter webSocketHandlerAdapter() { + return new WebSocketHandlerAdapter(); + } + + @Bean + public WebSocketClient webSocketClient() { + return new ReactorNettyWebSocketClient(HttpClient.create()); + } +} \ No newline at end of file diff --git a/timeline-gateway-service/src/main/java/com/timeline/gateway/filter/AuthenticationFilter.java b/timeline-gateway-service/src/main/java/com/timeline/gateway/filter/AuthenticationFilter.java index 65f0437..b20d555 100644 --- a/timeline-gateway-service/src/main/java/com/timeline/gateway/filter/AuthenticationFilter.java +++ b/timeline-gateway-service/src/main/java/com/timeline/gateway/filter/AuthenticationFilter.java @@ -1,22 +1,28 @@ package com.timeline.gateway.filter; + import com.timeline.common.response.ResponseEntity; import com.timeline.common.response.ResponseEnum; -import com.timeline.gateway.util.JwtUtils; +import com.timeline.gateway.utils.JwtUtils; +import com.timeline.common.utils.RedisUtils; import io.jsonwebtoken.Claims; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; -import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; @Component @Slf4j @@ -25,6 +31,20 @@ public class AuthenticationFilter implements GlobalFilter, Ordered { @Autowired private JwtUtils jwtUtils; + @Autowired + private RedisUtils redisUtils; + + private static final String TOKEN_BLACKLIST_PREFIX = "auth:token:blacklist:"; + + // 需要跳过认证的路径列表 + private static final List WHITELIST_PATHS = Arrays.asList( + "/user/auth/login", + "/user/auth/register", + "/user/auth/refresh", + "/ping", + "/actuator" + ); + @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); @@ -35,12 +55,44 @@ public class AuthenticationFilter implements GlobalFilter, Ordered { return chain.filter(exchange); } - // 从请求头中获取token + // WebSocket 连接需要 token,但不强制要求(允许通过,由下游服务处理) + if (path.startsWith("/user/ws")) { + String token = extractToken(request); + if (token != null && !token.isEmpty()) { + // 如果有 token,验证并传递用户信息 + if (jwtUtils.validateToken(token) && !isBlacklisted(token)) { + try { + Claims claims = jwtUtils.getClaimsFromToken(token); + if (claims != null && !jwtUtils.isTokenExpired(claims)) { + String userId = jwtUtils.getUserIdFromClaims(claims); + String username = jwtUtils.getUsernameFromClaims(claims); + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", userId) + .header("X-Username", username) + .build(); + ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build(); + return chain.filter(mutatedExchange); + } + } catch (Exception e) { + // token 验证失败,继续传递(由下游服务处理) + } + } + } + // 即使没有 token 也允许通过,由下游服务决定是否接受连接 + return chain.filter(exchange); + } + + // 从请求头或查询参数中获取token String token = extractToken(request); if (token == null || token.isEmpty()) { return handleUnauthorized(exchange, ResponseEnum.UNAUTHORIZED.getMessage()); } + + // 黑名单拦截 + if (isBlacklisted(token)) { + return handleUnauthorized(exchange, "Token已注销"); + } // 验证token if (!jwtUtils.validateToken(token)) { @@ -73,31 +125,44 @@ public class AuthenticationFilter implements GlobalFilter, Ordered { private boolean isWhitelisted(String path) { // 白名单路径不需要认证 - return path.startsWith("/auth/login") || - path.startsWith("/auth/register") || - path.startsWith("/ping") || - path.startsWith("/actuator"); + return WHITELIST_PATHS.stream().anyMatch(path::startsWith); } private String extractToken(ServerHttpRequest request) { + // 首先尝试从请求头中获取token String bearerToken = request.getHeaders().getFirst("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } + + // 如果请求头中没有token,则尝试从查询参数中获取 + URI uri = request.getURI(); + String query = uri.getRawQuery(); + if (query != null) { + String[] params = query.split("&"); + for (String param : params) { + if (param.startsWith("Authorization=")) { + return param.substring(21); + } + } + } + return null; } + + private boolean isBlacklisted(String token) { + Boolean exists = redisUtils.hasKey(TOKEN_BLACKLIST_PREFIX + token); + return Boolean.TRUE.equals(exists); + } + @SuppressWarnings("null") private Mono handleUnauthorized(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); - response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - ResponseEntity responseEntity = ResponseEntity.error(ResponseEnum.UNAUTHORIZED.getCode(), message); - String responseBody = com.alibaba.fastjson.JSON.toJSONString(responseEntity); - - byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); - DataBuffer buffer = response.bufferFactory().wrap(bytes); - - return response.writeWith(Mono.just(buffer)); + String body = ResponseEntity.error(ResponseEnum.UNAUTHORIZED, message).toString(); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))); } -} +} \ No newline at end of file diff --git a/timeline-gateway-service/src/main/java/com/timeline/gateway/utils/JwtUtils.java b/timeline-gateway-service/src/main/java/com/timeline/gateway/utils/JwtUtils.java index aa56910..8f5e11d 100644 --- a/timeline-gateway-service/src/main/java/com/timeline/gateway/utils/JwtUtils.java +++ b/timeline-gateway-service/src/main/java/com/timeline/gateway/utils/JwtUtils.java @@ -1,21 +1,29 @@ // JwtUtils.java -package com.timeline.gateway.util; +package com.timeline.gateway.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; +import java.util.Date; + @Component public class JwtUtils { @Value("${jwt.secret:timelineSecretKey}") private String secret; + private byte[] secretBytes() { + return secret.getBytes(StandardCharsets.UTF_8); + } + public Claims getClaimsFromToken(String token) { try { return Jwts.parserBuilder() - .setSigningKey(secret) + .setSigningKey(Keys.hmacShaKeyFor(secretBytes())) .build() .parseClaimsJws(token) .getBody(); @@ -26,7 +34,10 @@ public class JwtUtils { public boolean validateToken(String token) { try { - Jwts.parserBuilder().setSigningKey(secret).build().parseClaimsJws(token); + Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secretBytes())) + .build() + .parseClaimsJws(token); return true; } catch (Exception e) { return false; @@ -37,7 +48,7 @@ public class JwtUtils { if (claims == null) { return true; } - return claims.getExpiration().before(new java.util.Date()); + return claims.getExpiration().before(new Date()); } public String getUserIdFromClaims(Claims claims) { diff --git a/timeline-gateway-service/src/main/resources/application.properties b/timeline-gateway-service/src/main/resources/application.properties index e5ff041..87367c3 100644 --- a/timeline-gateway-service/src/main/resources/application.properties +++ b/timeline-gateway-service/src/main/resources/application.properties @@ -1,27 +1,48 @@ # application.properties spring.application.name=timeline-gateway - server.port=30000 -# ???? +spring.datasource.url=jdbc:mysql://59.80.22.43:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true +spring.datasource.username=root +spring.datasource.password=WoCloud@9ol7uj +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# 路由配置 spring.cloud.gateway.routes[0].id=story-service -spring.cloud.gateway.routes[0].uri=lb://timeline.story +spring.cloud.gateway.routes[0].uri=http://localhost:30001 spring.cloud.gateway.routes[0].predicates[0]=Path=/story/** -spring.cloud.gateway.routes[0].filters[0]=StripPrefix=1 +spring.cloud.gateway.routes[0].filters[0]=StripPrefix=0 spring.cloud.gateway.routes[1].id=file-service -spring.cloud.gateway.routes[1].uri=lb://timeline.file +spring.cloud.gateway.routes[1].uri=http://localhost:30002 spring.cloud.gateway.routes[1].predicates[0]=Path=/file/** -spring.cloud.gateway.routes[1].filters[0]=StripPrefix=1 +spring.cloud.gateway.routes[1].filters[0]=StripPrefix=0 -# JWT?? -jwt.secret=timelineSecretKey +spring.cloud.gateway.routes[2].id=user-service +spring.cloud.gateway.routes[2].uri=http://localhost:30003 +spring.cloud.gateway.routes[2].predicates[0]=Path=/user/** +spring.cloud.gateway.routes[2].filters[0]=StripPrefix=0 + +# 添加WebSocket路由配置 +spring.cloud.gateway.routes[3].id=user-service-ws +spring.cloud.gateway.routes[3].uri=http://localhost:30003 +spring.cloud.gateway.routes[3].predicates[0]=Path=/user/ws/** +spring.cloud.gateway.routes[3].filters[0]=StripPrefix=0 + +# JWT配置 +jwt.secret=6f3f9c2b9d9a4e3f8c0d6a7b5c4e3f1a6f3f9c2b9d9a4e3f8c0d6a7b5c4e3f1a jwt.expiration=86400 -# Actuator?? +# Redis +spring.data.redis.host=localhost +spring.data.redis.port=36379 +spring.data.redis.password=123456 +spring.data.redis.timeout=5000 + +# Actuator配置 management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always -# ???? +# 日志配置 logging.level.org.springframework.cloud.gateway=DEBUG -logging.level.com.timeline.gateway=DEBUG +logging.level.com.timeline.gateway=DEBUG \ No newline at end of file diff --git a/timeline-story-service/src/main/java/com/timeline/story/config/FeignConfig.java b/timeline-story-service/src/main/java/com/timeline/story/config/FeignConfig.java index 1530b8e..80d60b0 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/config/FeignConfig.java +++ b/timeline-story-service/src/main/java/com/timeline/story/config/FeignConfig.java @@ -2,8 +2,10 @@ package com.timeline.story.config; import feign.codec.Encoder; import feign.form.spring.SpringFormEncoder; -import org.springframework.boot.autoconfigure.http.HttpMessageConverters; -import org.springframework.cloud.openfeign.support.SpringEncoder; +import feign.RequestInterceptor; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,4 +16,22 @@ public class FeignConfig { public Encoder feignEncoder() { return new SpringFormEncoder(); } + + @Bean + public RequestInterceptor userHeaderInterceptor() { + return template -> { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletRequestAttributes) { + var req = servletRequestAttributes.getRequest(); + String userId = req.getHeader("X-User-Id"); + String username = req.getHeader("X-Username"); + if (userId != null) { + template.header("X-User-Id", userId); + } + if (username != null) { + template.header("X-Username", username); + } + } + }; + } } diff --git a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryActivityController.java b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryActivityController.java new file mode 100644 index 0000000..1bd9d32 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryActivityController.java @@ -0,0 +1,36 @@ +package com.timeline.story.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.story.dto.StoryItemActivityDto; +import com.timeline.story.entity.StoryActivity; +import com.timeline.story.service.StoryActivityService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/story/activity") +public class StoryActivityController { + + @Autowired + private StoryActivityService storyActivityService; + + @GetMapping("/my-and-friends") + public ResponseEntity> myAndFriends() { + return ResponseEntity.success(storyActivityService.listMyAndFriendsActivities()); + } + + /** + * 查询当前用户拥有权限的 story 中,storyItem 的创建/更新动态 + */ + @GetMapping("/authorized-items") + public ResponseEntity> authorizedItemUpdates() { + return ResponseEntity.success(storyActivityService.listAuthorizedItemUpdates()); + } +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryController.java b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryController.java index 9136f86..d94d9af 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryController.java +++ b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryController.java @@ -6,6 +6,7 @@ import com.timeline.story.service.StoryService; import com.timeline.story.vo.StoryVo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.openfeign.SpringQueryMap; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -59,4 +60,10 @@ public class StoryController { List stories = storyService.getStoriesByOwnerId(ownerId); return ResponseEntity.success(stories); } + @GetMapping("/list") + public ResponseEntity> getStories(@SpringQueryMap StoryVo storyVo) { + log.info("查询故事列表, 用户ID: {}", storyVo.getOwnerId()); + List stories = storyService.getStories(storyVo); + return ResponseEntity.success(stories); + } } diff --git a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryItemController.java b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryItemController.java index 8987b61..a43546f 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryItemController.java +++ b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryItemController.java @@ -4,8 +4,11 @@ import com.alibaba.fastjson.JSONObject; import com.timeline.common.response.ResponseEntity; import com.timeline.story.entity.StoryItem; import com.timeline.story.service.StoryItemService; +import com.timeline.story.service.StoryService; import com.timeline.story.vo.StoryItemAddVo; import com.timeline.story.vo.StoryItemVo; +import com.timeline.story.vo.StoryVo; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.openfeign.SpringQueryMap; @@ -31,12 +34,10 @@ public class StoryItemController { return ResponseEntity.success("StoryItem 创建成功"); } - @PutMapping("/{itemId}") - public ResponseEntity updateItem(@PathVariable String itemId, - @RequestParam String description, - @RequestParam String location) { - log.info("更新 StoryItem: {}", itemId); - storyItemService.updateItem(itemId, description, location); + @PutMapping("") + public ResponseEntity updateItem(@RequestParam("storyItem") String storyItemVoString, @RequestParam(value = "images", required = false) List images) { + log.info("更新 StoryItem: {}", storyItemVoString); + storyItemService.updateItem(JSONObject.parseObject(storyItemVoString, StoryItemAddVo.class), images); return ResponseEntity.success("StoryItem 更新成功"); } diff --git a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryPermissionController.java b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryPermissionController.java index 344d80b..01c7df4 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryPermissionController.java +++ b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryPermissionController.java @@ -9,6 +9,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.List; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + @RestController @RequestMapping("/story/permission") @@ -31,6 +34,12 @@ public class StoryPermissionController { storyPermissionService.updatePermission(permissionVo); return ResponseEntity.success("权限更新成功"); } + @PostMapping("/authorize") + public ResponseEntity authorizePermission(@RequestBody StoryPermissionVo permissionVo) { + log.info("授权权限: {}", permissionVo); + storyPermissionService.createPermission(permissionVo); + return ResponseEntity.success("权限授权成功"); + } @DeleteMapping("/{permissionId}") public ResponseEntity deletePermission(@PathVariable String permissionId) { diff --git a/timeline-story-service/src/main/java/com/timeline/story/controller/StoryShareController.java b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryShareController.java new file mode 100644 index 0000000..10b02ee --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/controller/StoryShareController.java @@ -0,0 +1,62 @@ +package com.timeline.story.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.story.dto.ShareStoryRequest; +import com.timeline.story.entity.Story; +import com.timeline.story.entity.StoryActivity; +import com.timeline.story.service.StoryActivityService; +import com.timeline.story.service.StoryPermissionService; +import com.timeline.story.service.StoryService; +import com.timeline.story.vo.StoryPermissionVo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/story/share") +public class StoryShareController { + + @Autowired + private StoryPermissionService storyPermissionService; + @Autowired + private StoryService storyService; + @Autowired + private StoryActivityService storyActivityService; + + @PostMapping("/{storyId}") + public ResponseEntity shareStory(@PathVariable String storyId, @RequestBody ShareStoryRequest req) { + StoryPermissionVo vo = new StoryPermissionVo(); + vo.setStoryInstanceId(storyId); + vo.setUserId(req.getFriendId()); + vo.setPermissionType(req.getPermissionType()); + storyPermissionService.createPermission(vo); + + StoryActivity activity = new StoryActivity(); + activity.setActorId(UserContextUtils.getCurrentUserId()); + activity.setAction("share_story"); + activity.setStoryInstanceId(storyId); + activity.setRemark("分享给 " + req.getFriendId()); + storyActivityService.logActivity(activity); + return ResponseEntity.success("已授权好友"); + } + + @GetMapping("/friends") + public ResponseEntity> friendStories() { + String uid = UserContextUtils.getCurrentUserId(); + var permissions = storyPermissionService.getPermissionsByUserId(uid); + List stories = new ArrayList<>(); + for (var p : permissions) { + try { + stories.add(storyService.getStoryByInstanceId(p.getStoryInstanceId())); + } catch (Exception ignored) { + } + } + return ResponseEntity.success(stories); + } +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/dao/StoryActivityMapper.java b/timeline-story-service/src/main/java/com/timeline/story/dao/StoryActivityMapper.java new file mode 100644 index 0000000..ee0fa49 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/dao/StoryActivityMapper.java @@ -0,0 +1,21 @@ +package com.timeline.story.dao; + +import com.timeline.story.entity.StoryActivity; +import com.timeline.story.dto.StoryItemActivityDto; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface StoryActivityMapper { + void insert(StoryActivity activity); + + List selectByActors(@Param("actorIds") List actorIds); + + /** + * 查询当前用户拥有权限的 story 中,与 storyItem 相关的最新动态,携带 storyItem 内容 + */ + List selectAuthorizedItemUpdates(@Param("userId") String userId); +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/dao/StoryMapper.java b/timeline-story-service/src/main/java/com/timeline/story/dao/StoryMapper.java index 1e83879..5be096d 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/dao/StoryMapper.java +++ b/timeline-story-service/src/main/java/com/timeline/story/dao/StoryMapper.java @@ -12,5 +12,5 @@ public interface StoryMapper { void deleteByInstanceId(String instanceId); Story selectByInstanceId(String instanceId); List selectByOwnerId(String ownerId); - + void touchUpdate(String instanceId, String updateId); } diff --git a/timeline-story-service/src/main/java/com/timeline/story/dto/ShareStoryRequest.java b/timeline-story-service/src/main/java/com/timeline/story/dto/ShareStoryRequest.java new file mode 100644 index 0000000..47c0b8d --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/dto/ShareStoryRequest.java @@ -0,0 +1,10 @@ +package com.timeline.story.dto; + +import lombok.Data; + +@Data +public class ShareStoryRequest { + private String friendId; + private Integer permissionType; // 1=读,2=写,3=管理 +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/dto/StoryItemActivityDto.java b/timeline-story-service/src/main/java/com/timeline/story/dto/StoryItemActivityDto.java new file mode 100644 index 0000000..6dfc214 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/dto/StoryItemActivityDto.java @@ -0,0 +1,31 @@ +package com.timeline.story.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 带有 storyItem 内容的动态信息,用于“好友动态 / 有权限的 storyItem 更新”接口 + */ +@Data +public class StoryItemActivityDto { + // activity 基本信息 + private Long id; + private String actorId; + private String actorName; + private String action; + private String storyInstanceId; + private String storyInstanceName; + private String storyItemId; + private String storyItemName; + private String remark; + private LocalDateTime activityTime; + + // storyItem 内容摘要 + private String itemTitle; + private String itemDescription; + private String itemLocation; + private LocalDateTime itemTime; +} + + diff --git a/timeline-story-service/src/main/java/com/timeline/story/entity/StoryActivity.java b/timeline-story-service/src/main/java/com/timeline/story/entity/StoryActivity.java new file mode 100644 index 0000000..2771e4b --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/entity/StoryActivity.java @@ -0,0 +1,17 @@ +package com.timeline.story.entity; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class StoryActivity { + private Long id; + private String actorId; + private String action; // create_story, update_story_item, share_story, etc. + private String storyInstanceId; + private String storyItemId; + private String remark; + private LocalDateTime createTime; +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/entity/StoryItem.java b/timeline-story-service/src/main/java/com/timeline/story/entity/StoryItem.java index 1229ca2..10e2791 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/entity/StoryItem.java +++ b/timeline-story-service/src/main/java/com/timeline/story/entity/StoryItem.java @@ -22,4 +22,5 @@ public class StoryItem { private Integer isDelete; private String coverInstanceId; private StoryItem[] subItems; + private String updateId; } diff --git a/timeline-story-service/src/main/java/com/timeline/story/feign/UserServiceClient.java b/timeline-story-service/src/main/java/com/timeline/story/feign/UserServiceClient.java new file mode 100644 index 0000000..c6e58f2 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/feign/UserServiceClient.java @@ -0,0 +1,19 @@ +package com.timeline.story.feign; + +import com.timeline.common.response.ResponseEntity; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; +import java.util.Map; + +@FeignClient(name = "timeline.user", url = "${user.service.url}") +public interface UserServiceClient { + + @GetMapping("/user/friend/ids") + ResponseEntity> getFriendIds(); + @GetMapping("/{userId}") + ResponseEntity getUserByUserId(@PathVariable String userId); +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/StoryActivityService.java b/timeline-story-service/src/main/java/com/timeline/story/service/StoryActivityService.java new file mode 100644 index 0000000..bfc8ac0 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/service/StoryActivityService.java @@ -0,0 +1,18 @@ +package com.timeline.story.service; + +import com.timeline.story.entity.StoryActivity; +import com.timeline.story.dto.StoryItemActivityDto; + +import java.util.List; + +public interface StoryActivityService { + void logActivity(StoryActivity activity); + + List listMyAndFriendsActivities(); + + /** + * 查询当前用户拥有权限的 story 中,storyItem 的更新/创建动态 + */ + List listAuthorizedItemUpdates(); +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/StoryItemService.java b/timeline-story-service/src/main/java/com/timeline/story/service/StoryItemService.java index 51c6463..aed0646 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/service/StoryItemService.java +++ b/timeline-story-service/src/main/java/com/timeline/story/service/StoryItemService.java @@ -12,7 +12,7 @@ import java.util.Map; public interface StoryItemService { void createStoryItem(StoryItemAddVo storyItemVo, List images); StoryItemWithCoverVo getStoryItemWithCover(String itemId); - void updateItem(String itemId, String description, String location); + void updateItem(StoryItemAddVo storyItemVo, List images); void deleteItem(String itemId); StoryItem getItemById(String itemId); Map getItemsByMasterItem(StoryItemVo storyItemVo); diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/StoryService.java b/timeline-story-service/src/main/java/com/timeline/story/service/StoryService.java index 23ceb2a..82b189f 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/service/StoryService.java +++ b/timeline-story-service/src/main/java/com/timeline/story/service/StoryService.java @@ -11,5 +11,6 @@ public interface StoryService { void deleteStory(String storyId); Story getStoryByInstanceId(String storyId); List getStoriesByOwnerId(String ownerId); + List getStories(StoryVo storyVo); } diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryActivityServiceImpl.java b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryActivityServiceImpl.java new file mode 100644 index 0000000..81e7376 --- /dev/null +++ b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryActivityServiceImpl.java @@ -0,0 +1,64 @@ +package com.timeline.story.service.impl; + +import com.timeline.common.exception.CustomException; +import com.timeline.common.response.ResponseEnum; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.story.dao.StoryActivityMapper; +import com.timeline.story.dto.StoryItemActivityDto; +import com.timeline.story.entity.StoryActivity; +import com.timeline.story.feign.UserServiceClient; +import com.timeline.story.service.StoryActivityService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class StoryActivityServiceImpl implements StoryActivityService { + + @Autowired + private StoryActivityMapper storyActivityMapper; + @Autowired + private UserServiceClient userServiceClient; + + private String currentUser() { + String uid = UserContextUtils.getCurrentUserId(); + if (uid == null || uid.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return uid; + } + + @Override + public void logActivity(StoryActivity activity) { + activity.setCreateTime(LocalDateTime.now()); + storyActivityMapper.insert(activity); + } + + @Override + public List listMyAndFriendsActivities() { + String uid = currentUser(); + List actorIds = new ArrayList<>(); + actorIds.add(uid); + try { + var resp = userServiceClient.getFriendIds(); + if (resp != null && resp.getData() != null) { + actorIds.addAll(resp.getData()); + } + } catch (Exception e) { + log.warn("获取好友列表失败,按仅自己查询", e); + } + return storyActivityMapper.selectByActors(actorIds); + } + + @Override + public List listAuthorizedItemUpdates() { + String uid = currentUser(); + return storyActivityMapper.selectAuthorizedItemUpdates(uid); + } +} + diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryItemServiceImpl.java b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryItemServiceImpl.java index 0c7a18f..560d585 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryItemServiceImpl.java +++ b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryItemServiceImpl.java @@ -2,15 +2,19 @@ package com.timeline.story.service.impl; import com.timeline.common.constants.CommonConstants; import com.timeline.common.utils.PageUtils; +import com.timeline.common.utils.UserContextUtils; import com.timeline.story.dao.CommonRelationMapper; import com.timeline.common.dto.CommonRelationDTO; import com.timeline.common.exception.CustomException; import com.timeline.common.response.ResponseEntity; import com.timeline.common.response.ResponseEnum; import com.timeline.story.dao.StoryItemMapper; +import com.timeline.story.dao.StoryMapper; import com.timeline.story.entity.StoryItem; +import com.timeline.story.entity.StoryActivity; import com.timeline.story.feign.FileServiceClient; import com.timeline.story.service.StoryItemService; +import com.timeline.story.service.StoryActivityService; import com.timeline.story.vo.StoryItemAddVo; import com.timeline.story.vo.StoryItemVo; import com.timeline.story.vo.StoryItemWithCoverVo; @@ -33,14 +37,33 @@ public class StoryItemServiceImpl implements StoryItemService { @Autowired private StoryItemMapper storyItemMapper; @Autowired + private StoryMapper storyMapper; + @Autowired private FileServiceClient fileServiceClient; @Autowired private CommonRelationMapper commonRelationMapper; + @Autowired + private StoryActivityService storyActivityService; + private String currentUserId() { + String userId = UserContextUtils.getCurrentUserId(); + if (userId == null || userId.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户未登录"); + } + return userId; + } + private String currentUsername() { + String username = UserContextUtils.getCurrentUsername(); + if (username == null || username.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户未登录"); + } + return username; + } @Override public void createStoryItem(StoryItemAddVo storyItemVo, List images) { try { + String currentUserId = currentUserId(); // 2. 创建 StoryItem 实体 StoryItem item = new StoryItem(); item.setInstanceId(IdUtils.randomUuidUpper()); @@ -49,10 +72,20 @@ public class StoryItemServiceImpl implements StoryItemService { item.setTitle(storyItemVo.getTitle()); item.setDescription(storyItemVo.getDescription()); item.setLocation(storyItemVo.getLocation()); - item.setCreateId("createId"); + item.setCreateId(currentUserId); + item.setUpdateId(currentUserId); item.setStoryItemTime(storyItemVo.getStoryItemTime()); item.setIsDelete(CommonConstants.NOT_DELETED); storyItemMapper.insert(item); + storyMapper.touchUpdate(storyItemVo.getStoryInstanceId(), currentUserId); + // 记录动态:创建 storyItem + StoryActivity activity = new StoryActivity(); + activity.setActorId(currentUserId); + activity.setAction("create_story_item"); + activity.setStoryInstanceId(storyItemVo.getStoryInstanceId()); + activity.setStoryItemId(item.getInstanceId()); + activity.setRemark("创建故事条目"); + storyActivityService.logActivity(activity); if (storyItemVo.getRelatedImageInstanceIds() != null && !storyItemVo.getRelatedImageInstanceIds().isEmpty()) { for (String imageInstanceId : storyItemVo.getRelatedImageInstanceIds()) { log.info("关联现有图像 {} - {}", imageInstanceId, item.getInstanceId()); @@ -92,16 +125,48 @@ public class StoryItemServiceImpl implements StoryItemService { } @Override - public void updateItem(String itemId, String description, String location) { + public void updateItem(StoryItemAddVo storyItemVo, List images) { try { - StoryItem item = storyItemMapper.selectById(itemId); + String currentUserId = UserContextUtils.getCurrentUserId(); + StoryItem item = storyItemMapper.selectById(storyItemVo.getInstanceId()); if (item == null) { throw new RuntimeException("StoryItem 不存在"); } - item.setDescription(description); - item.setLocation(location); + item.setDescription(storyItemVo.getDescription()); + item.setLocation(storyItemVo.getLocation()); + item.setStoryItemTime(storyItemVo.getStoryItemTime()); + item.setTitle(storyItemVo.getTitle()); item.setUpdateTime(LocalDateTime.now()); + item.setUpdateId(currentUserId); storyItemMapper.update(item); + storyMapper.touchUpdate(item.getStoryInstanceId(), currentUserId); + // 记录动态:更新 storyItem + StoryActivity activity = new StoryActivity(); + activity.setActorId(currentUserId); + activity.setAction("update_story_item"); + activity.setStoryInstanceId(item.getStoryInstanceId()); + activity.setStoryItemId(storyItemVo.getInstanceId()); + activity.setRemark("更新故事条目"); + storyActivityService.logActivity(activity); + if (storyItemVo.getRelatedImageInstanceIds() != null && !storyItemVo.getRelatedImageInstanceIds().isEmpty()) { + // 删除所有关联图像 + commonRelationMapper.deleteRelationByRelaId(item.getInstanceId()); + for (String imageInstanceId : storyItemVo.getRelatedImageInstanceIds()) { + log.info("关联现有图像 {} - {}", imageInstanceId, item.getInstanceId()); + // 3. 建立 StoryItem 与图像关系 + buildStoryItemImageRelation(item.getInstanceId(), imageInstanceId); + } + } + if (images != null) { + log.info("上传 StoryItem 关联图像"); + for (MultipartFile image : images) { + ResponseEntity response = fileServiceClient.uploadImage(image); + String key = response.getData(); + log.info("上传成功,文件instanceId:{}", key); + // 4. 建立图像与StoryItem 关系 + buildStoryItemImageRelation(item.getInstanceId(), key); + } + } } catch (Exception e) { log.error("更新 StoryItem 失败", e); throw new RuntimeException("更新 StoryItem 失败"); @@ -111,12 +176,23 @@ public class StoryItemServiceImpl implements StoryItemService { @Override public void deleteItem(String itemId) { try { + String currentUserId = UserContextUtils.getCurrentUserId(); StoryItem item = storyItemMapper.selectById(itemId); if (item == null) { throw new RuntimeException("StoryItem 不存在"); } storyItemMapper.deleteByItemId(itemId); commonRelationMapper.deleteRelationByRelaId(itemId); + storyMapper.touchUpdate(item.getStoryInstanceId(), currentUserId); + // 记录动态:删除 storyItem + StoryActivity activity = new StoryActivity(); + activity.setActorId(currentUserId); + activity.setAction("delete_story_item"); + activity.setStoryInstanceId(item.getStoryInstanceId()); + activity.setStoryItemId(itemId); + activity.setRemark("删除故事条目"); + storyActivityService.logActivity(activity); + } catch (Exception e) { log.error("删除 StoryItem 失败", e); throw new RuntimeException("删除 StoryItem 失败"); diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryPermissionServiceImpl.java b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryPermissionServiceImpl.java index 7a7402a..53ef01c 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryPermissionServiceImpl.java +++ b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryPermissionServiceImpl.java @@ -2,10 +2,13 @@ package com.timeline.story.service.impl; import com.timeline.common.constants.CommonConstants; import com.timeline.common.exception.CustomException; +import com.timeline.common.response.ResponseEntity; import com.timeline.common.response.ResponseEnum; import com.timeline.common.utils.IdUtils; +import com.timeline.common.utils.UserContextUtils; import com.timeline.story.dao.StoryPermissionMapper; import com.timeline.story.entity.StoryPermission; +import com.timeline.story.feign.UserServiceClient; import com.timeline.story.service.StoryPermissionService; import com.timeline.story.vo.StoryPermissionVo; import lombok.extern.slf4j.Slf4j; @@ -15,6 +18,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -22,10 +26,40 @@ public class StoryPermissionServiceImpl implements StoryPermissionService { @Autowired private StoryPermissionMapper storyPermissionMapper; + @Autowired + private UserServiceClient userServiceClient; + private String getCurrentUserId() { + String uid = UserContextUtils.getCurrentUserId(); + if (uid == null) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return uid; + } @Override public void createPermission(StoryPermissionVo permissionVo) { try { + String currentUserId = getCurrentUserId(); + if (currentUserId.equals(permissionVo.getUserId()) && permissionVo.getPermissionType() != 1) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "不能授权给自己"); + } + StoryPermission selectByStoryAndUser = storyPermissionMapper.selectByStoryAndUser(permissionVo.getStoryInstanceId(), permissionVo.getUserId());; + if (selectByStoryAndUser != null) { + log.info("用户已有该故事权限,更新当前权限为:{}", permissionVo.getPermissionType()); + selectByStoryAndUser.setPermissionType(permissionVo.getPermissionType());; + selectByStoryAndUser.setUpdateTime(LocalDateTime.now()); + storyPermissionMapper.update(selectByStoryAndUser); + return; + } + // 远程调用user服务,判断用户是否存在, feign 调用 + ResponseEntity response = userServiceClient.getUserByUserId(permissionVo.getUserId()); + log.info("响应结果: {}", response.toString()); + if (response.getCode() != 200) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "远程调用失败"); + } else if (response.getData() == null) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "用户不存在"); + } + log.info("新建故事{} 授权 {} 给 {}", permissionVo.getStoryInstanceId(), permissionVo.getPermissionType(), permissionVo.getUserId()); StoryPermission permission = new StoryPermission(); BeanUtils.copyProperties(permissionVo, permission); permission.setPermissionId(IdUtils.randomUuidUpper()); @@ -33,7 +67,11 @@ public class StoryPermissionServiceImpl implements StoryPermissionService { permission.setUpdateTime(LocalDateTime.now()); permission.setIsDeleted(CommonConstants.NOT_DELETED); storyPermissionMapper.insert(permission); - } catch (Exception e) { + + } catch(CustomException e) { + throw e; + } + catch (Exception e) { log.error("创建权限失败", e); throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "创建权限失败"); } diff --git a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryServiceImpl.java b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryServiceImpl.java index 20f9ece..f9240a7 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryServiceImpl.java +++ b/timeline-story-service/src/main/java/com/timeline/story/service/impl/StoryServiceImpl.java @@ -1,14 +1,19 @@ package com.timeline.story.service.impl; +import com.timeline.common.constants.CommonConstants; import com.timeline.common.exception.CustomException; import com.timeline.common.response.ResponseEnum; import com.timeline.story.entity.Story; +import com.timeline.story.entity.StoryActivity; import com.timeline.story.dao.StoryMapper; import com.timeline.story.service.StoryPermissionService; import com.timeline.story.service.StoryService; +import com.timeline.story.service.StoryActivityService; import com.timeline.story.vo.StoryPermissionVo; import com.timeline.story.vo.StoryVo; import com.timeline.common.utils.IdUtils; +import com.timeline.common.utils.UserContextUtils; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -25,11 +30,19 @@ public class StoryServiceImpl implements StoryService { @Autowired private StoryPermissionService storyPermissionService; + + @Autowired + private StoryActivityService storyActivityService; @Override public void createStory(StoryVo storyVo) { try { + String currentUserId = UserContextUtils.getCurrentUserId(); + if (currentUserId == null || currentUserId.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } Story story = new Story(); - story.setOwnerId("test11"); + story.setOwnerId(currentUserId); + story.setUpdateId(currentUserId); story.setTitle(storyVo.getTitle()); story.setInstanceId(IdUtils.randomUuidUpper()); story.setDescription(storyVo.getDescription()); @@ -42,9 +55,16 @@ public class StoryServiceImpl implements StoryService { // 自动添加创建者权限 StoryPermissionVo permissionVo = new StoryPermissionVo(); permissionVo.setStoryInstanceId(story.getInstanceId()); - permissionVo.setUserId(storyVo.getOwnerId()); - permissionVo.setPermissionType(1); // 创建者权限 + permissionVo.setUserId(currentUserId); + permissionVo.setPermissionType(CommonConstants.STORY_PERMISSION_TYPE_OWNER); // 创建者权限 storyPermissionService.createPermission(permissionVo); + + StoryActivity activity = new StoryActivity(); + activity.setActorId(currentUserId); + activity.setAction("create_story"); + activity.setStoryInstanceId(story.getInstanceId()); + activity.setRemark("创建故事"); + storyActivityService.logActivity(activity); } catch (Exception e) { log.error("创建故事失败", e); throw new CustomException(500, "创建故事失败: " + e.toString()); @@ -65,12 +85,19 @@ public class StoryServiceImpl implements StoryService { story.setUpdateTime(LocalDateTime.now()); story.setLogo(storyVo.getLogo()); - // 如果传入了 updateId,则更新 updateId todo: 使用线程获取用户ID - if (storyVo.getOwnerId() != null && !storyVo.getOwnerId().isEmpty()) { - story.setUpdateId(storyVo.getOwnerId()); + String currentUserId = UserContextUtils.getCurrentUserId(); + if (currentUserId != null && !currentUserId.isEmpty()) { + story.setUpdateId(currentUserId); } storyMapper.update(story); + StoryActivity activity = new StoryActivity(); + activity.setActorId(currentUserId); + activity.setAction("update_story"); + activity.setStoryInstanceId(storyId); + activity.setRemark("更新故事"); + storyActivityService.logActivity(activity); + } @Override @@ -100,4 +127,18 @@ public class StoryServiceImpl implements StoryService { throw new CustomException(500, "查询用户故事列表失败"); } } + + @Override + public List getStories(StoryVo storyVo) { + try { + String currentUserId = UserContextUtils.getCurrentUserId(); + if (currentUserId == null || currentUserId.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return storyMapper.selectByOwnerId(currentUserId); + } catch (Exception e) { + log.error("查询用户故事列表失败", e); + throw new CustomException(500, "查询用户故事列表失败"); + } + } } diff --git a/timeline-story-service/src/main/java/com/timeline/story/vo/StoryItemVo.java b/timeline-story-service/src/main/java/com/timeline/story/vo/StoryItemVo.java index 57e218c..1a57a3d 100644 --- a/timeline-story-service/src/main/java/com/timeline/story/vo/StoryItemVo.java +++ b/timeline-story-service/src/main/java/com/timeline/story/vo/StoryItemVo.java @@ -14,5 +14,10 @@ public class StoryItemVo extends CommonVo { private String location; private LocalDateTime storyItemTime; private String storyInstanceId; - + private String createId; + private String createName; + private LocalDateTime createTime; + private String updateId; + private String updateName; + private LocalDateTime updateTime; } diff --git a/timeline-story-service/src/main/resources/application.properties b/timeline-story-service/src/main/resources/application.properties index 75f5b3b..d1271c4 100644 --- a/timeline-story-service/src/main/resources/application.properties +++ b/timeline-story-service/src/main/resources/application.properties @@ -1,25 +1,52 @@ spring.application.name=timeline.story -spring.datasource.url=jdbc:mysql://8.137.148.196:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true +spring.datasource.url=jdbc:mysql://59.80.22.43:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true spring.datasource.username=root -spring.datasource.password=your_password +spring.datasource.password=WoCloud@9ol7uj spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # MyBatis ?? -mybatis.mapper-locations=classpath:mapper/*.xml -mybatis.type-aliases-package=com.timeline.story.entity mybatis.configuration.log4j=true server.port=30001 spring.web.mvc.use-trailing-slash=true +mybatis.mapper-locations=classpath:mapper/*.xml +mybatis.type-aliases-package=com.timeline.user.entity mybatis.configuration.mapUnderscoreToCamelCase=true -# ?????????? -logging.level.org.mybatis.spring=DEBUG -logging.level.org.apache.ibatis=DEBUG +mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl +logging.level.com.timeline.user.dao=DEBUG +logging.level.org.mybatis=DEBUG # LocalDateTime ??????? spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=UTC file.service.url=http://localhost:30002/file/ +user.service.url=http://localhost:30003/user/ spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB \ No newline at end of file +spring.servlet.multipart.max-request-size=10MB + +spring.data.redis.host=127.0.0.1 +spring.data.redis.port=36379 +spring.data.redis.password=123456 +spring.data.redis.timeout=5000 + +# HikariCP tuning to avoid stale/closed MySQL connections +spring.datasource.hikari.max-lifetime=600000 +# 10 minutes, below MySQL wait_timeout +spring.datasource.hikari.idle-timeout=300000 +# 5 minutes, recycle idle connections +spring.datasource.hikari.validation-timeout=3000 +# fast validation timeout +spring.datasource.hikari.connection-timeout=30000 +# wait up to 30s for a connection +spring.datasource.hikari.connection-test-query=SELECT 1 +spring.datasource.hikari.test-on-borrow=true +spring.datasource.hikari.test-while-idle=true + +# Ensure UTF-8 encoding for logs and web layer to avoid garbled Chinese output +logging.charset.console=UTF-8 +logging.charset.file=UTF-8 +server.tomcat.uri-encoding=UTF-8 +server.servlet.encoding.charset=UTF-8 +server.servlet.encoding.enabled=true +server.servlet.encoding.force=true diff --git a/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryActivityMapper.xml b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryActivityMapper.xml new file mode 100644 index 0000000..a74493b --- /dev/null +++ b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryActivityMapper.xml @@ -0,0 +1,55 @@ + + + + + + + INSERT INTO story_activity (actor_id, action, story_instance_id, story_item_id, remark, create_time) + VALUES (#{actorId}, #{action}, #{storyInstanceId}, #{storyItemId}, #{remark}, #{createTime}) + + + + + + + + + diff --git a/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryItemMapper.xml b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryItemMapper.xml index 90f8533..fb9c5a2 100644 --- a/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryItemMapper.xml +++ b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryItemMapper.xml @@ -5,8 +5,8 @@ - INSERT INTO story_item (instance_id, master_item_id, description, location, title, create_id, story_instance_id, is_delete, story_item_time) - VALUES (#{instanceId}, #{masterItemId}, #{description}, #{location}, #{title},#{createId}, #{storyInstanceId}, #{isDelete}, #{storyItemTime}) + INSERT INTO story_item (instance_id, master_item_id, description, location, title, create_id, story_instance_id, is_delete, story_item_time, update_id) + VALUES (#{instanceId}, #{masterItemId}, #{description}, #{location}, #{title},#{createId}, #{storyInstanceId}, #{isDelete}, #{storyItemTime}, #{updateId}) @@ -14,7 +14,8 @@ SET description = #{description}, location = #{location}, create_id = #{createId}, - update_time = NOW() + update_time = NOW(), + update_id = #{updateId} WHERE instance_id = #{instanceId} @@ -35,9 +36,30 @@ - + SELECT + instance_id, + si.description, + si.location, + title, + story_instance_id, + si.story_item_time as story_item_time, + si.update_time, + si.create_id AS create_id, + si.create_time AS create_time, + u1.username AS create_name, + si.update_id AS update_id, + si.update_time AS update_time, + u2.username AS update_name + FROM + story_item si + LEFT JOIN user u1 ON si.create_id = u1.user_id + LEFT JOIN `user` u2 ON si.update_id = u2.user_id + WHERE + story_instance_id = #{storyInstanceId} + AND is_delete = 0 + ORDER BY + story_item_time DESC SELECT s.*, - u1.user_name as owner_name, - u2.user_name as update_name, + u1.username as owner_name, + u2.username as update_name, (SELECT COUNT(*) FROM story_item si WHERE si.story_instance_id = s.instance_id AND si.is_delete = 0) as item_count FROM story s @@ -39,15 +39,29 @@ + + UPDATE story + SET update_id = #{updateId}, + update_time = NOW() + WHERE instance_id = #{instanceId} + + diff --git a/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryPermissionMapper.xml b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryPermissionMapper.xml index 2b00acc..124a2e0 100644 --- a/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryPermissionMapper.xml +++ b/timeline-story-service/src/main/resources/com/timeline/story/dao/StoryPermissionMapper.xml @@ -7,8 +7,7 @@ INSERT INTO story_permission (permission_id, story_instance_id, user_id, permission_type) VALUES (#{permissionId}, #{storyInstanceId}, #{userId}, #{permissionType}) - - + UPDATE story_permission SET permission_type = #{permissionType}, diff --git a/timeline-user-service/pom.xml b/timeline-user-service/pom.xml index 033cddb..9f2d9fd 100644 --- a/timeline-user-service/pom.xml +++ b/timeline-user-service/pom.xml @@ -93,6 +93,12 @@ lombok true + + + + org.springframework.boot + spring-boot-starter-websocket + diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/SecurityConfig.java b/timeline-user-service/src/main/java/com/timeline/user/config/SecurityConfig.java index 469fc29..c73e4e7 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/config/SecurityConfig.java +++ b/timeline-user-service/src/main/java/com/timeline/user/config/SecurityConfig.java @@ -2,12 +2,22 @@ package com.timeline.user.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/StompPrincipalInterceptor.java b/timeline-user-service/src/main/java/com/timeline/user/config/StompPrincipalInterceptor.java new file mode 100644 index 0000000..38f0bcd --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/config/StompPrincipalInterceptor.java @@ -0,0 +1,61 @@ +package com.timeline.user.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.security.Principal; +import java.util.Map; + +/** + * STOMP 拦截器,用于在 STOMP CONNECT 时设置 Principal + */ +@Slf4j +@Component +public class StompPrincipalInterceptor implements ChannelInterceptor { + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + // 从session attributes中获取userId + Map sessionAttrs = accessor.getSessionAttributes(); + if (sessionAttrs != null) { + Object userId = sessionAttrs.get("userId"); + if (userId != null && accessor.getUser() == null) { + String userIdStr = userId.toString(); + log.info("STOMP CONNECT: 从session attributes设置Principal为userId: {}", userIdStr); + Principal principal = new XUserIdHandshakeInterceptor.UserPrincipal(userIdStr); + accessor.setUser(principal); + } + } + + // 如果还没有Principal,尝试从请求头获取 + if (accessor.getUser() == null) { + String userId = accessor.getFirstNativeHeader("X-User-Id"); + if (userId != null && !userId.isEmpty()) { + log.info("STOMP CONNECT: 从请求头设置Principal为userId: {}", userId); + Principal principal = new XUserIdHandshakeInterceptor.UserPrincipal(userId); + accessor.setUser(principal); + } + } + + // 打印调试信息 + Principal principal = accessor.getUser(); + if (principal != null) { + log.info("STOMP CONNECT: Principal已设置,用户: {}", principal.getName()); + } else { + log.warn("STOMP CONNECT: 未设置Principal,sessionAttributes: {}", sessionAttrs); + } + } + + return message; + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/UserIdPrincipalHandshakeHandler.java b/timeline-user-service/src/main/java/com/timeline/user/config/UserIdPrincipalHandshakeHandler.java new file mode 100644 index 0000000..89eab4a --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/config/UserIdPrincipalHandshakeHandler.java @@ -0,0 +1,41 @@ +package com.timeline.user.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +@Slf4j +public class UserIdPrincipalHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map attributes) { + // 首先尝试从attributes中获取userId(由XUserIdHandshakeInterceptor放入) + Object userId = attributes.get("userId"); + if (userId != null) { + String userIdStr = userId.toString(); + log.info("WebSocket握手:设置Principal为userId: {},attributes: {}", userIdStr, attributes.keySet()); + // 使用 UserPrincipal 类而不是 lambda,确保 Principal 对象正确 + return new XUserIdHandshakeInterceptor.UserPrincipal(userIdStr); + } + + // 如果没有userId,则尝试获取username + Object username = attributes.get("username"); + if (username != null) { + String usernameStr = username.toString(); + log.info("WebSocket握手:设置Principal为username: {}", usernameStr); + return new XUserIdHandshakeInterceptor.UserPrincipal(usernameStr); + } + + // 如果都没有,则使用默认实现 + log.warn("WebSocket握手:未找到userId或username,attributes: {},使用默认Principal", attributes.keySet()); + Principal defaultPrincipal = super.determineUser(request, wsHandler, attributes); + if (defaultPrincipal != null) { + log.info("WebSocket握手:使用默认Principal: {}", defaultPrincipal.getName()); + } + return defaultPrincipal; + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/WebMvcConfig.java b/timeline-user-service/src/main/java/com/timeline/user/config/WebMvcConfig.java index 6ee9461..7ecda1e 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/config/WebMvcConfig.java +++ b/timeline-user-service/src/main/java/com/timeline/user/config/WebMvcConfig.java @@ -1,21 +1,31 @@ package com.timeline.user.config; -import com.timeline.user.interceptor.UserContextInterceptor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { - - @Autowired - private UserContextInterceptor userContextInterceptor; - + @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(userContextInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns("/api/auth/login", "/api/auth/register"); + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 为SockJS添加静态资源映射,处理/info等端点 + registry.addResourceHandler("/user/ws/**") + .addResourceLocations("classpath:/static/"); + + // 添加对测试页面的静态资源映射 + registry.addResourceHandler("/test/**") + .addResourceLocations("classpath:/static/"); } -} + + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + // 配置内容协商策略,确保能正确处理JSON响应 + configurer.defaultContentType(MediaType.APPLICATION_JSON) + .favorParameter(false) + .favorPathExtension(false) + .ignoreAcceptHeader(false); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/WebSocketConfig.java b/timeline-user-service/src/main/java/com/timeline/user/config/WebSocketConfig.java new file mode 100644 index 0000000..ace4f9c --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/config/WebSocketConfig.java @@ -0,0 +1,51 @@ +package com.timeline.user.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Slf4j +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private StompPrincipalInterceptor stompPrincipalInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/user/ws") + .setAllowedOriginPatterns("*") + .addInterceptors(new XUserIdHandshakeInterceptor(applicationContext)) + .setHandshakeHandler(new UserIdPrincipalHandshakeHandler()) + .withSockJS(); + log.info("WebSocket 端点已注册: /user/ws"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 启用简单的消息代理,支持 /topic 和 /queue 前缀 + registry.enableSimpleBroker("/topic", "/queue"); + // 设置应用程序目标前缀 + registry.setApplicationDestinationPrefixes("/app"); + // 设置用户目标前缀,用于点对点消息 + registry.setUserDestinationPrefix("/user"); + log.info("WebSocket 消息代理已配置: /topic, /queue, /app, /user"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // 添加STOMP拦截器,用于在CONNECT时设置Principal + registration.interceptors(stompPrincipalInterceptor); + log.info("已配置客户端入站通道拦截器"); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/config/XUserIdHandshakeInterceptor.java b/timeline-user-service/src/main/java/com/timeline/user/config/XUserIdHandshakeInterceptor.java new file mode 100644 index 0000000..9122625 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/config/XUserIdHandshakeInterceptor.java @@ -0,0 +1,137 @@ +package com.timeline.user.config; + +import com.timeline.user.utils.JwtUtils; +import io.jsonwebtoken.Claims; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.lang.NonNull; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.security.Principal; +import java.util.Map; + +@Slf4j +public class XUserIdHandshakeInterceptor implements HandshakeInterceptor { + + // 从配置中获取JWT密钥 + @Value("${jwt.secret:timelineSecretKey}") + private String jwtSecret = "timelineSecretKey"; + + @SuppressWarnings("unused") + private ApplicationContext applicationContext; + + public XUserIdHandshakeInterceptor(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public boolean beforeHandshake(@NonNull ServerHttpRequest request, + @NonNull ServerHttpResponse response, + @NonNull WebSocketHandler wsHandler, + @NonNull Map attributes) { + if (request instanceof ServletServerHttpRequest servletRequest) { + HttpServletRequest httpServletRequest = servletRequest.getServletRequest(); + + // 首先尝试从X-User-Id请求头获取userId(来自网关转发) + String userId = httpServletRequest.getHeader("X-User-Id"); + String username = httpServletRequest.getHeader("X-Username"); + + // 如果没有从请求头获取到,则尝试从查询参数中获取token并解析 + if (userId == null || userId.isEmpty()) { + URI uri = request.getURI(); + String query = uri.getRawQuery(); + if (query != null) { + String[] params = query.split("&"); + String token = null; + for (String param : params) { + // 支持 token=xxx 格式 + if (param.startsWith("token=")) { + token = param.substring(6); + break; + } + // 支持 Authorization=Bearer xxx 或 Authorization=xxx 格式 + if (param.startsWith("Authorization=")) { + String authValue = param.substring(14); + if (authValue.startsWith("Bearer ")) { + token = authValue.substring(7); + } else { + token = authValue; + } + break; + } + } + + // 解析 token + if (token != null && !token.isEmpty()) { + try { + // URL 解码 + token = java.net.URLDecoder.decode(token, java.nio.charset.StandardCharsets.UTF_8); + Claims claims = JwtUtils.parseToken(token, jwtSecret); + if (claims != null) { + userId = claims.get("userId", String.class); + username = claims.getSubject(); + log.info("WebSocket握手:从查询参数解析token获取到userId: {}", userId); + } + } catch (Exception e) { + log.warn("WebSocket握手:解析token失败", e); + } + } + } + } + + // 如果获取到userId,则将其放入attributes中供后续使用 + if (userId != null && !userId.isEmpty()) { + attributes.put("userId", userId); + servletRequest.getServletRequest().setAttribute("userId", userId); + log.info("WebSocket握手:从请求头获取到userId: {}", userId); + } else { + log.warn("WebSocket握手:未获取到userId,可能无法正确建立连接"); + } + + // 如果获取到username,也将其存储 + if (username != null && !username.isEmpty()) { + attributes.put("username", username); + servletRequest.getServletRequest().setAttribute("username", username); + log.debug("WebSocket握手:获取到username: {}", username); + } + } + return true; + } + + @Override + public void afterHandshake(@NonNull ServerHttpRequest request, + @org.springframework.lang.Nullable ServerHttpResponse response, + @NonNull WebSocketHandler wsHandler, + @org.springframework.lang.Nullable Exception exception) { + // 在握手完成后,检查userId是否已设置 + if (request instanceof ServletServerHttpRequest servletRequest) { + String userId = (String) servletRequest.getServletRequest().getAttribute("userId"); + if (userId != null) { + log.info("WebSocket握手完成,userId: {}", userId); + } else { + log.warn("WebSocket握手完成,但未找到userId"); + } + } + } + + + public static class UserPrincipal implements Principal { + private final String name; + + public UserPrincipal(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/AuthController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/AuthController.java index 06d5872..869e1ab 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/controller/AuthController.java +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/AuthController.java @@ -1,34 +1,105 @@ package com.timeline.user.controller; import com.timeline.common.response.ResponseEntity; +import com.timeline.common.response.ResponseEnum; import com.timeline.user.dto.LoginRequest; import com.timeline.user.dto.LoginResponse; import com.timeline.user.dto.RegisterRequest; +import com.timeline.user.dto.RefreshRequest; import com.timeline.user.entity.User; +import com.timeline.user.service.UserAuthService; import com.timeline.user.service.UserService; +import com.timeline.user.utils.JwtUtils; +import com.timeline.common.utils.RedisUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; import org.springframework.web.bind.annotation.*; +import java.time.Duration; + @Slf4j @RestController -@RequestMapping("/api/auth") +@RequestMapping("/user/auth") public class AuthController { @Autowired - private UserService userService; + private UserAuthService userAuthService; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private RedisUtils redisUtils; + + private static final String TOKEN_BLACKLIST_PREFIX = "auth:token:blacklist:"; @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest loginRequest) { log.info("用户登录请求: {}", loginRequest.getUsername()); - LoginResponse response = userService.login(loginRequest); + LoginResponse response = userAuthService.login(loginRequest); return ResponseEntity.success(response); } @PostMapping("/register") public ResponseEntity register(@RequestBody RegisterRequest registerRequest) { log.info("用户注册请求: {}", registerRequest.getUsername()); - User user = userService.register(registerRequest); + User user = userAuthService.register(registerRequest); return ResponseEntity.success(user); } + + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody RefreshRequest request) { + String refreshToken = request.getRefreshToken(); + if (refreshToken == null || !jwtUtils.validateToken(refreshToken) || jwtUtils.isTokenExpired(refreshToken)) { + return ResponseEntity.error(ResponseEnum.UNAUTHORIZED, "无效的刷新令牌"); + } + if (!"refresh".equals(jwtUtils.getTokenType(refreshToken))) { + return ResponseEntity.error(ResponseEnum.UNAUTHORIZED, "令牌类型错误"); + } + String userId = jwtUtils.getUserIdFromToken(refreshToken); + String username = jwtUtils.getUsernameFromToken(refreshToken); + String newAccess = jwtUtils.generateAccessToken(userId, username); + String newRefresh = jwtUtils.generateRefreshToken(userId, username); + LoginResponse resp = new LoginResponse(newAccess, newRefresh, jwtUtils.getAccessExpirationSeconds(), userId, username); + return ResponseEntity.success(resp); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String authHeader, + @RequestBody(required = false) RefreshRequest request) { + String accessToken = extractToken(authHeader); + String refreshToken = request != null ? request.getRefreshToken() : null; + + boolean hasToken = false; + if (accessToken != null) { + blacklist(accessToken); + hasToken = true; + } + if (refreshToken != null && jwtUtils.validateToken(refreshToken)) { + blacklist(refreshToken); + hasToken = true; + } + if (!hasToken) { + return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "缺少可注销的令牌"); + } + return ResponseEntity.success("已退出登录"); + } + + @SuppressWarnings("null") + private void blacklist(@NonNull String token) { + long ttlSeconds = jwtUtils.getRemainingSeconds(token); + if (ttlSeconds <= 0) { + return; + } + Duration ttl = Duration.ofSeconds(ttlSeconds); + redisUtils.set(TOKEN_BLACKLIST_PREFIX + token, "1", ttl); + } + + private String extractToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } } diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/FriendController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/FriendController.java new file mode 100644 index 0000000..eb5a89f --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/FriendController.java @@ -0,0 +1,67 @@ +package com.timeline.user.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.user.dto.FriendRequestDto; +import com.timeline.user.dto.FriendUserDto; +import com.timeline.user.entity.Friend; +import com.timeline.user.entity.FriendNotify; +import com.timeline.user.service.FriendService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/user/friend") +public class FriendController { + + @Autowired + private FriendService friendService; + + @PostMapping("/request") + public ResponseEntity requestFriend(@RequestBody FriendRequestDto dto) { + friendService.requestFriend(dto.getFriendId()); + return ResponseEntity.success("请求已发送"); + } + + @PostMapping("/accept") + public ResponseEntity accept(@RequestBody FriendRequestDto dto) { + friendService.acceptFriend(dto.getFriendId()); + return ResponseEntity.success("已接受"); + } + + @PostMapping("/reject") + public ResponseEntity reject(@RequestBody FriendRequestDto dto) { + friendService.rejectFriend(dto.getFriendId()); + return ResponseEntity.success("已拒绝"); + } + + @GetMapping("/list") + public ResponseEntity> list() { + return ResponseEntity.success(friendService.listFriends()); + } + + /* @GetMapping("/ids") + public ResponseEntity> friendIds() { + return ResponseEntity.success(friendService.listFriendIds()); + } */ + + @GetMapping("/pending") + public ResponseEntity> pending() { + return ResponseEntity.success(friendService.listPending()); + } + + @GetMapping("/notify/unread") + public ResponseEntity> unreadNotify() { + return ResponseEntity.success(friendService.listUnreadNotify()); + } + + @PostMapping("/notify/read/{id}") + public ResponseEntity markRead(@PathVariable Long id) { + friendService.markNotifyRead(id); + return ResponseEntity.success("已读"); + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/MessagePushController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/MessagePushController.java new file mode 100644 index 0000000..7b58264 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/MessagePushController.java @@ -0,0 +1,129 @@ +package com.timeline.user.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.user.service.UserMessageService; +import com.timeline.user.ws.WsNotifyService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/user/message") +public class MessagePushController { + + @Autowired + private WsNotifyService wsNotifyService; + + @Autowired + private UserMessageService userMessageService; + + /** + * 向指定用户推送自定义消息 + * + * @param toUserId 目标用户ID + * @param destination 消息目的地 + * @param message 消息内容 + * @return 响应结果 + */ + @PostMapping("/push") + public ResponseEntity pushMessageToUser( + @RequestParam String toUserId, + @RequestParam String destination, + @RequestBody String message) { + + Map payload = new HashMap<>(); + payload.put("message", message); + payload.put("timestamp", System.currentTimeMillis()); + payload.put("type", "custom"); + + wsNotifyService.pushMessageToUser(toUserId, destination, payload); + log.info("向用户 {} 推送消息到 {}: {}", toUserId, destination, message); + + return ResponseEntity.success("消息已推送"); + } + + /** + * 向指定用户推送通知 + * + * @param toUserId 目标用户ID + * @param request 通知内容 + * @return 响应结果 + */ + @PostMapping("/notify") + public ResponseEntity sendNotificationToUser( + @RequestParam String toUserId, + @RequestBody NotificationRequest request) { + + NotificationPayload payload = new NotificationPayload(); + payload.setTitle(request.getTitle()); + payload.setContent(request.getContent()); + payload.setType(request.getType()); + payload.setTimestamp(System.currentTimeMillis()); + + wsNotifyService.sendNotificationToUser(toUserId, payload); + + // 同时存储为未读消息 + userMessageService.addUnreadMessage(toUserId, Map.of( + "type", "notification", + "title", request.getTitle(), + "content", request.getContent(), + "notificationType", request.getType(), + "timestamp", System.currentTimeMillis() + )); + + log.info("向用户 {} 发送通知: {}", toUserId, request.getTitle()); + + return ResponseEntity.success("通知已发送"); + } + + /** + * 向指定用户添加未读消息(不会立即推送,只在下次连接时推送) + * + * @param toUserId 目标用户ID + * @param request 消息内容 + * @return 响应结果 + */ + @PostMapping("/unread") + public ResponseEntity addUnreadMessage( + @RequestParam String toUserId, + @RequestBody UnreadMessageRequest request) { + + userMessageService.addUnreadMessage(toUserId, Map.of( + "type", request.getType(), + "title", request.getTitle(), + "content", request.getContent(), + "timestamp", System.currentTimeMillis() + )); + + log.info("为用户 {} 添加未读消息: {}", toUserId, request.getTitle()); + + return ResponseEntity.success("未读消息已添加"); + } + + @Data + public static class NotificationRequest { + private String title; + private String content; + private String type; // info, warning, error + } + + @Data + public static class NotificationPayload { + private String title; + private String content; + private String type; + private long timestamp; + } + + @Data + public static class UnreadMessageRequest { + private String title; + private String content; + private String type; + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/PermissionController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/PermissionController.java index 4fa11dd..ac072bc 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/controller/PermissionController.java +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/PermissionController.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.*; @Slf4j @RestController -@RequestMapping("/api/permission") +@RequestMapping("/user/permission") public class PermissionController { @Autowired diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/SubscriptionController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/SubscriptionController.java new file mode 100644 index 0000000..73f3c34 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/SubscriptionController.java @@ -0,0 +1,43 @@ +package com.timeline.user.controller; + +import com.timeline.user.listener.StompSubscriptionListener; +import com.timeline.common.response.ResponseEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Set; + +/** + * 订阅信息控制器,用于查询用户的STOMP订阅状态 + */ +@Slf4j +@RestController +@RequestMapping("/user/subscription") +public class SubscriptionController { + + @Autowired + private StompSubscriptionListener stompSubscriptionListener; + + /** + * 获取指定用户的订阅信息 + * @param userId 用户ID + * @return 订阅信息 + */ + @GetMapping("/{userId}") + public ResponseEntity> getUserSubscription(@PathVariable String userId) { + Set subscriptions = stompSubscriptionListener.getUserSubscriptions(userId); + return ResponseEntity.success(subscriptions); + } + + /** + * 获取所有用户的订阅信息 + * @return 所有用户的订阅信息 + */ + @GetMapping("/all") + public ResponseEntity>> getAllSubscriptions() { + Map> subscriptions = stompSubscriptionListener.getAllSubscriptions(); + return ResponseEntity.success(subscriptions); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/TestNotificationPayload.java b/timeline-user-service/src/main/java/com/timeline/user/controller/TestNotificationPayload.java new file mode 100644 index 0000000..b5b4b18 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/TestNotificationPayload.java @@ -0,0 +1,117 @@ +package com.timeline.user.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.user.ws.WsNotifyService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 测试消息控制器,用于手动发送测试WebSocket消息 + */ +@Slf4j +@RestController +@RequestMapping("/user/test-message") +public class TestNotificationPayload { + + @Autowired + private WsNotifyService wsNotifyService; + + /** + * 发送测试好友通知 + * + * @param toUserId 目标用户ID + * @return 响应结果 + */ + @PostMapping("/friend") + public ResponseEntity sendFriendTestMessage(@RequestParam String toUserId) { + Map payload = buildMessagePayload( + "test_friend_notification", + "这是一条测试好友通知" + ); + + wsNotifyService.sendFriendNotify(toUserId, payload); + log.info("已发送测试好友通知给用户: {}", toUserId); + + return ResponseEntity.success("测试好友通知已发送"); + } + + /** + * 发送测试好友通知到所有频道 + * + * @param toUserId 目标用户ID + * @return 响应结果 + */ + @PostMapping("/friend-all") + public ResponseEntity sendFriendTestMessageToAllChannels(@RequestParam String toUserId) { + Map payload = buildMessagePayload( + "test_friend_notification_all", + "这是一条发送到所有频道的测试好友通知" + ); + + wsNotifyService.sendFriendNotifyToAllChannels(toUserId, payload); + log.info("已发送测试好友通知到所有频道给用户: {}", toUserId); + + return ResponseEntity.success("测试好友通知已发送到所有频道"); + } + + /** + * 发送测试聊天消息 + * + * @param toUserId 目标用户ID + * @return 响应结果 + */ + @PostMapping("/chat") + public ResponseEntity sendChatTestMessage(@RequestParam String toUserId) { + Map payload = buildMessagePayload( + "test_chat_message", + "这是一条测试聊天消息" + ); + + wsNotifyService.sendChatMessage(toUserId, payload); + log.info("已发送测试聊天消息给用户: {}", toUserId); + + return ResponseEntity.success("测试聊天消息已发送"); + } + + /** + * 发送测试通知 + * + * @param toUserId 目标用户ID + * @return 响应结果 + */ + @PostMapping("/notification") + public ResponseEntity sendNotificationTestMessage(@RequestParam String toUserId) { + Map payload = buildMessagePayload( + "test_notification", + "这是一条测试通知消息" + ); + // 添加通知特有的字段 + payload.put("title", "测试通知"); + payload.put("type", "info"); + + wsNotifyService.sendNotificationToUser(toUserId, payload); + log.info("已发送测试通知给用户: {}", toUserId); + + return ResponseEntity.success("测试通知已发送"); + } + + /** + * 构建通用消息负载 + * + * @param type 消息类型 + * @param message 消息内容 + * @return 消息负载Map + */ + private Map buildMessagePayload(String type, String message) { + Map payload = new HashMap<>(); + payload.put("type", type); + payload.put("message", message); + payload.put("timestamp", System.currentTimeMillis()); + return payload; + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/UserController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/UserController.java index 50f4e02..d236b46 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/controller/UserController.java +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/UserController.java @@ -1,17 +1,23 @@ package com.timeline.user.controller; import com.timeline.common.response.ResponseEntity; -import com.timeline.user.dto.RegisterRequest; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.user.dto.UpdateUser; import com.timeline.user.entity.User; import com.timeline.user.service.UserService; -import com.timeline.user.utils.UserContext; import lombok.extern.slf4j.Slf4j; + +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + @Slf4j @RestController -@RequestMapping("/api/user") +@RequestMapping("/user") public class UserController { @Autowired @@ -19,23 +25,28 @@ public class UserController { @GetMapping("/info") public ResponseEntity getCurrentUserInfo() { - String userId = UserContext.getCurrentUserId(); - log.info("获取当前用户信息: {}", userId); + User user = userService.getCurrentUser(); + return ResponseEntity.success(user); + } + /** + * 查询指定用户信息 + * @param userId + * @return + */ + @GetMapping("/{userId}") + public ResponseEntity updateUserInfo(@PathVariable String userId) { User user = userService.getUserByUserId(userId); return ResponseEntity.success(user); } - - @PutMapping("/info") - public ResponseEntity updateUserInfo(@RequestBody RegisterRequest updateRequest) { - String userId = UserContext.getCurrentUserId(); - log.info("更新用户信息: {}", userId); - User user = userService.updateUserInfo(userId, updateRequest); - return ResponseEntity.success(user); + @GetMapping("/search") + public ResponseEntity> getMethodName(User user) { + log.info(user.toString()); + return ResponseEntity.success(userService.searchUsers(user)); } - + @DeleteMapping public ResponseEntity deleteUser() { - String userId = UserContext.getCurrentUserId(); + String userId = UserContextUtils.getCurrentUserId(); log.info("删除用户: {}", userId); userService.deleteUser(userId); return ResponseEntity.success("用户删除成功"); diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/UserMessageController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/UserMessageController.java new file mode 100644 index 0000000..6189331 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/UserMessageController.java @@ -0,0 +1,162 @@ +package com.timeline.user.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.user.dao.FriendNotifyMapper; +import com.timeline.user.dto.UserMessageDto; +import com.timeline.user.entity.FriendNotify; +import com.timeline.user.service.UserMessageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 用户消息查询接口:未读消息 & 历史消息摘要 + */ +@Slf4j +@RestController +@RequestMapping("/user/message") +public class UserMessageController { + + @Autowired + private UserMessageService userMessageService; + + @Autowired + private FriendNotifyMapper friendNotifyMapper; + + private String currentUserId() { + return UserContextUtils.getCurrentUserId(); + } + + /** + * 获取当前用户的未读消息(包含系统/通知 + 好友请求等) + */ + @GetMapping("/unread") + public ResponseEntity> unreadMessages() { + String userId = currentUserId(); + List result = new ArrayList<>(); + + // 1. 来自 UserMessageService 的未读消息(内存/缓存) + List> unread = userMessageService.getUnreadMessages(userId); + long now = System.currentTimeMillis(); + for (Map m : unread) { + UserMessageDto dto = new UserMessageDto(); + dto.setCategory((String) m.getOrDefault("category", "notification")); + dto.setType((String) m.getOrDefault("type", "generic")); + dto.setFromUserId((String) m.get("fromUserId")); + dto.setToUserId((String) m.getOrDefault("toUserId", userId)); + dto.setTitle((String) m.get("title")); + dto.setContent((String) m.getOrDefault("content", m.get("message"))); + Object ts = m.get("timestamp"); + dto.setTimestamp(ts instanceof Number ? ((Number) ts).longValue() : now); + dto.setStatus((String) m.getOrDefault("status", "unread")); + result.add(dto); + } + + // 2. 未读好友通知(来自 friend_notify 表) + List friendNotifies = friendNotifyMapper.selectUnread(userId); + for (FriendNotify fn : friendNotifies) { + UserMessageDto dto = new UserMessageDto(); + dto.setId(fn.getId()); + dto.setCategory("friend"); + dto.setType("friend_" + fn.getType()); // request / accept / reject -> friend_request 等 + dto.setFromUserId(fn.getFromUserId()); + dto.setToUserId(fn.getToUserId()); + dto.setTitle("好友通知"); + dto.setContent(fn.getContent()); + dto.setTimestamp(fn.getCreateTime() != null ? fn.getCreateTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() : now); + dto.setStatus(fn.getStatus()); + result.add(dto); + } + + // 简单按时间倒序 + result.sort((a, b) -> Long.compare( + b.getTimestamp() != null ? b.getTimestamp() : 0L, + a.getTimestamp() != null ? a.getTimestamp() : 0L + )); + + log.info("用户 {} 未读消息数量: {}", userId, result.size()); + return ResponseEntity.success(result); + } + + /** + * 历史消息摘要:目前返回最近的好友通知(不限已读/未读) + * 如需更完整的历史,可扩展独立的消息表。 + */ + @GetMapping("/history/friend") + public ResponseEntity> friendHistory() { + String userId = currentUserId(); + // 复用 friend_notify 表,这里按“每个好友一条记录(取最新状态)”进行归并 + List all = friendNotifyMapper.selectAllByUser(userId); + List result = new ArrayList<>(); + long now = System.currentTimeMillis(); + + // key: fromUserId(谁发起/操作好友关系),value: 该好友的最新一条通知 + java.util.Map latestByFromUser = new java.util.HashMap<>(); + for (FriendNotify fn : all) { + String from = fn.getFromUserId(); + if (from == null) { + continue; + } + FriendNotify exist = latestByFromUser.get(from); + if (exist == null) { + latestByFromUser.put(from, fn); + } else { + // 按 create_time 取最新一条 + java.time.LocalDateTime existTime = exist.getCreateTime(); + java.time.LocalDateTime curTime = fn.getCreateTime(); + if (curTime != null && (existTime == null || curTime.isAfter(existTime))) { + latestByFromUser.put(from, fn); + } + } + } + + // 将每个好友的最新一条记录转换为摘要 DTO + for (FriendNotify fn : latestByFromUser.values()) { + UserMessageDto dto = new UserMessageDto(); + dto.setId(fn.getId()); + dto.setCategory("friend"); + String rawType = fn.getType(); // request / accept / reject + dto.setType("friend_" + rawType); + dto.setFromUserId(fn.getFromUserId()); + dto.setToUserId(fn.getToUserId()); + dto.setTitle("好友通知"); + + // 如果 content 为空,根据类型给出默认文案 + String content = fn.getContent(); + if (content == null || content.isEmpty()) { + if ("request".equals(rawType)) { + content = "向你发送了好友请求"; + } else if ("accept".equals(rawType)) { + content = "已接受你的好友请求"; + } else if ("reject".equals(rawType)) { + content = "已拒绝你的好友请求"; + } else { + content = "好友关系发生变更"; + } + } + dto.setContent(content); + + dto.setTimestamp(fn.getCreateTime() != null + ? fn.getCreateTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + : now); + dto.setStatus(fn.getStatus()); + result.add(dto); + } + + result.sort((a, b) -> Long.compare( + b.getTimestamp() != null ? b.getTimestamp() : 0L, + a.getTimestamp() != null ? a.getTimestamp() : 0L + )); + log.info("用户 {} 好友通知历史数量: {}", userId, result.size()); + return ResponseEntity.success(result); + } +} + + diff --git a/timeline-user-service/src/main/java/com/timeline/user/controller/WebSocketTestController.java b/timeline-user-service/src/main/java/com/timeline/user/controller/WebSocketTestController.java new file mode 100644 index 0000000..5ad570e --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/controller/WebSocketTestController.java @@ -0,0 +1,81 @@ +package com.timeline.user.controller; + +import com.timeline.common.response.ResponseEntity; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.user.ws.WsNotifyService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * WebSocket 测试控制器,用于测试消息推送功能 + */ +@Slf4j +@RestController +@RequestMapping("/user/ws/test") +public class WebSocketTestController { + + @Autowired + private WsNotifyService wsNotifyService; + + /** + * 测试推送通知消息给当前用户 + */ + @PostMapping("/notify/self") + public ResponseEntity testNotifySelf(@RequestBody Map message) { + String userId = UserContextUtils.getCurrentUserId(); + if (userId == null || userId.isEmpty()) { + return ResponseEntity.error(401, "未获取到用户身份"); + } + + log.info("测试推送通知给用户: {}", userId); + // 确保消息包含必要字段 + if (!message.containsKey("timestamp")) { + message.put("timestamp", System.currentTimeMillis()); + } + if (!message.containsKey("type")) { + message.put("type", "test"); + } + wsNotifyService.sendNotificationToUser(userId, message); + return ResponseEntity.success("通知已推送给用户: " + userId); + } + + /** + * 测试推送通知消息给指定用户 + */ + @PostMapping("/notify/{targetUserId}") + public ResponseEntity testNotifyUser( + @PathVariable String targetUserId, + @RequestBody Map message) { + log.info("测试推送通知给用户: {}", targetUserId); + wsNotifyService.sendNotificationToUser(targetUserId, message); + return ResponseEntity.success("通知已推送"); + } + + /** + * 测试推送好友通知 + */ + @PostMapping("/friend/{targetUserId}") + public ResponseEntity testFriendNotify( + @PathVariable String targetUserId, + @RequestBody Map message) { + log.info("测试推送好友通知给用户: {}", targetUserId); + wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, message); + return ResponseEntity.success("好友通知已推送"); + } + + /** + * 测试推送聊天消息 + */ + @PostMapping("/chat/{targetUserId}") + public ResponseEntity testChatMessage( + @PathVariable String targetUserId, + @RequestBody Map message) { + log.info("测试推送聊天消息给用户: {}", targetUserId); + wsNotifyService.sendChatMessage(targetUserId, message); + return ResponseEntity.success("聊天消息已推送"); + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dao/FriendMapper.java b/timeline-user-service/src/main/java/com/timeline/user/dao/FriendMapper.java new file mode 100644 index 0000000..4b00faf --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dao/FriendMapper.java @@ -0,0 +1,28 @@ +package com.timeline.user.dao; + +import com.timeline.user.dto.FriendUserDto; +import com.timeline.user.entity.Friend; +import com.timeline.user.entity.Friendship; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface FriendMapper { + void insert(Friendship friendship); + + Friendship selectByUsers(@Param("userId") String userId, @Param("friendId") String friendId); + + void updateStatus(@Param("userId") String userId, + @Param("friendId") String friendId, + @Param("status") String status); + + List selectFriends(@Param("userId") String userId); + + List selectPending(@Param("toUserId") String toUserId); + + // List selectFriendUsers(FriendUserDto userDto); +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dao/FriendNotifyMapper.java b/timeline-user-service/src/main/java/com/timeline/user/dao/FriendNotifyMapper.java new file mode 100644 index 0000000..25f33a3 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dao/FriendNotifyMapper.java @@ -0,0 +1,20 @@ +package com.timeline.user.dao; + +import com.timeline.user.entity.FriendNotify; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface FriendNotifyMapper { + void insert(FriendNotify notify); + List selectUnread(@Param("toUserId") String toUserId); + void markRead(@Param("id") Long id); + + /** + * 查询某个用户的全部好友通知,按时间倒序(用于历史记录) + */ + List selectAllByUser(@Param("toUserId") String toUserId); +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dao/UserMapper.java b/timeline-user-service/src/main/java/com/timeline/user/dao/UserMapper.java index 5e5e284..6c58ed3 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/dao/UserMapper.java +++ b/timeline-user-service/src/main/java/com/timeline/user/dao/UserMapper.java @@ -1,6 +1,9 @@ package com.timeline.user.dao; import com.timeline.user.entity.User; + +import java.util.List; + import org.apache.ibatis.annotations.Mapper; @Mapper @@ -9,6 +12,7 @@ public interface UserMapper { User selectById(Long id); User selectByUserId(String userId); User selectByUsername(String username); + List searchUsers(User user); void update(User user); void deleteByUserId(String userId); } diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/ChatMessage.java b/timeline-user-service/src/main/java/com/timeline/user/dto/ChatMessage.java new file mode 100644 index 0000000..9fdc84c --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/ChatMessage.java @@ -0,0 +1,32 @@ +package com.timeline.user.dto; + +import lombok.Data; + +@Data +public class ChatMessage { + /** + * 接收方用户 ID + */ + private String toUserId; + + /** + * 发送方用户 ID(由服务端在 WebSocket 会话中填充) + */ + private String fromUserId; + + /** + * 发送方用户名(可选) + */ + private String fromUsername; + + /** + * 消息内容 + */ + private String content; + + /** + * 发送时间戳(毫秒) + */ + private Long timestamp; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/FriendNotifyPayload.java b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendNotifyPayload.java new file mode 100644 index 0000000..955b6b8 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendNotifyPayload.java @@ -0,0 +1,37 @@ +package com.timeline.user.dto; + +import lombok.Data; + +@Data +public class FriendNotifyPayload { + /** + * 消息大类,固定为 friend + */ + private String category = "friend"; + + /** + * 好友操作类型:request / accept / reject + */ + private String type; + + /** + * 发送方用户 ID + */ + private String fromUserId; + + /** + * 发送方用户名(可选,用于前端展示) + */ + private String fromUsername; + + /** + * 提示文案或说明 + */ + private String content; + + /** + * 事件时间戳(毫秒) + */ + private Long timestamp; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/FriendRequestDto.java b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendRequestDto.java new file mode 100644 index 0000000..877c32a --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendRequestDto.java @@ -0,0 +1,10 @@ +package com.timeline.user.dto; + +import lombok.Data; + +@Data +public class FriendRequestDto { + private String friendId; + private String remark; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/FriendUserDto.java b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendUserDto.java new file mode 100644 index 0000000..ca2c6a0 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/FriendUserDto.java @@ -0,0 +1,16 @@ +package com.timeline.user.dto; + +import java.time.LocalDateTime; + +import com.timeline.user.entity.User; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper=false) +public class FriendUserDto extends User{ + private LocalDateTime createFriendTime; + private String remark; + private String friendStatus; +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/LoginResponse.java b/timeline-user-service/src/main/java/com/timeline/user/dto/LoginResponse.java index bf10838..4926018 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/dto/LoginResponse.java +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/LoginResponse.java @@ -2,11 +2,15 @@ package com.timeline.user.dto; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data @AllArgsConstructor +@NoArgsConstructor public class LoginResponse { - private String token; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresInSeconds; private String userId; private String username; } diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/RefreshRequest.java b/timeline-user-service/src/main/java/com/timeline/user/dto/RefreshRequest.java new file mode 100644 index 0000000..f87b481 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/RefreshRequest.java @@ -0,0 +1,9 @@ +package com.timeline.user.dto; + +import lombok.Data; + +@Data +public class RefreshRequest { + private String refreshToken; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/UpdateUser.java b/timeline-user-service/src/main/java/com/timeline/user/dto/UpdateUser.java new file mode 100644 index 0000000..f63de96 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/UpdateUser.java @@ -0,0 +1,15 @@ +package com.timeline.user.dto; + +import lombok.Data; + +@Data +public class UpdateUser { + private String username; + private String nickname; + private String email; + private String phone; + private String avatar; + private String description; + private String location; + private String tag; +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/dto/UserMessageDto.java b/timeline-user-service/src/main/java/com/timeline/user/dto/UserMessageDto.java new file mode 100644 index 0000000..7024861 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/dto/UserMessageDto.java @@ -0,0 +1,56 @@ +package com.timeline.user.dto; + +import lombok.Data; + +/** + * 通用用户消息 DTO,用于 WebSocket 推送和历史记录查询 + */ +@Data +public class UserMessageDto { + /** + * 数据库主键(如果来源于持久化表,可以为 null) + */ + private Long id; + + /** + * 消息大类:system / notification / friend / chat 等 + */ + private String category; + + /** + * 业务类型:如 friend_request / friend_accepted / friend_rejected / connection_established 等 + */ + private String type; + + /** + * 发送方用户 ID + */ + private String fromUserId; + + /** + * 接收方用户 ID + */ + private String toUserId; + + /** + * 可选标题(用于通知类消息) + */ + private String title; + + /** + * 文本内容 + */ + private String content; + + /** + * 发送时间戳(毫秒) + */ + private Long timestamp; + + /** + * 状态:unread / read 等 + */ + private String status; +} + + diff --git a/timeline-user-service/src/main/java/com/timeline/user/entity/Friend.java b/timeline-user-service/src/main/java/com/timeline/user/entity/Friend.java new file mode 100644 index 0000000..1b62118 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/entity/Friend.java @@ -0,0 +1,17 @@ +package com.timeline.user.entity; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class Friend { + private Long id; + private String userId; + private String friendId; + private Integer status; // 0 pending, 1 accepted, 2 rejected/blocked + private String remark; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/entity/FriendNotify.java b/timeline-user-service/src/main/java/com/timeline/user/entity/FriendNotify.java new file mode 100644 index 0000000..c172163 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/entity/FriendNotify.java @@ -0,0 +1,18 @@ +package com.timeline.user.entity; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class FriendNotify { + private Long id; + private String fromUserId; + private String toUserId; + private String type; // request / accept / reject + private String status; // unread / read + private String content; + private LocalDateTime createTime; + private LocalDateTime readTime; +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/entity/Friendship.java b/timeline-user-service/src/main/java/com/timeline/user/entity/Friendship.java new file mode 100644 index 0000000..752cf94 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/entity/Friendship.java @@ -0,0 +1,15 @@ +package com.timeline.user.entity; + +import java.time.LocalDateTime; + +import lombok.Data; +@Data +public class Friendship { + private Long id; + private String userId; + private String friendId; + private Integer sortKey; + private String status; // 0 pending, 1 accepted, 2 rejected/blocked + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/entity/User.java b/timeline-user-service/src/main/java/com/timeline/user/entity/User.java index 8791885..03c1311 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/entity/User.java +++ b/timeline-user-service/src/main/java/com/timeline/user/entity/User.java @@ -15,6 +15,10 @@ public class User { private String phone; private Integer status; // 0-正常,1-禁用 private Integer isDeleted; // 0-未删除,1-已删除 + private String avatar; + private String description; + private String location; + private String tag; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/timeline-user-service/src/main/java/com/timeline/user/interceptor/UserContextInterceptor.java b/timeline-user-service/src/main/java/com/timeline/user/interceptor/UserContextInterceptor.java deleted file mode 100644 index d6b2cb3..0000000 --- a/timeline-user-service/src/main/java/com/timeline/user/interceptor/UserContextInterceptor.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.timeline.user.interceptor; - -import com.timeline.user.entity.User; -import com.timeline.user.service.UserService; -import com.timeline.user.utils.JwtUtils; -import com.timeline.user.utils.UserContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; - -@Component -public class UserContextInterceptor implements HandlerInterceptor { - - @Autowired - private JwtUtils jwtUtils; - - @Autowired - private UserService userService; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - String token = request.getHeader("Authorization"); - if (token != null && token.startsWith("Bearer ")) { - token = token.substring(7); - if (jwtUtils.validateToken(token) && !jwtUtils.isTokenExpired(token)) { - String userId = jwtUtils.getUserIdFromToken(token); - User user = userService.getUserByUserId(userId); - if (user != null) { - UserContext.setUser(user); - } - } - } - return true; - } - - @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { - UserContext.clear(); - } -} diff --git a/timeline-user-service/src/main/java/com/timeline/user/listener/ApplicationStartupListener.java b/timeline-user-service/src/main/java/com/timeline/user/listener/ApplicationStartupListener.java new file mode 100644 index 0000000..530c215 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/listener/ApplicationStartupListener.java @@ -0,0 +1,20 @@ +package com.timeline.user.listener; + +import com.timeline.user.service.UserMessageService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationStartupListener implements ApplicationListener { + + @Autowired + private UserMessageService userMessageService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + // 应用启动完成后初始化测试消息 + userMessageService.initializeTestMessages(); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/listener/StompSubscriptionListener.java b/timeline-user-service/src/main/java/com/timeline/user/listener/StompSubscriptionListener.java new file mode 100644 index 0000000..a0ab52f --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/listener/StompSubscriptionListener.java @@ -0,0 +1,95 @@ +package com.timeline.user.listener; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * STOMP订阅监听器,用于跟踪用户的订阅信息 + */ +@Component +@Slf4j +public class StompSubscriptionListener { + + // 存储用户订阅信息:userId -> destinations + private final Map> userSubscriptions = new ConcurrentHashMap<>(); + + /** + * 监听STOMP订阅事件 + * @param event 订阅事件 + */ + @EventListener + public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + + String userId = getUserId(headerAccessor); + String destination = headerAccessor.getDestination(); + String subscriptionId = headerAccessor.getSubscriptionId(); + + if (userId != null && destination != null) { + userSubscriptions.computeIfAbsent(userId, k -> new HashSet<>()).add(destination); + log.info("用户 {} 订阅了 {}", userId, destination); + } + } + + /** + * 监听STOMP取消订阅事件 + * @param event 取消订阅事件 + */ + @EventListener + public void handleWebSocketUnsubscribeListener(SessionUnsubscribeEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + + String userId = getUserId(headerAccessor); + String subscriptionId = headerAccessor.getSubscriptionId(); + + if (userId != null) { + log.info("用户 {} 取消订阅 (subscriptionId: {})", userId, subscriptionId); + } + } + + /** + * 从headerAccessor中提取用户ID + * @param headerAccessor STOMP头部访问器 + * @return 用户ID + */ + private String getUserId(StompHeaderAccessor headerAccessor) { + // 从simpUser属性中获取用户ID + if (headerAccessor.getUser() != null) { + return headerAccessor.getUser().getName(); + } + + // 如果没有从simpUser获取到,尝试从session属性中获取 + Object userIdAttr = headerAccessor.getSessionAttributes().get("userId"); + if (userIdAttr instanceof String) { + return (String) userIdAttr; + } + + return null; + } + + /** + * 获取用户的订阅信息 + * @param userId 用户ID + * @return 订阅的目标地址集合 + */ + public Set getUserSubscriptions(String userId) { + return userSubscriptions.getOrDefault(userId, new HashSet<>()); + } + + /** + * 获取所有用户的订阅信息 + * @return 所有用户的订阅信息 + */ + public Map> getAllSubscriptions() { + return userSubscriptions; + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/FriendService.java b/timeline-user-service/src/main/java/com/timeline/user/service/FriendService.java new file mode 100644 index 0000000..db554fc --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/service/FriendService.java @@ -0,0 +1,24 @@ +package com.timeline.user.service; + +import com.timeline.user.dto.FriendUserDto; +import com.timeline.user.entity.Friend; +import com.timeline.user.entity.FriendNotify; + +import java.util.List; + +public interface FriendService { + void requestFriend(String targetUserId); + + void acceptFriend(String targetUserId); + + void rejectFriend(String targetUserId); + + List listFriends(); + + List listPending(); + + List listUnreadNotify(); + + void markNotifyRead(Long id); +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/UserAuthService.java b/timeline-user-service/src/main/java/com/timeline/user/service/UserAuthService.java new file mode 100644 index 0000000..f2c324a --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/service/UserAuthService.java @@ -0,0 +1,11 @@ +package com.timeline.user.service; + +import com.timeline.user.dto.LoginRequest; +import com.timeline.user.dto.LoginResponse; +import com.timeline.user.dto.RegisterRequest; +import com.timeline.user.entity.User; + +public interface UserAuthService { + LoginResponse login(LoginRequest loginRequest); + User register(RegisterRequest registerRequest); +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/UserMessageService.java b/timeline-user-service/src/main/java/com/timeline/user/service/UserMessageService.java new file mode 100644 index 0000000..76e0bb5 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/service/UserMessageService.java @@ -0,0 +1,66 @@ +package com.timeline.user.service; + +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Service; +import java.util.Collections; + +/** + * 用户消息服务,用于管理用户的未读消息 + */ +@Service +public class UserMessageService { + + // 模拟存储用户未读消息的数据结构 + // 在实际应用中,这应该存储在数据库或缓存中 + private final Map>> userUnreadMessages = new ConcurrentHashMap<>(); + + /** + * 为用户添加一条未读消息(统一结构建议): + * category/type/fromUserId/toUserId/title/content/timestamp/status 等 + * @param userId 用户ID + * @param message 消息内容 + */ + public void addUnreadMessage(String userId, Map message) { + userUnreadMessages.computeIfAbsent(userId, k -> new ArrayList<>()).add(message); + } + + /** + * 获取用户的所有未读消息 + * @param userId 用户ID + * @return 未读消息列表 + */ + public List> getUnreadMessages(String userId) { + return userUnreadMessages.getOrDefault(userId, Collections.emptyList()); + } + + /** + * 清除用户的所有未读消息 + * @param userId 用户ID + */ + public void clearUnreadMessages(String userId) { + userUnreadMessages.remove(userId); + } + + /** + * 初始化一些测试消息 + */ + public void initializeTestMessages() { + // 添加一些测试消息 + addUnreadMessage("testUser1", Map.of( + "type", "notification", + "title", "欢迎使用系统", + "content", "感谢您注册我们的系统", + "timestamp", System.currentTimeMillis() + )); + + addUnreadMessage("testUser1", Map.of( + "type", "friend_request", + "title", "好友请求", + "content", "用户John Doe想要添加您为好友", + "timestamp", System.currentTimeMillis() - 3600000 // 1小时前 + )); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/UserService.java b/timeline-user-service/src/main/java/com/timeline/user/service/UserService.java index 14cc5bb..69d8821 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/service/UserService.java +++ b/timeline-user-service/src/main/java/com/timeline/user/service/UserService.java @@ -1,15 +1,17 @@ package com.timeline.user.service; import com.timeline.user.entity.User; -import com.timeline.user.dto.LoginRequest; -import com.timeline.user.dto.RegisterRequest; -import com.timeline.user.dto.LoginResponse; + +import java.util.List; + +import com.timeline.user.dto.UpdateUser; public interface UserService { - LoginResponse login(LoginRequest loginRequest); - User register(RegisterRequest registerRequest); User getUserByUserId(String userId); - User updateUserInfo(String userId, RegisterRequest updateRequest); + User getCurrentUser(); + User updateUserInfo(UpdateUser updateUser); void deleteUser(String userId); boolean checkUserPermission(String userId, String requiredPermission); + List searchUsers(User user); + User getUserInfo(String userId); } diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/impl/FriendServiceImpl.java b/timeline-user-service/src/main/java/com/timeline/user/service/impl/FriendServiceImpl.java new file mode 100644 index 0000000..89ff6b3 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/service/impl/FriendServiceImpl.java @@ -0,0 +1,225 @@ +package com.timeline.user.service.impl; + +import com.timeline.common.constants.CommonConstants; +import com.timeline.common.exception.CustomException; +import com.timeline.common.response.ResponseEnum; +import com.timeline.common.utils.UserContextUtils; +import com.timeline.user.dao.FriendMapper; +import com.timeline.user.dao.FriendNotifyMapper; +import com.timeline.user.dao.UserMapper; +import com.timeline.user.dto.FriendUserDto; +import com.timeline.user.dto.FriendNotifyPayload; +import com.timeline.user.entity.Friend; +import com.timeline.user.entity.FriendNotify; +import com.timeline.user.entity.Friendship; +import com.timeline.user.entity.User; +import com.timeline.user.service.FriendService; +import com.timeline.user.service.UserMessageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class FriendServiceImpl implements FriendService { + + @Autowired + private FriendMapper friendMapper; + @Autowired + private UserMapper userMapper; + @Autowired + private FriendNotifyMapper friendNotifyMapper; + @Autowired + private com.timeline.user.ws.WsNotifyService wsNotifyService; + @Autowired + private UserMessageService userMessageService; + + private String currentUser() { + String uid = UserContextUtils.getCurrentUserId(); + if (uid == null || uid.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return uid; + } + private String currentUsername() { + String username = UserContextUtils.getCurrentUsername(); + if (username == null || username.isEmpty()) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份"); + } + return username; + } + + @Override + public void requestFriend(String targetUserId) { + String uid = currentUser(); + log.info("用户 {} 向用户 {} 发送好友请求", uid, targetUserId); + User targetUser = userMapper.selectByUserId(targetUserId); + if (targetUser == null) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "目标用户不存在"); + } + if (uid.equals(targetUserId)) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "不能添加自己为好友"); + } + Friendship exist = friendMapper.selectByUsers(uid, targetUserId); + if (exist != null && exist.getStatus() == CommonConstants.FRIENDSHIP_ACCEPTED) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "已是好友"); + } + LocalDateTime now = LocalDateTime.now(); + if (exist == null) { + Friendship f = new Friendship(); + f.setUserId(uid); + f.setFriendId(targetUserId); + f.setStatus(CommonConstants.FRIENDSHIP_PENDING); + f.setSortKey(CommonConstants.DEFAULT_SORT_KEY); + f.setCreateTime(now); + f.setUpdateTime(now); + friendMapper.insert(f); + } else { + friendMapper.updateStatus(uid, targetUserId, CommonConstants.FRIENDSHIP_PENDING); + } + FriendNotify notify = new FriendNotify(); + notify.setFromUserId(uid); + notify.setToUserId(targetUserId); + notify.setType("request"); + notify.setStatus("unread"); + notify.setCreateTime(now); + friendNotifyMapper.insert(notify); + + FriendNotifyPayload payload = new FriendNotifyPayload(); + payload.setType("request"); + payload.setFromUserId(uid); + payload.setFromUsername(currentUsername()); + payload.setContent("向你发送了好友请求"); + payload.setTimestamp(System.currentTimeMillis()); + log.info("准备发送好友请求通知给用户: {}", targetUserId); + wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload); + + // 存储未读消息,以便用户下次连接时能收到 + userMessageService.addUnreadMessage(targetUserId, Map.of( + "category", "friend", + "type", "friend_request", + "fromUserId", uid, + "fromUsername", currentUsername(), + "toUserId", targetUserId, + "title", "好友请求", + "content", "您收到了一个好友请求", + "timestamp", System.currentTimeMillis(), + "status", "unread" + )); + log.info("好友请求已处理完毕"); + } + + @Override + public void acceptFriend(String targetUserId) { + String uid = currentUser(); + log.info("用户 {} 接受了用户 {} 的好友请求", uid, targetUserId); + + LocalDateTime now = LocalDateTime.now(); + friendMapper.updateStatus(targetUserId, uid, CommonConstants.FRIENDSHIP_ACCEPTED); // 请求方记录 + + Friendship reverse = friendMapper.selectByUsers(uid, targetUserId); + if (reverse == null) { + Friendship f = new Friendship(); + f.setUserId(uid); + f.setFriendId(targetUserId); + f.setStatus(CommonConstants.FRIENDSHIP_ACCEPTED); + f.setCreateTime(now); + f.setUpdateTime(now); + friendMapper.insert(f); + } else { + friendMapper.updateStatus(uid, targetUserId, CommonConstants.FRIENDSHIP_ACCEPTED); + } + + FriendNotify notify = new FriendNotify(); + notify.setFromUserId(uid); + notify.setToUserId(targetUserId); + notify.setType("accept"); + notify.setStatus("unread"); + notify.setCreateTime(now); + friendNotifyMapper.insert(notify); + + FriendNotifyPayload payload = new FriendNotifyPayload(); + payload.setType("accept"); + payload.setFromUserId(uid); + payload.setFromUsername(currentUsername()); + payload.setContent("接受了你的好友请求"); + payload.setTimestamp(System.currentTimeMillis()); + log.info("准备发送好友接受通知给用户: {}", targetUserId); + wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload); + + // 存储未读消息,以便用户下次连接时能收到 + userMessageService.addUnreadMessage(targetUserId, Map.of( + "category", "friend", + "type", "friend_accepted", + "fromUserId", uid, + "fromUsername", currentUsername(), + "toUserId", targetUserId, + "title", "好友请求已通过", + "content", "您的好友请求已被接受", + "timestamp", System.currentTimeMillis(), + "status", "unread" + )); + log.info("好友接受已处理完毕"); + } + + @Override + public void rejectFriend(String targetUserId) { + String uid = currentUser(); + log.info("用户 {} 拒绝了用户 {} 的好友请求", uid, targetUserId); + + friendMapper.updateStatus(targetUserId, uid, CommonConstants.FRIENDSHIP_REJECTED); + FriendNotify notify = new FriendNotify(); + notify.setFromUserId(uid); + notify.setToUserId(targetUserId); + notify.setType("reject"); + notify.setStatus("unread"); + notify.setCreateTime(LocalDateTime.now()); + friendNotifyMapper.insert(notify); + + FriendNotifyPayload payload = new FriendNotifyPayload(); + payload.setType("reject"); + payload.setFromUserId(uid); + payload.setContent("拒绝了你的好友请求"); + payload.setTimestamp(System.currentTimeMillis()); + log.info("准备发送好友拒绝通知给用户: {}", targetUserId); + wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload); + + // 存储未读消息,以便用户下次连接时能收到 + userMessageService.addUnreadMessage(targetUserId, Map.of( + "category", "friend", + "type", "friend_rejected", + "fromUserId", uid, + "toUserId", targetUserId, + "title", "好友请求被拒绝", + "content", "您的好友请求已被拒绝", + "timestamp", System.currentTimeMillis(), + "status", "unread" + )); + log.info("好友拒绝已处理完毕"); + } + + @Override + public List listFriends() { + return friendMapper.selectFriends(currentUser()); + } + + @Override + public List listPending() { + return friendMapper.selectPending(currentUser()); + } + + + @Override + public List listUnreadNotify() { + return friendNotifyMapper.selectUnread(currentUser()); + } + + @Override + public void markNotifyRead(Long id) { + friendNotifyMapper.markRead(id); + } +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserAuthServiceImpl.java b/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserAuthServiceImpl.java new file mode 100644 index 0000000..7e9e321 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserAuthServiceImpl.java @@ -0,0 +1,100 @@ +package com.timeline.user.service.impl; + +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import com.timeline.common.constants.CommonConstants; +import com.timeline.common.exception.CustomException; +import com.timeline.common.response.ResponseEnum; +import com.timeline.common.utils.IdUtils; +import com.timeline.common.utils.RedisUtils; +import com.timeline.user.dao.UserMapper; +import com.timeline.user.dto.LoginRequest; +import com.timeline.user.dto.LoginResponse; +import com.timeline.user.dto.RegisterRequest; +import com.timeline.user.entity.User; +import com.timeline.user.service.UserAuthService; +import com.timeline.user.utils.JwtUtils; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class UserAuthServiceImpl implements UserAuthService { + @Autowired + private UserMapper userMapper; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private RedisUtils redisUtils; + + @SuppressWarnings("null") + @Override + public LoginResponse login(LoginRequest loginRequest) { + try { + User user = userMapper.selectByUsername(loginRequest.getUsername()); + if (user == null) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误"); + } + + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误"); + } + + if (user.getStatus() == 1) { + throw new CustomException(ResponseEnum.FORBIDDEN, "用户已被禁用"); + } + + String accessToken = jwtUtils.generateAccessToken(user.getUserId(), user.getUsername()); + String refreshToken = jwtUtils.generateRefreshToken(user.getUserId(), user.getUsername()); + redisUtils.set(loginRequest.getUsername(), refreshToken, jwtUtils.getAccessExpirationSeconds()); + return new LoginResponse(accessToken, refreshToken, jwtUtils.getAccessExpirationSeconds(), user.getUserId(), user.getUsername()); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("用户登录失败", e); + throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "登录失败"); + } + } + + @Override + public User register(RegisterRequest registerRequest) { + try { + // 检查用户名是否已存在 + User existingUser = userMapper.selectByUsername(registerRequest.getUsername()); + if (existingUser != null) { + throw new CustomException(ResponseEnum.BAD_REQUEST, "用户名已存在"); + } + + User user = new User(); + user.setUserId(IdUtils.randomUuidUpper()); + user.setUsername(registerRequest.getUsername()); + user.setNickname(registerRequest.getNickname()); + user.setPassword(passwordEncoder.encode(registerRequest.getPassword())); + user.setEmail(registerRequest.getEmail()); + user.setPhone(registerRequest.getPhone()); + user.setStatus(CommonConstants.USER_STATUS_NORMAL); // 正常状态 + user.setIsDeleted(CommonConstants.NOT_DELETED); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + + userMapper.insert(user); + return user; + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("用户注册失败", e); + throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "注册失败"); + } + } + + +} diff --git a/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserServiceImpl.java b/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserServiceImpl.java index 3d5192a..13cf9d9 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserServiceImpl.java +++ b/timeline-user-service/src/main/java/com/timeline/user/service/impl/UserServiceImpl.java @@ -1,22 +1,21 @@ package com.timeline.user.service.impl; -import com.timeline.common.constants.CommonConstants; import com.timeline.common.exception.CustomException; import com.timeline.common.response.ResponseEnum; -import com.timeline.common.utils.IdUtils; +import com.timeline.common.utils.UserContextUtils; import com.timeline.user.dao.UserMapper; import com.timeline.user.entity.User; -import com.timeline.user.dto.LoginRequest; -import com.timeline.user.dto.RegisterRequest; -import com.timeline.user.dto.LoginResponse; +import com.timeline.user.dto.UpdateUser; import com.timeline.user.service.UserService; -import com.timeline.user.utils.JwtUtils; +import com.timeline.user.ws.WsNotifyService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Slf4j @Service @@ -24,88 +23,60 @@ public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; - + @Autowired - private PasswordEncoder passwordEncoder; + private WsNotifyService wsNotifyService; - @Autowired - private JwtUtils jwtUtils; - - @Override - public LoginResponse login(LoginRequest loginRequest) { - try { - User user = userMapper.selectByUsername(loginRequest.getUsername()); - if (user == null) { - throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误"); - } - - if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { - throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误"); - } - - if (user.getStatus() == 1) { - throw new CustomException(ResponseEnum.FORBIDDEN, "用户已被禁用"); - } - - String token = jwtUtils.generateToken(user.getUserId(), user.getUsername()); - return new LoginResponse(token, user.getUserId(), user.getUsername()); - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("用户登录失败", e); - throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "登录失败"); + private String getCurrentUserId() { + String uid = UserContextUtils.getCurrentUserId(); + if (uid == null) { + throw new CustomException(ResponseEnum.UNAUTHORIZED); } + return uid; } - - @Override - public User register(RegisterRequest registerRequest) { - try { - // 检查用户名是否已存在 - User existingUser = userMapper.selectByUsername(registerRequest.getUsername()); - if (existingUser != null) { - throw new CustomException(ResponseEnum.BAD_REQUEST, "用户名已存在"); - } - - User user = new User(); - user.setUserId(IdUtils.randomUuidUpper()); - user.setUsername(registerRequest.getUsername()); - user.setNickname(registerRequest.getNickname()); - user.setPassword(passwordEncoder.encode(registerRequest.getPassword())); - user.setEmail(registerRequest.getEmail()); - user.setPhone(registerRequest.getPhone()); - user.setStatus(CommonConstants.USER_STATUS_NORMAL); // 正常状态 - user.setIsDeleted(CommonConstants.NOT_DELETED); - user.setCreateTime(LocalDateTime.now()); - user.setUpdateTime(LocalDateTime.now()); - - userMapper.insert(user); - return user; - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("用户注册失败", e); - throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "注册失败"); - } - } - @Override public User getUserByUserId(String userId) { return userMapper.selectByUserId(userId); } + @Override + public User getCurrentUser() { + String userId = getCurrentUserId(); + log.info("获取当前用户信息: {}",userId); + return userMapper.selectByUserId(userId); + } + @Override + public User getUserInfo(String userId) { + log.info("获取指定用户信息: {}",userId); + return userMapper.selectByUserId(userId); + } @Override - public User updateUserInfo(String userId, RegisterRequest updateRequest) { + public User updateUserInfo(UpdateUser updateUser) { try { + String userId = getCurrentUserId(); User user = userMapper.selectByUserId(userId); if (user == null) { throw new CustomException(ResponseEnum.NOT_FOUND, "用户不存在"); } - user.setEmail(updateRequest.getEmail()); - user.setPhone(updateRequest.getPhone()); + user.setNickname(updateUser.getNickname()); + user.setEmail(updateUser.getEmail()); + user.setPhone(updateUser.getPhone()); + user.setAvatar(updateUser.getAvatar()); + user.setDescription(updateUser.getDescription()); + user.setLocation(updateUser.getLocation()); + user.setTag(updateUser.getTag()); user.setUpdateTime(LocalDateTime.now()); userMapper.update(user); + + // 用户信息更新后,通过WebSocket推送通知 + Map notification = new HashMap<>(); + notification.put("type", "user_profile_updated"); + notification.put("message", "您的个人信息已成功更新"); + notification.put("timestamp", System.currentTimeMillis()); + wsNotifyService.sendNotificationToUser(userId, notification); + return user; } catch (CustomException e) { throw e; @@ -139,4 +110,9 @@ public class UserServiceImpl implements UserService { User user = userMapper.selectByUserId(userId); return user != null && user.getStatus() == 0; // 用户存在且未被禁用 } -} + + @Override + public List searchUsers(User user) { + return userMapper.searchUsers(user); + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/java/com/timeline/user/utils/JwtUtils.java b/timeline-user-service/src/main/java/com/timeline/user/utils/JwtUtils.java index f0d4402..ec1cb55 100644 --- a/timeline-user-service/src/main/java/com/timeline/user/utils/JwtUtils.java +++ b/timeline-user-service/src/main/java/com/timeline/user/utils/JwtUtils.java @@ -3,12 +3,15 @@ package com.timeline.user.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; @Component public class JwtUtils { @@ -16,25 +19,39 @@ public class JwtUtils { @Value("${jwt.secret:timelineSecretKey}") private String secret; - @Value("${jwt.expiration:86400}") - private Long expiration; + @Value("${jwt.access-expiration:900}") + private Long accessExpirationSeconds; - public String generateToken(String userId, String username) { + @Value("${jwt.refresh-expiration:604800}") + private Long refreshExpirationSeconds; + + public String generateAccessToken(String userId, String username) { + return buildToken(userId, username, "access", accessExpirationSeconds); + } + + public String generateRefreshToken(String userId, String username) { + return buildToken(userId, username, "refresh", refreshExpirationSeconds); + } + + private String buildToken(String userId, String username, String tokenType, long expiresInSeconds) { Map claims = new HashMap<>(); claims.put("userId", userId); claims.put("username", username); + claims.put("tokenType", tokenType); + long now = System.currentTimeMillis(); return Jwts.builder() .setClaims(claims) .setSubject(username) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) - .signWith(SignatureAlgorithm.HS512, secret) + .setIssuedAt(new Date(now)) + .setExpiration(new Date(now + expiresInSeconds * 1000)) + .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS512) .compact(); } public Claims getClaimsFromToken(String token) { - return Jwts.parser() - .setSigningKey(secret) + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) + .build() .parseClaimsJws(token) .getBody(); } @@ -47,9 +64,20 @@ public class JwtUtils { return getClaimsFromToken(token).getSubject(); } + public String getTokenType(String token) { + return getClaimsFromToken(token).get("tokenType", String.class); + } + + public Long getAccessExpirationSeconds() { + return accessExpirationSeconds; + } + public boolean validateToken(String token) { try { - Jwts.parser().setSigningKey(secret).parseClaimsJws(token); + Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token); return true; } catch (Exception e) { return false; @@ -60,4 +88,29 @@ public class JwtUtils { Date expiration = getClaimsFromToken(token).getExpiration(); return expiration.before(new Date()); } + + /** + * 计算 token 剩余有效期(秒),过期返回 0。 + */ + public long getRemainingSeconds(String token) { + try { + Date exp = getClaimsFromToken(token).getExpiration(); + long diff = exp.getTime() - System.currentTimeMillis(); + return diff > 0 ? TimeUnit.MILLISECONDS.toSeconds(diff) : 0L; + } catch (Exception e) { + return 0L; + } + } + + public static Claims parseToken(String token, String jwtSecret) { + try { + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + return null; + } + } } diff --git a/timeline-user-service/src/main/java/com/timeline/user/utils/UserContext.java b/timeline-user-service/src/main/java/com/timeline/user/utils/UserContext.java deleted file mode 100644 index 09c83de..0000000 --- a/timeline-user-service/src/main/java/com/timeline/user/utils/UserContext.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.timeline.user.utils; - -import com.timeline.user.entity.User; - -public class UserContext { - private static final ThreadLocal userHolder = new ThreadLocal<>(); - - public static void setUser(User user) { - userHolder.set(user); - } - - public static User getCurrentUser() { - return userHolder.get(); - } - - public static String getCurrentUserId() { - User user = getCurrentUser(); - return user != null ? user.getUserId() : null; - } - - public static String getCurrentUsername() { - User user = getCurrentUser(); - return user != null ? user.getUsername() : null; - } - - public static void clear() { - userHolder.remove(); - } -} diff --git a/timeline-user-service/src/main/java/com/timeline/user/ws/ChatWsController.java b/timeline-user-service/src/main/java/com/timeline/user/ws/ChatWsController.java new file mode 100644 index 0000000..c66d664 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/ws/ChatWsController.java @@ -0,0 +1,34 @@ +package com.timeline.user.ws; + +import com.timeline.common.utils.UserContextUtils; +import com.timeline.user.dto.ChatMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Slf4j +@Controller +public class ChatWsController { + + @Autowired + private WsNotifyService wsNotifyService; + + @MessageMapping("/chat/send") + public void send(@Payload ChatMessage msg, Principal principal) { + if (principal == null) { + log.warn("未认证用户尝试发送聊天消息"); + return; + } + String fromUserId = principal.getName(); + msg.setFromUserId(fromUserId); + msg.setFromUsername(UserContextUtils.getCurrentUsername()); + msg.setTimestamp(System.currentTimeMillis()); + log.info("用户 {}({}) 向 {} 发送消息: {}", fromUserId, msg.getFromUsername(), msg.getToUserId(), msg.getContent()); + wsNotifyService.sendChatMessage(msg.getToUserId(), msg); + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketEventListener.java b/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketEventListener.java new file mode 100644 index 0000000..d3ef234 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketEventListener.java @@ -0,0 +1,192 @@ +package com.timeline.user.ws; + +import com.timeline.user.service.UserMessageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.messaging.simp.stomp.StompCommand; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class WebSocketEventListener { + + @Autowired + @SuppressWarnings("unused") + private SimpMessagingTemplate messagingTemplate; + + @Autowired + private UserMessageService userMessageService; + + @Autowired + private WsNotifyService wsNotifyService; + + /** + * WebSocket 连接建立事件(STOMP CONNECT) + */ + @EventListener + public void handleWebSocketConnectListener(SessionConnectedEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal principal = headerAccessor.getUser(); + String sessionId = headerAccessor.getSessionId(); + StompCommand command = headerAccessor.getCommand(); + + log.info("WebSocket 连接建立事件,会话ID: {},STOMP命令: {},Principal: {}", + sessionId, command, principal != null ? principal.getName() : "null"); + + // 打印所有session attributes + Map sessionAttrs = headerAccessor.getSessionAttributes(); + log.info("会话属性: {}", sessionAttrs); + + if (principal != null) { + String userId = principal.getName(); + log.info("WebSocket 连接建立,会话ID: {},用户ID: {}", sessionId, userId); + + // 检查用户是否在注册表中 + org.springframework.messaging.simp.user.SimpUser user = userRegistry.getUser(userId); + if (user != null) { + log.info("用户 {} 已在注册表中,会话数: {}", userId, user.getSessions().size()); + } else { + log.warn("用户 {} 不在注册表中!", userId); + } + } else { + log.warn("WebSocket 连接建立,会话ID: {},但未获取到用户身份", sessionId); + } + } + + /** + * WebSocket 断开连接事件 + */ + @EventListener + public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal principal = headerAccessor.getUser(); + String sessionId = headerAccessor.getSessionId(); + + if (principal != null) { + String userId = principal.getName(); + log.info("WebSocket 连接断开,用户ID: {},会话ID: {}", userId, sessionId); + // 清除初始消息标记,允许下次连接时重新推送 + initialMessageSent.remove(userId); + } else { + log.warn("断开连接事件中未获取到用户身份,会话ID: {}", sessionId); + } + } + + // 用于跟踪已推送初始消息的用户,避免重复推送 + private final java.util.Set initialMessageSent = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + @Autowired + private org.springframework.messaging.simp.user.SimpUserRegistry userRegistry; + + /** + * 订阅事件 - 当用户订阅某个频道时触发 + */ + @EventListener + public void handleSubscribeEvent(SessionSubscribeEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal principal = headerAccessor.getUser(); + String destination = headerAccessor.getDestination(); + String sessionId = headerAccessor.getSessionId(); + + if (principal != null) { + String userId = principal.getName(); + log.info("用户 {} 订阅了频道: {},会话ID: {}", userId, destination, sessionId); + + // 检查用户是否在注册表中 + org.springframework.messaging.simp.user.SimpUser user = userRegistry.getUser(userId); + if (user != null) { + log.info("用户 {} 已在注册表中,会话数: {}", userId, user.getSessions().size()); + } else { + log.warn("用户 {} 不在注册表中!当前注册用户: {}", userId, + userRegistry.getUsers().stream() + .map(org.springframework.messaging.simp.user.SimpUser::getName) + .toList()); + } + + // 如果是第一次订阅(任意频道),推送初始消息 + if (!initialMessageSent.contains(userId)) { + initialMessageSent.add(userId); + log.info("用户 {} 首次订阅,准备推送初始消息", userId); + CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() -> { + pushInitialMessages(userId); + }); + } + + // 如果订阅的是通知频道,立即推送未读消息 + if (destination != null && destination.contains("/queue/notification")) { + CompletableFuture.delayedExecutor(300, TimeUnit.MILLISECONDS).execute(() -> { + pushUnreadNotifications(userId); + }); + } + } else { + log.warn("订阅事件中未获取到用户身份,会话ID: {},目标: {},sessionAttributes: {}", + sessionId, destination, headerAccessor.getSessionAttributes()); + } + } + + /** + * 推送初始消息给刚连接的用户 + */ + private void pushInitialMessages(String userId) { + try { + log.info("开始推送初始消息给用户: {}", userId); + + long now = System.currentTimeMillis(); + // 1. 推送连接成功的欢迎消息(统一结构) + Map welcomeMsg = Map.of( + "category", "system", + "type", "connection_established", + "fromUserId", "system", + "toUserId", userId, + "title", "连接成功", + "content", "WebSocket 连接已建立", + "timestamp", now, + "status", "unread" + ); + wsNotifyService.sendNotificationToUser(userId, welcomeMsg); + log.info("已推送欢迎消息给用户: {}", userId); + + // 2. 推送未读通知 + pushUnreadNotifications(userId); + + } catch (Exception e) { + log.error("推送初始消息失败,用户ID: {}", userId, e); + } + } + + /** + * 推送未读通知 + */ + private void pushUnreadNotifications(String userId) { + try { + List> unreadMessages = userMessageService.getUnreadMessages(userId); + log.info("用户 {} 有 {} 条未读消息", userId, unreadMessages.size()); + + for (Map message : unreadMessages) { + wsNotifyService.sendNotificationToUser(userId, message); + log.debug("已推送未读消息给用户 {}: {}", userId, message); + } + + // 推送完成后清除未读消息 + if (!unreadMessages.isEmpty()) { + userMessageService.clearUnreadMessages(userId); + log.info("已清除用户 {} 的未读消息", userId); + } + } catch (Exception e) { + log.error("推送未读通知失败,用户ID: {}", userId, e); + } + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketSessionRegistry.java b/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketSessionRegistry.java new file mode 100644 index 0000000..5fe119f --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/ws/WebSocketSessionRegistry.java @@ -0,0 +1,58 @@ +package com.timeline.user.ws; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * WebSocket 会话注册表,用于调试和管理用户会话 + */ +@Slf4j +@Component +public class WebSocketSessionRegistry { + + private final SimpUserRegistry userRegistry; + private final SimpMessagingTemplate messagingTemplate; + + public WebSocketSessionRegistry(SimpUserRegistry userRegistry, SimpMessagingTemplate messagingTemplate) { + this.userRegistry = userRegistry; + this.messagingTemplate = messagingTemplate; + } + + /** + * 获取所有已连接的用户 + */ + public Set getConnectedUsers() { + return userRegistry.getUsers().stream() + .map(SimpUser::getName) + .collect(Collectors.toSet()); + } + + /** + * 检查用户是否在线 + */ + public boolean isUserOnline(String userId) { + SimpUser user = userRegistry.getUser(userId); + boolean online = user != null; + log.info("用户 {} 在线状态: {}", userId, online); + if (online) { + log.info("用户 {} 的会话数: {}", userId, user.getSessions().size()); + } + return online; + } + + /** + * 打印所有在线用户 + */ + public void printOnlineUsers() { + Set users = getConnectedUsers(); + log.info("当前在线用户数: {}", users.size()); + users.forEach(userId -> log.info("在线用户: {}", userId)); + } +} + diff --git a/timeline-user-service/src/main/java/com/timeline/user/ws/WsNotifyService.java b/timeline-user-service/src/main/java/com/timeline/user/ws/WsNotifyService.java new file mode 100644 index 0000000..fd75364 --- /dev/null +++ b/timeline-user-service/src/main/java/com/timeline/user/ws/WsNotifyService.java @@ -0,0 +1,93 @@ +package com.timeline.user.ws; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class WsNotifyService { + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + @Autowired + private SimpUserRegistry userRegistry; + + public void sendFriendNotify(String toUserId, Object payload) { + log.info("发送好友通知给用户:{},内容:{}", toUserId, payload); + checkUserOnline(toUserId); + messagingTemplate.convertAndSendToUser(toUserId, "/queue/friend", payload); + log.info("好友通知已发送到目标路径: /user/{}/queue/friend", toUserId); + } + + public void sendChatMessage(String toUserId, Object payload) { + log.info("发送聊天消息给用户:{},内容:{}", toUserId, payload); + checkUserOnline(toUserId); + messagingTemplate.convertAndSendToUser(toUserId, "/queue/chat", payload); + log.info("聊天消息已发送到目标路径: /user/{}/queue/chat", toUserId); + } + + /** + * 向指定用户推送任意消息 + * @param toUserId 目标用户ID + * @param destination 消息目的地 + * @param payload 消息内容 + */ + public void pushMessageToUser(String toUserId, String destination, Object payload) { + log.info("推送消息给用户:{},目的地:{},内容:{}", toUserId, destination, payload); + messagingTemplate.convertAndSendToUser(toUserId, destination, payload); + log.debug("消息已推送"); + } + + /** + * 向指定用户推送通知消息 + * @param toUserId 目标用户ID + * @param payload 消息内容 + */ + public void sendNotificationToUser(String toUserId, Object payload) { + log.info("发送通知给用户:{},内容:{}", toUserId, payload); + checkUserOnline(toUserId); + messagingTemplate.convertAndSendToUser(toUserId, "/queue/notification", payload); + log.info("通知已发送到目标路径: /user/{}/queue/notification", toUserId); + } + + /** + * 向指定用户推送好友相关通知到所有可能的频道 + * @param toUserId 目标用户ID + * @param payload 消息内容 + */ + public void sendFriendNotifyToAllChannels(String toUserId, Object payload) { + log.info("向用户 {} 的所有频道发送好友通知,内容:{}", toUserId, payload); + + // 发送到好友通知频道 + messagingTemplate.convertAndSendToUser(toUserId, "/queue/friend", payload); + + // 发送到通知频道 + messagingTemplate.convertAndSendToUser(toUserId, "/queue/notification", payload); + + log.info("好友通知已发送到所有频道"); + } + + /** + * 检查用户是否在线并打印调试信息 + */ + private void checkUserOnline(String userId) { + SimpUser user = userRegistry.getUser(userId); + if (user != null) { + log.info("用户 {} 在线,会话数: {}", userId, user.getSessions().size()); + user.getSessions().forEach(session -> { + log.debug("会话ID: {}, 订阅数: {}", session.getId(), session.getSubscriptions().size()); + }); + } else { + log.warn("用户 {} 不在线,消息可能无法送达", userId); + log.info("当前在线用户: {}", userRegistry.getUsers().stream() + .map(SimpUser::getName) + .toList()); + } + } +} \ No newline at end of file diff --git a/timeline-user-service/src/main/resources/application.properties b/timeline-user-service/src/main/resources/application.properties index eaf7c18..721d818 100644 --- a/timeline-user-service/src/main/resources/application.properties +++ b/timeline-user-service/src/main/resources/application.properties @@ -1,20 +1,45 @@ spring.application.name=timeline.user server.port=30003 -# ????? -spring.datasource.url=jdbc:mysql://8.137.148.196:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true +# 数据库配置 +spring.datasource.url=jdbc:mysql://59.80.22.43:33306/timeline?serverTimezone=UTC&allowPublicKeyRetrieval=true spring.datasource.username=root -spring.datasource.password=your_password +spring.datasource.password=WoCloud@9ol7uj spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -# MyBatis ?? +# MyBatis 配置 mybatis.mapper-locations=classpath:mapper/*.xml mybatis.type-aliases-package=com.timeline.user.entity mybatis.configuration.mapUnderscoreToCamelCase=true +mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl +logging.level.com.timeline.user.dao=DEBUG +logging.level.org.mybatis=DEBUG -# JWT ?? -jwt.secret=timelineSecretKey -jwt.expiration=86400 +# JWT 配置 +jwt.secret=6f3f9c2b9d9a4e3f8c0d6a7b5c4e3f1a6f3f9c2b9d9a4e3f8c0d6a7b5c4e3f1a +# Access Token 过期时间(秒),默认15分钟 +jwt.access-expiration=900 +# Refresh Token 过期时间(秒),默认7天 +jwt.refresh-expiration=604800 -# ???? +# 日志配置 logging.level.com.timeline.user=DEBUG + +# Redis (本地默认配置,按需调整) +spring.data.redis.host=127.0.0.1 +spring.data.redis.port=36379 +spring.data.redis.password=123456 +spring.data.redis.timeout=5000 + +# 连接池 +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.idle-timeout=300000 +# 5分钟,低于 wait_timeout +spring.datasource.hikari.max-lifetime=1800000 +# 30分钟,低于 MySQL wait_timeout(默认8小时) +spring.datasource.hikari.connection-timeout=30000 +# 获取连接超时 +spring.datasource.hikari.keepalive-time=0 +# 如 MySQL wait_timeout 较短,可设 180000(3分钟) +spring.datasource.hikari.validation-timeout=5000 \ No newline at end of file diff --git a/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendMapper.xml b/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendMapper.xml new file mode 100644 index 0000000..696c1f4 --- /dev/null +++ b/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendMapper.xml @@ -0,0 +1,41 @@ + + + + + + + INSERT INTO friendships (user_id, friend_id, sorted_key, status, create_time, update_time) + VALUES (#{userId}, #{friendId}, #{sortKey}, #{status}, #{createTime}, #{updateTime}) + + + + + + UPDATE friendships + SET status = #{status}, + update_time = NOW() + WHERE user_id = #{userId} AND friend_id = #{friendId} + + + + + + + + + + diff --git a/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendNotifyMapper.xml b/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendNotifyMapper.xml new file mode 100644 index 0000000..59f7a03 --- /dev/null +++ b/timeline-user-service/src/main/resources/com/timeline/user/dao/FriendNotifyMapper.xml @@ -0,0 +1,31 @@ + + + + + + + INSERT INTO friend_notify (from_user_id, to_user_id, type, status, content, create_time) + VALUES (#{fromUserId}, #{toUserId}, #{type}, #{status}, #{content}, #{createTime}) + + + + + + + + UPDATE friend_notify + SET status = 'read', read_time = NOW() + WHERE id = #{id} + + + + diff --git a/timeline-user-service/src/main/resources/com/timeline/user/dao/UserMapper.xml b/timeline-user-service/src/main/resources/com/timeline/user/dao/UserMapper.xml index 1f501aa..1f42a0e 100644 --- a/timeline-user-service/src/main/resources/com/timeline/user/dao/UserMapper.xml +++ b/timeline-user-service/src/main/resources/com/timeline/user/dao/UserMapper.xml @@ -20,14 +20,41 @@ - + UPDATE user SET username = #{username}, email = #{email}, phone = #{phone}, status = #{status}, - update_time = #{updateTime} + update_time = #{updateTime}, + location = #{location}, + description = #{description}, + tag = #{tag} WHERE user_id = #{userId} AND is_deleted = 0 diff --git a/timeline-user-service/src/main/resources/static/info b/timeline-user-service/src/main/resources/static/info new file mode 100644 index 0000000..0cb7bd3 --- /dev/null +++ b/timeline-user-service/src/main/resources/static/info @@ -0,0 +1,6 @@ +{ + "entropy": 123456789, + "origins": ["*:*"], + "cookie_needed": true, + "websocket": true +} \ No newline at end of file diff --git a/timeline-user-service/src/main/resources/static/test-ws.html b/timeline-user-service/src/main/resources/static/test-ws.html new file mode 100644 index 0000000..b130d79 --- /dev/null +++ b/timeline-user-service/src/main/resources/static/test-ws.html @@ -0,0 +1,132 @@ + + + + + + WebSocket 测试页面 + + + +

WebSocket 消息推送测试

+ +
+

连接设置

+ + + + +
未连接
+
+ +
+

发送消息

+
+
+
+ +
+ +
+

接收到的消息

+
+
+ + + + + + + \ No newline at end of file