Compare commits

...

71 Commits

Author SHA1 Message Date
Tim
f22ca9cdcd chore: format backend via husky 2025-09-18 00:07:46 +08:00
Tim
d26b96ebd1 Merge pull request #997 from nagisa77/codex/add-placeholder-for-no-comments
feat: show placeholder when timeline empty
2025-09-17 21:20:59 +08:00
Tim
13cc981421 feat: show placeholder when timeline empty 2025-09-17 21:19:36 +08:00
Tim
efc8589ca0 Merge pull request #996 from nagisa77/codex/implement-reaction-group-gradient-sorting-zszqdc
feat: sort reactions by popularity
2025-09-17 20:58:31 +08:00
Tim
940690889c feat: sort reactions by popularity 2025-09-17 20:36:18 +08:00
Tim
d46420ef81 Merge pull request #993 from nagisa77/codex/fix-compilation-error-in-postservicetest
Fix PostServiceTest constructor parameters
2025-09-17 14:23:44 +08:00
Tim
b36b5b59dc Fix PostServiceTest constructor parameters 2025-09-17 14:23:27 +08:00
Tim
cf96806f80 Merge pull request #979 from sivdead/optimize-post-list-n+1
主页列表接口优化,优化帖子评论统计性能
2025-09-17 14:17:31 +08:00
Tim
3d0d0496b6 fix: comment count 放在last_reply_at后更新,确保数据正确 2025-09-17 14:16:49 +08:00
Tim
f67e220894 fix: 旧帖子的last_reply_at也要及时更新(仅一次) 2025-09-17 14:14:55 +08:00
Tim
9306e35b84 Merge remote-tracking branch 'origin/main' into pr-979 2025-09-17 13:49:34 +08:00
Tim
d2268a1944 Merge pull request #971 from smallclover/main
缓存功能追加
2025-09-17 13:43:40 +08:00
Tim
6baa4d4233 fix: 简单调整按钮格式 2025-09-17 13:37:52 +08:00
Tim
ef9d90455f Merge pull request #991 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-mrgsx4
Delete post change logs before removing posts
2025-09-17 13:31:31 +08:00
Tim
5d499956d7 Delete post change logs before removing posts 2025-09-17 13:30:58 +08:00
Tim
9101ed336c Merge pull request #990 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-1xt4ec
Fix foreign key failures when deleting posts
2025-09-17 12:29:25 +08:00
Tim
28e3ebb911 Handle point history cleanup when deleting posts 2025-09-17 12:29:09 +08:00
Tim
e93e33fe43 Revert "Handle point history cleanup when deleting posts"
This reverts commit b4a811ff4e.
2025-09-17 12:27:07 +08:00
Tim
0ebeccf21e Merge branch 'pr-971' of github.com:nagisa77/OpenIsle into pr-971 2025-09-17 12:23:40 +08:00
Tim
89842b82e9 fix: 文章缓存修改为 10 min 2025-09-17 12:23:20 +08:00
Tim
58594229f2 Merge pull request #989 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost
Handle point history cleanup when deleting posts
2025-09-17 12:21:34 +08:00
Tim
b4a811ff4e Handle point history cleanup when deleting posts 2025-09-17 12:21:17 +08:00
Tim
7067630bcc fix: 验证码部分验证完毕,提交小修改 2025-09-17 12:06:02 +08:00
Tim
b28e8d4bc9 Merge pull request #988 from nagisa77/codex/update-post_cache_name-to-handle-pagination
Fix post cache keys to include pagination parameters
2025-09-17 11:53:05 +08:00
Tim
063866cc3a Fix post cache keys to include pagination 2025-09-17 11:52:42 +08:00
Tim
6f968d16aa fix: 处理首屏返回空的问题 2025-09-17 11:41:35 +08:00
夢夢の幻想郷
6db969cc4d Update deploy-staging.yml
只有主仓库的时候才执行
2025-09-15 11:30:37 +08:00
wangshun
6ea9b4a33c 修复问题#927,#860
1.优化评论请求,将两个请求合并为一个
2.修改个人主页按钮的主次
2025-09-15 11:23:31 +08:00
夢夢の幻想郷
bcfc40d795 Merge branch 'nagisa77:main' into main 2025-09-15 09:38:18 +08:00
Tim
c5c7066b92 fix: ci 问题 2025-09-13 11:20:21 +08:00
夢夢の幻想郷
51b73fcc93 Merge branch 'nagisa77:main' into main 2025-09-12 17:07:57 +08:00
Tim
da181b9d6d Merge pull request #980 from nagisa77/feature/tag_height
fix: tags height
2025-09-12 14:27:41 +08:00
tim
134e3fc866 fix: tags height 2025-09-12 14:27:01 +08:00
tim
c3758cafe8 fix: 修复内容绑定问题 2025-09-12 13:42:03 +08:00
sivdead
1a21ba8935 feat(posts): 优化帖子评论统计性能
- 在 Post 模型中添加 commentCount 和 lastReplyAt 字段
- 在 CommentService 中实现更新帖子评论统计的方法
- 在 PostMapper 中使用 Post 模型中的评论统计字段
- 新增数据库迁移脚本,添加评论统计字段和索引
- 更新相关测试用例
2025-09-12 11:08:59 +08:00
Tim
a397ebe79b Merge pull request #978 from nagisa77/codex/fix-image-preview-trigger-in-markdown
fix: restrict image preview to markdown images
2025-09-12 10:50:45 +08:00
Tim
abbdb224e0 fix: restrict image preview to markdown images 2025-09-12 10:50:15 +08:00
Tim
f4fb3b2544 Merge pull request #976 from nagisa77/codex/remove-ffmpeg-dependency-and-functionality
chore: remove ffmpeg video compression
2025-09-12 10:46:39 +08:00
Tim
ae2412a906 Merge pull request #977 from nagisa77/feature/command_load
fix: 评论后--需要刷新帖子内容 #939
2025-09-12 10:46:29 +08:00
Tim
d8534fb94d fix: 评论后--需要刷新帖子内容 #939 2025-09-12 10:43:06 +08:00
Tim
6497cb92af chore: remove ffmpeg video compression 2025-09-12 10:41:48 +08:00
Tim
37bef0b2d7 fix: remove 依赖 2025-09-12 10:15:17 +08:00
Tim
3519a41a2e Merge pull request #975 from nagisa77/feature/ffmpeg_load
Feature/ffmpeg load
2025-09-11 19:12:16 +08:00
tim
ab04a8b6b1 fix: ffmpeg 压缩适配 2025-09-11 19:10:14 +08:00
tim
ea079e8b8a fix: 简化ffmpeg配置 2025-09-11 18:36:47 +08:00
Tim
519656359f Merge pull request #974 from 4twocc/feat/message-box-shortcut
feat(MessageEditor): 添加发送消息的快捷键支持
2025-09-11 17:56:22 +08:00
jiahaosheng
dc64785279 feat: rename is.js to device.js 2025-09-11 17:53:08 +08:00
jiahaosheng
9421d004d4 feat(MessageEditor): 添加发送消息的快捷键支持 2025-09-11 17:27:54 +08:00
tim
90bd41e740 Revert "feat: switch video compression to webcodecs"
This reverts commit 3f35add587.
2025-09-11 17:20:08 +08:00
Tim
7d5c864f64 Merge pull request #973 from nagisa77/codex/switch-video-upload-to-webcodec-and-mp4box.js-bkkx49
feat: replace ffmpeg with WebCodecs and MP4Box.js
2025-09-11 17:02:08 +08:00
Tim
3f35add587 feat: switch video compression to webcodecs 2025-09-11 17:01:54 +08:00
wangshun
37c4306010 缓存功能追加
1.最新回复列表
2.最新列表
2025-09-11 15:29:24 +08:00
Tim
1e284e15df Merge pull request #970 from sivdead/feat/video_upload_and_compress
feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
2025-09-11 12:54:12 +08:00
sivdead
9d76926b8a feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
- 添加视频压缩相关配置和工具函数
- 实现 FFmpeg.wasm 初始化和视频压缩功能
- 优化文件上传流程,支持视频文件压缩
2025-09-11 10:05:50 +08:00
tim
d2ce203236 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 18:13:16 +08:00
tim
b2228296af fix: hover 新增动画 2025-09-10 18:13:04 +08:00
Tim
7020ae19d0 Merge pull request #968 from smallclover/main
追加快捷键
2025-09-10 18:09:18 +08:00
tim
227fb6f6cc fix: 首页padding修改 2025-09-10 18:07:22 +08:00
wangshun
0e46a67ea6 评论追加快捷键
1.手机时不显示icon,且快捷键不起用
2.电脑端适配win和mac
2025-09-10 18:01:07 +08:00
wangshun
b20b705e46 添加快捷键
+ 不是手机的情况下不启用快捷键
2025-09-10 17:44:53 +08:00
夢夢の幻想郷
4b3ffbab99 Merge branch 'nagisa77:main' into main 2025-09-10 17:43:40 +08:00
tim
74039c89f9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 17:42:32 +08:00
tim
10dca73d2f fix: 新增本地GitHub调试 2025-09-10 17:42:19 +08:00
wangshun
e37ed1b70b 评论追加快捷键
ps:schedule包名拼写错误修正
2025-09-10 15:40:49 +08:00
Tim
8500a7a914 Merge pull request #965 from nagisa77/feature/homepage_ui
fix: 首页banner和帖子之间间距可以大一些 #849
2025-09-10 14:00:19 +08:00
Tim
3adf722b3b fix: 首页banner和帖子之间间距可以大一些 #849 2025-09-10 13:58:11 +08:00
Tim
791e5a4daf Merge pull request #964 from nagisa77/feature/change-log-ui
fix: changelog--文章内容更新 移动端适配 #937
2025-09-10 12:50:11 +08:00
tim
7d25e87fbc fix: changelog--文章内容更新 移动端适配 #937 2025-09-10 12:47:09 +08:00
Tim
d02c316a70 Merge pull request #963 from nagisa77/codex/fix-log-usage-with-slf4j
refactor: replace console prints with slf4j logging
2025-09-10 12:01:43 +08:00
Tim
c189c80c05 refactor: replace console prints with slf4j logging 2025-09-10 12:01:15 +08:00
Tim
07db73c9c7 Merge pull request #959 from nagisa77/codex/fix-chat-markdown-rendering-issues
feat: enhance chat markdown and editor
2025-09-09 21:20:49 +08:00
47 changed files with 1848 additions and 3435 deletions

