mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 15:10:48 +08:00
优化目录结构
This commit is contained in:
11
backend/src/main/java/com/openisle/OpenIsleApplication.java
Normal file
11
backend/src/main/java/com/openisle/OpenIsleApplication.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.openisle;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class OpenIsleApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OpenIsleApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.Activity;
|
||||
import com.openisle.model.ActivityType;
|
||||
import com.openisle.repository.ActivityRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityInitializer implements CommandLineRunner {
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
|
||||
Activity a = new Activity();
|
||||
a.setTitle("🎡建站送奶茶活动");
|
||||
a.setType(ActivityType.MILK_TEA);
|
||||
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
|
||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||
activityRepository.save(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
backend/src/main/java/com/openisle/config/AsyncConfig.java
Normal file
23
backend/src/main/java/com/openisle/config/AsyncConfig.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
@Bean(name = "notificationExecutor")
|
||||
public Executor notificationExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("notification-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Returns 401 Unauthorized when an authenticated user lacks required privileges.
|
||||
*/
|
||||
@Component
|
||||
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
|
||||
@Override
|
||||
public void handle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException, ServletException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||
}
|
||||
}
|
||||
189
backend/src/main/java/com/openisle/config/SecurityConfig.java
Normal file
189
backend/src/main/java/com/openisle/config/SecurityConfig.java
Normal file
@@ -0,0 +1,189 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.service.UserVisitService;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||
private final UserVisitService userVisitService;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
return username -> userRepository.findByUsername(username)
|
||||
.<UserDetails>map(user -> org.springframework.security.core.userdetails.User
|
||||
.withUsername(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.authorities(user.getRole().name())
|
||||
.build())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
|
||||
return http.getSharedObject(AuthenticationManagerBuilder.class)
|
||||
.userDetailsService(userDetailsService)
|
||||
.passwordEncoder(passwordEncoder)
|
||||
.and()
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost",
|
||||
"http://30.211.97.254:8080",
|
||||
"http://30.211.97.254",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/config/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll()
|
||||
.requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OncePerRequestFilter jwtAuthenticationFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
// 让预检请求直接通过
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
|
||||
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
|
||||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
|
||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/sitemap.xml"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
} catch (Exception e) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OncePerRequestFilter userVisitFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Activity;
|
||||
import com.openisle.model.ActivityType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.ActivityService;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityController {
|
||||
private final ActivityService activityService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public List<Activity> list() {
|
||||
return activityService.list();
|
||||
}
|
||||
|
||||
@GetMapping("/milk-tea")
|
||||
public MilkTeaInfo milkTea() {
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
long count = activityService.countParticipants(a);
|
||||
if (!a.isEnded() && count >= 50) {
|
||||
activityService.end(a);
|
||||
}
|
||||
MilkTeaInfo info = new MilkTeaInfo();
|
||||
info.setRedeemCount(count);
|
||||
info.setEnded(a.isEnded());
|
||||
return info;
|
||||
}
|
||||
|
||||
@PostMapping("/milk-tea/redeem")
|
||||
public java.util.Map<String, String> redeemMilkTea(@RequestBody RedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
boolean first = activityService.redeem(a, user, req.getContact());
|
||||
if (first) {
|
||||
return java.util.Map.of("message", "redeemed");
|
||||
}
|
||||
return java.util.Map.of("message", "updated");
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class MilkTeaInfo {
|
||||
private long redeemCount;
|
||||
private boolean ended;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class RedeemRequest {
|
||||
private String contact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.PasswordStrength;
|
||||
import com.openisle.model.PublishMode;
|
||||
import com.openisle.service.PasswordValidator;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.AiUsageService;
|
||||
import com.openisle.service.RegisterModeService;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/config")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminConfigController {
|
||||
private final PostService postService;
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final RegisterModeService registerModeService;
|
||||
|
||||
@GetMapping
|
||||
public ConfigDto getConfig() {
|
||||
ConfigDto dto = new ConfigDto();
|
||||
dto.setPublishMode(postService.getPublishMode());
|
||||
dto.setPasswordStrength(passwordValidator.getStrength());
|
||||
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
|
||||
dto.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
|
||||
if (dto.getPublishMode() != null) {
|
||||
postService.setPublishMode(dto.getPublishMode());
|
||||
}
|
||||
if (dto.getPasswordStrength() != null) {
|
||||
passwordValidator.setStrength(dto.getPasswordStrength());
|
||||
}
|
||||
if (dto.getAiFormatLimit() != null) {
|
||||
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
|
||||
}
|
||||
if (dto.getRegisterMode() != null) {
|
||||
registerModeService.setRegisterMode(dto.getRegisterMode());
|
||||
}
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ConfigDto {
|
||||
private PublishMode publishMode;
|
||||
private PasswordStrength passwordStrength;
|
||||
private Integer aiFormatLimit;
|
||||
private RegisterMode registerMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Simple admin demo endpoint.
|
||||
*/
|
||||
@RestController
|
||||
public class AdminController {
|
||||
@GetMapping("/api/admin/hello")
|
||||
public Map<String, String> adminHello() {
|
||||
return Map.of("message", "Hello, Admin User");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Endpoints for administrators to manage posts.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/posts")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminPostController {
|
||||
private final PostService postService;
|
||||
|
||||
@GetMapping("/pending")
|
||||
public List<PostDto> pendingPosts() {
|
||||
return postService.listPendingPosts().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public PostDto approve(@PathVariable Long id) {
|
||||
return toDto(postService.approvePost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public PostDto reject(@PathVariable Long id) {
|
||||
return toDto(postService.rejectPost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/pin")
|
||||
public PostDto pin(@PathVariable Long id) {
|
||||
return toDto(postService.pinPost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
public PostDto unpin(@PathVariable Long id) {
|
||||
return toDto(postService.unpinPost(id));
|
||||
}
|
||||
|
||||
private PostDto toDto(Post post) {
|
||||
PostDto dto = new PostDto();
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
dto.setContent(post.getContent());
|
||||
dto.setCreatedAt(post.getCreatedAt());
|
||||
dto.setAuthor(post.getAuthor().getUsername());
|
||||
dto.setCategory(toCategoryDto(post.getCategory()));
|
||||
dto.setViews(post.getViews());
|
||||
dto.setStatus(post.getStatus());
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CategoryDto toCategoryDto(com.openisle.model.Category c) {
|
||||
CategoryDto dto = new CategoryDto();
|
||||
dto.setId(c.getId());
|
||||
dto.setName(c.getName());
|
||||
dto.setDescription(c.getDescription());
|
||||
dto.setIcon(c.getIcon());
|
||||
dto.setSmallIcon(c.getSmallIcon());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private String author;
|
||||
private CategoryDto category;
|
||||
private long views;
|
||||
private com.openisle.model.PostStatus status;
|
||||
private LocalDateTime pinnedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CategoryDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.service.TagService;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/tags")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminTagController {
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
|
||||
@GetMapping("/pending")
|
||||
public List<TagDto> pendingTags() {
|
||||
return tagService.listPendingTags().stream()
|
||||
.map(t -> toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public TagDto approve(@PathVariable Long id) {
|
||||
Tag tag = tagService.approveTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return toDto(tag, count);
|
||||
}
|
||||
|
||||
private TagDto toDto(Tag tag, long count) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setIcon(tag.getIcon());
|
||||
dto.setSmallIcon(tag.getSmallIcon());
|
||||
dto.setCount(count);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TagDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private Long count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
private final UserRepository userRepository;
|
||||
private final EmailSender emailSender;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(true);
|
||||
userRepository.save(user);
|
||||
emailSender.sendEmail(user.getEmail(), "您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(false);
|
||||
userRepository.save(user);
|
||||
emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.OpenAiService;
|
||||
import com.openisle.service.AiUsageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
@RequiredArgsConstructor
|
||||
public class AiController {
|
||||
|
||||
private final OpenAiService openAiService;
|
||||
private final AiUsageService aiUsageService;
|
||||
|
||||
@PostMapping("/format")
|
||||
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
|
||||
Authentication auth) {
|
||||
String text = req.get("text");
|
||||
if (text == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
int limit = aiUsageService.getFormatLimit();
|
||||
int used = aiUsageService.getCount(auth.getName());
|
||||
if (limit > 0 && used >= limit) {
|
||||
return ResponseEntity.status(429).build();
|
||||
}
|
||||
aiUsageService.incrementAndGetCount(auth.getName());
|
||||
return openAiService.formatMarkdown(text)
|
||||
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
||||
.orElse(ResponseEntity.status(500).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.service.UserService;
|
||||
import com.openisle.service.CaptchaService;
|
||||
import com.openisle.service.GoogleAuthService;
|
||||
import com.openisle.service.GithubAuthService;
|
||||
import com.openisle.service.DiscordAuthService;
|
||||
import com.openisle.service.TwitterAuthService;
|
||||
import com.openisle.service.RegisterModeService;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.exception.FieldException;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
private final UserService userService;
|
||||
private final JwtService jwtService;
|
||||
private final EmailSender emailService;
|
||||
private final CaptchaService captchaService;
|
||||
private final GoogleAuthService googleAuthService;
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
|
||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
User user = userService.register(
|
||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
if (!user.isApproved()) {
|
||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
@PostMapping("/verify")
|
||||
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
||||
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
|
||||
if (ok) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Verified",
|
||||
"token", jwtService.generateReasonToken(req.getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
|
||||
if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsername(req.getUsername());
|
||||
if (userOpt.isEmpty()) {
|
||||
userOpt = userService.findByEmail(req.getUsername());
|
||||
}
|
||||
if (userOpt.isEmpty() || !userService.matchesPassword(userOpt.get(), req.getPassword())) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid credentials",
|
||||
"reason_code", "INVALID_CREDENTIALS"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!user.isVerified()) {
|
||||
user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "User not verified",
|
||||
"reason_code", "NOT_VERIFIED",
|
||||
"user_name", user.getUsername()));
|
||||
}
|
||||
if (RegisterMode.WHITELIST.equals(registerModeService.getRegisterMode()) && !user.isApproved()) {
|
||||
if (user.getRegisterReason() != null && !user.getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING"
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Register reason not approved",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.getUsername())));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.getUsername())));
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid google token",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/reason")
|
||||
public ResponseEntity<?> reason(@RequestBody MakeReasonRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReason(req.getToken());
|
||||
Optional<User> userOpt = userService.findByUsername(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid token, Please re-login",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
if (req.reason == null || req.reason.length() <= 20) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Reason's length must longer than 20",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (user.isApproved() || registerModeService.getRegisterMode() == RegisterMode.DIRECT) {
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
}
|
||||
|
||||
user = userService.updateReason(user.getUsername(), req.getReason());
|
||||
notificationService.createRegisterRequestNotifications(user, req.getReason());
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
}
|
||||
|
||||
@PostMapping("/github")
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
// 已填写注册理由
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid github code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/discord")
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid discord code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/twitter")
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
Optional<User> user = twitterAuthService.authenticate(
|
||||
req.getCode(),
|
||||
req.getCodeVerifier(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid twitter code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/send")
|
||||
public ResponseEntity<?> sendReset(@RequestBody ForgotPasswordRequest req) {
|
||||
Optional<User> userOpt = userService.findByEmail(req.getEmail());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
String code = userService.generatePasswordResetCode(req.getEmail());
|
||||
emailService.sendEmail(req.getEmail(), "请填写验证码以重置密码", "您的验证码是" + code);
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/verify")
|
||||
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
|
||||
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
|
||||
if (ok) {
|
||||
String username = userService.findByEmail(req.getEmail()).get().getUsername();
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReset(req.getToken());
|
||||
try {
|
||||
userService.updatePassword(username, req.getPassword());
|
||||
return ResponseEntity.ok(Map.of("message", "Password updated"));
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"field", e.getField(),
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class RegisterRequest {
|
||||
private String username;
|
||||
private String email;
|
||||
private String password;
|
||||
private String captcha;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class LoginRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String captcha;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class GoogleLoginRequest {
|
||||
private String idToken;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class GithubLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class DiscordLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TwitterLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String codeVerifier;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class VerifyRequest {
|
||||
private String username;
|
||||
private String code;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class MakeReasonRequest {
|
||||
private String token;
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ForgotPasswordRequest {
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class VerifyForgotRequest {
|
||||
private String email;
|
||||
private String code;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ResetPasswordRequest {
|
||||
private String token;
|
||||
private String password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.service.CategoryService;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
private final CategoryService categoryService;
|
||||
private final PostService postService;
|
||||
|
||||
@PostMapping
|
||||
public CategoryDto create(@RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return toDto(c, count);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return toDto(c, count);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable Long id) {
|
||||
categoryService.deleteCategory(id);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> list() {
|
||||
return categoryService.listCategories().stream()
|
||||
.map(c -> toDto(c, postService.countPostsByCategory(c.getId())))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CategoryDto get(@PathVariable Long id) {
|
||||
Category c = categoryService.getCategory(id);
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return toDto(c, count);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return postService.listPostsByCategories(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(p -> {
|
||||
PostSummaryDto dto = new PostSummaryDto();
|
||||
dto.setId(p.getId());
|
||||
dto.setTitle(p.getTitle());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private CategoryDto toDto(Category c, long count) {
|
||||
CategoryDto dto = new CategoryDto();
|
||||
dto.setId(c.getId());
|
||||
dto.setName(c.getName());
|
||||
dto.setIcon(c.getIcon());
|
||||
dto.setSmallIcon(c.getSmallIcon());
|
||||
dto.setDescription(c.getDescription());
|
||||
dto.setCount(count);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CategoryRequest {
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CategoryDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private Long count;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostSummaryDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.service.CaptchaService;
|
||||
import com.openisle.service.LevelService;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.model.CommentSort;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
public class CommentController {
|
||||
private final CommentService commentService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final ReactionService reactionService;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
|
||||
@PostMapping("/posts/{postId}/comments")
|
||||
public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth) {
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
|
||||
CommentDto dto = toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}/replies")
|
||||
public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth) {
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
|
||||
CommentDto dto = toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{postId}/comments")
|
||||
public List<CommentDto> listComments(@PathVariable Long postId,
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) {
|
||||
return commentService.getCommentsForPost(postId, sort).stream()
|
||||
.map(this::toDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private CommentDto toDtoWithReplies(Comment comment) {
|
||||
CommentDto dto = toDto(comment);
|
||||
List<CommentDto> replies = commentService.getReplies(comment.getId()).stream()
|
||||
.map(this::toDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReplies(replies);
|
||||
List<ReactionDto> reactions = reactionService.getReactionsForComment(comment.getId()).stream()
|
||||
.map(this::toReactionDto)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReactions(reactions);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
public void deleteComment(@PathVariable Long id, Authentication auth) {
|
||||
commentService.deleteComment(auth.getName(), id);
|
||||
}
|
||||
|
||||
private CommentDto toDto(Comment comment) {
|
||||
CommentDto dto = new CommentDto();
|
||||
dto.setId(comment.getId());
|
||||
dto.setContent(comment.getContent());
|
||||
dto.setCreatedAt(comment.getCreatedAt());
|
||||
dto.setAuthor(toAuthorDto(comment.getAuthor()));
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AuthorDto toAuthorDto(com.openisle.model.User user) {
|
||||
AuthorDto dto = new AuthorDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CommentRequest {
|
||||
private String content;
|
||||
private String captcha;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
private int reward;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class AuthorDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
|
||||
private ReactionDto toReactionDto(com.openisle.model.Reaction reaction) {
|
||||
ReactionDto dto = new ReactionDto();
|
||||
dto.setId(reaction.getId());
|
||||
dto.setType(reaction.getType());
|
||||
dto.setUser(reaction.getUser().getUsername());
|
||||
if (reaction.getPost() != null) {
|
||||
dto.setPostId(reaction.getPost().getId());
|
||||
}
|
||||
if (reaction.getComment() != null) {
|
||||
dto.setCommentId(reaction.getComment().getId());
|
||||
}
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ReactionDto {
|
||||
private Long id;
|
||||
private com.openisle.model.ReactionType type;
|
||||
private String user;
|
||||
private Long postId;
|
||||
private Long commentId;
|
||||
private int reward;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import com.openisle.service.RegisterModeService;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@lombok.RequiredArgsConstructor
|
||||
public class ConfigController {
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
|
||||
@Value("${app.ai.format-limit:3}")
|
||||
private int aiFormatLimit;
|
||||
|
||||
private final RegisterModeService registerModeService;
|
||||
|
||||
@GetMapping("/config")
|
||||
public ConfigResponse getConfig() {
|
||||
ConfigResponse resp = new ConfigResponse();
|
||||
resp.setCaptchaEnabled(captchaEnabled);
|
||||
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
|
||||
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
||||
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
||||
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
||||
resp.setAiFormatLimit(aiFormatLimit);
|
||||
resp.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ConfigResponse {
|
||||
private boolean captchaEnabled;
|
||||
private boolean registerCaptchaEnabled;
|
||||
private boolean loginCaptchaEnabled;
|
||||
private boolean postCaptchaEnabled;
|
||||
private boolean commentCaptchaEnabled;
|
||||
private int aiFormatLimit;
|
||||
private RegisterMode registerMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Draft;
|
||||
import com.openisle.service.DraftService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/drafts")
|
||||
@RequiredArgsConstructor
|
||||
public class DraftController {
|
||||
private final DraftService draftService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
|
||||
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
|
||||
return ResponseEntity.ok(toDto(draft));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
|
||||
return draftService.getDraft(auth.getName())
|
||||
.map(d -> ResponseEntity.ok(toDto(d)))
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/me")
|
||||
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
|
||||
draftService.deleteDraft(auth.getName());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private DraftDto toDto(Draft draft) {
|
||||
DraftDto dto = new DraftDto();
|
||||
dto.setId(draft.getId());
|
||||
dto.setTitle(draft.getTitle());
|
||||
dto.setContent(draft.getContent());
|
||||
if (draft.getCategory() != null) {
|
||||
dto.setCategoryId(draft.getCategory().getId());
|
||||
}
|
||||
dto.setTagIds(draft.getTags().stream().map(com.openisle.model.Tag::getId).collect(Collectors.toList()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class DraftRequest {
|
||||
private String title;
|
||||
private String content;
|
||||
private Long categoryId;
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class DraftDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private Long categoryId;
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(FieldException.class)
|
||||
public ResponseEntity<?> handleFieldException(FieldException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", ex.getMessage(), "field", ex.getField()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
|
||||
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(RateLimitException.class)
|
||||
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
|
||||
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleException(Exception ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class HelloController {
|
||||
@GetMapping("/api/hello")
|
||||
public Map<String, String> hello() {
|
||||
return Map.of("message", "Hello, Authenticated User");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Notification;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.NotificationService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Endpoints for user notifications. */
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationController {
|
||||
private final NotificationService notificationService;
|
||||
|
||||
@GetMapping
|
||||
public List<NotificationDto> list(@RequestParam(value = "read", required = false) Boolean read,
|
||||
Authentication auth) {
|
||||
return notificationService.listNotifications(auth.getName(), read).stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public UnreadCount unreadCount(Authentication auth) {
|
||||
long count = notificationService.countUnread(auth.getName());
|
||||
UnreadCount uc = new UnreadCount();
|
||||
uc.setCount(count);
|
||||
return uc;
|
||||
}
|
||||
|
||||
@PostMapping("/read")
|
||||
public void markRead(@RequestBody MarkReadRequest req, Authentication auth) {
|
||||
notificationService.markRead(auth.getName(), req.getIds());
|
||||
}
|
||||
|
||||
private NotificationDto toDto(Notification n) {
|
||||
NotificationDto dto = new NotificationDto();
|
||||
dto.setId(n.getId());
|
||||
dto.setType(n.getType());
|
||||
if (n.getPost() != null) {
|
||||
dto.setPost(toPostDto(n.getPost()));
|
||||
}
|
||||
if (n.getComment() != null) {
|
||||
dto.setComment(toCommentDto(n.getComment()));
|
||||
Comment parent = n.getComment().getParent();
|
||||
if (parent != null) {
|
||||
dto.setParentComment(toCommentDto(parent));
|
||||
}
|
||||
}
|
||||
if (n.getFromUser() != null) {
|
||||
dto.setFromUser(toAuthorDto(n.getFromUser()));
|
||||
}
|
||||
if (n.getReactionType() != null) {
|
||||
dto.setReactionType(n.getReactionType());
|
||||
}
|
||||
dto.setApproved(n.getApproved());
|
||||
dto.setContent(n.getContent());
|
||||
dto.setRead(n.isRead());
|
||||
dto.setCreatedAt(n.getCreatedAt());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PostDto toPostDto(Post post) {
|
||||
PostDto dto = new PostDto();
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CommentDto toCommentDto(Comment comment) {
|
||||
CommentDto dto = new CommentDto();
|
||||
dto.setId(comment.getId());
|
||||
dto.setContent(comment.getContent());
|
||||
dto.setCreatedAt(comment.getCreatedAt());
|
||||
dto.setAuthor(toAuthorDto(comment.getAuthor()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AuthorDto toAuthorDto(com.openisle.model.User user) {
|
||||
AuthorDto dto = new AuthorDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class MarkReadRequest {
|
||||
private List<Long> ids;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class NotificationDto {
|
||||
private Long id;
|
||||
private NotificationType type;
|
||||
private PostDto post;
|
||||
private CommentDto comment;
|
||||
private CommentDto parentComment;
|
||||
private AuthorDto fromUser;
|
||||
private ReactionType reactionType;
|
||||
private String content;
|
||||
private Boolean approved;
|
||||
private boolean read;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private AuthorDto author;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class AuthorDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UnreadCount {
|
||||
private long count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.service.CaptchaService;
|
||||
import com.openisle.service.DraftService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.UserVisitService;
|
||||
import com.openisle.service.LevelService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
@RequiredArgsConstructor
|
||||
public class PostController {
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
private final ReactionService reactionService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final DraftService draftService;
|
||||
private final UserVisitService userVisitService;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PostDto> createPost(@RequestBody PostRequest req, Authentication auth) {
|
||||
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
||||
req.getTitle(), req.getContent(), req.getTagIds());
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDto dto = toDto(post);
|
||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<PostDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
|
||||
Authentication auth) {
|
||||
Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
|
||||
req.getTitle(), req.getContent(), req.getTagIds());
|
||||
return ResponseEntity.ok(toDto(post));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void deletePost(@PathVariable Long id, Authentication auth) {
|
||||
postService.deletePost(id, auth.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<PostDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||
String viewer = auth != null ? auth.getName() : null;
|
||||
Post post = postService.viewPost(id, viewer);
|
||||
return ResponseEntity.ok(toDto(post, viewer));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PostDto> listPosts(@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());
|
||||
}
|
||||
|
||||
boolean hasCategories = ids != null && !ids.isEmpty();
|
||||
boolean hasTags = tids != null && !tids.isEmpty();
|
||||
|
||||
if (hasCategories && hasTags) {
|
||||
return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
if (hasTags) {
|
||||
return postService.listPostsByTags(tids, page, pageSize)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return postService.listPostsByCategories(ids, page, pageSize)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/ranking")
|
||||
public List<PostDto> rankingPosts(@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.listPostsByViews(ids, tids, page, pageSize)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/latest-reply")
|
||||
public List<PostDto> latestReplyPosts(@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.listPostsByLatestReply(ids, tids, page, pageSize)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private PostDto toDto(Post post) {
|
||||
PostDto dto = new PostDto();
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
dto.setContent(post.getContent());
|
||||
dto.setCreatedAt(post.getCreatedAt());
|
||||
dto.setAuthor(toAuthorDto(post.getAuthor()));
|
||||
dto.setCategory(toCategoryDto(post.getCategory()));
|
||||
dto.setTags(post.getTags().stream().map(this::toTagDto).collect(Collectors.toList()));
|
||||
dto.setViews(post.getViews());
|
||||
dto.setStatus(post.getStatus());
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
|
||||
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
|
||||
.stream()
|
||||
.map(this::toReactionDto)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReactions(reactions);
|
||||
|
||||
List<CommentDto> comments = commentService.getCommentsForPost(post.getId(), CommentSort.OLDEST)
|
||||
.stream()
|
||||
.map(this::toCommentDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
dto.setComments(comments);
|
||||
|
||||
java.util.List<com.openisle.model.User> participants = commentService.getParticipants(post.getId(), 5);
|
||||
dto.setParticipants(participants.stream().map(this::toAuthorDto).collect(Collectors.toList()));
|
||||
|
||||
java.time.LocalDateTime last = commentService.getLastCommentTime(post.getId());
|
||||
dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
|
||||
dto.setReward(0);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PostDto toDto(Post post, String viewer) {
|
||||
PostDto dto = toDto(post);
|
||||
if (viewer != null) {
|
||||
dto.setSubscribed(subscriptionService.isPostSubscribed(viewer, post.getId()));
|
||||
} else {
|
||||
dto.setSubscribed(false);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CommentDto toCommentDtoWithReplies(Comment comment) {
|
||||
CommentDto dto = toCommentDto(comment);
|
||||
List<CommentDto> replies = commentService.getReplies(comment.getId()).stream()
|
||||
.map(this::toCommentDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReplies(replies);
|
||||
|
||||
List<ReactionDto> reactions = reactionService.getReactionsForComment(comment.getId())
|
||||
.stream()
|
||||
.map(this::toReactionDto)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReactions(reactions);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CommentDto toCommentDto(Comment comment) {
|
||||
CommentDto dto = new CommentDto();
|
||||
dto.setId(comment.getId());
|
||||
dto.setContent(comment.getContent());
|
||||
dto.setCreatedAt(comment.getCreatedAt());
|
||||
dto.setAuthor(toAuthorDto(comment.getAuthor()));
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private ReactionDto toReactionDto(Reaction reaction) {
|
||||
ReactionDto dto = new ReactionDto();
|
||||
dto.setId(reaction.getId());
|
||||
dto.setType(reaction.getType());
|
||||
dto.setUser(reaction.getUser().getUsername());
|
||||
if (reaction.getPost() != null) {
|
||||
dto.setPostId(reaction.getPost().getId());
|
||||
}
|
||||
if (reaction.getComment() != null) {
|
||||
dto.setCommentId(reaction.getComment().getId());
|
||||
}
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CategoryDto toCategoryDto(com.openisle.model.Category category) {
|
||||
CategoryDto dto = new CategoryDto();
|
||||
dto.setId(category.getId());
|
||||
dto.setName(category.getName());
|
||||
dto.setDescription(category.getDescription());
|
||||
dto.setIcon(category.getIcon());
|
||||
dto.setSmallIcon(category.getSmallIcon());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private TagDto toTagDto(com.openisle.model.Tag tag) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setIcon(tag.getIcon());
|
||||
dto.setSmallIcon(tag.getSmallIcon());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AuthorDto toAuthorDto(com.openisle.model.User user) {
|
||||
AuthorDto dto = new AuthorDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostRequest {
|
||||
private Long categoryId;
|
||||
private String title;
|
||||
private String content;
|
||||
private java.util.List<Long> tagIds;
|
||||
private String captcha;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private AuthorDto author;
|
||||
private CategoryDto category;
|
||||
private java.util.List<TagDto> tags;
|
||||
private long views;
|
||||
private com.openisle.model.PostStatus status;
|
||||
private LocalDateTime pinnedAt;
|
||||
private LocalDateTime lastReplyAt;
|
||||
private List<CommentDto> comments;
|
||||
private List<ReactionDto> reactions;
|
||||
private java.util.List<AuthorDto> participants;
|
||||
private boolean subscribed;
|
||||
private int reward;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CategoryDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TagDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class AuthorDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
private int reward;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ReactionDto {
|
||||
private Long id;
|
||||
private com.openisle.model.ReactionType type;
|
||||
private String user;
|
||||
private Long postId;
|
||||
private Long commentId;
|
||||
private int reward;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.PushSubscriptionService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/push")
|
||||
@RequiredArgsConstructor
|
||||
public class PushSubscriptionController {
|
||||
private final PushSubscriptionService pushSubscriptionService;
|
||||
@Value("${app.webpush.public-key}")
|
||||
private String publicKey;
|
||||
|
||||
@GetMapping("/public-key")
|
||||
public PublicKeyResponse getPublicKey() {
|
||||
PublicKeyResponse r = new PublicKeyResponse();
|
||||
r.setKey(publicKey);
|
||||
return r;
|
||||
}
|
||||
|
||||
@PostMapping("/subscribe")
|
||||
public void subscribe(@RequestBody SubscriptionRequest req, Authentication auth) {
|
||||
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PublicKeyResponse {
|
||||
private String key;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class SubscriptionRequest {
|
||||
private String endpoint;
|
||||
private String p256dh;
|
||||
private String auth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.service.LevelService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
public class ReactionController {
|
||||
private final ReactionService reactionService;
|
||||
private final LevelService levelService;
|
||||
|
||||
/**
|
||||
* Get all available reaction types.
|
||||
*/
|
||||
@GetMapping("/reaction-types")
|
||||
public ReactionType[] listReactionTypes() {
|
||||
return ReactionType.values();
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{postId}/reactions")
|
||||
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}/reactions")
|
||||
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
private ReactionDto toDto(Reaction reaction) {
|
||||
ReactionDto dto = new ReactionDto();
|
||||
dto.setId(reaction.getId());
|
||||
dto.setType(reaction.getType());
|
||||
dto.setUser(reaction.getUser().getUsername());
|
||||
if (reaction.getPost() != null) {
|
||||
dto.setPostId(reaction.getPost().getId());
|
||||
}
|
||||
if (reaction.getComment() != null) {
|
||||
dto.setCommentId(reaction.getComment().getId());
|
||||
}
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ReactionRequest {
|
||||
private ReactionType type;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ReactionDto {
|
||||
private Long id;
|
||||
private ReactionType type;
|
||||
private String user;
|
||||
private Long postId;
|
||||
private Long commentId;
|
||||
private int reward;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.SearchService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
@RequiredArgsConstructor
|
||||
public class SearchController {
|
||||
private final SearchService searchService;
|
||||
|
||||
@GetMapping("/users")
|
||||
public List<UserDto> searchUsers(@RequestParam String keyword) {
|
||||
return searchService.searchUsers(keyword).stream()
|
||||
.map(this::toUserDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/posts")
|
||||
public List<PostDto> searchPosts(@RequestParam String keyword) {
|
||||
return searchService.searchPosts(keyword).stream()
|
||||
.map(this::toPostDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/posts/content")
|
||||
public List<PostDto> searchPostsByContent(@RequestParam String keyword) {
|
||||
return searchService.searchPostsByContent(keyword).stream()
|
||||
.map(this::toPostDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/posts/title")
|
||||
public List<PostDto> searchPostsByTitle(@RequestParam String keyword) {
|
||||
return searchService.searchPostsByTitle(keyword).stream()
|
||||
.map(this::toPostDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/global")
|
||||
public List<SearchResultDto> global(@RequestParam String keyword) {
|
||||
return searchService.globalSearch(keyword).stream()
|
||||
.map(r -> {
|
||||
SearchResultDto dto = new SearchResultDto();
|
||||
dto.setType(r.type());
|
||||
dto.setId(r.id());
|
||||
dto.setText(r.text());
|
||||
dto.setSubText(r.subText());
|
||||
dto.setExtra(r.extra());
|
||||
dto.setPostId(r.postId());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private UserDto toUserDto(User user) {
|
||||
UserDto dto = new UserDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PostDto toPostDto(Post post) {
|
||||
PostDto dto = new PostDto();
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UserDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class SearchResultDto {
|
||||
private String type;
|
||||
private Long id;
|
||||
private String text;
|
||||
private String subText;
|
||||
private String extra;
|
||||
private Long postId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Controller for dynamic sitemap generation.
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api")
|
||||
public class SitemapController {
|
||||
private final PostRepository postRepository;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
public ResponseEntity<String> sitemap() {
|
||||
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||
|
||||
List<String> staticRoutes = List.of(
|
||||
"/",
|
||||
"/about",
|
||||
"/activities",
|
||||
"/login",
|
||||
"/signup"
|
||||
);
|
||||
|
||||
for (String path : staticRoutes) {
|
||||
body.append(" <url><loc>")
|
||||
.append(websiteUrl)
|
||||
.append(path)
|
||||
.append("</loc></url>\n");
|
||||
}
|
||||
|
||||
for (Post p : posts) {
|
||||
body.append(" <url>\n")
|
||||
.append(" <loc>")
|
||||
.append(websiteUrl)
|
||||
.append("/posts/")
|
||||
.append(p.getId())
|
||||
.append("</loc>\n")
|
||||
.append(" <lastmod>")
|
||||
.append(p.getCreatedAt().toLocalDate())
|
||||
.append("</lastmod>\n")
|
||||
.append(" </url>\n");
|
||||
}
|
||||
|
||||
body.append("</urlset>");
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(body.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.UserVisitService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
@RequiredArgsConstructor
|
||||
public class StatController {
|
||||
private final UserVisitService userVisitService;
|
||||
|
||||
@GetMapping("/dau")
|
||||
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
long count = userVisitService.countDau(date);
|
||||
return Map.of("dau", count);
|
||||
}
|
||||
|
||||
@GetMapping("/dau-range")
|
||||
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = userVisitService.countDauRange(start, end);
|
||||
return data.entrySet().stream()
|
||||
.map(e -> Map.<String,Object>of(
|
||||
"date", e.getKey().toString(),
|
||||
"value", e.getValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** Endpoints for subscribing to posts, comments and users. */
|
||||
@RestController
|
||||
@RequestMapping("/api/subscriptions")
|
||||
@RequiredArgsConstructor
|
||||
public class SubscriptionController {
|
||||
private final SubscriptionService subscriptionService;
|
||||
|
||||
@PostMapping("/posts/{postId}")
|
||||
public void subscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.subscribePost(auth.getName(), postId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/posts/{postId}")
|
||||
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.unsubscribePost(auth.getName(), postId);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}")
|
||||
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.subscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.unsubscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
|
||||
@PostMapping("/users/{username}")
|
||||
public void subscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.subscribeUser(auth.getName(), username);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{username}")
|
||||
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.unsubscribeUser(auth.getName(), username);
|
||||
}
|
||||
}
|
||||
125
backend/src/main/java/com/openisle/controller/TagController.java
Normal file
125
backend/src/main/java/com/openisle/controller/TagController.java
Normal file
@@ -0,0 +1,125 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.service.TagService;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.model.PublishMode;
|
||||
import com.openisle.model.Role;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tags")
|
||||
@RequiredArgsConstructor
|
||||
public class TagController {
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PostMapping
|
||||
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
|
||||
boolean approved = true;
|
||||
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
|
||||
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
|
||||
if (user.getRole() != Role.ADMIN) {
|
||||
approved = false;
|
||||
}
|
||||
}
|
||||
Tag tag = tagService.createTag(
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon(),
|
||||
approved,
|
||||
auth != null ? auth.getName() : null);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return toDto(tag, count);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
|
||||
Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return toDto(tag, count);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable Long id) {
|
||||
tagService.deleteTag(id);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
||||
.map(t -> toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
return dtos.subList(0, limit);
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public TagDto get(@PathVariable Long id) {
|
||||
Tag tag = tagService.getTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return toDto(tag, count);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return postService.listPostsByTags(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(p -> {
|
||||
PostSummaryDto dto = new PostSummaryDto();
|
||||
dto.setId(p.getId());
|
||||
dto.setTitle(p.getTitle());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private TagDto toDto(Tag tag, long count) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setIcon(tag.getIcon());
|
||||
dto.setSmallIcon(tag.getSmallIcon());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setCount(count);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TagRequest {
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TagDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private Long count;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostSummaryDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.ImageUploader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/upload")
|
||||
@RequiredArgsConstructor
|
||||
public class UploadController {
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
|
||||
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
String url;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"code", 0,
|
||||
"msg", "ok",
|
||||
"data", Map.of("url", url)
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/url")
|
||||
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
|
||||
String link = body.get("url");
|
||||
if (link == null || link.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
|
||||
}
|
||||
try {
|
||||
URL u = URI.create(link).toURL();
|
||||
byte[] data = u.openStream().readAllBytes();
|
||||
if (data.length > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
String filename = link.substring(link.lastIndexOf('/') + 1);
|
||||
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
|
||||
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
String url = imageUploader.upload(data, filename).join();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"code", 0,
|
||||
"msg", "ok",
|
||||
"data", Map.of("url", url)
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/presign")
|
||||
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
|
||||
return imageUploader.presignUpload(filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final ImageUploader imageUploader;
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
private final ReactionService reactionService;
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final PostReadService postReadService;
|
||||
private final UserVisitService userVisitService;
|
||||
private final LevelService levelService;
|
||||
private final JwtService jwtService;
|
||||
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
|
||||
@Value("${app.user.posts-limit:10}")
|
||||
private int defaultPostsLimit;
|
||||
|
||||
@Value("${app.user.replies-limit:50}")
|
||||
private int defaultRepliesLimit;
|
||||
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
|
||||
@Value("${app.snippet-length:50}")
|
||||
private int snippetLength;
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<UserDto> me(Authentication auth) {
|
||||
User user = userService.findByUsername(auth.getName()).orElseThrow();
|
||||
return ResponseEntity.ok(toDto(user, auth));
|
||||
}
|
||||
|
||||
@PostMapping("/me/avatar")
|
||||
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
|
||||
Authentication auth) {
|
||||
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
|
||||
}
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
|
||||
}
|
||||
String url = null;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("url", url));
|
||||
}
|
||||
userService.updateAvatar(auth.getName(), url);
|
||||
return ResponseEntity.ok(Map.of("url", url));
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
||||
Authentication auth) {
|
||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(user.getUsername()),
|
||||
"user", toDto(user, auth)
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/me/signin")
|
||||
public Map<String, Integer> signIn(Authentication auth) {
|
||||
int reward = levelService.awardForSignin(auth.getName());
|
||||
return Map.of("reward", reward);
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}")
|
||||
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
|
||||
Authentication auth) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
|
||||
return ResponseEntity.ok(toDto(user, auth));
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/posts")
|
||||
public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultPostsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return postService.getRecentPostsByUser(user.getUsername(), l).stream()
|
||||
.map(this::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/replies")
|
||||
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultRepliesLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return commentService.getRecentCommentsByUser(user.getUsername(), l).stream()
|
||||
.map(this::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-posts")
|
||||
public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
|
||||
return postService.getPostsByIds(ids).stream()
|
||||
.map(this::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-replies")
|
||||
public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
|
||||
return commentService.getCommentsByIds(ids).stream()
|
||||
.map(this::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-tags")
|
||||
public java.util.List<TagInfoDto> hotTags(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService.getTagsByUser(user.getUsername()).stream()
|
||||
.map(t -> {
|
||||
TagInfoDto dto = new TagInfoDto();
|
||||
dto.setId(t.getId());
|
||||
dto.setName(t.getName());
|
||||
dto.setDescription(t.getDescription());
|
||||
dto.setIcon(t.getIcon());
|
||||
dto.setSmallIcon(t.getSmallIcon());
|
||||
dto.setCreatedAt(t.getCreatedAt());
|
||||
dto.setCount(postService.countPostsByTag(t.getId()));
|
||||
return dto;
|
||||
})
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.limit(l)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/tags")
|
||||
public java.util.List<TagInfoDto> userTags(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultTagsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
|
||||
.map(t -> {
|
||||
TagInfoDto dto = new TagInfoDto();
|
||||
dto.setId(t.getId());
|
||||
dto.setName(t.getName());
|
||||
dto.setDescription(t.getDescription());
|
||||
dto.setIcon(t.getIcon());
|
||||
dto.setSmallIcon(t.getSmallIcon());
|
||||
dto.setCreatedAt(t.getCreatedAt());
|
||||
dto.setCount(postService.countPostsByTag(t.getId()));
|
||||
return dto;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/following")
|
||||
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
|
||||
.map(this::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/followers")
|
||||
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService.getSubscribers(user.getUsername()).stream()
|
||||
.map(this::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* List all administrator users.
|
||||
*/
|
||||
@GetMapping("/admins")
|
||||
public java.util.List<UserDto> admins() {
|
||||
return userService.getAdmins().stream()
|
||||
.map(this::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/all")
|
||||
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
||||
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
|
||||
Authentication auth) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
|
||||
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
|
||||
java.util.List<PostMetaDto> posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream()
|
||||
.map(this::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
java.util.List<CommentInfoDto> replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream()
|
||||
.map(this::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
UserAggregateDto dto = new UserAggregateDto();
|
||||
dto.setUser(toDto(user, auth));
|
||||
dto.setPosts(posts);
|
||||
dto.setReplies(replies);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
private UserDto toDto(User user, Authentication viewer) {
|
||||
UserDto dto = new UserDto();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setEmail(user.getEmail());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
dto.setRole(user.getRole().name());
|
||||
dto.setIntroduction(user.getIntroduction());
|
||||
dto.setFollowers(subscriptionService.countSubscribers(user.getUsername()));
|
||||
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
|
||||
dto.setCreatedAt(user.getCreatedAt());
|
||||
dto.setLastPostTime(postService.getLastPostTime(user.getUsername()));
|
||||
dto.setTotalViews(postService.getTotalViews(user.getUsername()));
|
||||
dto.setVisitedDays(userVisitService.countVisits(user.getUsername()));
|
||||
dto.setReadPosts(postReadService.countReads(user.getUsername()));
|
||||
dto.setLikesSent(reactionService.countLikesSent(user.getUsername()));
|
||||
dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername()));
|
||||
dto.setExperience(user.getExperience());
|
||||
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
|
||||
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
|
||||
if (viewer != null) {
|
||||
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
|
||||
} else {
|
||||
dto.setSubscribed(false);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private UserDto toDto(User user) {
|
||||
return toDto(user, null);
|
||||
}
|
||||
|
||||
private PostMetaDto toMetaDto(com.openisle.model.Post post) {
|
||||
PostMetaDto dto = new PostMetaDto();
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
String content = post.getContent();
|
||||
if (content == null) {
|
||||
content = "";
|
||||
}
|
||||
if (snippetLength >= 0) {
|
||||
dto.setSnippet(content.length() > snippetLength ? content.substring(0, snippetLength) : content);
|
||||
} else {
|
||||
dto.setSnippet(content);
|
||||
}
|
||||
dto.setCreatedAt(post.getCreatedAt());
|
||||
dto.setCategory(post.getCategory().getName());
|
||||
dto.setViews(post.getViews());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private CommentInfoDto toCommentInfoDto(com.openisle.model.Comment comment) {
|
||||
CommentInfoDto dto = new CommentInfoDto();
|
||||
dto.setId(comment.getId());
|
||||
dto.setContent(comment.getContent());
|
||||
dto.setCreatedAt(comment.getCreatedAt());
|
||||
dto.setPost(toMetaDto(comment.getPost()));
|
||||
if (comment.getParent() != null) {
|
||||
ParentCommentDto pc = new ParentCommentDto();
|
||||
pc.setId(comment.getParent().getId());
|
||||
pc.setAuthor(comment.getParent().getAuthor().getUsername());
|
||||
pc.setContent(comment.getParent().getContent());
|
||||
dto.setParentComment(pc);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UserDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String email;
|
||||
private String avatar;
|
||||
private String role;
|
||||
private String introduction;
|
||||
private long followers;
|
||||
private long following;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private java.time.LocalDateTime lastPostTime;
|
||||
private long totalViews;
|
||||
private long visitedDays;
|
||||
private long readPosts;
|
||||
private long likesSent;
|
||||
private long likesReceived;
|
||||
private boolean subscribed;
|
||||
private int experience;
|
||||
private int currentLevel;
|
||||
private int nextLevelExp;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class PostMetaDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String snippet;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private String category;
|
||||
private long views;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class CommentInfoDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private PostMetaDto post;
|
||||
private ParentCommentDto parentComment;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TagInfoDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private Long count;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ParentCommentDto {
|
||||
private Long id;
|
||||
private String author;
|
||||
private String content;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UpdateProfileDto {
|
||||
private String username;
|
||||
private String introduction;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class UserAggregateDto {
|
||||
private UserDto user;
|
||||
private java.util.List<PostMetaDto> posts;
|
||||
private java.util.List<CommentInfoDto> replies;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openisle.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Exception carrying a target field name. Useful for reporting
|
||||
* validation errors to clients so they can display feedback near
|
||||
* the appropriate input element.
|
||||
*/
|
||||
@Getter
|
||||
public class FieldException extends RuntimeException {
|
||||
private final String field;
|
||||
|
||||
public FieldException(String field, String message) {
|
||||
super(message);
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.openisle.exception;
|
||||
|
||||
/**
|
||||
* Exception representing a missing resource such as a post or user.
|
||||
*/
|
||||
public class NotFoundException extends RuntimeException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.openisle.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a user exceeds allowed action rate.
|
||||
*/
|
||||
public class RateLimitException extends RuntimeException {
|
||||
public RateLimitException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
50
backend/src/main/java/com/openisle/model/Activity.java
Normal file
50
backend/src/main/java/com/openisle/model/Activity.java
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
|
||||
/** Generic activity entity. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "activities")
|
||||
public class Activity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
private String icon;
|
||||
|
||||
private String content;
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
@CreationTimestamp
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@Column(name = "end_time")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private ActivityType type = ActivityType.NORMAL;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "activity_participants",
|
||||
joinColumns = @JoinColumn(name = "activity_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
||||
private Set<User> participants = new HashSet<>();
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean ended = false;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/** Activity type enumeration. */
|
||||
public enum ActivityType {
|
||||
NORMAL,
|
||||
MILK_TEA
|
||||
}
|
||||
31
backend/src/main/java/com/openisle/model/AiFormatUsage.java
Normal file
31
backend/src/main/java/com/openisle/model/AiFormatUsage.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Daily count of AI markdown formatting usage for a user. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "ai_format_usage",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "use_date"}))
|
||||
public class AiFormatUsage {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column(name = "use_date", nullable = false)
|
||||
private LocalDate useDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int count;
|
||||
}
|
||||
29
backend/src/main/java/com/openisle/model/Category.java
Normal file
29
backend/src/main/java/com/openisle/model/Category.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "categories")
|
||||
public class Category {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String icon;
|
||||
|
||||
@Column
|
||||
private String smallIcon;
|
||||
|
||||
@Column(name = "description", nullable = false)
|
||||
private String description;
|
||||
}
|
||||
41
backend/src/main/java/com/openisle/model/Comment.java
Normal file
41
backend/src/main/java/com/openisle/model/Comment.java
Normal file
@@ -0,0 +1,41 @@
|
||||
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 = "comments")
|
||||
public class Comment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "author_id")
|
||||
private User author;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "post_id")
|
||||
private Post post;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "parent_id")
|
||||
private Comment parent;
|
||||
|
||||
}
|
||||
10
backend/src/main/java/com/openisle/model/CommentSort.java
Normal file
10
backend/src/main/java/com/openisle/model/CommentSort.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Sort options for comments.
|
||||
*/
|
||||
public enum CommentSort {
|
||||
NEWEST,
|
||||
OLDEST,
|
||||
MOST_INTERACTIONS
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Subscription to a comment for replies notifications. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "comment_subscriptions",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "comment_id"}))
|
||||
public class CommentSubscription {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "comment_id")
|
||||
private Comment comment;
|
||||
}
|
||||
41
backend/src/main/java/com/openisle/model/Draft.java
Normal file
41
backend/src/main/java/com/openisle/model/Draft.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "drafts", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"author_id"})
|
||||
})
|
||||
public class Draft {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "author_id")
|
||||
private User author;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "category_id")
|
||||
private Category category;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "draft_tags",
|
||||
joinColumns = @JoinColumn(name = "draft_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<Tag> tags = new HashSet<>();
|
||||
}
|
||||
37
backend/src/main/java/com/openisle/model/ExperienceLog.java
Normal file
37
backend/src/main/java/com/openisle/model/ExperienceLog.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Daily experience gain counts for a user. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "experience_logs",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "log_date"}))
|
||||
public class ExperienceLog {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column(name = "log_date", nullable = false)
|
||||
private LocalDate logDate;
|
||||
|
||||
@Column(name = "post_count", nullable = false)
|
||||
private int postCount;
|
||||
|
||||
@Column(name = "comment_count", nullable = false)
|
||||
private int commentCount;
|
||||
|
||||
@Column(name = "reaction_count", nullable = false)
|
||||
private int reactionCount;
|
||||
}
|
||||
26
backend/src/main/java/com/openisle/model/Image.java
Normal file
26
backend/src/main/java/com/openisle/model/Image.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Image entity tracking COS image reference counts.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "images")
|
||||
public class Image {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 512)
|
||||
private String url;
|
||||
|
||||
@Column(nullable = false)
|
||||
private long refCount = 0;
|
||||
}
|
||||
61
backend/src/main/java/com/openisle/model/Notification.java
Normal file
61
backend/src/main/java/com/openisle/model/Notification.java
Normal file
@@ -0,0 +1,61 @@
|
||||
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 representing a user notification.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "notifications")
|
||||
public class Notification {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private NotificationType type;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@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;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reaction_type")
|
||||
private ReactionType reactionType;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String content;
|
||||
|
||||
@Column
|
||||
private Boolean approved;
|
||||
|
||||
@Column(name = "is_read", nullable = false)
|
||||
private boolean read = false;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Types of user notifications.
|
||||
*/
|
||||
public enum NotificationType {
|
||||
/** Someone viewed your post */
|
||||
POST_VIEWED,
|
||||
/** Someone replied to your post or comment */
|
||||
COMMENT_REPLY,
|
||||
/** Someone reacted to your post or comment */
|
||||
REACTION,
|
||||
/** A new post is waiting for review */
|
||||
POST_REVIEW_REQUEST,
|
||||
/** Your post under review was approved or rejected */
|
||||
POST_REVIEWED,
|
||||
/** A subscribed post received a new comment */
|
||||
POST_UPDATED,
|
||||
/** Someone subscribed to your post */
|
||||
POST_SUBSCRIBED,
|
||||
/** Someone unsubscribed from your post */
|
||||
POST_UNSUBSCRIBED,
|
||||
/** Someone you follow published a new post */
|
||||
FOLLOWED_POST,
|
||||
/** Someone started following you */
|
||||
USER_FOLLOWED,
|
||||
/** Someone unfollowed you */
|
||||
USER_UNFOLLOWED,
|
||||
/** A user you subscribe to created a post or comment */
|
||||
USER_ACTIVITY,
|
||||
/** A user requested registration approval */
|
||||
REGISTER_REQUEST,
|
||||
/** A user redeemed an activity reward */
|
||||
ACTIVITY_REDEEM,
|
||||
/** You were mentioned in a post or comment */
|
||||
MENTION
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.openisle.model;
|
||||
|
||||
public enum PasswordStrength {
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
65
backend/src/main/java/com/openisle/model/Post.java
Normal file
65
backend/src/main/java/com/openisle/model/Post.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import com.openisle.model.Tag;
|
||||
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Post entity representing an article posted by a user.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "posts")
|
||||
public class Post {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "author_id")
|
||||
private User author;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "category_id")
|
||||
private Category category;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "post_tags",
|
||||
joinColumns = @JoinColumn(name = "post_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private java.util.Set<Tag> tags = new java.util.HashSet<>();
|
||||
|
||||
@Column(nullable = false)
|
||||
private long views = 0;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostStatus status = PostStatus.PUBLISHED;
|
||||
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
}
|
||||
32
backend/src/main/java/com/openisle/model/PostRead.java
Normal file
32
backend/src/main/java/com/openisle/model/PostRead.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Record of a user reading a post. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "post_reads",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"}))
|
||||
public class PostRead {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "post_id")
|
||||
private Post post;
|
||||
|
||||
@Column(name = "last_read_at")
|
||||
private LocalDateTime lastReadAt;
|
||||
}
|
||||
10
backend/src/main/java/com/openisle/model/PostStatus.java
Normal file
10
backend/src/main/java/com/openisle/model/PostStatus.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Status of a post during its lifecycle.
|
||||
*/
|
||||
public enum PostStatus {
|
||||
PUBLISHED,
|
||||
PENDING,
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Subscription to a post for update notifications. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "post_subscriptions",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"}))
|
||||
public class PostSubscription {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "post_id")
|
||||
private Post post;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Application-wide article publish mode.
|
||||
*/
|
||||
public enum PublishMode {
|
||||
DIRECT,
|
||||
REVIEW
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 storing a browser push subscription for a user.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "push_subscriptions")
|
||||
public class PushSubscription {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false, length = 512)
|
||||
private String endpoint;
|
||||
|
||||
@Column(nullable = false, length = 256)
|
||||
private String p256dh;
|
||||
|
||||
@Column(nullable = false, length = 256)
|
||||
private String auth;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
46
backend/src/main/java/com/openisle/model/Reaction.java
Normal file
46
backend/src/main/java/com/openisle/model/Reaction.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
/**
|
||||
* Reaction entity representing a user's reaction to a post or comment.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "reactions",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
|
||||
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
|
||||
})
|
||||
public class Reaction {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private ReactionType type;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "post_id")
|
||||
private Post post;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "comment_id")
|
||||
private Comment comment;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private java.time.LocalDateTime createdAt;
|
||||
}
|
||||
19
backend/src/main/java/com/openisle/model/ReactionType.java
Normal file
19
backend/src/main/java/com/openisle/model/ReactionType.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Enum of possible reaction types for posts and comments.
|
||||
*/
|
||||
public enum ReactionType {
|
||||
LIKE,
|
||||
DISLIKE,
|
||||
RECOMMEND,
|
||||
ANGRY,
|
||||
FLUSHED,
|
||||
STAR_STRUCK,
|
||||
ROFL,
|
||||
HOLDING_BACK_TEARS,
|
||||
MIND_BLOWN,
|
||||
POOP,
|
||||
CLOWN,
|
||||
SKULL
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.openisle.model;
|
||||
|
||||
/**
|
||||
* Application-wide user registration mode.
|
||||
*/
|
||||
public enum RegisterMode {
|
||||
DIRECT,
|
||||
WHITELIST
|
||||
}
|
||||
6
backend/src/main/java/com/openisle/model/Role.java
Normal file
6
backend/src/main/java/com/openisle/model/Role.java
Normal file
@@ -0,0 +1,6 @@
|
||||
package com.openisle.model;
|
||||
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
USER
|
||||
}
|
||||
45
backend/src/main/java/com/openisle/model/Tag.java
Normal file
45
backend/src/main/java/com/openisle/model/Tag.java
Normal file
@@ -0,0 +1,45 @@
|
||||
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 = "tags")
|
||||
public class Tag {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
@Column
|
||||
private String icon;
|
||||
|
||||
@Column
|
||||
private String smallIcon;
|
||||
|
||||
@Column(name = "description", nullable = false)
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean approved = true;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "creator_id")
|
||||
private User creator;
|
||||
}
|
||||
64
backend/src/main/java/com/openisle/model/User.java
Normal file
64
backend/src/main/java/com/openisle/model/User.java
Normal file
@@ -0,0 +1,64 @@
|
||||
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 com.openisle.model.Role;
|
||||
|
||||
/**
|
||||
* Simple user entity with basic fields and a role.
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean verified = false;
|
||||
|
||||
private String verificationCode;
|
||||
|
||||
private String passwordResetCode;
|
||||
|
||||
private String avatar;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int experience = 0;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String introduction;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String registerReason;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean approved = true;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role = Role.USER;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Following relationship between users. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "user_subscriptions",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"subscriber_id", "target_id"}))
|
||||
public class UserSubscription {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "subscriber_id")
|
||||
private User subscriber;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "target_id")
|
||||
private User target;
|
||||
}
|
||||
28
backend/src/main/java/com/openisle/model/UserVisit.java
Normal file
28
backend/src/main/java/com/openisle/model/UserVisit.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Daily visit record for a user. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "user_visits",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "visit_date"}))
|
||||
public class UserVisit {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column(name = "visit_date", nullable = false)
|
||||
private LocalDate visitDate;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Activity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ActivityRepository extends JpaRepository<Activity, Long> {
|
||||
Activity findByType(com.openisle.model.ActivityType type);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.AiFormatUsage;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AiFormatUsageRepository extends JpaRepository<AiFormatUsage, Long> {
|
||||
Optional<AiFormatUsage> findByUserAndUseDate(User user, LocalDate useDate);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CategoryRepository extends JpaRepository<Category, Long> {
|
||||
List<Category> findByNameContainingIgnoreCase(String keyword);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import com.openisle.model.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("SELECT DISTINCT c.author FROM Comment c WHERE c.post = :post")
|
||||
java.util.List<User> findDistinctAuthorsByPost(@org.springframework.data.repository.query.Param("post") Post post);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post")
|
||||
java.time.LocalDateTime findLastCommentTime(@org.springframework.data.repository.query.Param("post") Post post);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.author.username = :username AND c.createdAt >= :start")
|
||||
long countByAuthorAfter(@org.springframework.data.repository.query.Param("username") String username,
|
||||
@org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSubscription;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CommentSubscriptionRepository extends JpaRepository<CommentSubscription, Long> {
|
||||
List<CommentSubscription> findByComment(Comment comment);
|
||||
List<CommentSubscription> findByUser(User user);
|
||||
Optional<CommentSubscription> findByUserAndComment(User user, Comment comment);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Draft;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface DraftRepository extends JpaRepository<Draft, Long> {
|
||||
Optional<Draft> findByAuthor(User author);
|
||||
void deleteByAuthor(User author);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.ExperienceLog;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ExperienceLogRepository extends JpaRepository<ExperienceLog, Long> {
|
||||
Optional<ExperienceLog> findByUserAndLogDate(User user, LocalDate logDate);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Image;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository for images stored on COS.
|
||||
*/
|
||||
public interface ImageRepository extends JpaRepository<Image, Long> {
|
||||
Optional<Image> findByUrl(String url);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Notification;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.NotificationType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Repository for Notification entities. */
|
||||
public interface NotificationRepository extends JpaRepository<Notification, Long> {
|
||||
List<Notification> findByUserOrderByCreatedAtDesc(User user);
|
||||
List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read);
|
||||
long countByUserAndRead(User user, boolean read);
|
||||
List<Notification> findByPost(Post post);
|
||||
List<Notification> findByComment(Comment comment);
|
||||
|
||||
void deleteByTypeAndFromUser(NotificationType type, User fromUser);
|
||||
|
||||
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||
long countByUser(User user);
|
||||
void deleteByPost(Post post);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Tag;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByStatus(PostStatus status);
|
||||
List<Post> findByStatus(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status);
|
||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(User author, PostStatus status, Pageable pageable);
|
||||
List<Post> findByCategoryInAndStatus(List<Category> categories, PostStatus status);
|
||||
List<Post> findByCategoryInAndStatus(List<Category> categories, PostStatus status, Pageable pageable);
|
||||
List<Post> findByCategoryInAndStatusOrderByCreatedAtDesc(List<Category> categories, PostStatus status);
|
||||
List<Post> findByCategoryInAndStatusOrderByCreatedAtDesc(List<Category> categories, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatus(List<Category> categories, List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatus(List<Category> categories, List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc(List<Category> categories, List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc(List<Category> categories, List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
|
||||
// Queries requiring all provided tags to be present
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount")
|
||||
List<Post> findByAllTags(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount")
|
||||
List<Post> findByAllTags(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC")
|
||||
List<Post> findByAllTagsOrderByCreatedAtDesc(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC")
|
||||
List<Post> findByAllTagsOrderByCreatedAtDesc(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC")
|
||||
List<Post> findByAllTagsOrderByViewsDesc(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC")
|
||||
List<Post> findByAllTagsOrderByViewsDesc(@Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount")
|
||||
List<Post> findByCategoriesAndAllTags(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount")
|
||||
List<Post> findByCategoriesAndAllTags(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC")
|
||||
List<Post> findByCategoriesAndAllTagsOrderByViewsDesc(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC")
|
||||
List<Post> findByCategoriesAndAllTagsOrderByViewsDesc(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
@Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC")
|
||||
List<Post> findByCategoriesAndAllTagsOrderByCreatedAtDesc(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount);
|
||||
|
||||
@Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC")
|
||||
List<Post> findByCategoriesAndAllTagsOrderByCreatedAtDesc(@Param("categories") List<Category> categories, @Param("tags") List<Tag> tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable);
|
||||
|
||||
List<Post> findByCategoryInAndStatusOrderByViewsDesc(List<Category> categories, PostStatus status);
|
||||
List<Post> findByCategoryInAndStatusOrderByViewsDesc(List<Category> categories, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByViewsDesc(List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByViewsDesc(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc(List<Category> categories, List<Tag> tags, PostStatus status);
|
||||
List<Post> findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc(List<Category> categories, List<Tag> tags, PostStatus status, Pageable pageable);
|
||||
List<Post> findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(String titleKeyword, String contentKeyword, PostStatus status);
|
||||
List<Post> findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
|
||||
List<Post> findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
|
||||
|
||||
@Query("SELECT MAX(p.createdAt) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED")
|
||||
LocalDateTime findLastPostTime(@Param("username") String username);
|
||||
|
||||
@Query("SELECT SUM(p.views) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED")
|
||||
Long sumViews(@Param("username") String username);
|
||||
|
||||
@Query("SELECT COUNT(p) FROM Post p WHERE p.author.username = :username AND p.createdAt >= :start")
|
||||
long countByAuthorAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start);
|
||||
|
||||
long countByCategory_Id(Long categoryId);
|
||||
|
||||
long countDistinctByTags_Id(Long tagId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostSubscription;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PostSubscriptionRepository extends JpaRepository<PostSubscription, Long> {
|
||||
List<PostSubscription> findByPost(Post post);
|
||||
List<PostSubscription> findByUser(User user);
|
||||
Optional<PostSubscription> findByUserAndPost(User user, Post post);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PushSubscription;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PushSubscriptionRepository extends JpaRepository<PushSubscription, Long> {
|
||||
List<PushSubscription> findByUser(User user);
|
||||
void deleteByUserAndEndpoint(User user, String endpoint);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
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.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
|
||||
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
|
||||
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
|
||||
List<Reaction> findByPost(Post post);
|
||||
List<Reaction> findByComment(Comment comment);
|
||||
|
||||
@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);
|
||||
|
||||
@Query("SELECT r.comment.id FROM Reaction r WHERE r.comment IS NOT NULL AND r.comment.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.comment.id ORDER BY COUNT(r.id) DESC")
|
||||
List<Long> findTopCommentIds(@Param("username") String username, Pageable pageable);
|
||||
|
||||
@Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.type = com.openisle.model.ReactionType.LIKE")
|
||||
long countLikesSent(@Param("username") String username);
|
||||
|
||||
@Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.createdAt >= :start")
|
||||
long countByUserAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start);
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(r) FROM Reaction r
|
||||
LEFT JOIN r.post p
|
||||
LEFT JOIN r.comment c
|
||||
WHERE r.type = com.openisle.model.ReactionType.LIKE AND
|
||||
((p IS NOT NULL AND p.author.username = :username) OR
|
||||
(c IS NOT NULL AND c.author.username = :username))
|
||||
""")
|
||||
long countLikesReceived(@Param("username") String username);
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(r) FROM Reaction r
|
||||
LEFT JOIN r.post p
|
||||
LEFT JOIN r.comment c
|
||||
WHERE (p IS NOT NULL AND p.author.username = :username) OR
|
||||
(c IS NOT NULL AND c.author.username = :username)
|
||||
""")
|
||||
long countReceived(@Param("username") String username);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TagRepository extends JpaRepository<Tag, Long> {
|
||||
List<Tag> findByNameContainingIgnoreCase(String keyword);
|
||||
List<Tag> findByApproved(boolean approved);
|
||||
List<Tag> findByApprovedTrue();
|
||||
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
|
||||
|
||||
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
|
||||
List<Tag> findByCreator(User creator);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import com.openisle.model.User;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
Optional<User> findByEmail(String email);
|
||||
java.util.List<User> findByUsernameContainingIgnoreCase(String keyword);
|
||||
java.util.List<User> findByRole(com.openisle.model.Role role);
|
||||
long countByExperienceGreaterThanEqual(int experience);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.UserSubscription;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserSubscriptionRepository extends JpaRepository<UserSubscription, Long> {
|
||||
List<UserSubscription> findBySubscriber(User subscriber);
|
||||
List<UserSubscription> findByTarget(User target);
|
||||
Optional<UserSubscription> findBySubscriberAndTarget(User subscriber, User target);
|
||||
long countByTarget(User target);
|
||||
long countBySubscriber(User subscriber);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.UserVisit;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserVisitRepository extends JpaRepository<UserVisit, Long> {
|
||||
Optional<UserVisit> findByUserAndVisitDate(User user, LocalDate visitDate);
|
||||
long countByUser(User user);
|
||||
long countByVisitDate(LocalDate visitDate);
|
||||
|
||||
@Query("SELECT uv.visitDate AS d, COUNT(uv) AS c FROM UserVisit uv WHERE uv.visitDate BETWEEN :start AND :end GROUP BY uv.visitDate ORDER BY uv.visitDate")
|
||||
java.util.List<Object[]> countRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.ActivityRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityService {
|
||||
private final ActivityRepository activityRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final LevelService levelService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public List<Activity> list() {
|
||||
return activityRepository.findAll();
|
||||
}
|
||||
|
||||
public Activity getByType(ActivityType type) {
|
||||
Activity a = activityRepository.findByType(type);
|
||||
if (a == null) throw new NotFoundException("Activity not found");
|
||||
return a;
|
||||
}
|
||||
|
||||
public long countLevel1Users() {
|
||||
int threshold = levelService.nextLevelExp(0);
|
||||
return userRepository.countByExperienceGreaterThanEqual(threshold);
|
||||
}
|
||||
|
||||
public void end(Activity activity) {
|
||||
activity.setEnded(true);
|
||||
activityRepository.save(activity);
|
||||
}
|
||||
|
||||
public long countParticipants(Activity activity) {
|
||||
return activity.getParticipants().size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an activity for the given user.
|
||||
*
|
||||
* @return true if the user redeemed for the first time, false if the
|
||||
* information was simply updated
|
||||
*/
|
||||
public boolean redeem(Activity activity, User user, String contact) {
|
||||
notificationService.createActivityRedeemNotifications(user, contact);
|
||||
boolean added = activity.getParticipants().add(user);
|
||||
activityRepository.save(activity);
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.AiFormatUsage;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.AiFormatUsageRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiUsageService {
|
||||
private final AiFormatUsageRepository usageRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Value("${app.ai.format-limit:3}")
|
||||
private int formatLimit;
|
||||
|
||||
public int getFormatLimit() {
|
||||
return formatLimit;
|
||||
}
|
||||
|
||||
public void setFormatLimit(int formatLimit) {
|
||||
this.formatLimit = formatLimit;
|
||||
}
|
||||
|
||||
public int incrementAndGetCount(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
LocalDate today = LocalDate.now();
|
||||
AiFormatUsage usage = usageRepository.findByUserAndUseDate(user, today)
|
||||
.orElseGet(() -> {
|
||||
AiFormatUsage u = new AiFormatUsage();
|
||||
u.setUser(user);
|
||||
u.setUseDate(today);
|
||||
u.setCount(0);
|
||||
return u;
|
||||
});
|
||||
usage.setCount(usage.getCount() + 1);
|
||||
usageRepository.save(usage);
|
||||
return usage.getCount();
|
||||
}
|
||||
|
||||
public int getCount(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
return usageRepository.findByUserAndUseDate(user, LocalDate.now())
|
||||
.map(AiFormatUsage::getCount)
|
||||
.orElse(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Service
|
||||
public class AvatarGenerator {
|
||||
|
||||
@Value("${app.avatar.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${app.avatar.style}")
|
||||
private String style;
|
||||
|
||||
@Value("${app.avatar.size}")
|
||||
private int size;
|
||||
|
||||
public String generate(String seed) {
|
||||
String encoded = URLEncoder.encode(seed, StandardCharsets.UTF_8);
|
||||
return String.format("%s/%s/png?seed=%s&size=%d", baseUrl, style, encoded, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.service;
|
||||
|
||||
/**
|
||||
* Abstract service for verifying CAPTCHA tokens.
|
||||
*/
|
||||
public abstract class CaptchaService {
|
||||
/**
|
||||
* Verify the CAPTCHA token sent from client.
|
||||
*
|
||||
* @param token CAPTCHA token
|
||||
* @return true if token is valid
|
||||
*/
|
||||
public abstract boolean verify(String token);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||
Category category = new Category();
|
||||
category.setName(name);
|
||||
category.setDescription(description);
|
||||
category.setIcon(icon);
|
||||
category.setSmallIcon(smallIcon);
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
|
||||
Category category = categoryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
if (name != null) {
|
||||
category.setName(name);
|
||||
}
|
||||
if (description != null) {
|
||||
category.setDescription(description);
|
||||
}
|
||||
if (icon != null) {
|
||||
category.setIcon(icon);
|
||||
}
|
||||
if (smallIcon != null) {
|
||||
category.setSmallIcon(smallIcon);
|
||||
}
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public Category getCategory(Long id) {
|
||||
return categoryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
}
|
||||
|
||||
public List<Category> listCategories() {
|
||||
return categoryRepository.findAll();
|
||||
}
|
||||
}
|
||||
185
backend/src/main/java/com/openisle/service/CommentService.java
Normal file
185
backend/src/main/java/com/openisle/service/CommentService.java
Normal file
@@ -0,0 +1,185 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
private final CommentRepository commentRepository;
|
||||
private final PostRepository postRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
public Comment addComment(String username, Long postId, String content) {
|
||||
long recent = commentRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(1));
|
||||
if (recent >= 3) {
|
||||
throw new RateLimitException("Too many comments");
|
||||
}
|
||||
User author = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(post);
|
||||
comment.setContent(content);
|
||||
comment = commentRepository.save(comment);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(post.getAuthor().getId())) {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(postId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
public Comment addReply(String username, Long parentId, String content) {
|
||||
long recent = commentRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(1));
|
||||
if (recent >= 3) {
|
||||
throw new RateLimitException("Too many comments");
|
||||
}
|
||||
User author = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Comment parent = commentRepository.findById(parentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(parent.getPost());
|
||||
comment.setParent(parent);
|
||||
comment.setContent(content);
|
||||
comment = commentRepository.save(comment);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(parent.getAuthor().getId())) {
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
public List<Comment> getCommentsForPost(Long postId, CommentSort sort) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
||||
if (sort == CommentSort.NEWEST) {
|
||||
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
||||
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<Comment> getReplies(Long parentId) {
|
||||
Comment parent = commentRepository.findById(parentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
return commentRepository.findByParentOrderByCreatedAtAsc(parent);
|
||||
}
|
||||
|
||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
return commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable);
|
||||
}
|
||||
|
||||
public java.util.List<User> getParticipants(Long postId, int limit) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
java.util.LinkedHashSet<User> set = new java.util.LinkedHashSet<>();
|
||||
set.add(post.getAuthor());
|
||||
set.addAll(commentRepository.findDistinctAuthorsByPost(post));
|
||||
java.util.List<User> list = new java.util.ArrayList<>(set);
|
||||
return list.subList(0, Math.min(limit, list.size()));
|
||||
}
|
||||
|
||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||
return commentRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastCommentTime(Long postId) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
return commentRepository.findLastCommentTime(post);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deleteComment(String username, Long id) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Comment comment = commentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
if (!user.getId().equals(comment.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
deleteCommentCascade(comment);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deleteCommentCascade(Comment comment) {
|
||||
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(comment);
|
||||
for (Comment c : replies) {
|
||||
deleteCommentCascade(c);
|
||||
}
|
||||
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||
commentRepository.delete(comment);
|
||||
}
|
||||
|
||||
private int interactionCount(Comment comment) {
|
||||
int reactions = reactionRepository.findByComment(comment).size();
|
||||
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
||||
return reactions + replies;
|
||||
}
|
||||
}
|
||||
124
backend/src/main/java/com/openisle/service/CosImageUploader.java
Normal file
124
backend/src/main/java/com/openisle/service/CosImageUploader.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.auth.COSCredentials;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.http.HttpMethodName;
|
||||
import com.qcloud.cos.model.GeneratePresignedUrlRequest;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* ImageUploader implementation using Tencent Cloud COS.
|
||||
*/
|
||||
@Service
|
||||
public class CosImageUploader extends ImageUploader {
|
||||
|
||||
private final COSClient cosClient;
|
||||
private final String bucketName;
|
||||
private final String baseUrl;
|
||||
private static final String UPLOAD_DIR = "dynamic_assert/";
|
||||
private static final Logger logger = LoggerFactory.getLogger(CosImageUploader.class);
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(2,
|
||||
new CustomizableThreadFactory("cos-upload-"));
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public CosImageUploader(
|
||||
com.openisle.repository.ImageRepository imageRepository,
|
||||
@Value("${cos.secret-id:}") String secretId,
|
||||
@Value("${cos.secret-key:}") String secretKey,
|
||||
@Value("${cos.region:ap-guangzhou}") String region,
|
||||
@Value("${cos.bucket-name:}") String bucketName,
|
||||
@Value("${cos.base-url:https://example.com}") String baseUrl) {
|
||||
super(imageRepository, baseUrl);
|
||||
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
|
||||
ClientConfig config = new ClientConfig(new Region(region));
|
||||
this.cosClient = new COSClient(cred, config);
|
||||
this.bucketName = bucketName;
|
||||
this.baseUrl = baseUrl;
|
||||
logger.debug("COS client initialized for region {} with bucket {}", region, bucketName);
|
||||
}
|
||||
|
||||
// for tests
|
||||
CosImageUploader(COSClient cosClient,
|
||||
com.openisle.repository.ImageRepository imageRepository,
|
||||
String bucketName,
|
||||
String baseUrl) {
|
||||
super(imageRepository, baseUrl);
|
||||
this.cosClient = cosClient;
|
||||
this.bucketName = bucketName;
|
||||
this.baseUrl = baseUrl;
|
||||
logger.debug("COS client provided directly with bucket {}", bucketName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CompletableFuture<String> doUpload(byte[] data, String filename) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
logger.debug("Uploading {} bytes as {}", data.length, filename);
|
||||
String ext = "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot != -1) {
|
||||
ext = filename.substring(dot);
|
||||
}
|
||||
String randomName = UUID.randomUUID().toString().replace("-", "") + ext;
|
||||
String objectKey = UPLOAD_DIR + randomName;
|
||||
logger.debug("Generated object key {}", objectKey);
|
||||
|
||||
ObjectMetadata meta = new ObjectMetadata();
|
||||
meta.setContentLength(data.length);
|
||||
PutObjectRequest req = new PutObjectRequest(
|
||||
bucketName,
|
||||
objectKey,
|
||||
new ByteArrayInputStream(data),
|
||||
meta);
|
||||
logger.debug("Sending PutObject request to bucket {}", bucketName);
|
||||
cosClient.putObject(req);
|
||||
String url = baseUrl + "/" + objectKey;
|
||||
logger.debug("Upload successful, accessible at {}", url);
|
||||
return url;
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void deleteFromStore(String key) {
|
||||
try {
|
||||
cosClient.deleteObject(bucketName, key);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to delete image {} from COS", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Map<String, String> presignUpload(String filename) {
|
||||
String ext = "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot != -1) {
|
||||
ext = filename.substring(dot);
|
||||
}
|
||||
String randomName = java.util.UUID.randomUUID().toString().replace("-", "") + ext;
|
||||
String objectKey = UPLOAD_DIR + randomName;
|
||||
java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + 15 * 60 * 1000L);
|
||||
GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucketName, objectKey, HttpMethodName.PUT);
|
||||
req.setExpiration(expiration);
|
||||
java.net.URL url = cosClient.generatePresignedUrl(req);
|
||||
String fileUrl = baseUrl + "/" + objectKey;
|
||||
return java.util.Map.of(
|
||||
"uploadUrl", url.toString(),
|
||||
"fileUrl", fileUrl,
|
||||
"key", objectKey
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DiscordAuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Value("${discord.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
@Value("${discord.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
body.add("client_id", clientId);
|
||||
body.add("client_secret", clientSecret);
|
||||
body.add("grant_type", "authorization_code");
|
||||
body.add("code", code);
|
||||
if (redirectUri != null) {
|
||||
body.add("redirect_uri", redirectUri);
|
||||
}
|
||||
body.add("scope", "identify email");
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class);
|
||||
if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String accessToken = tokenRes.getBody().get("access_token").asText();
|
||||
HttpHeaders authHeaders = new HttpHeaders();
|
||||
authHeaders.setBearerAuth(accessToken);
|
||||
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
|
||||
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
|
||||
"https://discord.com/api/users/@me", HttpMethod.GET, entity, JsonNode.class);
|
||||
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
JsonNode userNode = userRes.getBody();
|
||||
String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null;
|
||||
String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null;
|
||||
String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null;
|
||||
String avatar = null;
|
||||
if (userNode.hasNonNull("avatar") && id != null) {
|
||||
avatar = "https://cdn.discordapp.com/avatars/" + id + "/" + userNode.get("avatar").asText() + ".png";
|
||||
}
|
||||
if (email == null) {
|
||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatar, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(finalUsername).isPresent()) {
|
||||
finalUsername = baseUsername + suffix++;
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(finalUsername);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
76
backend/src/main/java/com/openisle/service/DraftService.java
Normal file
76
backend/src/main/java/com/openisle/service/DraftService.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Draft;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.DraftRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.ImageUploader;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DraftService {
|
||||
private final DraftRepository draftRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Transactional
|
||||
public Draft saveDraft(String username, Long categoryId, String title, String content, List<Long> tagIds) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Draft draft = draftRepository.findByAuthor(user).orElse(new Draft());
|
||||
String oldContent = draft.getContent();
|
||||
boolean existing = draft.getId() != null;
|
||||
draft.setAuthor(user);
|
||||
draft.setTitle(title);
|
||||
draft.setContent(content);
|
||||
if (categoryId != null) {
|
||||
Category category = categoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
draft.setCategory(category);
|
||||
} else {
|
||||
draft.setCategory(null);
|
||||
}
|
||||
Set<Tag> tags = new HashSet<>();
|
||||
if (tagIds != null && !tagIds.isEmpty()) {
|
||||
tags.addAll(tagRepository.findAllById(tagIds));
|
||||
}
|
||||
draft.setTags(tags);
|
||||
Draft saved = draftRepository.save(draft);
|
||||
if (existing) {
|
||||
imageUploader.adjustReferences(oldContent == null ? "" : oldContent, content);
|
||||
} else {
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Draft> getDraft(String username) {
|
||||
return userRepository.findByUsername(username)
|
||||
.flatMap(draftRepository::findByAuthor);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDraft(String username) {
|
||||
userRepository.findByUsername(username).ifPresent(user ->
|
||||
draftRepository.findByAuthor(user).ifPresent(draft -> {
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(draft.getContent()));
|
||||
draftRepository.delete(draft);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
14
backend/src/main/java/com/openisle/service/EmailSender.java
Normal file
14
backend/src/main/java/com/openisle/service/EmailSender.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.openisle.service;
|
||||
|
||||
/**
|
||||
* Abstract email sender used to deliver emails.
|
||||
*/
|
||||
public abstract class EmailSender {
|
||||
/**
|
||||
* Send an email to a recipient.
|
||||
* @param to recipient email address
|
||||
* @param subject email subject
|
||||
* @param text email body
|
||||
*/
|
||||
public abstract void sendEmail(String to, String subject, String text);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import com.openisle.service.AvatarGenerator;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GithubAuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final AvatarGenerator avatarGenerator;
|
||||
|
||||
@Value("${github.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
@Value("${github.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("client_id", clientId);
|
||||
body.put("client_secret", clientSecret);
|
||||
body.put("code", code);
|
||||
if (redirectUri != null) {
|
||||
body.put("redirect_uri", redirectUri);
|
||||
}
|
||||
|
||||
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class);
|
||||
if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String accessToken = tokenRes.getBody().get("access_token").asText();
|
||||
HttpHeaders authHeaders = new HttpHeaders();
|
||||
authHeaders.setBearerAuth(accessToken);
|
||||
authHeaders.set(HttpHeaders.USER_AGENT, "OpenIsle");
|
||||
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
|
||||
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
|
||||
"https://api.github.com/user", HttpMethod.GET, entity, JsonNode.class);
|
||||
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
JsonNode userNode = userRes.getBody();
|
||||
String username = userNode.hasNonNull("login") ? userNode.get("login").asText() : null;
|
||||
String avatarUrl = userNode.hasNonNull("avatar_url") ? userNode.get("avatar_url").asText() : null;
|
||||
String email = null;
|
||||
if (userNode.hasNonNull("email")) {
|
||||
email = userNode.get("email").asText();
|
||||
}
|
||||
if (email == null || email.isEmpty()) {
|
||||
try {
|
||||
ResponseEntity<JsonNode> emailsRes = restTemplate.exchange(
|
||||
"https://api.github.com/user/emails", HttpMethod.GET, entity, JsonNode.class);
|
||||
if (emailsRes.getStatusCode().is2xxSuccessful() && emailsRes.getBody() != null && emailsRes.getBody().isArray()) {
|
||||
for (JsonNode n : emailsRes.getBody()) {
|
||||
if (n.has("primary") && n.get("primary").asBoolean()) {
|
||||
email = n.get("email").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (HttpClientErrorException ignored) {
|
||||
// ignore when the email API is not accessible
|
||||
}
|
||||
}
|
||||
if (email == null) {
|
||||
email = username + "@users.noreply.github.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(finalUsername).isPresent()) {
|
||||
finalUsername = baseUsername + suffix++;
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(finalUsername);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||
import com.google.api.client.json.jackson2.JacksonFactory;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.openisle.service.AvatarGenerator;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GoogleAuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarGenerator avatarGenerator;
|
||||
|
||||
@Value("${google.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||
.setAudience(Collections.singletonList(clientId))
|
||||
.build();
|
||||
try {
|
||||
GoogleIdToken idToken = verifier.verify(idTokenString);
|
||||
if (idToken == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
GoogleIdToken.Payload payload = idToken.getPayload();
|
||||
String email = payload.getEmail();
|
||||
String name = (String) payload.get("name");
|
||||
String picture = (String) payload.get("picture");
|
||||
return Optional.of(processUser(email, name, picture, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
User user = new User();
|
||||
String baseUsername = email.split("@")[0];
|
||||
String username = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(username).isPresent()) {
|
||||
username = baseUsername + suffix++;
|
||||
}
|
||||
user.setUsername(username);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
106
backend/src/main/java/com/openisle/service/ImageUploader.java
Normal file
106
backend/src/main/java/com/openisle/service/ImageUploader.java
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Image;
|
||||
import com.openisle.repository.ImageRepository;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Abstract service for uploading images and tracking their references.
|
||||
*/
|
||||
public abstract class ImageUploader {
|
||||
private final ImageRepository imageRepository;
|
||||
private final String baseUrl;
|
||||
private final Pattern urlPattern;
|
||||
|
||||
protected ImageUploader(ImageRepository imageRepository, String baseUrl) {
|
||||
this.imageRepository = imageRepository;
|
||||
if (baseUrl.endsWith("/")) {
|
||||
this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
} else {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image asynchronously and return a future of its accessible URL.
|
||||
*/
|
||||
public CompletableFuture<String> upload(byte[] data, String filename) {
|
||||
return doUpload(data, filename).thenApply(url -> url);
|
||||
}
|
||||
|
||||
protected abstract CompletableFuture<String> doUpload(byte[] data, String filename);
|
||||
|
||||
protected abstract void deleteFromStore(String key);
|
||||
|
||||
/**
|
||||
* Generate a presigned PUT URL for direct browser upload.
|
||||
* Default implementation is unsupported.
|
||||
*/
|
||||
public java.util.Map<String, String> presignUpload(String filename) {
|
||||
throw new UnsupportedOperationException("presignUpload not supported");
|
||||
}
|
||||
|
||||
/** Extract COS URLs from text. */
|
||||
public Set<String> extractUrls(String text) {
|
||||
Set<String> set = new HashSet<>();
|
||||
if (text == null) return set;
|
||||
Matcher m = urlPattern.matcher(text);
|
||||
while (m.find()) {
|
||||
set.add(m.group());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
public void addReferences(Set<String> urls) {
|
||||
for (String u : urls) addReference(u);
|
||||
}
|
||||
|
||||
public void removeReferences(Set<String> urls) {
|
||||
for (String u : urls) removeReference(u);
|
||||
}
|
||||
|
||||
public void adjustReferences(String oldText, String newText) {
|
||||
Set<String> oldUrls = extractUrls(oldText);
|
||||
Set<String> newUrls = extractUrls(newText);
|
||||
for (String u : newUrls) {
|
||||
if (!oldUrls.contains(u)) addReference(u);
|
||||
}
|
||||
for (String u : oldUrls) {
|
||||
if (!newUrls.contains(u)) removeReference(u);
|
||||
}
|
||||
}
|
||||
|
||||
private void addReference(String url) {
|
||||
if (!url.startsWith(baseUrl)) return;
|
||||
imageRepository.findByUrl(url).ifPresentOrElse(img -> {
|
||||
img.setRefCount(img.getRefCount() + 1);
|
||||
imageRepository.save(img);
|
||||
}, () -> {
|
||||
Image img = new Image();
|
||||
img.setUrl(url);
|
||||
img.setRefCount(1);
|
||||
imageRepository.save(img);
|
||||
});
|
||||
}
|
||||
|
||||
private void removeReference(String url) {
|
||||
if (!url.startsWith(baseUrl)) return;
|
||||
imageRepository.findByUrl(url).ifPresent(img -> {
|
||||
long count = img.getRefCount() - 1;
|
||||
if (count <= 0) {
|
||||
imageRepository.delete(img);
|
||||
String key = url.substring(baseUrl.length() + 1);
|
||||
deleteFromStore(key);
|
||||
} else {
|
||||
img.setRefCount(count);
|
||||
imageRepository.save(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
99
backend/src/main/java/com/openisle/service/JwtService.java
Normal file
99
backend/src/main/java/com/openisle/service/JwtService.java
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
@Value("${app.jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${app.jwt.reason-secret}")
|
||||
private String reasonSecret;
|
||||
|
||||
@Value("${app.jwt.reset-secret}")
|
||||
private String resetSecret;
|
||||
|
||||
@Value("${app.jwt.expiration}")
|
||||
private long expiration;
|
||||
|
||||
private Key getSigningKeyForSecret(String signSecret) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] keyBytes = digest.digest(signSecret.getBytes(StandardCharsets.UTF_8));
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateToken(String subject) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
return Jwts.builder()
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKeyForSecret(secret))
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateReasonToken(String subject) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
return Jwts.builder()
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKeyForSecret(reasonSecret))
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateResetToken(String subject) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
return Jwts.builder()
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKeyForSecret(resetSecret))
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String validateAndGetSubject(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(secret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public String validateAndGetSubjectForReason(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(reasonSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public String validateAndGetSubjectForReset(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(resetSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
90
backend/src/main/java/com/openisle/service/LevelService.java
Normal file
90
backend/src/main/java/com/openisle/service/LevelService.java
Normal file
@@ -0,0 +1,90 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ExperienceLogRepository;
|
||||
import com.openisle.model.ExperienceLog;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LevelService {
|
||||
private final UserRepository userRepository;
|
||||
// repositories for experience-related entities
|
||||
private final ExperienceLogRepository experienceLogRepository;
|
||||
private final UserVisitService userVisitService;
|
||||
|
||||
private static final int[] LEVEL_EXP = {100,200,300,600,1200,10000};
|
||||
|
||||
private ExperienceLog getTodayLog(User user) {
|
||||
LocalDate today = LocalDate.now();
|
||||
return experienceLogRepository.findByUserAndLogDate(user, today)
|
||||
.orElseGet(() -> {
|
||||
ExperienceLog log = new ExperienceLog();
|
||||
log.setUser(user);
|
||||
log.setLogDate(today);
|
||||
log.setPostCount(0);
|
||||
log.setCommentCount(0);
|
||||
log.setReactionCount(0);
|
||||
return experienceLogRepository.save(log);
|
||||
});
|
||||
}
|
||||
|
||||
private int addExperience(User user, int amount) {
|
||||
user.setExperience(user.getExperience() + amount);
|
||||
userRepository.save(user);
|
||||
return amount;
|
||||
}
|
||||
|
||||
public int awardForPost(String username) {
|
||||
User user = userRepository.findByUsername(username).orElseThrow();
|
||||
ExperienceLog log = getTodayLog(user);
|
||||
if (log.getPostCount() > 1) return 0;
|
||||
log.setPostCount(log.getPostCount() + 1);
|
||||
experienceLogRepository.save(log);
|
||||
return addExperience(user,30);
|
||||
}
|
||||
|
||||
public int awardForComment(String username) {
|
||||
User user = userRepository.findByUsername(username).orElseThrow();
|
||||
ExperienceLog log = getTodayLog(user);
|
||||
if (log.getCommentCount() > 3) return 0;
|
||||
log.setCommentCount(log.getCommentCount() + 1);
|
||||
experienceLogRepository.save(log);
|
||||
return addExperience(user,10);
|
||||
}
|
||||
|
||||
public int awardForReaction(String username) {
|
||||
User user = userRepository.findByUsername(username).orElseThrow();
|
||||
ExperienceLog log = getTodayLog(user);
|
||||
if (log.getReactionCount() > 3) return 0;
|
||||
log.setReactionCount(log.getReactionCount() + 1);
|
||||
experienceLogRepository.save(log);
|
||||
return addExperience(user,5);
|
||||
}
|
||||
|
||||
public int awardForSignin(String username) {
|
||||
boolean first = userVisitService.recordVisit(username);
|
||||
if (!first) return 0;
|
||||
User user = userRepository.findByUsername(username).orElseThrow();
|
||||
return addExperience(user,5);
|
||||
}
|
||||
|
||||
public int getLevel(int exp) {
|
||||
int level = 0;
|
||||
for (int t : LEVEL_EXP) {
|
||||
if (exp >= t) level++; else break;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
public int nextLevelExp(int exp) {
|
||||
for (int t : LEVEL_EXP) {
|
||||
if (exp < t) return t;
|
||||
}
|
||||
return LEVEL_EXP[LEVEL_EXP.length-1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.openisle.service.EmailSender;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/** Service for creating and retrieving notifications. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationService {
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final EmailSender emailSender;
|
||||
private final PushNotificationService pushNotificationService;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final Executor notificationExecutor;
|
||||
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
private String buildPayload(String body, String url) {
|
||||
// try {
|
||||
// return new ObjectMapper().writeValueAsString(Map.of(
|
||||
// "body", body,
|
||||
// "url", url
|
||||
// ));
|
||||
// } catch (Exception e) {
|
||||
// return body;
|
||||
// }
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public void sendCustomPush(User user, String body, String url) {
|
||||
pushNotificationService.sendNotification(user, buildPayload(body, url));
|
||||
}
|
||||
|
||||
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) {
|
||||
return createNotification(user, type, post, comment, approved, null, null, null);
|
||||
}
|
||||
|
||||
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved,
|
||||
User fromUser, ReactionType reactionType, String content) {
|
||||
Notification n = new Notification();
|
||||
n.setUser(user);
|
||||
n.setType(type);
|
||||
n.setPost(post);
|
||||
n.setComment(comment);
|
||||
n.setApproved(approved);
|
||||
n.setFromUser(fromUser);
|
||||
n.setReactionType(reactionType);
|
||||
n.setContent(content);
|
||||
n = notificationRepository.save(n);
|
||||
|
||||
notificationExecutor.execute(() -> {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
|
||||
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||
String pushContent = comment.getAuthor() + "回复了你: \"" + comment.getContent() + "\"";
|
||||
emailSender.sendEmail(user.getEmail(), "您有新的回复", pushContent + ", 点击以查看: " + url);
|
||||
sendCustomPush(user, pushContent, url);
|
||||
} else if (type == NotificationType.REACTION && comment != null) {
|
||||
long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
||||
if (count % 5 == 0) {
|
||||
String url = websiteUrl + "/messages";
|
||||
sendCustomPush(comment.getAuthor(), "你有新的互动", url);
|
||||
if (comment.getAuthor().getEmail() != null) {
|
||||
emailSender.sendEmail(comment.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url);
|
||||
}
|
||||
}
|
||||
} else if (type == NotificationType.REACTION && post != null) {
|
||||
long count = reactionRepository.countReceived(post.getAuthor().getUsername());
|
||||
if (count % 5 == 0) {
|
||||
String url = websiteUrl + "/messages";
|
||||
sendCustomPush(post.getAuthor(), "你有新的互动", url);
|
||||
if (post.getAuthor().getEmail() != null) {
|
||||
emailSender.sendEmail(post.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for all admins when a user submits a register request.
|
||||
* Old register request notifications from the same applicant are removed first.
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void createRegisterRequestNotifications(User applicant, String reason) {
|
||||
notificationRepository.deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
|
||||
for (User admin : userRepository.findByRole(Role.ADMIN)) {
|
||||
createNotification(admin, NotificationType.REGISTER_REQUEST, null, null,
|
||||
null, applicant, null, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for all admins when a user redeems an activity.
|
||||
* Old redeem notifications from the same user are removed first.
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void createActivityRedeemNotifications(User user, String content) {
|
||||
notificationRepository.deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user);
|
||||
for (User admin : userRepository.findByRole(Role.ADMIN)) {
|
||||
createNotification(admin, NotificationType.ACTIVITY_REDEEM, null, null,
|
||||
null, user, null, content);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Notification> listNotifications(String username, Boolean read) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (read == null) {
|
||||
return notificationRepository.findByUserOrderByCreatedAtDesc(user);
|
||||
}
|
||||
return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read);
|
||||
}
|
||||
|
||||
public void markRead(String username, List<Long> ids) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
List<Notification> notifs = notificationRepository.findAllById(ids);
|
||||
for (Notification n : notifs) {
|
||||
if (n.getUser().getId().equals(user.getId())) {
|
||||
n.setRead(true);
|
||||
}
|
||||
}
|
||||
notificationRepository.saveAll(notifs);
|
||||
}
|
||||
|
||||
public long countUnread(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
return notificationRepository.countByUserAndRead(user, false);
|
||||
}
|
||||
|
||||
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {
|
||||
if (content == null || fromUser == null) {
|
||||
return;
|
||||
}
|
||||
Matcher matcher = MENTION_PATTERN.matcher(content);
|
||||
Set<String> names = new HashSet<>();
|
||||
while (matcher.find()) {
|
||||
names.add(matcher.group(1));
|
||||
}
|
||||
for (String name : names) {
|
||||
userRepository.findByUsername(name).ifPresent(target -> {
|
||||
if (!target.getId().equals(fromUser.getId())) {
|
||||
createNotification(target, NotificationType.MENTION, post, comment, null, fromUser, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class OpenAiService {
|
||||
|
||||
@Value("${openai.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${openai.model:gpt-4o}")
|
||||
private String model;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
public Optional<String> formatMarkdown(String text) {
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String url = "https://api.openai.com/v1/chat/completions";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + apiKey);
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("model", model);
|
||||
List<Map<String, String>> messages = new ArrayList<>();
|
||||
messages.add(Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。"));
|
||||
messages.add(Map.of("role", "user", "content", text));
|
||||
body.put("messages", messages);
|
||||
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
try {
|
||||
ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
|
||||
Map respBody = resp.getBody();
|
||||
if (respBody != null) {
|
||||
Object choicesObj = respBody.get("choices");
|
||||
if (choicesObj instanceof List choices && !choices.isEmpty()) {
|
||||
Object first = choices.get(0);
|
||||
if (first instanceof Map firstMap) {
|
||||
Object messageObj = firstMap.get("message");
|
||||
if (messageObj instanceof Map message) {
|
||||
Object content = message.get("content");
|
||||
if (content instanceof String str) {
|
||||
return Optional.of(str.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PasswordStrength;
|
||||
import com.openisle.exception.FieldException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PasswordValidator {
|
||||
private PasswordStrength strength;
|
||||
|
||||
public PasswordValidator(@Value("${app.password.strength:LOW}") PasswordStrength strength) {
|
||||
this.strength = strength;
|
||||
}
|
||||
|
||||
public PasswordStrength getStrength() {
|
||||
return strength;
|
||||
}
|
||||
|
||||
public void setStrength(PasswordStrength strength) {
|
||||
this.strength = strength;
|
||||
}
|
||||
|
||||
public void validate(String password) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
throw new FieldException("password", "Password cannot be empty");
|
||||
}
|
||||
switch (strength) {
|
||||
case MEDIUM:
|
||||
checkMedium(password);
|
||||
break;
|
||||
case HIGH:
|
||||
checkHigh(password);
|
||||
break;
|
||||
default:
|
||||
checkLow(password);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLow(String password) {
|
||||
if (password.length() < 6) {
|
||||
throw new FieldException("password", "Password must be at least 6 characters long");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMedium(String password) {
|
||||
if (password.length() < 8) {
|
||||
throw new FieldException("password", "Password must be at least 8 characters long");
|
||||
}
|
||||
if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) {
|
||||
throw new FieldException("password", "Password must contain letters and numbers");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkHigh(String password) {
|
||||
if (password.length() < 12) {
|
||||
throw new FieldException("password", "Password must be at least 12 characters long");
|
||||
}
|
||||
if (!password.matches(".*[A-Z].*")) {
|
||||
throw new FieldException("password", "Password must contain uppercase letters");
|
||||
}
|
||||
if (!password.matches(".*[a-z].*")) {
|
||||
throw new FieldException("password", "Password must contain lowercase letters");
|
||||
}
|
||||
if (!password.matches(".*\\d.*")) {
|
||||
throw new FieldException("password", "Password must contain numbers");
|
||||
}
|
||||
if (!password.matches(".*[^A-Za-z0-9].*")) {
|
||||
throw new FieldException("password", "Password must contain special characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PostReadRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PostReadService {
|
||||
private final PostReadRepository postReadRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PostRepository postRepository;
|
||||
|
||||
public void recordRead(String username, Long postId) {
|
||||
if (username == null) return;
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
postReadRepository.findByUserAndPost(user, post).ifPresentOrElse(pr -> {
|
||||
pr.setLastReadAt(LocalDateTime.now());
|
||||
postReadRepository.save(pr);
|
||||
}, () -> {
|
||||
PostRead pr = new PostRead();
|
||||
pr.setUser(user);
|
||||
pr.setPost(post);
|
||||
pr.setLastReadAt(LocalDateTime.now());
|
||||
postReadRepository.save(pr);
|
||||
});
|
||||
}
|
||||
|
||||
public long countReads(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
return postReadRepository.countByUser(user);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deleteByPost(Post post) {
|
||||
postReadRepository.deleteByPost(post);
|
||||
}
|
||||
}
|
||||
486
backend/src/main/java/com/openisle/service/PostService.java
Normal file
486
backend/src/main/java/com/openisle/service/PostService.java
Normal file
@@ -0,0 +1,486 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PublishMode;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.PostSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PostService {
|
||||
private final PostRepository postRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private PublishMode publishMode;
|
||||
private final NotificationService notificationService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final CommentService commentService;
|
||||
private final CommentRepository commentRepository;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final PostSubscriptionRepository postSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final PostReadService postReadService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public PostService(PostRepository postRepository,
|
||||
UserRepository userRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
TagRepository tagRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
CommentService commentService,
|
||||
CommentRepository commentRepository,
|
||||
ReactionRepository reactionRepository,
|
||||
PostSubscriptionRepository postSubscriptionRepository,
|
||||
NotificationRepository notificationRepository,
|
||||
PostReadService postReadService,
|
||||
ImageUploader imageUploader,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.subscriptionService = subscriptionService;
|
||||
this.commentService = commentService;
|
||||
this.commentRepository = commentRepository;
|
||||
this.reactionRepository = reactionRepository;
|
||||
this.postSubscriptionRepository = postSubscriptionRepository;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.postReadService = postReadService;
|
||||
this.imageUploader = imageUploader;
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
public PublishMode getPublishMode() {
|
||||
return publishMode;
|
||||
}
|
||||
|
||||
public void setPublishMode(PublishMode publishMode) {
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
public Post createPost(String username,
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(5));
|
||||
if (recent >= 1) {
|
||||
throw new RateLimitException("Too many posts");
|
||||
}
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
}
|
||||
if (tagIds.size() > 2) {
|
||||
throw new IllegalArgumentException("At most two tags allowed");
|
||||
}
|
||||
User author = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Category category = categoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
throw new IllegalArgumentException("Tag not found");
|
||||
}
|
||||
Post post = new Post();
|
||||
post.setTitle(title);
|
||||
post.setContent(content);
|
||||
post.setAuthor(author);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (post.getStatus() == PostStatus.PENDING) {
|
||||
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
|
||||
for (User admin : admins) {
|
||||
notificationService.createNotification(admin,
|
||||
NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null, null);
|
||||
}
|
||||
notificationService.createNotification(author,
|
||||
NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null, null);
|
||||
}
|
||||
// notify followers of author
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(
|
||||
u,
|
||||
NotificationType.FOLLOWED_POST,
|
||||
post,
|
||||
null,
|
||||
null,
|
||||
author,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Post viewPost(Long id, String viewer) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
}
|
||||
User viewerUser = userRepository.findByUsername(viewer)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!viewerUser.getRole().equals(com.openisle.model.Role.ADMIN) && !viewerUser.getId().equals(post.getAuthor().getId())) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
}
|
||||
}
|
||||
post.setViews(post.getViews() + 1);
|
||||
post = postRepository.save(post);
|
||||
if (viewer != null) {
|
||||
postReadService.recordRead(viewer, id);
|
||||
}
|
||||
if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) {
|
||||
User viewerUser = userRepository.findByUsername(viewer).orElse(null);
|
||||
if (viewerUser != null) {
|
||||
notificationRepository.deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewerUser, post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null, null);
|
||||
}
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
public List<Post> listPosts() {
|
||||
return listPostsByCategories(null, null, null);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||
return listPostsByViews(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
if (hasTags) {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
||||
categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED);
|
||||
}
|
||||
} else {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
}
|
||||
|
||||
return paginate(sortByPinnedAndViews(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||||
return listPostsByLatestReply(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
if (hasTags) {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
||||
categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
|
||||
}
|
||||
} else {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
}
|
||||
|
||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategories(java.util.List<Long> categoryIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (categoryIds == null || categoryIds.isEmpty()) {
|
||||
java.util.List<Post> posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
java.util.List<Post> posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> getRecentPostsByUser(String username, int limit) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
return postRepository.findByAuthorAndStatusOrderByCreatedAtDesc(user, PostStatus.PUBLISHED, pageable);
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastPostTime(String username) {
|
||||
return postRepository.findLastPostTime(username);
|
||||
}
|
||||
|
||||
public long getTotalViews(String username) {
|
||||
Long v = postRepository.sumViews(username);
|
||||
return v != null ? v : 0;
|
||||
}
|
||||
|
||||
public List<Post> listPostsByTags(java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Post> posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategoriesAndTags(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (categories.isEmpty() || tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Post> posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPendingPosts() {
|
||||
return postRepository.findByStatus(PostStatus.PENDING);
|
||||
}
|
||||
|
||||
public Post approvePost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
// publish all pending tags along with the post
|
||||
for (com.openisle.model.Tag tag : post.getTags()) {
|
||||
if (!tag.isApproved()) {
|
||||
tag.setApproved(true);
|
||||
tagRepository.save(tag);
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true, null, null, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
public Post rejectPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
// remove user created tags that are only linked to this post
|
||||
java.util.Set<com.openisle.model.Tag> tags = new java.util.HashSet<>(post.getTags());
|
||||
for (com.openisle.model.Tag tag : tags) {
|
||||
if (!tag.isApproved()) {
|
||||
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
||||
if (count <= 1) {
|
||||
post.getTags().remove(tag);
|
||||
tagRepository.delete(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.REJECTED);
|
||||
post = postRepository.save(post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false, null, null, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
public Post pinPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setPinnedAt(java.time.LocalDateTime.now());
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post unpinPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setPinnedAt(null);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public Post updatePost(Long id,
|
||||
String username,
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
}
|
||||
if (tagIds.size() > 2) {
|
||||
throw new IllegalArgumentException("At most two tags allowed");
|
||||
}
|
||||
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");
|
||||
}
|
||||
Category category = categoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
throw new IllegalArgumentException("Tag not found");
|
||||
}
|
||||
post.setTitle(title);
|
||||
String oldContent = post.getContent();
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deletePost(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");
|
||||
}
|
||||
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
|
||||
commentService.deleteCommentCascade(c);
|
||||
}
|
||||
reactionRepository.findByPost(post).forEach(reactionRepository::delete);
|
||||
postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByPost(post));
|
||||
postReadService.deleteByPost(post);
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(post.getContent()));
|
||||
postRepository.delete(post);
|
||||
}
|
||||
|
||||
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {
|
||||
return postRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public long countPostsByCategory(Long categoryId) {
|
||||
return postRepository.countByCategory_Id(categoryId);
|
||||
}
|
||||
|
||||
public long countPostsByTag(Long tagId) {
|
||||
return postRepository.countDistinctByTags_Id(tagId);
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndViews(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(Post::getViews, java.util.Comparator.reverseOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndLastReply(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(p -> {
|
||||
java.time.LocalDateTime t = commentRepository.findLastCommentTime(p);
|
||||
return t != null ? t : p.getCreatedAt();
|
||||
}, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> paginate(java.util.List<Post> posts, Integer page, Integer pageSize) {
|
||||
if (page == null || pageSize == null) {
|
||||
return posts;
|
||||
}
|
||||
int from = page * pageSize;
|
||||
if (from >= posts.size()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
int to = Math.min(from + pageSize, posts.size());
|
||||
return posts.subList(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PushSubscription;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PushSubscriptionRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import nl.martijndwars.webpush.Notification;
|
||||
import nl.martijndwars.webpush.PushService;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.Security;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PushNotificationService {
|
||||
private final PushSubscriptionRepository subscriptionRepository;
|
||||
private final PushService pushService;
|
||||
|
||||
public PushNotificationService(PushSubscriptionRepository subscriptionRepository,
|
||||
@Value("${app.webpush.public-key}") String publicKey,
|
||||
@Value("${app.webpush.private-key}") String privateKey) throws GeneralSecurityException {
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
this.pushService = new PushService(publicKey, privateKey);
|
||||
}
|
||||
|
||||
public void sendNotification(User user, String payload) {
|
||||
List<PushSubscription> subs = subscriptionRepository.findByUser(user);
|
||||
for (PushSubscription sub : subs) {
|
||||
try {
|
||||
Notification notification = new Notification(sub.getEndpoint(), sub.getP256dh(), sub.getAuth(), payload);
|
||||
pushService.send(notification);
|
||||
} catch (GeneralSecurityException | IOException | JoseException | InterruptedException | java.util.concurrent.ExecutionException e) {
|
||||
log.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PushSubscription;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PushSubscriptionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PushSubscriptionService {
|
||||
private final PushSubscriptionRepository subscriptionRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
public void saveSubscription(String username, String endpoint, String p256dh, String auth) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
subscriptionRepository.deleteByUserAndEndpoint(user, endpoint);
|
||||
PushSubscription sub = new PushSubscription();
|
||||
sub.setUser(user);
|
||||
sub.setEndpoint(endpoint);
|
||||
sub.setP256dh(p256dh);
|
||||
sub.setAuth(auth);
|
||||
subscriptionRepository.save(sub);
|
||||
}
|
||||
|
||||
public List<PushSubscription> listByUser(User user) {
|
||||
return subscriptionRepository.findByUser(user);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user