Compare commits

...

40 Commits

Author SHA1 Message Date
Tim
cfaa4cd094 Update application.properties 2025-09-09 11:48:42 +08:00
Tim
fc414794ff docs: use https for openapi base url 2025-09-09 11:48:07 +08:00
Tim
d8264956c3 Merge pull request #943 from nagisa77/codex/fix-invalid-workflow-permissions-in-deploy-staging.yml
fix: grant write permissions for docs deployment
2025-09-09 11:30:28 +08:00
Tim
effa7f25ca fix: grant write permissions for docs deployment 2025-09-09 11:30:11 +08:00
Tim
9b19fae69a Merge pull request #942 from nagisa77/codex/resolve-conflict-between-deploy-staging-and-deploy-docs
Run docs deployment after staging deploy
2025-09-09 11:06:39 +08:00
Tim
ec04f64ce1 chore: trigger docs deployment after staging 2025-09-09 11:06:16 +08:00
Tim
50bea76c0e Merge pull request #940 from nagisa77/codex/adjust-diff2html-font-for-mobile-ui
style: adjust diff2html fonts on mobile
2025-09-09 00:33:58 +08:00
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
Tim
24d0da0864 feat(frontend): render diff for content changes 2025-09-08 13:22:25 +08:00
Tim
9b53479ab6 feat: changelog前端ui优化 2025-09-08 13:04:14 +08:00
Tim
039d482517 Add post change log tracking 2025-09-08 11:27:35 +08:00
Tim
7cc32c36b1 Merge pull request #922 from nagisa77/feature/chat_ui
fix: revert 100vh 修改
2025-09-08 10:44:12 +08:00
Tim
a2b72d7c00 Merge pull request #921 from nagisa77/feature/chat_ui
Chat UI update
2025-09-08 00:17:34 +08:00
Tim
ce213d4c24 Merge pull request #918 from nagisa77/feature/menu_select_state
Some UI fixes~
2025-09-07 23:51:22 +08:00
36 changed files with 1114 additions and 88 deletions

View File

@@ -1,7 +1,11 @@
name: Deploy Documentation name: Deploy Documentation
on: on:
push: workflow_call:
inputs:
build-id:
required: false
type: string
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -16,6 +20,9 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Log build
run: echo "Running documentation deployment from build ${{ inputs.build-id }}"
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:

View File

@@ -5,6 +5,9 @@ on:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -21,3 +24,11 @@ jobs:
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy-staging.sh script: bash /opt/openisle/deploy-staging.sh
deploy-docs:
needs: build-and-deploy
if: ${{ success() }}
uses: ./.github/workflows/deploy-docs.yml
secrets: inherit
with:
build-id: ${{ github.run_id }}

View File

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

@@ -37,22 +37,22 @@ public class AdminPostController {
} }
@PostMapping("/{id}/pin") @PostMapping("/{id}/pin")
public PostSummaryDto pin(@PathVariable Long id) { public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.pinPost(id)); return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
} }
@PostMapping("/{id}/unpin") @PostMapping("/{id}/unpin")
public PostSummaryDto unpin(@PathVariable Long id) { public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.unpinPost(id)); return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
} }
@PostMapping("/{id}/rss-exclude") @PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) { public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.excludeFromRss(id)); return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
} }
@PostMapping("/{id}/rss-include") @PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) { public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.includeInRss(id)); return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
} }
} }

View File

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

View File

@@ -0,0 +1,25 @@
package com.openisle.controller;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.service.PostChangeLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostChangeLogController {
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper mapper;
@GetMapping("/{id}/change-logs")
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream()
.map(mapper::toDto)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,32 @@
package com.openisle.dto;
import com.openisle.model.PostChangeType;
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 CategoryDto oldCategory;
private CategoryDto newCategory;
private List<TagDto> oldTags;
private List<TagDto> newTags;
private Boolean oldClosed;
private Boolean newClosed;
private LocalDateTime oldPinnedAt;
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
}

View File