View File

@@ -12,6 +12,7 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: Deploy environment: Deploy
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

3
backend/.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-java"]
}

View File

@@ -46,6 +46,8 @@ public class CachingConfig {
public static final String LIMIT_CACHE_NAME="openisle_limit"; public static final String LIMIT_CACHE_NAME="openisle_limit";
// 用户访问统计 // 用户访问统计
public static final String VISIT_CACHE_NAME="openisle_visit"; public static final String VISIT_CACHE_NAME="openisle_visit";
// 文章缓存
public static final String POST_CACHE_NAME="openisle_posts";
/** /**
* 自定义Redis的序列化器 * 自定义Redis的序列化器
@@ -65,7 +67,10 @@ public class CachingConfig {
// Hibernate6Module 可以自动处理懒加载代理对象。 // Hibernate6Module 可以自动处理懒加载代理对象。
// Tag对象的creator是FetchType.LAZY // Tag对象的creator是FetchType.LAZY
objectMapper.registerModule(new Hibernate6Module() objectMapper.registerModule(new Hibernate6Module()
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)); .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)
// 将 Hibernate 特有的集合类型转换为标准 Java 集合类型
// 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息
.configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true));
// service的时候带上类型信息 // service的时候带上类型信息
// 启用类型信息,避免 LinkedHashMap 问题 // 启用类型信息,避免 LinkedHashMap 问题
objectMapper.activateDefaultTyping( objectMapper.activateDefaultTyping(
@@ -92,8 +97,10 @@ public class CachingConfig {
// 个别缓存单独设置 TTL 时间 // 个别缓存单独设置 TTL 时间
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>(); Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig); cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
return RedisCacheManager.builder(connectionFactory) return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config) .cacheDefaults(config)

View File

@@ -1,6 +1,7 @@
package com.openisle.config; package com.openisle.config;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.Queue;
@@ -23,6 +24,7 @@ import java.util.List;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class RabbitMQConfig { public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "openisle-exchange"; public static final String EXCHANGE_NAME = "openisle-exchange";
@@ -38,7 +40,7 @@ public class RabbitMQConfig {
@PostConstruct @PostConstruct
public void init() { public void init() {
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable); log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
} }
@Bean @Bean
@@ -51,7 +53,7 @@ public class RabbitMQConfig {
*/ */
@Bean @Bean
public List<Queue> shardedQueues() { public List<Queue> shardedQueues() {
System.out.println("开始创建分片队列 Bean..."); log.info("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>(); List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) { for (int i = 0; i < queueCount; i++) {
@@ -61,7 +63,7 @@ public class RabbitMQConfig {
queues.add(queue); queues.add(queue);
} }
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size()); log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
return queues; return queues;
} }
@@ -70,7 +72,7 @@ public class RabbitMQConfig {
*/ */
@Bean @Bean
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) { public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
System.out.println("开始创建分片绑定 Bean..."); log.info("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>(); List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) { if (shardedQueues != null) {
for (Queue queue : shardedQueues) { for (Queue queue : shardedQueues) {
@@ -82,7 +84,7 @@ public class RabbitMQConfig {
} }
} }
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size()); log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
return bindings; return bindings;
} }
@@ -135,14 +137,14 @@ public class RabbitMQConfig {
@Qualifier("shardedBindings") List<Binding> shardedBindings, @Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding) { Binding legacyBinding) {
return args -> { return args -> {
System.out.println("=== 开始主动声明 RabbitMQ 组件 ==="); log.info("=== 开始主动声明 RabbitMQ 组件 ===");
try { try {
// 声明交换 // 声明交换
rabbitAdmin.declareExchange(exchange); rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性 // 声明分片队列 - 检查存在性
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列..."); log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
int successCount = 0; int successCount = 0;
int skippedCount = 0; int skippedCount = 0;
@@ -159,45 +161,44 @@ public class RabbitMQConfig {
skippedCount++; skippedCount++;
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage()); log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
} }
} }
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size()); log.info("分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", successCount, skippedCount, shardedQueues.size());
// 声明分片绑定 // 声明分片绑定
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定..."); log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
int bindingSuccessCount = 0; int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) { for (Binding binding : shardedBindings) {
try { try {
rabbitAdmin.declareBinding(binding); rabbitAdmin.declareBinding(binding);
bindingSuccessCount++; bindingSuccessCount++;
} catch (Exception e) { } catch (Exception e) {
System.err.println("绑定声明失败: " + e.getMessage()); log.error("绑定声明失败: {}", e.getMessage());
} }
} }
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size()); log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性 // 声明遗留队列和绑定 - 检查存在性
try { try {
rabbitAdmin.declareQueue(legacyQueue); rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding); rabbitAdmin.declareBinding(legacyBinding);
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)"); log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
} catch (org.springframework.amqp.AmqpIOException e) { } catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) { if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列"); log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
} else { } else {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage()); log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage()); log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
} }
System.out.println("=== RabbitMQ 组件声明完成 ==="); log.info("=== RabbitMQ 组件声明完成 ===");
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建"); log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) { } catch (Exception e) {
System.err.println("RabbitMQ 组件声明过程中发生严重错误:"); log.error("RabbitMQ 组件声明过程中发生严重错误", e);
e.printStackTrace();
} }
}; };
} }

View File

@@ -1,13 +1,14 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TimelineItemDto;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.dto.CommentDto; import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest; import com.openisle.dto.CommentRequest;
import com.openisle.mapper.CommentMapper; import com.openisle.mapper.CommentMapper;
import com.openisle.service.CaptchaService; import com.openisle.model.CommentSort;
import com.openisle.service.CommentService; import com.openisle.service.*;
import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -21,6 +22,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -34,6 +37,8 @@ public class CommentController {
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final PointService pointService; private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
private boolean captchaEnabled; private boolean captchaEnabled;
@@ -85,15 +90,43 @@ public class CommentController {
@GetMapping("/posts/{postId}/comments") @GetMapping("/posts/{postId}/comments")
@Operation(summary = "List comments", description = "List comments for a post") @Operation(summary = "List comments", description = "List comments for a post")
@ApiResponse(responseCode = "200", description = "Comments", @ApiResponse(responseCode = "200", description = "Comments",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentDto.class)))) content = @Content(array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))))
public List<CommentDto> listComments(@PathVariable Long postId, public List<TimelineItemDto<?>> listComments(@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) { @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) {
log.debug("listComments called for post {} with sort {}", postId, sort); log.debug("listComments called for post {} with sort {}", postId, sort);
List<CommentDto> list = commentService.getCommentsForPost(postId, sort).stream() List<CommentDto> commentDtoList = commentService.getCommentsForPost(postId, sort).stream()
.map(commentMapper::toDtoWithReplies) .map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList()); .collect(Collectors.toList());
log.debug("listComments returning {} comments", list.size()); List<PostChangeLogDto> postChangeLogDtoList = changeLogService.listLogs(postId).stream()
return list; .map(postChangeLogMapper::toDto)
.collect(Collectors.toList());
List<TimelineItemDto<?>> itemDtoList = new ArrayList<>();
itemDtoList.addAll(commentDtoList.stream()
.map(c -> new TimelineItemDto<>(
c.getId(),
"comment",
c.getCreatedAt(),
c // payload 是 CommentDto
))
.toList());
itemDtoList.addAll(postChangeLogDtoList.stream()
.map(l -> new TimelineItemDto<>(
l.getId(),
"log",
l.getTime(), // 注意字段名不一样
l // payload 是 PostChangeLogDto
))
.toList());
// 排序
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
if (CommentSort.NEWEST.equals(sort)) {
comparator = comparator.reversed();
}
itemDtoList.sort(comparator);
log.debug("listComments returning {} comments", itemDtoList.size());
return itemDtoList;
} }
@DeleteMapping("/comments/{id}") @DeleteMapping("/comments/{id}")

