diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 9375c79..2fac6e2 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -24,6 +24,8 @@ func main() { service.ConfigureCookieNames(!cfg.DevMode) // 过期/已吊销 refresh token 定期清理 service.StartRefreshTokenCleanup(model.DB) + // 悬赏过期退回 / 抽奖到期开奖 + service.StartTypedPostSettler(service.NewPostService(model.DB)) r, err := router.Setup(cfg) if err != nil { diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 67efaa4..9205ffa 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -22,6 +22,8 @@ type Handlers struct { Checkin *service.CheckinService Announcement *service.AnnouncementService Upload *service.UploadService + PostFile *service.PostFileService + Points *service.PointsService Setting *service.SettingService AdminUser *service.AdminUserService Moderation *service.ModerationService diff --git a/backend/handler/post.go b/backend/handler/post.go index 7a7416d..fe49381 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -2,7 +2,10 @@ package handler import ( "errors" + "io" "net/http" + "net/url" + "path/filepath" "strconv" "github.com/freefire/jiang13-bbs/middleware" @@ -43,7 +46,6 @@ func (h *Handlers) Posts(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // 填充点赞状态(仅登录用户) if claims := middleware.CurrentUser(c); claims != nil { ids := make([]uint, 0, len(items)) for _, p := range items { @@ -74,14 +76,13 @@ func (h *Handlers) PostDetail(c *gin.Context) { var loadActor func() *service.Actor if claims != nil { viewerID = claims.ID - loadActor = h.actorLoader(claims.ID) // 懒加载:仅非已发布帖才查 DB + loadActor = h.actorLoader(claims.ID) } post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) return } - // 填充点赞状态(仅登录用户) if claims != nil { post.Liked = h.Like.HasLiked(post.ID, claims.ID) } @@ -90,11 +91,15 @@ func (h *Handlers) PostDetail(c *gin.Context) { // CreatePostRequest 发帖请求 type CreatePostRequest struct { - BoardID uint `json:"board_id" binding:"required"` - Title string `json:"title" binding:"required,min=1,max=256"` - Content string `json:"content" binding:"required,min=1"` - Tags string `json:"tags"` - PostType string `json:"post_type"` + BoardID uint `json:"board_id" binding:"required"` + Title string `json:"title" binding:"required,min=1,max=256"` + Content string `json:"content" binding:"required,min=1"` + Tags string `json:"tags"` + PostType string `json:"post_type"` + ContentAccess string `json:"content_access"` + AccessPoints int `json:"access_points"` + TypeMeta string `json:"type_meta"` + AttachmentIDs []uint `json:"attachment_ids"` } // CreatePost 创建帖子 @@ -105,17 +110,23 @@ func (h *Handlers) CreatePost(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - postType := req.PostType - if postType == "" { - postType = "normal" - } - // 管理团队成员发帖免审直发;普通用户进入待审核队列。 - // 角色变更会强制 JWT 失效(token_version 递增),claims.Role 可视为实时值 status := model.ContentStatusPending if model.IsStaff(model.Role(claims.Role)) { status = model.ContentStatusPublished } - post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType, status) + post, err := h.Post.Create(service.CreatePostInput{ + UserID: claims.ID, + BoardID: req.BoardID, + Title: req.Title, + Content: req.Content, + Tags: req.Tags, + PostType: req.PostType, + ContentAccess: req.ContentAccess, + AccessPoints: req.AccessPoints, + TypeMeta: req.TypeMeta, + Status: status, + AttachmentIDs: req.AttachmentIDs, + }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -130,9 +141,13 @@ func (h *Handlers) CreatePost(c *gin.Context) { // UpdatePostRequest 更新帖子请求 type UpdatePostRequest struct { - Title string `json:"title" binding:"omitempty,min=1,max=256"` - Content string `json:"content" binding:"omitempty,min=1"` - Tags string `json:"tags"` + Title string `json:"title" binding:"omitempty,min=1,max=256"` + Content string `json:"content" binding:"omitempty,min=1"` + Tags string `json:"tags"` + ContentAccess *string `json:"content_access"` + AccessPoints *int `json:"access_points"` + TypeMeta *string `json:"type_meta"` + AttachmentIDs *[]uint `json:"attachment_ids"` } // UpdatePost 编辑帖子 @@ -149,7 +164,15 @@ func (h *Handlers) UpdatePost(c *gin.Context) { return } actor := h.loadActor(claims.ID) - post, err := h.Post.Update(actor, uint(id), claims.ID, req.Title, req.Content, req.Tags) + post, err := h.Post.Update(actor, uint(id), claims.ID, service.UpdatePostInput{ + Title: req.Title, + Content: req.Content, + Tags: req.Tags, + ContentAccess: req.ContentAccess, + AccessPoints: req.AccessPoints, + TypeMeta: req.TypeMeta, + AttachmentIDs: req.AttachmentIDs, + }) if err != nil { respondPostModError(c, err) return @@ -157,6 +180,190 @@ func (h *Handlers) UpdatePost(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"post": post}) } +// UnlockPostContent 积分解锁正文 +func (h *Handlers) UnlockPostContent(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.UnlockContent(claims.ID, uint(id)) + if err != nil { + if errors.Is(err, service.ErrInsufficientPoints) { + c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// GetPointsBalance 当前用户积分余额 +func (h *Handlers) GetPointsBalance(c *gin.Context) { + claims := middleware.CurrentUser(c) + bal, err := h.Points.Balance(claims.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"points": bal}) +} + +// GetPointsLedger 本人积分流水 +func (h *Handlers) GetPointsLedger(c *gin.Context) { + claims := middleware.CurrentUser(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + items, total, err := h.Points.Ledger(claims.ID, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "items": nonNilSlice(items), + "total": total, + "page": page, + "size": size, + }) +} + +// AdminPointsStats 管理端积分与类型帖看板 +func (h *Handlers) AdminPointsStats(c *gin.Context) { + st, err := h.Points.AdminEconomyStats() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, st) +} + +// UploadPostFile 上传帖子附件(草稿态) +func (h *Handlers) UploadPostFile(c *gin.Context) { + claims := middleware.CurrentUser(c) + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"}) + return + } + price, _ := strconv.Atoi(c.DefaultPostForm("price_points", "0")) + f, err := file.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"}) + return + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, service.FileMaxBytes+1)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"}) + return + } + att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "attachment": gin.H{ + "id": att.ID, + "name": att.Name, + "size": att.Size, + "mime": att.MIME, + "price_points": att.PricePoints, + "download_count": att.DownloadCount, + "unlocked": true, + }, + }) +} + +// DeletePostFile 删除本人附件草稿 +func (h *Handlers) DeletePostFile(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"}) + return + } + if err := h.PostFile.DeleteOwn(claims.ID, uint(id)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// UpdatePostFilePrice 更新附件积分定价 +func (h *Handlers) UpdatePostFilePrice(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"}) + return + } + var req struct { + PricePoints int `json:"price_points"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if err := h.PostFile.UpdatePrice(claims.ID, uint(id), req.PricePoints); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// DownloadPostAttachment 下载帖子附件(鉴权 + 积分) +func (h *Handlers) DownloadPostAttachment(c *gin.Context) { + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + attID, err := strconv.ParseUint(c.Param("aid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"}) + return + } + claims := middleware.CurrentUser(c) + var viewerID uint + var loadActor func() *service.Actor + if claims != nil { + viewerID = claims.ID + loadActor = h.actorLoader(claims.ID) + } + if err := h.Post.EnsurePostVisible(uint(postID), viewerID, loadActor); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) + return + } + att, err := h.PostFile.Get(uint(attID)) + if err != nil || att.PostID != uint(postID) { + c.JSON(http.StatusNotFound, gin.H{"error": "附件不存在"}) + return + } + if att.PricePoints > 0 { + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + if err := h.PostFile.EnsureAttachmentUnlocked(claims.ID, att); err != nil { + if errors.Is(err, service.ErrInsufficientPoints) { + c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + path := h.PostFile.FilePath(att) + h.PostFile.IncDownload(att.ID) + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(att.Name)) + c.Header("Content-Type", att.MIME) + c.File(path) + _ = filepath.Base(path) +} + // DeletePost 删除帖子 func (h *Handlers) DeletePost(c *gin.Context) { claims := middleware.CurrentUser(c) @@ -200,12 +407,12 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } - recommended, err := h.Post.ToggleRecommend(uint(id)) + rec, err := h.Post.ToggleRecommend(uint(id)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"recommended": recommended}) + c.JSON(http.StatusOK, gin.H{"recommended": rec}) } // requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false diff --git a/backend/handler/post_interact.go b/backend/handler/post_interact.go new file mode 100644 index 0000000..3533710 --- /dev/null +++ b/backend/handler/post_interact.go @@ -0,0 +1,197 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +func (h *Handlers) respondInteractError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrPostNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrPostForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrInsufficientPoints): + c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrAlreadyVoted), + errors.Is(err, service.ErrPollClosed), + errors.Is(err, service.ErrPollOptionsLocked), + errors.Is(err, service.ErrLotteryDrawn), + errors.Is(err, service.ErrLotteryClosed), + errors.Is(err, service.ErrBountySettled), + errors.Is(err, service.ErrBountyExpired): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } +} + +// VotePoll 投票 +func (h *Handlers) VotePoll(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + var req struct { + Options []int `json:"options"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + post, err := h.Post.VotePoll(claims.ID, uint(id), req.Options) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// ClosePoll 结束投票 +func (h *Handlers) ClosePoll(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.ClosePoll(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// AcceptQuestion 采纳问答答案 +func (h *Handlers) AcceptQuestion(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + var req struct { + CommentID uint `json:"comment_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"}) + return + } + post, err := h.Post.AcceptQuestion(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// SolveQuestion 手动标已解决 +func (h *Handlers) SolveQuestion(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.SolveQuestion(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// ReopenQuestion 重新打开问答 +func (h *Handlers) ReopenQuestion(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.ReopenQuestion(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// AcceptBounty 采纳评论并发放悬赏 +func (h *Handlers) AcceptBounty(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + var req struct { + CommentID uint `json:"comment_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"}) + return + } + post, err := h.Post.AcceptBounty(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// RefundBounty 退回悬赏 +func (h *Handlers) RefundBounty(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// CloseLottery 截止抽奖 +func (h *Handlers) CloseLottery(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} + +// DrawLottery 开奖 +func (h *Handlers) DrawLottery(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + post, err := h.Post.DrawLottery(h.loadActor(claims.ID), claims.ID, uint(id)) + if err != nil { + h.respondInteractError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"post": post}) +} diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 98246b0..6b6cb3e 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -49,6 +49,46 @@ func (h *Handlers) UploadAvatar(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att}) } +// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP) +func (h *Handlers) UploadImage(c *gin.Context) { + claims := middleware.CurrentUser(c) + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ImageMaxBytes+4096) + + fh, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(不能超过 5MB)或格式不正确"}) + return + } + if fh.Size > service.ImageMaxBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"}) + return + } + f, err := fh.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) + return + } + defer f.Close() + + data, err := io.ReadAll(io.LimitReader(f, service.ImageMaxBytes+1)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) + return + } + if len(data) > service.ImageMaxBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"}) + return + } + + att, err := h.Upload.SaveImage(claims.ID, data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att}) +} + // UseAvatarRequest 选用历史头像请求 type UseAvatarRequest struct { URL string `json:"url"` diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index f183a59..ebfac66 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -2,12 +2,13 @@ package middleware import ( "net/http" + "strconv" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) -// RateLimitMiddleware 速率限制中间件 +// RateLimitMiddleware 速率限制中间件(按 IP) func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc { return func(c *gin.Context) { key := rateType + ":" + c.ClientIP() @@ -18,3 +19,20 @@ func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFu c.Next() } } + +// RateLimitUserMiddleware 按登录用户限流(需挂在 RequireAuth 之后) +func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc { + return func(c *gin.Context) { + claims := CurrentUser(c) + id := "anon" + if claims != nil { + id = strconv.FormatUint(uint64(claims.ID), 10) + } + key := rateType + ":u:" + id + if !rl.Allow(key) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"}) + return + } + c.Next() + } +} diff --git a/backend/model/db.go b/backend/model/db.go index 4280bb7..6ac4b4c 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -39,10 +39,23 @@ func InitDB(dsn string) error { &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, + &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, + &PostPollVote{}, &PostLotteryEntry{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } + // 旧 post_type=normal → discussion + if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`, + PostTypeDiscussion, PostTypeNormal).Error; err != nil { + return fmt.Errorf("帖子类型归一失败: %w", err) + } + + // 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户) + if err := backfillPointsFromCheckin(db); err != nil { + return fmt.Errorf("积分余额回填失败: %w", err) + } + // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // 已存在 owner 时不动数据,保证幂等 if err := ensureOwnerRole(db); err != nil { @@ -213,6 +226,46 @@ func ensureOwnerRole(db *gorm.DB) error { return nil } +// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到) +func backfillPointsFromCheckin(db *gorm.DB) error { + type row struct { + UserID uint + Total int + } + var rows []row + if err := db.Raw(` + SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total + FROM checkins c + INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0 + GROUP BY c.user_id + HAVING COALESCE(SUM(c.points), 0) > 0 + `).Scan(&rows).Error; err != nil { + return err + } + for _, r := range rows { + err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID). + Update("points", r.Total).Error; err != nil { + return err + } + return tx.Create(&PointLedger{ + UserID: r.UserID, + Delta: r.Total, + Balance: r.Total, + Reason: PointReasonMigrateCheckin, + Note: "历史签到积分回填", + }).Error + }) + if err != nil { + return err + } + } + if len(rows) > 0 { + log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows)) + } + return nil +} + // seedDefaultBoards 写入默认板块 func seedDefaultBoards(db *gorm.DB) { defaults := []Board{ diff --git a/backend/model/models.go b/backend/model/models.go index 6ba33f4..3e9fac1 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -54,12 +54,64 @@ const ( ContentStatusRejected = "rejected" ) -// 帖子类型 +// 帖子类型(discussion 为默认;兼容旧值 normal → discussion) const ( - PostTypeNormal = "normal" - PostTypeQuestion = "question" + PostTypeDiscussion = "discussion" // 讨论(默认) + PostTypeQuestion = "question" // 问答 + PostTypePoll = "poll" // 投票 + PostTypeBounty = "bounty" // 悬赏 + PostTypeLottery = "lottery" // 抽奖 + PostTypeNormal = "normal" // 旧值,读取时归一为 discussion ) +// ValidPostType 发帖类型白名单 +func ValidPostType(t string) bool { + switch t { + case PostTypeDiscussion, PostTypeQuestion, PostTypePoll, PostTypeBounty, PostTypeLottery: + return true + } + return false +} + +// NormalizePostType 归一化类型(空/旧 normal → discussion) +func NormalizePostType(t string) string { + if t == "" || t == PostTypeNormal { + return PostTypeDiscussion + } + if ValidPostType(t) { + return t + } + return PostTypeDiscussion +} + +// 正文可见性 +const ( + ContentAccessPublic = "public" // 公开 + ContentAccessLogin = "login" // 登录可见 + ContentAccessReply = "reply" // 回复可见 + ContentAccessPoints = "points" // 积分购买可见 +) + +// ValidContentAccess 可见性白名单 +func ValidContentAccess(a string) bool { + switch a { + case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints: + return true + } + return false +} + +// NormalizeContentAccess 归一化可见性 +func NormalizeContentAccess(a string) string { + if a == "" { + return ContentAccessPublic + } + if ValidContentAccess(a) { + return a + } + return ContentAccessPublic +} + // User 用户表 type User struct { ID uint `gorm:"primaryKey" json:"id"` @@ -71,6 +123,7 @@ type User struct { Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名 Role Role `gorm:"size:16;default:user" json:"role"` Banned bool `gorm:"default:false" json:"banned"` + Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) CreatedAt time.Time `json:"created_at"` @@ -111,28 +164,105 @@ type Board struct { // Post 帖子 type Post struct { - ID uint `gorm:"primaryKey" json:"id"` - BoardID uint `gorm:"index;not null" json:"board_id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Title string `gorm:"size:256;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Tags string `gorm:"size:256" json:"tags"` - PostType string `gorm:"size:16;default:normal;index" json:"post_type"` - Pinned int `gorm:"default:0" json:"pinned"` - Recommended bool `gorm:"default:false;index" json:"recommended"` - Status string `gorm:"size:16;default:published;index" json:"status"` - LikeCount int `gorm:"default:0" json:"like_count"` - ViewCount int `gorm:"default:0" json:"view_count"` - CommentCount int `gorm:"default:0" json:"comment_count"` - Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + BoardID uint `gorm:"index;not null" json:"board_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Title string `gorm:"size:256;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Tags string `gorm:"size:256" json:"tags"` + PostType string `gorm:"size:16;default:discussion;index" json:"post_type"` + ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points + AccessPoints int `gorm:"not null;default:0" json:"access_points"` // points 可见时所需积分 + TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) + Pinned int `gorm:"default:0" json:"pinned"` + Recommended bool `gorm:"default:false;index" json:"recommended"` + Status string `gorm:"size:16;default:published;index" json:"status"` + LikeCount int `gorm:"default:0" json:"like_count"` + ViewCount int `gorm:"default:0" json:"view_count"` + CommentCount int `gorm:"default:0" json:"comment_count"` + Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } +// PointLedger 积分流水(余额以 User.Points 为准,本表可审计) +type PointLedger struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Delta int `gorm:"not null" json:"delta"` // 正入负出 + Balance int `gorm:"not null" json:"balance"` // 变动后余额 + Reason string `gorm:"size:32;not null;index" json:"reason"` + RefType string `gorm:"size:32;not null;default:''" json:"ref_type"` + RefID uint `gorm:"not null;default:0" json:"ref_id"` + Note string `gorm:"size:256;not null;default:''" json:"note"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// 积分流水原因 +const ( + PointReasonCheckin = "checkin" + PointReasonUnlockPost = "unlock_post" + PointReasonDownloadFile = "download_file" + PointReasonMigrateCheckin = "migrate_checkin" + PointReasonBountyEscrow = "bounty_escrow" + PointReasonBountyRefund = "bounty_refund" + PointReasonBountyAward = "bounty_award" +) + +// PostPollVote 投票记录(多选时同一用户多行) +type PostPollVote struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"post_id"` + UserID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"user_id"` + OptionIndex int `gorm:"uniqueIndex:idx_poll_vote;not null" json:"option_index"` + CreatedAt time.Time `json:"created_at"` +} + +// PostLotteryEntry 抽奖报名 +type PostLotteryEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"` + UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + +// PostContentUnlock 积分购买正文解锁记录 +type PostContentUnlock struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"post_id"` + UserID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"user_id"` + Points int `gorm:"not null;default:0" json:"points"` + CreatedAt time.Time `json:"created_at"` +} + +// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载) +type PostAttachment struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定 + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:256;not null" json:"name"` // 原始文件名 + StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名 + MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"` + Size int `gorm:"not null;default:0" json:"size"` + PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费 + DownloadCount int `gorm:"not null;default:0" json:"download_count"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// PostAttachmentUnlock 积分附件下载解锁(按附件计费一次) +type PostAttachmentUnlock struct { + ID uint `gorm:"primaryKey" json:"id"` + AttachmentID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"attachment_id"` + UserID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"user_id"` + Points int `gorm:"not null;default:0" json:"points"` + CreatedAt time.Time `json:"created_at"` +} + // Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下) type Comment struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/router/router.go b/backend/router/router.go index 15d6d77..2f66c79 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -49,6 +49,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { checkinSvc := service.NewCheckinService(model.DB) announcementSvc := service.NewAnnouncementService(model.DB) uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")) + postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")) + pointsSvc := service.NewPointsService(model.DB) settingSvc := service.NewSettingService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) moderationSvc := service.NewModerationService(model.DB, notifSvc) @@ -56,6 +58,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { if err := uploadSvc.EnsureDir(); err != nil { return nil, err } + if err := postFileSvc.EnsureDir(); err != nil { + return nil, err + } limiter := service.DefaultRateLimiter() h := &handler.Handlers{ @@ -71,6 +76,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Checkin: checkinSvc, Announcement: announcementSvc, Upload: uploadSvc, + PostFile: postFileSvc, + Points: pointsSvc, Setting: settingSvc, AdminUser: adminUserSvc, Moderation: moderationSvc, @@ -107,6 +114,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/overview", h.Overview) pubAPI.GET("/posts", h.Posts) pubAPI.GET("/posts/:id", h.PostDetail) + pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment) pubAPI.GET("/posts/:id/comments", h.PostComments) pubAPI.GET("/users/:id", h.UserProfile) pubAPI.GET("/users/:id/comments", h.UserComments) @@ -128,6 +136,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/profile", h.UpdateProfile) api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost) api.PUT("/posts/:id", h.UpdatePost) + api.POST("/posts/:id/unlock", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.UnlockPostContent) + api.POST("/posts/:id/poll/vote", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.VotePoll) + api.POST("/posts/:id/poll/close", h.ClosePoll) + api.POST("/posts/:id/question/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptQuestion) + api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion) + api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion) + api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty) + api.POST("/posts/:id/bounty/refund", h.RefundBounty) + api.POST("/posts/:id/lottery/close", h.CloseLottery) + api.POST("/posts/:id/lottery/draw", h.DrawLottery) api.DELETE("/posts/:id", h.DeletePost) api.PUT("/posts/:id/pin", h.TogglePin) api.PUT("/posts/:id/recommend", h.ToggleRecommend) @@ -139,11 +157,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.GET("/notifications/unread-count", h.UnreadCount) api.PUT("/notifications/:id/read", h.MarkRead) api.PUT("/notifications/read-all", h.MarkAllRead) - // 每日签到 + // 每日签到 / 积分 api.GET("/checkin", h.GetCheckin) api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin) - // 头像上传(裁剪后的 WebP)/ 媒体库 / 附件删除 + api.GET("/points", h.GetPointsBalance) + api.GET("/points/ledger", h.GetPointsLedger) + // 头像上传(裁剪后的 WebP)/ 帖子插图 / 媒体库 / 附件删除 api.POST("/upload/avatar", h.UploadAvatar) + api.POST("/upload/image", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadImage) + api.POST("/upload/file", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadPostFile) + api.PUT("/upload/file/:id/price", h.UpdatePostFilePrice) + api.DELETE("/upload/file/:id", h.DeletePostFile) api.PUT("/avatar/use", h.UseAvatar) api.GET("/my/media", h.MyMedia) api.DELETE("/my/attachments/:id", h.DeleteAttachment) @@ -186,6 +210,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.PUT("/moderation/comments/:id/approve", h.AdminApproveComment) staffAPI.PUT("/moderation/comments/:id/reject", h.AdminRejectComment) + // 积分与类型帖概览(管理员及以上) + staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) + // 站点公告文章管理(管理员及以上) announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) announceAPI.GET("/announcements", h.AdminListAnnouncements) diff --git a/backend/service/checkin.go b/backend/service/checkin.go index 61780b2..423b053 100644 --- a/backend/service/checkin.go +++ b/backend/service/checkin.go @@ -21,7 +21,7 @@ var ErrAlreadyCheckedIn = errors.New("今日已签到") type CheckinStatus struct { CheckedToday bool `json:"checked_today"` Streak int `json:"streak"` // 连续签到天数 - TotalPoints int `json:"total_points"` // 累计积分(签到所得) + TotalPoints int `json:"total_points"` // 累计积分(可用余额) TodayPoints int `json:"today_points"` // 今日签到可得积分 } @@ -85,9 +85,9 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { } var total int64 - if err := s.db.Model(&model.Checkin{}). - Where("user_id = ?", userID). - Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil { + if err := s.db.Model(&model.User{}). + Where("id = ?", userID). + Select("points").Scan(&total).Error; err != nil { return nil, err } @@ -122,6 +122,10 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) { if err := tx.Create(&row).Error; err != nil { return err } + // 同步入账到用户积分余额 + if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil { + return err + } return nil }) if err != nil { diff --git a/backend/service/moderation.go b/backend/service/moderation.go index e1f724b..549de01 100644 --- a/backend/service/moderation.go +++ b/backend/service/moderation.go @@ -171,7 +171,14 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er if post.Status != model.ContentStatusPending { return ErrNotPending } - if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil { + updates := map[string]interface{}{ + "status": model.ContentStatusPublished, + } + // 悬赏/抽奖:首次公开发布时补写 ends_at + if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok { + updates["type_meta"] = raw + } + if err := tx.Model(&post).Updates(updates).Error; err != nil { return err } boardID = post.BoardID diff --git a/backend/service/points.go b/backend/service/points.go new file mode 100644 index 0000000..c843267 --- /dev/null +++ b/backend/service/points.go @@ -0,0 +1,231 @@ +package service + +import ( + "errors" + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInsufficientPoints = errors.New("积分不足") + ErrInvalidPoints = errors.New("积分数量无效") +) + +// PointsService 用户积分账户 +type PointsService struct { + db *gorm.DB +} + +func NewPointsService(db *gorm.DB) *PointsService { + return &PointsService{db: db} +} + +// Balance 查询可用积分 +func (s *PointsService) Balance(userID uint) (int, error) { + var u model.User + if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil { + return 0, err + } + return u.Points, nil +} + +// CreditTx 在事务内入账 +func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + if delta <= 0 { + return 0, ErrInvalidPoints + } + var u model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "points").First(&u, userID).Error; err != nil { + return 0, err + } + bal := u.Points + delta + if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil { + return 0, err + } + if err := tx.Create(&model.PointLedger{ + UserID: userID, + Delta: delta, + Balance: bal, + Reason: reason, + RefType: refType, + RefID: refID, + Note: note, + }).Error; err != nil { + return 0, err + } + return bal, nil +} + +// DebitTx 在事务内扣款 +func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + if delta <= 0 { + return 0, ErrInvalidPoints + } + var u model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "points").First(&u, userID).Error; err != nil { + return 0, err + } + if u.Points < delta { + return 0, ErrInsufficientPoints + } + bal := u.Points - delta + if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil { + return 0, err + } + if err := tx.Create(&model.PointLedger{ + UserID: userID, + Delta: -delta, + Balance: bal, + Reason: reason, + RefType: refType, + RefID: refID, + Note: note, + }).Error; err != nil { + return 0, err + } + return bal, nil +} + +// Credit 入账 +func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + var bal int + err := s.db.Transaction(func(tx *gorm.DB) error { + var e error + bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note) + return e + }) + return bal, err +} + +// Debit 扣款 +func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + var bal int + err := s.db.Transaction(func(tx *gorm.DB) error { + var e error + bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note) + return e + }) + return bal, err +} + +// LedgerItem 流水展示项 +type LedgerItem struct { + ID uint `json:"id"` + Delta int `json:"delta"` + Balance int `json:"balance"` + Reason string `json:"reason"` + RefType string `json:"ref_type"` + RefID uint `json:"ref_id"` + Note string `json:"note"` + CreatedAt time.Time `json:"created_at"` +} + +// Ledger 分页查询本人积分流水 +func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + var total int64 + q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.PointLedger + if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, 0, err + } + out := make([]LedgerItem, 0, len(rows)) + for _, r := range rows { + out = append(out, LedgerItem{ + ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, + RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, + }) + } + return out, total, nil +} + +// PointsEconomyStats 全站积分与类型帖概览(管理端) +type PointsEconomyStats struct { + TotalBalance int64 `json:"total_balance"` // 用户余额合计 + LedgerCount int64 `json:"ledger_count"` // 流水条数 + CheckinToday int64 `json:"checkin_today"` // 今日签到人数 + OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖 + EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算) + PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量 + RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note) +} + +// AdminEconomyStats 管理端经济看板 +func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { + st := &PointsEconomyStats{PostsByType: map[string]int64{}} + if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL"). + Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil { + return nil, err + } + today := time.Now().Truncate(24 * time.Hour) + // 用日期字符串更稳妥(与 checkin 一致用 date) + if err := s.db.Model(&model.Checkin{}). + Where("checkin_date::date = CURRENT_DATE"). + Count(&st.CheckinToday).Error; err != nil { + return nil, err + } + _ = today + + type row struct { + PostType string + Cnt int64 + } + var rows []row + if err := s.db.Model(&model.Post{}). + Select("post_type, count(*) as cnt"). + Where("deleted_at IS NULL"). + Group("post_type").Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + pt := model.NormalizePostType(r.PostType) + st.PostsByType[pt] += r.Cnt + } + + // 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算 + var bountyPosts []model.Post + if err := s.db.Select("id, type_meta"). + Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). + Find(&bountyPosts).Error; err != nil { + return nil, err + } + for _, p := range bountyPosts { + m, err := parseBountyMeta(p.TypeMeta) + if err != nil { + continue + } + if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired { + st.OpenBounties++ + st.EscrowedPoints += int64(m.Points) + } + } + + var recent []model.PointLedger + if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil { + return nil, err + } + st.RecentLedger = make([]LedgerItem, 0, len(recent)) + for _, r := range recent { + st.RecentLedger = append(st.RecentLedger, LedgerItem{ + ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, + RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, + }) + } + return st, nil +} diff --git a/backend/service/post.go b/backend/service/post.go index 499d9b1..3f987b0 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -40,9 +40,13 @@ type PostListQuery struct { func toPostListItems(posts []model.Post) []PostListItem { items := make([]PostListItem, 0, len(posts)) for _, p := range posts { + pt := model.NormalizePostType(p.PostType) items = append(items, PostListItem{ ID: p.ID, BoardID: p.BoardID, UserID: p.UserID, - Title: p.Title, Tags: p.Tags, PostType: p.PostType, + Title: p.Title, Tags: p.Tags, PostType: pt, + TypeStatus: ComputeTypeStatus(pt, p.TypeMeta), + ContentAccess: model.NormalizeContentAccess(p.ContentAccess), + AccessPoints: p.AccessPoints, Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount, CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt, Board: p.Board, User: p.User, @@ -67,23 +71,26 @@ type LastReplyInfo struct { // PostListItem 帖子列表项(不含正文) type PostListItem struct { - ID uint `json:"id"` - BoardID uint `json:"board_id"` - UserID uint `json:"user_id"` - Title string `json:"title"` - Tags string `json:"tags"` - PostType string `json:"post_type"` - Pinned int `json:"pinned"` - Recommended bool `json:"recommended"` - LikeCount int `json:"like_count"` - ViewCount int `json:"view_count"` - CommentCount int `json:"comment_count"` - Status string `json:"status"` - Liked bool `json:"liked"` - CreatedAt time.Time `json:"created_at"` - LastReply *LastReplyInfo `json:"last_reply,omitempty"` - Board model.Board `json:"board"` - User model.User `json:"user"` + ID uint `json:"id"` + BoardID uint `json:"board_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Tags string `json:"tags"` + PostType string `json:"post_type"` + TypeStatus string `json:"type_status,omitempty"` // unsolved|solved|open|closed|expired|drawn + ContentAccess string `json:"content_access"` + AccessPoints int `json:"access_points"` + Pinned int `json:"pinned"` + Recommended bool `json:"recommended"` + LikeCount int `json:"like_count"` + ViewCount int `json:"view_count"` + CommentCount int `json:"comment_count"` + Status string `json:"status"` + Liked bool `json:"liked"` + CreatedAt time.Time `json:"created_at"` + LastReply *LastReplyInfo `json:"last_reply,omitempty"` + Board model.Board `json:"board"` + User model.User `json:"user"` } // fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间), @@ -327,9 +334,78 @@ func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) boo return loadActor().CanModerateBoard(post.BoardID) } -// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。 -// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil -func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) { +// PostAttachmentDTO 附件对外字段 +type PostAttachmentDTO struct { + ID uint `json:"id"` + Name string `json:"name"` + Size int `json:"size"` + MIME string `json:"mime"` + PricePoints int `json:"price_points"` + DownloadCount int `json:"download_count"` + Unlocked bool `json:"unlocked"` // 当前用户是否可直接下载(免费/已购/作者) +} + +// PostDetail 帖子详情(含可见性裁剪与附件) +type PostDetail struct { + ID uint `json:"id"` + BoardID uint `json:"board_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + Tags string `json:"tags"` + PostType string `json:"post_type"` + ContentAccess string `json:"content_access"` + AccessPoints int `json:"access_points"` + TypeMeta string `json:"type_meta"` + TypeStatus string `json:"type_status,omitempty"` + Pinned int `json:"pinned"` + Recommended bool `json:"recommended"` + Status string `json:"status"` + LikeCount int `json:"like_count"` + ViewCount int `json:"view_count"` + CommentCount int `json:"comment_count"` + Liked bool `json:"liked"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Board model.Board `json:"board"` + User model.User `json:"user"` + ContentLocked bool `json:"content_locked"` + AccessHint string `json:"access_hint,omitempty"` + Attachments []PostAttachmentDTO `json:"attachments"` + Question *QuestionState `json:"question,omitempty"` + Poll *PollState `json:"poll,omitempty"` + Bounty *BountyState `json:"bounty,omitempty"` + Lottery *LotteryState `json:"lottery,omitempty"` +} + +// CreatePostInput 发帖入参 +type CreatePostInput struct { + UserID uint + BoardID uint + Title string + Content string + Tags string + PostType string + ContentAccess string + AccessPoints int + TypeMeta string + Status string + AttachmentIDs []uint +} + +// UpdatePostInput 编辑入参 +type UpdatePostInput struct { + Title string + Content string + Tags string + ContentAccess *string + AccessPoints *int + TypeMeta *string + AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表 +} + +// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制) +func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) { var post model.Post if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil { return nil, ErrPostNotFound @@ -337,9 +413,504 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto if !visibleToPost(&post, viewerID, loadActor) { return nil, ErrPostNotFound } - // 增加浏览量(待审/被拒内容不计) + // 惰性结算:悬赏过期退回 / 抽奖到期开奖 + _ = s.settleExpiredBountyIfNeeded(&post) + _ = s.settleDueLotteryIfNeeded(&post) + if post.TypeMeta != "" { + var fresh model.Post + if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil { + post.TypeMeta = fresh.TypeMeta + } + } + s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1")) - return &post, nil + post.ViewCount++ + + detail := buildPostDetail(&post) + locked, hint := s.evalContentAccess(&post, viewerID, loadActor) + detail.ContentLocked = locked + detail.AccessHint = hint + if locked { + detail.Content = "" + } + + atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID) + detail.Attachments = atts + s.fillInteractState(detail, &post, viewerID, loadActor) + return detail, nil +} + +func buildPostDetail(post *model.Post) *PostDetail { + return &PostDetail{ + ID: post.ID, BoardID: post.BoardID, UserID: post.UserID, + Title: post.Title, Content: post.Content, Tags: post.Tags, + PostType: model.NormalizePostType(post.PostType), + ContentAccess: model.NormalizeContentAccess(post.ContentAccess), + AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta, + TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta), + Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status, + LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount, + Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt, + Board: post.Board, User: post.User, + Attachments: []PostAttachmentDTO{}, + } +} + +func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) { + access := model.NormalizeContentAccess(post.ContentAccess) + if access == model.ContentAccessPublic { + return false, "" + } + // 作者与版主始终可见 + if viewerID > 0 && post.UserID == viewerID { + return false, "" + } + if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) { + return false, "" + } + + switch access { + case model.ContentAccessLogin: + if viewerID == 0 { + return true, "登录后可见全文" + } + return false, "" + case model.ContentAccessReply: + if viewerID == 0 { + return true, "回复本帖后可见全文" + } + var n int64 + s.db.Model(&model.Comment{}). + Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL", + post.ID, viewerID, model.ContentStatusPublished). + Count(&n) + if n == 0 { + return true, "回复本帖后可见全文" + } + return false, "" + case model.ContentAccessPoints: + need := post.AccessPoints + if need <= 0 { + need = 1 + } + if viewerID == 0 { + return true, "支付积分后可见全文" + } + var n int64 + s.db.Model(&model.PostContentUnlock{}). + Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n) + if n > 0 { + return false, "" + } + return true, "支付积分后可见全文" + default: + return false, "" + } +} + +func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) { + var rows []model.PostAttachment + if err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + out := make([]PostAttachmentDTO, 0, len(rows)) + unlockedIDs := map[uint]bool{} + if viewerID > 0 { + ids := make([]uint, 0, len(rows)) + for _, r := range rows { + if r.PricePoints > 0 { + ids = append(ids, r.ID) + } + } + if len(ids) > 0 { + var unlocks []model.PostAttachmentUnlock + s.db.Where("attachment_id IN ? AND user_id = ?", ids, viewerID).Find(&unlocks) + for _, u := range unlocks { + unlockedIDs[u.AttachmentID] = true + } + } + } + for _, r := range rows { + ok := r.PricePoints <= 0 || viewerID == authorID || unlockedIDs[r.ID] + out = append(out, PostAttachmentDTO{ + ID: r.ID, Name: r.Name, Size: r.Size, MIME: r.MIME, + PricePoints: r.PricePoints, DownloadCount: r.DownloadCount, Unlocked: ok, + }) + } + return out, nil +} + +// UnlockContent 积分解锁正文 +func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) { + var post model.Post + if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { + return nil, ErrPostNotFound + } + if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints { + return nil, errors.New("本文无需积分解锁") + } + if post.UserID == userID { + return buildPostDetail(&post), nil + } + need := post.AccessPoints + if need <= 0 { + need = 1 + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var n int64 + if err := tx.Model(&model.PostContentUnlock{}). + Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return nil + } + if _, err := DebitTx(tx, userID, need, model.PointReasonUnlockPost, "post", postID, "解锁帖子:"+post.Title); err != nil { + return err + } + if post.UserID > 0 { + if _, err := CreditTx(tx, post.UserID, need, model.PointReasonUnlockPost, "post_earn", postID, "正文解锁收益"); err != nil { + return err + } + } + return tx.Create(&model.PostContentUnlock{ + PostID: postID, UserID: userID, Points: need, + }).Error + }) + if err != nil { + return nil, err + } + detail := buildPostDetail(&post) + atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID) + detail.Attachments = atts + s.fillInteractState(detail, &post, userID, nil) + return detail, nil +} + +// Create 创建帖子。status 由 handler 按角色计算 +func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) { + title := strings.TrimSpace(in.Title) + content := strings.TrimSpace(in.Content) + if title == "" { + return nil, errors.New("标题不能为空") + } + if content == "" { + return nil, errors.New("内容不能为空") + } + if in.BoardID == 0 { + return nil, errors.New("请选择板块") + } + status := in.Status + if status != model.ContentStatusPending && status != model.ContentStatusPublished { + status = model.ContentStatusPending + } + postType := model.NormalizePostType(in.PostType) + if !model.ValidPostType(postType) { + return nil, errors.New("无效的帖子类型") + } + access := model.NormalizeContentAccess(in.ContentAccess) + accessPts := in.AccessPoints + if access == model.ContentAccessPoints { + if accessPts <= 0 { + return nil, errors.New("请设置解锁所需积分") + } + if accessPts > 100000 { + return nil, errors.New("解锁积分过高") + } + } else { + accessPts = 0 + } + typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta) + if err != nil { + return nil, err + } + if err := s.checkNewUserCooldown(in.UserID); err != nil { + return nil, err + } + + now := time.Now().UTC() + if postType == model.PostTypeQuestion { + qm, _ := parseQuestionMeta(typeMeta) + if qm == nil { + qm = &QuestionMeta{} + } + typeMeta, _ = encodeMeta(qm) + } + + // 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限) + var bountyPts int + if postType == model.PostTypeBounty { + bm, _ := parseBountyMeta(typeMeta) + bountyPts = bm.Points + var bountyPosts []model.Post + s.db.Select("type_meta"). + Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty). + Find(&bountyPosts) + open := 0 + for _, bp := range bountyPosts { + m, err := parseBountyMeta(bp.TypeMeta) + if err != nil { + continue + } + if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired { + open++ + } + } + if open >= 3 { + return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回") + } + var todayN int64 + s.db.Model(&model.Post{}). + Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL", + in.UserID, model.PostTypeBounty). + Count(&todayN) + if todayN >= 5 { + return nil, errors.New("今日悬赏发帖已达上限(5)") + } + bm.Escrowed = true + bm.Refunded = false + bm.Expired = false + bm.AcceptedCommentID = 0 + if status == model.ContentStatusPublished { + days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30}) + bm.ExpireDays = 0 + bm.EndsAt = endsAtFromDays(days, now) + } + typeMeta, _ = encodeMeta(bm) + } + + if postType == model.PostTypeLottery { + lm, _ := parseLotteryMeta(typeMeta) + if lm.WinnerIDs == nil { + lm.WinnerIDs = []uint{} + } + if status == model.ContentStatusPublished { + days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14}) + lm.ExpireDays = 0 + lm.EndsAt = endsAtFromDays(days, now) + } + typeMeta, _ = encodeMeta(lm) + } + + post := &model.Post{ + BoardID: in.BoardID, UserID: in.UserID, + Title: title, Content: content, Tags: in.Tags, + PostType: postType, ContentAccess: access, AccessPoints: accessPts, + TypeMeta: typeMeta, Status: status, + } + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(post).Error; err != nil { + return err + } + if bountyPts > 0 { + if _, err := DebitTx(tx, in.UserID, bountyPts, model.PointReasonBountyEscrow, "post", post.ID, "悬赏托管:"+title); err != nil { + return err + } + } + if len(in.AttachmentIDs) == 0 { + return nil + } + if len(in.AttachmentIDs) > MaxPostAttachments { + return ErrTooManyAttachments + } + var atts []model.PostAttachment + if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID). + Find(&atts).Error; err != nil { + return err + } + if len(atts) != len(in.AttachmentIDs) { + return errors.New("部分附件无效或无权使用") + } + return tx.Model(&model.PostAttachment{}). + Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID). + Update("post_id", post.ID).Error + }) + if err != nil { + return nil, err + } + s.db.Preload("Board").Preload("User").First(post, post.ID) + detail := buildPostDetail(post) + atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID) + detail.Attachments = atts + s.fillInteractState(detail, post, in.UserID, nil) + return detail, nil +} + +func validateTypeMeta(postType, meta string) error { + _, err := NormalizeAndValidateTypeMeta(postType, meta) + return err +} + +// Update 更新帖子(作者本人,或对该板块有审核权的管理成员) +func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) { + var post model.Post + if err := s.db.First(&post, postID).Error; err != nil { + return nil, ErrPostNotFound + } + if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { + return nil, ErrPostForbidden + } + + updates := map[string]interface{}{} + if in.Title != "" { + t := strings.TrimSpace(in.Title) + if t == "" { + return nil, errors.New("标题不能为空") + } + updates["title"] = t + } + if in.Content != "" { + c := strings.TrimSpace(in.Content) + if c == "" { + return nil, errors.New("内容不能为空") + } + updates["content"] = c + } + updates["tags"] = in.Tags + + if in.ContentAccess != nil { + access := model.NormalizeContentAccess(*in.ContentAccess) + updates["content_access"] = access + if access == model.ContentAccessPoints { + pts := post.AccessPoints + if in.AccessPoints != nil { + pts = *in.AccessPoints + } + if pts <= 0 { + return nil, errors.New("请设置解锁所需积分") + } + updates["access_points"] = pts + } else { + updates["access_points"] = 0 + } + } else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints { + if *in.AccessPoints <= 0 { + return nil, errors.New("请设置解锁所需积分") + } + updates["access_points"] = *in.AccessPoints + } + if in.TypeMeta != nil { + pt := model.NormalizePostType(post.PostType) + meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta) + if err != nil { + return nil, err + } + // 悬赏已托管:禁止改积分,保留结算/截止字段 + if pt == model.PostTypeBounty { + old, _ := parseBountyMeta(post.TypeMeta) + neu, _ := parseBountyMeta(meta) + if old != nil && neu != nil { + neu.Escrowed = old.Escrowed + neu.Refunded = old.Refunded + neu.Expired = old.Expired + neu.AcceptedCommentID = old.AcceptedCommentID + neu.EndsAt = old.EndsAt + if old.Escrowed || old.AcceptedCommentID > 0 || old.Refunded || old.Expired { + neu.Points = old.Points + } + meta, _ = encodeMeta(neu) + } + } + // 投票已有票:明确报错,禁止改选项/单多选/匿名 + if pt == model.PostTypePoll { + old, _ := parsePollMeta(post.TypeMeta) + neu, _ := parsePollMeta(meta) + if old != nil && neu != nil { + var n int64 + s.db.Model(&model.PostPollVote{}).Where("post_id = ?", postID).Count(&n) + if n > 0 { + optsChanged := len(neu.Options) != len(old.Options) + if !optsChanged { + for i := range old.Options { + if neu.Options[i] != old.Options[i] { + optsChanged = true + break + } + } + } + if optsChanged || neu.Multi != old.Multi || neu.Anonymous != old.Anonymous { + return nil, ErrPollOptionsLocked + } + neu.Options = old.Options + neu.Multi = old.Multi + neu.Anonymous = old.Anonymous + neu.Closed = old.Closed + meta, _ = encodeMeta(neu) + } else { + neu.Closed = old.Closed + meta, _ = encodeMeta(neu) + } + } + } + if pt == model.PostTypeLottery { + old, _ := parseLotteryMeta(post.TypeMeta) + neu, _ := parseLotteryMeta(meta) + if old != nil && neu != nil { + neu.Drawn = old.Drawn + neu.WinnerIDs = old.WinnerIDs + neu.Closed = old.Closed + neu.EndsAt = old.EndsAt + if old.Drawn { + neu.Slots = old.Slots + } + meta, _ = encodeMeta(neu) + } + } + if pt == model.PostTypeQuestion { + old, _ := parseQuestionMeta(post.TypeMeta) + neu, _ := parseQuestionMeta(meta) + if old != nil && neu != nil { + // 解题态走专用 API,编辑帖子不覆盖 + neu.Solved = old.Solved + neu.AcceptedCommentID = old.AcceptedCommentID + meta, _ = encodeMeta(neu) + } + } + updates["type_meta"] = meta + } + + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&post).Updates(updates).Error; err != nil { + return err + } + if in.AttachmentIDs == nil { + return nil + } + ids := *in.AttachmentIDs + if len(ids) > MaxPostAttachments { + return ErrTooManyAttachments + } + if err := tx.Model(&model.PostAttachment{}). + Where("post_id = ? AND user_id = ?", postID, post.UserID). + Update("post_id", 0).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + var atts []model.PostAttachment + if err := tx.Where( + "id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)", + ids, post.UserID, postID, + ).Find(&atts).Error; err != nil { + return err + } + if len(atts) != len(ids) { + return errors.New("部分附件无效或无权使用") + } + return tx.Model(&model.PostAttachment{}). + Where("id IN ? AND user_id = ?", ids, post.UserID). + Update("post_id", postID).Error + }) + if err != nil { + return nil, err + } + s.db.Preload("Board").Preload("User").First(&post, post.ID) + detail := buildPostDetail(&post) + atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID) + detail.Attachments = atts + s.fillInteractState(detail, &post, userID, nil) + return detail, nil } // EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量) @@ -354,105 +925,27 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() return nil } -// Create 创建帖子。status 由 handler 按角色计算: -// 管理团队成员直发 published,普通用户进入 pending 等待审核 -func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) { - title = strings.TrimSpace(title) - content = strings.TrimSpace(content) - if title == "" { - return nil, errors.New("标题不能为空") - } - if content == "" { - return nil, errors.New("内容不能为空") - } - if boardID == 0 { - return nil, errors.New("请选择板块") - } - if status != model.ContentStatusPending && status != model.ContentStatusPublished { - status = model.ContentStatusPending - } - - // 新用户 24h 冷静期校验 - if err := s.checkNewUserCooldown(userID); err != nil { - return nil, err - } - - post := &model.Post{ - BoardID: boardID, - UserID: userID, - Title: title, - Content: content, - Tags: tags, - PostType: postType, - Status: status, - } - if err := s.db.Create(post).Error; err != nil { - return nil, err - } - // 预加载关联 - s.db.Preload("Board").Preload("User").First(post, post.ID) - return post, nil -} - // checkNewUserCooldown 新用户发帖 24h 冷静期 func (s *PostService) checkNewUserCooldown(userID uint) error { var user model.User if err := s.db.First(&user, userID).Error; err != nil { return err } - // 注册不足 24 小时的新用户不能发帖 if time.Since(user.CreatedAt) < 24*time.Hour { return errors.New("新用户注册 24 小时后才能发帖") } return nil } -// Update 更新帖子(作者本人,或对该板块有审核权的管理成员) -func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) { - var post model.Post - if err := s.db.First(&post, postID).Error; err != nil { - return nil, ErrPostNotFound - } - // 权限校验:作者本人或板块审核权 - if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { - return nil, ErrPostForbidden - } - - updates := map[string]interface{}{} - if title != "" { - t := strings.TrimSpace(title) - if t == "" { - return nil, errors.New("标题不能为空") - } - updates["title"] = t - } - if content != "" { - c := strings.TrimSpace(content) - if c == "" { - return nil, errors.New("内容不能为空") - } - updates["content"] = c - } - updates["tags"] = tags - - if err := s.db.Model(&post).Updates(updates).Error; err != nil { - return nil, err - } - s.db.Preload("Board").Preload("User").First(&post, post.ID) - return &post, nil -} - // Delete 删除帖子(作者本人,或对该板块有审核权的管理成员) func (s *PostService) Delete(actor *Actor, postID, userID uint) error { var post model.Post if err := s.db.First(&post, postID).Error; err != nil { return ErrPostNotFound } - // 权限校验:作者本人或板块审核权 if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { return ErrPostForbidden } - // 软删除(gorm DeletedAt) if err := s.db.Delete(&post).Error; err != nil { return err } diff --git a/backend/service/post_file.go b/backend/service/post_file.go new file mode 100644 index 0000000..c65e399 --- /dev/null +++ b/backend/service/post_file.go @@ -0,0 +1,283 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "mime" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +const ( + FileMaxBytes = 20 << 20 // 20 MiB + MaxPostAttachments = 10 +) + +var ( + ErrAttachmentNotFound = errors.New("附件不存在") + ErrAttachmentForbidden = errors.New("无权操作此附件") + ErrTooManyAttachments = errors.New("附件数量超过上限") +) + +// PostFileService 帖子文件附件(私有目录 + API 下载) +type PostFileService struct { + db *gorm.DB + dir string // data/private/files +} + +func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService { + return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")} +} + +func (s *PostFileService) EnsureDir() error { + return os.MkdirAll(s.dir, 0o755) +} + +func absPath(dir, stored string) string { + return filepath.Join(dir, stored) +} + +// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定) +func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) { + if len(data) == 0 { + return nil, errors.New("文件为空") + } + if len(data) > FileMaxBytes { + return nil, errors.New("附件不能超过 20MB") + } + if pricePoints < 0 { + pricePoints = 0 + } + if pricePoints > 100000 { + return nil, errors.New("积分定价过高") + } + name := sanitizeFilename(originalName) + if name == "" { + name = "file" + } + + var orphan int64 + if err := s.db.Model(&model.PostAttachment{}). + Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil { + return nil, err + } + if orphan >= MaxPostAttachments { + return nil, ErrTooManyAttachments + } + + ext := filepath.Ext(name) + if utf8.RuneCountInString(ext) > 16 { + ext = "" + } + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + stored := hex.EncodeToString(raw) + ext + full := absPath(s.dir, stored) + if err := os.WriteFile(full, data, 0o600); err != nil { + return nil, err + } + + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + att := &model.PostAttachment{ + PostID: 0, + UserID: userID, + Name: name, + StoredName: stored, + MIME: mimeType, + Size: len(data), + PricePoints: pricePoints, + } + if err := s.db.Create(att).Error; err != nil { + _ = os.Remove(full) + return nil, err + } + return att, nil +} + +func sanitizeFilename(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|': + return '_' + default: + return r + } + }, name) + if utf8.RuneCountInString(name) > 200 { + runes := []rune(name) + name = string(runes[:200]) + } + return name +} + +// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定) +func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error { + if len(ids) == 0 { + return nil + } + if len(ids) > MaxPostAttachments { + return ErrTooManyAttachments + } + return s.db.Transaction(func(tx *gorm.DB) error { + var atts []model.PostAttachment + if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil { + return err + } + if len(atts) != len(ids) { + return errors.New("部分附件无效或无权使用") + } + return tx.Model(&model.PostAttachment{}). + Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID). + Update("post_id", postID).Error + }) +} + +// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的) +func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error { + if len(ids) > MaxPostAttachments { + return ErrTooManyAttachments + } + return s.db.Transaction(func(tx *gorm.DB) error { + var keep []model.PostAttachment + if len(ids) > 0 { + if err := tx.Where( + "id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)", + ids, userID, postID, + ).Find(&keep).Error; err != nil { + return err + } + if len(keep) != len(ids) { + return errors.New("部分附件无效或无权使用") + } + } + // 解绑本帖旧附件(软删物理文件可选:P1 仅解绑) + if err := tx.Model(&model.PostAttachment{}). + Where("post_id = ? AND user_id = ?", postID, userID). + Update("post_id", 0).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + return tx.Model(&model.PostAttachment{}). + Where("id IN ? AND user_id = ?", ids, userID). + Update("post_id", postID).Error + }) +} + +// ListByPost 帖子附件列表 +func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) { + var list []model.PostAttachment + err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error + return list, err +} + +// UpdatePrice 更新附件积分定价(作者) +func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error { + if price < 0 { + price = 0 + } + res := s.db.Model(&model.PostAttachment{}). + Where("id = ? AND user_id = ?", attID, userID). + Update("price_points", price) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrAttachmentNotFound + } + return nil +} + +// DeleteOwn 删除本人未绑定或本帖附件 +func (s *PostFileService) DeleteOwn(userID, attID uint) error { + var att model.PostAttachment + if err := s.db.First(&att, attID).Error; err != nil { + return ErrAttachmentNotFound + } + if att.UserID != userID { + return ErrAttachmentForbidden + } + path := absPath(s.dir, att.StoredName) + if err := s.db.Delete(&att).Error; err != nil { + return err + } + _ = os.Remove(path) + return nil +} + +// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分 +func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) { + var att model.PostAttachment + if err := s.db.First(&att, attID).Error; err != nil { + return nil, ErrAttachmentNotFound + } + return &att, nil +} + +func (s *PostFileService) FilePath(att *model.PostAttachment) string { + return absPath(s.dir, att.StoredName) +} + +func (s *PostFileService) IncDownload(attID uint) { + s.db.Model(&model.PostAttachment{}).Where("id = ?", attID). + UpdateColumn("download_count", gorm.Expr("download_count + 1")) +} + +// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次 +func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error { + if att.PricePoints <= 0 { + return nil + } + if att.UserID == userID { + return nil + } + var n int64 + if err := s.db.Model(&model.PostAttachmentUnlock{}). + Where("attachment_id = ? AND user_id = ?", att.ID, userID). + Count(&n).Error; err != nil { + return err + } + if n > 0 { + return nil + } + return s.db.Transaction(func(tx *gorm.DB) error { + var again int64 + if err := tx.Model(&model.PostAttachmentUnlock{}). + Where("attachment_id = ? AND user_id = ?", att.ID, userID). + Count(&again).Error; err != nil { + return err + } + if again > 0 { + return nil + } + if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil { + return err + } + // 积分转给作者 + if att.UserID > 0 && att.UserID != userID { + if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil { + return err + } + } + return tx.Create(&model.PostAttachmentUnlock{ + AttachmentID: att.ID, + UserID: userID, + Points: att.PricePoints, + }).Error + }) +} diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go new file mode 100644 index 0000000..8acb5d9 --- /dev/null +++ b/backend/service/post_interact.go @@ -0,0 +1,1082 @@ +package service + +import ( + "crypto/rand" + "encoding/json" + "errors" + "math/big" + "strings" + "time" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// ---- TypeMeta 结构 ---- + +type QuestionMeta struct { + Solved bool `json:"solved"` + AcceptedCommentID uint `json:"accepted_comment_id"` +} + +type PollMeta struct { + Options []string `json:"options"` + Multi bool `json:"multi"` + Closed bool `json:"closed"` + Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true +} + +type BountyMeta struct { + Points int `json:"points"` + AcceptedCommentID uint `json:"accepted_comment_id"` + Escrowed bool `json:"escrowed"` + Refunded bool `json:"refunded"` + Expired bool `json:"expired"` + EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写 + ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清 +} + +type LotteryMeta struct { + Slots int `json:"slots"` + Drawn bool `json:"drawn"` + WinnerIDs []uint `json:"winner_ids"` + Closed bool `json:"closed"` + EndsAt string `json:"ends_at"` + ExpireDays int `json:"expire_days,omitempty"` +} + +var ( + ErrPollClosed = errors.New("投票已结束") + ErrAlreadyVoted = errors.New("你已投过票") + ErrInvalidPollOpt = errors.New("无效的投票选项") + ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") + ErrBountySettled = errors.New("悬赏已结算") + ErrBountyNotEscrow = errors.New("悬赏未托管") + ErrBountyExpired = errors.New("悬赏已过期") + ErrLotteryDrawn = errors.New("已开奖") + ErrLotteryClosed = errors.New("抽奖已截止") + ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") + ErrQuestionNotType = errors.New("本文不是问答帖") +) + +func parseQuestionMeta(raw string) (*QuestionMeta, error) { + if strings.TrimSpace(raw) == "" { + return &QuestionMeta{}, nil + } + var m QuestionMeta + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, errors.New("问答配置无效") + } + return &m, nil +} + +func parsePollMeta(raw string) (*PollMeta, error) { + var wire struct { + Options []string `json:"options"` + Multi bool `json:"multi"` + Closed bool `json:"closed"` + Anonymous *bool `json:"anonymous"` + } + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, errors.New("投票配置无效") + } + anon := true // 缺省 / null → 匿名 + if wire.Anonymous != nil { + anon = *wire.Anonymous + } + return &PollMeta{ + Options: wire.Options, + Multi: wire.Multi, + Closed: wire.Closed, + Anonymous: anon, + }, nil +} + +func parseBountyMeta(raw string) (*BountyMeta, error) { + var m BountyMeta + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, errors.New("悬赏配置无效") + } + return &m, nil +} + +func parseLotteryMeta(raw string) (*LotteryMeta, error) { + var m LotteryMeta + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, errors.New("抽奖配置无效") + } + return &m, nil +} + +func encodeMeta(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func normalizeExpireDays(d, def int, allowed []int) int { + if d <= 0 { + return def + } + for _, a := range allowed { + if d == a { + return d + } + } + return def +} + +func endsAtFromDays(days int, from time.Time) string { + return from.UTC().Add(time.Duration(days) * 24 * time.Hour).Format(time.RFC3339) +} + +func parseEndsAt(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// ComputeTypeStatus 列表徽章状态 +func ComputeTypeStatus(postType, typeMeta string) string { + pt := model.NormalizePostType(postType) + switch pt { + case model.PostTypeQuestion: + m, err := parseQuestionMeta(typeMeta) + if err != nil { + return "unsolved" + } + if m.Solved { + return "solved" + } + return "unsolved" + case model.PostTypePoll: + m, err := parsePollMeta(typeMeta) + if err != nil { + return "open" + } + if m.Closed { + return "closed" + } + return "open" + case model.PostTypeBounty: + m, err := parseBountyMeta(typeMeta) + if err != nil { + return "open" + } + if m.AcceptedCommentID > 0 { + return "solved" + } + if m.Expired || m.Refunded { + return "expired" + } + return "open" + case model.PostTypeLottery: + m, err := parseLotteryMeta(typeMeta) + if err != nil { + return "open" + } + if m.Drawn { + return "drawn" + } + if m.Closed { + return "closed" + } + return "open" + default: + return "" + } +} + +// NormalizeAndValidateTypeMeta 校验并规范化 type_meta(发帖/编辑) +func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) { + postType = model.NormalizePostType(postType) + meta = strings.TrimSpace(meta) + switch postType { + case model.PostTypeQuestion: + m, err := parseQuestionMeta(meta) + if err != nil { + return "", err + } + // 新建默认未解决 + return encodeMeta(m) + case model.PostTypePoll: + if meta == "" { + return "", errors.New("请填写至少 2 个投票选项") + } + m, err := parsePollMeta(meta) + if err != nil { + return "", err + } + opts := make([]string, 0, len(m.Options)) + seen := map[string]bool{} + for _, o := range m.Options { + o = strings.TrimSpace(o) + if o == "" { + continue + } + if utf8.RuneCountInString(o) > 64 { + return "", errors.New("选项最多 64 字") + } + key := strings.ToLower(o) + if seen[key] { + continue + } + seen[key] = true + opts = append(opts, o) + } + if len(opts) < 2 { + return "", errors.New("至少需要 2 个投票选项") + } + if len(opts) > 10 { + return "", errors.New("最多 10 个投票选项") + } + m.Options = opts + return encodeMeta(m) + case model.PostTypeBounty: + if meta == "" { + return "", errors.New("请设置悬赏积分") + } + m, err := parseBountyMeta(meta) + if err != nil { + return "", err + } + if m.Points < 1 { + return "", errors.New("悬赏积分至少为 1") + } + if m.Points > 100000 { + return "", errors.New("悬赏积分过高") + } + days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) + m.ExpireDays = days + // ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值 + return encodeMeta(m) + case model.PostTypeLottery: + if meta == "" { + return "", errors.New("请设置中奖名额") + } + m, err := parseLotteryMeta(meta) + if err != nil { + return "", err + } + if m.Slots < 1 { + return "", errors.New("中奖名额至少为 1") + } + if m.Slots > 100 { + return "", errors.New("中奖名额过多") + } + if m.WinnerIDs == nil { + m.WinnerIDs = []uint{} + } + days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) + m.ExpireDays = days + return encodeMeta(m) + default: + return "", nil + } +} + +// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖) +func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) { + pt := model.NormalizePostType(postType) + switch pt { + case model.PostTypeBounty: + m, err := parseBountyMeta(typeMeta) + if err != nil { + return typeMeta, false + } + if m.EndsAt != "" { + return typeMeta, false + } + days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) + m.ExpireDays = 0 + m.EndsAt = endsAtFromDays(days, publishedAt) + raw, err := encodeMeta(m) + if err != nil { + return typeMeta, false + } + return raw, true + case model.PostTypeLottery: + m, err := parseLotteryMeta(typeMeta) + if err != nil { + return typeMeta, false + } + if m.EndsAt != "" { + return typeMeta, false + } + days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) + m.ExpireDays = 0 + m.EndsAt = endsAtFromDays(days, publishedAt) + raw, err := encodeMeta(m) + if err != nil { + return typeMeta, false + } + return raw, true + default: + return typeMeta, false + } +} + +// ---- 详情附加状态 ---- + +type QuestionState struct { + Solved bool `json:"solved"` + AcceptedCommentID uint `json:"accepted_comment_id"` + CanAccept bool `json:"can_accept"` + CanSolve bool `json:"can_solve"` + CanReopen bool `json:"can_reopen"` +} + +type PollVoter struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type PollOptionStat struct { + Index int `json:"index"` + Text string `json:"text"` + Votes int `json:"votes"` + Percent int `json:"percent"` + Voters []PollVoter `json:"voters,omitempty"` // 非匿名且可看结果时填充 +} + +type PollState struct { + Options []PollOptionStat `json:"options"` + Multi bool `json:"multi"` + Closed bool `json:"closed"` + Anonymous bool `json:"anonymous"` + TotalVotes int `json:"total_votes"` + Voters int `json:"voters"` + MyOptions []int `json:"my_options"` + CanVote bool `json:"can_vote"` + CanClose bool `json:"can_close"` + CanEditOptions bool `json:"can_edit_options"` +} + +type BountyState struct { + Points int `json:"points"` + AcceptedCommentID uint `json:"accepted_comment_id"` + Escrowed bool `json:"escrowed"` + Refunded bool `json:"refunded"` + Expired bool `json:"expired"` + EndsAt string `json:"ends_at,omitempty"` + Settled bool `json:"settled"` + CanAccept bool `json:"can_accept"` + CanRefund bool `json:"can_refund"` +} + +type LotteryEntrant struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +type LotteryState struct { + Slots int `json:"slots"` + Drawn bool `json:"drawn"` + Closed bool `json:"closed"` + EndsAt string `json:"ends_at,omitempty"` + EntryCount int `json:"entry_count"` // 回帖参与人数 + Eligible bool `json:"eligible"` // 当前用户是否在奖池 + CanDraw bool `json:"can_draw"` + CanClose bool `json:"can_close"` + Winners []LotteryEntrant `json:"winners"` +} + +func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) { + pt := model.NormalizePostType(post.PostType) + isAuthor := viewerID > 0 && viewerID == post.UserID + isMod := false + if loadActor != nil { + if a := loadActor(); a != nil { + isMod = a.CanModerateBoard(post.BoardID) + } + } + canManage := isAuthor || isMod + + switch pt { + case model.PostTypeQuestion: + detail.Question = s.buildQuestionState(post, canManage) + case model.PostTypePoll: + detail.Poll = s.buildPollState(post, viewerID, canManage) + case model.PostTypeBounty: + detail.Bounty = s.buildBountyState(post, canManage) + case model.PostTypeLottery: + detail.Lottery = s.buildLotteryState(post, viewerID, canManage) + } +} + +func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState { + m, err := parseQuestionMeta(post.TypeMeta) + if err != nil || m == nil { + m = &QuestionMeta{} + } + return &QuestionState{ + Solved: m.Solved, + AcceptedCommentID: m.AcceptedCommentID, + CanAccept: canManage && !m.Solved, + CanSolve: canManage && !m.Solved, + CanReopen: canManage && m.Solved, + } +} + +func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState { + m, err := parsePollMeta(post.TypeMeta) + if err != nil || len(m.Options) == 0 { + return nil + } + var votes []model.PostPollVote + s.db.Where("post_id = ?", post.ID).Find(&votes) + counts := make([]int, len(m.Options)) + voterSet := map[uint]struct{}{} + my := []int{} + // optionIndex → userIDs(去重保序) + optVoters := make([][]uint, len(m.Options)) + seenOptUser := make([]map[uint]struct{}, len(m.Options)) + for i := range seenOptUser { + seenOptUser[i] = map[uint]struct{}{} + } + for _, v := range votes { + if v.OptionIndex >= 0 && v.OptionIndex < len(counts) { + counts[v.OptionIndex]++ + if _, ok := seenOptUser[v.OptionIndex][v.UserID]; !ok { + seenOptUser[v.OptionIndex][v.UserID] = struct{}{} + optVoters[v.OptionIndex] = append(optVoters[v.OptionIndex], v.UserID) + } + } + voterSet[v.UserID] = struct{}{} + if viewerID > 0 && v.UserID == viewerID { + my = append(my, v.OptionIndex) + } + } + total := 0 + for _, c := range counts { + total += c + } + // 非匿名:已投票或已结束(或作者/版主)才下发投票人,避免未投票先窥名单 + revealVoters := !m.Anonymous && (m.Closed || len(my) > 0 || canClose) + userMap := map[uint]model.User{} + if revealVoters { + uidSet := map[uint]struct{}{} + for _, ids := range optVoters { + for _, id := range ids { + uidSet[id] = struct{}{} + } + } + if len(uidSet) > 0 { + uids := make([]uint, 0, len(uidSet)) + for id := range uidSet { + uids = append(uids, id) + } + var users []model.User + s.db.Select("id, username, nickname, avatar").Where("id IN ?", uids).Find(&users) + for _, u := range users { + userMap[u.ID] = u + } + } + } + opts := make([]PollOptionStat, len(m.Options)) + for i, text := range m.Options { + pct := 0 + if total > 0 { + pct = counts[i] * 100 / total + } + stat := PollOptionStat{Index: i, Text: text, Votes: counts[i], Percent: pct} + if revealVoters { + vs := make([]PollVoter, 0, len(optVoters[i])) + for _, id := range optVoters[i] { + if u, ok := userMap[id]; ok { + vs = append(vs, PollVoter{ + ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar, + }) + } + } + stat.Voters = vs + } + opts[i] = stat + } + voters := len(voterSet) + return &PollState{ + Options: opts, + Multi: m.Multi, + Closed: m.Closed, + Anonymous: m.Anonymous, + TotalVotes: total, + Voters: voters, + MyOptions: my, + CanVote: viewerID > 0 && !m.Closed && len(my) == 0, + CanClose: canClose && !m.Closed, + CanEditOptions: voters == 0 && !m.Closed, + } +} + +func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState { + m, err := parseBountyMeta(post.TypeMeta) + if err != nil { + return nil + } + settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired + return &BountyState{ + Points: m.Points, + AcceptedCommentID: m.AcceptedCommentID, + Escrowed: m.Escrowed, + Refunded: m.Refunded, + Expired: m.Expired, + EndsAt: m.EndsAt, + Settled: settled, + CanAccept: canManage && m.Escrowed && !settled && !m.Expired, + CanRefund: canManage && m.Escrowed && !settled && !m.Expired, + } +} + +func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) { + var ids []uint + err := s.db.Model(&model.Comment{}). + Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?", + postID, model.ContentStatusPublished, authorID). + Distinct("user_id"). + Pluck("user_id", &ids).Error + return ids, err +} + +func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canManage bool) *LotteryState { + m, err := parseLotteryMeta(post.TypeMeta) + if err != nil { + return nil + } + ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID) + eligible := false + for _, id := range ids { + if viewerID > 0 && id == viewerID { + eligible = true + break + } + } + winners := []LotteryEntrant{} + if len(m.WinnerIDs) > 0 { + var users []model.User + s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users) + umap := map[uint]model.User{} + for _, u := range users { + umap[u.ID] = u + } + for _, id := range m.WinnerIDs { + if u, ok := umap[id]; ok { + winners = append(winners, LotteryEntrant{ + ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar, + }) + } + } + } + return &LotteryState{ + Slots: m.Slots, + Drawn: m.Drawn, + Closed: m.Closed, + EndsAt: m.EndsAt, + EntryCount: len(ids), + Eligible: eligible, + CanDraw: canManage && !m.Drawn, + CanClose: canManage && !m.Drawn && !m.Closed, + Winners: winners, + } +} + +// VotePoll 投票 +func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostDetail, error) { + var post model.Post + if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { + return nil, ErrPostNotFound + } + if model.NormalizePostType(post.PostType) != model.PostTypePoll { + return nil, errors.New("本文不是投票帖") + } + if post.Status != model.ContentStatusPublished { + return nil, errors.New("帖子未公开,无法投票") + } + m, err := parsePollMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Closed { + return nil, ErrPollClosed + } + if len(optionIndexes) == 0 { + return nil, ErrInvalidPollOpt + } + if !m.Multi && len(optionIndexes) != 1 { + return nil, errors.New("单选只能选一项") + } + if m.Multi && len(optionIndexes) > len(m.Options) { + return nil, ErrInvalidPollOpt + } + seen := map[int]bool{} + clean := make([]int, 0, len(optionIndexes)) + for _, idx := range optionIndexes { + if idx < 0 || idx >= len(m.Options) { + return nil, ErrInvalidPollOpt + } + if seen[idx] { + continue + } + seen[idx] = true + clean = append(clean, idx) + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + var n int64 + if err := tx.Model(&model.PostPollVote{}). + Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return ErrAlreadyVoted + } + for _, idx := range clean { + if err := tx.Create(&model.PostPollVote{ + PostID: postID, UserID: userID, OptionIndex: idx, + }).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return s.detailAfterInteract(&post, userID) +} + +// ClosePoll 结束投票 +func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + if model.NormalizePostType(post.PostType) != model.PostTypePoll { + return nil, errors.New("本文不是投票帖") + } + m, err := parsePollMeta(post.TypeMeta) + if err != nil { + return nil, err + } + m.Closed = true + raw, err := encodeMeta(m) + if err != nil { + return nil, err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return nil, err + } + post.TypeMeta = raw + return s.detailAfterInteract(post, userID) +} + +// AcceptQuestion 采纳问答答案 +func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { + return nil, ErrQuestionNotType + } + m, err := parseQuestionMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Solved { + return nil, errors.New("问题已解决") + } + var cm model.Comment + if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { + return nil, errors.New("评论不存在") + } + if cm.Status != model.ContentStatusPublished { + return nil, errors.New("只能采纳已公开的评论") + } + if cm.UserID == post.UserID { + return nil, errors.New("不能采纳自己的评论") + } + m.AcceptedCommentID = commentID + m.Solved = true + raw, err := encodeMeta(m) + if err != nil { + return nil, err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return nil, err + } + post.TypeMeta = raw + return s.detailAfterInteract(post, userID) +} + +// SolveQuestion 手动标为已解决 +func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { + return nil, ErrQuestionNotType + } + m, err := parseQuestionMeta(post.TypeMeta) + if err != nil { + return nil, err + } + m.Solved = true + raw, err := encodeMeta(m) + if err != nil { + return nil, err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return nil, err + } + post.TypeMeta = raw + return s.detailAfterInteract(post, userID) +} + +// ReopenQuestion 重新打开 +func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { + return nil, ErrQuestionNotType + } + m := &QuestionMeta{Solved: false, AcceptedCommentID: 0} + raw, err := encodeMeta(m) + if err != nil { + return nil, err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return nil, err + } + post.TypeMeta = raw + return s.detailAfterInteract(post, userID) +} + +// AcceptBounty 采纳评论并发放悬赏 +func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + _ = s.settleExpiredBountyIfNeeded(post) + s.db.First(post, post.ID) + + if model.NormalizePostType(post.PostType) != model.PostTypeBounty { + return nil, errors.New("本文不是悬赏帖") + } + m, err := parseBountyMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Expired { + return nil, ErrBountyExpired + } + if !m.Escrowed { + return nil, ErrBountyNotEscrow + } + if m.AcceptedCommentID > 0 || m.Refunded { + return nil, ErrBountySettled + } + var cm model.Comment + if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { + return nil, errors.New("评论不存在") + } + if cm.Status != model.ContentStatusPublished { + return nil, errors.New("只能采纳已公开的评论") + } + if cm.UserID == post.UserID { + return nil, errors.New("不能采纳自己的评论") + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil { + return err + } + m.AcceptedCommentID = commentID + m.Escrowed = false + raw, err := encodeMeta(m) + if err != nil { + return err + } + return tx.Model(post).Update("type_meta", raw).Error + }) + if err != nil { + return nil, err + } + s.db.Preload("Board").Preload("User").First(post, post.ID) + return s.detailAfterInteract(post, userID) +} + +// RefundBounty 退回悬赏积分(未采纳时) +func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + _ = s.settleExpiredBountyIfNeeded(post) + s.db.First(post, post.ID) + + if model.NormalizePostType(post.PostType) != model.PostTypeBounty { + return nil, errors.New("本文不是悬赏帖") + } + m, err := parseBountyMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Expired { + return nil, ErrBountyExpired + } + if !m.Escrowed { + return nil, ErrBountyNotEscrow + } + if m.AcceptedCommentID > 0 || m.Refunded { + return nil, ErrBountySettled + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil { + return err + } + m.Refunded = true + m.Escrowed = false + raw, err := encodeMeta(m) + if err != nil { + return err + } + return tx.Model(post).Update("type_meta", raw).Error + }) + if err != nil { + return nil, err + } + s.db.Preload("Board").Preload("User").First(post, post.ID) + return s.detailAfterInteract(post, userID) +} + +func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error { + if model.NormalizePostType(post.PostType) != model.PostTypeBounty { + return nil + } + m, err := parseBountyMeta(post.TypeMeta) + if err != nil { + return nil + } + if !m.Escrowed || m.AcceptedCommentID > 0 || m.Refunded || m.Expired { + return nil + } + ends, ok := parseEndsAt(m.EndsAt) + if !ok || time.Now().UTC().Before(ends) { + return nil + } + return s.db.Transaction(func(tx *gorm.DB) error { + var fresh model.Post + if err := tx.First(&fresh, post.ID).Error; err != nil { + return err + } + fm, err := parseBountyMeta(fresh.TypeMeta) + if err != nil { + return err + } + if !fm.Escrowed || fm.AcceptedCommentID > 0 || fm.Refunded || fm.Expired { + return nil + } + if _, err := CreditTx(tx, fresh.UserID, fm.Points, model.PointReasonBountyRefund, "post", fresh.ID, "悬赏过期退回:"+fresh.Title); err != nil { + return err + } + fm.Expired = true + fm.Refunded = true + fm.Escrowed = false + raw, err := encodeMeta(fm) + if err != nil { + return err + } + if err := tx.Model(&fresh).Update("type_meta", raw).Error; err != nil { + return err + } + post.TypeMeta = raw + return nil + }) +} + +// CloseLotteryEntries 截止抽奖(仍可手动开奖) +func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + m, err := parseLotteryMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Drawn { + return nil, ErrLotteryDrawn + } + m.Closed = true + raw, err := encodeMeta(m) + if err != nil { + return nil, err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return nil, err + } + post.TypeMeta = raw + return s.detailAfterInteract(post, userID) +} + +func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error { + ids, err := s.lotteryEligibleIDs(post.ID, post.UserID) + if err != nil { + return err + } + nWin := m.Slots + if nWin > len(ids) { + nWin = len(ids) + } + if nWin > 0 { + for i := len(ids) - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return err + } + j := int(jBig.Int64()) + ids[i], ids[j] = ids[j], ids[i] + } + m.WinnerIDs = ids[:nWin] + } else { + m.WinnerIDs = []uint{} + } + m.Drawn = true + m.Closed = true + raw, err := encodeMeta(m) + if err != nil { + return err + } + if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { + return err + } + post.TypeMeta = raw + return nil +} + +// DrawLottery 开奖(回帖用户奖池) +func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetail, error) { + post, err := s.loadManageablePost(actor, userID, postID) + if err != nil { + return nil, err + } + if model.NormalizePostType(post.PostType) != model.PostTypeLottery { + return nil, errors.New("本文不是抽奖帖") + } + m, err := parseLotteryMeta(post.TypeMeta) + if err != nil { + return nil, err + } + if m.Drawn { + return nil, ErrLotteryDrawn + } + if err := s.drawLotteryCore(post, m); err != nil { + return nil, err + } + return s.detailAfterInteract(post, userID) +} + +func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error { + if model.NormalizePostType(post.PostType) != model.PostTypeLottery { + return nil + } + m, err := parseLotteryMeta(post.TypeMeta) + if err != nil || m.Drawn { + return nil + } + ends, ok := parseEndsAt(m.EndsAt) + if !ok || time.Now().UTC().Before(ends) { + return nil + } + return s.drawLotteryCore(post, m) +} + +// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖 +func (s *PostService) SettleDueTypedPosts(limit int) { + if limit <= 0 { + limit = 50 + } + var bounties []model.Post + s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). + Order("id ASC").Limit(limit * 3).Find(&bounties) + n := 0 + for i := range bounties { + if n >= limit { + break + } + before := bounties[i].TypeMeta + _ = s.settleExpiredBountyIfNeeded(&bounties[i]) + if bounties[i].TypeMeta != before { + n++ + } + } + + var lotteries []model.Post + s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery). + Order("id ASC").Limit(limit * 3).Find(&lotteries) + n = 0 + for i := range lotteries { + if n >= limit { + break + } + before := lotteries[i].TypeMeta + _ = s.settleDueLotteryIfNeeded(&lotteries[i]) + if lotteries[i].TypeMeta != before { + n++ + } + } +} + +// StartTypedPostSettler 启动定时结算 +func StartTypedPostSettler(s *PostService) { + go func() { + t := time.NewTicker(2 * time.Minute) + defer t.Stop() + for range t.C { + s.SettleDueTypedPosts(50) + } + }() +} + +func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, error) { + var post model.Post + if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { + return nil, ErrPostNotFound + } + if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) { + return nil, ErrPostForbidden + } + return &post, nil +} + +func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) { + detail := buildPostDetail(post) + locked, hint := s.evalContentAccess(post, viewerID, nil) + detail.ContentLocked = locked + detail.AccessHint = hint + if locked { + detail.Content = "" + } + atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID) + detail.Attachments = atts + s.fillInteractState(detail, post, viewerID, nil) + return detail, nil +} diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index 7a4161e..3840594 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -67,6 +67,8 @@ const ( RatePost = "post" RateComment = "comment" RateChat = "chat" // 群聊发消息 + RateUpload = "upload" // 帖子插图等上传 + RateInteract = "interact" // 投票/抽奖/解锁等互动 ) // DefaultRateLimiter 创建默认速率限制器 @@ -77,5 +79,7 @@ func DefaultRateLimiter() *RateLimiter { rl.SetLimit(RatePost, 10) // 发帖 10/分钟 rl.SetLimit(RateComment, 30) // 评论 30/分钟 rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟 + rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟 + rl.SetLimit(RateInteract, 40) // 互动 40/分钟 return rl } diff --git a/backend/service/upload.go b/backend/service/upload.go index 1432648..2326074 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -5,6 +5,9 @@ import ( "crypto/rand" "encoding/hex" "errors" + "image" + _ "image/jpeg" + _ "image/png" "log" "os" "path/filepath" @@ -20,6 +23,10 @@ const ( AvatarMaxBytes = 2 << 20 // 2 MiB AvatarMinDim = 64 AvatarMaxDim = 512 + + // 帖子插图:允许 JPEG/PNG/WebP,不强制转码 + ImageMaxBytes = 5 << 20 // 5 MiB + ImageMaxDim = 4096 ) // UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments @@ -34,7 +41,10 @@ func NewUploadService(db *gorm.DB, uploadDir string) *UploadService { // EnsureDir 启动时确保上传目录存在 func (s *UploadService) EnsureDir() error { - return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755) + if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil { + return err + } + return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755) } // SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像 @@ -96,6 +106,89 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment, return att, nil } +// imageFormat 由魔数识别的插图格式 +type imageFormat struct { + ext string + mime string +} + +func detectImageFormat(data []byte) (imageFormat, error) { + if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff { + return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil + } + if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" { + return imageFormat{ext: ".png", mime: "image/png"}, nil + } + if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return imageFormat{ext: ".webp", mime: "image/webp"}, nil + } + return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP") +} + +func decodeImageSize(data []byte, mime string) (w, h int, err error) { + r := bytes.NewReader(data) + var cfg image.Config + switch mime { + case "image/webp": + cfg, err = webp.DecodeConfig(r) + default: + cfg, _, err = image.DecodeConfig(r) + } + if err != nil { + return 0, 0, errors.New("无法解析图片") + } + return cfg.Width, cfg.Height, nil +} + +// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image) +func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) { + if len(data) == 0 { + return nil, errors.New("文件为空") + } + if len(data) > ImageMaxBytes { + return nil, errors.New("图片不能超过 5MB") + } + format, err := detectImageFormat(data) + if err != nil { + return nil, err + } + w, h, err := decodeImageSize(data, format.mime) + if err != nil { + return nil, err + } + if w < 1 || h < 1 { + return nil, errors.New("无效的图片尺寸") + } + if w > ImageMaxDim || h > ImageMaxDim { + return nil, errors.New("图片边长不能超过 4096px") + } + + nameBytes := make([]byte, 16) + if _, err := rand.Read(nameBytes); err != nil { + return nil, err + } + filename := hex.EncodeToString(nameBytes) + format.ext + fullPath := filepath.Join(s.dir, "images", filename) + if err := os.WriteFile(fullPath, data, 0o644); err != nil { + return nil, err + } + + att := &model.Attachment{ + UserID: userID, + Kind: model.AttachmentKindImage, + URL: "/uploads/images/" + filename, + MIME: format.mime, + Size: len(data), + Width: w, + Height: h, + } + if err := s.db.Create(att).Error; err != nil { + _ = os.Remove(fullPath) + return nil, err + } + return att, nil +} + // UseAvatar 选用一张【本人历史上传】的头像 func (s *UploadService) UseAvatar(userID uint, url string) error { url = strings.TrimSpace(url) diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx index 425860c..c0e2e12 100644 --- a/frontend/app/admin/AdminNav.tsx +++ b/frontend/app/admin/AdminNav.tsx @@ -12,6 +12,7 @@ import { ShieldCheck, ArrowLeft, ClipboardCheck, + Coins, } from "lucide-react"; import type { User } from "@/lib/api"; import { apiAdminPendingCounts } from "@/lib/api"; @@ -47,6 +48,12 @@ const NAV_ITEMS: NavItem[] = [ visible: (u) => canModerateAny(u), pendingBadge: true, }, + { + href: "/admin/economy", + label: "积分看板", + icon: Coins, + visible: (u) => isAdminOrAbove(u.role), + }, { href: "/admin/announcements", label: "公告管理", diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 7769443..9a1a7f5 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -191,7 +191,7 @@ export default function AnnouncementAdmin() {
@@ -334,7 +334,7 @@ export default function AnnouncementAdmin() { target="_blank" className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]" style={{ color: "var(--ink-3)" }} - title="查看" + data-tip="查看" aria-label="查看公告" >该板块还没有帖子
来发第一篇,抢占沙发
- +正在准备发帖…
+可用余额
++ {balance === null ? "—" : balance.toLocaleString("zh-CN")} +
++ 签到、悬赏采纳与附件收益会增加余额;解锁正文、悬赏托管与积分附件会扣减。 +
+加载中…
+ ) : items.length === 0 ? ( +暂无流水,去首页签到赚第一笔积分吧
+ ) : ( ++ {pointReasonLabel(it.reason)} +
+ {it.note && ( +{it.note}
+ )} ++ {formatRelative(it.created_at)} +
+= 0 ? "var(--accent)" : "var(--danger)" }} + > + {it.delta >= 0 ? `+${it.delta}` : it.delta} +
++ 余 {it.balance} +
+正在加载帖子…
-
-
累计积分
+可用余额
+ {isOwner && ( + + 查看流水 → + + )} )} diff --git a/frontend/components/AvatarCropModal.tsx b/frontend/components/AvatarCropModal.tsx index 464ce64..6df3848 100644 --- a/frontend/components/AvatarCropModal.tsx +++ b/frontend/components/AvatarCropModal.tsx @@ -315,7 +315,7 @@ export default function AvatarCropModal({ type="button" onClick={() => handlePick(item)} disabled={busy || active} - title={ + data-tip={ active ? "当前头像" : item.kind === "image" @@ -366,7 +366,7 @@ export default function AvatarCropModal({ }} disabled={busy} aria-label={confirming ? "再次点击确认删除" : "删除图片"} - title={confirming ? "再次点击确认彻底删除" : "彻底删除"} + data-tip={confirming ? "再次点击确认彻底删除" : "彻底删除"} className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50" style={{ background: confirming ? "var(--red, #dc2626)" : "var(--panel)", diff --git a/frontend/components/AvatarPicker.tsx b/frontend/components/AvatarPicker.tsx index c0f0568..b79705e 100644 --- a/frontend/components/AvatarPicker.tsx +++ b/frontend/components/AvatarPicker.tsx @@ -69,8 +69,8 @@ export default function AvatarPicker({ onClick={() => setOpen(true)} className="group relative inline-flex items-center justify-center shrink-0 p-0 rounded-full cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--panel)]" style={{ width: size, height: size, lineHeight: 0 }} + data-tip="更换头像" aria-label="更换头像" - title="更换头像" >,
+ onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }),
+ },
+ {
+ key: "quote",
+ label: "引用",
+ icon: , + onClick: () => + runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }), + }, + { + key: "list", + label: "列表", + icon:
+ {state.solved + ? state.accepted_comment_id + ? `已采纳评论 #${state.accepted_comment_id}` + : "作者已标记为已解决" + : "可在评论旁采纳答案,或手动标记已解决"} +
++ {statusText} + {state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""} +
+在评论旁点击「采纳」发放积分
+ )} ++ 已有 {state.entry_count} 名回帖用户参与 + {state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""} + {state.eligible && !state.drawn ? " · 你已在奖池中" : ""} + {state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""} +
+ {!state.drawn && ( +对本帖发表评论即可参与(楼主除外)
+ )} ++ {state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"} +
+ {state.winners.length > 0 && ( +