@@ -0,0 +1,92 @@
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());
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) {
dto.setOldTitle(t.getOldTitle());
dto.setNewTitle(t.getNewTitle());
} else if (log instanceof PostContentChangeLog c) {
dto.setOldContent(c.getOldContent());
dto.setNewContent(c.getNewContent());
} else if (log instanceof PostCategoryChangeLog cat) {
dto.setOldCategory(mapCategory(cat.getOldCategory()));
dto.setNewCategory(mapCategory(cat.getNewCategory()));
} else if (log instanceof PostTagChangeLog tag) {
dto.setOldTags(mapTags(tag.getOldTags()));
dto.setNewTags(mapTags(tag.getNewTags()));
} else if (log instanceof PostClosedChangeLog cl) {
dto.setOldClosed(cl.isOldClosed());
dto.setNewClosed(cl.isNewClosed());
} else if (log instanceof PostPinnedChangeLog p) {
dto.setOldPinnedAt(p.getOldPinnedAt());
dto.setNewPinnedAt(p.getNewPinnedAt());
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
}
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

@@ -0,0 +1,17 @@
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_category_change_logs")
public class PostCategoryChangeLog extends PostChangeLog {
private String oldCategory;
private String newCategory;
}

View File

@@ -0,0 +1,37 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_change_logs")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class PostChangeLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "user_id")
private User user;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostChangeType type;
}

View File

@@ -0,0 +1,13 @@
package com.openisle.model;
public enum PostChangeType {
CONTENT,
TITLE,
CATEGORY,
TAG,
CLOSED,
PINNED,
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT
}

View File

@@ -0,0 +1,17 @@
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_closed_change_logs")
public class PostClosedChangeLog extends PostChangeLog {
private boolean oldClosed;
private boolean newClosed;
}

View File

@@ -0,0 +1,21 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_content_change_logs")
public class PostContentChangeLog extends PostChangeLog {
@Column(name = "old_content", columnDefinition = "LONGTEXT")
private String oldContent;
@Column(name = "new_content", columnDefinition = "LONGTEXT")
private String newContent;
}

View File

@@ -0,0 +1,17 @@
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_featured_change_logs")
public class PostFeaturedChangeLog extends PostChangeLog {
private boolean oldFeatured;
private boolean newFeatured;
}

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,19 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_pinned_change_logs")
public class PostPinnedChangeLog extends PostChangeLog {
private LocalDateTime oldPinnedAt;
private LocalDateTime newPinnedAt;
}

View File

@@ -0,0 +1,21 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_tag_change_logs")
public class PostTagChangeLog extends PostChangeLog {
@Column(name = "old_tags")
private String oldTags;
@Column(name = "new_tags")
private String newTags;
}

View File

@@ -0,0 +1,17 @@
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_title_change_logs")
public class PostTitleChangeLog extends PostChangeLog {
private String oldTitle;
private String newTitle;
}

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

@@ -0,0 +1,11 @@
package com.openisle.repository;
import com.openisle.model.Post;
import com.openisle.model.PostChangeLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> {
List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post);
}

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

@@ -0,0 +1,117 @@
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;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
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();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CONTENT);
log.setOldContent(oldContent);
log.setNewContent(newContent);
logRepository.save(log);
}
public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) {
PostTitleChangeLog log = new PostTitleChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TITLE);
log.setOldTitle(oldTitle);
log.setNewTitle(newTitle);
logRepository.save(log);
}
public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) {
PostCategoryChangeLog log = new PostCategoryChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CATEGORY);
log.setOldCategory(oldCategory);
log.setNewCategory(newCategory);
logRepository.save(log);
}
public void recordTagChange(Post post, User user, Set<Tag> oldTags, Set<Tag> newTags) {
PostTagChangeLog log = new PostTagChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TAG);
log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
logRepository.save(log);
}
public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) {
PostClosedChangeLog log = new PostClosedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CLOSED);
log.setOldClosed(oldClosed);
log.setNewClosed(newClosed);
logRepository.save(log);
}
public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) {
PostPinnedChangeLog log = new PostPinnedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.PINNED);
log.setOldPinnedAt(oldPinnedAt);
log.setNewPinnedAt(newPinnedAt);
logRepository.save(log);
}
public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) {
PostFeaturedChangeLog log = new PostFeaturedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.FEATURED);
log.setOldFeatured(oldFeatured);
log.setNewFeatured(newFeatured);
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"));
return logRepository.findByPostOrderByCreatedAtAsc(post);
}
}

View File