View File

@@ -27,6 +27,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PostController { public class PostController {
private final PostService postService; private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final LevelService levelService; private final LevelService levelService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final DraftService draftService; private final DraftService draftService;
@@ -147,33 +149,16 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
// } // }
boolean hasCategories = ids != null && !ids.isEmpty(); return postService.defaultListPosts(ids,tids,page, pageSize).stream()
boolean hasTags = tids != null && !tids.isEmpty(); .map(postMapper::toSummaryDto).collect(Collectors.toList());
if (hasCategories && hasTags) {
return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
if (hasTags) {
return postService.listPostsByTags(tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
return postService.listPostsByCategories(ids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }
@GetMapping("/ranking") @GetMapping("/ranking")
@@ -187,14 +172,9 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
@@ -215,21 +195,16 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
// } // }
return postService.listPostsByLatestReply(ids, tids, page, pageSize) List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }
@GetMapping("/featured") @GetMapping("/featured")
@@ -243,14 +218,9 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());

View File

@@ -0,0 +1,20 @@
package com.openisle.dto;
import lombok.*;
import java.time.LocalDateTime;
/**
* comment and change_log Dto
*/
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class TimelineItemDto<T> {
private Long id;
private String kind; // "comment" | "log"
private LocalDateTime createdAt;
private T payload; // 泛型,具体类型由外部决定
}

View File

@@ -67,7 +67,6 @@ public class PostMapper {
dto.setCategory(categoryMapper.toDto(post.getCategory())); dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList())); dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews()); dto.setViews(post.getViews());
dto.setCommentCount(commentService.countComments(post.getId()));
dto.setStatus(post.getStatus()); dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt()); dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded()); dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
@@ -82,8 +81,12 @@ public class PostMapper {
List<User> participants = commentService.getParticipants(post.getId(), 5); List<User> participants = commentService.getParticipants(post.getId(), 5);
dto.setParticipants(participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); dto.setParticipants(participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
LocalDateTime last = commentService.getLastCommentTime(post.getId()); LocalDateTime last = post.getLastReplyAt();
dto.setLastReplyAt(last != null ? last : post.getCreatedAt()); if (last == null) {
commentService.updatePostCommentStats(post);
}
dto.setCommentCount(post.getCommentCount());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setReward(0); dto.setReward(0);
dto.setSubscribed(false); dto.setSubscribed(false);
dto.setType(post.getType()); dto.setType(post.getType());
@@ -96,8 +99,6 @@ public class PostMapper {
l.setPointCost(lp.getPointCost()); l.setPointCost(lp.getPointCost());
l.setStartTime(lp.getStartTime()); l.setStartTime(lp.getStartTime());
l.setEndTime(lp.getEndTime()); l.setEndTime(lp.getEndTime());
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
dto.setLottery(l); dto.setLottery(l);
} }
@@ -106,7 +107,6 @@ public class PostMapper {
p.setOptions(pp.getOptions()); p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes()); p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime()); p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream() Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex, .collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));

View File

@@ -39,19 +39,19 @@ public class Post {
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "author_id") @JoinColumn(name = "author_id")
private User author; private User author;
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "category_id") @JoinColumn(name = "category_id")
private Category category; private Category category;
@ManyToMany(fetch = FetchType.LAZY) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "post_tags", @JoinTable(name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"), joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")) inverseJoinColumns = @JoinColumn(name = "tag_id"))
private java.util.Set<Tag> tags = new java.util.HashSet<>(); private Set<Tag> tags = new HashSet<>();
@Column(nullable = false) @Column(nullable = false)
private long views = 0; private long views = 0;
@@ -72,4 +72,10 @@ public class Post {
@Column(nullable = true) @Column(nullable = true)
private Boolean rssExcluded = true; private Boolean rssExcluded = true;
@Column(nullable = false)
private long commentCount = 0;
@Column(nullable = true)
private LocalDateTime lastReplyAt;
} }

View File

@@ -1,8 +1,9 @@
package com.openisle.repository; package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.Post;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -14,6 +15,8 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
long countByUser(User user); long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment); List<PointHistory> findByComment(Comment comment);
List<PointHistory> findByPost(Post post);
} }

View File

@@ -8,4 +8,6 @@ import java.util.List;
public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> { public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> {
List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post); List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post);
void deleteByPost(Post post);
} }

View File

