Files
backend/internal/service/vote_service.go
lan 0a0cbacbcc feat(schedule): add course table screens and navigation
Add complete schedule functionality including:
- Schedule screen with weekly course table view
- Course detail screen with transparent modal presentation
- New ScheduleStack navigator integrated into main tab bar
- Schedule service for API interactions
- Type definitions for course entities

Also includes bug fixes for group invite/request handlers
to include required groupId parameter.
2026-03-12 08:38:14 +08:00

313 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
)
// VoteService 投票服务
type VoteService struct {
voteRepo *repository.VoteRepository
postRepo *repository.PostRepository
cache cache.Cache
postAIService *PostAIService
systemMessageService SystemMessageService
}
// NewVoteService 创建投票服务
func NewVoteService(
voteRepo *repository.VoteRepository,
postRepo *repository.PostRepository,
cache cache.Cache,
postAIService *PostAIService,
systemMessageService SystemMessageService,
) *VoteService {
return &VoteService{
voteRepo: voteRepo,
postRepo: postRepo,
cache: cache,
postAIService: postAIService,
systemMessageService: systemMessageService,
}
}
// CreateVotePost 创建投票帖子
func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dto.CreateVotePostRequest) (*dto.PostResponse, error) {
// 验证投票选项数量
if len(req.VoteOptions) < 2 {
return nil, errors.New("投票选项至少需要2个")
}
if len(req.VoteOptions) > 10 {
return nil, errors.New("投票选项最多10个")
}
// 创建普通帖子设置IsVote=true
post := &model.Post{
UserID: userID,
CommunityID: req.CommunityID,
Title: req.Title,
Content: req.Content,
Status: model.PostStatusPending,
IsVote: true,
}
err := s.postRepo.Create(post, req.Images)
if err != nil {
return nil, err
}
// 创建投票选项
err = s.voteRepo.CreateOptions(post.ID, req.VoteOptions)
if err != nil {
return nil, err
}
// 异步审核
go s.reviewVotePostAsync(post.ID, userID, req.Title, req.Content, req.Images)
// 重新查询以获取关联的User和Images
createdPost, err := s.postRepo.GetByID(post.ID)
if err != nil {
return nil, err
}
// 转换为响应DTO
return s.convertToPostResponse(createdPost, userID), nil
}
func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string, images []string) {
defer func() {
if r := recover(); r != nil {
log.Printf("[ERROR] Panic in vote post moderation async flow, fallback publish post=%s panic=%v", postID, r)
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post %s after panic recovery: %v", postID, err)
}
}
}()
if s.postAIService == nil || !s.postAIService.IsEnabled() {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post without AI moderation: %v", err)
}
return
}
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
if err != nil {
var rejectedErr *PostModerationRejectedError
if errors.As(err, &rejectedErr) {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
}
s.notifyModerationRejected(userID, rejectedErr.Reason)
return
}
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
log.Printf("[WARN] Failed to publish vote post %s after moderation error: %v", postID, updateErr)
}
return
}
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
log.Printf("[WARN] Failed to publish vote post %s: %v", postID, err)
}
}
func (s *VoteService) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
const maxAttempts = 3
const retryDelay = 200 * time.Millisecond
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := s.postRepo.UpdateModerationStatus(postID, status, rejectReason, reviewedBy); err != nil {
lastErr = err
if attempt < maxAttempts {
log.Printf("[WARN] UpdateModerationStatus for vote post failed post=%s attempt=%d/%d err=%v", postID, attempt, maxAttempts, err)
time.Sleep(time.Duration(attempt) * retryDelay)
continue
}
} else {
return nil
}
}
return lastErr
}
func (s *VoteService) notifyModerationRejected(userID, reason string) {
if s.systemMessageService == nil || strings.TrimSpace(userID) == "" {
return
}
content := "您发布的投票帖未通过AI审核请修改后重试。"
if strings.TrimSpace(reason) != "" {
content = fmt.Sprintf("您发布的投票帖未通过AI审核原因%s。请修改后重试。", reason)
}
go func() {
_ = s.systemMessageService.SendSystemAnnouncement(
context.Background(),
[]string{userID},
"投票帖审核未通过",
content,
)
}()
}
// GetVoteOptions 获取投票选项
func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) {
options, err := s.voteRepo.GetOptionsByPostID(postID)
if err != nil {
return nil, err
}
result := make([]dto.VoteOptionDTO, 0, len(options))
for _, option := range options {
result = append(result, dto.VoteOptionDTO{
ID: option.ID,
Content: option.Content,
VotesCount: option.VotesCount,
})
}
return result, nil
}
// GetVoteResult 获取投票结果(包含用户投票状态)
func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO, error) {
// 获取所有投票选项
options, err := s.voteRepo.GetOptionsByPostID(postID)
if err != nil {
return nil, err
}
// 获取用户的投票记录
userVote, err := s.voteRepo.GetUserVote(postID, userID)
if err != nil {
return nil, err
}
// 构建结果
result := &dto.VoteResultDTO{
Options: make([]dto.VoteOptionDTO, 0, len(options)),
TotalVotes: 0,
HasVoted: userVote != nil,
}
if userVote != nil {
result.VotedOptionID = userVote.OptionID
}
for _, option := range options {
result.Options = append(result.Options, dto.VoteOptionDTO{
ID: option.ID,
Content: option.Content,
VotesCount: option.VotesCount,
})
result.TotalVotes += option.VotesCount
}
return result, nil
}
// Vote 投票
func (s *VoteService) Vote(ctx context.Context, postID, userID, optionID string) error {
// 调用voteRepo.Vote
err := s.voteRepo.Vote(postID, userID, optionID)
if err != nil {
return err
}
// 失效帖子详情缓存
cache.InvalidatePostDetail(s.cache, postID)
return nil
}
// Unvote 取消投票
func (s *VoteService) Unvote(ctx context.Context, postID, userID string) error {
// 调用voteRepo.Unvote
err := s.voteRepo.Unvote(postID, userID)
if err != nil {
return err
}
// 失效帖子详情缓存
cache.InvalidatePostDetail(s.cache, postID)
return nil
}
// UpdateVoteOption 更新投票选项(作者权限)
func (s *VoteService) UpdateVoteOption(ctx context.Context, postID, optionID, userID, content string) error {
// 获取帖子信息
post, err := s.postRepo.GetByID(postID)
if err != nil {
return err
}
// 验证用户是否为帖子作者
if post.UserID != userID {
return errors.New("只有帖子作者可以更新投票选项")
}
// 调用voteRepo.UpdateOption
return s.voteRepo.UpdateOption(optionID, content)
}
// convertToPostResponse 将Post模型转换为PostResponse DTO
func (s *VoteService) convertToPostResponse(post *model.Post, currentUserID string) *dto.PostResponse {
if post == nil {
return nil
}
response := &dto.PostResponse{
ID: post.ID,
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
LikesCount: post.LikesCount,
CommentsCount: post.CommentsCount,
FavoritesCount: post.FavoritesCount,
SharesCount: post.SharesCount,
ViewsCount: post.ViewsCount,
IsPinned: post.IsPinned,
IsLocked: post.IsLocked,
IsVote: post.IsVote,
CreatedAt: dto.FormatTime(post.CreatedAt),
Images: make([]dto.PostImageResponse, 0, len(post.Images)),
}
// 转换图片
for _, img := range post.Images {
response.Images = append(response.Images, dto.PostImageResponse{
ID: img.ID,
URL: img.URL,
ThumbnailURL: img.ThumbnailURL,
Width: img.Width,
Height: img.Height,
})
}
// 转换作者信息
if post.User != nil {
response.Author = &dto.UserResponse{
ID: post.User.ID,
Username: post.User.Username,
Nickname: post.User.Nickname,
Avatar: post.User.Avatar,
}
}
return response
}