@@ -19,6 +19,7 @@ import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository; import com.openisle.repository.TagRepository;
import com.openisle.service.SubscriptionService; import com.openisle.service.SubscriptionService;
import com.openisle.service.CommentService; 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;
@@ -74,6 +75,7 @@ public class PostService {
private final EmailSender emailSender; private final EmailSender emailSender;
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final PointService pointService; private final PointService pointService;
private final PostChangeLogService postChangeLogService;
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;
@@ -99,6 +101,7 @@ public class PostService {
EmailSender emailSender, EmailSender emailSender,
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService, PointService pointService,
PostChangeLogService postChangeLogService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -120,6 +123,7 @@ public class PostService {
this.emailSender = emailSender; this.emailSender = emailSender;
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.pointService = pointService; this.pointService = pointService;
this.postChangeLogService = postChangeLogService;
this.publishMode = publishMode; this.publishMode = publishMode;
} }
@@ -159,19 +163,28 @@ public class PostService {
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
} }
public Post excludeFromRss(Long id) { public Post excludeFromRss(Long id, String username) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded());
post.setRssExcluded(true); post.setRssExcluded(true);
return postRepository.save(post); Post saved = postRepository.save(post);
postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false);
return saved;
} }
public Post includeInRss(Long id) { public Post includeInRss(Long id, String username) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded());
post.setRssExcluded(false); post.setRssExcluded(false);
post = postRepository.save(post); Post saved = postRepository.save(post);
notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null); postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true);
pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId()); notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null);
return post; pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId());
return saved;
} }
public Post createPost(String username, public Post createPost(String username,
@@ -355,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);
}); });
} }
@@ -389,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);
}); });
} }
@@ -638,18 +653,28 @@ public class PostService {
return post; return post;
} }
public Post pinPost(Long id) { 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"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
java.time.LocalDateTime oldPinned = post.getPinnedAt();
post.setPinnedAt(java.time.LocalDateTime.now()); post.setPinnedAt(java.time.LocalDateTime.now());
return postRepository.save(post); Post saved = postRepository.save(post);
postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt());
return saved;
} }
public Post unpinPost(Long id) { 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"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
java.time.LocalDateTime oldPinned = post.getPinnedAt();
post.setPinnedAt(null); post.setPinnedAt(null);
return postRepository.save(post); Post saved = postRepository.save(post);
postChangeLogService.recordPinnedChange(saved, user, oldPinned, null);
return saved;
} }
public Post closePost(Long id, String username) { public Post closePost(Long id, String username) {
@@ -660,8 +685,11 @@ public class PostService {
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized"); throw new IllegalArgumentException("Unauthorized");
} }
boolean oldClosed = post.isClosed();
post.setClosed(true); post.setClosed(true);
return postRepository.save(post); Post saved = postRepository.save(post);
postChangeLogService.recordClosedChange(saved, user, oldClosed, true);
return saved;
} }
public Post reopenPost(Long id, String username) { public Post reopenPost(Long id, String username) {
@@ -672,8 +700,11 @@ public class PostService {
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized"); throw new IllegalArgumentException("Unauthorized");
} }
boolean oldClosed = post.isClosed();
post.setClosed(false); post.setClosed(false);
return postRepository.save(post); Post saved = postRepository.save(post);
postChangeLogService.recordClosedChange(saved, user, oldClosed, false);
return saved;
} }
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
@@ -702,14 +733,30 @@ public class PostService {
if (tags.isEmpty()) { if (tags.isEmpty()) {
throw new IllegalArgumentException("Tag not found"); throw new IllegalArgumentException("Tag not found");
} }
post.setTitle(title); String oldTitle = post.getTitle();
String oldContent = post.getContent(); String oldContent = post.getContent();
Category oldCategory = post.getCategory();
java.util.Set<com.openisle.model.Tag> oldTags = new java.util.HashSet<>(post.getTags());
post.setTitle(title);
post.setContent(content); post.setContent(content);
post.setCategory(category); post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags)); post.setTags(new java.util.HashSet<>(tags));
Post updated = postRepository.save(post); Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content); imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null); notificationService.notifyMentions(content, user, updated, null);
if (!java.util.Objects.equals(oldTitle, title)) {
postChangeLogService.recordTitleChange(updated, user, oldTitle, title);
}
if (!java.util.Objects.equals(oldContent, content)) {
postChangeLogService.recordContentChange(updated, user, oldContent, content);
}
if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) {
postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName());
}
java.util.Set<com.openisle.model.Tag> newTags = new java.util.HashSet<>(tags);
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
return updated; return updated;
} }

View File

