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