Compare commits

...

27 Commits

Author SHA1 Message Date
tim
05522fcdc7 fix: 修改分割线颜色 2025-09-09 00:32:17 +08:00
tim
3820eaa774 fix: changlog--移动端支持换行 #938 2025-09-09 00:23:53 +08:00
Tim
7effaf920a style: adjust diff2html fonts on mobile 2025-09-08 23:48:32 +08:00
Tim
e40a6a3ca9 Merge pull request #935 from smallclover/main
redis功能-注册找回密码
2025-09-08 17:14:04 +08:00
Tim
7c9475cfe2 Merge pull request #936 from nagisa77/codex/fix-compilation-issues-in-postservicetest
test: add PostChangeLogService to PostService tests
2025-09-08 15:42:20 +08:00
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
wangshun
43fa408f46 redis功能-注册找回密码
+ 注册功能,验证码使用缓存,五分钟过期
+ 重置密码,验证码使用缓存,五分钟过期
2025-09-08 15:23:52 +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
c38e4bc44c feat: enable dark mode for diff2html 2025-09-08 14:28:42 +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
Tim
f3421265d2 fix: 修改changelog UI 2025-09-08 14:02:47 +08:00
Tim
f4817cd6d1 Merge pull request #929 from nagisa77/codex/add-user-avatar-return-in-changelog
feat: expand post change log details
2025-09-08 13:54:51 +08:00
Tim
5ae0f9311c feat: add result change log entities 2025-09-08 13:54:35 +08:00
Tim
567452f570 feat: 标题/内容变化的ui 2025-09-08 13:46:22 +08:00
Tim
bb4e866bd0 Merge pull request #928 from nagisa77/codex/add-content-change-details-rendering
feat(frontend): render diff for content changes
2025-09-08 13:22:44 +08:00
21 changed files with 424 additions and 85 deletions

View File

