diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc60..2312dc587 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npx lint-staged diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9b9804d2c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务** + +## 如何部署 + +> Step1 先克隆仓库 + +```shell +git clone https://github.com/nagisa77/OpenIsle.git +cd OpenIsle +``` + +> Step2 后端部署 + +```shell +cd backend +``` + +以IDEA编辑器为例,IDEA打开backend文件夹。 + +- 设置VM Option,最好运行在其他端口,非8080,这里设置8081 + +```shell +-Dserver.port=8081 +``` + +![CleanShot 2025-08-04 at 11 .35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png) + +- 设置jdk版本为java 17 + +![CleanShot 2025-08-04 at 11 .38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png) + +- 本机配置MySQL服务(网上很多教程,忽略) +- 设置环境变量.env 文件 或.properties 文件(二选一) + +1. 环境变量文件生成 + +```shell +cp open-isle.env.example open-isle.env +``` + +修改环境变量,留下需要的,比如你要开发Google登录业务,就需要谷歌相关的变量,数据库是一定要的 + +![CleanShot 2025-08-04 at 11 .41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png) + +应用环境文件, 选择刚刚的`open-isle.env` + +![CleanShot 2025-08-04 at 11 .44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png) + +2. 直接修改 .properities 文件 + +位置src/main/application.properties, 数据库需要修改标红处,其他按需修改 + +![CleanShot 2025-08-04 at 11 .47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png) + +处理完环境问题直接跑起来就能通了 + +![CleanShot 2025-08-04 at 11 .49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png) + +> Step3 前端部署 + +前端可以依赖本机部署的后端,也可以直接调用线上的后端接口 + +```shell +cd ../frontend_nuxt/ +``` + +copy环境.env文件 + +```shell +cp .env.staging.example .env +``` + +1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口 + +```yaml +; 本地部署后端 +NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +2. 依赖预发环境后台环境 + +**(⚠️强烈推荐只部署前端的朋友使用该环境)** + +```yaml +; 本地部署后端 +; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +4. 依赖线上后台环境 + +```yaml +; 本地部署后端 +; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +```shell +# 安装依赖 +npm install --verbose + +# 运行前端服务 +npm run dev +``` + +如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面 diff --git a/README.md b/README.md index e1d721d01..a394d7574 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,18 @@

OpenIsle -

- 高效的开源社区前后端端平台 -

- +
+ 高效的开源社区前后端平台 +


+ Image