@@ -1,4 +1,4 @@
package com.openisle.schdule; package com.openisle.scheduler;
import com.openisle.config.CachingConfig; import com.openisle.config.CachingConfig;
import com.openisle.model.User; import com.openisle.model.User;
@@ -27,12 +27,12 @@ public class UserVisitScheduler {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserVisitRepository userVisitRepository; private final UserVisitRepository userVisitRepository;
@Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行 @Scheduled(cron = "0 5 0 * * ?") // 每天 00:05 执行
public void persistDailyVisits(){ public void persistDailyVisits(){
LocalDate yesterday = LocalDate.now().minusDays(1); LocalDate yesterday = LocalDate.now().minusDays(1);
String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday; String key = CachingConfig.VISIT_CACHE_NAME + ":" + yesterday;
Set<String> usernames = redisTemplate.opsForSet().members(key); Set<String> usernames = redisTemplate.opsForSet().members(key);
if(!CollectionUtils.isEmpty(usernames)){ if (!CollectionUtils.isEmpty(usernames)) {
for(String username: usernames){ for(String username: usernames){
User user = userRepository.findByUsername(username).orElse(null); User user = userRepository.findByUsername(username).orElse(null);
if(user != null){ if(user != null){

View File

@@ -62,4 +62,18 @@ public class CategoryService {
public List<Category> listCategories() { public List<Category> listCategories() {
return categoryRepository.findAll(); return categoryRepository.findAll();
} }
/**
* 获取检索用的分类Id列表
* @param categoryIds
* @param categoryId
* @return
*/
public List<Long> getSearchCategoryIds(List<Long> categoryIds, Long categoryId){
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = List.of(categoryId);
}
return ids;
}
} }

View File

@@ -1,5 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.User; import com.openisle.model.User;
@@ -20,6 +21,8 @@ import com.openisle.model.Role;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -47,6 +50,10 @@ public class CommentService {
private final PointService pointService; private final PointService pointService;
private final ImageUploader imageUploader; private final ImageUploader imageUploader;
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public Comment addComment(String username, Long postId, String content) { public Comment addComment(String username, Long postId, String content) {
log.debug("addComment called by user {} for post {}", username, postId); log.debug("addComment called by user {} for post {}", username, postId);
@@ -69,6 +76,10 @@ public class CommentService {
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
log.debug("Comment {} saved for post {}", comment.getId(), postId); log.debug("Comment {} saved for post {}", comment.getId(), postId);
// Update post comment statistics
updatePostCommentStats(post);
imageUploader.addReferences(imageUploader.extractUrls(content)); imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) { if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
@@ -95,6 +106,10 @@ public class CommentService {
return commentRepository.findLastCommentTimeOfUserByUserId(userId); return commentRepository.findLastCommentTimeOfUserByUserId(userId);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public Comment addReply(String username, Long parentId, String content) { public Comment addReply(String username, Long parentId, String content) {
log.debug("addReply called by user {} for parent comment {}", username, parentId); log.debug("addReply called by user {} for parent comment {}", username, parentId);
@@ -118,6 +133,10 @@ public class CommentService {
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
log.debug("Reply {} saved for parent {}", comment.getId(), parentId); log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
// Update post comment statistics
updatePostCommentStats(parent.getPost());
imageUploader.addReferences(imageUploader.extractUrls(content)); imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) { if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
@@ -228,6 +247,10 @@ public class CommentService {
return count; return count;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public void deleteComment(String username, Long id) { public void deleteComment(String username, Long id) {
log.debug("deleteComment called by user {} for comment {}", username, id); log.debug("deleteComment called by user {} for comment {}", username, id);
@@ -243,6 +266,10 @@ public class CommentService {
log.debug("deleteComment completed for comment {}", id); log.debug("deleteComment completed for comment {}", id);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public void deleteCommentCascade(Comment comment) { public void deleteCommentCascade(Comment comment) {
log.debug("deleteCommentCascade called for comment {}", comment.getId()); log.debug("deleteCommentCascade called for comment {}", comment.getId());
@@ -263,9 +290,13 @@ public class CommentService {
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论 // 逻辑删除评论
Post post = comment.getPost();
commentRepository.delete(comment); commentRepository.delete(comment);
// 删除积分历史 // 删除积分历史
pointHistoryRepository.deleteAll(pointHistories); pointHistoryRepository.deleteAll(pointHistories);
// Update post comment statistics
updatePostCommentStats(post);
// 重新计算受影响用户的积分 // 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) { if (!usersToRecalculate.isEmpty()) {
@@ -311,4 +342,23 @@ public class CommentService {
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
return reactions + replies; return reactions + replies;
} }
/**
* Update post comment statistics (comment count and last reply time)
*/
public void updatePostCommentStats(Post post) {
long commentCount = commentRepository.countByPostId(post.getId());
post.setCommentCount(commentCount);
LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post);
if (lastReplyAt == null) {
post.setLastReplyAt(post.getCreatedAt());
} else {
post.setLastReplyAt(lastReplyAt);
}
postRepository.save(post);
log.debug("Updated post {} stats: commentCount={}, lastReplyAt={}",
post.getId(), commentCount, lastReplyAt);
}
} }

View File

@@ -109,6 +109,10 @@ public class PostChangeLogService {
logRepository.save(log); logRepository.save(log);
} }
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}
public List<PostChangeLog> listLogs(Long postId) { public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -1,36 +1,26 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig; import com.openisle.config.CachingConfig;
import com.openisle.model.Post; import com.openisle.mapper.PostMapper;
import com.openisle.model.PostStatus; import com.openisle.model.*;
import com.openisle.model.PostType;
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.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.PollVote;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository; import com.openisle.repository.PollPostRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository; import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository; import com.openisle.repository.TagRepository;
import com.openisle.service.SubscriptionService;
import com.openisle.service.CommentService;
import com.openisle.service.PostChangeLogService;
import com.openisle.repository.CommentRepository; import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository; import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role; import com.openisle.repository.PointHistoryRepository;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -52,6 +42,8 @@ import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.stream.Collectors;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
@@ -80,6 +72,7 @@ public class PostService {
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final PointService pointService; private final PointService pointService;
private final PostChangeLogService postChangeLogService; private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
@@ -108,6 +101,7 @@ public class PostService {
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService, PointService pointService,
PostChangeLogService postChangeLogService, PostChangeLogService postChangeLogService,
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate) { RedisTemplate redisTemplate) {
this.postRepository = postRepository; this.postRepository = postRepository;
@@ -131,6 +125,7 @@ public class PostService {
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.pointService = pointService; this.pointService = pointService;
this.postChangeLogService = postChangeLogService; this.postChangeLogService = postChangeLogService;
this.pointHistoryRepository = pointHistoryRepository;
this.publishMode = publishMode; this.publishMode = publishMode;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
@@ -195,12 +190,14 @@ public class PostService {
pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId());
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
public Post createPost(String username, public Post createPost(String username,
Long categoryId, Long categoryId,
String title, String title,
String content, String content,
java.util.List<Long> tagIds, List<Long> tagIds,
PostType type, PostType type,
String prizeDescription, String prizeDescription,
String prizeIcon, String prizeIcon,
@@ -511,6 +508,10 @@ public class PostService {
return listPostsByLatestReply(null, null, page, pageSize); return listPostsByLatestReply(null, null, page, pageSize);
} }
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryIds, #tagIds, #page, #pageSize)"
)
public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds, public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
java.util.List<Long> tagIds, java.util.List<Long> tagIds,
Integer page, Integer page,
@@ -538,9 +539,9 @@ public class PostService {
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
} }
} else { } else {
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds); List<Tag> tags = tagRepository.findAllById(tagIds);
if (tags.isEmpty()) { if (tags.isEmpty()) {
return java.util.List.of(); return new ArrayList<>();
} }
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
} }
@@ -638,11 +639,43 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize); return paginate(sortByPinnedAndCreated(posts), page, pageSize);
} }
/**
* 默认的文章列表
* @param ids
* @param tids
* @param page
* @param pageSize
* @return
*/
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('default', #ids, #tids, #page, #pageSize)"
)
public List<Post> defaultListPosts(List<Long> ids, List<Long> tids, Integer page, Integer pageSize){
boolean hasCategories = !CollectionUtils.isEmpty(ids);
boolean hasTags = !CollectionUtils.isEmpty(tids);
if (hasCategories && hasTags) {
return listPostsByCategoriesAndTags(ids, tids, page, pageSize)
.stream().collect(Collectors.toList());
}
if (hasTags) {
return listPostsByTags(tids, page, pageSize)
.stream().collect(Collectors.toList());
}
return listPostsByCategories(ids, page, pageSize)
.stream().collect(Collectors.toList());
}
public List<Post> listPendingPosts() { public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING); return postRepository.findByStatus(PostStatus.PENDING);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post approvePost(Long id) { public Post approvePost(Long id) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -679,6 +712,10 @@ public class PostService {
return post; return post;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post pinPost(Long id, String username) { public Post pinPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -691,6 +728,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post unpinPost(Long id, String username) { public Post unpinPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -703,6 +744,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post closePost(Long id, String username) { public Post closePost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -718,6 +763,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post reopenPost(Long id, String username) { public Post reopenPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -733,7 +782,11 @@ public class PostService {
return saved; return saved;
} }
@org.springframework.transaction.annotation.Transactional @CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public Post updatePost(Long id, public Post updatePost(Long id,
String username, String username,
Long categoryId, Long categoryId,
@@ -786,7 +839,11 @@ public class PostService {
return updated; return updated;
} }
@org.springframework.transaction.annotation.Transactional @CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public void deletePost(Long id, String username) { public void deletePost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -805,6 +862,25 @@ public class PostService {
notificationRepository.deleteAll(notificationRepository.findByPost(post)); notificationRepository.deleteAll(notificationRepository.findByPost(post));
postReadService.deleteByPost(post); postReadService.deleteByPost(post);
imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); imageUploader.removeReferences(imageUploader.extractUrls(post.getContent()));
List<PointHistory> pointHistories = pointHistoryRepository.findByPost(post);
Set<User> usersToRecalculate = pointHistories.stream()
.map(PointHistory::getUser)
.collect(Collectors.toSet());
if (!pointHistories.isEmpty()) {
LocalDateTime deletedAt = LocalDateTime.now();
for (PointHistory history : pointHistories) {
history.setDeletedAt(deletedAt);
history.setPost(null);
}
pointHistoryRepository.saveAll(pointHistories);
}
if (!usersToRecalculate.isEmpty()) {
for (User affected : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(affected);
affected.setPoint(newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
if (post instanceof LotteryPost lp) { if (post instanceof LotteryPost lp) {
ScheduledFuture<?> future = scheduledFinalizations.remove(lp.getId()); ScheduledFuture<?> future = scheduledFinalizations.remove(lp.getId());
if (future != null) { if (future != null) {
@@ -812,6 +888,7 @@ public class PostService {
} }
} }
String title = post.getTitle(); String title = post.getTitle();
postChangeLogService.deleteLogsForPost(post);
postRepository.delete(post); postRepository.delete(post);
if (adminDeleting) { if (adminDeleting) {
notificationService.createNotification(author, NotificationType.POST_DELETED, notificationService.createNotification(author, NotificationType.POST_DELETED,
@@ -879,15 +956,17 @@ public class PostService {
.toList(); .toList();
} }
private java.util.List<Post> paginate(java.util.List<Post> posts, Integer page, Integer pageSize) { private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) { if (page == null || pageSize == null) {
return posts; return posts;
} }
int from = page * pageSize; int from = page * pageSize;
if (from >= posts.size()) { if (from >= posts.size()) {
return java.util.List.of(); return new ArrayList<>();
} }
int to = Math.min(from + pageSize, posts.size()); int to = Math.min(from + pageSize, posts.size());
return posts.subList(from, to); // 这里必须将list包装为arrayList类型否则序列化会有问题
// list.sublist返回的是内部类
return new ArrayList<>(posts.subList(from, to));
} }
} }

View File

@@ -120,4 +120,18 @@ public class TagService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return tagRepository.findByCreator(user); return tagRepository.findByCreator(user);
} }
/**
* 获取检索用的标签Id列表
* @param tagIds
* @param tagId
* @return
*/
public List<Long> getSearchTagIds(List<Long> tagIds, Long tagId){
List<Long> ids = tagIds;
if (tagId != null) {
ids = List.of(tagId);
}
return ids;
}
} }

View File

@@ -100,7 +100,7 @@ public class UserService {
* @param user * @param user
*/ */
public void sendVerifyMail(User user, VerifyType verifyType){ public void sendVerifyMail(User user, VerifyType verifyType){
//缓存验证码 // 缓存验证码
String code = genCode(); String code = genCode();
String key; String key;
String subject; String subject;

View File

@@ -49,9 +49,9 @@ public class UserVisitService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
// 如果缓存存在就返回 // 如果缓存存在就返回
String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username; String key1 = CachingConfig.VISIT_CACHE_NAME + ":" +LocalDate.now() + ":count:" + username;
Integer cached = (Integer) redisTemplate.opsForValue().get(key1); Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if(cached != null){ if (cached != null){
return cached.longValue(); return cached.longValue();
} }

View File

@@ -0,0 +1,19 @@
-- Add comment count and last reply time fields to posts table for performance optimization
ALTER TABLE posts ADD COLUMN comment_count BIGINT NOT NULL DEFAULT 0;
ALTER TABLE posts ADD COLUMN last_reply_at DATETIME(6) NULL;
-- Add index on last_reply_at for sorting by latest reply
CREATE INDEX idx_posts_last_reply_at ON posts(last_reply_at);
-- Initialize comment_count and last_reply_at with existing data
UPDATE posts p SET
comment_count = (
SELECT COUNT(*)
FROM comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
),
last_reply_at = (
SELECT MAX(c.created_at)
FROM comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
);

View File

@@ -55,6 +55,10 @@ class PostControllerTest {
private UserVisitService userVisitService; private UserVisitService userVisitService;
@MockBean @MockBean
private PostReadService postReadService; private PostReadService postReadService;
@MockBean
private MedalService medalService;
@MockBean
private com.openisle.repository.PollVoteRepository pollVoteRepository;
@Test @Test
void createAndGetPost() throws Exception { void createAndGetPost() throws Exception {
@@ -63,9 +67,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");
@@ -111,9 +119,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t2"); post.setTitle("t2");
@@ -147,9 +159,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(2L); post.setId(2L);
post.setTitle("hello"); post.setTitle("hello");
@@ -197,9 +213,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");
@@ -262,6 +282,8 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");

View File

@@ -0,0 +1,90 @@
package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(locations = "classpath:application.properties")
@Transactional
public class PostCommentStatsTest {
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private TagRepository tagRepository;
@Autowired
private CommentService commentService;
@Test
public void testPostCommentStatsUpdate() {
// Create test user
User user = new User();
user.setUsername("testuser");
user.setEmail("test@example.com");
user.setPassword("hash");
user = userRepository.save(user);
// Create test category
Category category = new Category();
category.setName("Test Category");
category.setDescription("Test Category Description");
category.setIcon("test-icon");
category = categoryRepository.save(category);
// Create test tag
Tag tag = new Tag();
tag.setName("Test Tag");
tag.setDescription("Test Tag Description");
tag.setIcon("test-tag-icon");
tag = tagRepository.save(tag);
// Create test post
Post post = new Post();
post.setTitle("Test Post");
post.setContent("Test content");
post.setAuthor(user);
post.setCategory(category);
post.getTags().add(tag);
post.setStatus(PostStatus.PUBLISHED);
post.setCommentCount(0L);
post = postRepository.save(post);
// Verify initial state
assertEquals(0L, post.getCommentCount());
assertNull(post.getLastReplyAt());
// Add a comment
commentService.addComment("testuser", post.getId(), "Test comment");
// Refresh post from database
post = postRepository.findById(post.getId()).orElseThrow();
// Verify comment count and last reply time are updated
assertEquals(1L, post.getCommentCount());
assertNotNull(post.getLastReplyAt());
// Add another comment
commentService.addComment("testuser", post.getId(), "Another comment");
// Refresh post again
post = postRepository.findById(post.getId()).orElseThrow();
// Verify comment count is updated
assertEquals(2L, post.getCommentCount());
}
}

View File

@@ -10,8 +10,11 @@ import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import java.util.Optional; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -39,12 +42,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -60,11 +65,13 @@ class PostServiceTest {
when(reactionRepo.findByPost(post)).thenReturn(List.of()); when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of()); when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of()); when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "alice"); service.deletePost(1L, "alice");
verify(postReadService).deleteByPost(post); verify(postReadService).deleteByPost(post);
verify(postRepo).delete(post); verify(postRepo).delete(post);
verify(postChangeLogService).deleteLogsForPost(post);
} }
@Test @Test
@@ -90,12 +97,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -117,6 +126,7 @@ class PostServiceTest {
when(reactionRepo.findByPost(post)).thenReturn(List.of()); when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of()); when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of()); when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "admin"); service.deletePost(1L, "admin");
@@ -147,12 +157,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -162,6 +174,77 @@ class PostServiceTest {
null, null, null, null, null, null, null, null, null)); null, null, null, null, null, null, null, null, null));
} }
@Test
void deletePostRemovesPointHistoriesAndRecalculatesPoints() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
post.setId(10L);
User author = new User();
author.setId(20L);
author.setRole(Role.USER);
post.setAuthor(author);
User historyUser = new User();
historyUser.setId(30L);
PointHistory history = new PointHistory();
history.setUser(historyUser);
history.setPost(post);
when(postRepo.findById(10L)).thenReturn(Optional.of(post));
when(userRepo.findByUsername("author")).thenReturn(Optional.of(author));
when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of(history));
when(pointService.recalculateUserPoints(historyUser)).thenReturn(0);
service.deletePost(10L, "author");
ArgumentCaptor<List<PointHistory>> captor = ArgumentCaptor.forClass(List.class);
verify(pointHistoryRepository).saveAll(captor.capture());
List<PointHistory> savedHistories = captor.getValue();
assertEquals(1, savedHistories.size());
PointHistory savedHistory = savedHistories.get(0);
assertNull(savedHistory.getPost());
assertNotNull(savedHistory.getDeletedAt());
assertTrue(savedHistory.getDeletedAt().isBefore(LocalDateTime.now().plusSeconds(1)));
verify(pointService).recalculateUserPoints(historyUser);
verify(userRepo).saveAll(any());
}
@Test @Test
void finalizeLotteryNotifiesAuthor() { void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class); PostRepository postRepo = mock(PostRepository.class);
@@ -185,12 +268,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -4,7 +4,18 @@ spring.datasource.username=sa
spring.datasource.password= spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=create-drop
springdoc.info.title=openisle
springdoc.info.description=Test API documentation
springdoc.info.version=1.0.0
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization
rabbitmq.queue.durable=true
rabbitmq.sharding.enabled=true
resend.api.key=dummy resend.api.key=dummy
resend.from.email=dummy@example.com
cos.base-url=http://test.example.com cos.base-url=http://test.example.com
cos.secret-id=dummy cos.secret-id=dummy
cos.secret-key=dummy cos.secret-key=dummy
@@ -18,6 +29,7 @@ app.upload.max-size=1048576
app.jwt.secret=TestSecret app.jwt.secret=TestSecret
app.jwt.reason-secret=TestReasonSecret app.jwt.reason-secret=TestReasonSecret
app.jwt.reset-secret=TestResetSecret app.jwt.reset-secret=TestResetSecret
app.jwt.invite-secret=TestInviteSecret
app.jwt.expiration=3600000 app.jwt.expiration=3600000
# Default publish mode for tests # Default publish mode for tests

View File

@@ -4,7 +4,9 @@ NUXT_PUBLIC_WEBSOCKET_URL=https://127.0.0.1:8082
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000 NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ # NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
; 本地
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135

View File

@@ -2,6 +2,8 @@
--primary-color-hover: rgb(9, 95, 105); --primary-color-hover: rgb(9, 95, 105);
--primary-color: rgb(10, 110, 120); --primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5); --primary-color-disabled: rgba(93, 152, 156, 0.5);
--secondary-color: rgb(255, 255, 255);
--secondary-color-hover: rgba(10, 111, 120, 0.184);
--new-post-icon-color: rgba(10, 111, 120, 0.598); --new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px; --header-height: 60px;
--header-background-color: white; --header-background-color: white;
@@ -239,7 +241,7 @@ body {
} }
.info-content-text img { .info-content-text img {
max-width: 400px; max-width: min(800px, 100%);
max-height: 600px; max-height: 600px;
height: auto; height: auto;
cursor: pointer; cursor: pointer;
@@ -354,25 +356,41 @@ body {
position: relative; position: relative;
min-width: 0; min-width: 0;
} }
}
/* Adjust diff2html layout on mobile */ .d2h-file-name {
@media (max-width: 768px) { font-size: 14px !important;
.content-diff .d2h-wrapper,
.content-diff .d2h-code-line,
.content-diff .d2h-code-side-line,
.content-diff .d2h-code-line-ctn,
.content-diff .d2h-code-side-line-ctn,
.content-diff .d2h-file-header {
font-size: 12px;
} }
.content-diff .d2h-wrapper { .d2h-file-header {
overflow-x: auto; height: auto !important;
} }
.d2h-code-linenumber {
display: none !important;
}
.d2h-code-line {
padding-left: 10px !important;
}
/* .d2h-diff-table {
font-size: 6px !important;
}
.d2h-code-line ins {
height: 100%;
font-size: 13px !important;
} */
/* .d2h-code-line {
height: 12px;
}
.d2h-code-line-ctn {
font-size: 12px !important;
} */
} }
/* Transition API */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {
animation: none; animation: none;

View File

@@ -35,6 +35,7 @@ const isImageIcon = (icon) => {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
min-height: 25px;
} }
.article-info-item { .article-info-item {
@@ -63,5 +64,9 @@ const isImageIcon = (icon) => {
.article-info-item { .article-info-item {
font-size: 10px; font-size: 10px;
} }
.article-category-container {
min-height: 20px;
}
} }
</style> </style>

View File

@@ -44,6 +44,7 @@ const isImageIcon = (icon) => {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
min-height: 25px;
} }
.article-info-item { .article-info-item {
@@ -72,5 +73,9 @@ const isImageIcon = (icon) => {
.article-info-item { .article-info-item {
font-size: 10px; font-size: 10px;
} }
.article-tags-container {
min-height: 20px;
}
} }
</style> </style>

View File

@@ -6,8 +6,15 @@
</div> </div>
<div class="comment-bottom-container"> <div class="comment-bottom-container">
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit"> <div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
<template v-if="!loading"> 发布评论 </template> <template v-if="!loading">
<template v-else> <loading-four /> 发布中... </template> 发布评论
<span class="shortcut-icon" v-if="!isMobile">
{{ isMac ? '' : 'Ctrl' }}
</span>
</template>
<template v-else>
<loading-four /> 发布中...
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -24,6 +31,7 @@ import {
} from '~/utils/vditor' } from '~/utils/vditor'
import '~/assets/global.css' import '~/assets/global.css'
import LoginOverlay from '~/components/LoginOverlay.vue' import LoginOverlay from '~/components/LoginOverlay.vue'
import { useIsMobile } from '~/utils/screen'
export default { export default {
name: 'CommentEditor', name: 'CommentEditor',
@@ -52,12 +60,22 @@ export default {
}, },
components: { LoginOverlay }, components: { LoginOverlay },
setup(props, { emit }) { setup(props, { emit }) {
const isMobile = useIsMobile()
const vditorInstance = ref(null) const vditorInstance = ref(null)
const text = ref('') const text = ref('')
const editorId = ref(props.editorId) const editorId = ref(props.editorId)
if (!editorId.value) { if (!editorId.value) {
editorId.value = 'editor-' + useId() editorId.value = 'editor-' + useId()
} }
const isMac = ref(false)
if (navigator.userAgentData) {
isMac.value = navigator.userAgentData.platform === 'macOS'
} else {
isMac.value = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
}
const getEditorTheme = getEditorThemeUtil const getEditorTheme = getEditorThemeUtil
const getPreviewTheme = getPreviewThemeUtil const getPreviewTheme = getPreviewThemeUtil
const applyTheme = () => { const applyTheme = () => {
@@ -96,7 +114,27 @@ export default {
applyTheme() applyTheme()
}, },
}) })
// applyTheme() // 不是手机的情况下不添加快捷键
if(!isMobile.value){
// 添加快捷键监听 (Ctrl+Enter 或 Cmd+Enter)
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
submit()
}
}
const el = document.getElementById(editorId.value)
if (el) {
el.addEventListener('keydown', handleKeydown)
}
onUnmounted(() => {
if (el) {
el.removeEventListener('keydown', handleKeydown)
}
})
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -134,7 +172,7 @@ export default {
}, },
) )
return { submit, isDisabled, editorId } return { submit, isDisabled, editorId, isMac, isMobile}
}, },
} }
</script> </script>
@@ -174,10 +212,16 @@ export default {
.comment-submit:hover { .comment-submit:hover {
background-color: var(--primary-color-hover); background-color: var(--primary-color-hover);
} }
/** 评论按钮快捷键样式 */
@media (max-width: 768px) { .shortcut-icon {
.comment-editor-container { padding: 2px 6px;
margin-bottom: 10px; border-radius: 6px;
} font-size: 12px;
font-weight: 500;
line-height: 1.2;
background-color: rgba(0, 0, 0, 0.25);
}
.comment-submit.disabled .shortcut-icon {
background-color: rgba(0, 0, 0, 0);
} }
</style> </style>

View File

@@ -342,7 +342,7 @@ const copyCommentLink = () => {
const handleContentClick = (e) => { const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG' && !e.target.classList.contains('emoji')) {
const container = e.target.parentNode const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src) const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs lightboxImgs.value = imgs

View File

@@ -314,6 +314,7 @@ const gotoTag = (t) => {
border-radius: 10px; border-radius: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
transition: background-color 0.5s ease;
} }
.menu-item:hover { .menu-item:hover {
@@ -408,6 +409,7 @@ const gotoTag = (t) => {
gap: 5px; gap: 5px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.5s ease;
} }
.section-item:hover { .section-item:hover {

View File

@@ -5,7 +5,10 @@
</div> </div>
<div class="message-bottom-container"> <div class="message-bottom-container">
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit"> <div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
<template v-if="!loading"> 发送 </template> <template v-if="!loading">
发送
<span class="shortcut-icon" v-if="!isMobile"> {{ isMac ? '' : 'Ctrl' }} </span>
</template>
<template v-else> <loading-four /> 发送中... </template> <template v-else> <loading-four /> 发送中... </template>
</div> </div>
</div> </div>
@@ -21,6 +24,8 @@ import {
getEditorTheme as getEditorThemeUtil, getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil, getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor' } from '~/utils/vditor'
import { useIsMobile } from '~/utils/screen'
import { isMac } from '~/utils/device'
import '~/assets/global.css' import '~/assets/global.css'
export default { export default {
@@ -44,6 +49,7 @@ export default {
const vditorInstance = ref(null) const vditorInstance = ref(null)
const text = ref('') const text = ref('')
const editorId = ref(props.editorId) const editorId = ref(props.editorId)
const isMobile = useIsMobile()
if (!editorId.value) { if (!editorId.value) {
editorId.value = 'editor-' + useId() editorId.value = 'editor-' + useId()
} }
@@ -84,6 +90,28 @@ export default {
applyTheme() applyTheme()
}, },
}) })
// 不是手机的情况下不添加快捷键
if (!isMobile.value) {
// 添加快捷键监听 (Ctrl+Enter 或 Cmd+Enter)
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
submit()
}
}
const el = document.getElementById(editorId.value)
if (el) {
el.addEventListener('keydown', handleKeydown)
}
onUnmounted(() => {
if (el) {
el.removeEventListener('keydown', handleKeydown)
}
})
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -121,7 +149,7 @@ export default {
}, },
) )
return { submit, isDisabled, editorId } return { submit, isDisabled, editorId, isMac, isMobile }
}, },
} }
</script> </script>
@@ -168,4 +196,17 @@ export default {
.message-submit:not(.disabled):hover { .message-submit:not(.disabled):hover {
background-color: var(--primary-color-hover); background-color: var(--primary-color-hover);
} }
/** 评论按钮快捷键样式 */
.shortcut-icon {
padding: 2px 6px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
background-color: rgba(0, 0, 0, 0.25);
}
.comment-submit.disabled .shortcut-icon {
background-color: rgba(0, 0, 0, 0);
}
</style> </style>

View File

@@ -110,11 +110,13 @@ const diffHtml = computed(() => {
border-bottom: 1px solid var(--normal-border-color); border-bottom: 1px solid var(--normal-border-color);
padding-bottom: 10px; padding-bottom: 10px;
} }
.change-log-text { .change-log-text {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
} }
.change-log-user { .change-log-user {
font-weight: bold; font-weight: bold;
margin-right: 4px; margin-right: 4px;
@@ -132,6 +134,7 @@ const diffHtml = computed(() => {
margin-right: 4px; margin-right: 4px;
cursor: pointer; cursor: pointer;
} }
.change-log-time { .change-log-time {
font-size: 12px; font-size: 12px;
opacity: 0.6; opacity: 0.6;

View File

@@ -107,14 +107,52 @@ const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) => const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username) reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => { const baseReactionOrder = computed(() => {
return Object.entries(counts.value) if (reactionTypes.value.length) return [...reactionTypes.value]
.sort((a, b) => b[1] - a[1])
.slice(0, 3) const order = []
.map(([type]) => ({ type })) const seen = new Set()
for (const reaction of reactions.value) {
if (!seen.has(reaction.type)) {
seen.add(reaction.type)
order.push(reaction.type)
}
}
return order
}) })
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE')) const sortedReactionTypes = computed(() => {
const baseOrder = [...baseReactionOrder.value]
for (const type of Object.keys(counts.value)) {
if (!baseOrder.includes(type)) baseOrder.push(type)
}
const withMetadata = baseOrder.map((type, index) => ({
type,
count: counts.value[type] || 0,
index,
}))
const nonZero = withMetadata
.filter((item) => item.count > 0)
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count
return a.index - b.index
})
const zero = withMetadata.filter((item) => item.count === 0)
return [...nonZero, ...zero].map((item) => item.type)
})
const displayedReactions = computed(() => {
return sortedReactionTypes.value
.filter((type) => counts.value[type] > 0)
.slice(0, 3)
.map((type) => ({ type }))
})
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false) const panelVisible = ref(false)
let hideTimer = null let hideTimer = null

View File

@@ -0,0 +1,90 @@
/**
* 文件上传配置
*/
export const UPLOAD_CONFIG = {
// 视频文件配置
VIDEO: {
// 文件大小限制 (字节)
MAX_SIZE: 20 * 1024 * 1024,
// 支持的输入格式
SUPPORTED_FORMATS: ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv'],
},
// 图片文件配置
IMAGE: {
MAX_SIZE: 5 * 1024 * 1024, // 5MB
TARGET_SIZE: 5 * 1024 * 1024, // 5MB
SUPPORTED_FORMATS: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'],
},
// 音频文件配置
AUDIO: {
MAX_SIZE: 5 * 1024 * 1024, // 5MB
TARGET_SIZE: 5 * 1024 * 1024, // 5MB
SUPPORTED_FORMATS: ['mp3', 'wav', 'ogg', 'aac', 'm4a'],
},
// 通用文件配置
GENERAL: {
MAX_SIZE: 100 * 1024 * 1024, // 100MB
CHUNK_SIZE: 5 * 1024 * 1024, // 5MB 分片大小
},
// 用户体验配置
UI: {
SUCCESS_DURATION: 2000,
ERROR_DURATION: 3000,
WARNING_DURATION: 3000,
},
}
/**
* 获取文件类型配置
*/
export function getFileTypeConfig(filename) {
const ext = filename.split('.').pop().toLowerCase()
if (UPLOAD_CONFIG.VIDEO.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'video', config: UPLOAD_CONFIG.VIDEO }
}
if (UPLOAD_CONFIG.IMAGE.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'image', config: UPLOAD_CONFIG.IMAGE }
}
if (UPLOAD_CONFIG.AUDIO.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'audio', config: UPLOAD_CONFIG.AUDIO }
}
return { type: 'general', config: UPLOAD_CONFIG.GENERAL }
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
/**
* 计算压缩节省的费用 (示例函数)
*/
export function calculateSavings(originalSize, compressedSize, costPerMB = 0.01) {
const originalMB = originalSize / (1024 * 1024)
const compressedMB = compressedSize / (1024 * 1024)
const savedMB = originalMB - compressedMB
const savedCost = savedMB * costPerMB
return {
savedMB: savedMB.toFixed(2),
savedCost: savedCost.toFixed(4),
originalCost: (originalMB * costPerMB).toFixed(4),
compressedCost: (compressedMB * costPerMB).toFixed(4),
}
}