@@ -1,5 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.Role; import com.openisle.model.Role;
import com.openisle.service.PasswordValidator; import com.openisle.service.PasswordValidator;
@@ -7,13 +8,18 @@ import com.openisle.service.UsernameValidator;
import com.openisle.service.AvatarGenerator; import com.openisle.service.AvatarGenerator;
import com.openisle.exception.FieldException; import com.openisle.exception.FieldException;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor; 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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -25,6 +31,10 @@ public class UserService {
private final ImageUploader imageUploader; private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator; 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) { public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username); usernameValidator.validate(username);
passwordValidator.validate(password); passwordValidator.validate(password);
@@ -38,7 +48,7 @@ public class UserService {
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码 // 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉 u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password)); u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode()); // u.setVerificationCode(genCode());
u.setRegisterReason(reason); u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u); return userRepository.save(u);
@@ -54,7 +64,7 @@ public class UserService {
// 未验证 → 允许“重注册” // 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉 u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password)); u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode()); // u.setVerificationCode(genCode());
u.setRegisterReason(reason); u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u); return userRepository.save(u);
@@ -67,7 +77,7 @@ public class UserService {
user.setPassword(passwordEncoder.encode(password)); user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(false); user.setVerified(false);
user.setVerificationCode(genCode()); // user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username)); user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason); user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
@@ -77,7 +87,7 @@ public class UserService {
public User registerWithInvite(String username, String email, String password) { public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT); User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true); user.setVerified(true);
user.setVerificationCode(genCode()); // user.setVerificationCode(genCode());
return userRepository.save(user); return userRepository.save(user);
} }
@@ -85,16 +95,58 @@ public class UserService {
return String.format("%06d", new Random().nextInt(1000000)); 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())) { * @param user
User user = userOpt.get(); */
user.setVerified(true); public void sendVerifyMail(User user, VerifyType verifyType){
user.setVerificationCode(null); //缓存验证码
userRepository.save(user); String code = genCode();
return true; 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) { public Optional<User> authenticate(String username, String password) {
@@ -165,26 +217,6 @@ public class UserService {
return userRepository.save(user); 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) { public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword); passwordValidator.validate(newPassword);
User user = userRepository.findByUsername(username) 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

@@ -108,6 +108,7 @@ rabbitmq.sharding.enabled=true
# see https://springdoc.org/#springdoc-openapi-core-properties # see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/api/v3/api-docs springdoc.api-docs.path=/api/v3/api-docs
springdoc.api-docs.enabled=true springdoc.api-docs.enabled=true
springdoc.api-docs.server-url=${WEBSITE_URL:https://www.open-isle.com}
springdoc.info.title=OpenIsle springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1 springdoc.info.version=0.0.1

View File

@@ -4,6 +4,7 @@ import com.openisle.model.User;
import com.openisle.service.*; import com.openisle.service.*;
import com.openisle.model.RegisterMode; import com.openisle.model.RegisterMode;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -71,7 +72,9 @@ class AuthControllerTest {
@Test @Test
void verifyCodeEndpoint() throws Exception { 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"); Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token");
mockMvc.perform(post("/api/auth/verify") mockMvc.perform(post("/api/auth/verify")

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

@@ -17,7 +17,7 @@
--background-color: white; --background-color: white;
--background-color-blur: rgba(255, 255, 255, 0.57); --background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray; --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); --menu-selected-background-color: rgba(88, 241, 255, 0.166);
--normal-light-background-color: rgba(242, 242, 242, 0.884); --normal-light-background-color: rgba(242, 242, 242, 0.884);
--menu-selected-background-color-hover: 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 */ /* Transition API */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {

View File

@@ -0,0 +1,151 @@
<template>
<div :id="`change-log-${log.id}`" class="change-log-container">
<div class="change-log-text">
<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'" class="change-log-content">
<template v-if="log.newPinnedAt">置顶了文章</template>
<template v-else>取消置顶文章</template>
</span>
<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>
<script setup>
import { computed } from 'vue'
import { html } from 'diff2html'
import { createTwoFilesPatch } from 'diff'
import { useIsMobile } from '~/utils/screen'
import 'diff2html/bundles/css/diff2html.min.css'
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(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 ''
})
</script>
<style scoped>
.change-log-container {
display: flex;
flex-direction: column;
/* 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;
}
.content-diff {
margin-top: 8px;
}
.change-log-category {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
</style>

View File

@@ -10,6 +10,8 @@
"@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",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -7218,6 +7220,41 @@
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/diff2html": {
"version": "3.4.52",
"resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.52.tgz",
"integrity": "sha512-qhMg8/I3sZ4zm/6R/Kh0xd6qG6Vm86w6M+C9W+DuH1V8ACz+1cgEC8/k0ucjv6AGqZWzHm/8G1gh7IlrUqCMhg==",
"license": "MIT",
"dependencies": {
"diff": "^7.0.0",
"hogan.js": "3.0.2"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"highlight.js": "11.9.0"
}
},
"node_modules/diff2html/node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff2html/node_modules/highlight.js": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
"integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -8291,6 +8328,49 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/hogan.js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz",
"integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==",
"dependencies": {
"mkdirp": "0.3.0",
"nopt": "1.0.10"
},
"bin": {
"hulk": "bin/hulk"
}
},
"node_modules/hogan.js/node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/hogan.js/node_modules/mkdirp": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz",
"integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==",
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/hogan.js/node_modules/nopt": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
"integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
"license": "MIT",
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "*"
}
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",

View File

@@ -16,6 +16,8 @@
"@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",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",

View File

@@ -122,9 +122,10 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else class="comments-container"> <div v-else class="comments-container">
<BaseTimeline :items="comments"> <BaseTimeline :items="timelineItems">
<template #item="{ item }"> <template #item="{ item }">
<CommentItem <CommentItem
v-if="item.kind === 'comment'"
:key="item.id" :key="item.id"
:comment="item" :comment="item"
:level="0" :level="0"
@@ -133,6 +134,7 @@
:post-closed="closed" :post-closed="closed"
@deleted="onCommentDeleted" @deleted="onCommentDeleted"
/> />
<PostChangeLogItem v-else :log="item" :title="title" />
</template> </template>
</BaseTimeline> </BaseTimeline>
</div> </div>
@@ -182,6 +184,7 @@ import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue' import CommentItem from '~/components/CommentItem.vue'
import CommentEditor from '~/components/CommentEditor.vue' import CommentEditor from '~/components/CommentEditor.vue'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue' import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue' import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue'
@@ -212,6 +215,7 @@ const category = ref('')
const tags = ref([]) const tags = ref([])
const postReactions = ref([]) const postReactions = ref([])
const comments = ref([]) const comments = ref([])
const changeLogs = ref([])
const status = ref('PUBLISHED') const status = ref('PUBLISHED')
const closed = ref(false) const closed = ref(false)
const pinnedAt = ref(null) const pinnedAt = ref(null)
@@ -225,6 +229,7 @@ const subscribed = ref(false)
const commentSort = ref('NEWEST') const commentSort = ref('NEWEST')
const isFetchingComments = ref(false) const isFetchingComments = ref(false)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const timelineItems = ref([])
const headerHeight = import.meta.client const headerHeight = import.meta.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
@@ -290,8 +295,13 @@ const gatherPostItems = () => {
const main = mainContainer.value.querySelector('.info-content-container') const main = mainContainer.value.querySelector('.info-content-container')
if (main) items.push({ el: main, top: getTop(main) }) if (main) items.push({ el: main, top: getTop(main) })
for (const c of comments.value) { for (const c of timelineItems.value) {
const el = document.getElementById('comment-' + c.id) let el
if (c.kind === 'comment') {
el = document.getElementById('comment-' + c.id)
} else {
el = document.getElementById('change-log-' + c.id)
}
if (el) { if (el) {
items.push({ el, top: getTop(el) }) items.push({ el, top: getTop(el) })
} }
@@ -323,12 +333,66 @@ const mapComment = (
), ),
openReplies: level === 0, openReplies: level === 0,
src: c.author.avatar, src: c.author.avatar,
createdAt: c.createdAt,
iconClick: () => navigateTo(`/users/${c.author.id}`), iconClick: () => navigateTo(`/users/${c.author.id}`),
parentUserName: parentUserName, parentUserName: parentUserName,
parentUserAvatar: parentUserAvatar, parentUserAvatar: parentUserAvatar,
parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null, parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null,
}) })
const changeLogIcon = (l) => {
if (l.type === 'CONTENT') {
return 'edit'
} else if (l.type === 'TITLE') {
return 'hashtag-key'
} else if (l.type === 'CATEGORY') {
return 'tag-one'
} else if (l.type === 'TAG') {
return 'tag-one'
} else if (l.type === 'CLOSED') {
if (l.newClosed) {
return 'lock-one'
} else {
return 'unlock'
}
} else if (l.type === 'PINNED') {
return 'pin-icon'
} else if (l.type === 'FEATURED') {
if (l.newFeatured) {
return 'star'
} else {
return 'dislike'
}
} else if (l.type === 'VOTE_RESULT') {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else {
return 'info'
}
}
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),
})
const getTop = (el) => { const getTop = (el) => {
return el.getBoundingClientRect().top + window.scrollY return el.getBoundingClientRect().top + window.scrollY
} }
@@ -422,19 +486,21 @@ watchEffect(() => {
// router.replace('/404') // router.replace('/404')
// } // }
const totalPosts = computed(() => comments.value.length + 1) const totalPosts = computed(() => timelineItems.value.length + 1)
const lastReplyTime = computed(() => const lastReplyTime = computed(() =>
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value, timelineItems.value.length
? timelineItems.value[timelineItems.value.length - 1].time
: postTime.value,
) )
const firstReplyTime = computed(() => const firstReplyTime = computed(() =>
comments.value.length ? comments.value[0].time : postTime.value, timelineItems.value.length ? timelineItems.value[0].time : postTime.value,
) )
const scrollerTopTime = computed(() => const scrollerTopTime = computed(() =>
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value, commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value,
) )
watch( watch(
() => comments.value.length, () => timelineItems.value.length,
async () => { async () => {
await nextTick() await nextTick()
gatherPostItems() gatherPostItems()
@@ -546,6 +612,7 @@ const approvePost = async () => {
status.value = 'PUBLISHED' status.value = 'PUBLISHED'
toast.success('已通过审核') toast.success('已通过审核')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -561,6 +628,7 @@ const pinPost = async () => {
if (res.ok) { if (res.ok) {
toast.success('已置顶') toast.success('已置顶')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -576,6 +644,7 @@ const unpinPost = async () => {
if (res.ok) { if (res.ok) {
toast.success('已取消置顶') toast.success('已取消置顶')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -591,6 +660,7 @@ const excludeRss = async () => {
if (res.ok) { if (res.ok) {
rssExcluded.value = true rssExcluded.value = true
toast.success('已标记为rss不推荐') toast.success('已标记为rss不推荐')
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -606,6 +676,7 @@ const includeRss = async () => {
if (res.ok) { if (res.ok) {
rssExcluded.value = false rssExcluded.value = false
toast.success('已标记为rss推荐') toast.success('已标记为rss推荐')
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -622,6 +693,7 @@ const closePost = async () => {
closed.value = true closed.value = true
toast.success('已关闭') toast.success('已关闭')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -638,6 +710,7 @@ const reopenPost = async () => {
closed.value = false closed.value = false
toast.success('已重新打开') toast.success('已重新打开')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -682,6 +755,7 @@ const rejectPost = async () => {
status.value = 'REJECTED' status.value = 'REJECTED'
toast.success('已驳回') toast.success('已驳回')
await refreshPost() await refreshPost()
await fetchChangeLogs()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -740,7 +814,42 @@ const fetchComments = async () => {
} }
} }
watch(commentSort, fetchComments) 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 () => {
await Promise.all([fetchComments(), fetchChangeLogs()])
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 () => {
await fetchTimeline()
})
const jumpToHashComment = async () => { const jumpToHashComment = async () => {
const hash = location.hash const hash = location.hash
@@ -763,7 +872,7 @@ const gotoProfile = () => {
const initPage = async () => { const initPage = async () => {
scrollTo(0, 0) scrollTo(0, 0)
await fetchComments() await fetchTimeline()
const hash = location.hash const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
if (id) expandCommentPath(id) if (id) expandCommentPath(id)
@@ -1054,6 +1163,7 @@ onMounted(async () => {
margin-top: 10px; margin-top: 10px;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.info-content-container { .info-content-container {
@@ -1109,7 +1219,7 @@ onMounted(async () => {
} }
.post-time { .post-time {
font-size: 14px; font-size: 12px;
opacity: 0.5; opacity: 0.5;
} }
@@ -1175,10 +1285,6 @@ onMounted(async () => {
font-size: 12px; font-size: 12px;
} }
.post-time {
font-size: 12px;
}
.info-content-text { .info-content-text {
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -74,6 +74,9 @@ import {
Server, Server,
Protection, Protection,
DoubleDown, DoubleDown,
Open,
Dislike,
CheckOne,
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
@@ -151,4 +154,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('ServerIcon', Server) nuxtApp.vueApp.component('ServerIcon', Server)
nuxtApp.vueApp.component('Protection', Protection) nuxtApp.vueApp.component('Protection', Protection)
nuxtApp.vueApp.component('DoubleDown', DoubleDown) nuxtApp.vueApp.component('DoubleDown', DoubleDown)
nuxtApp.vueApp.component('OpenIcon', Open)
nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne)
}) })