feat: 帖子分级置顶(板块/全站)与板块管理员置顶权限、消息管理权限调整
This commit is contained in:
@@ -21,8 +21,10 @@ func main() {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
// 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)
|
||||
service.ConfigureCookieNames(!cfg.DevMode)
|
||||
// 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)。
|
||||
// 以前是 !DevMode 硬绑定,现在解耦:根据 SITE_URL scheme 或 FORCE_HTTPS 判断,
|
||||
// 内网 HTTP 部署零配置即可跑,反代 TLS 但后端只看到 HTTP 时设 FORCE_HTTPS=true。
|
||||
service.ConfigureCookieNames(cfg.IsSecureOrigin())
|
||||
// 过期/已吊销 refresh token 定期清理
|
||||
service.StartRefreshTokenCleanup(model.DB)
|
||||
// 悬赏过期退回 / 抽奖到期开奖
|
||||
|
||||
@@ -24,6 +24,7 @@ type Config struct {
|
||||
DevMode bool
|
||||
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
|
||||
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
|
||||
ForceHTTPS bool // 强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀;反代 TLS 但后端只看到 HTTP 时设 true
|
||||
}
|
||||
|
||||
// appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。
|
||||
@@ -43,7 +44,8 @@ var appIniSchema = []appIniKey{
|
||||
{section: "paths", key: "DATA", value: "data"},
|
||||
{section: "app", key: "DEV_MODE", value: "true"},
|
||||
{section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"},
|
||||
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"},
|
||||
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。"},
|
||||
{section: "app", key: "FORCE_HTTPS", value: "false", comment: "强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀。反代 TLS 但后端只看到 HTTP 时设 true。\n默认自动根据 SITE_URL scheme 判断。环境变量 FORCE_HTTPS 优先。"},
|
||||
}
|
||||
|
||||
// Parse 解析配置:环境变量 > app.ini > 默认值。
|
||||
@@ -98,6 +100,9 @@ func Parse() (*Config, error) {
|
||||
if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
|
||||
cfg.CORSOrigins = splitCSVOrigins(v)
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("FORCE_HTTPS")); v != "" {
|
||||
cfg.ForceHTTPS = strings.EqualFold(v, "true") || v == "1"
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
|
||||
abs, err := filepath.Abs(v)
|
||||
if err != nil {
|
||||
@@ -179,6 +184,9 @@ func applyIni(cfg *Config, f *ini.File) {
|
||||
cfg.CORSOrigins = splitCSVOrigins(v)
|
||||
}
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("FORCE_HTTPS") {
|
||||
cfg.ForceHTTPS = s.Key("FORCE_HTTPS").MustBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。
|
||||
@@ -227,6 +235,19 @@ func loadAndEnsureAppIni(path string) (*ini.File, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// IsSecureOrigin 判断对外站点是否走 HTTPS,用于控制 cookie Secure 标志、HSTS 头、__Host- cookie 前缀。
|
||||
// 优先级:FORCE_HTTPS 显式设置 > SITE_URL scheme 推断。
|
||||
// 本地 dev 未填 SITE_URL 时按 http 处理,不会误开安全策略。
|
||||
func (c *Config) IsSecureOrigin() bool {
|
||||
if c.ForceHTTPS {
|
||||
return true
|
||||
}
|
||||
if c.SiteURL == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(c.SiteURL, "https://")
|
||||
}
|
||||
|
||||
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
|
||||
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
|
||||
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
|
||||
|
||||
@@ -164,7 +164,7 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin())
|
||||
h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
@@ -212,7 +212,7 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
h.Ops.ClearFailure(req.Username)
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin())
|
||||
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
|
||||
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
|
||||
// access token 过期或硬刷新才发现)
|
||||
@@ -244,13 +244,13 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrAccountBanned) {
|
||||
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
|
||||
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
|
||||
return
|
||||
}
|
||||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||||
setAuthCookies(c, accessToken, newRefresh, h.Cfg.IsSecureOrigin())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
@@ -279,7 +279,7 @@ func (h *Handlers) Logout(c *gin.Context) {
|
||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||||
}
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ func (h *Handlers) chatOversee(userID uint) bool {
|
||||
if err != nil || actor == nil {
|
||||
return false
|
||||
}
|
||||
return service.CanOverseeChat(actor.Role)
|
||||
return actor.CanOverseeChat()
|
||||
}
|
||||
|
||||
// RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。
|
||||
@@ -673,7 +673,7 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) {
|
||||
"messages": msgs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"can_oversee": service.CanOverseeChat(actor.Role),
|
||||
"can_oversee": actor.CanOverseeChat(),
|
||||
"can_recall": true,
|
||||
"site_messages": actor.HasSiteMessagePerm(),
|
||||
})
|
||||
@@ -697,7 +697,7 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
|
||||
return
|
||||
}
|
||||
oversee := service.CanOverseeChat(actor.Role)
|
||||
oversee := actor.CanOverseeChat()
|
||||
msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
|
||||
@@ -246,3 +246,55 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint)
|
||||
h.broadcastModerationChanged()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||
}
|
||||
|
||||
// ===== 置顶顺序配置 =====
|
||||
|
||||
// AdminListPinnedPosts 列出指定层级(level=1 板块 / 2 全站)的置顶帖,供后台排序
|
||||
func (h *Handlers) AdminListPinnedPosts(c *gin.Context) {
|
||||
level, _ := strconv.Atoi(c.Query("level"))
|
||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||
if level != model.PinBoard && level != model.PinGlobal {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"})
|
||||
return
|
||||
}
|
||||
items, err := h.Moderation.ListPinnedPosts(middleware.CurrentActor(c), level, uint(boardID))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrPinOrderForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取置顶列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(items)})
|
||||
}
|
||||
|
||||
// AdminSavePinOrderRequest 置顶顺序保存请求
|
||||
type AdminSavePinOrderRequest struct {
|
||||
Level int `json:"level" binding:"required"`
|
||||
BoardID uint `json:"board_id"`
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminSavePinOrder 保存置顶帖显示顺序(ids 数组顺序即展示顺序)
|
||||
func (h *Handlers) AdminSavePinOrder(c *gin.Context) {
|
||||
var req AdminSavePinOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Level != model.PinBoard && req.Level != model.PinGlobal {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"})
|
||||
return
|
||||
}
|
||||
if err := h.Moderation.SavePinOrder(middleware.CurrentActor(c), req.Level, req.BoardID, req.IDs); err != nil {
|
||||
if errors.Is(err, service.ErrPinOrderForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.broadcastFeedChanged("post", 0, req.BoardID, middleware.CurrentActor(c).ID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
|
||||
}
|
||||
|
||||
@@ -550,9 +550,12 @@ func (h *Handlers) DeletePost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限)
|
||||
// TogglePin 切换帖子置顶:管理员及以上切全站置顶,板块管理员切本板板块置顶
|
||||
func (h *Handlers) TogglePin(c *gin.Context) {
|
||||
if !h.requireAdminOrAbove(c) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
actor, err := h.Auth.LoadActor(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -560,9 +563,17 @@ func (h *Handlers) TogglePin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
pinned, err := h.Post.TogglePin(uint(id))
|
||||
level, _ := strconv.Atoi(c.Query("level"))
|
||||
pinned, err := h.Post.TogglePin(uint(id), actor, level)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": 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.ErrPinGlobalOnly):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pinned": pinned})
|
||||
|
||||
@@ -61,7 +61,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||
canOverseeChat := false
|
||||
if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil {
|
||||
isStaff = actor.IsStaff()
|
||||
canOverseeChat = service.CanOverseeChat(actor.Role)
|
||||
canOverseeChat = actor.CanOverseeChat()
|
||||
}
|
||||
|
||||
client := realtime.NewClient(
|
||||
@@ -74,7 +74,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||
return verr == nil
|
||||
},
|
||||
func() { h.Auth.TouchLastSeen(claims.ID) },
|
||||
// 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群
|
||||
// 动态房间订阅鉴权:成员可订阅;站长/已授权超管旁路可订阅任意群
|
||||
func(room string) bool {
|
||||
roomID, ok := parseWSChatRoom(room)
|
||||
if !ok {
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
)
|
||||
|
||||
// SecurityHeaders 安全响应头中间件
|
||||
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险。
|
||||
// secure=true 时额外下发 HSTS(仅 HTTPS origin 合法,HTTP 环境发 HSTS 属于误导)。
|
||||
func SecurityHeaders(secure bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// CSP:限制资源加载来源,降低 XSS 危害
|
||||
c.Header("Content-Security-Policy",
|
||||
@@ -20,8 +21,10 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
"base-uri 'self'; "+
|
||||
"form-action 'self'")
|
||||
|
||||
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略)
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
// HSTS:仅 HTTPS origin 下发。HTTP 响应发 HSTS 是无效且误导的。
|
||||
if secure {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
}
|
||||
|
||||
// 点击劫持防护
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
@@ -32,9 +35,6 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// 控制 Referer 信息泄露
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// 禁止浏览器缓存敏感页面(可按需覆盖)
|
||||
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 帖子置顶分级:历史全站置顶(pinned=1)升为 2,避免存量置顶帖被降级为板块置顶
|
||||
if err := preparePostPinLevels(db); err != nil {
|
||||
return fmt.Errorf("帖子置顶分级迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
|
||||
// 已存在 owner 时不动数据,保证幂等
|
||||
if err := ensureOwnerRole(db); err != nil {
|
||||
@@ -180,6 +185,25 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error {
|
||||
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
|
||||
}
|
||||
|
||||
// preparePostPinLevels 一次性迁移:历史置顶(pinned=1,旧语义=全站置顶)升为 2。
|
||||
// 新语义下 1=板块置顶、2=全站置顶,若不迁移,存量全站置顶会被降级为板块置顶。
|
||||
// 用 site_settings 内部键做幂等标记,避免每次启动把新建的板块置顶(=1)误升为全站。
|
||||
func preparePostPinLevels(db *gorm.DB) error {
|
||||
const markerKey = "internal_pin_levels_migrated"
|
||||
var marker int64
|
||||
if err := db.Model(&SiteSetting{}).Where("key = ?", markerKey).Count(&marker).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if marker > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := db.Exec(`UPDATE posts SET pinned = 2 WHERE pinned = 1`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec(`INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, markerKey, "1").Error
|
||||
}
|
||||
|
||||
// prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等)
|
||||
func prepareSiteSettingValueText(db *gorm.DB) error {
|
||||
var tableCount int64
|
||||
|
||||
@@ -11,7 +11,7 @@ type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user" // 普通用户:发帖/评论需审核
|
||||
RoleBoardAdmin Role = "board_admin" // 板块管理员:仅管理被授权板块的帖子与评论
|
||||
RoleBoardAdmin Role = "board_admin" // 板块管理员:管理被授权板块的帖子、评论与板块内置顶
|
||||
RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核
|
||||
RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长
|
||||
RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁
|
||||
@@ -121,7 +121,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"`
|
||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;超管亦需授予,站长固有全站监管)
|
||||
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
|
||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||
@@ -207,6 +207,13 @@ type ImportRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 帖子置顶级别:同一 pinned 字段分级,数值越大范围越大
|
||||
const (
|
||||
PinNone = 0 // 不置顶
|
||||
PinBoard = 1 // 板块置顶:仅所属板块列表靠前(板块管理员可操作)
|
||||
PinGlobal = 2 // 全站置顶:首页与所有列表靠前(管理员及以上可操作)
|
||||
)
|
||||
|
||||
// Post 帖子
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -219,7 +226,8 @@ type Post struct {
|
||||
ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points|password|mixed(由正文隐藏块派生)
|
||||
AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 积分隐藏块价格之和
|
||||
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
|
||||
Pinned int `gorm:"default:0" json:"pinned"`
|
||||
Pinned int `gorm:"default:0" json:"pinned"` // 置顶级别:0=无 1=板块 2=全站
|
||||
PinOrder int `gorm:"default:0" json:"pin_order"` // 同层级置顶帖的显示顺序:越小越靠前
|
||||
Recommended bool `gorm:"default:false;index" json:"recommended"`
|
||||
Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
|
||||
@@ -65,8 +65,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// 全局安全响应头
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
// 全局安全响应头(HSTS 仅 HTTPS origin 下发)
|
||||
r.Use(middleware.SecurityHeaders(cfg.IsSecureOrigin()))
|
||||
|
||||
// CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域)
|
||||
r.Use(cors.New(cors.Config{
|
||||
@@ -159,7 +159,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Chat: chatSvc,
|
||||
Visit: visitSvc,
|
||||
Analytics: analyticsSvc,
|
||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, cfg.IsSecureOrigin()),
|
||||
Ads: adSvc,
|
||||
Sidebar: service.NewSidebarService(model.DB),
|
||||
FriendLink: service.NewFriendLinkService(model.DB),
|
||||
@@ -203,7 +203,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.GET("/api/ws", h.RealtimeWS)
|
||||
|
||||
// 公开 API(可选登录);EnsureCSRF 让游客也能拿到双提交 token(密码解锁等)
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(!cfg.DevMode))
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(cfg.IsSecureOrigin()))
|
||||
{
|
||||
pubAPI.GET("/me", h.Me)
|
||||
pubAPI.GET("/boards", h.Boards)
|
||||
@@ -380,6 +380,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
|
||||
staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment)
|
||||
|
||||
// 置顶帖顺序配置(板块置顶:板管限授权板块;全站置顶:管理员及以上)
|
||||
staffAPI.GET("/pinned-posts", h.AdminListPinnedPosts)
|
||||
staffAPI.PUT("/pinned-posts/order", h.AdminSavePinOrder)
|
||||
|
||||
// 积分与类型帖概览(管理员及以上)
|
||||
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||
PermMessages = "messages" // 后台消息管理(站长固有;超管及其他角色需站点消息 flag;群管另有业务层放行)
|
||||
PermBoards = "boards" // 板块管理(仅站长)
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages
|
||||
for _, p := range ParsePermOverrides(permOverridesRaw) {
|
||||
set[p] = true
|
||||
}
|
||||
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
|
||||
if role == model.RoleOwner || canManageMessages {
|
||||
set[PermMessages] = true
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
@@ -123,7 +123,7 @@ type Actor struct {
|
||||
Username string
|
||||
Role model.Role
|
||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||
CanManageMessages bool // 站点级消息管理 flag(仅站长可授,超管亦需此授予);站长固有
|
||||
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||
}
|
||||
|
||||
@@ -151,15 +151,22 @@ func (a *Actor) HasPerm(p string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages
|
||||
// HasSiteMessagePerm 站点级消息管理:站长固有;超管及其他角色需被授予 CanManageMessages
|
||||
func (a *Actor) HasSiteMessagePerm() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if a.Role == model.RoleOwner || a.Role == model.RoleSuperAdmin {
|
||||
return true
|
||||
return a.Role == model.RoleOwner || a.CanManageMessages
|
||||
}
|
||||
|
||||
// CanOverseeChat 全站消息监管(可见全部群含私聊、撤回任意消息、旁路订阅任意房间):
|
||||
// 站长固有;超管需先被授予站点消息管理
|
||||
func (a *Actor) CanOverseeChat() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
return a.CanManageMessages
|
||||
return a.Role == model.RoleOwner ||
|
||||
(a.Role == model.RoleSuperAdmin && a.CanManageMessages)
|
||||
}
|
||||
|
||||
// CanPurgeContent 彻底删除帖/评:仅站长
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var (
|
||||
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
|
||||
ErrContentNotFound = errors.New("内容不存在")
|
||||
ErrPinOrderForbidden = errors.New("无权配置该层级置顶顺序")
|
||||
)
|
||||
|
||||
func (s *ModerationService) authorRole(userID uint) (model.Role, error) {
|
||||
@@ -511,3 +512,105 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
|
||||
return tx.Unscoped().Delete(&model.Comment{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 置顶顺序配置(后台可拖拽排序,同层级 pin_order 越小越靠前) =====
|
||||
|
||||
// PinnedPostItem 置顶帖排序列表项
|
||||
type PinnedPostItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Pinned int `json:"pinned"`
|
||||
PinOrder int `json:"pin_order"`
|
||||
BoardID uint `json:"board_id"`
|
||||
BoardName string `json:"board_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// assertPinScopePermission 校验 actor 对指定置顶层级/板块的排序权限:
|
||||
// 全局置顶仅管理员及以上;板块置顶允许管理员或管辖该板块的板管(board_id 必填)。
|
||||
func (s *ModerationService) assertPinScopePermission(actor *Actor, level int, boardID uint) error {
|
||||
if actor == nil {
|
||||
return ErrPinOrderForbidden
|
||||
}
|
||||
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||
return nil
|
||||
}
|
||||
if level == model.PinGlobal {
|
||||
return ErrPinOrderForbidden
|
||||
}
|
||||
if level == model.PinBoard {
|
||||
if boardID == 0 {
|
||||
return ErrPinOrderForbidden // 板管必须指定具体板块
|
||||
}
|
||||
if actor.CanModerateBoard(boardID) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrPinOrderForbidden
|
||||
}
|
||||
|
||||
// ListPinnedPosts 列出指定层级的置顶帖(当前顺序:pin_order 升序,未配置按发帖时间倒序兜底)。
|
||||
// level=2 全站置顶;level=1 需带 board_id(管理员可省=全部板块的板块置顶)。
|
||||
func (s *ModerationService) ListPinnedPosts(actor *Actor, level int, boardID uint) ([]PinnedPostItem, error) {
|
||||
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.db.Model(&model.Post{}).
|
||||
Where("pinned = ? AND deleted_at IS NULL AND status = ?", level, model.ContentStatusPublished)
|
||||
if boardID > 0 {
|
||||
q = q.Where("board_id = ?", boardID)
|
||||
}
|
||||
var posts []model.Post
|
||||
if err := q.Order("(pin_order = 0), pin_order ASC, created_at DESC").
|
||||
Preload("Board").Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PinnedPostItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PinnedPostItem{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
Pinned: p.Pinned,
|
||||
PinOrder: p.PinOrder,
|
||||
BoardID: p.BoardID,
|
||||
BoardName: p.Board.Name,
|
||||
CreatedAt: p.CreatedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// SavePinOrder 按传入 ID 顺序重写 pin_order(从 1 递增)。
|
||||
// 安全约束:ids 必须全部属于该层级且在权限范围内,否则整体拒绝,避免半截写入。
|
||||
func (s *ModerationService) SavePinOrder(actor *Actor, level int, boardID uint, ids []uint) error {
|
||||
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ids) > 200 {
|
||||
return errors.New("单次最多排序 200 条")
|
||||
}
|
||||
var count int64
|
||||
q := s.db.Model(&model.Post{}).
|
||||
Where("id IN ? AND pinned = ? AND deleted_at IS NULL", ids, level)
|
||||
if boardID > 0 {
|
||||
q = q.Where("board_id = ?", boardID)
|
||||
}
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != int64(len(ids)) {
|
||||
return ErrPinOrderForbidden // 含越权/已取消置顶/已删除的 ID
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Post{}).Where("id = ?", id).
|
||||
UpdateColumn("pin_order", i+1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perm
|
||||
return s.getItem(s.db, targetID)
|
||||
}
|
||||
|
||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(超管亦需授予;不可操作站长账号与自己)
|
||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||
if operator == nil || operator.Role != model.RoleOwner {
|
||||
return nil, ErrOwnerOnly
|
||||
@@ -486,10 +486,6 @@ func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint,
|
||||
if u.Role == model.RoleOwner {
|
||||
return ErrProtectedOwner
|
||||
}
|
||||
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
|
||||
if u.Role == model.RoleSuperAdmin {
|
||||
enabled = false
|
||||
}
|
||||
if u.CanManageMessages == enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -42,11 +42,6 @@ var (
|
||||
ErrChatAdminAccess = errors.New("无权管理消息")
|
||||
)
|
||||
|
||||
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
|
||||
func CanOverseeChat(role model.Role) bool {
|
||||
return role == model.RoleOwner || role == model.RoleSuperAdmin
|
||||
}
|
||||
|
||||
const (
|
||||
chatNameMinRune = 2
|
||||
chatNameMaxRune = 32
|
||||
@@ -929,7 +924,7 @@ func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
|
||||
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
|
||||
}
|
||||
|
||||
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee)
|
||||
// hasSiteGroupMessagePerm 站点消息管理 flag(不含站长,超管经 oversee 已放行;调用方通常已判 oversee)
|
||||
func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool {
|
||||
var u model.User
|
||||
if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil {
|
||||
@@ -1219,7 +1214,7 @@ type AdminChatMessageItem struct {
|
||||
}
|
||||
|
||||
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
|
||||
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
|
||||
// actor 为站长或已授权超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
|
||||
func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
||||
if actor == nil || !s.CanAccessAdminMessages(actor) {
|
||||
return nil, 0, ErrChatAdminAccess
|
||||
@@ -1227,7 +1222,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
fullOversee := CanOverseeChat(actor.Role)
|
||||
fullOversee := actor.CanOverseeChat()
|
||||
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
|
||||
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
||||
return nil, 0, errors.New("房间类型无效")
|
||||
|
||||
@@ -226,6 +226,14 @@ type legacyPostRow struct {
|
||||
|
||||
func (legacyPostRow) TableName() string { return "posts" }
|
||||
|
||||
// legacyPinLevel 旧库 pinned(0/1,非零=全站置顶)映射到新置顶分级:板块置顶为本站新能力,旧数据无此语义
|
||||
func legacyPinLevel(v int) int {
|
||||
if v > 0 {
|
||||
return model.PinGlobal
|
||||
}
|
||||
return model.PinNone
|
||||
}
|
||||
|
||||
type legacyCommentRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
@@ -757,7 +765,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Pinned: legacyPinLevel(p.Pinned),
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
|
||||
@@ -368,7 +368,7 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
|
||||
return e
|
||||
}
|
||||
origin, originErr := url.Parse(o.cfg.SiteURL)
|
||||
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
|
||||
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && o.cfg.IsSecureOrigin()) {
|
||||
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
|
||||
}
|
||||
}
|
||||
@@ -430,8 +430,8 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
|
||||
}
|
||||
if c.Backend == "s3" {
|
||||
u, e := url.Parse(c.Endpoint)
|
||||
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
|
||||
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
|
||||
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && o.cfg.IsSecureOrigin()) {
|
||||
return bad("endpoint", "须为 HTTPS origin(HTTP origin 部署可放宽)")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
|
||||
return bad("bucket", "桶名称无效")
|
||||
|
||||
@@ -19,6 +19,8 @@ var (
|
||||
ErrPostInvalidType = errors.New("无效的删除类型")
|
||||
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
|
||||
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
|
||||
ErrPinGlobalOnly = errors.New("全站置顶仅管理员可操作")
|
||||
ErrPinInvalidLevel = errors.New("无效的置顶级别")
|
||||
)
|
||||
|
||||
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
|
||||
@@ -247,18 +249,25 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
|
||||
var posts []model.Post
|
||||
offset := (q.Page - 1) * q.Size
|
||||
// 置顶排序前缀按范围区分:板块视图里全站置顶(2)在板块置顶(1)之上;
|
||||
// 全站信息流只让全站置顶(2)浮顶,板块置顶(1)不影响首页与跨板列表。
|
||||
// 同层级置顶帖之间按后台配置的 pin_order 升序(pin_order=0 视为未配置,排在已配置的后面,再按活跃序兜底)。
|
||||
pinFirst := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC"
|
||||
if q.BoardID > 0 {
|
||||
pinFirst = "pinned DESC, (pin_order = 0), pin_order ASC"
|
||||
}
|
||||
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
|
||||
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
|
||||
order := `pinned DESC, GREATEST(created_at, COALESCE((
|
||||
order := pinFirst + `, 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"
|
||||
order = pinFirst + ", (like_count + comment_count * 2) DESC, created_at DESC"
|
||||
}
|
||||
// new:纯按发帖时间(新帖子)
|
||||
if q.Sort == "new" {
|
||||
order = "pinned DESC, created_at DESC"
|
||||
order = pinFirst + ", created_at DESC"
|
||||
}
|
||||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||||
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
|
||||
@@ -311,7 +320,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
|
||||
}
|
||||
var posts []model.Post
|
||||
offset := (page - 1) * size
|
||||
order := "pinned DESC, created_at DESC"
|
||||
order := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC, created_at DESC"
|
||||
if sort == "hot" {
|
||||
order = "like_count + comment_count DESC, view_count DESC, created_at DESC"
|
||||
}
|
||||
@@ -349,24 +358,36 @@ func (s *PostService) CountByUser(userID uint) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) TogglePin(id uint) (int, error) {
|
||||
// TogglePin 设置置顶级别:level 0=取消置顶,1=板块置顶,2=全站置顶。
|
||||
// 板块管理员只能操作自己授权板块且只能设置/取消板块置顶;管理员可设置任意级别。
|
||||
func (s *PostService) TogglePin(id uint, actor *Actor, level int) (int, error) {
|
||||
if level < model.PinNone || level > model.PinGlobal {
|
||||
return 0, ErrPinInvalidLevel
|
||||
}
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
if err := s.db.Select("id", "board_id", "pinned").First(&post, id).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var newPinned int
|
||||
if post.Pinned > 0 {
|
||||
newPinned = 0
|
||||
} else {
|
||||
newPinned = 1
|
||||
isGlobalAdmin := actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)
|
||||
isBoardModerator := actor != nil && actor.CanModerateBoard(post.BoardID)
|
||||
|
||||
// 权限校验
|
||||
if level == model.PinGlobal && !isGlobalAdmin {
|
||||
return 0, ErrPinGlobalOnly
|
||||
}
|
||||
if level > model.PinNone && !isGlobalAdmin && !isBoardModerator {
|
||||
return 0, ErrPostForbidden
|
||||
}
|
||||
// 板块管理员不能取消全局置顶(只能取消自己设置的板块置顶)
|
||||
if !isGlobalAdmin && level == model.PinNone && post.Pinned == model.PinGlobal {
|
||||
return 0, ErrPinGlobalOnly
|
||||
}
|
||||
|
||||
// UpdateColumn:置顶非内容编辑,不刷新 updated_at
|
||||
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
if err := s.db.Model(&post).UpdateColumn("pinned", level).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return newPinned, nil
|
||||
return level, nil
|
||||
}
|
||||
|
||||
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。
|
||||
|
||||
Reference in New Issue
Block a user