View File

@@ -3,7 +3,7 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({ export default defineNuxtConfig({
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000 port: 3000,
}, },
ssr: true, ssr: true,
modules: ['@nuxt/image'], modules: ['@nuxt/image'],
@@ -97,26 +97,7 @@ export default defineNuxtConfig({
}, },
}, },
vite: { vite: {
build: { optimizeDeps: {},
// increase warning limit and split large libraries into separate chunks build: {},
// chunkSizeWarningLimit: 1024,
// rollupOptions: {
// output: {
// manualChunks(id) {
// if (id.includes('node_modules')) {
// if (id.includes('vditor')) {
// return 'vditor'
// }
// if (id.includes('echarts')) {
// return 'echarts'
// }
// if (id.includes('highlight.js')) {
// return 'highlight'
// }
// }
// },
// },
// },
},
}, },
}) })

View File

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"ldrs": "^1.0.0", "ldrs": "^1.0.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"mermaid": "^10.9.4", "mermaid": "^10.9.4",
"nanoid": "^5.1.5",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"nuxt": "latest", "nuxt": "latest",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",

View File

@@ -424,7 +424,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
.topic-container { .topic-container {
position: sticky; position: sticky;
top: calc(var(--header-height) + 1px); top: var(--header-height);
padding-top: 10px;
z-index: 10; z-index: 10;
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
display: flex; display: flex;
@@ -432,12 +433,10 @@ const sanitizeDescription = (text) => stripMarkdown(text)
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
padding: 10px 0;
backdrop-filter: var(--blur-10); backdrop-filter: var(--blur-10);
} }
.topic-item-container { .topic-item-container {
margin-left: 20px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -478,6 +477,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
width: 100%; width: 100%;
color: gray; color: gray;
border-bottom: 1px solid var(--normal-border-color); border-bottom: 1px solid var(--normal-border-color);
padding-top: 30px;
padding-bottom: 10px; padding-bottom: 10px;
} }
@@ -487,6 +487,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
align-items: center; align-items: center;
width: 100%; width: 100%;
border-bottom: 1px solid var(--normal-border-color); border-bottom: 1px solid var(--normal-border-color);
transition: background-color 0.5s ease;
} }
.article-item:hover { .article-item:hover {
@@ -593,13 +594,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
margin-bottom: 10px; margin-bottom: 10px;
} }
.article-tags-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.article-tag-item { .article-tag-item {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -20,7 +20,7 @@
</div> </div>
</div> </div>
<div class="messages-list" ref="messagesListEl" @click="handleContentClick"> <div class="messages-list" ref="messagesListEl">
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
@@ -50,7 +50,11 @@
<div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div> <div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div>
</div> </div>
<div class="message-content"> <div class="message-content">
<div class="info-content-text" v-html="renderMarkdown(item.content)"></div> <div
class="info-content-text"
v-html="renderMarkdown(item.content)"
@click="handleContentClick"
></div>
</div> </div>
<ReactionsGroup <ReactionsGroup
:model-value="item.reactions" :model-value="item.reactions"
@@ -463,7 +467,7 @@ function minimize() {
function handleContentClick(e) { function handleContentClick(e) {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG' && !e.target.classList.contains('emoji')) {
const container = e.target.parentNode const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src) const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs lightboxImgs.value = imgs

View File

@@ -122,7 +122,8 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else class="comments-container"> <div v-else class="comments-container">
<BaseTimeline :items="timelineItems"> <BasePlaceholder v-if="timelineItems.length === 0" text="暂无评论" icon="inbox" />
<BaseTimeline v-else :items="timelineItems">
<template #item="{ item }"> <template #item="{ item }">
<CommentItem <CommentItem
v-if="item.kind === 'comment'" v-if="item.kind === 'comment'"
@@ -184,6 +185,7 @@ import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue' import CommentItem from '~/components/CommentItem.vue'
import CommentEditor from '~/components/CommentEditor.vue' import CommentEditor from '~/components/CommentEditor.vue'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import PostChangeLogItem from '~/components/PostChangeLogItem.vue' import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue' import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue' import ArticleCategory from '~/components/ArticleCategory.vue'
@@ -320,6 +322,7 @@ const mapComment = (
level = 0, level = 0,
) => ({ ) => ({
id: c.id, id: c.id,
kind: 'comment',
userName: c.author.username, userName: c.author.username,
medal: c.author.displayMedal, medal: c.author.displayMedal,
userId: c.author.id, userId: c.author.id,
@@ -374,6 +377,7 @@ const changeLogIcon = (l) => {
const mapChangeLog = (l) => ({ const mapChangeLog = (l) => ({
id: l.id, id: l.id,
kind: 'log',
username: l.username, username: l.username,
userAvatar: l.userAvatar, userAvatar: l.userAvatar,
type: l.type, type: l.type,
@@ -434,7 +438,7 @@ const removeCommentFromList = (id, list) => {
const handleContentClick = (e) => { const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG' && !e.target.classList.contains('emoji')) {
const container = e.target.parentNode const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src) const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs lightboxImgs.value = imgs
@@ -445,7 +449,7 @@ const handleContentClick = (e) => {
const onCommentDeleted = (id) => { const onCommentDeleted = (id) => {
removeCommentFromList(Number(id), comments.value) removeCommentFromList(Number(id), comments.value)
fetchComments() fetchTimeline()
} }
const { const {
@@ -557,7 +561,7 @@ const postComment = async (parentUserName, text, clear) => {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
console.debug('Post comment response data', data) console.debug('Post comment response data', data)
await fetchComments() await fetchTimeline()
clear() clear()
if (data.reward && data.reward > 0) { if (data.reward && data.reward > 0) {
toast.success(`评论成功,获得 ${data.reward} 经验值`) toast.success(`评论成功,获得 ${data.reward} 经验值`)
@@ -612,7 +616,7 @@ const approvePost = async () => {
status.value = 'PUBLISHED' status.value = 'PUBLISHED'
toast.success('已通过审核') toast.success('已通过审核')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -628,7 +632,7 @@ const pinPost = async () => {
if (res.ok) { if (res.ok) {
toast.success('已置顶') toast.success('已置顶')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -644,7 +648,7 @@ const unpinPost = async () => {
if (res.ok) { if (res.ok) {
toast.success('已取消置顶') toast.success('已取消置顶')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -660,7 +664,7 @@ const excludeRss = async () => {
if (res.ok) { if (res.ok) {
rssExcluded.value = true rssExcluded.value = true
toast.success('已标记为rss不推荐') toast.success('已标记为rss不推荐')
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -676,7 +680,8 @@ const includeRss = async () => {
if (res.ok) { if (res.ok) {
rssExcluded.value = false rssExcluded.value = false
toast.success('已标记为rss推荐') toast.success('已标记为rss推荐')
await fetchChangeLogs() await refreshPost()
await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -693,7 +698,7 @@ const closePost = async () => {
closed.value = true closed.value = true
toast.success('已关闭') toast.success('已关闭')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -710,7 +715,7 @@ const reopenPost = async () => {
closed.value = false closed.value = false
toast.success('已重新打开') toast.success('已重新打开')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -755,7 +760,7 @@ const rejectPost = async () => {
status.value = 'REJECTED' status.value = 'REJECTED'
toast.success('已驳回') toast.success('已驳回')
await refreshPost() await refreshPost()
await fetchChangeLogs() await fetchTimeline()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -787,9 +792,9 @@ const fetchCommentSorts = () => {
]) ])
} }
const fetchComments = async () => { const fetchCommentsAndChangeLog = async () => {
isFetchingComments.value = true isFetchingComments.value = true
console.debug('Fetching comments', { postId, sort: commentSort.value }) console.info('Fetching comments and chang log', { postId, sort: commentSort.value })
try { try {
const token = getToken() const token = getToken()
const res = await fetch( const res = await fetch(
@@ -798,11 +803,32 @@ const fetchComments = async () => {
headers: { Authorization: token ? `Bearer ${token}` : '' }, headers: { Authorization: token ? `Bearer ${token}` : '' },
}, },
) )
console.debug('Fetch comments response status', res.status) console.info('Fetch comments response status', res.status)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
console.debug('Fetched comments count', data.length) console.info('Fetched comments data', data)
comments.value = data.map(mapComment)
const commentList = []
const changeLogList = []
// 时间线列表,包含评论和日志
const newTimelineItemList = []
for (const item of data) {
const mappedPayload =
item.kind === 'comment' ? mapComment(item.payload) : mapChangeLog(item.payload)
newTimelineItemList.push(mappedPayload)
if (item.kind === 'comment') {
commentList.push(mappedPayload)
} else {
changeLogList.push(mappedPayload)
}
}
comments.value = commentList
changeLogs.value = changeLogList
timelineItems.value = newTimelineItemList
isFetchingComments.value = false isFetchingComments.value = false
await nextTick() await nextTick()
gatherPostItems() gatherPostItems()
@@ -814,37 +840,8 @@ const fetchComments = async () => {
} }
} }
const fetchChangeLogs = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/change-logs`)
if (res.ok) {
const data = await res.json()
changeLogs.value = data.map(mapChangeLog)
await nextTick()
gatherPostItems()
}
} catch (e) {
console.debug('Fetch change logs error', e)
}
}
//
// todo(tim): fetchComments, fetchChangeLogs 整合到一个请求,并且取消前端排序
//
const fetchTimeline = async () => { const fetchTimeline = async () => {
await Promise.all([fetchComments(), fetchChangeLogs()]) await fetchCommentsAndChangeLog()
const cs = comments.value.map((c) => ({ ...c, kind: 'comment' }))
const ls = changeLogs.value.map((l) => ({ ...l, kind: 'log' }))
if (commentSort.value === 'NEWEST') {
timelineItems.value = [...cs, ...ls].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
)
} else {
timelineItems.value = [...cs, ...ls].sort(
(a, b) => new Date(a.createdAt) - new Date(b.createdAt),
)
}
} }
watch(commentSort, async () => { watch(commentSort, async () => {

View File

@@ -29,7 +29,7 @@
<reduce-user /> <reduce-user />
取消关注 取消关注
</div> </div>
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage"> <div v-if="!isMine" class="profile-page-header-send-mail-button" @click="sendMessage">
<message-one /> <message-one />
发私信 发私信
</div> </div>
@@ -703,6 +703,26 @@ watch(selectedTab, async (val) => {
cursor: pointer; cursor: pointer;
} }
.profile-page-header-send-mail-button {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
font-size: 14px;
border-radius: 8px;
padding: 5px 10px;
color: var(--primary-color);
border: 1px solid var(--primary-color);
margin-top: 15px;
width: fit-content;
cursor: pointer;
}
.profile-page-header-unsubscribe-button:hover,
.profile-page-header-send-mail-button:hover {
background-color: var(--secondary-color-hover);
}
.profile-level-container { .profile-level-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,28 @@
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'
export const isMac = getIsMac()
function getIsMac() {
if (!isClient) {
return false
}
try {
// 优先使用现代浏览器的 navigator.userAgentData API
if (navigator.userAgentData && navigator.userAgentData.platform) {
return navigator.userAgentData.platform === 'macOS'
}
// 降级到传统的 User-Agent 检测
if (navigator.userAgent) {
return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent)
}
// 默认返回false
return false
} catch (error) {
// 异常处理,记录错误并返回默认值
console.warn('检测Mac设备时发生错误:', error)
return false
}
}

View File

@@ -3,6 +3,7 @@ import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user' import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji' import { tiebaEmoji } from './tiebaEmoji'
import vditorPostCitation from './vditorPostCitation.js' import vditorPostCitation from './vditorPostCitation.js'
import { checkFileSize, formatFileSize } from './videoCompressor.js'
export function getEditorTheme() { export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic' return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -91,7 +92,26 @@ export function createVditor(editorId, options = {}) {
multiple: false, multiple: false,
handler: async (files) => { handler: async (files) => {
const file = files[0] const file = files[0]
vditor.tip('图片上传中', 0) const ext = file.name.split('.').pop().toLowerCase()
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv']
// 检查文件大小
const sizeCheck = checkFileSize(file)
if (!sizeCheck.isValid) {
console.log(
'文件大小不能超过',
formatFileSize(sizeCheck.maxSize),
',当前文件',
formatFileSize(sizeCheck.actualSize),
)
vditor.tip(
`文件大小不能超过 ${formatFileSize(sizeCheck.maxSize)},当前文件 ${formatFileSize(sizeCheck.actualSize)}`,
3000,
)
return '文件过大'
}
vditor.tip('文件上传中', 0)
vditor.disabled() vditor.disabled()
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, `${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
@@ -110,7 +130,6 @@ export function createVditor(editorId, options = {}) {
return '上传失败' return '上传失败'
} }
const ext = file.name.split('.').pop().toLowerCase()
const imageExts = [ const imageExts = [
'apng', 'apng',
'bmp', 'bmp',
@@ -132,6 +151,8 @@ export function createVditor(editorId, options = {}) {
md = `![${file.name}](${info.fileUrl})` md = `![${file.name}](${info.fileUrl})`
} else if (audioExts.includes(ext)) { } else if (audioExts.includes(ext)) {
md = `<audio controls="controls" src="${info.fileUrl}"></audio>` md = `<audio controls="controls" src="${info.fileUrl}"></audio>`
} else if (videoExts.includes(ext)) {
md = `<video width="600" controls>\n <source src="${info.fileUrl}" type="video/${ext}">\n 你的浏览器不支持 video 标签。\n</video>`
} else { } else {
md = `[${file.name}](${info.fileUrl})` md = `[${file.name}](${info.fileUrl})`
} }

View File

@@ -0,0 +1,30 @@
/**
* 视频上传工具
*/
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
// 导出配置供外部使用
export const VIDEO_CONFIG = UPLOAD_CONFIG.VIDEO
/**
* 检查文件大小是否超出限制
*/
export function checkFileSize(file) {
return {
isValid: file.size <= VIDEO_CONFIG.MAX_SIZE,
actualSize: file.size,
maxSize: VIDEO_CONFIG.MAX_SIZE,
}
}
/**
* 格式化文件大小显示
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

114
package-lock.json generated
View File

@@ -11,9 +11,54 @@
"devDependencies": { "devDependencies": {
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.5", "lint-staged": "^16.1.5",
"prettier": "^3.6.2" "prettier": "^3.6.2",
"prettier-plugin-java": "^2.6.3"
} }
}, },
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz",
@@ -82,6 +127,34 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
}
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -242,6 +315,18 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/java-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/java-parser/-/java-parser-3.0.1.tgz",
"integrity": "sha512-sDIR7u9b7O2JViNUxiZRhnRz7URII/eE7g2B+BmGxDeS6Ex3OYAcCyz5oh0H4LQ+hL/BS8OJTz8apMy9xtGmrQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"chevrotain": "11.0.3",
"chevrotain-allstar": "0.3.1",
"lodash": "4.17.21"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -301,6 +386,20 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"dev": true,
"license": "MIT"
},
"node_modules/log-update": { "node_modules/log-update": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@@ -459,6 +558,19 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prettier-plugin-java": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/prettier-plugin-java/-/prettier-plugin-java-2.7.5.tgz",
"integrity": "sha512-LH5PKX+cjKOcjnnLXn3/cT8u7vxXxm68r5zsBPI3QQfkfyA/Sx8TTnhbwZdqwQXca431RquBG2ZtmyqmBwBKEw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"java-parser": "3.0.1"
},
"peerDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/restore-cursor": { "node_modules/restore-cursor": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",

View File

@@ -20,9 +20,11 @@
"devDependencies": { "devDependencies": {
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.5", "lint-staged": "^16.1.5",
"prettier": "^3.6.2" "prettier": "^3.6.2",
"prettier-plugin-java": "^2.6.3"
}, },
"lint-staged": { "lint-staged": {
"frontend_nuxt/**/*": "prettier --write --cache --ignore-unknown" "frontend_nuxt/**/*": "prettier --write --cache --ignore-unknown",
"backend/src/**/*.java": "prettier --write --cache --ignore-unknown"
} }
} }