## 💡 简介 OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。 -## 🚧 开发 +## 🚧 开发 & 部署 -### 后端 - -1. 确保安装 JDK 17 及 Maven -2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数 -3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run` - -### 前端 - -1. 进入前端目录 - ```bash - cd frontend_nuxt - ``` -2. 安装依赖 - ```bash - npm install - ``` -3. 启动开发服务 - ```bash - npm run dev - ``` - - 生产版本使用如下命令编译: - - ```bash - npm run build - ``` - - 会在 `.output` 目录生成文件,配合线上网站方式部署 +详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file) ## ✨ 项目特点 diff --git a/backend/pom.xml b/backend/pom.xml index 56c4048eb..4193590b6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -26,6 +26,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + org.slf4j slf4j-api diff --git a/backend/src/main/java/com/openisle/config/ChannelInitializer.java b/backend/src/main/java/com/openisle/config/ChannelInitializer.java new file mode 100644 index 000000000..ba034b49d --- /dev/null +++ b/backend/src/main/java/com/openisle/config/ChannelInitializer.java @@ -0,0 +1,32 @@ +package com.openisle.config; + +import com.openisle.model.MessageConversation; +import com.openisle.repository.MessageConversationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ChannelInitializer implements CommandLineRunner { + private final MessageConversationRepository conversationRepository; + + @Override + public void run(String... args) { + if (conversationRepository.countByChannelTrue() == 0) { + MessageConversation chat = new MessageConversation(); + chat.setChannel(true); + chat.setName("吹水群"); + chat.setDescription("吹水聊天"); + chat.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg"); + conversationRepository.save(chat); + + MessageConversation tech = new MessageConversation(); + tech.setChannel(true); + tech.setName("技术讨论群"); + tech.setDescription("讨论技术相关话题"); + tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png"); + conversationRepository.save(tech); + } + } +} diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 9fabbacbd..bb6081aac 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -99,11 +99,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检 - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(Customizer.withDefaults()) + .headers(h -> h.frameOptions(f -> f.sameOrigin())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() @@ -119,6 +121,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() @@ -154,7 +157,7 @@ public class SecurityConfig { uri.startsWith("/api/search") || uri.startsWith("/api/users") || uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || - uri.startsWith("/api/point-goods") || + uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") || uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || uri.startsWith("/api/rss")); @@ -172,7 +175,8 @@ public class SecurityConfig { response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); return; } - } else if (!uri.startsWith("/api/auth") && !publicGet) { + } else if (!uri.startsWith("/api/auth") && !publicGet + && !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Missing token\"}"); diff --git a/backend/src/main/java/com/openisle/config/WebSocketConfig.java b/backend/src/main/java/com/openisle/config/WebSocketConfig.java new file mode 100644 index 000000000..f3576335b --- /dev/null +++ b/backend/src/main/java/com/openisle/config/WebSocketConfig.java @@ -0,0 +1,110 @@ +package com.openisle.config; + +import com.openisle.service.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + @Value("${app.website-url}") + private String websiteUrl; + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue" + config.enableSimpleBroker("/topic", "/queue"); + // Set user destination prefix for personal messages + config.setUserDestinationPrefix("/user"); + // Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods. + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 1) 原生 WebSocket(不带 SockJS) + registry.addEndpoint("/api/ws") + .setAllowedOriginPatterns( + "https://staging.open-isle.com", + "https://www.staging.open-isle.com", + websiteUrl, + websiteUrl.replace("://www.", "://"), + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.7.98:*", + "http://30.211.97.238:*" + ); + + // 2) SockJS 回退:单独路径 + registry.addEndpoint("/api/sockjs") + .setAllowedOriginPatterns( + "https://staging.open-isle.com", + "https://www.staging.open-isle.com", + websiteUrl, + websiteUrl.replace("://www.", "://"), + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.7.98:*", + "http://30.211.97.238:*" + ) + .withSockJS() + .setWebSocketEnabled(true) + .setSessionCookieNeeded(false); + } + + + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + System.out.println("WebSocket CONNECT command received"); + String authHeader = accessor.getFirstNativeHeader("Authorization"); + System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing")); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + String username = jwtService.validateAndGetSubject(token); + System.out.println("JWT validated for user: " + username); + var userDetails = userDetailsService.loadUserByUsername(username); + Authentication auth = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + accessor.setUser(auth); + System.out.println("WebSocket user set: " + username); + } catch (Exception e) { + System.err.println("JWT validation failed: " + e.getMessage()); + } + } + } else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination()); + System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null")); + } + return message; + } + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java index bad3abcfe..d4a7a07da 100644 --- a/backend/src/main/java/com/openisle/controller/AuthController.java +++ b/backend/src/main/java/com/openisle/controller/AuthController.java @@ -47,13 +47,14 @@ public class AuthController { return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); } if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) { - if (!inviteService.validate(req.getInviteToken())) { + InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken()); + if (!result.isValidate()) { return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多")); } try { User user = userService.registerWithInvite( req.getUsername(), req.getEmail(), req.getPassword()); - inviteService.consume(req.getInviteToken()); + inviteService.consume(req.getInviteToken(), user.getUsername()); emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); return ResponseEntity.ok(Map.of( "token", jwtService.generateToken(user.getUsername()), @@ -144,7 +145,8 @@ public class AuthController { @PostMapping("/google") public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - if (viaInvite && !inviteService.validate(req.getInviteToken())) { + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); + if (viaInvite && !inviteValidateResult.isValidate()) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } Optional resultOpt = googleAuthService.authenticate( @@ -154,7 +156,7 @@ public class AuthController { if (resultOpt.isPresent()) { AuthResult result = resultOpt.get(); if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken()); + inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); return ResponseEntity.ok(Map.of( "token", jwtService.generateToken(result.getUser().getUsername()), "reason_code", "INVITE_APPROVED" @@ -218,7 +220,8 @@ public class AuthController { @PostMapping("/github") public ResponseEntity loginWithGithub(@RequestBody GithubLoginRequest req) { boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - if (viaInvite && !inviteService.validate(req.getInviteToken())) { + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); + if (viaInvite && !inviteValidateResult.isValidate()) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } Optional resultOpt = githubAuthService.authenticate( @@ -229,7 +232,7 @@ public class AuthController { if (resultOpt.isPresent()) { AuthResult result = resultOpt.get(); if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken()); + inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); return ResponseEntity.ok(Map.of( "token", jwtService.generateToken(result.getUser().getUsername()), "reason_code", "INVITE_APPROVED" @@ -265,7 +268,8 @@ public class AuthController { @PostMapping("/discord") public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - if (viaInvite && !inviteService.validate(req.getInviteToken())) { + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); + if (viaInvite && !inviteValidateResult.isValidate()) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } Optional resultOpt = discordAuthService.authenticate( @@ -276,7 +280,7 @@ public class AuthController { if (resultOpt.isPresent()) { AuthResult result = resultOpt.get(); if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken()); + inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); return ResponseEntity.ok(Map.of( "token", jwtService.generateToken(result.getUser().getUsername()), "reason_code", "INVITE_APPROVED" @@ -311,7 +315,8 @@ public class AuthController { @PostMapping("/twitter") public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - if (viaInvite && !inviteService.validate(req.getInviteToken())) { + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); + if (viaInvite && !inviteValidateResult.isValidate()) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } Optional resultOpt = twitterAuthService.authenticate( @@ -323,7 +328,7 @@ public class AuthController { if (resultOpt.isPresent()) { AuthResult result = resultOpt.get(); if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken()); + inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); return ResponseEntity.ok(Map.of( "token", jwtService.generateToken(result.getUser().getUsername()), "reason_code", "INVITE_APPROVED" diff --git a/backend/src/main/java/com/openisle/controller/ChannelController.java b/backend/src/main/java/com/openisle/controller/ChannelController.java new file mode 100644 index 000000000..03b5a6952 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/ChannelController.java @@ -0,0 +1,42 @@ +package com.openisle.controller; + +import com.openisle.dto.ChannelDto; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.ChannelService; +import com.openisle.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/channels") +@RequiredArgsConstructor +public class ChannelController { + private final ChannelService channelService; + private final MessageService messageService; + private final UserRepository userRepository; + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return user.getId(); + } + + @GetMapping + public List listChannels(Authentication auth) { + return channelService.listChannels(getCurrentUserId(auth)); + } + + @PostMapping("/{channelId}/join") + public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) { + return channelService.joinChannel(channelId, getCurrentUserId(auth)); + } + + @GetMapping("/unread-count") + public long unreadCount(Authentication auth) { + return messageService.getUnreadChannelCount(getCurrentUserId(auth)); + } +} diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index 09e998607..77b3b0b16 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -47,7 +47,7 @@ public class CommentController { Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); CommentDto dto = commentMapper.toDto(comment); dto.setReward(levelService.awardForComment(auth.getName())); - dto.setPointReward(pointService.awardForComment(auth.getName(),postId)); + dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId())); log.debug("createComment succeeded for comment {}", comment.getId()); return ResponseEntity.ok(dto); } diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java new file mode 100644 index 000000000..bf2b2b1ad --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/MessageController.java @@ -0,0 +1,137 @@ +package com.openisle.controller; + +import com.openisle.dto.ConversationDetailDto; +import com.openisle.dto.ConversationDto; +import com.openisle.dto.CreateConversationRequest; +import com.openisle.dto.CreateConversationResponse; +import com.openisle.dto.MessageDto; +import com.openisle.dto.UserSummaryDto; +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/messages") +@RequiredArgsConstructor +public class MessageController { + + private final MessageService messageService; + private final UserRepository userRepository; + + // This is a placeholder for getting the current user's ID + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found")); + // In a real application, you would get this from the Authentication object + return user.getId(); + } + + @GetMapping("/conversations") + public ResponseEntity> getConversations(Authentication auth) { + List conversations = messageService.getConversations(getCurrentUserId(auth)); + return ResponseEntity.ok(conversations); + } + + @GetMapping("/conversations/{conversationId}") + public ResponseEntity getMessages(@PathVariable Long conversationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Authentication auth) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable); + return ResponseEntity.ok(conversationDetails); + } + + @PostMapping + public ResponseEntity sendMessage(@RequestBody MessageRequest req, Authentication auth) { + Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent()); + return ResponseEntity.ok(toDto(message)); + } + + @PostMapping("/conversations/{conversationId}/messages") + public ResponseEntity sendMessageToConversation(@PathVariable Long conversationId, + @RequestBody ChannelMessageRequest req, + Authentication auth) { + Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent()); + return ResponseEntity.ok(toDto(message)); + } + + @PostMapping("/conversations/{conversationId}/read") + public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) { + messageService.markConversationAsRead(conversationId, getCurrentUserId(auth)); + return ResponseEntity.ok().build(); + } + + @PostMapping("/conversations") + public ResponseEntity findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) { + MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId()); + return ResponseEntity.ok(new CreateConversationResponse(conversation.getId())); + } + + private MessageDto toDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setCreatedAt(message.getCreatedAt()); + + dto.setConversationId(message.getConversation().getId()); + + UserSummaryDto senderDto = new UserSummaryDto(); + senderDto.setId(message.getSender().getId()); + senderDto.setUsername(message.getSender().getUsername()); + senderDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(senderDto); + + return dto; + } + + @GetMapping("/unread-count") + public ResponseEntity getUnreadCount(Authentication auth) { + return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth))); + } + + // A simple request DTO + static class MessageRequest { + private Long recipientId; + private String content; + + public Long getRecipientId() { + return recipientId; + } + + public void setRecipientId(Long recipientId) { + this.recipientId = recipientId; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } + + static class ChannelMessageRequest { + private String content; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/controller/PointHistoryController.java b/backend/src/main/java/com/openisle/controller/PointHistoryController.java new file mode 100644 index 000000000..a547d309a --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/PointHistoryController.java @@ -0,0 +1,28 @@ +package com.openisle.controller; + +import com.openisle.dto.PointHistoryDto; +import com.openisle.mapper.PointHistoryMapper; +import com.openisle.service.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +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; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/point-histories") +@RequiredArgsConstructor +public class PointHistoryController { + private final PointService pointService; + private final PointHistoryMapper pointHistoryMapper; + + @GetMapping + public List list(Authentication auth) { + return pointService.listHistory(auth.getName()).stream() + .map(pointHistoryMapper::toDto) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 3dd4662e0..3ea7334e2 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -45,7 +45,7 @@ public class PostController { draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); - dto.setPointReward(pointService.awardForPost(auth.getName())); + dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId())); return ResponseEntity.ok(dto); } @@ -171,4 +171,27 @@ public class PostController { return postService.listPostsByLatestReply(ids, tids, page, pageSize) .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); } + + @GetMapping("/featured") + public List featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "categoryIds", required = false) List categoryIds, + @RequestParam(value = "tagId", required = false) Long tagId, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + Authentication auth) { + List ids = categoryIds; + if (categoryId != null) { + ids = java.util.List.of(categoryId); + } + List tids = tagIds; + if (tagId != null) { + tids = java.util.List.of(tagId); + } + if (auth != null) { + userVisitService.recordVisit(auth.getName()); + } + return postService.listFeaturedPosts(ids, tids, page, pageSize) + .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java index 4970ebcc1..7dc6122f7 100644 --- a/backend/src/main/java/com/openisle/controller/RssController.java +++ b/backend/src/main/java/com/openisle/controller/RssController.java @@ -1,7 +1,10 @@ package com.openisle.controller; import com.openisle.model.Post; +import com.openisle.model.Comment; +import com.openisle.model.CommentSort; import com.openisle.service.PostService; +import com.openisle.service.CommentService; import lombok.RequiredArgsConstructor; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; @@ -31,6 +34,7 @@ import java.util.regex.Pattern; @RequiredArgsConstructor public class RssController { private final PostService postService; + private final CommentService commentService; @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -103,6 +107,12 @@ public class RssController { enclosure = absolutifyUrl(enclosure, base); } + // 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 + List topComments = commentService + .getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS); + topComments = topComments.subList(0, Math.min(10, topComments.size())); + String footerHtml = buildFooterHtml(base, link, topComments); + sb.append(""); elem(sb, "title", cdata(nullSafe(p.getTitle()))); elem(sb, "link", link); @@ -110,8 +120,11 @@ public class RssController { elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault()))); // 摘要 elem(sb, "description", cdata(plain)); - // 全文(HTML) - sb.append(""); + // 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML) + sb.append(""); // 首图 enclosure(图片类型) if (enclosure != null) { sb.append(" topComments) { + StringBuilder md = new StringBuilder(256); + + // 分割线 + md.append("\n\n---\n\n"); + + // 原文链接(强调 + 可点击) + md.append("**原文链接:** ") + .append("[").append(originalLink).append("](").append(originalLink).append(")") + .append("\n\n"); + + // 精选评论(仅当有评论时展示) + if (topComments != null && !topComments.isEmpty()) { + md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n"); + for (Comment c : topComments) { + String author = usernameOf(c); + String content = nullSafe(c.getContent()).replace("\r", ""); + // 使用引用样式展示,提升可读性 + md.append("> @").append(author).append(": ").append(content).append("\n\n"); + } + } + + // 渲染为 HTML,并保持和正文一致的处理流程 + String html = renderMarkdown(md.toString()); + String safe = sanitizeHtml(html); + return absolutifyHtml(safe, baseUrl); + } + + private static String usernameOf(Comment c) { + if (c == null) return "匿名"; + try { + Object authorObj = c.getAuthor(); + if (authorObj == null) return "匿名"; + // 反射避免直接依赖实体字段名变化(也可直接强转到具体类型) + String username; + try { + username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj); + } catch (Exception e) { + username = null; + } + if (username == null || username.isEmpty()) return "匿名"; + return username; + } catch (Exception ignored) { + return "匿名"; + } + } + /* ===================== 时间/字符串/XML ===================== */ private static String toRfc1123Gmt(ZonedDateTime zdt) { diff --git a/backend/src/main/java/com/openisle/dto/ChannelDto.java b/backend/src/main/java/com/openisle/dto/ChannelDto.java new file mode 100644 index 000000000..5c3d20d7f --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ChannelDto.java @@ -0,0 +1,17 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChannelDto { + private Long id; + private String name; + private String description; + private String avatar; + private MessageDto lastMessage; + private long memberCount; + private boolean joined; + private long unreadCount; +} diff --git a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java new file mode 100644 index 000000000..6b0c9e97c --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java @@ -0,0 +1,16 @@ +package com.openisle.dto; + +import lombok.Data; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Data +public class ConversationDetailDto { + private Long id; + private String name; + private boolean channel; + private String avatar; + private List participants; + private Page messages; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/ConversationDto.java b/backend/src/main/java/com/openisle/dto/ConversationDto.java new file mode 100644 index 000000000..fdc83e639 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java @@ -0,0 +1,20 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +public class ConversationDto { + private Long id; + private String name; + private boolean channel; + private String avatar; + private MessageDto lastMessage; + private List participants; + private LocalDateTime createdAt; + private long unreadCount; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java new file mode 100644 index 000000000..611557360 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java @@ -0,0 +1,8 @@ +package com.openisle.dto; + +import lombok.Data; + +@Data +public class CreateConversationRequest { + private Long recipientId; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java new file mode 100644 index 000000000..349f120f9 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateConversationResponse { + private Long conversationId; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java new file mode 100644 index 000000000..2e5cbaf9e --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class FeaturedMedalDto extends MedalDto { + private long currentFeaturedCount; + private long targetFeaturedCount; +} + diff --git a/backend/src/main/java/com/openisle/dto/MessageDto.java b/backend/src/main/java/com/openisle/dto/MessageDto.java new file mode 100644 index 000000000..ff536cf84 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/MessageDto.java @@ -0,0 +1,13 @@ +package com.openisle.dto; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class MessageDto { + private Long id; + private String content; + private UserSummaryDto sender; + private Long conversationId; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/PointHistoryDto.java b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java new file mode 100644 index 000000000..cae0b6f6b --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java @@ -0,0 +1,23 @@ +package com.openisle.dto; + +import com.openisle.model.PointHistoryType; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class PointHistoryDto { + private Long id; + private PointHistoryType type; + private int amount; + private int balance; + private Long postId; + private String postTitle; + private Long commentId; + private String commentContent; + private Long fromUserId; + private String fromUserName; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/openisle/dto/UserSummaryDto.java b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java new file mode 100644 index 000000000..5df8254a4 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java @@ -0,0 +1,10 @@ +package com.openisle.dto; + +import lombok.Data; + +@Data +public class UserSummaryDto { + private Long id; + private String username; + private String avatar; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java new file mode 100644 index 000000000..9a3881d5a --- /dev/null +++ b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java @@ -0,0 +1,34 @@ +package com.openisle.mapper; + +import com.openisle.dto.PointHistoryDto; +import com.openisle.model.PointHistory; +import org.springframework.stereotype.Component; + +@Component +public class PointHistoryMapper { + public PointHistoryDto toDto(PointHistory history) { + PointHistoryDto dto = new PointHistoryDto(); + dto.setId(history.getId()); + dto.setType(history.getType()); + dto.setAmount(history.getAmount()); + dto.setBalance(history.getBalance()); + dto.setCreatedAt(history.getCreatedAt()); + if (history.getPost() != null) { + dto.setPostId(history.getPost().getId()); + dto.setPostTitle(history.getPost().getTitle()); + } + if (history.getComment() != null) { + dto.setCommentId(history.getComment().getId()); + dto.setCommentContent(history.getComment().getContent()); + if (history.getComment().getPost() != null && dto.getPostId() == null) { + dto.setPostId(history.getComment().getPost().getId()); + dto.setPostTitle(history.getComment().getPost().getTitle()); + } + } + if (history.getFromUser() != null) { + dto.setFromUserId(history.getFromUser().getId()); + dto.setFromUserName(history.getFromUser().getUsername()); + } + return dto; + } +} diff --git a/backend/src/main/java/com/openisle/model/MedalType.java b/backend/src/main/java/com/openisle/model/MedalType.java index 00553511d..c6509cebb 100644 --- a/backend/src/main/java/com/openisle/model/MedalType.java +++ b/backend/src/main/java/com/openisle/model/MedalType.java @@ -3,6 +3,7 @@ package com.openisle.model; public enum MedalType { COMMENT, POST, + FEATURED, CONTRIBUTOR, SEED, PIONEER diff --git a/backend/src/main/java/com/openisle/model/Message.java b/backend/src/main/java/com/openisle/model/Message.java new file mode 100644 index 000000000..2cd1d4cca --- /dev/null +++ b/backend/src/main/java/com/openisle/model/Message.java @@ -0,0 +1,35 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "messages") +public class Message { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private MessageConversation conversation; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/MessageConversation.java b/backend/src/main/java/com/openisle/model/MessageConversation.java new file mode 100644 index 000000000..dfcda4e0c --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MessageConversation.java @@ -0,0 +1,48 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "message_conversations") +public class MessageConversation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Indicates whether this conversation represents a public channel + @Column(nullable = false) + private boolean channel = false; + + // Channel metadata + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + private String avatar; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_message_id") + private Message lastMessage; + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + private Set participants = new HashSet<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + private Set messages = new HashSet<>(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/MessageParticipant.java b/backend/src/main/java/com/openisle/model/MessageParticipant.java new file mode 100644 index 000000000..d69901c8f --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MessageParticipant.java @@ -0,0 +1,30 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "message_participants") +public class MessageParticipant { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private MessageConversation conversation; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column + private LocalDateTime lastReadAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 132af5176..c4b4e0e25 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -14,6 +14,8 @@ public enum NotificationType { POST_REVIEW_REQUEST, /** Your post under review was approved or rejected */ POST_REVIEWED, + /** An administrator deleted your post */ + POST_DELETED, /** A subscribed post received a new comment */ POST_UPDATED, /** Someone subscribed to your post */ @@ -38,6 +40,8 @@ public enum NotificationType { LOTTERY_WIN, /** Your lottery post was drawn */ LOTTERY_DRAW, + /** Your post was featured */ + POST_FEATURED, /** You were mentioned in a post or comment */ MENTION } diff --git a/backend/src/main/java/com/openisle/model/PointHistory.java b/backend/src/main/java/com/openisle/model/PointHistory.java new file mode 100644 index 000000000..347d4c75a --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PointHistory.java @@ -0,0 +1,49 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** Point change history for a user. */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "point_histories") +public class PointHistory { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PointHistoryType type; + + @Column(nullable = false) + private int amount; + + @Column(nullable = false) + private int balance; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_user_id") + private User fromUser; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java new file mode 100644 index 000000000..af03d989c --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java @@ -0,0 +1,12 @@ +package com.openisle.model; + +public enum PointHistoryType { + POST, + COMMENT, + POST_LIKED, + COMMENT_LIKED, + INVITE, + FEATURE, + SYSTEM_ONLINE, + REDEEM +} diff --git a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java new file mode 100644 index 000000000..d260c4f38 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java @@ -0,0 +1,34 @@ +package com.openisle.repository; + +import com.openisle.model.MessageConversation; +import com.openisle.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MessageConversationRepository extends JpaRepository { + @Query("SELECT c FROM MessageConversation c " + + "WHERE c.channel = false AND size(c.participants) = 2 " + + "AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " + + "AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " + + "ORDER BY c.createdAt DESC") + List findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2); + + @Query("SELECT DISTINCT c FROM MessageConversation c " + + "JOIN c.participants p " + + "LEFT JOIN FETCH c.lastMessage lm " + + "LEFT JOIN FETCH lm.sender " + + "LEFT JOIN FETCH c.participants cp " + + "LEFT JOIN FETCH cp.user " + + "WHERE p.user.id = :userId " + + "ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC") + List findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId); + + List findByChannelTrue(); + + long countByChannelTrue(); +} diff --git a/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java new file mode 100644 index 000000000..3c63bccbd --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java @@ -0,0 +1,14 @@ +package com.openisle.repository; + +import com.openisle.model.MessageParticipant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MessageParticipantRepository extends JpaRepository { + Optional findByConversationIdAndUserId(Long conversationId, Long userId); + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/repository/MessageRepository.java b/backend/src/main/java/com/openisle/repository/MessageRepository.java new file mode 100644 index 000000000..9c89a0247 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageRepository.java @@ -0,0 +1,21 @@ +package com.openisle.repository; + +import com.openisle.model.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MessageRepository extends JpaRepository { + List findByConversationIdOrderByCreatedAtAsc(Long conversationId); + + Page findByConversationId(Long conversationId, Pageable pageable); + + long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt); + + // 只计算不是指定用户发送的消息(即别人发给当前用户的消息) + long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java new file mode 100644 index 000000000..ac1ee7096 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java @@ -0,0 +1,12 @@ +package com.openisle.repository; + +import com.openisle.model.PointHistory; +import com.openisle.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PointHistoryRepository extends JpaRepository { + List findByUserOrderByIdDesc(User user); + long countByUser(User user); +} diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index 58083b193..a072c83f1 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository { long countDistinctByTags_Id(Long tagId); + long countByAuthor_IdAndRssExcludedFalse(Long userId); + @Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id") List countPostsByTagIds(@Param("tagIds") List tagIds); diff --git a/backend/src/main/java/com/openisle/service/ChannelService.java b/backend/src/main/java/com/openisle/service/ChannelService.java new file mode 100644 index 000000000..62b1c392b --- /dev/null +++ b/backend/src/main/java/com/openisle/service/ChannelService.java @@ -0,0 +1,98 @@ +package com.openisle.service; + +import com.openisle.dto.ChannelDto; +import com.openisle.dto.MessageDto; +import com.openisle.dto.UserSummaryDto; +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.MessageParticipant; +import com.openisle.model.User; +import com.openisle.repository.MessageConversationRepository; +import com.openisle.repository.MessageParticipantRepository; +import com.openisle.repository.MessageRepository; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ChannelService { + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listChannels(Long userId) { + List channels = conversationRepository.findByChannelTrue(); + return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList()); + } + + @Transactional + public ChannelDto joinChannel(Long channelId, Long userId) { + MessageConversation channel = conversationRepository.findById(channelId) + .orElseThrow(() -> new IllegalArgumentException("Channel not found")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + participantRepository.findByConversationIdAndUserId(channelId, userId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(channel); + p.setUser(user); + MessageParticipant saved = participantRepository.save(p); + channel.getParticipants().add(saved); + return saved; + }); + return toDto(channel, userId); + } + + private ChannelDto toDto(MessageConversation channel, Long userId) { + ChannelDto dto = new ChannelDto(); + dto.setId(channel.getId()); + dto.setName(channel.getName()); + dto.setDescription(channel.getDescription()); + dto.setAvatar(channel.getAvatar()); + if (channel.getLastMessage() != null) { + dto.setLastMessage(toMessageDto(channel.getLastMessage())); + } + dto.setMemberCount(channel.getParticipants().size()); + boolean joined = channel.getParticipants().stream() + .anyMatch(p -> p.getUser().getId().equals(userId)); + dto.setJoined(joined); + if (joined) { + MessageParticipant participant = channel.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst().orElse(null); + LocalDateTime lastRead = participant.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : participant.getLastReadAt(); + long unread = messageRepository + .countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId); + dto.setUnreadCount(unread); + } else { + dto.setUnreadCount(0); + } + return dto; + } + + private MessageDto toMessageDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setConversationId(message.getConversation().getId()); + dto.setCreatedAt(message.getCreatedAt()); + + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(message.getSender().getId()); + userDto.setUsername(message.getSender().getUsername()); + userDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(userDto); + + return dto; + } +} diff --git a/backend/src/main/java/com/openisle/service/InviteService.java b/backend/src/main/java/com/openisle/service/InviteService.java index cd0f895a3..23ca58bd1 100644 --- a/backend/src/main/java/com/openisle/service/InviteService.java +++ b/backend/src/main/java/com/openisle/service/InviteService.java @@ -5,6 +5,7 @@ import com.openisle.model.User; import com.openisle.repository.InviteTokenRepository; import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.Value; import org.springframework.stereotype.Service; import java.time.LocalDate; @@ -18,6 +19,12 @@ public class InviteService { private final JwtService jwtService; private final PointService pointService; + @Value + public class InviteValidateResult { + InviteToken inviteToken; + boolean validate; + } + public String generate(String username) { User inviter = userRepository.findByUsername(username).orElseThrow(); LocalDate today = LocalDate.now(); @@ -35,20 +42,23 @@ public class InviteService { return token; } - public boolean validate(String token) { + public InviteValidateResult validate(String token) { + if (token == null || token.isEmpty()) { + return new InviteValidateResult(null, false); + } try { jwtService.validateAndGetSubjectForInvite(token); } catch (Exception e) { - return false; + return new InviteValidateResult(null, false); } InviteToken invite = inviteTokenRepository.findById(token).orElse(null); - return invite != null && invite.getUsageCount() < 3; + return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3); } - public void consume(String token) { + public void consume(String token, String newUserName) { InviteToken invite = inviteTokenRepository.findById(token).orElseThrow(); invite.setUsageCount(invite.getUsageCount() + 1); inviteTokenRepository.save(invite); - pointService.awardForInvite(invite.getInviter().getUsername()); + pointService.awardForInvite(invite.getInviter().getUsername(), newUserName); } } diff --git a/backend/src/main/java/com/openisle/service/MedalService.java b/backend/src/main/java/com/openisle/service/MedalService.java index daa4acc31..1f43caccd 100644 --- a/backend/src/main/java/com/openisle/service/MedalService.java +++ b/backend/src/main/java/com/openisle/service/MedalService.java @@ -6,6 +6,7 @@ import com.openisle.dto.MedalDto; import com.openisle.dto.PostMedalDto; import com.openisle.dto.SeedUserMedalDto; import com.openisle.dto.PioneerMedalDto; +import com.openisle.dto.FeaturedMedalDto; import com.openisle.model.MedalType; import com.openisle.model.User; import com.openisle.repository.CommentRepository; @@ -74,6 +75,23 @@ public class MedalService { postMedal.setSelected(selected == MedalType.POST); medals.add(postMedal); + FeaturedMedalDto featuredMedal = new FeaturedMedalDto(); + featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png"); + featuredMedal.setTitle("精选作者"); + featuredMedal.setDescription("至少有1篇文章被收录为精选"); + featuredMedal.setType(MedalType.FEATURED); + featuredMedal.setTargetFeaturedCount(1); + if (user != null) { + long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()); + featuredMedal.setCurrentFeaturedCount(count); + featuredMedal.setCompleted(count >= 1); + } else { + featuredMedal.setCurrentFeaturedCount(0); + featuredMedal.setCompleted(false); + } + featuredMedal.setSelected(selected == MedalType.FEATURED); + medals.add(featuredMedal); + ContributorMedalDto contributorMedal = new ContributorMedalDto(); contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"); contributorMedal.setTitle("贡献者"); @@ -141,6 +159,8 @@ public class MedalService { user.setDisplayMedal(MedalType.COMMENT); } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { user.setDisplayMedal(MedalType.POST); + } else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) { + user.setDisplayMedal(MedalType.FEATURED); } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { user.setDisplayMedal(MedalType.CONTRIBUTOR); } else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) { diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java new file mode 100644 index 000000000..f4e1d20cd --- /dev/null +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -0,0 +1,289 @@ +package com.openisle.service; + +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.MessageParticipant; +import com.openisle.model.User; +import com.openisle.repository.MessageConversationRepository; +import com.openisle.repository.MessageParticipantRepository; +import com.openisle.repository.MessageRepository; +import com.openisle.repository.UserRepository; +import com.openisle.dto.ConversationDetailDto; +import com.openisle.dto.ConversationDto; +import com.openisle.dto.MessageDto; +import com.openisle.dto.UserSummaryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MessageService { + + private final MessageRepository messageRepository; + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final UserRepository userRepository; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional + public Message sendMessage(Long senderId, Long recipientId, String content) { + log.info("Attempting to send message from user {} to user {}", senderId, recipientId); + User sender = userRepository.findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + User recipient = userRepository.findById(recipientId) + .orElseThrow(() -> new IllegalArgumentException("Recipient not found")); + + log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername()); + MessageConversation conversation = findOrCreateConversation(sender, recipient); + log.info("Conversation found or created with ID: {}", conversation.getId()); + + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + message = messageRepository.save(message); + log.info("Message saved with ID: {}", message.getId()); + + conversation.setLastMessage(message); + conversationRepository.save(conversation); + log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId()); + + // Broadcast the new message to subscribed clients + MessageDto messageDto = toDto(message); + String conversationDestination = "/topic/conversation/" + conversation.getId(); + messagingTemplate.convertAndSend(conversationDestination, messageDto); + log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination); + + // Also notify the recipient on their personal channel to update the conversation list + String userDestination = "/topic/user/" + recipient.getId() + "/messages"; + messagingTemplate.convertAndSend(userDestination, messageDto); + log.info("Message {} notification sent to destination: {}", message.getId(), userDestination); + + // Notify recipient of new unread count + long unreadCount = getUnreadMessageCount(recipientId); + log.info("Calculating unread count for user {}: {}", recipientId, unreadCount); + + // Send using username instead of user ID for WebSocket routing + String recipientUsername = recipient.getUsername(); + messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount); + log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count", + unreadCount, recipientId, recipientUsername, recipientUsername); + + return message; + } + + @Transactional + public Message sendMessageToConversation(Long senderId, Long conversationId, String content) { + User sender = userRepository.findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + MessageConversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + + // Join the conversation if not already a participant (useful for channels) + participantRepository.findByConversationIdAndUserId(conversationId, senderId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(conversation); + p.setUser(sender); + return participantRepository.save(p); + }); + + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + message = messageRepository.save(message); + + conversation.setLastMessage(message); + conversationRepository.save(conversation); + + MessageDto messageDto = toDto(message); + String conversationDestination = "/topic/conversation/" + conversation.getId(); + messagingTemplate.convertAndSend(conversationDestination, messageDto); + + // Notify all participants except sender for updates + for (MessageParticipant participant : conversation.getParticipants()) { + if (participant.getUser().getId().equals(senderId)) continue; + String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages"; + messagingTemplate.convertAndSend(userDestination, messageDto); + + long unreadCount = getUnreadMessageCount(participant.getUser().getId()); + String username = participant.getUser().getUsername(); + messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount); + + long channelUnread = getUnreadChannelCount(participant.getUser().getId()); + messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread); + } + + return message; + } + + private MessageDto toDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setConversationId(message.getConversation().getId()); + dto.setCreatedAt(message.getCreatedAt()); + + UserSummaryDto userSummaryDto = new UserSummaryDto(); + userSummaryDto.setId(message.getSender().getId()); + userSummaryDto.setUsername(message.getSender().getUsername()); + userSummaryDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(userSummaryDto); + + return dto; + } + + public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) { + User user1 = userRepository.findById(user1Id) + .orElseThrow(() -> new IllegalArgumentException("User1 not found")); + User user2 = userRepository.findById(user2Id) + .orElseThrow(() -> new IllegalArgumentException("User2 not found")); + return findOrCreateConversation(user1, user2); + } + + private MessageConversation findOrCreateConversation(User user1, User user2) { + log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername()); + return conversationRepository.findConversationsByUsers(user1, user2).stream() + .findFirst() + .orElseGet(() -> { + log.info("No existing conversation found. Creating a new one."); + MessageConversation conversation = new MessageConversation(); + conversation = conversationRepository.save(conversation); + log.info("New conversation created with ID: {}", conversation.getId()); + + MessageParticipant participant1 = new MessageParticipant(); + participant1.setConversation(conversation); + participant1.setUser(user1); + participantRepository.save(participant1); + log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId()); + + MessageParticipant participant2 = new MessageParticipant(); + participant2.setConversation(conversation); + participant2.setUser(user2); + participantRepository.save(participant2); + log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId()); + + return conversation; + }); + } + + @Transactional(readOnly = true) + public List getConversations(Long userId) { + List conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId); + return conversations.stream() + .filter(c -> !c.isChannel()) + .map(c -> toDto(c, userId)) + .collect(Collectors.toList()); + } + + private ConversationDto toDto(MessageConversation conversation, Long userId) { + ConversationDto dto = new ConversationDto(); + dto.setId(conversation.getId()); + dto.setChannel(conversation.isChannel()); + dto.setName(conversation.getName()); + dto.setAvatar(conversation.getAvatar()); + dto.setCreatedAt(conversation.getCreatedAt()); + if (conversation.getLastMessage() != null) { + dto.setLastMessage(toDto(conversation.getLastMessage())); + } + dto.setParticipants(conversation.getParticipants().stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList())); + + MessageParticipant self = conversation.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Participant not found in conversation")); + + LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId); + dto.setUnreadCount(unreadCount); + + return dto; + } + + @Transactional + public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) { + markConversationAsRead(conversationId, userId); + + MessageConversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + + Page messagesPage = messageRepository.findByConversationId(conversationId, pageable); + Page messageDtoPage = messagesPage.map(this::toDto); + + List participants = conversation.getParticipants().stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList()); + + ConversationDetailDto detailDto = new ConversationDetailDto(); + detailDto.setId(conversation.getId()); + detailDto.setName(conversation.getName()); + detailDto.setChannel(conversation.isChannel()); + detailDto.setAvatar(conversation.getAvatar()); + detailDto.setParticipants(participants); + detailDto.setMessages(messageDtoPage); + + return detailDto; + } + + @Transactional + public void markConversationAsRead(Long conversationId, Long userId) { + MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId) + .orElseThrow(() -> new IllegalArgumentException("Participant not found")); + participant.setLastReadAt(LocalDateTime.now()); + participantRepository.save(participant); + } + + @Transactional(readOnly = true) + public long getUnreadMessageCount(Long userId) { + List participations = participantRepository.findByUserId(userId); + long totalUnreadCount = 0; + for (MessageParticipant p : participations) { + if (p.getConversation().isChannel()) continue; + LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); + } + return totalUnreadCount; + } + + @Transactional(readOnly = true) + public long getUnreadChannelCount(Long userId) { + List participations = participantRepository.findByUserId(userId); + long unreadChannelCount = 0; + for (MessageParticipant p : participations) { + if (!p.getConversation().isChannel()) continue; + LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); + long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); + if (unread > 0) { + unreadChannelCount++; + } + } + return unreadChannelCount; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/service/PointMallService.java b/backend/src/main/java/com/openisle/service/PointMallService.java index c930ca7f6..0f3965b52 100644 --- a/backend/src/main/java/com/openisle/service/PointMallService.java +++ b/backend/src/main/java/com/openisle/service/PointMallService.java @@ -3,8 +3,11 @@ package com.openisle.service; import com.openisle.exception.FieldException; import com.openisle.exception.NotFoundException; import com.openisle.model.PointGood; +import com.openisle.model.PointHistory; +import com.openisle.model.PointHistoryType; import com.openisle.model.User; import com.openisle.repository.PointGoodRepository; +import com.openisle.repository.PointHistoryRepository; import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -18,6 +21,7 @@ public class PointMallService { private final PointGoodRepository pointGoodRepository; private final UserRepository userRepository; private final NotificationService notificationService; + private final PointHistoryRepository pointHistoryRepository; public List listGoods() { return pointGoodRepository.findAll(); @@ -32,6 +36,13 @@ public class PointMallService { user.setPoint(user.getPoint() - good.getCost()); userRepository.save(user); notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact); + PointHistory history = new PointHistory(); + history.setUser(user); + history.setType(PointHistoryType.REDEEM); + history.setAmount(-good.getCost()); + history.setBalance(user.getPoint()); + history.setCreatedAt(java.time.LocalDateTime.now()); + pointHistoryRepository.save(history); return user.getPoint(); } } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index be46b1fc6..2b5a53060 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -1,7 +1,6 @@ package com.openisle.service; -import com.openisle.model.PointLog; -import com.openisle.model.User; +import com.openisle.model.*; import com.openisle.repository.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,19 +15,28 @@ public class PointService { private final PointLogRepository pointLogRepository; private final PostRepository postRepository; private final CommentRepository commentRepository; + private final PointHistoryRepository pointHistoryRepository; - public int awardForPost(String userName) { + public int awardForPost(String userName, Long postId) { User user = userRepository.findByUsername(userName).orElseThrow(); PointLog log = getTodayLog(user); if (log.getPostCount() > 1) return 0; log.setPostCount(log.getPostCount() + 1); pointLogRepository.save(log); - return addPoint(user, 30); + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(user, 30, PointHistoryType.POST, post, null, null); } - public int awardForInvite(String userName) { + public int awardForInvite(String userName, String inviteeName) { User user = userRepository.findByUsername(userName).orElseThrow(); - return addPoint(user, 500); + User invitee = userRepository.findByUsername(inviteeName).orElseThrow(); + return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee); + } + + public int awardForFeatured(String userName, Long postId) { + User user = userRepository.findByUsername(userName).orElseThrow(); + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null); } private PointLog getTodayLog(User user) { @@ -45,20 +53,41 @@ public class PointService { }); } - private int addPoint(User user, int amount) { + private int addPoint(User user, int amount, PointHistoryType type, + Post post, Comment comment, User fromUser) { + if (pointHistoryRepository.countByUser(user) == 0) { + recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); + } user.setPoint(user.getPoint() + amount); userRepository.save(user); + recordHistory(user, type, amount, post, comment, fromUser); return amount; } + private void recordHistory(User user, PointHistoryType type, int amount, + Post post, Comment comment, User fromUser) { + PointHistory history = new PointHistory(); + history.setUser(user); + history.setType(type); + history.setAmount(amount); + history.setBalance(user.getPoint()); + history.setPost(post); + history.setComment(comment); + history.setFromUser(fromUser); + history.setCreatedAt(java.time.LocalDateTime.now()); + pointHistoryRepository.save(history); + } + // 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数 // 注意需要考虑发帖和回复是同一人的场景 - public int awardForComment(String commenterName, Long postId) { + public int awardForComment(String commenterName, Long postId, Long commentId) { // 标记评论者是否已达到积分奖励上限 boolean isTheRewardCapped = false; // 根据帖子id找到发帖人 - User poster = postRepository.findById(postId).orElseThrow().getAuthor(); + Post post = postRepository.findById(postId).orElseThrow(); + User poster = post.getAuthor(); + Comment comment = commentRepository.findById(commentId).orElseThrow(); // 获取评论者的加分日志 User commenter = userRepository.findByUsername(commenterName).orElseThrow(); @@ -74,15 +103,15 @@ public class PointService { } else { log.setCommentCount(log.getCommentCount() + 1); pointLogRepository.save(log); - return addPoint(commenter, 10); + return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); } } else { - addPoint(poster, 10); + addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter); // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 if (isTheRewardCapped) { return 0; } else { - return addPoint(commenter, 10); + return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); } } } @@ -101,7 +130,8 @@ public class PointService { } // 如果不是同一个,则为发帖人加分 - return addPoint(poster, 10); + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner); } // 考虑点赞者和评论者是同一个的情况 @@ -118,7 +148,17 @@ public class PointService { } // 如果不是同一个,则为发帖人加分 - return addPoint(commenter, 10); + Comment comment = commentRepository.findById(commentId).orElseThrow(); + Post post = comment.getPost(); + return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner); + } + + public java.util.List listHistory(String userName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + if (pointHistoryRepository.countByUser(user) == 0) { + recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); + } + return pointHistoryRepository.findByUserOrderByIdDesc(user); } } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index e9a205fb9..a3038d478 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -67,6 +67,7 @@ public class PostService { private final TaskScheduler taskScheduler; private final EmailSender emailSender; private final ApplicationContext applicationContext; + private final PointService pointService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -89,6 +90,7 @@ public class PostService { TaskScheduler taskScheduler, EmailSender emailSender, ApplicationContext applicationContext, + PointService pointService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -107,6 +109,7 @@ public class PostService { this.taskScheduler = taskScheduler; this.emailSender = emailSender; this.applicationContext = applicationContext; + this.pointService = pointService; this.publishMode = publishMode; } @@ -146,7 +149,10 @@ public class PostService { public Post includeInRss(Long id) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); post.setRssExcluded(false); - return postRepository.save(post); + post = postRepository.save(post); + notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null); + pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId()); + return post; } public Post createPost(String username, @@ -458,6 +464,34 @@ public class PostService { return paginate(sortByPinnedAndCreated(posts), page, pageSize); } + public List listFeaturedPosts(List categoryIds, + List tagIds, + Integer page, + Integer pageSize) { + List posts; + boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); + boolean hasTags = tagIds != null && !tagIds.isEmpty(); + + if (hasCategories && hasTags) { + posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null); + } else if (hasCategories) { + posts = listPostsByCategories(categoryIds, null, null); + } else if (hasTags) { + posts = listPostsByTags(tagIds, null, null); + } else { + posts = listPosts(); + } + + // 仅保留 getRssExcluded 为 0 且不为空 + // 若字段类型是 Boolean(包装类型),0 等价于 false: + posts = posts.stream() + .filter(p -> p.getRssExcluded() != null && !p.getRssExcluded()) + .toList(); + + return paginate(sortByPinnedAndCreated(posts), page, pageSize); + } + + public List listPendingPosts() { return postRepository.findByStatus(PostStatus.PENDING); } @@ -579,7 +613,9 @@ public class PostService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + User author = post.getAuthor(); + boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN; + if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { @@ -596,7 +632,12 @@ public class PostService { future.cancel(false); } } + String title = post.getTitle(); postRepository.delete(post); + if (adminDeleting) { + notificationService.createNotification(author, NotificationType.POST_DELETED, + null, null, null, user, null, title); + } } public java.util.List getPostsByIds(java.util.List ids) { diff --git a/backend/src/test/java/com/openisle/service/MedalServiceTest.java b/backend/src/test/java/com/openisle/service/MedalServiceTest.java index a4a10a56d..a873ea9c6 100644 --- a/backend/src/test/java/com/openisle/service/MedalServiceTest.java +++ b/backend/src/test/java/com/openisle/service/MedalServiceTest.java @@ -27,7 +27,7 @@ class MedalServiceTest { List medals = service.getMedals(null); medals.forEach(m -> assertFalse(m.isCompleted())); - assertEquals(5, medals.size()); + assertEquals(6, medals.size()); } @Test diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index e1dbfd297..6abe97238 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -34,11 +34,12 @@ class PostServiceTest { TaskScheduler taskScheduler = mock(TaskScheduler.class); EmailSender emailSender = mock(EmailSender.class); ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, notifService, subService, commentService, commentRepo, reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT); + imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); when(context.getBean(PostService.class)).thenReturn(service); Post post = new Post(); @@ -61,6 +62,59 @@ class PostServiceTest { verify(postRepo).delete(post); } + @Test + void deletePostByAdminNotifiesAuthor() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + + PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, + notifService, subService, commentService, commentRepo, + reactionRepo, subRepo, notificationRepo, postReadService, + imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); + when(context.getBean(PostService.class)).thenReturn(service); + + Post post = new Post(); + post.setId(1L); + post.setTitle("T"); + post.setContent(""); + User author = new User(); + author.setId(2L); + author.setRole(Role.USER); + post.setAuthor(author); + + User admin = new User(); + admin.setId(1L); + admin.setRole(Role.ADMIN); + + when(postRepo.findById(1L)).thenReturn(Optional.of(post)); + when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin)); + when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); + when(reactionRepo.findByPost(post)).thenReturn(List.of()); + when(subRepo.findByPost(post)).thenReturn(List.of()); + when(notificationRepo.findByPost(post)).thenReturn(List.of()); + + service.deletePost(1L, "admin"); + + verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(), + isNull(), isNull(), eq(admin), isNull(), eq("T")); + } + @Test void createPostRespectsRateLimit() { PostRepository postRepo = mock(PostRepository.class); @@ -80,11 +134,12 @@ class PostServiceTest { TaskScheduler taskScheduler = mock(TaskScheduler.class); EmailSender emailSender = mock(EmailSender.class); ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, notifService, subService, commentService, commentRepo, reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT); + imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); when(context.getBean(PostService.class)).thenReturn(service); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); @@ -113,11 +168,12 @@ class PostServiceTest { TaskScheduler taskScheduler = mock(TaskScheduler.class); EmailSender emailSender = mock(EmailSender.class); ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, notifService, subService, commentService, commentRepo, reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT); + imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); when(context.getBean(PostService.class)).thenReturn(service); User author = new User(); diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index edfad14a8..4f7a3a895 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -18,7 +18,7 @@ --background-color-blur: rgba(255, 255, 255, 0.57); --menu-border-color: lightgray; --normal-border-color: lightgray; - --menu-selected-background-color: rgba(208, 250, 255, 0.659); + --menu-selected-background-color: rgba(228, 228, 228, 0.884); --menu-text-color: black; --scroller-background-color: rgba(130, 175, 180, 0.5); /* --normal-background-color: rgb(241, 241, 241); */ diff --git a/frontend_nuxt/components/AchievementList.vue b/frontend_nuxt/components/AchievementList.vue index a58e4b4a2..ddcceef94 100644 --- a/frontend_nuxt/components/AchievementList.vue +++ b/frontend_nuxt/components/AchievementList.vue @@ -26,6 +26,9 @@ + diff --git a/frontend_nuxt/components/BaseSwitch.vue b/frontend_nuxt/components/BaseSwitch.vue new file mode 100644 index 000000000..f04197c92 --- /dev/null +++ b/frontend_nuxt/components/BaseSwitch.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend_nuxt/components/BaseTimeline.vue b/frontend_nuxt/components/BaseTimeline.vue index 41d6064a2..dac7e55cf 100644 --- a/frontend_nuxt/components/BaseTimeline.vue +++ b/frontend_nuxt/components/BaseTimeline.vue @@ -41,6 +41,12 @@ export default { margin-top: 10px; } +.timeline-item:hover { + background-color: var(--menu-selected-background-color); + transition: background-color 0.2s; + border-radius: 10px; +} + .timeline-icon { position: sticky; top: 0; diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue index f5d87b2eb..58af7d1dd 100644 --- a/frontend_nuxt/components/GlobalPopups.vue +++ b/frontend_nuxt/components/GlobalPopups.vue @@ -7,6 +7,7 @@ @close="closeMilkTeaPopup" /> + { await checkInviteCodeActivity() if (showInviteCodePopup.value) return + await checkMessageFeature() + if (showMessagePopup.value) return + await checkNotificationSetting() if (showNotificationPopup.value) return @@ -50,7 +56,7 @@ onMounted(async () => { }) const checkMilkTeaActivity = async () => { - if (!process.client) return + if (!import.meta.client) return if (localStorage.getItem('milkTeaActivityPopupShown')) return try { const res = await fetch(`${API_BASE_URL}/api/activities`) @@ -68,7 +74,7 @@ const checkMilkTeaActivity = async () => { } const checkInviteCodeActivity = async () => { - if (!process.client) return + if (!import.meta.client) return if (localStorage.getItem('inviteCodeActivityPopupShown')) return try { const res = await fetch(`${API_BASE_URL}/api/activities`) @@ -86,32 +92,42 @@ const checkInviteCodeActivity = async () => { } const closeInviteCodePopup = () => { - if (!process.client) return + if (!import.meta.client) return localStorage.setItem('inviteCodeActivityPopupShown', 'true') showInviteCodePopup.value = false } const closeMilkTeaPopup = () => { - if (!process.client) return + if (!import.meta.client) return localStorage.setItem('milkTeaActivityPopupShown', 'true') showMilkTeaPopup.value = false - checkNotificationSetting() +} + +const checkMessageFeature = async () => { + if (!import.meta.client) return + if (!authState.loggedIn) return + if (localStorage.getItem('messageFeaturePopupShown')) return + showMessagePopup.value = true +} +const closeMessagePopup = () => { + if (!import.meta.client) return + localStorage.setItem('messageFeaturePopupShown', 'true') + showMessagePopup.value = false } const checkNotificationSetting = async () => { - if (!process.client) return + if (!import.meta.client) return if (!authState.loggedIn) return if (localStorage.getItem('notificationSettingPopupShown')) return showNotificationPopup.value = true } const closeNotificationPopup = () => { - if (!process.client) return + if (!import.meta.client) return localStorage.setItem('notificationSettingPopupShown', 'true') showNotificationPopup.value = false - checkNewMedals() } const checkNewMedals = async () => { - if (!process.client) return + if (!import.meta.client) return if (!authState.loggedIn || !authState.userId) return try { const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`) @@ -129,7 +145,7 @@ const checkNewMedals = async () => { } } const closeMedalPopup = () => { - if (!process.client) return + if (!import.meta.client) return const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]')) newMedals.value.forEach((m) => seen.add(m.type)) localStorage.setItem('seenMedals', JSON.stringify([...seen])) diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 56f1b8299..37a824c93 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -6,7 +6,10 @@ - + + +
+ + {{ + unreadMessageCount + }} + +
+
+