@@ -40,6 +40,8 @@ public class CachingConfig {
public static final String CATEGORY_CACHE_NAME="openisle_categories";
// 在线人数缓存名
public static final String ONLINE_CACHE_NAME="openisle_online";
// 注册验证码
public static final String VERIFY_CACHE_NAME="openisle_verify";
/**
* 自定义Redis的序列化器

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

@@ -1,18 +1,22 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.*;
import com.openisle.exception.FieldException;
import com.openisle.model.RegisterMode;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.*;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/auth")
@@ -56,7 +60,8 @@ public class AuthController {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -70,7 +75,8 @@ public class AuthController {
}
User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
if (!user.isApproved()) {
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
}
@@ -79,13 +85,12 @@ public class AuthController {
@PostMapping("/verify")
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER);
if (ok) {
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
User user = userOpt.get();
if (user.isApproved()) {
@@ -122,7 +127,7 @@ public class AuthController {
User user = userOpt.get();
if (!user.isVerified()) {
user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.badRequest().body(Map.of(
"error", "User not verified",
"reason_code", "NOT_VERIFIED",
@@ -417,14 +422,17 @@ public class AuthController {
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
String code = userService.generatePasswordResetCode(req.getEmail());
emailService.sendEmail(req.getEmail(), "请填写验证码以重置密码", "您的验证码是" + code);
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
}
@PostMapping("/forgot/verify")
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
Optional<User> userOpt = userService.findByEmail(req.getEmail());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD);
if (ok) {
String username = userService.findByEmail(req.getEmail()).get().getUsername();
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));

View File

@@ -5,22 +5,24 @@ import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
public class PostChangeLogDto {
private Long id;
private String username;
private String userAvatar;
private PostChangeType type;
private LocalDateTime time;
private String oldTitle;
private String newTitle;
private String oldContent;
private String newContent;
private String oldCategory;
private String newCategory;
private String oldTags;
private String newTags;
private CategoryDto oldCategory;
private CategoryDto newCategory;
private List<TagDto> oldTags;
private List<TagDto> newTags;
private Boolean oldClosed;
private Boolean newClosed;
private LocalDateTime oldPinnedAt;

View File

@@ -1,15 +1,35 @@
package com.openisle.mapper;
import com.openisle.dto.CategoryDto;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TagDto;
import com.openisle.model.*;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class PostChangeLogMapper {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
public PostChangeLogDto toDto(PostChangeLog log) {
PostChangeLogDto dto = new PostChangeLogDto();
dto.setId(log.getId());
dto.setUsername(log.getUser().getUsername());
if (log.getUser() != null) {
dto.setUsername(log.getUser().getUsername());
dto.setUserAvatar(log.getUser().getAvatar());
}
dto.setType(log.getType());
dto.setTime(log.getCreatedAt());
if (log instanceof PostTitleChangeLog t) {
@@ -19,11 +39,11 @@ public class PostChangeLogMapper {
dto.setOldContent(c.getOldContent());
dto.setNewContent(c.getNewContent());
} else if (log instanceof PostCategoryChangeLog cat) {
dto.setOldCategory(cat.getOldCategory());
dto.setNewCategory(cat.getNewCategory());
dto.setOldCategory(mapCategory(cat.getOldCategory()));
dto.setNewCategory(mapCategory(cat.getNewCategory()));
} else if (log instanceof PostTagChangeLog tag) {
dto.setOldTags(tag.getOldTags());
dto.setNewTags(tag.getNewTags());
dto.setOldTags(mapTags(tag.getOldTags()));
dto.setNewTags(mapTags(tag.getNewTags()));
} else if (log instanceof PostClosedChangeLog cl) {
dto.setOldClosed(cl.isOldClosed());
dto.setNewClosed(cl.isNewClosed());
@@ -36,4 +56,37 @@ public class PostChangeLogMapper {
}
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

@@ -23,7 +23,7 @@ public abstract class PostChangeLog {
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "user_id")
private User user;

View File

@@ -7,5 +7,7 @@ public enum PostChangeType {
TAG,
CLOSED,
PINNED,
FEATURED
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT
}

View File

@@ -0,0 +1,16 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_lottery_result_change_logs")
public class PostLotteryResultChangeLog extends PostChangeLog {
}

View File

@@ -0,0 +1,16 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_vote_result_change_logs")
public class PostVoteResultChangeLog extends PostChangeLog {
}

View File

@@ -4,7 +4,10 @@ import com.openisle.model.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
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 java.util.List;
import java.util.Optional;
public interface TagRepository extends JpaRepository<Tag, Long> {
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> 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.repository.PostChangeLogRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -15,6 +16,12 @@ import java.util.stream.Collectors;
public class PostChangeLogService {
private final PostChangeLogRepository logRepository;
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) {
PostContentChangeLog log = new PostContentChangeLog();
@@ -86,6 +93,22 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log);
}
public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log);
}
public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -368,6 +368,7 @@ public class PostService {
for (User participant : pp.getParticipants()) {
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.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
postChangeLogService.recordLotteryResult(lp);
});
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.Role;
import com.openisle.service.PasswordValidator;
@@ -7,13 +8,18 @@ import com.openisle.service.UsernameValidator;
import com.openisle.service.AvatarGenerator;
import com.openisle.exception.FieldException;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
@@ -25,6 +31,10 @@ public class UserService {
private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator;
private final RedisTemplate redisTemplate;
private final EmailSender emailService;
public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username);
passwordValidator.validate(password);
@@ -38,7 +48,7 @@ public class UserService {
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -54,7 +64,7 @@ public class UserService {
// 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -67,7 +77,7 @@ public class UserService {
user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER);
user.setVerified(false);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
@@ -77,7 +87,7 @@ public class UserService {
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
return userRepository.save(user);
}
@@ -85,16 +95,58 @@ public class UserService {
return String.format("%06d", new Random().nextInt(1000000));
}
public boolean verifyCode(String username, String code) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent() && code.equals(userOpt.get().getVerificationCode())) {
User user = userOpt.get();
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
return true;
/**
* 将验证码存入缓存,并发送邮件
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType){
//缓存验证码
String code = genCode();
String key;
String subject;
String content = "您的验证码是:" + code;
// 注册类型
if(verifyType.equals(VerifyType.REGISTER)){
key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername();
subject = "在网站填写验证码以验证(有效期为5分钟)";
}else {
// 重置密码
key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername();
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
return false;
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);// 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
}
/**
* 验证code是否正确
* @param user
* @param code
* @param verifyType
* @return
*/
public boolean verifyCode(User user, String code, VerifyType verifyType) {
// 生成key
String key1 = VerifyType.REGISTER.equals(verifyType)?":register:code:":":reset_password:code:";
String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername();
// 这里不能使用getAndDelete,需要6.x版本
String cachedCode = (String)redisTemplate.opsForValue().get(key);
// 如果校验code过期或者不存在
// 或者校验code不一致
if(Objects.isNull(cachedCode)
|| !cachedCode.equals(code)){
return false;
}
// 注册模式需要设置已经确认
if(VerifyType.REGISTER.equals(verifyType)){
user.setVerified(true);
userRepository.save(user);
}
// 走到这里说明验证成功删除验证码
redisTemplate.delete(key);
return true;
}
public Optional<User> authenticate(String username, String password) {
@@ -165,26 +217,6 @@ public class UserService {
return userRepository.save(user);
}
public String generatePasswordResetCode(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String code = genCode();
user.setPasswordResetCode(code);
userRepository.save(user);
return code;
}
public boolean verifyPasswordResetCode(String email, String code) {
Optional<User> userOpt = userRepository.findByEmail(email);
if (userOpt.isPresent() && code.equals(userOpt.get().getPasswordResetCode())) {
User user = userOpt.get();
user.setPasswordResetCode(null);
userRepository.save(user);
return true;
}
return false;
}
public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword);
User user = userRepository.findByUsername(username)

View File

@@ -0,0 +1,20 @@
package com.openisle.util;
/**
* 验证码类型
* @author smallclover
* @since 2025-09-08
*/
public enum VerifyType {
REGISTER(1),
RESET_PASSWORD(2);
private final int code;
VerifyType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -4,6 +4,7 @@ import com.openisle.model.User;
import com.openisle.service.*;
import com.openisle.model.RegisterMode;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
@@ -71,7 +72,9 @@ class AuthControllerTest {
@Test
void verifyCodeEndpoint() throws Exception {
Mockito.when(userService.verifyCode("u", "123")).thenReturn(true);
User user = new User();
user.setUsername("u");
Mockito.when(userService.verifyCode(user, "123", VerifyType.REGISTER)).thenReturn(true);
Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token");
mockMvc.perform(post("/api/auth/verify")

View File

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

View File

@@ -17,7 +17,7 @@
--background-color: white;
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--normal-border-color: rgba(211, 211, 211, 0.63);
--menu-selected-background-color: rgba(88, 241, 255, 0.166);
--normal-light-background-color: rgba(242, 242, 242, 0.884);
--menu-selected-background-color-hover: rgba(242, 242, 242, 0.884);
@@ -348,6 +348,22 @@ body {
}
}
/* Adjust diff2html layout on mobile */
@media (max-width: 768px) {
.content-diff .d2h-wrapper,
.content-diff .d2h-code-line,
.content-diff .d2h-code-side-line,
.content-diff .d2h-code-line-ctn,
.content-diff .d2h-code-side-line-ctn,
.content-diff .d2h-file-header {
font-size: 12px;
}
.content-diff .d2h-wrapper {
overflow-x: auto;
}
}
/* Transition API */
::view-transition-old(root),
::view-transition-new(root) {

View File

@@ -1,28 +1,53 @@
<template>
<div :id="`change-log-${log.id}`" class="change-log-container">
<div class="change-log-text">
<span class="change-log-user">{{ log.username }}</span>
<span v-if="log.type === 'CONTENT'">
变更了文章内容
<div class="content-diff" v-html="diffHtml"></div>
</span>
<span v-else-if="log.type === 'TITLE'">变更了文章标题</span>
<span v-else-if="log.type === 'CATEGORY'">变更了文章分类</span>
<span v-else-if="log.type === 'TAG'">变更了文章标签</span>
<span v-else-if="log.type === 'CLOSED'">
<BaseImage
v-if="log.userAvatar"
class="change-log-avatar"
:src="log.userAvatar"
alt="avatar"
@click="() => navigateTo(`/users/${log.username}`)"
/>
<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-else-if="log.type === 'TITLE'" class="change-log-content">变更了文章标题</span>
<template v-else-if="log.type === 'CATEGORY'">
<div class="change-log-category-text">变更了文章分类, </div>
<ArticleCategory :category="log.oldCategory" />
<div class="change-log-category-text">修改为</div>
<ArticleCategory :category="log.newCategory" />
</template>
<template v-else-if="log.type === 'TAG'">
<div class="change-log-category-text">变更了文章标签, </div>
<ArticleTags :tags="log.oldTags" />
<div class="change-log-category-text">修改为</div>
<ArticleTags :tags="log.newTags" />
</template>
<span v-else-if="log.type === 'CLOSED'" class="change-log-content">
<template v-if="log.newClosed">关闭了文章</template>
<template v-else>重新打开了文章</template>
</span>
<span v-else-if="log.type === 'PINNED'">
<span v-else-if="log.type === 'PINNED'" class="change-log-content">
<template v-if="log.newPinnedAt">置顶了文章</template>
<template v-else>取消置顶文章</template>
<template v-else>取消置顶文章</template>
</span>
<span v-else-if="log.type === 'FEATURED'">
<span v-else-if="log.type === 'FEATURED'" class="change-log-content">
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
>系统已计算投票结果</span
>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div
v-if="log.type === 'CONTENT' || log.type === 'TITLE'"
class="content-diff"
v-html="diffHtml"
></div>
</div>
</template>
@@ -30,14 +55,46 @@
import { computed } from 'vue'
import { html } from 'diff2html'
import { createTwoFilesPatch } from 'diff'
import { useIsMobile } from '~/utils/screen'
import 'diff2html/bundles/css/diff2html.min.css'
const props = defineProps({ log: Object })
import BaseImage from '~/components/BaseImage.vue'
import { navigateTo } from 'nuxt/app'
import { themeState } from '~/utils/theme'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
const props = defineProps({
log: Object,
title: String,
})
const diffHtml = computed(() => {
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
themeState.mode
const colorScheme = isDark ? 'dark' : 'light'
if (props.log.type === 'CONTENT') {
const oldContent = props.log.oldContent ?? ''
const newContent = props.log.newContent ?? ''
const diff = createTwoFilesPatch('old', 'new', oldContent, newContent)
return html(diff, { inputFormat: 'diff', showFiles: false, matching: 'lines' })
const diff = createTwoFilesPatch(props.title, props.title, oldContent, newContent)
return html(diff, {
inputFormat: 'diff',
showFiles: false,
matching: 'lines',
drawFileList: false,
colorScheme,
})
} else if (props.log.type === 'TITLE') {
const oldTitle = props.log.oldTitle ?? ''
const newTitle = props.log.newTitle ?? ''
const diff = createTwoFilesPatch(oldTitle, newTitle, '', '')
return html(diff, {
inputFormat: 'diff',
showFiles: false,
matching: 'lines',
drawFileList: false,
colorScheme,
})
}
return ''
})
@@ -47,13 +104,34 @@ const diffHtml = computed(() => {
.change-log-container {
display: flex;
flex-direction: column;
padding-bottom: 30px;
opacity: 0.7;
/* padding-top: 5px; */
/* padding-bottom: 30px; */
font-size: 14px;
border-bottom: 1px solid var(--normal-border-color);
padding-bottom: 10px;
}
.change-log-text {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.change-log-user {
font-weight: bold;
margin-right: 4px;
}
.change-log-user,
.change-log-content {
opacity: 0.7;
}
.change-log-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 4px;
cursor: pointer;
}
.change-log-time {
font-size: 12px;
opacity: 0.6;
@@ -62,4 +140,12 @@ const diffHtml = computed(() => {
.content-diff {
margin-top: 8px;
}
.change-log-category {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
</style>

View File

@@ -134,7 +134,7 @@
:post-closed="closed"
@deleted="onCommentDeleted"
/>
<PostChangeLogItem v-else :log="item" />
<PostChangeLogItem v-else :log="item" :title="title" />
</template>
</BaseTimeline>
</div>
@@ -363,6 +363,10 @@ const changeLogIcon = (l) => {
} else {
return 'dislike'
}
} else if (l.type === 'VOTE_RESULT') {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else {
return 'info'
}
@@ -371,12 +375,21 @@ const changeLogIcon = (l) => {
const mapChangeLog = (l) => ({
id: l.id,
username: l.username,
userAvatar: l.userAvatar,
type: l.type,
createdAt: l.time,
time: TimeManager.format(l.time),
newClosed: l.newClosed,
newPinnedAt: l.newPinnedAt,
newFeatured: l.newFeatured,
oldContent: l.oldContent,
newContent: l.newContent,
oldTitle: l.oldTitle,
newTitle: l.newTitle,
oldCategory: l.oldCategory,
newCategory: l.newCategory,
oldTags: l.oldTags,
newTags: l.newTags,
icon: changeLogIcon(l),
})
@@ -1150,6 +1163,7 @@ onMounted(async () => {
margin-top: 10px;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.info-content-container {
@@ -1205,7 +1219,7 @@ onMounted(async () => {
}
.post-time {
font-size: 14px;
font-size: 12px;
opacity: 0.5;
}
@@ -1271,10 +1285,6 @@ onMounted(async () => {
font-size: 12px;
}
.post-time {
font-size: 12px;
}
.info-content-text {
line-height: 1.5;
}

View File

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