diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0bf059c3..8e0ef3ca5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,6 @@ - [前置工作](#前置工作) - [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境) + - [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide) - [启动后端服务](#启动后端服务) - [本地 IDEA](#本地-idea) - [配置环境变量](#配置环境变量) @@ -39,13 +40,6 @@ cd OpenIsle ``` `.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。 2. 启动 Dev Profile: - ```shell - docker compose \ - -f docker/docker-compose.yaml \ - --env-file .env \ - --profile dev build - ``` - ```shell docker compose \ -f docker/docker-compose.yaml \ @@ -81,6 +75,41 @@ cd OpenIsle 如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。 + + +### 🧭 dev 与 dev_local_backend 巡航指南 + +在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile: + +```bash +docker compose \ + -f docker/docker-compose.yaml \ + --env-file .env \ + --profile dev_local_backend up -d +``` + +> [!TIP] +> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景! + +| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 | +| --- | --- | --- | --- | +| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 | +| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 | + +切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突: + +```bash +docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down +# 或者 +docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down +``` + +常见小贴士: + +- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。 +- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。 +- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。 + ## 启动后端服务 启动后端服务有多种方式,选择一种即可。 @@ -110,6 +139,17 @@ IDEA 打开 `backend/` 文件夹。 LOG_LEVEL=DEBUG ``` +> [!WARNING] +> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`: +> +> ```ini +> MYSQL_HOST=localhost +> REDIS_HOST=localhost +> RABBITMQ_HOST=localhost +> ``` +> +> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`,IDEA 将尝试解析容器网络内的别名而导致连接失败。 + 也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。 ![配置数据库](assets/contributing/backend_img_5.png) diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index 261c45b17..aad682124 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -19,6 +19,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..985c9a448 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -73,7 +73,9 @@ public class PostController { req.getStartTime(), req.getEndTime(), req.getOptions(), - req.getMultiple() + req.getMultiple(), + req.getProposedName(), + 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 0419804ea..6dce08d18 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -28,4 +28,8 @@ public class PostRequest { // fields for poll posts private List options; private Boolean multiple; + + // fields for category proposal posts + private String proposedName; + private String proposalDescription; } diff --git a/backend/src/main/java/com/openisle/dto/ProposalDto.java b/backend/src/main/java/com/openisle/dto/ProposalDto.java new file mode 100644 index 000000000..dea9eca8a --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ProposalDto.java @@ -0,0 +1,20 @@ +package com.openisle.dto; + +import com.openisle.model.CategoryProposalStatus; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ProposalDto extends PollDto { + + private CategoryProposalStatus proposalStatus; + private String proposedName; + private String description; + private int approveThreshold; + private int quorum; + private LocalDateTime startAt; + private String resultSnapshot; + private String rejectReason; +} diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index d09e0e896..11b175c59 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto; import com.openisle.dto.PollDto; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostSummaryDto; +import com.openisle.dto.ProposalDto; import com.openisle.dto.ReactionDto; +import com.openisle.model.CategoryProposalPost; import com.openisle.model.CommentSort; import com.openisle.model.LotteryPost; import com.openisle.model.PollPost; @@ -113,26 +115,40 @@ public class PostMapper { dto.setLottery(l); } - if (post instanceof PollPost pp) { - PollDto p = new PollDto(); - p.setOptions(pp.getOptions()); - p.setVotes(pp.getVotes()); - p.setEndTime(pp.getEndTime()); - p.setParticipants( - pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) - ); - Map> optionParticipants = pollVoteRepository - .findByPostId(pp.getId()) - .stream() - .collect( - Collectors.groupingBy( - PollVote::getOptionIndex, - Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()) - ) - ); - p.setOptionParticipants(optionParticipants); - p.setMultiple(Boolean.TRUE.equals(pp.getMultiple())); - dto.setPoll(p); + if (post instanceof CategoryProposalPost cp) { + ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto()); + proposalDto.setProposalStatus(cp.getProposalStatus()); + proposalDto.setProposedName(cp.getProposedName()); + proposalDto.setDescription(cp.getDescription()); + proposalDto.setApproveThreshold(cp.getApproveThreshold()); + proposalDto.setQuorum(cp.getQuorum()); + proposalDto.setStartAt(cp.getStartAt()); + proposalDto.setResultSnapshot(cp.getResultSnapshot()); + proposalDto.setRejectReason(cp.getRejectReason()); + dto.setPoll(proposalDto); + } else if (post instanceof PollPost pp) { + dto.setPoll(buildPollDto(pp, new PollDto())); } } + + private PollDto buildPollDto(PollPost pollPost, PollDto target) { + target.setOptions(pollPost.getOptions()); + target.setVotes(pollPost.getVotes()); + target.setEndTime(pollPost.getEndTime()); + target.setParticipants( + pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) + ); + Map> optionParticipants = pollVoteRepository + .findByPostId(pollPost.getId()) + .stream() + .collect( + Collectors.groupingBy( + PollVote::getOptionIndex, + Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()) + ) + ); + target.setOptionParticipants(optionParticipants); + target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple())); + return target; + } } 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..4410f08c5 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java @@ -0,0 +1,59 @@ +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 java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 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") } +) +@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, unique = true) + private String proposedName; + + @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/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 74262c9b2..6bf7c0224 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -46,6 +46,10 @@ public enum NotificationType { POLL_RESULT_OWNER, /** A poll you participated in has concluded */ POLL_RESULT_PARTICIPANT, + /** Your category proposal has concluded */ + CATEGORY_PROPOSAL_RESULT_OWNER, + /** A category proposal you participated in has concluded */ + CATEGORY_PROPOSAL_RESULT_PARTICIPANT, /** Your post was featured */ POST_FEATURED, /** Someone donated to your post */ 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..78801366f --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java @@ -0,0 +1,19 @@ +package com.openisle.repository; + +import com.openisle.model.CategoryProposalPost; +import com.openisle.model.CategoryProposalStatus; +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 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 c28566609..7457c947c 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -2,8 +2,8 @@ package com.openisle.service; import com.openisle.config.CachingConfig; import com.openisle.exception.RateLimitException; -import com.openisle.mapper.PostMapper; import com.openisle.model.*; +import com.openisle.repository.CategoryProposalPostRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.CommentRepository; import com.openisle.repository.LotteryPostRepository; @@ -21,7 +21,6 @@ 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; @@ -32,7 +31,6 @@ 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; @@ -54,6 +52,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; @@ -71,11 +70,17 @@ public class PostService { private final PointService pointService; private final PostChangeLogService postChangeLogService; private final PointHistoryRepository pointHistoryRepository; + private final CategoryService categoryService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); 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; @@ -89,6 +94,7 @@ public class PostService { TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, PollPostRepository pollPostRepository, + CategoryProposalPostRepository categoryProposalPostRepository, PollVoteRepository pollVoteRepository, NotificationService notificationService, SubscriptionService subscriptionService, @@ -107,7 +113,8 @@ public class PostService { PointHistoryRepository pointHistoryRepository, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, RedisTemplate redisTemplate, - SearchIndexEventPublisher searchIndexEventPublisher + SearchIndexEventPublisher searchIndexEventPublisher, + CategoryService categoryService ) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -115,6 +122,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; @@ -135,6 +143,7 @@ public class PostService { this.redisTemplate = redisTemplate; this.searchIndexEventPublisher = searchIndexEventPublisher; + this.categoryService = categoryService; } @EventListener(ApplicationReadyEvent.class) @@ -160,6 +169,24 @@ 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() { @@ -232,10 +259,12 @@ public class PostService { LocalDateTime startTime, LocalDateTime endTime, java.util.List options, - Boolean multiple + Boolean multiple, + String proposedName, + String proposalDescription ) { // 限制访问次数 - boolean limitResult = postRateLimit(username); + boolean limitResult = isPostLimitReached(username); if (!limitResult) { throw new RateLimitException("Too many posts"); } @@ -278,6 +307,25 @@ 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"); + } + String normalizedName = proposedName.trim(); + if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) { + throw new IllegalArgumentException("Proposed name already exists: " + normalizedName); + } + cp.setProposedName(normalizedName); + cp.setDescription(proposalDescription); + 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(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS)); + cp.setMultiple(false); + post = cp; } else { post = new Post(); } @@ -290,6 +338,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 { @@ -344,6 +394,12 @@ 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()), @@ -354,24 +410,110 @@ public class PostService { if (post.getStatus() == PostStatus.PUBLISHED) { searchIndexEventPublisher.publishPostSaved(post); } + markPostLimit(author.getUsername()); 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(); + boolean approved = false; + String rejectReason = null; + if (quorumMet && thresholdMet) { + cp.setProposalStatus(CategoryProposalStatus.APPROVED); + approved = true; + } else { + cp.setProposalStatus(CategoryProposalStatus.REJECTED); + String reason; + if (!quorumMet && !thresholdMet) { + reason = "未达到法定人数且赞成率不足"; + } else if (!quorumMet) { + reason = "未达到法定人数"; + } else { + reason = "赞成率不足"; + } + cp.setRejectReason(reason); + rejectReason = reason; + } + cp.setResultSnapshot( + "approveVotes=" + + approveVotes + + ", totalParticipants=" + + totalParticipants + + ", approvePercent=" + + approvePercent + ); + categoryProposalPostRepository.save(cp); + if (approved) { + categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null); + } + if (cp.getAuthor() != null) { + notificationService.createNotification( + cp.getAuthor(), + NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER, + cp, + null, + approved, + null, + null, + approved ? null : rejectReason + ); + } + for (User participant : cp.getParticipants()) { + if ( + cp.getAuthor() != null && + java.util.Objects.equals(participant.getId(), cp.getAuthor().getId()) + ) { + continue; + } + notificationService.createNotification( + participant, + NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT, + cp, + null, + approved, + null, + null, + approved ? null : rejectReason + ); + } + postChangeLogService.recordVoteResult(cp); + }); + } + /** - * 限制发帖频率 + * 检查用户是否达到发帖限制 * @param username - * @return + * @return true - 允许发帖,false - 已达限制 */ - private boolean postRateLimit(String username) { + private boolean isPostLimitReached(String username) { String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username; String result = (String) redisTemplate.opsForValue().get(key); - //最近没有创建过文章 - if (StringUtils.isEmpty(result)) { - // 限制频率为5分钟 - redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5)); - return true; - } - return false; + return StringUtils.isEmpty(result); + } + + /** + * 标记用户发帖,触发limit计时 + * @param username + */ + private void markPostLimit(String username) { + String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username; + redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5)); } @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) @@ -450,6 +592,9 @@ public class PostService { pollPostRepository .findById(postId) .ifPresent(pp -> { + if (pp instanceof CategoryProposalPost) { + return; + } if (pp.isResultAnnounced()) { return; } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bf8e62ea8..ee734cd29 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/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/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 3bf386ff4..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; @@ -117,6 +126,11 @@ class PostControllerTest { isNull(), isNull(), isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), isNull() ) ).thenReturn(post); @@ -266,6 +280,11 @@ class PostControllerTest { any(), 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 e57e8579b..357fc4871 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -26,6 +26,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); @@ -52,6 +53,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -104,6 +106,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); @@ -130,6 +133,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -195,6 +199,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); @@ -221,6 +226,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -260,6 +266,11 @@ class PostServiceTest { null, null, null, + null, + null, + null, + null, + null, null ) ); @@ -273,6 +284,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); @@ -299,6 +311,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -367,6 +380,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); @@ -393,6 +407,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, 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} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 56afacd53..6fd60458a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -25,6 +25,10 @@ services: timeout: 3s retries: 30 start_period: 20s + profiles: + - dev + - dev_local_backend + - prod # OpenSearch Service opensearch: @@ -61,6 +65,9 @@ services: start_period: 60s networks: - openisle-network + profiles: + - dev + - dev_local_backend dashboards: image: opensearchproject/opensearch-dashboards:3.0.0 @@ -75,6 +82,10 @@ services: restart: unless-stopped networks: - openisle-network + profiles: + - dev + - dev_local_backend + - prod rabbitmq: image: rabbitmq:3.13-management @@ -98,6 +109,10 @@ services: start_period: 30s networks: - openisle-network + profiles: + - dev + - dev_local_backend + - prod redis: image: redis:7 @@ -111,6 +126,10 @@ services: - redis-data:/data networks: - openisle-network + profiles: + - dev + - dev_local_backend + - prod # Java spring boot service (开发便捷镜像,后续可换成打包镜像) springboot: @@ -155,6 +174,9 @@ services: start_period: 60s networks: - openisle-network + profiles: + - dev + - prod websocket-service: image: maven:3.9-eclipse-temurin-17 @@ -186,6 +208,10 @@ services: start_period: 60s networks: - openisle-network + profiles: + - dev + - dev_local_backend + - prod frontend_dev: image: node:20 @@ -208,6 +234,28 @@ services: - openisle-network profiles: - dev + + frontend_dev_local_backend: + image: node:20 + container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend + working_dir: /app + env_file: + - ${ENV_FILE:-../.env} + command: sh -c "npm install && npm run dev" + volumes: + - ../frontend_nuxt:/app + - frontend-node-modules:/app/node_modules + ports: + - "${FRONTEND_PORT:-3000}:3000" + depends_on: + websocket-service: + condition: service_healthy + networks: + - openisle-network + profiles: + - dev_local_backend + extra_hosts: + - "host.docker.internal:host-gateway" frontend_service: build: @@ -226,13 +274,13 @@ services: websocket-service: condition: service_healthy restart: unless-stopped - profiles: ["staging", "prod"] - + profiles: + - prod + # 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080 loopback_8080: image: alpine/socat container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080 - # 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080 command: - -d - -d @@ -243,13 +291,37 @@ services: springboot: condition: service_healthy network_mode: "service:frontend_dev" - profiles: ["dev"] healthcheck: test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"] interval: 5s timeout: 3s retries: 20 start_period: 10s + profiles: + - dev + + # 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080 + loopback_8080_host: + image: alpine/socat + container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host + command: + - -d + - -d + - -ly + - TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork + - TCP4:host.docker.internal:8080 + network_mode: "service:frontend_dev_local_backend" + depends_on: + frontend_dev_local_backend: + condition: service_started + healthcheck: + test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + profiles: + - dev_local_backend loopback_8082: image: alpine/socat @@ -265,13 +337,37 @@ services: websocket-service: condition: service_healthy network_mode: "service:frontend_dev" - profiles: ["dev"] healthcheck: test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"] interval: 5s timeout: 3s retries: 20 start_period: 10s + profiles: + - dev + + loopback_8082_host: + image: alpine/socat + container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host + # 监听 127.0.0.1:8082 → 转发到 websocket-service:8082(WS 纯 TCP 可直接过) + command: + - -d + - -d + - -ly + - TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork + - TCP4:websocket-service:8082 + depends_on: + websocket-service: + condition: service_healthy + network_mode: "service:frontend_dev_local_backend" + healthcheck: + test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + profiles: + - dev_local_backend networks: openisle-network: 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/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue index fced95271..ac95cdaa2 100644 --- a/frontend_nuxt/components/PostPoll.vue +++ b/frontend_nuxt/components/PostPoll.vue @@ -2,6 +2,30 @@
+
+
+
多选
+
+ 拟议分类:{{ poll.proposedName }} + + + +
+
单选
+
+ +
离结束
+
{{ countdown }}
+
+
+
+
{{ poll.description }}
+
+
@@ -29,16 +53,6 @@
-
-
多选
-
单选
- -
- -
离结束
-
{{ countdown }}
-
-
@@ -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,10 @@ const poll = reactive({ endTime: null, multiple: false, }) +const proposal = reactive({ + proposedName: '', + proposalDescription: '', +}) const startTime = ref(null) const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -123,6 +129,8 @@ const clearPost = async () => { poll.options = ['', ''] poll.endTime = null poll.multiple = false + proposal.proposedName = '' + proposal.proposalDescription = '' // 删除草稿 const token = getToken() @@ -283,6 +291,12 @@ const submitPost = async () => { return } } + if (postType.value === 'PROPOSAL') { + if (!proposal.proposedName.trim()) { + toast.error('请填写拟议分类名称') + return + } + } try { const token = getToken() await ensureTags(token) @@ -303,35 +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, - 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() - : undefined, - }), + body: JSON.stringify(payload), }) const data = await res.json() if (res.ok) { diff --git a/frontend_nuxt/plugins/iconpark.client.ts b/frontend_nuxt/plugins/iconpark.client.ts index 58338d98a..ee11c64f5 100644 --- a/frontend_nuxt/plugins/iconpark.client.ts +++ b/frontend_nuxt/plugins/iconpark.client.ts @@ -81,6 +81,7 @@ import { CheckOne, Share, Financing, + Hands, } from '@icon-park/vue-next' export default defineNuxtPlugin((nuxtApp) => { @@ -165,4 +166,5 @@ export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.component('CheckOne', CheckOne) nuxtApp.vueApp.component('Share', Share) nuxtApp.vueApp.component('Financing', Financing) + nuxtApp.vueApp.component('Hands', Hands) }) diff --git a/frontend_nuxt/utils/notification.js b/frontend_nuxt/utils/notification.js index 9d71e2a54..c13f1d9cd 100644 --- a/frontend_nuxt/utils/notification.js +++ b/frontend_nuxt/utils/notification.js @@ -28,6 +28,8 @@ const iconMap = { POLL_VOTE: 'ChartHistogram', POLL_RESULT_OWNER: 'RankingList', POLL_RESULT_PARTICIPANT: 'ChartLine', + CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne', + CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne', MENTION: 'HashtagKey', POST_DELETED: 'ClearIcon', POST_FEATURED: 'Star', @@ -254,7 +256,9 @@ function createFetchNotifications() { } else if ( n.type === 'POLL_VOTE' || n.type === 'POLL_RESULT_OWNER' || - n.type === 'POLL_RESULT_PARTICIPANT' + n.type === 'POLL_RESULT_PARTICIPANT' || + n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' || + n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT' ) { arr.push({ ...n,