Compare commits

..

1 Commits

Author SHA1 Message Date
Tim
901b3f344a Add invite code points activity 2025-08-17 11:37:21 +08:00
148 changed files with 1208 additions and 7007 deletions

View File

@@ -1 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged npx lint-staged

View File

@@ -1,116 +0,0 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
## 如何部署
> 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 即可访问前端页面

View File

@@ -1,18 +1,45 @@
<p align="center"> <p align="center">
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200"> <img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
<br> <br><br>
高效的开源社区前后端平台 高效的开源社区前后端平台
<br><br><br> <br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a>
</p> </p>
## 💡 简介 ## 💡 简介
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。 OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚧 开发 & 部署 ## 🚧 开发
详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file) ### 后端
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` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点 ## ✨ 项目特点

View File

@@ -3,12 +3,6 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
MYSQL_USER=<数据库用户名> MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码> MYSQL_PASSWORD=<数据库密码>
# === JWT ===
JWT_SECRET=<jwt secret>
JWT_REASON_SECRET=<jwt reason secret>
JWT_RESET_SECRET=<jwt reset secret>
JWT_INVITE_SECRET=<jwt invite secret>
JWT_EXPIRATION=2592000000
# === Resend === # === Resend ===
RESEND_API_KEY=<你的resend-api-key> RESEND_API_KEY=<你的resend-api-key>
@@ -36,4 +30,4 @@ OPENAI_API_KEY=<你的openai-api-key>
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key> WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key> WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
# LOG_LEVEL=DEBUG # LOG_LEVEL=DEBUG

View File

@@ -26,10 +26,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
@@ -42,16 +38,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.64.8</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>

View File

@@ -6,7 +6,7 @@ import com.openisle.repository.ActivityRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Component @Component
@@ -29,10 +29,9 @@ public class ActivityInitializer implements CommandLineRunner {
Activity a = new Activity(); Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动"); a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS); a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://img.icons8.com/color/96/gift.png"); a.setIcon("https://icons.veryicon.com/png/o/commerce-shopping/two-color-icon-library/gift-30.png");
a.setContent("使用邀请码注册或邀请好友可获得积分奖励,快来参与吧!"); a.setContent("活动期间,邀请好友注册可获得积分奖励,快来参与吧!");
a.setStartTime(LocalDateTime.now()); a.setEndTime(LocalDateTime.of(2025, 10, 1, 0, 0));
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
activityRepository.save(a); activityRepository.save(a);
} }
} }

View File

@@ -1,32 +0,0 @@
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);
}
}
}

View File

@@ -99,13 +99,11 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable()) http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults()) .cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检
.headers(h -> h.frameOptions(f -> f.sameOrigin())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
@@ -121,8 +119,6 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").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.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
@@ -157,9 +153,8 @@ public class SecurityConfig {
uri.startsWith("/api/search") || uri.startsWith("/api/users") || uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") || uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) { if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); String token = authHeader.substring(7);
@@ -175,8 +170,7 @@ public class SecurityConfig {
response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return; 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.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json"); response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}"); response.getWriter().write("{\"error\": \"Missing token\"}");

View File

@@ -1,110 +0,0 @@
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;
}
});
}
}

View File

@@ -45,14 +45,4 @@ public class AdminPostController {
public PostSummaryDto unpin(@PathVariable Long id) { public PostSummaryDto unpin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.unpinPost(id)); return postMapper.toSummaryDto(postService.unpinPost(id));
} }
@PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.excludeFromRss(id));
}
@PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.includeInRss(id));
}
} }

View File

@@ -29,7 +29,6 @@ public class AuthController {
private final RegisterModeService registerModeService; private final RegisterModeService registerModeService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final InviteService inviteService;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
@@ -46,27 +45,6 @@ public class AuthController {
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
} }
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
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(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
));
} catch (FieldException e) {
return ResponseEntity.badRequest().body(Map.of(
"field", e.getField(),
"error", e.getMessage()
));
}
}
User user = userService.register( User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode()); req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
@@ -80,26 +58,10 @@ public class AuthController {
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) { public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode()); boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
if (ok) { if (ok) {
Optional<User> userOpt = userService.findByUsername(req.getUsername()); return ResponseEntity.ok(Map.of(
if (userOpt.isEmpty()) { "message", "Verified",
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")); "token", jwtService.generateReasonToken(req.getUsername())
} ));
User user = userOpt.get();
if (user.isApproved()) {
return ResponseEntity.ok(Map.of(
"message", "Verified and isApproved",
"reason_code", "VERIFIED_AND_APPROVED",
"token", jwtService.generateToken(req.getUsername())
));
} else {
return ResponseEntity.ok(Map.of(
"message", "Verified",
"reason_code", "VERIFIED",
"token", jwtService.generateReasonToken(req.getUsername())
));
}
} }
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
} }
@@ -144,43 +106,27 @@ public class AuthController {
@PostMapping("/google") @PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) { public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); if (user.isPresent()) {
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
req.getIdToken(),
registerModeService.getRegisterMode(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
if (!result.getUser().isApproved()) { if (!user.get().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid google token", "error", "Invalid google token",
@@ -219,45 +165,28 @@ public class AuthController {
@PostMapping("/github") @PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) { public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); if (user.isPresent()) {
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
if (!result.getUser().isApproved()) { if (!user.get().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
// 已填写注册理由 // 已填写注册理由
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid github code", "error", "Invalid github code",
@@ -267,44 +196,27 @@ public class AuthController {
@PostMapping("/discord") @PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) { public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); if (user.isPresent()) {
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
if (!result.getUser().isApproved()) { if (!user.get().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid discord code", "error", "Invalid discord code",
@@ -314,45 +226,31 @@ public class AuthController {
@PostMapping("/twitter") @PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) { public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); Optional<User> user = twitterAuthService.authenticate(
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
req.getCode(), req.getCode(),
req.getCodeVerifier(), req.getCodeVerifier(),
registerModeService.getRegisterMode(), registerModeService.getRegisterMode(),
req.getRedirectUri(), req.getRedirectUri());
viaInvite); if (user.isPresent()) {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
if (!result.getUser().isApproved()) { if (!user.get().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername()) "token", jwtService.generateReasonToken(user.get().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid twitter code", "error", "Invalid twitter code",

View File

@@ -1,42 +0,0 @@
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<ChannelDto> 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));
}
}

View File

@@ -47,7 +47,7 @@ public class CommentController {
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment); CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName())); dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId())); dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
log.debug("createComment succeeded for comment {}", comment.getId()); log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }

View File

@@ -1,23 +0,0 @@
package com.openisle.controller;
import com.openisle.service.InviteService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
}

View File

@@ -1,137 +0,0 @@
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.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<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
}
@GetMapping("/conversations/{conversationId}")
public ResponseEntity<ConversationDetailDto> 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<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
}
@PostMapping("/conversations")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
@GetMapping("/unread-count")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
}
// A simple request DTO
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
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;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}

View File

@@ -23,19 +23,9 @@ public class NotificationController {
private final NotificationMapper notificationMapper; private final NotificationMapper notificationMapper;
@GetMapping @GetMapping
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page, public List<NotificationDto> list(@RequestParam(value = "read", required = false) Boolean read,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) { Authentication auth) {
return notificationService.listNotifications(auth.getName(), null, page, size).stream() return notificationService.listNotifications(auth.getName(), read).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread")
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), false, page, size).stream()
.map(notificationMapper::toDto) .map(notificationMapper::toDto)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -1,28 +0,0 @@
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<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
}

View File

@@ -45,7 +45,7 @@ public class PostController {
draftService.deleteDraft(auth.getName()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName())); dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId())); dto.setPointReward(pointService.awardForPost(auth.getName()));
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@@ -62,16 +62,6 @@ public class PostController {
postService.deletePost(id, auth.getName()); postService.deletePost(id, auth.getName());
} }
@PostMapping("/{id}/close")
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/reopen")
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) { public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
String viewer = auth != null ? auth.getName() : null; String viewer = auth != null ? auth.getName() : null;
@@ -171,27 +161,4 @@ public class PostController {
return postService.listPostsByLatestReply(ids, tids, page, pageSize) return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }
@GetMapping("/featured")
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> 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());
}
} }

View File

@@ -57,17 +57,4 @@ public class ReactionController {
pointService.awardForReactionOfComment(auth.getName(), commentId); pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@PostMapping("/messages/{messageId}/reactions")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
} }

View File

@@ -1,352 +0,0 @@
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;
import org.jsoup.nodes.Element;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
// flexmarkMarkdown -> HTML
private static final Parser MD_PARSER;
private static final HtmlRenderer MD_RENDERER;
static {
MutableDataSet opts = new MutableDataSet();
opts.set(Parser.EXTENSIONS, Arrays.asList(
TablesExtension.create(),
AutolinkExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create()
));
// 允许内联 HTML下游再做 sanitize
opts.set(Parser.HTML_BLOCK_PARSER, true);
MD_PARSER = Parser.builder(opts).build();
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
for (Post p : posts) {
String link = base + "/posts/" + p.getId();
// 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
// 2) Sanitize白名单增强
String safeHtml = sanitizeHtml(html);
// 3) 绝对化 href/src + 强制 rel/target
String absHtml = absolutifyHtml(safeHtml, base);
// 4) 纯文本摘要(用于 <description>
String plain = textSummary(absHtml, 180);
// 5) enclosure首图已绝对化
String enclosure = firstImage(p.getContent());
if (enclosure == null) {
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
enclosure = firstImage(absHtml);
}
if (enclosure != null) {
enclosure = absolutifyUrl(enclosure, base);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> 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("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML正文 + 优雅的 Markdown 区块(已转 HTML
sb.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
.append(getMimeType(enclosure)).append("\" />");
}
sb.append("</item>");
}
sb.append("</channel></rss>");
return sb.toString();
}
/* ===================== Markdown → HTML ===================== */
private static String renderMarkdown(String md) {
if (md == null || md.isEmpty()) return "";
return MD_RENDERER.render(MD_PARSER.parse(md));
}
/* ===================== Sanitize & 绝对化 ===================== */
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
.addTags(
"pre","code","figure","figcaption","picture","source",
"table","thead","tbody","tr","th","td",
"h1","h2","h3","h4","h5","h6",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
private static String absolutifyHtml(String html, String baseUrl) {
if (html == null || html.isEmpty()) return "";
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
// a[href]
for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
private static String absolutifyUrl(String url, String baseUrl) {
if (url == null || url.isEmpty()) return url;
String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
private static String absolutifySrcset(String srcset, String baseUrl) {
if (srcset == null || srcset.isEmpty()) return srcset;
String[] parts = srcset.split(",");
List<String> out = new ArrayList<>(parts.length);
for (String part : parts) {
String p = part.trim();
if (p.isEmpty()) continue;
String[] seg = p.split("\\s+");
String url = seg[0];
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
/* ===================== 摘要 & enclosure ===================== */
private static String textSummary(String html, int maxLen) {
if (html == null) return "";
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "";
}
private String firstImage(String content) {
if (content == null) return null;
Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> 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) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
}
private static String cdata(String s) {
if (s == null) return "<![CDATA[]]>";
// 防止出现 "]]>" 终止标记破坏 CDATA
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
private static void elem(StringBuilder sb, String name, String value) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
}
private static String escapeXml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&apos;");
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String ensureTrailingSlash(String s) {
if (s == null || s.isEmpty()) return "/";
return s.endsWith("/") ? s : s + "/";
}
private static String nullSafe(String s) { return s == null ? "" : s; }
}

View File

@@ -1,17 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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<UserSummaryDto> participants;
private Page<MessageDto> messages;
}

View File

@@ -1,20 +0,0 @@
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<UserSummaryDto> participants;
private LocalDateTime createdAt;
private long unreadCount;
}

View File

@@ -1,8 +0,0 @@
package com.openisle.dto;
import lombok.Data;
@Data
public class CreateConversationRequest {
private Long recipientId;
}

View File

@@ -1,12 +0,0 @@
package com.openisle.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreateConversationResponse {
private Long conversationId;
}

View File

@@ -7,5 +7,4 @@ import lombok.Data;
public class DiscordLoginRequest { public class DiscordLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String inviteToken;
} }

View File

@@ -1,12 +0,0 @@
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;
}

View File

@@ -7,5 +7,4 @@ import lombok.Data;
public class GithubLoginRequest { public class GithubLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String inviteToken;
} }

View File

@@ -6,5 +6,4 @@ import lombok.Data;
@Data @Data
public class GoogleLoginRequest { public class GoogleLoginRequest {
private String idToken; private String idToken;
private String inviteToken;
} }

View File

@@ -1,16 +0,0 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class MessageDto {
private Long id;
private String content;
private UserSummaryDto sender;
private Long conversationId;
private LocalDateTime createdAt;
private MessageDto replyTo;
private List<ReactionDto> reactions;
}

View File

@@ -1,23 +0,0 @@
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;
}

View File

@@ -31,7 +31,5 @@ public class PostSummaryDto {
private int pointReward; private int pointReward;
private PostType type; private PostType type;
private LotteryDto lottery; private LotteryDto lottery;
private boolean rssExcluded;
private boolean closed;
} }

View File

@@ -4,7 +4,7 @@ import com.openisle.model.ReactionType;
import lombok.Data; import lombok.Data;
/** /**
* DTO representing a reaction on a post, comment or message. * DTO representing a reaction on a post or comment.
*/ */
@Data @Data
public class ReactionDto { public class ReactionDto {
@@ -13,7 +13,6 @@ public class ReactionDto {
private String user; private String user;
private Long postId; private Long postId;
private Long commentId; private Long commentId;
private Long messageId;
private int reward; private int reward;
} }

View File

@@ -9,5 +9,4 @@ public class RegisterRequest {
private String email; private String email;
private String password; private String password;
private String captcha; private String captcha;
private String inviteToken;
} }

View File

@@ -8,5 +8,4 @@ public class TwitterLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String codeVerifier; private String codeVerifier;
private String inviteToken;
} }

View File

@@ -1,10 +0,0 @@
package com.openisle.dto;
import lombok.Data;
@Data
public class UserSummaryDto {
private Long id;
private String username;
private String avatar;
}

View File

@@ -1,34 +0,0 @@
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;
}
}

View File

@@ -63,8 +63,6 @@ public class PostMapper {
dto.setCommentCount(commentService.countComments(post.getId())); dto.setCommentCount(commentService.countComments(post.getId()));
dto.setStatus(post.getStatus()); dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt()); dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId()) List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
.stream() .stream()

View File

@@ -19,9 +19,6 @@ public class ReactionMapper {
if (reaction.getComment() != null) { if (reaction.getComment() != null) {
dto.setCommentId(reaction.getComment().getId()); dto.setCommentId(reaction.getComment().getId());
} }
if (reaction.getMessage() != null) {
dto.setMessageId(reaction.getMessage().getId());
}
dto.setReward(0); dto.setReward(0);
return dto; return dto;
} }

View File

@@ -1,23 +0,0 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
/**
* Invite token entity tracking usage counts.
*/
@Data
@Entity
public class InviteToken {
@Id
private String token;
@ManyToOne
private User inviter;
private LocalDate createdDate;
private int usageCount;
}

View File

@@ -3,7 +3,6 @@ package com.openisle.model;
public enum MedalType { public enum MedalType {
COMMENT, COMMENT,
POST, POST,
FEATURED,
CONTRIBUTOR, CONTRIBUTOR,
SEED, SEED,
PIONEER PIONEER

View File

@@ -1,39 +0,0 @@
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;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reply_to_id")
private Message replyTo;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@@ -1,48 +0,0 @@
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<MessageParticipant> participants = new HashSet<>();
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Message> messages = new HashSet<>();
}

View File

@@ -1,30 +0,0 @@
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;
}

View File

@@ -14,8 +14,6 @@ public enum NotificationType {
POST_REVIEW_REQUEST, POST_REVIEW_REQUEST,
/** Your post under review was approved or rejected */ /** Your post under review was approved or rejected */
POST_REVIEWED, POST_REVIEWED,
/** An administrator deleted your post */
POST_DELETED,
/** A subscribed post received a new comment */ /** A subscribed post received a new comment */
POST_UPDATED, POST_UPDATED,
/** Someone subscribed to your post */ /** Someone subscribed to your post */
@@ -40,8 +38,6 @@ public enum NotificationType {
LOTTERY_WIN, LOTTERY_WIN,
/** Your lottery post was drawn */ /** Your lottery post was drawn */
LOTTERY_DRAW, LOTTERY_DRAW,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */ /** You were mentioned in a post or comment */
MENTION MENTION
} }

View File

@@ -1,49 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
package com.openisle.model;
public enum PointHistoryType {
POST,
COMMENT,
POST_LIKED,
COMMENT_LIKED,
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM
}

View File

@@ -64,12 +64,7 @@ public class Post {
@Column(nullable = false) @Column(nullable = false)
private PostType type = PostType.NORMAL; private PostType type = PostType.NORMAL;
@Column(nullable = false)
private boolean closed = false;
@Column @Column
private LocalDateTime pinnedAt; private LocalDateTime pinnedAt;
@Column(nullable = true)
private Boolean rssExcluded = true;
} }

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
/** /**
* Reaction entity representing a user's reaction to a post, comment or message. * Reaction entity representing a user's reaction to a post or comment.
*/ */
@Entity @Entity
@Getter @Getter
@@ -16,8 +16,7 @@ import org.hibernate.annotations.CreationTimestamp;
@Table(name = "reactions", @Table(name = "reactions",
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}), @UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}), @UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
@UniqueConstraint(columnNames = {"user_id", "message_id", "type"})
}) })
public class Reaction { public class Reaction {
@Id @Id
@@ -40,10 +39,6 @@ public class Reaction {
@JoinColumn(name = "comment_id") @JoinColumn(name = "comment_id")
private Comment comment; private Comment comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "message_id")
private Message message;
@CreationTimestamp @CreationTimestamp
@Column(nullable = false, updatable = false, @Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -6,9 +6,7 @@ package com.openisle.model;
public enum ReactionType { public enum ReactionType {
LIKE, LIKE,
DISLIKE, DISLIKE,
SMILE,
RECOMMEND, RECOMMEND,
CONGRATULATIONS,
ANGRY, ANGRY,
FLUSHED, FLUSHED,
STAR_STRUCK, STAR_STRUCK,
@@ -28,5 +26,5 @@ public enum ReactionType {
CHINA, CHINA,
USA, USA,
JAPAN, JAPAN,
KOREA, KOREA
} }

View File

@@ -1,12 +0,0 @@
package com.openisle.repository;
import com.openisle.model.InviteToken;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
}

View File

@@ -1,34 +0,0 @@
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<MessageConversation, Long> {
@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<MessageConversation> 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<MessageConversation> findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
List<MessageConversation> findByChannelTrue();
long countByChannelTrue();
}

View File

@@ -1,14 +0,0 @@
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<MessageParticipant, Long> {
Optional<MessageParticipant> findByConversationIdAndUserId(Long conversationId, Long userId);
List<MessageParticipant> findByUserId(Long userId);
}

View File

@@ -1,21 +0,0 @@
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<Message, Long> {
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
Page<Message> findByConversationId(Long conversationId, Pageable pageable);
long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt);
// 只计算不是指定用户发送的消息(即别人发给当前用户的消息)
long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId);
}

View File

@@ -6,8 +6,6 @@ import com.openisle.model.Post;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
@@ -15,12 +13,7 @@ import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> { public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserOrderByCreatedAtDesc(User user); List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read);
Page<Notification> findByUserOrderByCreatedAtDesc(User user, Pageable pageable);
Page<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable);
Page<Notification> findByUserAndTypeNotInOrderByCreatedAtDesc(User user, java.util.Collection<NotificationType> types, Pageable pageable);
Page<Notification> findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(User user, boolean read, java.util.Collection<NotificationType> types, Pageable pageable);
long countByUserAndRead(User user, boolean read); long countByUserAndRead(User user, boolean read);
long countByUserAndReadAndTypeNotIn(User user, boolean read, java.util.Collection<NotificationType> types);
List<Notification> findByPost(Post post); List<Notification> findByPost(Post post);
List<Notification> findByComment(Comment comment); List<Notification> findByComment(Comment comment);

View File

@@ -1,12 +0,0 @@
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<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
long countByUser(User user);
}

View File

@@ -97,8 +97,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countDistinctByTags_Id(Long tagId); 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") @Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds); List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
@@ -108,6 +106,4 @@ public interface PostRepository extends JpaRepository<Post, Long> {
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start, java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end); @Param("end") LocalDateTime end);
List<Post> findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
} }

View File

@@ -1,7 +1,6 @@
package com.openisle.repository; package com.openisle.repository;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.Message;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.Reaction; import com.openisle.model.Reaction;
import com.openisle.model.User; import com.openisle.model.User;
@@ -16,10 +15,8 @@ import java.util.Optional;
public interface ReactionRepository extends JpaRepository<Reaction, Long> { public interface ReactionRepository extends JpaRepository<Reaction, Long> {
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type); Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type); Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type);
List<Reaction> findByPost(Post post); List<Reaction> findByPost(Post post);
List<Reaction> findByComment(Comment comment); List<Reaction> findByComment(Comment comment);
List<Reaction> findByMessage(Message message);
@Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC") @Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC")
List<Long> findTopPostIds(@Param("username") String username, Pageable pageable); List<Long> findTopPostIds(@Param("username") String username, Pageable pageable);

View File

@@ -1,12 +0,0 @@
package com.openisle.service;
import com.openisle.model.User;
import lombok.Value;
/** Result for OAuth authentication indicating whether a new user was created. */
@Value
public class AuthResult {
User user;
boolean newUser;
}

View File

@@ -1,98 +0,0 @@
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<ChannelDto> listChannels(Long userId) {
List<MessageConversation> 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;
}
}

View File

@@ -52,9 +52,6 @@ public class CommentService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment(); Comment comment = new Comment();
comment.setAuthor(author); comment.setAuthor(author);
comment.setPost(post); comment.setPost(post);
@@ -97,9 +94,6 @@ public class CommentService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment parent = commentRepository.findById(parentId) Comment parent = commentRepository.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found")); .orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (parent.getPost().isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment(); Comment comment = new Comment();
comment.setAuthor(author); comment.setAuthor(author);
comment.setPost(parent.getPost()); comment.setPost(parent.getPost());

View File

@@ -26,7 +26,7 @@ public class DiscordAuthService {
@Value("${discord.client-secret:}") @Value("${discord.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
try { try {
String tokenUrl = "https://discord.com/api/oauth2/token"; String tokenUrl = "https://discord.com/api/oauth2/token";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@@ -67,13 +67,13 @@ public class DiscordAuthService {
if (email == null) { if (email == null) {
email = (username != null ? username : id) + "@users.noreply.discord.com"; email = (username != null ? username : id) + "@users.noreply.discord.com";
} }
return Optional.of(processUser(email, username, avatar, mode, viaInvite)); return Optional.of(processUser(email, username, avatar, mode));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -82,7 +82,7 @@ public class DiscordAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return new AuthResult(user, false); return user;
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -96,12 +96,12 @@ public class DiscordAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
} }
return new AuthResult(userRepository.save(user), true); return userRepository.save(user);
} }
} }

View File

@@ -30,7 +30,7 @@ public class GithubAuthService {
@Value("${github.client-secret:}") @Value("${github.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
try { try {
String tokenUrl = "https://github.com/login/oauth/access_token"; String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@@ -86,13 +86,13 @@ public class GithubAuthService {
if (email == null) { if (email == null) {
email = username + "@users.noreply.github.com"; email = username + "@users.noreply.github.com";
} }
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite)); return Optional.of(processUser(email, username, avatarUrl, mode));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -101,7 +101,7 @@ public class GithubAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return new AuthResult(user, false); return user;
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -115,12 +115,12 @@ public class GithubAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar(avatarGenerator.generate(finalUsername)); user.setAvatar(avatarGenerator.generate(finalUsername));
} }
return new AuthResult(userRepository.save(user), true); return userRepository.save(user);
} }
} }

View File

@@ -25,7 +25,7 @@ public class GoogleAuthService {
@Value("${google.client-id:}") @Value("${google.client-id:}")
private String clientId; private String clientId;
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) { public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
.setAudience(Collections.singletonList(clientId)) .setAudience(Collections.singletonList(clientId))
.build(); .build();
@@ -38,13 +38,13 @@ public class GoogleAuthService {
String email = payload.getEmail(); String email = payload.getEmail();
String name = (String) payload.get("name"); String name = (String) payload.get("name");
String picture = (String) payload.get("picture"); String picture = (String) payload.get("picture");
return Optional.of(processUser(email, name, picture, mode, viaInvite)); return Optional.of(processUser(email, name, picture, mode));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -53,7 +53,8 @@ public class GoogleAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return new AuthResult(user, false);
return user;
} }
User user = new User(); User user = new User();
String baseUsername = email.split("@")[0]; String baseUsername = email.split("@")[0];
@@ -67,12 +68,12 @@ public class GoogleAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar(avatarGenerator.generate(username)); user.setAvatar(avatarGenerator.generate(username));
} }
return new AuthResult(userRepository.save(user), true); return userRepository.save(user);
} }
} }

View File

@@ -1,64 +0,0 @@
package com.openisle.service;
import com.openisle.model.InviteToken;
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;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InviteService {
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
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();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
}
String token = jwtService.generateInviteToken(username);
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
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(), newUserName);
}
}

View File

@@ -24,9 +24,6 @@ public class JwtService {
@Value("${app.jwt.reset-secret}") @Value("${app.jwt.reset-secret}")
private String resetSecret; private String resetSecret;
@Value("${app.jwt.invite-secret}")
private String inviteSecret;
@Value("${app.jwt.expiration}") @Value("${app.jwt.expiration}")
private long expiration; private long expiration;
@@ -73,17 +70,6 @@ public class JwtService {
.compact(); .compact();
} }
public String generateInviteToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(inviteSecret))
.compact();
}
public String validateAndGetSubject(String token) { public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder() Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(secret)) .setSigningKey(getSigningKeyForSecret(secret))
@@ -110,13 +96,4 @@ public class JwtService {
.getBody(); .getBody();
return claims.getSubject(); return claims.getSubject();
} }
public String validateAndGetSubjectForInvite(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(inviteSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
} }

View File

@@ -6,7 +6,6 @@ import com.openisle.dto.MedalDto;
import com.openisle.dto.PostMedalDto; import com.openisle.dto.PostMedalDto;
import com.openisle.dto.SeedUserMedalDto; import com.openisle.dto.SeedUserMedalDto;
import com.openisle.dto.PioneerMedalDto; import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.model.MedalType; import com.openisle.model.MedalType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.repository.CommentRepository; import com.openisle.repository.CommentRepository;
@@ -75,23 +74,6 @@ public class MedalService {
postMedal.setSelected(selected == MedalType.POST); postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal); 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(); ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"); contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
contributorMedal.setTitle("贡献者"); contributorMedal.setTitle("贡献者");
@@ -159,8 +141,6 @@ public class MedalService {
user.setDisplayMedal(MedalType.COMMENT); user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST); user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR); user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) { } else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {

View File

@@ -1,324 +0,0 @@
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.model.Reaction;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
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;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
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);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
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, Long replyToId) {
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);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
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;
}
public 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);
if (message.getReplyTo() != null) {
Message reply = message.getReplyTo();
MessageDto replyDto = new MessageDto();
replyDto.setId(reply.getId());
replyDto.setContent(reply.getContent());
UserSummaryDto replySender = new UserSummaryDto();
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
java.util.List<ReactionDto> reactionDtos = reactions.stream()
.map(reactionMapper::toDto)
.collect(Collectors.toList());
dto.setReactions(reactionDtos);
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<ConversationDto> getConversations(Long userId) {
List<MessageConversation> 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<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
List<UserSummaryDto> 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<MessageParticipant> 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<MessageParticipant> 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;
}
}

View File

@@ -23,6 +23,7 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/** Service for creating and retrieving notifications. */ /** Service for creating and retrieving notifications. */
@Service @Service
@@ -179,26 +180,17 @@ public class NotificationService {
userRepository.save(user); userRepository.save(user);
} }
public List<Notification> listNotifications(String username, Boolean read, int page, int size) { public List<Notification> listNotifications(String username, Boolean read) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes(); Set<NotificationType> disabled = user.getDisabledNotificationTypes();
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size); List<Notification> list;
org.springframework.data.domain.Page<Notification> result;
if (read == null) { if (read == null) {
if (disabled.isEmpty()) { list = notificationRepository.findByUserOrderByCreatedAtDesc(user);
result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable);
} else {
result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable);
}
} else { } else {
if (disabled.isEmpty()) { list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read);
result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable);
} else {
result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable);
}
} }
return result.getContent(); return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList());
} }
public void markRead(String username, List<Long> ids) { public void markRead(String username, List<Long> ids) {
@@ -217,10 +209,8 @@ public class NotificationService {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes(); Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (disabled.isEmpty()) { return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, false).stream()
return notificationRepository.countByUserAndRead(user, false); .filter(n -> !disabled.contains(n.getType())).count();
}
return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled);
} }
public void notifyMentions(String content, User fromUser, Post post, Comment comment) { public void notifyMentions(String content, User fromUser, Post post, Comment comment) {

View File

@@ -3,11 +3,8 @@ package com.openisle.service;
import com.openisle.exception.FieldException; import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException; import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood; import com.openisle.model.PointGood;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository; import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -21,7 +18,6 @@ public class PointMallService {
private final PointGoodRepository pointGoodRepository; private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public List<PointGood> listGoods() { public List<PointGood> listGoods() {
return pointGoodRepository.findAll(); return pointGoodRepository.findAll();
@@ -36,13 +32,6 @@ public class PointMallService {
user.setPoint(user.getPoint() - good.getCost()); user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user); userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact); 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(); return user.getPoint();
} }
} }

View File

@@ -1,6 +1,7 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.model.*; import com.openisle.model.PointLog;
import com.openisle.model.User;
import com.openisle.repository.*; import com.openisle.repository.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,28 +16,14 @@ public class PointService {
private final PointLogRepository pointLogRepository; private final PointLogRepository pointLogRepository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
public int awardForPost(String userName, Long postId) { public int awardForPost(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow(); User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user); PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0; if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1); log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log); pointLogRepository.save(log);
Post post = postRepository.findById(postId).orElseThrow(); return addPoint(user, 30);
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
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) { private PointLog getTodayLog(User user) {
@@ -53,41 +40,20 @@ public class PointService {
}); });
} }
private int addPoint(User user, int amount, PointHistoryType type, private int addPoint(User user, int amount) {
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); user.setPoint(user.getPoint() + amount);
userRepository.save(user); userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount; 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, Long commentId) { public int awardForComment(String commenterName, Long postId) {
// 标记评论者是否已达到积分奖励上限 // 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false; boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人 // 根据帖子id找到发帖人
Post post = postRepository.findById(postId).orElseThrow(); User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志 // 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow(); User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -103,15 +69,15 @@ public class PointService {
} else { } else {
log.setCommentCount(log.getCommentCount() + 1); log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log); pointLogRepository.save(log);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); return addPoint(commenter, 10);
} }
} else { } else {
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter); addPoint(poster, 10);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) { if (isTheRewardCapped) {
return 0; return 0;
} else { } else {
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); return addPoint(commenter, 10);
} }
} }
} }
@@ -130,8 +96,7 @@ public class PointService {
} }
// 如果不是同一个,则为发帖人加分 // 如果不是同一个,则为发帖人加分
Post post = postRepository.findById(postId).orElseThrow(); return addPoint(poster, 10);
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
} }
// 考虑点赞者和评论者是同一个的情况 // 考虑点赞者和评论者是同一个的情况
@@ -148,17 +113,7 @@ public class PointService {
} }
// 如果不是同一个,则为发帖人加分 // 如果不是同一个,则为发帖人加分
Comment comment = commentRepository.findById(commentId).orElseThrow(); return addPoint(commenter, 10);
Post post = comment.getPost();
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public java.util.List<PointHistory> 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);
} }
} }

View File

@@ -67,7 +67,6 @@ public class PostService {
private final TaskScheduler taskScheduler; private final TaskScheduler taskScheduler;
private final EmailSender emailSender; private final EmailSender emailSender;
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final PointService pointService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
@@ -90,7 +89,6 @@ public class PostService {
TaskScheduler taskScheduler, TaskScheduler taskScheduler,
EmailSender emailSender, EmailSender emailSender,
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -109,7 +107,6 @@ public class PostService {
this.taskScheduler = taskScheduler; this.taskScheduler = taskScheduler;
this.emailSender = emailSender; this.emailSender = emailSender;
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.pointService = pointService;
this.publishMode = publishMode; this.publishMode = publishMode;
} }
@@ -135,26 +132,6 @@ public class PostService {
this.publishMode = publishMode; this.publishMode = publishMode;
} }
public List<Post> listLatestRssPosts(int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt"));
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
}
public Post excludeFromRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(true);
return postRepository.save(post);
}
public Post includeInRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(false);
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, public Post createPost(String username,
Long categoryId, Long categoryId,
String title, String title,
@@ -464,34 +441,6 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize); return paginate(sortByPinnedAndCreated(posts), page, pageSize);
} }
public List<Post> listFeaturedPosts(List<Long> categoryIds,
List<Long> tagIds,
Integer page,
Integer pageSize) {
List<Post> 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<Post> listPendingPosts() { public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING); return postRepository.findByStatus(PostStatus.PENDING);
} }
@@ -546,30 +495,6 @@ public class PostService {
return postRepository.save(post); return postRepository.save(post);
} }
public Post closePost(Long id, String username) {
Post post = postRepository.findById(id)
.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) {
throw new IllegalArgumentException("Unauthorized");
}
post.setClosed(true);
return postRepository.save(post);
}
public Post reopenPost(Long id, String username) {
Post post = postRepository.findById(id)
.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) {
throw new IllegalArgumentException("Unauthorized");
}
post.setClosed(false);
return postRepository.save(post);
}
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public Post updatePost(Long id, public Post updatePost(Long id,
String username, String username,
@@ -613,9 +538,7 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
User author = post.getAuthor(); if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
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"); throw new IllegalArgumentException("Unauthorized");
} }
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -632,12 +555,7 @@ public class PostService {
future.cancel(false); future.cancel(false);
} }
} }
String title = post.getTitle();
postRepository.delete(post); postRepository.delete(post);
if (adminDeleting) {
notificationService.createNotification(author, NotificationType.POST_DELETED,
null, null, null, user, null, title);
}
} }
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) { public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {

View File

@@ -6,12 +6,10 @@ import com.openisle.model.Reaction;
import com.openisle.model.ReactionType; import com.openisle.model.ReactionType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import com.openisle.model.Message;
import com.openisle.repository.CommentRepository; import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.service.NotificationService; import com.openisle.service.NotificationService;
import com.openisle.service.EmailSender; import com.openisle.service.EmailSender;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -26,7 +24,6 @@ public class ReactionService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
private final EmailSender emailSender; private final EmailSender emailSender;
@@ -80,26 +77,6 @@ public class ReactionService {
return reaction; return reaction;
} }
@Transactional
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndMessageAndType(user, message, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
return reaction;
}
public java.util.List<Reaction> getReactionsForPost(Long postId) { public java.util.List<Reaction> getReactionsForPost(Long postId) {
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -33,12 +33,11 @@ public class TwitterAuthService {
@Value("${twitter.client-secret:}") @Value("${twitter.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<AuthResult> authenticate( public Optional<User> authenticate(
String code, String code,
String codeVerifier, String codeVerifier,
RegisterMode mode, RegisterMode mode,
String redirectUri, String redirectUri) {
boolean viaInvite) {
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier); logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
@@ -107,10 +106,10 @@ public class TwitterAuthService {
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email // Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com"; String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email); logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode, viaInvite)); return Optional.of(processUser(email, username, avatar, mode));
} }
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -120,7 +119,7 @@ public class TwitterAuthService {
userRepository.save(user); userRepository.save(user);
} }
logger.debug("Existing user {} authenticated", user.getUsername()); logger.debug("Existing user {} authenticated", user.getUsername());
return new AuthResult(user, false); return user;
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -134,13 +133,13 @@ public class TwitterAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
} }
logger.debug("Creating new user {}", finalUsername); logger.debug("Creating new user {}", finalUsername);
return new AuthResult(userRepository.save(user), true); return userRepository.save(user);
} }
} }

View File

@@ -74,13 +74,6 @@ public class UserService {
return userRepository.save(user); return userRepository.save(user);
} }
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
return userRepository.save(user);
}
private String genCode() { private String genCode() {
return String.format("%06d", new Random().nextInt(1000000)); return String.format("%06d", new Random().nextInt(1000000));
} }

View File

@@ -10,7 +10,6 @@ spring.jpa.hibernate.ddl-auto=update
app.jwt.secret=${JWT_SECRET:jwt_sec} app.jwt.secret=${JWT_SECRET:jwt_sec}
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec} app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec} app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
# 30 days # 30 days
app.jwt.expiration=${JWT_EXPIRATION:2592000000} app.jwt.expiration=${JWT_EXPIRATION:2592000000}
# Password strength: LOW, MEDIUM or HIGH # Password strength: LOW, MEDIUM or HIGH

View File

@@ -1 +0,0 @@
ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -45,7 +45,7 @@ class NotificationControllerTest {
p.setId(2L); p.setId(2L);
n.setPost(p); n.setPost(p);
n.setCreatedAt(LocalDateTime.now()); n.setCreatedAt(LocalDateTime.now());
when(notificationService.listNotifications("alice", null, 0, 30)) when(notificationService.listNotifications("alice", null))
.thenReturn(List.of(n)); .thenReturn(List.of(n));
NotificationDto dto = new NotificationDto(); NotificationDto dto = new NotificationDto();
@@ -62,24 +62,6 @@ class NotificationControllerTest {
.andExpect(jsonPath("$[0].post.id").value(2)); .andExpect(jsonPath("$[0].post.id").value(2));
} }
@Test
void listUnreadNotifications() throws Exception {
Notification n = new Notification();
n.setId(5L);
n.setType(NotificationType.POST_VIEWED);
when(notificationService.listNotifications("alice", false, 0, 30))
.thenReturn(List.of(n));
NotificationDto dto = new NotificationDto();
dto.setId(5L);
when(notificationMapper.toDto(n)).thenReturn(dto);
mockMvc.perform(get("/api/notifications/unread")
.principal(new UsernamePasswordAuthenticationToken("alice","p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(5));
}
@Test @Test
void markReadEndpoint() throws Exception { void markReadEndpoint() throws Exception {
mockMvc.perform(post("/api/notifications/read") mockMvc.perform(post("/api/notifications/read")

View File

@@ -5,7 +5,6 @@ import com.openisle.model.Post;
import com.openisle.model.Reaction; import com.openisle.model.Reaction;
import com.openisle.model.ReactionType; import com.openisle.model.ReactionType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.Message;
import com.openisle.service.ReactionService; import com.openisle.service.ReactionService;
import com.openisle.service.LevelService; import com.openisle.service.LevelService;
import com.openisle.mapper.ReactionMapper; import com.openisle.mapper.ReactionMapper;
@@ -79,27 +78,6 @@ class ReactionControllerTest {
.andExpect(jsonPath("$.commentId").value(2)); .andExpect(jsonPath("$.commentId").value(2));
} }
@Test
void reactToMessage() throws Exception {
User user = new User();
user.setUsername("u3");
Message message = new Message();
message.setId(3L);
Reaction reaction = new Reaction();
reaction.setId(3L);
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(ReactionType.LIKE);
Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction);
mockMvc.perform(post("/api/messages/3/reactions")
.contentType("application/json")
.content("{\"type\":\"LIKE\"}")
.principal(new UsernamePasswordAuthenticationToken("u3", "p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.messageId").value(3));
}
@Test @Test
void listReactionTypes() throws Exception { void listReactionTypes() throws Exception {
mockMvc.perform(get("/api/reaction-types")) mockMvc.perform(get("/api/reaction-types"))

View File

@@ -27,7 +27,7 @@ class MedalServiceTest {
List<MedalDto> medals = service.getMedals(null); List<MedalDto> medals = service.getMedals(null);
medals.forEach(m -> assertFalse(m.isCompleted())); medals.forEach(m -> assertFalse(m.isCompleted()));
assertEquals(6, medals.size()); assertEquals(5, medals.size());
} }
@Test @Test

View File

@@ -11,9 +11,6 @@ import org.mockito.Mockito;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.HashSet;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -65,17 +62,15 @@ class NotificationServiceTest {
User user = new User(); User user = new User();
user.setId(2L); user.setId(2L);
user.setUsername("bob"); user.setUsername("bob");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user)); when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user));
Notification n = new Notification(); Notification n = new Notification();
when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class))) when(nRepo.findByUserOrderByCreatedAtDesc(user)).thenReturn(List.of(n));
.thenReturn(new PageImpl<>(List.of(n)));
List<Notification> list = service.listNotifications("bob", null, 0, 10); List<Notification> list = service.listNotifications("bob", null);
assertEquals(1, list.size()); assertEquals(1, list.size());
verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)); verify(nRepo).findByUserOrderByCreatedAtDesc(user);
} }
@Test @Test
@@ -92,7 +87,6 @@ class NotificationServiceTest {
User user = new User(); User user = new User();
user.setId(3L); user.setId(3L);
user.setUsername("carl"); user.setUsername("carl");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user)); when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndRead(user, false)).thenReturn(5L); when(nRepo.countByUserAndRead(user, false)).thenReturn(5L);
@@ -102,56 +96,6 @@ class NotificationServiceTest {
verify(nRepo).countByUserAndRead(user, false); verify(nRepo).countByUserAndRead(user, false);
} }
@Test
void listNotificationsFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(4L);
user.setUsername("dana");
when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user));
Notification n = new Notification();
when(nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(n)));
List<Notification> list = service.listNotifications("dana", null, 0, 10);
assertEquals(1, list.size());
verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class));
}
@Test
void countUnreadFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(5L);
user.setUsername("erin");
when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes())))
.thenReturn(2L);
long count = service.countUnread("erin");
assertEquals(2L, count);
verify(nRepo).countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes()));
}
@Test @Test
void createRegisterRequestNotificationsDeletesOldOnes() { void createRegisterRequestNotificationsDeletesOldOnes() {
NotificationRepository nRepo = mock(NotificationRepository.class); NotificationRepository nRepo = mock(NotificationRepository.class);

View File

@@ -34,12 +34,11 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -62,59 +61,6 @@ class PostServiceTest {
verify(postRepo).delete(post); 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 @Test
void createPostRespectsRateLimit() { void createPostRespectsRateLimit() {
PostRepository postRepo = mock(PostRepository.class); PostRepository postRepo = mock(PostRepository.class);
@@ -134,12 +80,11 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -168,12 +113,11 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -15,10 +15,9 @@ class ReactionServiceTest {
UserRepository userRepo = mock(UserRepository.class); UserRepository userRepo = mock(UserRepository.class);
PostRepository postRepo = mock(PostRepository.class); PostRepository postRepo = mock(PostRepository.class);
CommentRepository commentRepo = mock(CommentRepository.class); CommentRepository commentRepo = mock(CommentRepository.class);
MessageRepository messageRepo = mock(MessageRepository.class);
NotificationService notif = mock(NotificationService.class); NotificationService notif = mock(NotificationService.class);
EmailSender email = mock(EmailSender.class); EmailSender email = mock(EmailSender.class);
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email); ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User(); User user = new User();

View File

@@ -1,6 +1,6 @@
<template> <template>
<div id="app"> <div id="app">
<div v-if="!isFloatMode" class="header-container"> <div class="header-container">
<HeaderComponent <HeaderComponent
ref="header" ref="header"
@toggle-menu="menuVisible = !menuVisible" @toggle-menu="menuVisible = !menuVisible"
@@ -9,28 +9,18 @@
</div> </div>
<div class="main-container"> <div class="main-container">
<div v-if="!isFloatMode" class="menu-container" v-click-outside="handleMenuOutside"> <div class="menu-container" v-click-outside="handleMenuOutside">
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" /> <MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
</div> </div>
<div <div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
class="content"
:class="{ 'menu-open': menuVisible && !hideMenu && !isFloatMode }"
:style="isFloatMode ? { paddingTop: '0px', minHeight: '100vh' } : {}"
>
<NuxtPage keepalive /> <NuxtPage keepalive />
</div> </div>
<div <div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
v-if="showNewPostIcon && isMobile && !isFloatMode"
class="app-new-post-icon"
@click="goToNewPost"
>
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</div> </div>
</div> </div>
<GlobalPopups /> <GlobalPopups />
<ConfirmDialog />
<MessageFloatWindow v-if="!isFloatMode" />
</div> </div>
</template> </template>
@@ -38,8 +28,6 @@
import HeaderComponent from '~/components/HeaderComponent.vue' import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue' import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue' import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -62,7 +50,6 @@ const hideMenu = computed(() => {
}) })
const header = useTemplateRef('header') const header = useTemplateRef('header')
const isFloatMode = computed(() => useRoute().query.float !== undefined)
onMounted(() => { onMounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View File

@@ -18,7 +18,7 @@
--background-color-blur: rgba(255, 255, 255, 0.57); --background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray; --menu-border-color: lightgray;
--normal-border-color: lightgray; --normal-border-color: lightgray;
--menu-selected-background-color: rgba(228, 228, 228, 0.884); --menu-selected-background-color: rgba(208, 250, 255, 0.659);
--menu-text-color: black; --menu-text-color: black;
--scroller-background-color: rgba(130, 175, 180, 0.5); --scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */ /* --normal-background-color: rgb(241, 241, 241); */
@@ -90,8 +90,7 @@ body {
} }
.vditor-toolbar--pin { .vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important; top: var(--header-height) !important;
z-index: 2000;
} }
.vditor-panel { .vditor-panel {
@@ -184,7 +183,7 @@ body {
font-family: 'Maple Mono', monospace; font-family: 'Maple Mono', monospace;
font-size: 13px; font-size: 13px;
border-radius: 4px; border-radius: 4px;
white-space: break-spaces; white-space: no-wrap;
background-color: var(--code-highlight-background-color); background-color: var(--code-highlight-background-color);
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -26,9 +26,6 @@
<template v-else-if="medal.type === 'POST'"> <template v-else-if="medal.type === 'POST'">
{{ medal.currentPostCount }}/{{ medal.targetPostCount }} {{ medal.currentPostCount }}/{{ medal.targetPostCount }}
</template> </template>
<template v-else-if="medal.type === 'FEATURED'">
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
</template>
<template v-else-if="medal.type === 'CONTRIBUTOR'"> <template v-else-if="medal.type === 'CONTRIBUTOR'">
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }} {{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
</template> </template>

View File

@@ -1,65 +0,0 @@
<template>
<label class="switch">
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span class="slider"></span>
</label>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="timeline" :class="{ 'hover-enabled': hover }"> <div class="timeline">
<div class="timeline-item" v-for="(item, idx) in items" :key="idx"> <div class="timeline-item" v-for="(item, idx) in items" :key="idx">
<div <div
class="timeline-icon" class="timeline-icon"
@@ -8,7 +8,7 @@
> >
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" /> <img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<i v-else-if="item.icon" :class="item.icon"></i> <i v-else-if="item.icon" :class="item.icon"></i>
<img v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" /> <span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span>
</div> </div>
<div class="timeline-content"> <div class="timeline-content">
<slot name="item" :item="item">{{ item.content }}</slot> <slot name="item" :item="item">{{ item.content }}</slot>
@@ -22,7 +22,6 @@ export default {
name: 'BaseTimeline', name: 'BaseTimeline',
props: { props: {
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
}, },
} }
</script> </script>
@@ -42,12 +41,6 @@ export default {
margin-top: 10px; margin-top: 10px;
} }
.hover-enabled .timeline-item:hover {
background-color: var(--menu-selected-background-color);
transition: background-color 0.2s;
border-radius: 10px;
}
.timeline-icon { .timeline-icon {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -74,9 +67,8 @@ export default {
} }
.timeline-emoji { .timeline-emoji {
width: 20px; font-size: 20px;
height: 20px; line-height: 1;
object-fit: contain;
} }
.timeline-item::before { .timeline-item::before {

View File

@@ -22,7 +22,6 @@ import {
getEditorTheme as getEditorThemeUtil, getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil, getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor' } from '~/utils/vditor'
import '~/assets/global.css'
import LoginOverlay from '~/components/LoginOverlay.vue' import LoginOverlay from '~/components/LoginOverlay.vue'
export default { export default {

View File

@@ -16,11 +16,11 @@
<div class="info-content-header-left"> <div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span> <span class="user-name">{{ comment.userName }}</span>
<i class="fas fa-medal medal-icon"></i> <i class="fas fa-medal medal-icon"></i>
<NuxtLink <router-link
v-if="comment.medal" v-if="comment.medal"
class="medal-name" class="medal-name"
:to="`/users/${comment.userId}?tab=achievements`" :to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</NuxtLink >{{ getMedalTitle(comment.medal) }}</router-link
> >
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i> <i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2"> <span v-if="level >= 2">
@@ -57,7 +57,7 @@
v-if="showEditor" v-if="showEditor"
@submit="submitReply" @submit="submitReply"
:loading="isWaitingForReply" :loading="isWaitingForReply"
:disabled="!loggedIn || postClosed" :disabled="!loggedIn"
:show-login-overlay="!loggedIn" :show-login-overlay="!loggedIn"
:parent-user-name="comment.userName" :parent-user-name="comment.userName"
/> />
@@ -76,7 +76,6 @@
:level="level + 1" :level="level + 1"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="postAuthorId" :post-author-id="postAuthorId"
:post-closed="postClosed"
/> />
</template> </template>
</BaseTimeline> </BaseTimeline>
@@ -123,10 +122,6 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
required: true, required: true,
}, },
postClosed: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['deleted']) const emit = defineEmits(['deleted'])
@@ -153,7 +148,6 @@ const toggleReplies = () => {
} }
const toggleEditor = () => { const toggleEditor = () => {
if (props.postClosed) return
showEditor.value = !showEditor.value showEditor.value = !showEditor.value
if (showEditor.value) { if (showEditor.value) {
setTimeout(() => { setTimeout(() => {
@@ -219,10 +213,6 @@ const deleteComment = async () => {
} }
const submitReply = async (parentUserName, text, clear) => { const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return if (!text.trim()) return
if (props.postClosed) {
toast.error('帖子已关闭')
return
}
isWaitingForReply.value = true isWaitingForReply.value = true
const token = getToken() const token = getToken()
if (!token) { if (!token) {

View File

@@ -1,71 +0,0 @@
<template>
<BasePopup :visible="visible" @close="onCancel">
<div class="confirm-dialog" role="dialog" aria-modal="true">
<h3 class="confirm-title">{{ title }}</h3>
<p class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<div class="cancel-button" @click="onCancel">取消</div>
<div class="confirm-button" @click="onConfirm">确认</div>
</div>
</div>
</BasePopup>
</template>
<script setup lang="ts">
import BasePopup from '~/components/BasePopup.vue'
import { useConfirm } from '~/composables/useConfirm'
const { visible, title, message, onConfirm, onCancel } = useConfirm()
</script>
<style scoped>
.confirm-dialog {
padding: 20px;
text-align: center;
}
.confirm-title {
margin-top: 0;
font-size: 18px;
font-weight: 600;
}
.confirm-message {
margin: 16px 0 20px;
line-height: 1.6;
color: var(--text-secondary, #666);
}
.confirm-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.confirm-button,
.cancel-button {
min-width: 88px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.confirm-button {
background: var(--primary-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-button:hover {
background: var(--primary-color-hover);
}
.cancel-button {
background: transparent;
color: var(--primary-color);
border-color: currentColor;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-button:hover {
opacity: 0.85;
}
</style>

View File

@@ -1,5 +1,11 @@
<template> <template>
<div> <div>
<ActivityPopup
:visible="showInvitePointsPopup"
:icon="invitePointsIcon"
text="邀请码送积分活动火热进行中,快来邀请好友吧!"
@close="closeInvitePointsPopup"
/>
<ActivityPopup <ActivityPopup
:visible="showMilkTeaPopup" :visible="showMilkTeaPopup"
:icon="milkTeaIcon" :icon="milkTeaIcon"
@@ -7,15 +13,7 @@
@close="closeMilkTeaPopup" @close="closeMilkTeaPopup"
/> />
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" /> <NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MessagePopup :visible="showMessagePopup" @close="closeMessagePopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" /> <MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
:visible="showInviteCodePopup"
:icon="inviteCodeIcon"
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
@close="closeInviteCodePopup"
/>
</div> </div>
</template> </template>
@@ -23,32 +21,26 @@
import ActivityPopup from '~/components/ActivityPopup.vue' import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue' import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue' import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import MessagePopup from '~/components/MessagePopup.vue'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const showInvitePointsPopup = ref(false)
const invitePointsIcon = ref('')
const showMilkTeaPopup = ref(false) const showMilkTeaPopup = ref(false)
const showInviteCodePopup = ref(false)
const milkTeaIcon = ref('') const milkTeaIcon = ref('')
const inviteCodeIcon = ref('')
const showNotificationPopup = ref(false) const showNotificationPopup = ref(false)
const showMessagePopup = ref(false)
const showMedalPopup = ref(false) const showMedalPopup = ref(false)
const newMedals = ref([]) const newMedals = ref([])
onMounted(async () => { onMounted(async () => {
await checkInvitePointsActivity()
if (showInvitePointsPopup.value) return
await checkMilkTeaActivity() await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return if (showMilkTeaPopup.value) return
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkMessageFeature()
if (showMessagePopup.value) return
await checkNotificationSetting() await checkNotificationSetting()
if (showNotificationPopup.value) return if (showNotificationPopup.value) return
@@ -56,7 +48,7 @@ onMounted(async () => {
}) })
const checkMilkTeaActivity = async () => { const checkMilkTeaActivity = async () => {
if (!import.meta.client) return if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return if (localStorage.getItem('milkTeaActivityPopupShown')) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -72,62 +64,49 @@ const checkMilkTeaActivity = async () => {
// ignore network errors // ignore network errors
} }
} }
const closeMilkTeaPopup = () => {
const checkInviteCodeActivity = async () => { if (!process.client) return
if (!import.meta.client) return localStorage.setItem('milkTeaActivityPopupShown', 'true')
if (localStorage.getItem('inviteCodeActivityPopupShown')) return showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkInvitePointsActivity = async () => {
if (!process.client) return
if (localStorage.getItem('invitePointsActivityPopupShown')) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) { if (res.ok) {
const list = await res.json() const list = await res.json()
const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended) const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended)
if (a) { if (a) {
inviteCodeIcon.value = a.icon invitePointsIcon.value = a.icon
showInviteCodePopup.value = true showInvitePointsPopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore network errors // ignore network errors
} }
} }
const closeInvitePointsPopup = () => {
const closeInviteCodePopup = () => { if (!process.client) return
if (!import.meta.client) return localStorage.setItem('invitePointsActivityPopupShown', 'true')
localStorage.setItem('inviteCodeActivityPopupShown', 'true') showInvitePointsPopup.value = false
showInviteCodePopup.value = false checkMilkTeaActivity()
} }
const closeMilkTeaPopup = () => {
if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
}
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 () => { const checkNotificationSetting = async () => {
if (!import.meta.client) return if (!process.client) return
if (!authState.loggedIn) return if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true showNotificationPopup.value = true
} }
const closeNotificationPopup = () => { const closeNotificationPopup = () => {
if (!import.meta.client) return if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true') localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false showNotificationPopup.value = false
checkNewMedals()
} }
const checkNewMedals = async () => { const checkNewMedals = async () => {
if (!import.meta.client) return if (!process.client) return
if (!authState.loggedIn || !authState.userId) return if (!authState.loggedIn || !authState.userId) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`) const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -145,7 +124,7 @@ const checkNewMedals = async () => {
} }
} }
const closeMedalPopup = () => { const closeMedalPopup = () => {
if (!import.meta.client) return if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]')) const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type)) newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen])) localStorage.setItem('seenMedals', JSON.stringify([...seen]))

View File

@@ -6,10 +6,7 @@
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')"> <button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<span <span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
class="menu-unread-dot"
></span>
</div> </div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData"> <NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img <img
@@ -32,34 +29,12 @@
<i :class="iconClass"></i> <i :class="iconClass"></i>
</div> </div>
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
<i class="fas fa-copy"></i>
邀请
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
</div>
<ToolTip content="复制RSS链接" placement="bottom">
<div class="rss-icon" @click="copyRssLink">
<i class="fas fa-rss"></i>
</div>
</ToolTip>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom"> <ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost"> <div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</div> </div>
</ToolTip> </ToolTip>
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
<div class="messages-icon" @click="goToMessages">
<i class="fas fa-comments"></i>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems"> <DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger> <template #trigger>
<div class="avatar-container"> <div class="avatar-container">
@@ -88,15 +63,9 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue' import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue' import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth' import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount' import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const props = defineProps({ const props = defineProps({
showMenuBtn: { showMenuBtn: {
@@ -107,14 +76,12 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount() const unreadCount = computed(() => notificationState.unreadCount)
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('') const avatar = ref('')
const showSearch = ref(false) const showSearch = ref(false)
const searchDropdown = ref(null) const searchDropdown = ref(null)
const userMenu = ref(null) const userMenu = ref(null)
const menuBtn = ref(null) const menuBtn = ref(null)
const isCopying = ref(false)
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true
@@ -133,53 +100,6 @@ const goToLogin = () => {
const goToSettings = () => { const goToSettings = () => {
navigateTo('/settings', { replace: true }) navigateTo('/settings', { replace: true })
} }
const copyInviteLink = async () => {
isCopying.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
/**
* navigator.clipboard在webkit中有点奇怪的行为
* https://stackoverflow.com/questions/62327358/javascript-clipboard-api-safari-ios-notallowederror-message
* https://webkit.org/blog/10247/new-webkit-features-in-safari-13-1/
*/
setTimeout(() => {
navigator.clipboard.writeText(inviteLink)
.then(() => {
toast.success('邀请链接已复制')
})
.catch(() => {
toast.error('邀请链接复制失败')
})
}, 0)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
isCopying.value = false
}
}
const copyRssLink = async () => {
const rssLink = `${API_BASE_URL}/api/rss`
await navigator.clipboard.writeText(rssLink)
toast.success('RSS链接已复制')
}
const goToProfile = async () => { const goToProfile = async () => {
if (!authState.loggedIn) { if (!authState.loggedIn) {
navigateTo('/login', { replace: true }) navigateTo('/login', { replace: true })
@@ -209,13 +129,10 @@ const goToNewPost = () => {
} }
const refrechData = async () => { const refrechData = async () => {
await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home')) window.dispatchEvent(new Event('refresh-home'))
} }
const goToMessages = () => {
navigateTo('/message-box')
}
const headerMenuItems = computed(() => [ const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings }, { text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile }, { text: '个人主页', onClick: goToProfile },
@@ -245,10 +162,9 @@ onMounted(async () => {
} }
const updateUnread = async () => { const updateUnread = async () => {
if (authState.loggedIn) { if (authState.loggedIn) {
fetchUnreadCount() await fetchUnreadCount()
fetchChannelUnread()
} else { } else {
fetchChannelUnread() notificationState.unreadCount = 0
} }
} }
@@ -257,7 +173,7 @@ onMounted(async () => {
watch( watch(
() => authState.loggedIn, () => authState.loggedIn,
async (isLoggedIn) => { async () => {
await updateAvatar() await updateAvatar()
await updateUnread() await updateUnread()
}, },
@@ -308,7 +224,7 @@ onMounted(async () => {
margin-left: auto; margin-left: auto;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 30px; gap: 20px;
} }
.auth-btns { .auth-btns {
@@ -399,67 +315,9 @@ onMounted(async () => {
cursor: pointer; cursor: pointer;
} }
.invite_text { .new-post-icon {
font-size: 12px;
cursor: pointer;
color: var(--primary-color);
}
.invite_text:hover {
text-decoration: underline;
}
.rss-icon,
.new-post-icon,
.messages-icon {
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
position: relative;
}
.unread-badge {
position: absolute;
top: -5px;
right: -10px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
padding: 2px 5px;
font-size: 10px;
font-weight: bold;
line-height: 1;
min-width: 16px;
text-align: center;
box-sizing: border-box;
}
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff4d4f;
}
.rss-icon {
animation: rss-glow 2s 3;
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
50% {
text-shadow: 0 0 12px var(--primary-color);
opacity: 0.8;
}
100% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -478,9 +336,5 @@ onMounted(async () => {
.logo-text { .logo-text {
display: none; display: none;
} }
.header-content-right {
gap: 15px;
}
} }
</style> </style>

View File

@@ -1,102 +0,0 @@
<template>
<!-- done 后整个容器自动隐藏不再占位 -->
<div v-show="!done" class="infinite-loadmore">
<div v-show="isLoading" class="loading-container bottom-loading" aria-live="polite">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<!-- 永久存在的底部触发器由组件内部持有与观察 -->
<div ref="sentinel" class="load-more-trigger" aria-hidden="true"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
const props = defineProps({
/** 父组件提供:执行“加载下一页”的函数
* 返回:
* - booleantrue 表示“已经没有更多数据done
* - { done: boolean }:同上
*/
onLoad: { type: Function, required: true },
/** pause=true 时暂停观察(例如首屏/筛选加载过程) */
pause: { type: Boolean, default: false },
/** 预取范围,默认 200px */
rootMargin: { type: String, default: '200px 0px' },
/** 触发阈值 */
threshold: { type: Number, default: 0 },
})
const isLoading = ref(false)
const done = ref(false)
const sentinel = ref(null)
let io = null
const stopObserver = () => {
if (io) {
io.disconnect()
io = null
}
}
const startObserver = () => {
if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {
const e = entries[0]
if (!e?.isIntersecting || isLoading.value || done.value) return
isLoading.value = true
try {
const res = await props.onLoad()
const finished = typeof res === 'boolean' ? res : !!(res && res.done)
if (finished) {
done.value = true
stopObserver()
}
} finally {
isLoading.value = false
}
},
{ root: null, rootMargin: props.rootMargin, threshold: props.threshold },
)
if (sentinel.value) io.observe(sentinel.value)
}
onMounted(() => {
nextTick(startObserver)
})
onBeforeUnmount(stopObserver)
watch(
() => props.pause,
(p) => {
if (p) stopObserver()
else nextTick(startObserver)
},
)
/** 父组件可选择性调用,用于外部强制重置(一般直接用 :key 重建更简单) */
const reset = () => {
done.value = false
nextTick(startObserver)
}
defineExpose({ reset })
</script>
<style scoped>
.infinite-loadmore {
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100px; /* 与原样式匹配 */
}
.load-more-trigger {
width: 100%;
height: 1px;
}
</style>

View File

@@ -1,190 +0,0 @@
<template>
<div class="invite-code-activity">
<div class="invite-code-description">
<div class="invite-code-description-title">
<i class="fas fa-info-circle"></i>
<span class="invite-code-description-title-text">邀请规则说明</span>
</div>
<div class="invite-code-description-content">
<p>邀请好友注册并登录每次可以获得500积分🎉🎉🎉</p>
<p>邀请链接的有效期为1个月</p>
<p>每一个邀请链接的邀请人数上限为3人</p>
<p>通过邀请链接注册无需注册审核</p>
<p>每人每天仅能生产1个邀请链接</p>
</div>
</div>
<div v-if="inviteLink" class="invite-code-link-content">
<p class="invite-code-link-content-text">
邀请链接{{ inviteLink }}
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
</p>
</div>
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
生成邀请链接
</div>
</div>
</template>
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const user = ref(null)
const isLoadingUser = ref(true)
const inviteCode = ref('')
const loadingInvite = ref(false)
const inviteLink = computed(() =>
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
)
onMounted(async () => {
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
// if (user.value) {
// await fetchInvite(false)
// }
})
const fetchInvite = async (showToast = true) => {
loadingInvite.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
loadingInvite.value = false
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
inviteCode.value = data.token
if (showToast) toast.success('邀请链接已生成')
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
loadingInvite.value = false
}
}
const generateInvite = () => fetchInvite(true)
const copyLink = async () => {
if (!inviteLink.value) return
try {
await navigator.clipboard.writeText(inviteLink.value)
toast.success('已复制')
} catch (e) {
toast.error('复制失败')
}
}
</script>
<style scoped>
.invite-code-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.invite-code-description-content {
font-size: 12px;
opacity: 0.8;
}
.status-title {
font-weight: bold;
}
.status-text {
font-size: 12px;
opacity: 0.8;
}
.invite-code-activity {
margin-top: 20px;
padding: 20px;
}
.generate-button {
margin-top: 20px;
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 10px;
width: fit-content;
cursor: pointer;
}
.generate-button:hover {
background-color: var(--primary-color-hover);
}
.generate-button.disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.generate-button.disabled:hover {
background-color: var(--primary-color-disabled);
}
.invite-code-status-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 30px;
margin-top: 20px;
}
.invite-code-status {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
font-size: 14px;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;
color: var(--primary-color);
}
.invite-code-link-content {
margin-top: 20px;
font-size: 12px;
opacity: 0.8;
}
.invite-code-link-content-text {
word-break: break-all;
}
.copy-icon {
cursor: pointer;
margin-left: 5px;
}
@media screen and (max-width: 768px) {
.invite-code-status-container {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -106,7 +106,7 @@
<div class="menu-section"> <div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen"> <div class="section-header" @click="tagOpen = !tagOpen">
<span>标签</span> <span>tag</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i> <i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div> </div>
<div v-if="tagOpen" class="section-items"> <div v-if="tagOpen" class="section-items">
@@ -262,7 +262,7 @@ const gotoTag = (t) => {
top: var(--header-height); top: var(--header-height);
width: 220px; width: 220px;
background-color: var(--app-menu-background-color); background-color: var(--app-menu-background-color);
height: calc(100vh - var(--header-height)); height: calc(100vh - 20px - var(--header-height));
border-right: 1px solid var(--menu-border-color); border-right: 1px solid var(--menu-border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -348,7 +348,6 @@ const gotoTag = (t) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
} }
.menu-section { .menu-section {

View File

@@ -1,182 +0,0 @@
<template>
<div class="message-editor-container">
<div class="message-editor-wrapper">
<div :id="editorId" ref="vditorElement"></div>
</div>
<div class="message-bottom-container">
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
<template v-if="!loading"> 发送 </template>
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
</div>
</div>
</div>
</template>
<script>
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { clearVditorStorage } from '~/utils/clearVditorStorage'
import { themeState } from '~/utils/theme'
import {
createVditor,
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor'
import '~/assets/global.css'
export default {
name: 'MessageEditor',
emits: ['submit'],
props: {
editorId: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const vditorInstance = ref(null)
const text = ref('')
const editorId = ref(props.editorId)
if (!editorId.value) {
editorId.value = 'editor-' + useId()
}
const getEditorTheme = getEditorThemeUtil
const getPreviewTheme = getPreviewThemeUtil
const applyTheme = () => {
if (vditorInstance.value) {
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
}
}
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
const submit = () => {
if (!vditorInstance.value || isDisabled.value) return
const value = vditorInstance.value.getValue()
emit('submit', value, () => {
if (!vditorInstance.value) return
vditorInstance.value.setValue('')
text.value = ''
})
}
onMounted(() => {
vditorInstance.value = createVditor(editorId.value, {
placeholder: '输入消息...',
height: 150,
toolbar: [
'emoji',
'bold',
'italic',
'strike',
'link',
'|',
'list',
'|',
'line',
'quote',
'code',
'inline-code',
'|',
'upload',
],
preview: {
actions: [],
markdown: { toc: false },
},
input(value) {
text.value = value
},
after() {
if (props.loading || props.disabled) {
vditorInstance.value.disabled()
}
applyTheme()
},
})
})
onUnmounted(() => {
clearVditorStorage()
})
watch(
() => props.loading,
(val) => {
if (!vditorInstance.value) return
if (val) {
vditorInstance.value.disabled()
} else if (!props.disabled) {
vditorInstance.value.enable()
}
},
)
watch(
() => props.disabled,
(val) => {
if (!vditorInstance.value) return
if (val) {
vditorInstance.value.disabled()
} else if (!props.loading) {
vditorInstance.value.enable()
}
},
)
watch(
() => themeState.mode,
() => {
applyTheme()
},
)
return { submit, isDisabled, editorId }
},
}
</script>
<style scoped>
.message-editor-container {
border: 1px solid var(--border-color);
border-radius: 8px;
}
.message-bottom-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 10px;
background-color: var(--bg-color-soft);
border-top: 1px solid var(--border-color);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.message-submit {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.message-submit.disabled {
background-color: var(--primary-color-disabled);
opacity: 0.6;
cursor: not-allowed;
}
.message-submit:not(.disabled):hover {
background-color: var(--primary-color-hover);
}
</style>

View File

@@ -1,101 +0,0 @@
<template>
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
<iframe :src="iframeSrc" frameborder="0"></iframe>
<div class="float-actions">
<i
class="fas fa-chevron-down"
v-if="floatHeight !== MINI_HEIGHT"
title="收起至 100px"
@click="collapseToMini"
></i>
<!-- 回弹60vh -->
<i
class="fas fa-chevron-up"
v-if="floatHeight !== DEFAULT_HEIGHT"
title="回弹至 60vh"
@click="reboundToDefault"
></i>
<!-- 全屏打开原有逻辑 -->
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
</div>
</div>
</template>
<script setup>
const floatRoute = useState('messageFloatRoute')
const DEFAULT_HEIGHT = '60vh'
const MINI_HEIGHT = '45px'
const floatHeight = ref(DEFAULT_HEIGHT)
const iframeSrc = computed(() => {
if (!floatRoute.value) return ''
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
})
function collapseToMini() {
floatHeight.value = MINI_HEIGHT
}
function reboundToDefault() {
floatHeight.value = DEFAULT_HEIGHT
}
function expand() {
if (!floatRoute.value) return
const target = floatRoute.value
floatRoute.value = null
navigateTo(target)
}
// 当浮窗重新出现时,恢复默认高度
watch(
() => floatRoute.value,
(v) => {
if (v) floatHeight.value = DEFAULT_HEIGHT
},
)
</script>
<style scoped>
.message-float-window {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
/* 高度由内联样式绑定控制60vh / 100px */
max-height: 90vh;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
z-index: 2000;
display: flex;
flex-direction: column;
transition: height 0.25s ease; /* 平滑过渡 */
}
.message-float-window iframe {
width: 100%;
flex: 1;
}
.float-actions {
position: absolute;
top: 4px;
right: 8px;
padding: 12px;
display: flex;
gap: 10px;
}
.float-actions i {
cursor: pointer;
font-size: 14px;
opacity: 0.9;
}
.float-actions i:hover {
opacity: 1;
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="message-popup">
<div class="message-popup-title">📨 站内信上线啦</div>
<div class="message-popup-text">现在可以在右上角使用站内信功能</div>
<div class="message-popup-actions">
<div class="message-popup-close" @click="close">知道了</div>
<div class="message-popup-button" @click="gotoMessage">去看看</div>
</div>
</div>
</BasePopup>
</template>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
defineProps({
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const gotoMessage = () => {
emit('close')
navigateTo('/message-box', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>
.message-popup {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
min-width: 200px;
}
.message-popup-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.message-popup-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 20px;
}
.message-popup-button {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.message-popup-button:hover {
background-color: var(--primary-color-hover);
}
.message-popup-close {
cursor: pointer;
color: var(--primary-color);
display: flex;
align-items: center;
}
.message-popup-close:hover {
text-decoration: underline;
}
</style>

View File

@@ -16,7 +16,6 @@ import {
getEditorTheme as getEditorThemeUtil, getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil, getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor' } from '~/utils/vditor'
import '~/assets/global.css'
export default { export default {
name: 'PostEditor', name: 'PostEditor',

Some files were not shown because too many files have changed in this diff Show More