Compare commits

...

15 Commits

Author SHA1 Message Date
Tim
17929dd95d test: add PostChangeLogService to PostService tests 2025-09-08 15:42:08 +08:00
Tim
f478b55538 Merge pull request #924 from nagisa77/codex/add-article-metadata-change-logging
Track post metadata changes and display in timeline
2025-09-08 15:35:44 +08:00
Tim
c58c14f9b7 feat: 设置system的icon+role 2025-09-08 15:35:09 +08:00
Tim
990d7cfbf9 fix: 投票结果UI 2025-09-08 15:32:57 +08:00
Tim
eb860a74af Merge pull request #934 from nagisa77/codex/add-system-user-for-vote-and-lottery-results
Create system user for internal logging
2025-09-08 15:21:30 +08:00
Tim
b3d050b42e Add system user and log attribution 2025-09-08 15:19:17 +08:00
Tim
db678a95c6 Merge pull request #933 from nagisa77/codex/call-recordlotteryresult-and-recordvoteresult
feat: log poll and lottery results
2025-09-08 15:00:30 +08:00
Tim
6d66cb48dc feat: log poll and lottery results 2025-09-08 15:00:15 +08:00
Tim
1fe2994743 fix: 适配分类/tags ui 2025-09-08 14:56:44 +08:00
Tim
126b10ce45 Merge pull request #932 from nagisa77/codex/update-changelog-to-return-dto-format-rnzqgd
Expose category and tag changes as DTOs
2025-09-08 14:46:09 +08:00
Tim
3b1843b6dd Return category and tag change logs as DTOs 2025-09-08 14:45:47 +08:00
Tim
6a5d00f086 Revert "Return structured category and tag data in change logs"
This reverts commit fe167aa0b9.
2025-09-08 14:44:08 +08:00
Tim
06368a6cf1 Merge pull request #931 from nagisa77/codex/add-dark-mode-support-for-diff2html
feat: enable dark mode for diff2html
2025-09-08 14:29:01 +08:00
Tim
e9f25d3b1a Merge pull request #930 from nagisa77/codex/update-changelog-to-return-dto-format
Return structured category and tag data in change logs
2025-09-08 14:27:36 +08:00
Tim
fe167aa0b9 Return structured category and tag data in change logs 2025-09-08 14:27:18 +08:00
10 changed files with 149 additions and 16 deletions

View File

@@ -0,0 +1,36 @@
package com.openisle.config;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Ensure a dedicated "system" user exists for internal operations.
*/
@Component
@RequiredArgsConstructor
public class SystemUserInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void run(String... args) {
userRepository.findByUsername("system").orElseGet(() -> {
User system = new User();
system.setUsername("system");
system.setEmail("system@openisle.local");
// todo(tim): raw password 采用环境变量
system.setPassword(passwordEncoder.encode("system"));
system.setRole(Role.USER);
system.setVerified(true);
system.setApproved(true);
system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png");
return userRepository.save(system);
});
}
}

View File

