1136 lines
36 KiB
Go
1136 lines
36 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/markdown"
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 帖子操作错误
|
||
var (
|
||
ErrPostNotFound = errors.New("帖子不存在")
|
||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
|
||
ErrPostInvalidType = errors.New("无效的删除类型")
|
||
)
|
||
|
||
// PostService 帖子服务
|
||
type PostService struct {
|
||
db *gorm.DB
|
||
setting *SettingService
|
||
devMode bool
|
||
}
|
||
|
||
func NewPostService(db *gorm.DB) *PostService {
|
||
return &PostService{db: db}
|
||
}
|
||
|
||
// WithSetting 注入站点设置(发帖冷静期可读配置);可链式调用
|
||
func (s *PostService) WithSetting(setting *SettingService) *PostService {
|
||
s.setting = setting
|
||
return s
|
||
}
|
||
|
||
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
|
||
func (s *PostService) WithDevMode(dev bool) *PostService {
|
||
s.devMode = dev
|
||
return s
|
||
}
|
||
|
||
// PostListQuery 帖子列表查询参数
|
||
type PostListQuery struct {
|
||
BoardID uint
|
||
Page int
|
||
Size int
|
||
Sort string // latest | recommended | new
|
||
Keyword string // 搜索关键词
|
||
Recommended bool // 仅精华帖
|
||
ViewerID uint // 当前查看者(0=游客)
|
||
Actor *Actor // 查看者权限快照;nil 仅 published
|
||
}
|
||
|
||
// toPostListItems 将 Post 模型批量转为不含正文的列表项
|
||
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: 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,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
// LastReplyUser 最后回复人(仅列表展示所需字段)
|
||
type LastReplyUser struct {
|
||
ID uint `json:"id"`
|
||
Username string `json:"username"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
}
|
||
|
||
// LastReplyInfo 帖子最后一条已发布评论的摘要
|
||
type LastReplyInfo struct {
|
||
User LastReplyUser `json:"user"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// 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"`
|
||
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 批量填充每帖最后一条已发布评论(发帖人+时间),
|
||
// 单条 SQL 取每帖最新评论,再批量取用户,避免 N+1
|
||
func (s *PostService) fillLastReply(items []PostListItem) {
|
||
if len(items) == 0 {
|
||
return
|
||
}
|
||
postIDs := make([]uint, 0, len(items))
|
||
for _, it := range items {
|
||
postIDs = append(postIDs, it.ID)
|
||
}
|
||
|
||
type latestRow struct {
|
||
PostID uint
|
||
UserID uint
|
||
CreatedAt time.Time
|
||
}
|
||
var rows []latestRow
|
||
if err := s.db.Raw(`
|
||
SELECT DISTINCT ON (post_id) post_id, user_id, created_at
|
||
FROM comments
|
||
WHERE post_id IN ? AND status = 'published' AND deleted_at IS NULL
|
||
ORDER BY post_id, created_at DESC`, postIDs).Scan(&rows).Error; err != nil || len(rows) == 0 {
|
||
return
|
||
}
|
||
|
||
userIDs := make([]uint, 0, len(rows))
|
||
seen := map[uint]bool{}
|
||
for _, r := range rows {
|
||
if !seen[r.UserID] {
|
||
seen[r.UserID] = true
|
||
userIDs = append(userIDs, r.UserID)
|
||
}
|
||
}
|
||
var users []model.User
|
||
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
|
||
return
|
||
}
|
||
userMap := make(map[uint]model.User, len(users))
|
||
for _, u := range users {
|
||
userMap[u.ID] = u
|
||
}
|
||
|
||
replyMap := make(map[uint]latestRow, len(rows))
|
||
for _, r := range rows {
|
||
replyMap[r.PostID] = r
|
||
}
|
||
for i := range items {
|
||
r, ok := replyMap[items[i].ID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
u, ok := userMap[r.UserID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
items[i].LastReply = &LastReplyInfo{
|
||
User: LastReplyUser{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar},
|
||
CreatedAt: r.CreatedAt,
|
||
}
|
||
}
|
||
}
|
||
|
||
// applyListVisibility 公开流可见性:published,或 pending 且(作者本人 / 该板可审者)
|
||
func applyListVisibility(db *gorm.DB, viewerID uint, actor *Actor) *gorm.DB {
|
||
if viewerID == 0 || actor == nil {
|
||
return db.Where("status = ?", model.ContentStatusPublished)
|
||
}
|
||
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||
return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
|
||
}
|
||
if actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
|
||
return db.Where(
|
||
"status = ? OR (status = ? AND (user_id = ? OR board_id IN ?))",
|
||
model.ContentStatusPublished, model.ContentStatusPending, viewerID, actor.BoardIDs,
|
||
)
|
||
}
|
||
return db.Where(
|
||
"status = ? OR (status = ? AND user_id = ?)",
|
||
model.ContentStatusPublished, model.ContentStatusPending, viewerID,
|
||
)
|
||
}
|
||
|
||
// List 获取帖子列表
|
||
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||
if q.Page < 1 {
|
||
q.Page = 1
|
||
}
|
||
if q.Size < 1 || q.Size > 50 {
|
||
q.Size = 20
|
||
}
|
||
|
||
query := applyListVisibility(s.db.Model(&model.Post{}), q.ViewerID, q.Actor)
|
||
if q.BoardID > 0 {
|
||
query = query.Where("board_id = ?", q.BoardID)
|
||
}
|
||
if q.Keyword != "" {
|
||
kw := "%" + q.Keyword + "%"
|
||
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
|
||
}
|
||
if q.Recommended {
|
||
query = query.Where("recommended = ?", true)
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
var posts []model.Post
|
||
offset := (q.Page - 1) * q.Size
|
||
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
|
||
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
|
||
order := `pinned DESC, GREATEST(created_at, COALESCE((
|
||
SELECT MAX(c.created_at) FROM comments c
|
||
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
|
||
), created_at)) DESC`
|
||
if q.Sort == "recommended" {
|
||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||
}
|
||
// new:纯按发帖时间(新帖子)
|
||
if q.Sort == "new" {
|
||
order = "pinned DESC, created_at DESC"
|
||
}
|
||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
items := toPostListItems(posts)
|
||
s.fillLastReply(items)
|
||
return items, total, nil
|
||
}
|
||
|
||
// ListByUser 获取指定用户发布的帖子。
|
||
// viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。
|
||
func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 || size > 50 {
|
||
size = 20
|
||
}
|
||
query := s.db.Model(&model.Post{}).Where("user_id = ?", userID)
|
||
if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) {
|
||
query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
|
||
} else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
|
||
query = query.Where(
|
||
"status = ? OR (status = ? AND board_id IN ?)",
|
||
model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs,
|
||
)
|
||
} else {
|
||
query = query.Where("status = ?", model.ContentStatusPublished)
|
||
}
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
var posts []model.Post
|
||
offset := (page - 1) * size
|
||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items := toPostListItems(posts)
|
||
s.fillLastReply(items)
|
||
return items, total, nil
|
||
}
|
||
|
||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
|
||
var post model.Post
|
||
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return post.UserID, nil
|
||
}
|
||
|
||
// GetBoardID 获取帖子所属板块 ID(不增加浏览量)
|
||
func (s *PostService) GetBoardID(postID uint) (uint, error) {
|
||
var post model.Post
|
||
if err := s.db.Select("board_id").First(&post, postID).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return post.BoardID, nil
|
||
}
|
||
|
||
// CountByUser 统计用户发帖数
|
||
func (s *PostService) CountByUser(userID uint) (int64, error) {
|
||
var total int64
|
||
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
|
||
return total, err
|
||
}
|
||
|
||
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
|
||
func (s *PostService) TogglePin(id uint) (int, error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, id).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
var newPinned int
|
||
if post.Pinned > 0 {
|
||
newPinned = 0
|
||
} else {
|
||
newPinned = 1
|
||
}
|
||
result := s.db.Model(&post).Update("pinned", newPinned)
|
||
if result.Error != nil {
|
||
return 0, result.Error
|
||
}
|
||
return newPinned, nil
|
||
}
|
||
|
||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, id).Error; err != nil {
|
||
return false, err
|
||
}
|
||
newVal := !post.Recommended
|
||
result := s.db.Model(&post).Update("recommended", newVal)
|
||
if result.Error != nil {
|
||
return false, result.Error
|
||
}
|
||
return newVal, nil
|
||
}
|
||
|
||
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
|
||
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
|
||
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
|
||
if post.Status == model.ContentStatusPublished {
|
||
return true
|
||
}
|
||
if viewerID > 0 && post.UserID == viewerID {
|
||
return true
|
||
}
|
||
if loadActor == nil {
|
||
return false
|
||
}
|
||
return loadActor().CanModerateBoard(post.BoardID)
|
||
}
|
||
|
||
// 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"`
|
||
// 软删 tombstone:列表不可见,详情专用页可读
|
||
Tombstone bool `json:"tombstone,omitempty"`
|
||
DeleteType string `json:"delete_type,omitempty"`
|
||
DeleteReason string `json:"delete_reason,omitempty"`
|
||
DeletedBy uint `json:"deleted_by,omitempty"`
|
||
StaffDeleted bool `json:"staff_deleted,omitempty"`
|
||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||
}
|
||
|
||
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
|
||
type CreatePostInput struct {
|
||
UserID uint
|
||
BoardID uint
|
||
Title string
|
||
Content string
|
||
Tags string
|
||
PostType string
|
||
TypeMeta string
|
||
Status string
|
||
AttachmentIDs []uint
|
||
}
|
||
|
||
// UpdatePostInput 编辑入参
|
||
type UpdatePostInput struct {
|
||
Title string
|
||
Content string
|
||
Tags string
|
||
TypeMeta *string
|
||
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
|
||
}
|
||
|
||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。
|
||
// pwdUnlocked 为密码隐藏块已解锁下标(来自签名 cookie);可为 nil。
|
||
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
|
||
var post model.Post
|
||
err := s.db.Preload("Board").Preload("User").First(&post, id).Error
|
||
if err != nil {
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
// 默认 scope 找不到:尝试软删行 → tombstone
|
||
if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
if !post.DeletedAt.Valid {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
return buildPostTombstone(&post), nil
|
||
}
|
||
if !visibleToPost(&post, viewerID, loadActor) {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
|
||
_ = s.settleExpiredBountyIfNeeded(&post)
|
||
_ = s.settleDueLotteryIfNeeded(&post)
|
||
_ = s.settleDuePollIfNeeded(&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"))
|
||
post.ViewCount++
|
||
|
||
detail := buildPostDetail(&post)
|
||
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked)
|
||
detail.Content = sanitized
|
||
detail.ContentLocked = fullyLocked
|
||
detail.AccessHint = hint
|
||
|
||
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
|
||
detail.Attachments = atts
|
||
s.fillInteractState(detail, &post, viewerID, loadActor)
|
||
return detail, nil
|
||
}
|
||
|
||
func buildPostTombstone(post *model.Post) *PostDetail {
|
||
var deletedAt *time.Time
|
||
if post.DeletedAt.Valid {
|
||
t := post.DeletedAt.Time
|
||
deletedAt = &t
|
||
}
|
||
staffDeleted := post.DeletedBy != 0 && post.DeletedBy != post.UserID
|
||
return &PostDetail{
|
||
ID: post.ID,
|
||
BoardID: post.BoardID,
|
||
UserID: post.UserID,
|
||
Title: post.Title,
|
||
PostType: model.NormalizePostType(post.PostType),
|
||
Status: post.Status,
|
||
CreatedAt: post.CreatedAt,
|
||
UpdatedAt: post.UpdatedAt,
|
||
Board: post.Board,
|
||
User: post.User,
|
||
Attachments: []PostAttachmentDTO{},
|
||
Tombstone: true,
|
||
DeleteType: post.DeleteType,
|
||
DeleteReason: post.DeleteReason,
|
||
DeletedBy: post.DeletedBy,
|
||
StaffDeleted: staffDeleted,
|
||
DeletedAt: deletedAt,
|
||
}
|
||
}
|
||
|
||
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{},
|
||
}
|
||
}
|
||
|
||
// sanitizePostContent 按读者能力对正文 [hide] 块脱敏;fullyLocked 表示无可见正文。
|
||
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (content string, fullyLocked bool, hint string) {
|
||
caps := s.buildHideViewerCaps(post, viewerID, loadActor, pwdUnlocked)
|
||
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)
|
||
if !fully {
|
||
return sanitized, false, ""
|
||
}
|
||
access := model.NormalizeContentAccess(post.ContentAccess)
|
||
switch access {
|
||
case model.ContentAccessPoints:
|
||
return sanitized, true, "支付积分后可见隐藏内容"
|
||
case model.ContentAccessPassword:
|
||
return sanitized, true, "输入密码后可见隐藏内容"
|
||
case model.ContentAccessMixed:
|
||
return sanitized, true, "满足条件后可见隐藏内容"
|
||
case model.ContentAccessReply:
|
||
return sanitized, true, "回复本帖后可见隐藏内容"
|
||
case model.ContentAccessLogin:
|
||
return sanitized, true, "登录后可见隐藏内容"
|
||
default:
|
||
return sanitized, true, "正文暂不可见"
|
||
}
|
||
}
|
||
|
||
func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) markdown.ViewerCaps {
|
||
caps := markdown.ViewerCaps{PasswordUnlocked: pwdUnlocked}
|
||
if viewerID > 0 && post.UserID == viewerID {
|
||
caps.Bypass = true
|
||
caps.LoggedIn = true
|
||
return caps
|
||
}
|
||
if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
|
||
caps.Bypass = true
|
||
caps.LoggedIn = true
|
||
return caps
|
||
}
|
||
if viewerID == 0 {
|
||
return caps
|
||
}
|
||
caps.LoggedIn = true
|
||
|
||
needReply := false
|
||
needPoints := false
|
||
blocks, err := markdown.ParseHideBlocks(post.Content)
|
||
if err == nil {
|
||
for _, b := range blocks {
|
||
if b.Kind == markdown.HideKindReply {
|
||
needReply = true
|
||
}
|
||
if b.Kind == markdown.HideKindPoints {
|
||
needPoints = true
|
||
}
|
||
}
|
||
} else {
|
||
access := model.NormalizeContentAccess(post.ContentAccess)
|
||
needReply = access == model.ContentAccessReply || access == model.ContentAccessMixed
|
||
needPoints = post.AccessPoints > 0 || access == model.ContentAccessPoints || access == model.ContentAccessMixed
|
||
}
|
||
|
||
if needReply {
|
||
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)
|
||
caps.HasReplied = n > 0
|
||
}
|
||
if needPoints {
|
||
var n int64
|
||
s.db.Model(&model.PostContentUnlock{}).
|
||
Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
|
||
caps.PointsPaid = n > 0
|
||
}
|
||
return caps
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// UnlockByPassword 校验密码,返回匹配的隐藏块下标(调用方写 cookie)
|
||
func (s *PostService) UnlockByPassword(postID uint, password string) (matched []int, err error) {
|
||
var post model.Post
|
||
if err := s.db.Select("id", "content", "status").First(&post, postID).Error; err != nil {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
password = strings.TrimSpace(password)
|
||
if password == "" {
|
||
return nil, errors.New("请输入密码")
|
||
}
|
||
hit, err := markdown.MatchPasswordBlocks(post.Content, password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(hit) == 0 {
|
||
return nil, errors.New("密码错误")
|
||
}
|
||
return hit, 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
|
||
}
|
||
derived, derr := markdown.DeriveAccessFromContent(post.Content)
|
||
if derr != nil {
|
||
return nil, derr
|
||
}
|
||
need := derived.Points
|
||
if need <= 0 {
|
||
need = post.AccessPoints
|
||
}
|
||
if need <= 0 {
|
||
return nil, errors.New("本文无需积分解锁")
|
||
}
|
||
if post.UserID == userID {
|
||
return s.detailAfterInteract(&post, userID)
|
||
}
|
||
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
|
||
}
|
||
return s.detailAfterInteract(&post, userID)
|
||
}
|
||
|
||
// Create 创建帖子。status 由 handler 按角色计算
|
||
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||
if err := NewOperations(s.db, nil).Filter(scope, text, in.UserID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
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("无效的帖子类型")
|
||
}
|
||
if err := markdown.ValidateHideContent(content); err != nil {
|
||
return nil, err
|
||
}
|
||
derived, err := markdown.DeriveAccessFromContent(content)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
access := model.NormalizeContentAccess(derived.Access)
|
||
accessPts := derived.Points
|
||
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 {
|
||
bm.EndsAt = resolveEndsAtOnPublish(bm.EndsAt, bm.ExpireDays, 7, []int{3, 7, 14, 30}, now)
|
||
bm.ExpireDays = 0
|
||
}
|
||
typeMeta, _ = encodeMeta(bm)
|
||
}
|
||
|
||
if postType == model.PostTypeLottery {
|
||
lm, _ := parseLotteryMeta(typeMeta)
|
||
if lm.WinnerIDs == nil {
|
||
lm.WinnerIDs = []uint{}
|
||
}
|
||
if lm.Prizes == nil {
|
||
lm.Prizes = []LotteryPrize{}
|
||
}
|
||
lm.Slots = lotterySlotsFromPrizes(lm.Prizes)
|
||
if status == model.ContentStatusPublished {
|
||
lm.EndsAt = resolveEndsAtOnPublish(lm.EndsAt, lm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
|
||
lm.ExpireDays = 0
|
||
}
|
||
typeMeta, _ = encodeMeta(lm)
|
||
}
|
||
|
||
if postType == model.PostTypePoll {
|
||
pm, _ := parsePollMeta(typeMeta)
|
||
if status == model.ContentStatusPublished {
|
||
pm.EndsAt = resolveEndsAtOnPublish(pm.EndsAt, pm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
|
||
pm.ExpireDays = 0
|
||
}
|
||
typeMeta, _ = encodeMeta(pm)
|
||
}
|
||
|
||
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) {
|
||
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||
if err := NewOperations(s.db, nil).Filter(scope, text, userID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
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("内容不能为空")
|
||
}
|
||
if err := markdown.ValidateHideContent(c); err != nil {
|
||
return nil, err
|
||
}
|
||
derived, err := markdown.DeriveAccessFromContent(c)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updates["content"] = c
|
||
updates["content_access"] = model.NormalizeContentAccess(derived.Access)
|
||
updates["access_points"] = derived.Points
|
||
}
|
||
updates["tags"] = in.Tags
|
||
|
||
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
|
||
neu.EndsAt = old.EndsAt
|
||
neu.ClosedAt = old.ClosedAt
|
||
neu.ExpireDays = 0
|
||
meta, _ = encodeMeta(neu)
|
||
} else {
|
||
neu.Closed = old.Closed
|
||
neu.EndsAt = old.EndsAt
|
||
neu.ClosedAt = old.ClosedAt
|
||
neu.ExpireDays = 0
|
||
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
|
||
// 已有评论或已开奖:冻结奖品(名额由奖品合计推导)
|
||
locked := old.Drawn || post.CommentCount > 0
|
||
if locked {
|
||
if !lotteryPrizesEqual(neu.Prizes, old.Prizes) || neu.Slots != old.Slots {
|
||
return nil, ErrLotteryPrizesLocked
|
||
}
|
||
neu.Prizes = old.Prizes
|
||
neu.Slots = old.Slots
|
||
} else {
|
||
neu.Slots = lotterySlotsFromPrizes(neu.Prizes)
|
||
}
|
||
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
|
||
neu.SolvedAt = old.SolvedAt
|
||
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 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
|
||
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
|
||
var post model.Post
|
||
if err := s.db.Select("id", "user_id", "board_id", "status").First(&post, postID).Error; err != nil {
|
||
return ErrPostNotFound
|
||
}
|
||
if !visibleToPost(&post, viewerID, loadActor) {
|
||
return ErrPostNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭)
|
||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||
hours := DefaultCooldownHours
|
||
if s.setting != nil {
|
||
if h, err := s.setting.PostCooldownHours(); err == nil {
|
||
hours = h
|
||
}
|
||
}
|
||
if hours <= 0 {
|
||
return nil
|
||
}
|
||
var user model.User
|
||
if err := s.db.First(&user, userID).Error; err != nil {
|
||
return err
|
||
}
|
||
if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
|
||
return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
|
||
type DeletePostOpts struct {
|
||
DeleteType string
|
||
DeleteReason string
|
||
}
|
||
|
||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)。
|
||
// staff 删除他人帖必须提供合法类型与非空理由;作者自删不写类型/理由。
|
||
// 返回 staffDeleted 与作者 ID,供 handler 发通知。
|
||
func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostOpts) (staffDeleted bool, authorID uint, err error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, postID).Error; err != nil {
|
||
return false, 0, ErrPostNotFound
|
||
}
|
||
authorID = post.UserID
|
||
|
||
deleteType := ""
|
||
deleteReason := ""
|
||
if post.UserID == userID {
|
||
staffDeleted = false
|
||
} else if actor != nil && actor.CanModerateBoard(post.BoardID) {
|
||
var author model.User
|
||
if err := s.db.Select("role").First(&author, post.UserID).Error; err != nil {
|
||
return false, authorID, ErrPostNotFound
|
||
}
|
||
if !actor.CanModerateAuthor(author.Role) {
|
||
return false, authorID, ErrAuthorProtected
|
||
}
|
||
deleteType = strings.TrimSpace(opts.DeleteType)
|
||
deleteReason = strings.TrimSpace(opts.DeleteReason)
|
||
if !model.ValidPostDeleteType(deleteType) {
|
||
return false, authorID, ErrPostInvalidType
|
||
}
|
||
if deleteType == model.PostDeleteTypeOther {
|
||
if deleteReason == "" {
|
||
return false, authorID, ErrPostDeleteMeta
|
||
}
|
||
if len([]rune(deleteReason)) > 200 {
|
||
return false, authorID, errors.New("删除理由不能超过 200 字")
|
||
}
|
||
} else {
|
||
deleteReason = ""
|
||
}
|
||
staffDeleted = true
|
||
} else {
|
||
return false, authorID, ErrPostForbidden
|
||
}
|
||
|
||
updates := map[string]interface{}{
|
||
"delete_type": deleteType,
|
||
"delete_reason": deleteReason,
|
||
"deleted_by": userID,
|
||
}
|
||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||
return false, authorID, err
|
||
}
|
||
if err := s.db.Delete(&post).Error; err != nil {
|
||
return false, authorID, err
|
||
}
|
||
return staffDeleted, authorID, nil
|
||
}
|