Compare commits

..

48 Commits

Author SHA1 Message Date
Tim
1fd31184a7 Sort reactions by count with stable fallback order 2025-09-17 20:23:26 +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
44 changed files with 1462 additions and 3950 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

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

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

@@ -10,8 +10,3 @@ 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
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

View File

@@ -17,8 +17,3 @@ NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
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
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

View File

@@ -15,8 +15,3 @@ NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
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
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

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;
@@ -356,7 +358,7 @@ body {
} }
.d2h-file-name { .d2h-file-name {
font-size: 12px !important; font-size: 14px !important;
} }
.d2h-file-header { .d2h-file-header {
@@ -371,14 +373,14 @@ body {
padding-left: 10px !important; padding-left: 10px !important;
} }
.d2h-diff-table { /* .d2h-diff-table {
font-size: 6px !important; font-size: 6px !important;
} }
.d2h-code-line ins { .d2h-code-line ins {
height: 100%; height: 100%;
font-size: 13px !important; font-size: 13px !important;
} } */
/* .d2h-code-line { /* .d2h-code-line {
height: 12px; height: 12px;

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

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

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

@@ -107,11 +107,33 @@ 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 defaultOrder = computed(() => {
if (reactionTypes.value && reactionTypes.value.length) {
return reactionTypes.value
}
const seen = new Set()
const order = []
for (const reaction of reactions.value) {
if (!seen.has(reaction.type)) {
seen.add(reaction.type)
order.push(reaction.type)
}
}
return order
})
const displayedReactions = computed(() => { const displayedReactions = computed(() => {
const orderIndex = new Map(defaultOrder.value.map((type, index) => [type, index]))
return Object.entries(counts.value) return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1]) .map(([type, count]) => ({ type, count }))
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count
const indexA = orderIndex.has(a.type) ? orderIndex.get(a.type) : Number.MAX_SAFE_INTEGER
const indexB = orderIndex.has(b.type) ? orderIndex.get(b.type) : Number.MAX_SAFE_INTEGER
return indexA - indexB
})
.slice(0, 3) .slice(0, 3)
.map(([type]) => ({ type })) .map(({ type }) => ({ type }))
}) })
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE')) const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))

View File

@@ -1,62 +1,15 @@
/** /**
* 文件上传配置 - 简化版 * 文件上传配置
* 专注于 FFmpeg.wasm 视频压缩,支持 Chrome/Safari
*/ */
// 声明全局变量以避免 TypeScript 错误
/* global useRuntimeConfig */
// 简化的环境变量读取功能
function getEnvNumber(key, defaultValue) {
if (typeof window !== 'undefined') {
// 客户端:尝试从 Nuxt 环境获取
try {
// 使用 globalThis 避免直接引用未定义的变量
const nuxtApp = globalThis.$nuxt || globalThis.nuxtApp
if (nuxtApp && nuxtApp.$config) {
const value = nuxtApp.$config.public?.[key.replace('NUXT_PUBLIC_', '').toLowerCase()]
return value ? Number(value) : defaultValue
}
return defaultValue
} catch {
return defaultValue
}
}
// 服务端:从 process.env 获取
return process.env[key] ? Number(process.env[key]) : defaultValue
}
function getEnvBoolean(key, defaultValue) {
if (typeof window !== 'undefined') {
try {
// 使用 globalThis 避免直接引用未定义的变量
const nuxtApp = globalThis.$nuxt || globalThis.nuxtApp
if (nuxtApp && nuxtApp.$config) {
const value = nuxtApp.$config.public?.[key.replace('NUXT_PUBLIC_', '').toLowerCase()]
return value === 'true' || value === true
}
return defaultValue
} catch {
return defaultValue
}
}
const envValue = process.env[key]
return envValue ? envValue === 'true' : defaultValue
}
export const UPLOAD_CONFIG = { export const UPLOAD_CONFIG = {
// 视频文件配置 - 专为 FFmpeg.wasm 优化 // 视频文件配置
VIDEO: { VIDEO: {
// 文件大小限制 (字节) // 文件大小限制 (字节)
MAX_SIZE: getEnvNumber('NUXT_PUBLIC_VIDEO_MAX_SIZE', 20 * 1024 * 1024), // 5MB MAX_SIZE: 20 * 1024 * 1024,
TARGET_SIZE: getEnvNumber('NUXT_PUBLIC_VIDEO_TARGET_SIZE', 5 * 1024 * 1024), // 5MB
// 支持的输入格式 (FFmpeg.wasm 支持更多格式) // 支持的输入格式
SUPPORTED_FORMATS: ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv'], SUPPORTED_FORMATS: ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv'],
// 输出格式 - MP4 (兼容性最好)
OUTPUT_FORMAT: 'mp4',
OUTPUT_CODEC: 'h264',
}, },
// 图片文件配置 // 图片文件配置

View File

@@ -1,11 +1,4 @@
import { defineNuxtConfig } from 'nuxt/config' import { defineNuxtConfig } from 'nuxt/config'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const appPkg = require('./package.json') as {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
export default defineNuxtConfig({ export default defineNuxtConfig({
devServer: { devServer: {
@@ -104,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

@@ -13,8 +13,6 @@
}, },
"dependencies": { "dependencies": {
"@icon-park/vue-next": "^1.4.2", "@icon-park/vue-next": "^1.4.2",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@nuxt/image": "^1.11.0", "@nuxt/image": "^1.11.0",
"@stomp/stompjs": "^7.0.0", "@stomp/stompjs": "^7.0.0",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",

View File

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

@@ -320,6 +320,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 +375,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 +436,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 +447,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 +559,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 +614,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 +630,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 +646,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 +662,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 +678,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 +696,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 +713,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 +758,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 +790,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 +801,34 @@ 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

@@ -1,24 +0,0 @@
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'
import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
let ffmpeg: FFmpeg | null = null
export default defineNuxtPlugin(() => {
return {
provide: {
ffmpeg: async () => {
if (ffmpeg) return ffmpeg
ffmpeg = new FFmpeg()
const base = '~/assets/js/ffmpeg/core-mt/dist/umd'
await ffmpeg.load({
coreURL: await toBlobURL(`${base}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${base}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: await toBlobURL(`${base}/ffmpeg-core.worker.js`, 'text/javascript'),
})
return ffmpeg
},
},
}
})

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

@@ -1,327 +0,0 @@
/**
* FFmpeg.wasm 视频压缩器
*
* 用法:
* const { $ffmpeg } = useNuxtApp()
* const ff = await $ffmpeg() // 插件里已完成 ffmpeg.load()
* const out = await compressVideoWithFFmpeg(ff, file, { onProgress, strictSize: false })
*
* 设计要点:
* - 本文件不再负责加载/初始化,只负责转码逻辑;和 Nuxt 插件解耦。
* - 针对【同一个 ffmpeg 实例】做串行队列,避免并发 exec 踩内存文件系统。
* - 使用 nanoid 生成唯一文件名;日志环形缓冲;默认 CRF+VBV可选 strictSizetwo-pass
* - 体积明显小于目标时直通返回,减少无谓重编码。
*/
import { fetchFile } from '@ffmpeg/util'
import { nanoid } from 'nanoid'
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
/*************************
* 每实例一个串行队列 *
*************************/
// WeakMap<FFmpeg, { q: (()=>Promise<any>)[], running: boolean, resolvers: {res,rej}[] }>
const queues = new WeakMap()
function enqueueOn(instance, taskFn) {
return new Promise((res, rej) => {
let st = queues.get(instance)
if (!st) {
st = { q: [], running: false, resolvers: [] }
queues.set(instance, st)
}
st.q.push(taskFn)
st.resolvers.push({ res, rej })
drain(instance)
})
}
async function drain(instance) {
const st = queues.get(instance)
if (!st || st.running) return
st.running = true
try {
while (st.q.length) {
const task = st.q.shift()
const rr = st.resolvers.shift()
try {
rr.res(await task())
} catch (e) {
rr.rej(e)
}
}
} finally {
st.running = false
}
}
/*****************
* 工具函数 *
*****************/
function decideScale(widthHint) {
if (!widthHint) return { filter: null, width: null }
const evenW = widthHint % 2 === 0 ? widthHint : widthHint - 1
return { filter: `scale=${evenW}:-2:flags=bicubic,setsar=1`, width: evenW }
}
function calculateParamsByRatio(originalSize, targetSize) {
const ratio = Math.min(targetSize / originalSize, 1)
const crf = ratio < 0.35 ? 29 : ratio < 0.5 ? 27 : ratio < 0.7 ? 25 : 23
const preset = ratio < 0.35 ? 'slow' : ratio < 0.5 ? 'medium' : 'veryfast'
const s =
ratio < 0.35
? decideScale(720)
: ratio < 0.6
? decideScale(960)
: ratio < 0.8
? decideScale(1280)
: { filter: null, width: null }
const audioBitrateK = ratio < 0.5 ? 96 : ratio < 0.7 ? 128 : 160
const profile = s.width && s.width <= 1280 ? 'main' : 'high'
return { crf, preset, scaleFilter: s.filter, scaleWidth: s.width, audioBitrateK, profile }
}
function makeRingLogger(capBytes = 4000) {
const buf = []
let total = 0
function push(s) {
if (!s) return
buf.push(s)
total += s.length
while (total > capBytes) total -= buf.shift().length
}
return { push, dump: () => buf.slice() }
}
function parseDurationFromLogs(logs) {
// 避免正则:查找 Duration: 后的 00:00:00.xx
const text = logs.join(' ')
const idx = text.indexOf('Duration:')
if (idx === -1) return null
let i = idx + 'Duration:'.length
while (i < text.length && text[i] === ' ') i++
function read2(start) {
const a = text.charCodeAt(start) - 48
const b = text.charCodeAt(start + 1) - 48
if (a < 0 || a > 9 || b < 0 || b > 9) return null
return a * 10 + b
}
const hh = read2(i)
if (hh === null) return null
i += 2
if (text[i++] !== ':') return null
const mm = read2(i)
if (mm === null) return null
i += 2
if (text[i++] !== ':') return null
const s1 = read2(i)
if (s1 === null) return null
i += 2
if (text[i++] !== '.') return null
let j = i
while (j < text.length && text.charCodeAt(j) >= 48 && text.charCodeAt(j) <= 57) j++
const frac = parseFloat('0.' + text.slice(i, j) || '0')
return hh * 3600 + mm * 60 + s1 + frac
}
export function isFFmpegSupported() {
return typeof WebAssembly !== 'undefined' && typeof Worker !== 'undefined'
}
/**
* 读取 ffmpeg 核心版本(通过 -version会进入队列避免并发冲突
*/
export async function getFFmpegInfo(ffmpegInstance) {
return enqueueOn(ffmpegInstance, async () => {
const logs = []
const onLog = ({ type, message }) => {
if (type === 'info' || type === 'fferr') logs.push(message)
}
ffmpegInstance.on('log', onLog)
try {
await ffmpegInstance.exec(['-version'])
} finally {
ffmpegInstance.off('log', onLog)
}
const line = logs.find((l) => l.toLowerCase().includes('ffmpeg version')) || ''
const parts = line.trim().split(' ').filter(Boolean)
const version = parts.length > 2 ? parts[2] : parts[1] || null
return { version }
})
}
/**
* 压缩:接受一个已经 load() 完成的 ffmpeg 实例
* @param {*} ffmpegInstance 已初始化的 FFmpeg 实例(来自 Nuxt 插件)
* @param {File|Blob} file 输入文件
* @param {{ onProgress?:(p:{stage:string,progress:number})=>void, signal?:AbortSignal, strictSize?:boolean, targetSize?:number }} opts
*/
export async function compressVideoWithFFmpeg(ffmpegInstance, file, opts = {}) {
return enqueueOn(ffmpegInstance, () => doCompress(ffmpegInstance, file, opts))
}
async function doCompress(ffmpegInstance, file, opts) {
const onProgress = opts.onProgress || (() => {})
const { signal, strictSize = false } = opts
onProgress({ stage: 'preparing', progress: 10 })
const targetSize = opts.targetSize ?? UPLOAD_CONFIG?.VIDEO?.TARGET_SIZE ?? 12 * 1024 * 1024
// 小体积直通
const sizeKnown = 'size' in file && typeof file.size === 'number'
if (sizeKnown && file.size <= targetSize * 0.9) {
onProgress({ stage: 'skipped', progress: 100 })
return file
}
const params = calculateParamsByRatio(sizeKnown ? file.size : targetSize * 2, targetSize)
const { crf, preset, scaleFilter, audioBitrateK, profile } = params
const name = 'name' in file && typeof file.name === 'string' ? file.name : 'input.mp4'
const dot = name.lastIndexOf('.')
const outName = (dot > -1 ? name.slice(0, dot) : name) + '.mp4'
const ext = dot > -1 ? name.slice(dot + 1).toLowerCase() : 'mp4'
const id = nanoid()
const inputName = `input-${id}.${ext}`
const outputName = `output-${id}.mp4`
const passlog = `ffpass-${id}`
// 监听
const ring = makeRingLogger()
const onFfmpegProgress = ({ progress: p }) => {
const adjusted = 20 + p * 70
onProgress({ stage: 'compressing', progress: Math.min(90, adjusted) })
}
const onFfmpegLog = ({ type, message }) => {
if (type === 'fferr' || type === 'info') ring.push(message)
}
ffmpegInstance.on('progress', onFfmpegProgress)
ffmpegInstance.on('log', onFfmpegLog)
let aborted = false
const abortHandler = () => {
aborted = true
}
if (signal) signal.addEventListener('abort', abortHandler, { once: true })
try {
await ffmpegInstance.writeFile(inputName, await fetchFile(file))
onProgress({ stage: 'analyzing', progress: 20 })
let durationSec = null
try {
await ffmpegInstance.exec(['-hide_banner', '-i', inputName, '-f', 'null', '-'])
durationSec = parseDurationFromLogs(ring.dump())
} catch {
durationSec = durationSec ?? parseDurationFromLogs(ring.dump())
}
let videoBitrate = null
if (durationSec && sizeKnown && targetSize < file.size) {
const totalTargetBits = targetSize * 8
const audioBits = audioBitrateK * 1000 * durationSec
const maxVideoBits = Math.max(totalTargetBits - audioBits, totalTargetBits * 0.7)
const bps = Math.max(180000, Math.floor(maxVideoBits / durationSec))
videoBitrate = String(Math.min(bps, 5000000))
}
const baseArgs = [
'-hide_banner',
'-i',
inputName,
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-profile:v',
profile,
'-movflags',
'+faststart',
'-preset',
preset,
'-c:a',
'aac',
'-b:a',
`${audioBitrateK}k`,
'-ac',
'2',
]
if (scaleFilter) baseArgs.push('-vf', scaleFilter)
const onePassArgs = [...baseArgs, '-crf', String(crf)]
if (videoBitrate)
onePassArgs.push('-maxrate', videoBitrate, '-bufsize', String(parseInt(videoBitrate, 10) * 2))
const twoPassFirst = [
'-y',
'-hide_banner',
'-i',
inputName,
'-c:v',
'libx264',
'-b:v',
`${videoBitrate || '1000000'}`,
'-pass',
'1',
'-passlogfile',
passlog,
'-an',
'-f',
'mp4',
'/dev/null',
]
const twoPassSecond = [
...baseArgs,
'-b:v',
`${videoBitrate || '1000000'}`,
'-pass',
'2',
'-passlogfile',
passlog,
outputName,
]
if (aborted) throw new DOMException('Aborted', 'AbortError')
if (!strictSize) {
await ffmpegInstance.exec([...onePassArgs, outputName])
} else {
if (!videoBitrate) videoBitrate = '1000000'
await ffmpegInstance.exec(twoPassFirst)
onProgress({ stage: 'second-pass', progress: 85 })
await ffmpegInstance.exec(twoPassSecond)
}
if (aborted) throw new DOMException('Aborted', 'AbortError')
onProgress({ stage: 'finalizing', progress: 95 })
const out = await ffmpegInstance.readFile(outputName)
const mime = 'video/mp4'
const blob = new Blob([out], { type: mime })
const hasFileCtor = typeof File === 'function'
const result = hasFileCtor ? new File([blob], outName, { type: mime }) : blob
onProgress({ stage: 'completed', progress: 100 })
return result
} finally {
try {
await ffmpegInstance.deleteFile(inputName)
} catch {}
try {
await ffmpegInstance.deleteFile(outputName)
} catch {}
try {
await ffmpegInstance.deleteFile(`${passlog}-0.log`)
} catch {}
try {
await ffmpegInstance.deleteFile(`${passlog}-0.log.mbtree`)
} catch {}
ffmpegInstance.off('progress', onFfmpegProgress)
ffmpegInstance.off('log', onFfmpegLog)
if (signal) signal.removeEventListener('abort', abortHandler)
}
}

View File

@@ -3,8 +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, compressVideo, VIDEO_CONFIG } from './videoCompressor.js' import { checkFileSize, formatFileSize } from './videoCompressor.js'
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
export function getEditorTheme() { export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic' return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -95,7 +94,6 @@ export function createVditor(editorId, options = {}) {
const file = files[0] const file = files[0]
const ext = file.name.split('.').pop().toLowerCase() const ext = file.name.split('.').pop().toLowerCase()
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv'] const videoExts = ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv']
const isVideo = videoExts.includes(ext)
// 检查文件大小 // 检查文件大小
const sizeCheck = checkFileSize(file) const sizeCheck = checkFileSize(file)
@@ -113,61 +111,10 @@ export function createVditor(editorId, options = {}) {
return '文件过大' return '文件过大'
} }
let processedFile = file
// 如果是视频文件且需要压缩
if (isVideo && sizeCheck.needsCompression) {
try {
vditor.tip('视频压缩中...', 0)
vditor.disabled()
// 使用 FFmpeg 压缩视频
processedFile = await compressVideo(file, (progress) => {
const messages = {
initializing: '初始化 FFmpeg',
preparing: '准备压缩',
analyzing: '分析视频',
compressing: '压缩中',
finalizing: '完成压缩',
completed: '压缩完成',
}
const message = messages[progress.stage] || progress.stage
vditor.tip(`${message} ${progress.progress}%`, 0)
})
const originalSize = formatFileSize(file.size)
const compressedSize = formatFileSize(processedFile.size)
const savings = Math.round((1 - processedFile.size / file.size) * 100)
vditor.tip(`压缩完成!${originalSize}${compressedSize} (节省 ${savings}%)`, 2000)
// 压缩成功但仍然超过最大限制,则阻止上传
if (processedFile.size > VIDEO_CONFIG.MAX_SIZE) {
vditor.tip(
`压缩后仍超过限制 (${formatFileSize(VIDEO_CONFIG.MAX_SIZE)}). 请降低分辨率或码率后再上传。`,
4000,
)
vditor.enable()
return '压缩后仍超过大小限制'
}
} catch (error) {
// 压缩失败时,如果原文件超过最大限制,则阻止上传
if (file.size > VIDEO_CONFIG.MAX_SIZE) {
vditor.tip(
`视频压缩失败,且文件超过限制 (${formatFileSize(VIDEO_CONFIG.MAX_SIZE)}). 请先压缩后再上传。`,
4000,
)
vditor.enable()
return '视频压缩失败且文件过大'
}
vditor.tip('视频压缩失败,将尝试上传原文件', 3000)
processedFile = file
}
}
vditor.tip('文件上传中', 0) vditor.tip('文件上传中', 0)
vditor.disabled() vditor.disabled()
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(processedFile.name)}`, `${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
{ headers: { Authorization: `Bearer ${getToken()}` } }, { headers: { Authorization: `Bearer ${getToken()}` } },
) )
if (!res.ok) { if (!res.ok) {
@@ -176,7 +123,7 @@ export function createVditor(editorId, options = {}) {
return '获取上传地址失败' return '获取上传地址失败'
} }
const info = await res.json() const info = await res.json()
const put = await fetch(info.uploadUrl, { method: 'PUT', body: processedFile }) const put = await fetch(info.uploadUrl, { method: 'PUT', body: file })
if (!put.ok) { if (!put.ok) {
vditor.enable() vditor.enable()
vditor.tip('上传失败') vditor.tip('上传失败')

View File

@@ -1,11 +1,8 @@
/** /**
* 基于 FFmpeg.wasm 的视频压缩工具 * 视频上传工具
* 专为现代浏览器 (Chrome/Safari) 优化
*/ */
import { UPLOAD_CONFIG } from '../config/uploadConfig.js' import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
import { compressVideoWithFFmpeg, isFFmpegSupported } from './ffmpegVideoCompressor.js'
import { useNuxtApp } from '#app'
// 导出配置供外部使用 // 导出配置供外部使用
export const VIDEO_CONFIG = UPLOAD_CONFIG.VIDEO export const VIDEO_CONFIG = UPLOAD_CONFIG.VIDEO
@@ -18,7 +15,6 @@ export function checkFileSize(file) {
isValid: file.size <= VIDEO_CONFIG.MAX_SIZE, isValid: file.size <= VIDEO_CONFIG.MAX_SIZE,
actualSize: file.size, actualSize: file.size,
maxSize: VIDEO_CONFIG.MAX_SIZE, maxSize: VIDEO_CONFIG.MAX_SIZE,
needsCompression: file.size > VIDEO_CONFIG.TARGET_SIZE,
} }
} }
@@ -32,48 +28,3 @@ export function formatFileSize(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
} }
/**
* 压缩视频文件 - 使用 FFmpeg.wasm
*/
export async function compressVideo(file, onProgress = () => {}) {
// 检查是否需要压缩
const sizeCheck = checkFileSize(file)
if (!sizeCheck.needsCompression) {
onProgress({ stage: 'completed', progress: 100 })
return file
}
// 检查 FFmpeg 支持
if (!isFFmpegSupported()) {
throw new Error('当前浏览器不支持视频压缩功能,请使用 Chrome 或 Safari 浏览器')
}
try {
const { $ffmpeg } = useNuxtApp()
const ff = await $ffmpeg()
return await compressVideoWithFFmpeg(ff, file, { onProgress })
} catch (error) {
console.error('FFmpeg 压缩失败:', error)
throw new Error(`视频压缩失败: ${error.message}`)
}
}
/**
* 预加载 FFmpeg可选的性能优化
*/
export async function preloadVideoCompressor() {
try {
// FFmpeg 初始化现在通过 Nuxt 插件处理
// 这里只需要检查支持性
if (!isFFmpegSupported()) {
throw new Error('当前浏览器不支持 FFmpeg')
}
const { $ffmpeg } = useNuxtApp()
await $ffmpeg()
return { success: true, message: 'FFmpeg 预加载成功' }
} catch (error) {
console.warn('FFmpeg 预加载失败:', error)
return { success: false, error: error.message }
}
}