@@ -5,6 +5,7 @@ import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Getter @Getter
@Setter @Setter
@@ -18,10 +19,10 @@ public class PostChangeLogDto {
private String newTitle; private String newTitle;
private String oldContent; private String oldContent;
private String newContent; private String newContent;
private String oldCategory; private CategoryDto oldCategory;
private String newCategory; private CategoryDto newCategory;
private String oldTags; private List<TagDto> oldTags;
private String newTags; private List<TagDto> newTags;
private Boolean oldClosed; private Boolean oldClosed;
private Boolean newClosed; private Boolean newClosed;
private LocalDateTime oldPinnedAt; private LocalDateTime oldPinnedAt;

View File

@@ -1,11 +1,28 @@
package com.openisle.mapper; package com.openisle.mapper;
import com.openisle.dto.CategoryDto;
import com.openisle.dto.PostChangeLogDto; import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TagDto;
import com.openisle.model.*; import com.openisle.model.*;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Component @Component
@RequiredArgsConstructor
public class PostChangeLogMapper { public class PostChangeLogMapper {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
public PostChangeLogDto toDto(PostChangeLog log) { public PostChangeLogDto toDto(PostChangeLog log) {
PostChangeLogDto dto = new PostChangeLogDto(); PostChangeLogDto dto = new PostChangeLogDto();
dto.setId(log.getId()); dto.setId(log.getId());
@@ -22,11 +39,11 @@ public class PostChangeLogMapper {
dto.setOldContent(c.getOldContent()); dto.setOldContent(c.getOldContent());
dto.setNewContent(c.getNewContent()); dto.setNewContent(c.getNewContent());
} else if (log instanceof PostCategoryChangeLog cat) { } else if (log instanceof PostCategoryChangeLog cat) {
dto.setOldCategory(cat.getOldCategory()); dto.setOldCategory(mapCategory(cat.getOldCategory()));
dto.setNewCategory(cat.getNewCategory()); dto.setNewCategory(mapCategory(cat.getNewCategory()));
} else if (log instanceof PostTagChangeLog tag) { } else if (log instanceof PostTagChangeLog tag) {
dto.setOldTags(tag.getOldTags()); dto.setOldTags(mapTags(tag.getOldTags()));
dto.setNewTags(tag.getNewTags()); dto.setNewTags(mapTags(tag.getNewTags()));
} else if (log instanceof PostClosedChangeLog cl) { } else if (log instanceof PostClosedChangeLog cl) {
dto.setOldClosed(cl.isOldClosed()); dto.setOldClosed(cl.isOldClosed());
dto.setNewClosed(cl.isNewClosed()); dto.setNewClosed(cl.isNewClosed());
@@ -39,4 +56,37 @@ public class PostChangeLogMapper {
} }
return dto; return dto;
} }
private CategoryDto mapCategory(String name) {
if (name == null) {
return null;
}
return categoryRepository.findByName(name)
.map(categoryMapper::toDto)
.orElseGet(() -> {
CategoryDto dto = new CategoryDto();
dto.setName(name);
return dto;
});
}
private List<TagDto> mapTags(String tags) {
if (tags == null || tags.isBlank()) {
return Collections.emptyList();
}
return Arrays.stream(tags.split(","))
.map(String::trim)
.map(this::mapTag)
.collect(Collectors.toList());
}
private TagDto mapTag(String name) {
return tagRepository.findByName(name)
.map(tagMapper::toDto)
.orElseGet(() -> {
TagDto dto = new TagDto();
dto.setName(name);
return dto;
});
}
} }

View File

@@ -4,7 +4,10 @@ import com.openisle.model.Category;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> { public interface CategoryRepository extends JpaRepository<Category, Long> {
List<Category> findByNameContainingIgnoreCase(String keyword); List<Category> findByNameContainingIgnoreCase(String keyword);
Optional<Category> findByName(String name);
} }

View File

@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface TagRepository extends JpaRepository<Tag, Long> { public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByNameContainingIgnoreCase(String keyword); List<Tag> findByNameContainingIgnoreCase(String keyword);
@@ -15,4 +16,6 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable); List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator); List<Tag> findByCreator(User creator);
Optional<Tag> findByName(String name);
} }

View File

@@ -3,6 +3,7 @@ package com.openisle.service;
import com.openisle.model.*; import com.openisle.model.*;
import com.openisle.repository.PostChangeLogRepository; import com.openisle.repository.PostChangeLogRepository;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,6 +16,12 @@ import java.util.stream.Collectors;
public class PostChangeLogService { public class PostChangeLogService {
private final PostChangeLogRepository logRepository; private final PostChangeLogRepository logRepository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final UserRepository userRepository;
private User getSystemUser() {
return userRepository.findByUsername("system")
.orElseThrow(() -> new IllegalStateException("System user not found"));
}
public void recordContentChange(Post post, User user, String oldContent, String newContent) { public void recordContentChange(Post post, User user, String oldContent, String newContent) {
PostContentChangeLog log = new PostContentChangeLog(); PostContentChangeLog log = new PostContentChangeLog();
@@ -89,6 +96,7 @@ public class PostChangeLogService {
public void recordVoteResult(Post post) { public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog(); PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post); log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT); log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log); logRepository.save(log);
} }
@@ -96,6 +104,7 @@ public class PostChangeLogService {
public void recordLotteryResult(Post post) { public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog(); PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post); log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT); log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log); logRepository.save(log);
} }

