From 76962d6d1cfe3d61039b82d054913968d6f6abb5 Mon Sep 17 00:00:00 2001 From: sivdead <923396178@qq.com> Date: Thu, 25 Sep 2025 17:00:17 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=8F=90=E6=A1=88=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E6=8F=90=E6=A1=88=E8=A1=A8=E5=8D=95=E5=92=8C=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=90=8E=E7=AB=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/open-isle.env.example | 1 + .../openisle/controller/PostController.java | 7 +- .../java/com/openisle/dto/PostRequest.java | 7 + .../openisle/model/CategoryProposalPost.java | 65 ++++++++ .../model/CategoryProposalStatus.java | 10 ++ .../java/com/openisle/model/PostType.java | 1 + .../CategoryProposalPostRepository.java | 18 +++ .../com/openisle/service/PostService.java | 149 ++++++++++++++++-- .../src/main/resources/application.properties | 1 + .../V6__add_category_proposal_posts.sql | 25 +++ .../controller/PostControllerTest.java | 9 ++ .../com/openisle/service/PostServiceTest.java | 15 ++ frontend_nuxt/components/PostTypeSelect.vue | 1 + frontend_nuxt/components/ProposalForm.vue | 133 ++++++++++++++++ frontend_nuxt/pages/new-post.vue | 62 +++++++- 15 files changed, 485 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/openisle/model/CategoryProposalPost.java create mode 100644 backend/src/main/java/com/openisle/model/CategoryProposalStatus.java create mode 100644 backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java create mode 100644 backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql create mode 100644 frontend_nuxt/components/ProposalForm.vue diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index a62ac877f..a9b0ccc8b 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -16,6 +16,7 @@ JWT_EXPIRATION=2592000000 # === Redis === REDIS_HOST= REDIS_PORT= +REDIS_PASS= # === Resend === RESEND_API_KEY=<你的resend-api-key> diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 0a039975e..8fb6579a9 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -73,7 +73,12 @@ public class PostController { req.getStartTime(), req.getEndTime(), req.getOptions(), - req.getMultiple() + req.getMultiple(), + req.getProposedName(), + req.getProposedSlug(), + req.getProposalDescription(), + req.getApproveThreshold(), + req.getQuorum() ); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 0419804ea..3a7dad5f8 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -28,4 +28,11 @@ public class PostRequest { // fields for poll posts private List options; private Boolean multiple; + + // fields for category proposal posts + private String proposedName; + private String proposedSlug; + private String proposalDescription; + private Integer approveThreshold; + private Integer quorum; } diff --git a/backend/src/main/java/com/openisle/model/CategoryProposalPost.java b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java new file mode 100644 index 000000000..dde9dfddf --- /dev/null +++ b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java @@ -0,0 +1,65 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.PrimaryKeyJoinColumn; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * A specialized post type used for proposing new categories. + * It reuses poll mechanics (participants, votes, endTime) by extending PollPost. + */ +@Entity +@Table(name = "category_proposal_posts", indexes = { + @Index(name = "idx_category_proposal_posts_status", columnList = "status"), + @Index(name = "idx_category_proposal_posts_slug", columnList = "proposed_slug", unique = true) +}) +@Getter +@Setter +@NoArgsConstructor +@PrimaryKeyJoinColumn(name = "post_id") +public class CategoryProposalPost extends PollPost { + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING; + + @Column(name = "proposed_name", nullable = false) + private String proposedName; + + @Column(name = "proposed_slug", nullable = false, unique = true) + private String proposedSlug; + + @Column(name = "description") + private String description; + + // Approval threshold as percentage (0-100), default 60 + @Column(name = "approve_threshold", nullable = false) + private int approveThreshold = 60; + + // Minimum number of participants required to meet quorum + @Column(name = "quorum", nullable = false) + private int quorum = 10; + + // Optional voting start time (end time inherited from PollPost) + @Column(name = "start_at") + private LocalDateTime startAt; + + // Snapshot of poll results at finalization (e.g., JSON) + @Column(name = "result_snapshot", columnDefinition = "TEXT") + private String resultSnapshot; + + // Reason when proposal is rejected + @Column(name = "reject_reason") + private String rejectReason; +} + + diff --git a/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java b/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java new file mode 100644 index 000000000..81c2649c3 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java @@ -0,0 +1,10 @@ +package com.openisle.model; + +public enum CategoryProposalStatus { + PENDING, + APPROVED, + REJECTED +} + + + diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java index 7e675dafc..75509566a 100644 --- a/backend/src/main/java/com/openisle/model/PostType.java +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -4,4 +4,5 @@ public enum PostType { NORMAL, LOTTERY, POLL, + PROPOSAL } diff --git a/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java new file mode 100644 index 000000000..a403e44b4 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java @@ -0,0 +1,18 @@ +package com.openisle.repository; + +import com.openisle.model.CategoryProposalPost; +import com.openisle.model.CategoryProposalStatus; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface CategoryProposalPostRepository extends JpaRepository { + List findByEndTimeAfterAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); + List findByEndTimeBeforeAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); + boolean existsByProposedSlug(String proposedSlug); +} + + + diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 029c82882..0af3758f6 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,6 +1,30 @@ package com.openisle.service; import com.openisle.config.CachingConfig; +import com.openisle.exception.NotFoundException; +import com.openisle.exception.RateLimitException; +import com.openisle.model.*; +import com.openisle.repository.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; import com.openisle.exception.RateLimitException; import com.openisle.mapper.PostMapper; import com.openisle.model.*; @@ -26,22 +50,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.EventListener; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.CollectionUtils; @Slf4j @Service @@ -53,6 +61,7 @@ public class PostService { private final TagRepository tagRepository; private final LotteryPostRepository lotteryPostRepository; private final PollPostRepository pollPostRepository; + private final CategoryProposalPostRepository categoryProposalPostRepository; private final PollVoteRepository pollVoteRepository; private PublishMode publishMode; private final NotificationService notificationService; @@ -86,6 +95,7 @@ public class PostService { TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, PollPostRepository pollPostRepository, + CategoryProposalPostRepository categoryProposalPostRepository, PollVoteRepository pollVoteRepository, NotificationService notificationService, SubscriptionService subscriptionService, @@ -155,6 +165,19 @@ public class PostService { for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); } + for (CategoryProposalPost cp : categoryProposalPostRepository + .findByEndTimeAfterAndProposalStatus(now, CategoryProposalStatus.PENDING)) { + if (cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + scheduledFinalizations.put(cp.getId(), future); + } + } + for (CategoryProposalPost cp : categoryProposalPostRepository + .findByEndTimeBeforeAndProposalStatus(now, CategoryProposalStatus.PENDING)) { + applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); + } } public PublishMode getPublishMode() { @@ -227,7 +250,12 @@ public class PostService { LocalDateTime startTime, LocalDateTime endTime, java.util.List options, - Boolean multiple + Boolean multiple, + String proposedName, + String proposedSlug, + String proposalDescription, + Integer approveThreshold, + Integer quorum ) { // 限制访问次数 boolean limitResult = postRateLimit(username); @@ -273,6 +301,42 @@ public class PostService { pp.setEndTime(endTime); pp.setMultiple(multiple != null && multiple); post = pp; + } else if (actualType == PostType.PROPOSAL) { + CategoryProposalPost cp = new CategoryProposalPost(); + if (proposedName == null || proposedName.isBlank()) { + throw new IllegalArgumentException("Proposed name required"); + } + if (proposedSlug == null || proposedSlug.isBlank()) { + throw new IllegalArgumentException("Proposed slug required"); + } + if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { + throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); + } + cp.setProposedName(proposedName); + cp.setProposedSlug(proposedSlug); + cp.setDescription(proposalDescription); + if (approveThreshold != null) { + if (approveThreshold < 0 || approveThreshold > 100) { + throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); + } + cp.setApproveThreshold(approveThreshold); + } + if (quorum != null) { + if (quorum < 0) { + throw new IllegalArgumentException("quorum must be >= 0"); + } + cp.setQuorum(quorum); + } + cp.setStartAt(startTime); + cp.setEndTime(endTime); + // default yes/no options if not provided + if (options == null || options.size() < 2) { + cp.setOptions(List.of("同意", "反对")); + } else { + cp.setOptions(options); + } + cp.setMultiple(false); + post = cp; } else { post = new Post(); } @@ -285,6 +349,8 @@ public class PostService { post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); + }else if (post instanceof CategoryProposalPost categoryProposalPost) { + post = categoryProposalPostRepository.save(categoryProposalPost); } else if (post instanceof PollPost) { post = pollPostRepository.save((PollPost) post); } else { @@ -339,6 +405,11 @@ public class PostService { java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) ); scheduledFinalizations.put(lp.getId(), future); + } else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + scheduledFinalizations.put(cp.getId(), future); } else if (post instanceof PollPost pp && pp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), @@ -349,6 +420,50 @@ public class PostService { return post; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, allEntries = true + ) + @Transactional + public void finalizeProposal(Long postId) { + scheduledFinalizations.remove(postId); + categoryProposalPostRepository.findById(postId).ifPresent(cp -> { + if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { + return; + } + int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; + int approveVotes = 0; + if (cp.getVotes() != null) { + approveVotes = cp.getVotes().getOrDefault(0, 0); + } + boolean quorumMet = totalParticipants >= cp.getQuorum(); + int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; + boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); + if (quorumMet && thresholdMet) { + cp.setProposalStatus(CategoryProposalStatus.APPROVED); + } else { + cp.setProposalStatus(CategoryProposalStatus.REJECTED); + String reason; + if (!quorumMet && !thresholdMet) { + reason = "未达到法定人数且赞成率不足"; + } else if (!quorumMet) { + reason = "未达到法定人数"; + } else { + reason = "赞成率不足"; + } + cp.setRejectReason(reason); + } + cp.setResultSnapshot("approveVotes=" + approveVotes + ", totalParticipants=" + totalParticipants + ", approvePercent=" + approvePercent); + categoryProposalPostRepository.save(cp); + if (cp.getAuthor() != null) { + notificationService.createNotification(cp.getAuthor(), NotificationType.POLL_RESULT_OWNER, cp, null, null, null, null, null); + } + for (User participant : cp.getParticipants()) { + notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, cp, null, null, null, null, null); + } + postChangeLogService.recordVoteResult(cp); + }); + } + /** * 限制发帖频率 * @param username diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 00ccc0302..1fef7f09b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.database=${REDIS_DATABASE:0} +spring.data.redis.password=${REDIS_PASS: null} # for jwt app.jwt.secret=${JWT_SECRET:jwt_sec} diff --git a/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql b/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql new file mode 100644 index 000000000..1ac39337b --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql @@ -0,0 +1,25 @@ +-- Create table for category proposal posts (subclass of poll_posts) +CREATE TABLE IF NOT EXISTS category_proposal_posts ( + post_id BIGINT NOT NULL, + status VARCHAR(50) NOT NULL, + proposed_name VARCHAR(255) NOT NULL, + proposed_slug VARCHAR(255) NOT NULL, + description VARCHAR(255), + approve_threshold INT NOT NULL DEFAULT 60, + quorum INT NOT NULL DEFAULT 10, + start_at DATETIME(6) NULL, + result_snapshot LONGTEXT NULL, + reject_reason VARCHAR(255), + PRIMARY KEY (post_id), + CONSTRAINT fk_category_proposal_posts_parent + FOREIGN KEY (post_id) REFERENCES poll_posts (post_id) +); + +CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status + ON category_proposal_posts (status); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug + ON category_proposal_posts (proposed_slug); + + + diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 3bf386ff4..12180eecc 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -117,6 +117,11 @@ class PostControllerTest { isNull(), isNull(), isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), isNull() ) ).thenReturn(post); @@ -266,6 +271,10 @@ class PostControllerTest { any(), any(), any(), + any(), + any(), + any(), + any(), any() ); } diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 6d9a220b6..71f3ebc45 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -25,6 +25,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -50,6 +51,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -101,6 +103,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -126,6 +129,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -190,6 +194,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -215,6 +220,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -253,6 +259,11 @@ class PostServiceTest { null, null, null, + null, + null, + null, + null, + null, null ) ); @@ -266,6 +277,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -291,6 +303,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -358,6 +371,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -383,6 +397,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue index 5fa16eab7..cfb605922 100644 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -34,6 +34,7 @@ export default { { id: 'NORMAL', name: '普通帖子', icon: 'file-text' }, { id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' }, { id: 'POLL', name: '投票帖子', icon: 'ranking-list' }, + { id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' }, ] } diff --git a/frontend_nuxt/components/ProposalForm.vue b/frontend_nuxt/components/ProposalForm.vue new file mode 100644 index 000000000..382b70a5e --- /dev/null +++ b/frontend_nuxt/components/ProposalForm.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index bd13de3f5..1128c0991 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -37,6 +37,7 @@ + @@ -50,6 +51,7 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue' import TagSelect from '~/components/TagSelect.vue' import LotteryForm from '~/components/LotteryForm.vue' import PollForm from '~/components/PollForm.vue' +import ProposalForm from '~/components/ProposalForm.vue' import { toast } from '~/main' import { authState, getToken } from '~/utils/auth' const config = useRuntimeConfig() @@ -76,6 +78,15 @@ const poll = reactive({ endTime: null, multiple: false, }) +const proposal = reactive({ + proposedName: '', + proposedSlug: '', + proposalDescription: '', + approveThreshold: 60, + quorum: 10, + endTime: null, + options: ['同意', '反对'], +}) const startTime = ref(null) const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -123,6 +134,13 @@ const clearPost = async () => { poll.options = ['', ''] poll.endTime = null poll.multiple = false + proposal.proposedName = '' + proposal.proposedSlug = '' + proposal.proposalDescription = '' + proposal.approveThreshold = 60 + proposal.quorum = 10 + proposal.endTime = null + proposal.options = ['同意', '反对'] // 删除草稿 const token = getToken() @@ -283,6 +301,32 @@ const submitPost = async () => { return } } + if (postType.value === 'PROPOSAL') { + if (!proposal.proposedName.trim()) { + toast.error('请填写拟议分类名称') + return + } + if (!proposal.proposedSlug.trim()) { + toast.error('请填写拟议分类 Slug') + return + } + if (proposal.approveThreshold < 0 || proposal.approveThreshold > 100) { + toast.error('通过阈值需在0到100之间') + return + } + if (proposal.quorum < 0) { + toast.error('最小参与数需大于或等于0') + return + } + if (proposal.options.length < 2 || proposal.options.some((o) => !o.trim())) { + toast.error('请填写至少两个投票选项') + return + } + if (!proposal.endTime) { + toast.error('请选择投票结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -321,6 +365,18 @@ const submitPost = async () => { prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined, options: postType.value === 'POLL' ? poll.options : undefined, multiple: postType.value === 'POLL' ? poll.multiple : undefined, + proposedName: postType.value === 'PROPOSAL' ? proposal.proposedName : undefined, + proposedSlug: postType.value === 'PROPOSAL' ? proposal.proposedSlug : undefined, + proposalDescription: + postType.value === 'PROPOSAL' ? proposal.proposalDescription : undefined, + approveThreshold: postType.value === 'PROPOSAL' ? proposal.approveThreshold : undefined, + quorum: postType.value === 'PROPOSAL' ? proposal.quorum : undefined, + options: + postType.value === 'POLL' + ? poll.options + : postType.value === 'PROPOSAL' + ? proposal.options + : undefined, startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined, @@ -330,7 +386,11 @@ const submitPost = async () => { ? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : postType.value === 'POLL' ? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() - : undefined, + : postType.value === 'PROPOSAL' + ? new Date( + new Date(proposal.endTime).getTime() + 8.02 * 60 * 60 * 1000, + ).toISOString() + : undefined, }), }) const data = await res.json() From 3da5d24488c97a2d7e2e39b16fa682e114cd036a Mon Sep 17 00:00:00 2001 From: sivdead <923396178@qq.com> Date: Thu, 25 Sep 2025 18:17:26 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=90=88=E5=B9=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/openisle/service/PostService.java | 247 +++++++++--------- .../controller/PostControllerTest.java | 10 + .../src/test/resources/application.properties | 1 + 3 files changed, 139 insertions(+), 119 deletions(-) diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 0af3758f6..9c4be50f1 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,10 +1,17 @@ package com.openisle.service; import com.openisle.config.CachingConfig; -import com.openisle.exception.NotFoundException; import com.openisle.exception.RateLimitException; import com.openisle.model.*; import com.openisle.repository.*; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -21,36 +28,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.*; -import com.openisle.exception.RateLimitException; -import com.openisle.mapper.PostMapper; -import com.openisle.model.*; -import com.openisle.repository.CategoryRepository; -import com.openisle.repository.CommentRepository; -import com.openisle.repository.LotteryPostRepository; -import com.openisle.repository.NotificationRepository; -import com.openisle.repository.PointHistoryRepository; -import com.openisle.repository.PollPostRepository; -import com.openisle.repository.PollVoteRepository; -import com.openisle.repository.PostRepository; -import com.openisle.repository.PostSubscriptionRepository; -import com.openisle.repository.ReactionRepository; -import com.openisle.repository.TagRepository; -import com.openisle.repository.UserRepository; -import com.openisle.service.EmailSender; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ScheduledFuture; -import java.util.stream.Collectors; - @Slf4j @Service public class PostService { @@ -121,6 +98,7 @@ public class PostService { this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; this.pollPostRepository = pollPostRepository; + this.categoryProposalPostRepository = categoryProposalPostRepository; this.pollVoteRepository = pollVoteRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; @@ -165,18 +143,23 @@ public class PostService { for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); } - for (CategoryProposalPost cp : categoryProposalPostRepository - .findByEndTimeAfterAndProposalStatus(now, CategoryProposalStatus.PENDING)) { - if (cp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), - java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(cp.getId(), future); - } + for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus( + now, + CategoryProposalStatus.PENDING + )) { + if (cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(cp.getId(), future); + } } - for (CategoryProposalPost cp : categoryProposalPostRepository - .findByEndTimeBeforeAndProposalStatus(now, CategoryProposalStatus.PENDING)) { - applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); + for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus( + now, + CategoryProposalStatus.PENDING + )) { + applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); } } @@ -302,41 +285,41 @@ public class PostService { pp.setMultiple(multiple != null && multiple); post = pp; } else if (actualType == PostType.PROPOSAL) { - CategoryProposalPost cp = new CategoryProposalPost(); - if (proposedName == null || proposedName.isBlank()) { - throw new IllegalArgumentException("Proposed name required"); + CategoryProposalPost cp = new CategoryProposalPost(); + if (proposedName == null || proposedName.isBlank()) { + throw new IllegalArgumentException("Proposed name required"); + } + if (proposedSlug == null || proposedSlug.isBlank()) { + throw new IllegalArgumentException("Proposed slug required"); + } + if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { + throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); + } + cp.setProposedName(proposedName); + cp.setProposedSlug(proposedSlug); + cp.setDescription(proposalDescription); + if (approveThreshold != null) { + if (approveThreshold < 0 || approveThreshold > 100) { + throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); } - if (proposedSlug == null || proposedSlug.isBlank()) { - throw new IllegalArgumentException("Proposed slug required"); + cp.setApproveThreshold(approveThreshold); + } + if (quorum != null) { + if (quorum < 0) { + throw new IllegalArgumentException("quorum must be >= 0"); } - if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { - throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); - } - cp.setProposedName(proposedName); - cp.setProposedSlug(proposedSlug); - cp.setDescription(proposalDescription); - if (approveThreshold != null) { - if (approveThreshold < 0 || approveThreshold > 100) { - throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); - } - cp.setApproveThreshold(approveThreshold); - } - if (quorum != null) { - if (quorum < 0) { - throw new IllegalArgumentException("quorum must be >= 0"); - } - cp.setQuorum(quorum); - } - cp.setStartAt(startTime); - cp.setEndTime(endTime); - // default yes/no options if not provided - if (options == null || options.size() < 2) { - cp.setOptions(List.of("同意", "反对")); - } else { - cp.setOptions(options); - } - cp.setMultiple(false); - post = cp; + cp.setQuorum(quorum); + } + cp.setStartAt(startTime); + cp.setEndTime(endTime); + // default yes/no options if not provided + if (options == null || options.size() < 2) { + cp.setOptions(List.of("同意", "反对")); + } else { + cp.setOptions(options); + } + cp.setMultiple(false); + post = cp; } else { post = new Post(); } @@ -349,8 +332,8 @@ public class PostService { post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); - }else if (post instanceof CategoryProposalPost categoryProposalPost) { - post = categoryProposalPostRepository.save(categoryProposalPost); + } else if (post instanceof CategoryProposalPost categoryProposalPost) { + post = categoryProposalPostRepository.save(categoryProposalPost); } else if (post instanceof PollPost) { post = pollPostRepository.save((PollPost) post); } else { @@ -406,10 +389,11 @@ public class PostService { ); scheduledFinalizations.put(lp.getId(), future); } else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), - java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(cp.getId(), future); + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(cp.getId(), future); } else if (post instanceof PollPost pp && pp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), @@ -420,47 +404,72 @@ public class PostService { return post; } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) @Transactional public void finalizeProposal(Long postId) { scheduledFinalizations.remove(postId); - categoryProposalPostRepository.findById(postId).ifPresent(cp -> { - if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { - return; - } - int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; - int approveVotes = 0; - if (cp.getVotes() != null) { - approveVotes = cp.getVotes().getOrDefault(0, 0); - } - boolean quorumMet = totalParticipants >= cp.getQuorum(); - int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; - boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); - if (quorumMet && thresholdMet) { - cp.setProposalStatus(CategoryProposalStatus.APPROVED); - } else { - cp.setProposalStatus(CategoryProposalStatus.REJECTED); - String reason; - if (!quorumMet && !thresholdMet) { - reason = "未达到法定人数且赞成率不足"; - } else if (!quorumMet) { - reason = "未达到法定人数"; - } else { - reason = "赞成率不足"; - } - cp.setRejectReason(reason); - } - cp.setResultSnapshot("approveVotes=" + approveVotes + ", totalParticipants=" + totalParticipants + ", approvePercent=" + approvePercent); - categoryProposalPostRepository.save(cp); - if (cp.getAuthor() != null) { - notificationService.createNotification(cp.getAuthor(), NotificationType.POLL_RESULT_OWNER, cp, null, null, null, null, null); - } - for (User participant : cp.getParticipants()) { - notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, cp, null, null, null, null, null); - } - postChangeLogService.recordVoteResult(cp); + categoryProposalPostRepository + .findById(postId) + .ifPresent(cp -> { + if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { + return; + } + int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; + int approveVotes = 0; + if (cp.getVotes() != null) { + approveVotes = cp.getVotes().getOrDefault(0, 0); + } + boolean quorumMet = totalParticipants >= cp.getQuorum(); + int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; + boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); + if (quorumMet && thresholdMet) { + cp.setProposalStatus(CategoryProposalStatus.APPROVED); + } else { + cp.setProposalStatus(CategoryProposalStatus.REJECTED); + String reason; + if (!quorumMet && !thresholdMet) { + reason = "未达到法定人数且赞成率不足"; + } else if (!quorumMet) { + reason = "未达到法定人数"; + } else { + reason = "赞成率不足"; + } + cp.setRejectReason(reason); + } + cp.setResultSnapshot( + "approveVotes=" + + approveVotes + + ", totalParticipants=" + + totalParticipants + + ", approvePercent=" + + approvePercent + ); + categoryProposalPostRepository.save(cp); + if (cp.getAuthor() != null) { + notificationService.createNotification( + cp.getAuthor(), + NotificationType.POLL_RESULT_OWNER, + cp, + null, + null, + null, + null, + null + ); + } + for (User participant : cp.getParticipants()) { + notificationService.createNotification( + participant, + NotificationType.POLL_RESULT_PARTICIPANT, + cp, + null, + null, + null, + null, + null + ); + } + postChangeLogService.recordVoteResult(cp); }); } diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 12180eecc..6a5cdfdc5 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -76,6 +76,15 @@ class PostControllerTest { @MockBean private MedalService medalService; + @MockBean + private CategoryService categoryService; + + @MockBean + private TagService tagService; + + @MockBean + private PointService pointService; + @MockBean private com.openisle.repository.PollVoteRepository pollVoteRepository; @@ -275,6 +284,7 @@ class PostControllerTest { any(), any(), any(), + any(), any() ); } diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index b65fb13f2..a7bbe101e 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x} # Web push configuration app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:} app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:} +app.snippet-length=${SNIPPET_LENGTH:200} From 8ed11df99c6589f344556feb62f4b0c2b05568b4 Mon Sep 17 00:00:00 2001 From: "XueMian (ICT.RUN)" <38741078+xuemian168@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:10:57 +1000 Subject: [PATCH 03/13] Enhance security policy with detailed guidelines Expanded the security policy to include detailed reporting procedures, security considerations, and best practices for contributors. --- SECURITY.md | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..bfeab3158 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,176 @@ +# Security Policy + +## Supported Versions + +We take the security of OpenIsle seriously. The following versions are currently being supported with security updates: + +| Version | Supported | +| ------- | ------------------ | +| 0.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions. + +### How to Report a Security Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via one of the following methods: + +1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information) +2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new + +### What to Include in Your Report + +To help us better understand the nature and scope of the issue, please include as much of the following information as possible: + +- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit it + +### Response Timeline + +- **Initial Response**: We will acknowledge your report within 48 hours +- **Status Updates**: We will provide status updates at least every 5 business days +- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure + +### What to Expect + +After you submit a report: + +1. We will confirm receipt of your vulnerability report and may ask for additional information +2. We will investigate the issue and determine its impact and severity +3. We will work on a fix and coordinate disclosure timing with you +4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous) + +## Security Considerations for Deployment + +### Authentication & Authorization + +- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits) +- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control +- **Session Management**: Configure appropriate session timeout values + +### Database Security + +- Use strong database passwords +- Never expose database ports publicly +- Use database connection encryption when available +- Regularly backup your database + +### API Security + +- Enable rate limiting to prevent abuse +- Validate all user inputs on both client and server side +- Use HTTPS in production environments +- Configure CORS properly to restrict origins + +### Environment Variables + +The following sensitive environment variables should be kept secure: + +- `JWT_SECRET` - JWT signing key +- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials +- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials +- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials +- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials +- `WEBPUSH_PRIVATE_KEY` - Web push notification private key +- Database connection strings and credentials +- Cloud storage credentials (Tencent COS) + +**Never commit these values to version control or expose them in logs.** + +### File Upload Security + +- Validate file types and sizes +- Scan uploaded files for malware +- Store uploaded files outside the web root +- Use cloud storage with proper access controls + +### Password Security + +- Configure password strength requirements via environment variables +- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security) +- Implement account lockout after failed login attempts + +### Web Push Notifications + +- Keep `WEBPUSH_PRIVATE_KEY` secret and secure +- Only send notifications to users who have explicitly opted in +- Validate notification payloads + +### Dependency Management + +- Regularly update dependencies to patch known vulnerabilities +- Run `mvn dependency-check:check` to scan for vulnerable dependencies +- Monitor GitHub security advisories for this project + +### Production Deployment Checklist + +- [ ] Use HTTPS/TLS for all connections +- [ ] Set strong, unique secrets for all environment variables +- [ ] Enable CSRF protection +- [ ] Configure secure headers (CSP, X-Frame-Options, etc.) +- [ ] Disable debug mode and verbose error messages +- [ ] Set up proper logging and monitoring +- [ ] Implement rate limiting and DDoS protection +- [ ] Regular security updates and patches +- [ ] Database backups and disaster recovery plan +- [ ] Restrict admin access to trusted IPs when possible + +## Known Security Features + +OpenIsle includes the following security features: + +- JWT-based authentication with configurable expiration +- OAuth 2.0 integration with major providers +- Password strength validation +- Protection codes for sensitive operations +- Input validation and sanitization +- SQL injection prevention through ORM (JPA/Hibernate) +- XSS protection in Vue.js templates +- CSRF protection (Spring Security) + +## Security Best Practices for Contributors + +- Never commit credentials, API keys, or secrets +- Follow secure coding practices (OWASP Top 10) +- Validate and sanitize all user inputs +- Use parameterized queries for database operations +- Implement proper error handling without exposing sensitive information +- Write security tests for new features +- Review code for security issues before submitting PRs + +## Disclosure Policy + +When we receive a security bug report, we will: + +1. Confirm the problem and determine affected versions +2. Audit code to find any similar problems +3. Prepare fixes for all supported versions +4. Release patches as soon as possible + +We appreciate your help in keeping OpenIsle and its users safe! + +## Attribution + +We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in: + +- Security advisory +- Release notes +- A security hall of fame (if established) + +If you prefer to remain anonymous, we will respect your wishes. + +## Contact + +For any security-related questions or concerns, please reach out through the channels mentioned above. + +--- + +Thank you for helping keep OpenIsle secure! From 23d8eafc08178c98f1da52aaba75bf48a3d44053 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Oct 2025 20:15:34 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=B8=BAicon-park?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/components/PollForm.vue | 6 +----- frontend_nuxt/components/ProposalForm.vue | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend_nuxt/components/PollForm.vue b/frontend_nuxt/components/PollForm.vue index 320d7693f..dc213b04c 100644 --- a/frontend_nuxt/components/PollForm.vue +++ b/frontend_nuxt/components/PollForm.vue @@ -4,11 +4,7 @@ 投票选项
- +
添加选项
diff --git a/frontend_nuxt/components/ProposalForm.vue b/frontend_nuxt/components/ProposalForm.vue index 382b70a5e..559d21bc3 100644 --- a/frontend_nuxt/components/ProposalForm.vue +++ b/frontend_nuxt/components/ProposalForm.vue @@ -38,11 +38,7 @@ 投票选项
- +
添加选项
From 67efb64ccc85929f952dd0bf8c5389c0883d2667 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Oct 2025 20:33:24 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=E5=88=86=E7=B1=BB=E6=8F=90?= =?UTF-8?q?=E6=A1=88=E7=AE=80=E5=8C=96=E7=94=A8=E6=88=B7=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openisle/controller/PostController.java | 5 +- .../java/com/openisle/dto/PostRequest.java | 3 - .../openisle/model/CategoryProposalPost.java | 60 +++++------ .../CategoryProposalPostRepository.java | 19 ++-- .../com/openisle/service/PostService.java | 47 +++----- ..._proposed_slug_from_category_proposals.sql | 8 ++ frontend_nuxt/components/ProposalForm.vue | 93 +--------------- frontend_nuxt/pages/new-post.vue | 100 ++++++------------ 8 files changed, 96 insertions(+), 239 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V7__remove_proposed_slug_from_category_proposals.sql diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 8fb6579a9..985c9a448 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -75,10 +75,7 @@ public class PostController { req.getOptions(), req.getMultiple(), req.getProposedName(), - req.getProposedSlug(), - req.getProposalDescription(), - req.getApproveThreshold(), - req.getQuorum() + req.getProposalDescription() ); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 3a7dad5f8..6dce08d18 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -31,8 +31,5 @@ public class PostRequest { // fields for category proposal posts private String proposedName; - private String proposedSlug; private String proposalDescription; - private Integer approveThreshold; - private Integer quorum; } diff --git a/backend/src/main/java/com/openisle/model/CategoryProposalPost.java b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java index dde9dfddf..4410f08c5 100644 --- a/backend/src/main/java/com/openisle/model/CategoryProposalPost.java +++ b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java @@ -7,59 +7,53 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Index; import jakarta.persistence.PrimaryKeyJoinColumn; import jakarta.persistence.Table; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; - /** * A specialized post type used for proposing new categories. * It reuses poll mechanics (participants, votes, endTime) by extending PollPost. */ @Entity -@Table(name = "category_proposal_posts", indexes = { - @Index(name = "idx_category_proposal_posts_status", columnList = "status"), - @Index(name = "idx_category_proposal_posts_slug", columnList = "proposed_slug", unique = true) -}) +@Table( + name = "category_proposal_posts", + indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") } +) @Getter @Setter @NoArgsConstructor @PrimaryKeyJoinColumn(name = "post_id") public class CategoryProposalPost extends PollPost { - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING; - @Column(name = "proposed_name", nullable = false) - private String proposedName; + @Column(name = "proposed_name", nullable = false, unique = true) + private String proposedName; - @Column(name = "proposed_slug", nullable = false, unique = true) - private String proposedSlug; + @Column(name = "description") + private String description; - @Column(name = "description") - private String description; + // Approval threshold as percentage (0-100), default 60 + @Column(name = "approve_threshold", nullable = false) + private int approveThreshold = 60; - // Approval threshold as percentage (0-100), default 60 - @Column(name = "approve_threshold", nullable = false) - private int approveThreshold = 60; + // Minimum number of participants required to meet quorum + @Column(name = "quorum", nullable = false) + private int quorum = 10; - // Minimum number of participants required to meet quorum - @Column(name = "quorum", nullable = false) - private int quorum = 10; + // Optional voting start time (end time inherited from PollPost) + @Column(name = "start_at") + private LocalDateTime startAt; - // Optional voting start time (end time inherited from PollPost) - @Column(name = "start_at") - private LocalDateTime startAt; + // Snapshot of poll results at finalization (e.g., JSON) + @Column(name = "result_snapshot", columnDefinition = "TEXT") + private String resultSnapshot; - // Snapshot of poll results at finalization (e.g., JSON) - @Column(name = "result_snapshot", columnDefinition = "TEXT") - private String resultSnapshot; - - // Reason when proposal is rejected - @Column(name = "reject_reason") - private String rejectReason; + // Reason when proposal is rejected + @Column(name = "reject_reason") + private String rejectReason; } - - diff --git a/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java index a403e44b4..78801366f 100644 --- a/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java +++ b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java @@ -2,17 +2,18 @@ package com.openisle.repository; import com.openisle.model.CategoryProposalPost; import com.openisle.model.CategoryProposalStatus; - -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface CategoryProposalPostRepository extends JpaRepository { - List findByEndTimeAfterAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); - List findByEndTimeBeforeAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); - boolean existsByProposedSlug(String proposedSlug); + List findByEndTimeAfterAndProposalStatus( + LocalDateTime now, + CategoryProposalStatus status + ); + List findByEndTimeBeforeAndProposalStatus( + LocalDateTime now, + CategoryProposalStatus status + ); + boolean existsByProposedNameIgnoreCase(String proposedName); } - - - diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 16c15d8de..a13ee00ed 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -75,6 +75,11 @@ public class PostService { private final SearchIndexEventPublisher searchIndexEventPublisher; + private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60; + private static final int DEFAULT_PROPOSAL_QUORUM = 10; + private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3; + private static final List DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对"); + @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -253,10 +258,7 @@ public class PostService { java.util.List options, Boolean multiple, String proposedName, - String proposedSlug, - String proposalDescription, - Integer approveThreshold, - Integer quorum + String proposalDescription ) { // 限制访问次数 boolean limitResult = postRateLimit(username); @@ -307,35 +309,18 @@ public class PostService { if (proposedName == null || proposedName.isBlank()) { throw new IllegalArgumentException("Proposed name required"); } - if (proposedSlug == null || proposedSlug.isBlank()) { - throw new IllegalArgumentException("Proposed slug required"); + String normalizedName = proposedName.trim(); + if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) { + throw new IllegalArgumentException("Proposed name already exists: " + normalizedName); } - if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { - throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); - } - cp.setProposedName(proposedName); - cp.setProposedSlug(proposedSlug); + cp.setProposedName(normalizedName); cp.setDescription(proposalDescription); - if (approveThreshold != null) { - if (approveThreshold < 0 || approveThreshold > 100) { - throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); - } - cp.setApproveThreshold(approveThreshold); - } - if (quorum != null) { - if (quorum < 0) { - throw new IllegalArgumentException("quorum must be >= 0"); - } - cp.setQuorum(quorum); - } - cp.setStartAt(startTime); - cp.setEndTime(endTime); - // default yes/no options if not provided - if (options == null || options.size() < 2) { - cp.setOptions(List.of("同意", "反对")); - } else { - cp.setOptions(options); - } + cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD); + cp.setQuorum(DEFAULT_PROPOSAL_QUORUM); + LocalDateTime now = LocalDateTime.now(); + cp.setStartAt(now); + cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS)); + cp.setOptions(DEFAULT_PROPOSAL_OPTIONS); cp.setMultiple(false); post = cp; } else { diff --git a/backend/src/main/resources/db/migration/V7__remove_proposed_slug_from_category_proposals.sql b/backend/src/main/resources/db/migration/V7__remove_proposed_slug_from_category_proposals.sql new file mode 100644 index 000000000..2bfd48735 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__remove_proposed_slug_from_category_proposals.sql @@ -0,0 +1,8 @@ +ALTER TABLE category_proposal_posts + DROP INDEX idx_category_proposal_posts_slug; + +ALTER TABLE category_proposal_posts + DROP COLUMN proposed_slug; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name + ON category_proposal_posts (proposed_name); diff --git a/frontend_nuxt/components/ProposalForm.vue b/frontend_nuxt/components/ProposalForm.vue index 559d21bc3..109cea040 100644 --- a/frontend_nuxt/components/ProposalForm.vue +++ b/frontend_nuxt/components/ProposalForm.vue @@ -4,67 +4,19 @@ 拟议分类名称 -
- 拟议分类 Slug - -
提案描述
-
-
- 通过阈值(%) - -
-
- 法定最小参与数 - -
-
-
- 投票结束时间 - - - -
-
- 投票选项 -
- - -
-
添加选项
-
diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 1128c0991..5d03f4dad 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -80,12 +80,7 @@ const poll = reactive({ }) const proposal = reactive({ proposedName: '', - proposedSlug: '', proposalDescription: '', - approveThreshold: 60, - quorum: 10, - endTime: null, - options: ['同意', '反对'], }) const startTime = ref(null) const isWaitingPosting = ref(false) @@ -135,12 +130,7 @@ const clearPost = async () => { poll.endTime = null poll.multiple = false proposal.proposedName = '' - proposal.proposedSlug = '' proposal.proposalDescription = '' - proposal.approveThreshold = 60 - proposal.quorum = 10 - proposal.endTime = null - proposal.options = ['同意', '反对'] // 删除草稿 const token = getToken() @@ -306,26 +296,6 @@ const submitPost = async () => { toast.error('请填写拟议分类名称') return } - if (!proposal.proposedSlug.trim()) { - toast.error('请填写拟议分类 Slug') - return - } - if (proposal.approveThreshold < 0 || proposal.approveThreshold > 100) { - toast.error('通过阈值需在0到100之间') - return - } - if (proposal.quorum < 0) { - toast.error('最小参与数需大于或等于0') - return - } - if (proposal.options.length < 2 || proposal.options.some((o) => !o.trim())) { - toast.error('请填写至少两个投票选项') - return - } - if (!proposal.endTime) { - toast.error('请选择投票结束时间') - return - } } try { const token = getToken() @@ -347,51 +317,43 @@ const submitPost = async () => { } prizeIconUrl = uploadData.data.url } + const toUtcString = (value) => { + if (!value) return undefined + return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString() + } + + const payload = { + title: title.value, + content: content.value, + categoryId: selectedCategory.value, + tagIds: selectedTags.value, + type: postType.value, + } + + if (postType.value === 'LOTTERY') { + payload.prizeIcon = prizeIconUrl + payload.prizeName = lottery.prizeName + payload.prizeCount = lottery.prizeCount + payload.prizeDescription = lottery.prizeDescription + payload.pointCost = lottery.pointCost + payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined + payload.endTime = toUtcString(lottery.endTime) + } else if (postType.value === 'POLL') { + payload.options = poll.options + payload.multiple = poll.multiple + payload.endTime = toUtcString(poll.endTime) + } else if (postType.value === 'PROPOSAL') { + payload.proposedName = proposal.proposedName + payload.proposalDescription = proposal.proposalDescription + } + const res = await fetch(`${API_BASE_URL}/api/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - title: title.value, - content: content.value, - categoryId: selectedCategory.value, - tagIds: selectedTags.value, - type: postType.value, - prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined, - prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined, - prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined, - prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined, - options: postType.value === 'POLL' ? poll.options : undefined, - multiple: postType.value === 'POLL' ? poll.multiple : undefined, - proposedName: postType.value === 'PROPOSAL' ? proposal.proposedName : undefined, - proposedSlug: postType.value === 'PROPOSAL' ? proposal.proposedSlug : undefined, - proposalDescription: - postType.value === 'PROPOSAL' ? proposal.proposalDescription : undefined, - approveThreshold: postType.value === 'PROPOSAL' ? proposal.approveThreshold : undefined, - quorum: postType.value === 'PROPOSAL' ? proposal.quorum : undefined, - options: - postType.value === 'POLL' - ? poll.options - : postType.value === 'PROPOSAL' - ? proposal.options - : undefined, - startTime: - postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, - pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined, - // 将时间转换为 UTC+8.5 时区 todo: 需要优化 - endTime: - postType.value === 'LOTTERY' - ? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() - : postType.value === 'POLL' - ? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() - : postType.value === 'PROPOSAL' - ? new Date( - new Date(proposal.endTime).getTime() + 8.02 * 60 * 60 * 1000, - ).toISOString() - : undefined, - }), + body: JSON.stringify(payload), }) const data = await res.json() if (res.ok) { From 90649b422df6690453745be3ef03866149463c58 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Oct 2025 20:39:54 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E6=8F=90?= =?UTF-8?q?=E6=A1=88=E8=A7=84=E5=88=99ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/components/ProposalForm.vue | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/frontend_nuxt/components/ProposalForm.vue b/frontend_nuxt/components/ProposalForm.vue index 109cea040..847ec1f68 100644 --- a/frontend_nuxt/components/ProposalForm.vue +++ b/frontend_nuxt/components/ProposalForm.vue @@ -1,5 +1,16 @@