优化目录结构

This commit is contained in:
WilliamColton
2025-08-03 01:27:28 +08:00
parent d63081955e
commit c08723574d
222 changed files with 2 additions and 25 deletions

View 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);
}
}

View File

@@ -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);
}
}
}

View 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;
}
}

View File

@@ -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\"}");
}
}

View 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);
}
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}

View File

@@ -0,0 +1,7 @@
package com.openisle.model;
/** Activity type enumeration. */
public enum ActivityType {
NORMAL,
MILK_TEA
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.model;
/**
* Sort options for comments.
*/
public enum CommentSort {
NEWEST,
OLDEST,
MOST_INTERACTIONS
}

View File

@@ -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;
}

View 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<>();
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
package com.openisle.model;
public enum PasswordStrength {
LOW,
MEDIUM,
HIGH
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.model;
/**
* Status of a post during its lifecycle.
*/
public enum PostStatus {
PUBLISHED,
PENDING,
REJECTED
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
package com.openisle.model;
/**
* Application-wide article publish mode.
*/
public enum PublishMode {
DIRECT,
REVIEW
}

View File

@@ -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;
}

View 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;
}

View 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
}

View File

@@ -0,0 +1,9 @@
package com.openisle.model;
/**
* Application-wide user registration mode.
*/
public enum RegisterMode {
DIRECT,
WHITELIST
}

View File

@@ -0,0 +1,6 @@
package com.openisle.model;
public enum Role {
ADMIN,
USER
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View 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;
}
}

View 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
);
}
}

View File

@@ -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);
}
}

View 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);
})
);
}
}

View 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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
});
}
}

View 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();
}
}

View 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];
}
}

View File

@@ -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);
}
});
}
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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());
}
}
}
}

View File

@@ -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