View File

@@ -368,6 +368,7 @@ public class PostService {
for (User participant : pp.getParticipants()) { for (User participant : pp.getParticipants()) {
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null); notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
} }
postChangeLogService.recordVoteResult(pp);
}); });
} }
@@ -402,6 +403,7 @@ public class PostService {
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null); notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId())); notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
} }
postChangeLogService.recordLotteryResult(lp);
}); });
} }

View File

@@ -37,11 +37,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.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, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -86,11 +87,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.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, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -141,11 +143,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.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, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -177,11 +180,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.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, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -11,8 +11,18 @@
<span v-if="log.username" class="change-log-user">{{ log.username }}</span> <span v-if="log.username" class="change-log-user">{{ log.username }}</span>
<span v-if="log.type === 'CONTENT'" class="change-log-content">变更了文章内容</span> <span v-if="log.type === 'CONTENT'" class="change-log-content">变更了文章内容</span>
<span v-else-if="log.type === 'TITLE'" class="change-log-content">变更了文章标题</span> <span v-else-if="log.type === 'TITLE'" class="change-log-content">变更了文章标题</span>
<span v-else-if="log.type === 'CATEGORY'" class="change-log-content">变更了文章分类</span> <span v-else-if="log.type === 'CATEGORY'" class="change-log-content change-log-category">
<span v-else-if="log.type === 'TAG'" class="change-log-content">变更了文章标签</span> <div class="change-log-category-text">变更了文章分类, </div>
<ArticleCategory :category="log.oldCategory" />
<div class="change-log-category-text">修改为</div>
<ArticleCategory :category="log.newCategory" />
</span>
<span v-else-if="log.type === 'TAG'" class="change-log-content change-log-category">
<div class="change-log-category-text">变更了文章标签, </div>
<ArticleTags :tags="log.oldTags" />
<div class="change-log-category-text">修改为</div>
<ArticleTags :tags="log.newTags" />
</span>
<span v-else-if="log.type === 'CLOSED'" class="change-log-content"> <span v-else-if="log.type === 'CLOSED'" class="change-log-content">
<template v-if="log.newClosed">关闭了文章</template> <template v-if="log.newClosed">关闭了文章</template>
<template v-else>重新打开了文章</template> <template v-else>重新打开了文章</template>
@@ -25,8 +35,12 @@
<template v-if="log.newFeatured">将文章设为精选</template> <template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template> <template v-else>取消精选文章</template>
</span> </span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content">投票已出结果</span> <span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content">抽奖已开奖</span> >系统已计算投票结果</span
>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
</div> </div>
<div class="change-log-time">{{ log.time }}</div> <div class="change-log-time">{{ log.time }}</div>
<div <div
@@ -46,6 +60,8 @@ import 'diff2html/bundles/css/diff2html.min.css'
import BaseImage from '~/components/BaseImage.vue' import BaseImage from '~/components/BaseImage.vue'
import { navigateTo } from 'nuxt/app' import { navigateTo } from 'nuxt/app'
import { themeState } from '~/utils/theme' import { themeState } from '~/utils/theme'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
const props = defineProps({ const props = defineProps({
log: Object, log: Object,
title: String, title: String,
@@ -124,4 +140,11 @@ const diffHtml = computed(() => {
.content-diff { .content-diff {
margin-top: 8px; margin-top: 8px;
} }
.change-log-category {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
</style> </style>

View File

@@ -76,6 +76,7 @@ import {
DoubleDown, DoubleDown,
Open, Open,
Dislike, Dislike,
CheckOne,
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
@@ -155,4 +156,5 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('DoubleDown', DoubleDown) nuxtApp.vueApp.component('DoubleDown', DoubleDown)
nuxtApp.vueApp.component('OpenIcon', Open) nuxtApp.vueApp.component('OpenIcon', Open)
nuxtApp.vueApp.component('Dislike', Dislike) nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne)
}) })