package handler import ( "context" "errors" "io" "net/http" "net/url" "path/filepath" "strconv" "time" "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) // Posts 获取帖子列表 func (h *Handlers) Posts(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) sort := c.DefaultQuery("sort", "latest") keyword := c.Query("keyword") recommended := c.Query("recommended") == "true" var viewerID uint var actor *service.Actor if claims := middleware.CurrentUser(c); claims != nil { viewerID = claims.ID if a, err := h.Auth.LoadActor(claims.ID); err == nil { actor = a } } items, total, err := h.Post.List(service.PostListQuery{ BoardID: uint(boardID), Page: page, Size: size, Sort: sort, Keyword: keyword, Recommended: recommended, ViewerID: viewerID, Actor: actor, }) if err != nil { 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 { ids = append(ids, p.ID) } likedMap := h.Like.BatchHasLiked(ids, claims.ID) for i := range items { items[i].Liked = likedMap[items[i].ID] } } c.JSON(http.StatusOK, gin.H{ "posts": items, "total": total, "page": page, "size": size, }) } // PostDetail 获取帖子详情 func (h *Handlers) PostDetail(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 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) } pwdUnlocked := h.HidePwd.ReadUnlocked(c, uint(id)) post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor, pwdUnlocked) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) return } if claims != nil { post.Liked = h.Like.HasLiked(post.ID, claims.ID) } h.attachNecroReplyHint(claims, post) c.JSON(http.StatusOK, gin.H{"post": post}) } // 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,max=20000"` Tags string `json:"tags"` PostType string `json:"post_type"` TypeMeta string `json:"type_meta"` AttachmentIDs []uint `json:"attachment_ids"` } // CreatePost 创建帖子 func (h *Handlers) CreatePost(c *gin.Context) { claims := middleware.CurrentUser(c) var req CreatePostRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } status := h.resolvePublishStatus(claims.ID, claims.Role) 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, TypeMeta: req.TypeMeta, Status: status, AttachmentIDs: req.AttachmentIDs, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if post.Status == model.ContentStatusPublished { h.broadcastFeedChanged("post", post.ID, post.BoardID, claims.ID) } else if post.Status == model.ContentStatusPending { h.notifyPendingReview(post.BoardID, claims.ID, post.ID, 0, "有新帖待审核:"+post.Title) } c.JSON(http.StatusOK, gin.H{"post": post}) } // UpdatePostRequest 更新帖子请求 type UpdatePostRequest struct { Title string `json:"title" binding:"omitempty,min=1,max=256"` Content string `json:"content" binding:"omitempty,min=1,max=20000"` Tags string `json:"tags"` TypeMeta *string `json:"type_meta"` AttachmentIDs *[]uint `json:"attachment_ids"` } // UpdatePost 编辑帖子 func (h *Handlers) UpdatePost(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 UpdatePostRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } actor := h.loadActor(claims.ID) post, err := h.Post.Update(actor, uint(id), claims.ID, service.UpdatePostInput{ Title: req.Title, Content: req.Content, Tags: req.Tags, TypeMeta: req.TypeMeta, AttachmentIDs: req.AttachmentIDs, }) if err != nil { respondPostModError(c, err) return } c.JSON(http.StatusOK, gin.H{"post": post}) } // PostEditHistory 帖子编辑历史(登录即可见;分页) func (h *Handlers) PostEditHistory(c *gin.Context) { claims := middleware.CurrentUser(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) items, total, err := h.Post.ListEditHistory(h.loadActor(claims.ID), uint(id), page, size) if err != nil { 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()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) } return } if page < 1 { page = 1 } if size < 1 || size > 50 { size = 10 } c.JSON(http.StatusOK, gin.H{ "items": items, "total": total, "page": page, "size": size, }) } // 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}) } // UnlockPostPasswordRequest 密码解锁隐藏块 type UnlockPostPasswordRequest struct { Password string `json:"password" binding:"required,min=1,max=64"` } // UnlockPostPassword 输入密码解锁正文密码隐藏块(游客可用,签名 cookie 记住) func (h *Handlers) UnlockPostPassword(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } var req UnlockPostPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请输入密码"}) return } matched, err := h.Post.UnlockByPassword(uint(id), req.Password) if err != nil { if errors.Is(err, service.ErrPostNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } h.HidePwd.WriteUnlocked(c, uint(id), matched) claims := middleware.CurrentUser(c) var viewerID uint var loadActor func() *service.Actor if claims != nil { viewerID = claims.ID loadActor = h.actorLoader(claims.ID) } pwdUnlocked := h.HidePwd.ReadUnlocked(c, uint(id)) for _, i := range matched { pwdUnlocked[i] = true } post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor, pwdUnlocked) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) return } if claims != nil { post.Liked = h.Like.HasLiked(post.ID, claims.ID) } h.attachNecroReplyHint(claims, post) 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) maxBytes, err := h.Setting.AttachmentMaxBytes() if err != nil || maxBytes < 1 { maxBytes = service.FileMaxBytes } overhead := int64(64 << 10) // multipart 边界开销 limit := maxBytes + overhead if c.Request.ContentLength > limit { mb := int(maxBytes >> 20) if mb < 1 { mb = 1 } c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) file, err := c.FormFile("file") if err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { mb := int(maxBytes >> 20) if mb < 1 { mb = 1 } c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"}) return } if file.Size > maxBytes { mb := int(maxBytes >> 20) if mb < 1 { mb = 1 } c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) 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() att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, f, price) if err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) || errors.Is(err, service.ErrAttachmentTooLarge) { mb := int(maxBytes >> 20) if mb < 1 { mb = 1 } c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) return } 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("X-Content-Type-Options", "nosniff") c.Header("Content-Security-Policy", "sandbox") ct := att.MIME ext := service.ExtOfFilename(att.Name) if service.IsActiveContentExt(ext) || ct == "" { ct = "application/octet-stream" } c.Header("Content-Type", ct) if att.ObjectID != "" { ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) defer cancel() r, _, err := h.Ops.OpenObject(ctx, att.ObjectID, false) if err != nil { c.JSON(503, gin.H{"error": "文件暂不可用"}) return } defer r.Close() c.Header("Cache-Control", "private, no-store") c.Status(200) _, _ = io.Copy(c.Writer, r) return } c.File(path) _ = filepath.Base(path) } // DeletePost 删除帖子(作者自删或 staff 软删;staff 须提交类型与理由) func (h *Handlers) DeletePost(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 body struct { DeleteType string `json:"delete_type"` DeleteReason string `json:"delete_reason"` } _ = c.ShouldBindJSON(&body) staffDeleted, authorID, err := h.Post.Delete( h.loadActor(claims.ID), uint(id), claims.ID, service.DeletePostOpts{DeleteType: body.DeleteType, DeleteReason: body.DeleteReason}, ) if err != nil { respondPostModError(c, err) return } if staffDeleted { label := model.PostDeleteTypeLabel(body.DeleteType) preview := label if body.DeleteReason != "" { preview = label + ":" + body.DeleteReason } h.Notification.Create(authorID, claims.ID, model.NotificationTypeDeleted, uint(id), 0, preview) } c.JSON(http.StatusOK, gin.H{"message": "已删除"}) } // TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限) func (h *Handlers) TogglePin(c *gin.Context) { if !h.requireAdminOrAbove(c) { return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } pinned, err := h.Post.TogglePin(uint(id)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"pinned": pinned}) } // ToggleRecommend 切换帖子加精(管理员及以上,板块管理员无此权限) func (h *Handlers) ToggleRecommend(c *gin.Context) { if !h.requireAdminOrAbove(c) { return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } claims := middleware.CurrentUser(c) rec, err := h.Post.ToggleRecommend(uint(id), claims.ID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"recommended": rec}) } // TogglePostLock 切换帖子手动锁定(管理员及以上;锁定后普通用户不可编辑/回复,staff 豁免) func (h *Handlers) TogglePostLock(c *gin.Context) { if !h.requireAdminOrAbove(c) { return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } locked, err := h.Post.ToggleLock(uint(id)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"locked": locked}) } // attachNecroReplyHint 旧帖回复确认提示:仅当 viewer 已登录、非 staff、规则开启、 // 帖子判定为旧帖、且其评论将直发(待审不扣分不提醒,不弹窗)时返回 func (h *Handlers) attachNecroReplyHint(claims *service.UserClaims, detail *service.PostDetail) { if claims == nil || detail == nil || detail.Tombstone || detail.NecroReply != nil { return } if model.IsStaff(model.Role(claims.Role)) { return } afterHours, err1 := h.Setting.NecroReplyAfterHours() penalty, err2 := h.Setting.NecroReplyPenalty() if err1 != nil || err2 != nil || afterHours <= 0 || penalty <= 0 { return } // 旧帖基准:最后回复时间;无回复(null)回落发帖时间 baseline := detail.CreatedAt if detail.LastReplyAt != nil { baseline = *detail.LastReplyAt } if !service.IsNecroReply(baseline, afterHours, time.Now()) { return } if h.resolvePublishStatus(claims.ID, claims.Role) != model.ContentStatusPublished { return } detail.NecroReply = &service.NecroReplyHint{AfterHours: afterHours, Penalty: penalty} } // requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准 func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool { claims := middleware.CurrentUser(c) actor, err := h.Auth.LoadActor(claims.ID) if err != nil || model.RoleLevel(actor.Role) < model.RoleLevel(model.RoleAdmin) { c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"}) return false } return true } // respondPostModError 帖子编辑/删除业务错误 → HTTP 状态码 func respondPostModError(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), errors.Is(err, service.ErrAuthorProtected), errors.Is(err, service.ErrPostLocked), errors.Is(err, service.ErrPostEditLocked): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPollOptionsLocked), errors.Is(err, service.ErrLotteryPrizesLocked): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } }