Files
jiang13-bbs/backend/model/models.go
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

700 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"time"
"gorm.io/gorm"
)
// Role 用户角色(等级递增:普通用户 < 板块管理员 < 管理员 < 超级管理员 < 站长)
type Role string
const (
RoleUser Role = "user" // 普通用户:发帖/评论需审核
RoleBoardAdmin Role = "board_admin" // 板块管理员:仅管理被授权板块的帖子与评论
RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核
RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长
RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁
)
// RoleLevel 角色等级,用于层级比较;未知角色按普通用户处理
func RoleLevel(r Role) int {
switch r {
case RoleOwner:
return 100
case RoleSuperAdmin:
return 80
case RoleAdmin:
return 50
case RoleBoardAdmin:
return 30
default:
return 0
}
}
// IsStaff 是否为任一管理角色(板块管理员及以上)
func IsStaff(r Role) bool {
return RoleLevel(r) >= RoleLevel(RoleBoardAdmin)
}
// ValidRole 角色枚举校验
func ValidRole(r Role) bool {
switch r {
case RoleUser, RoleBoardAdmin, RoleAdmin, RoleSuperAdmin, RoleOwner:
return true
}
return false
}
// 内容审核状态
const (
ContentStatusPending = "pending"
ContentStatusPublished = "published"
ContentStatusRejected = "rejected"
)
// 帖子类型(discussion 为默认)
const (
PostTypeDiscussion = "discussion" // 讨论(默认)
PostTypeQuestion = "question" // 问答
PostTypePoll = "poll" // 投票
PostTypeBounty = "bounty" // 悬赏
PostTypeLottery = "lottery" // 抽奖
)
// ValidPostType 发帖类型白名单
func ValidPostType(t string) bool {
switch t {
case PostTypeDiscussion, PostTypeQuestion, PostTypePoll, PostTypeBounty, PostTypeLottery:
return true
}
return false
}
// NormalizePostType 归一化类型(空或未知 → discussion)
func NormalizePostType(t string) string {
if ValidPostType(t) {
return t
}
return PostTypeDiscussion
}
// 正文可见性(由正文 [hide] 块派生;mixed = 多种隐藏类型并存)
const (
ContentAccessPublic = "public" // 公开
ContentAccessLogin = "login" // 仅登录可见块
ContentAccessReply = "reply" // 仅回复可见块
ContentAccessPoints = "points" // 仅积分可见块
ContentAccessPassword = "password" // 仅密码可见块
ContentAccessMixed = "mixed" // 多种隐藏块混合
)
// ValidContentAccess 可见性白名单
func ValidContentAccess(a string) bool {
switch a {
case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints, ContentAccessPassword, ContentAccessMixed:
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"`
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
Email string `gorm:"index;size:128;default:''" json:"-"`
Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"` // 全站唯一:LOWER 函数唯一索引在 db.go InitDB 中创建(应用层校验见 service/auth.go)
Avatar string `gorm:"size:512" json:"avatar"`
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"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 管理员颁发的徽章;仅显式 Preload("User.Badges.Badge") 的查询点加载,未加载时省略
Badges []UserBadge `gorm:"foreignKey:UserID" json:"badges,omitempty"`
}
// AfterFind 由累计获得积分计算等级(纯算术,随行加载,所有序列化 User 的接口自动携带 level)
func (u *User) AfterFind(tx *gorm.DB) error {
u.Level = LevelOf(u.TotalPoints)
return nil
}
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
// - TokenHash: token 的 SHA-256,数据库不存明文
// - TokenCipher: token 的 AES-GCM 密文,仅存在于"当前有效"行;轮转后旧行立即抹除。
// 保留它是为了在轮转宽限期内把【同一个】新 token 返回给并发重放请求(见 service 层)
// - RotatedTo/RotatedAt: 轮转链,用于宽限重放判定与盗用(吊销后重放)杀全家族
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
FamilyID uint `gorm:"index;not null;default:0" json:"-"` // 同一登录会话跨轮转保持不变
TokenHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
TokenCipher string `gorm:"size:512;default:''" json:"-"`
IP string `gorm:"size:45;not null;default:''" json:"-"`
UserAgent string `gorm:"size:512;not null;default:''" json:"-"`
LoggedInAt time.Time `gorm:"not null" json:"-"` // 该会话首次登录时间,轮转时原样复制
LastUsedAt time.Time `gorm:"index;not null" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
Revoked bool `gorm:"default:false;index" json:"revoked"`
RotatedTo uint `gorm:"index;default:0" json:"-"`
RotatedAt *time.Time `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Board 论坛板块
type Board struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
Icon string `gorm:"size:64;default:''" json:"icon"`
ColorIndex int `gorm:"default:-1" json:"color_index"`
SortOrder int `gorm:"default:0" json:"sort_order"`
PostPolicy string `gorm:"size:16;default:'all'" json:"post_policy"` // all=全员可发;staff=仅管理员及以上
Visible bool `gorm:"default:true" json:"visible"` // false 时不在前台列表/导航展示(仅隐藏入口)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// 板块发帖策略常量
const (
BoardPostPolicyAll = "all" // 全员可发帖
BoardPostPolicyStaff = "staff" // 仅管理员及以上(含管辖该板的版主)可发帖
)
// NormalizeBoardPostPolicy 归一化发帖策略,非法值回落 all
func NormalizeBoardPostPolicy(v string) string {
switch v {
case BoardPostPolicyStaff:
return BoardPostPolicyStaff
default:
return BoardPostPolicyAll
}
}
// ImportRecord 外部数据导入去重记录(旧站/WordPress/Typecho 等来源):
// 同一来源同一源对象只导入一次,保证「预检→正式导入→重复导入」全程幂等。
type ImportRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
Source string `gorm:"size:32;not null;uniqueIndex:idx_import_dedupe,priority:1" json:"source"` // jiang13 / wordpress / typecho
Kind string `gorm:"size:16;not null;uniqueIndex:idx_import_dedupe,priority:2" json:"kind"` // board / post / comment
SourceID string `gorm:"size:64;not null;uniqueIndex:idx_import_dedupe,priority:3" json:"source_id"`
NewID uint `json:"new_id"`
CreatedAt time.Time `json:"created_at"`
}
// 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:discussion;index" json:"post_type"`
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"`
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"`
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"` // 当前用户是否已点赞(展示字段,不入库)
// 软删元数据:列表仍排除;详情 Unscoped 可读作 tombstone 页
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` // 执行删除者;与 UserID 不同即为管理删除
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"`
}
// 帖子管理删除类型(staff 软删必填)
const (
PostDeleteTypeSpam = "spam"
PostDeleteTypeViolation = "violation"
PostDeleteTypeOffTopic = "off_topic"
PostDeleteTypeDuplicate = "duplicate"
PostDeleteTypeOther = "other"
)
// ValidPostDeleteType 管理删帖类型白名单
func ValidPostDeleteType(t string) bool {
switch t {
case PostDeleteTypeSpam, PostDeleteTypeViolation, PostDeleteTypeOffTopic,
PostDeleteTypeDuplicate, PostDeleteTypeOther:
return true
}
return false
}
// PostDeleteTypeLabel 类型中文标签(通知/专用页)
func PostDeleteTypeLabel(t string) string {
switch t {
case PostDeleteTypeSpam:
return "垃圾广告"
case PostDeleteTypeViolation:
return "违规内容"
case PostDeleteTypeOffTopic:
return "灌水跑题"
case PostDeleteTypeDuplicate:
return "重复内容"
case PostDeleteTypeOther:
return "其他"
default:
return ""
}
}
// 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"`
}
// Badge 管理员自定义徽章定义(名称/图标/颜色)
type Badge struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:32;not null;uniqueIndex" json:"name"` // ≤16 字(runes 校验见 service 层)
Icon string `gorm:"size:32;not null;default:''" json:"icon"` // lucide 图标名(前端白名单渲染,未知回落 Medal)
Color string `gorm:"size:16;not null;default:''" json:"color"` // CSS token 色板键(gold/accent/ok/danger/info/purple/teal)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserBadge 徽章授予记录(同一徽章每用户至多一枚)
type UserBadge struct {
ID uint `gorm:"primaryKey" json:"id"`
BadgeID uint `gorm:"not null;uniqueIndex:idx_badge_user" json:"badge_id"`
UserID uint `gorm:"not null;uniqueIndex:idx_badge_user;index" json:"user_id"`
Badge Badge `gorm:"foreignKey:BadgeID" json:"badge"` // 嵌套 Preload("User.Badges.Badge") 时填充
AwardedBy uint `json:"awarded_by"`
AwardedAt time.Time `json:"awarded_at"`
}
// 徽章色板键(对应前端 CSS token --<color> / --<color>-soft)
var BadgeColors = map[string]bool{
"gold": true, "accent": true, "ok": true, "danger": true,
"info": true, "purple": true, "teal": true,
}
// 积分流水原因
const (
PointReasonCheckin = "checkin"
PointReasonUnlockPost = "unlock_post"
PointReasonDownloadFile = "download_file"
PointReasonMigrateCheckin = "migrate_checkin"
PointReasonBountyEscrow = "bounty_escrow"
PointReasonBountyRefund = "bounty_refund"
PointReasonBountyAward = "bounty_award"
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
)
// 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 抽奖报名(主评入池时登记;同 IP 去重用 ClientIP)
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"`
CommentID uint `gorm:"not null;default:0" json:"comment_id"` // 入池时的主评 ID
ClientIP string `gorm:"size:45;not null;default:''" json:"-"`
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 {
ObjectID string `gorm:"size:64;index" json:"-"`
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"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层)
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:16;default:published;index" json:"status"`
// 软删元数据:列表 Unscoped 作 tombstone;DeletedBy≠UserID 为管理删除
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// CommentEditHistory 评论修订快照(每次编辑前的旧正文;仅管理可见)
type CommentEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"index;not null" json:"comment_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldContent string `gorm:"type:text;not null" json:"old_content"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// PostEditHistory 帖子修订快照(每次编辑前的旧标题/旧正文;登录用户可见)
type PostEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldTitle string `gorm:"size:256;not null;default:''" json:"old_title"`
OldContent string `gorm:"type:text;not null" json:"old_content"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// Like 点赞记录(联合唯一索引防止重复点赞)
type Like struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// Favorite 收藏记录(联合唯一索引防止重复收藏;无计数列,按钮仅表示状态)
type Favorite struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// Follow 关注关系(FollowerID 关注了 FollowingID;联合唯一防止重复关注)
type Follow struct {
ID uint `gorm:"primaryKey" json:"id"`
FollowerID uint `gorm:"uniqueIndex:idx_follow_pair,priority:1;index;not null" json:"follower_id"`
FollowingID uint `gorm:"uniqueIndex:idx_follow_pair,priority:2;index;not null" json:"following_id"`
CreatedAt time.Time `json:"created_at"`
}
// Checkin 每日签到记录(用户+日期联合唯一,防止重复签到)
type Checkin struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_checkin_user_date;not null" json:"user_id"`
CheckinDate time.Time `gorm:"type:date;uniqueIndex:idx_checkin_user_date;not null" json:"checkin_date"`
Points int `gorm:"not null;default:5" json:"points"` // 当日所得积分
CreatedAt time.Time `json:"created_at"`
}
// 通知类型
const (
NotificationTypeComment = "comment" // 评论了你的帖子
NotificationTypeReply = "reply" // 回复了你的评论
NotificationTypeLike = "like" // 点赞了你的帖子
NotificationTypeApproved = "approved" // 内容审核通过
NotificationTypeRejected = "rejected" // 内容审核未通过
NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID)
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0)
NotificationTypeFollow = "follow" // 关注了你
)
// Notification 站内通知
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 接收通知的用户
ActorID uint `gorm:"not null" json:"actor_id"` // 触发通知的用户
Type string `gorm:"size:16;not null;index" json:"type"` // comment | like | mention ...
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 关联帖子(mention 类为 0)
CommentID uint `gorm:"index" json:"comment_id"` // 关联评论(点赞时为 0)
RoomID uint `gorm:"index;not null;default:0" json:"room_id"` // 关联群聊(mention)
MessageID uint `gorm:"not null;default:0" json:"message_id"` // 关联群聊消息(mention)
Content string `gorm:"size:256" json:"content"` // 内容预览
IsRead bool `gorm:"default:false;index" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
}
// Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色)
type Announcement struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray/teal/cyan/pink/amber/indigo/rose
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
Pinned bool `gorm:"not null;index" json:"pinned"` // 默认 false;写入须 Select,禁止 default:true
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SitePage 站点单页(功能介绍等;侧栏入口 + /p/:slug)
type SitePage struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Excerpt string `gorm:"size:300" json:"excerpt"`
Published bool `gorm:"not null;index" json:"published"`
ShowInSidebar bool `gorm:"not null;index" json:"show_in_sidebar"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
AllowComments bool `gorm:"not null" json:"allow_comments"` // 默认 false;本轮无评论 UI
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"type:text;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
// SidebarWidget 自定义侧栏工具(站长 HTML+CSS+JS 代码块,前台原样渲染执行)
// Enabled 不加 gorm default 标签:规避布尔零值省略陷阱,创建时由代码显式赋值
type SidebarWidget struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Html string `gorm:"type:text" json:"html"`
Css string `gorm:"type:text" json:"css"`
Js string `gorm:"type:text" json:"js"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
type SiteDailyStats struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
PV int64 `gorm:"not null;default:0" json:"pv"`
UV int64 `gorm:"not null;default:0" json:"uv"`
LoggedInPV int64 `gorm:"not null;default:0" json:"logged_in_pv"`
UpdatedAt time.Time `json:"updated_at"`
}
// SiteDailyVisitor 当日访客去重(vid 哈希);插入成功才给 UV +1
type SiteDailyVisitor struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
VidHash string `gorm:"size:64;primaryKey" json:"-"`
}
// 访问事件类型:pageview 人类浏览(计 PV/UV);bot 爬虫;probe 异常/错误响应
const (
VisitKindPageview = "pageview"
VisitKindBot = "bot"
VisitKindProbe = "probe"
)
// VisitEvent 单次访问事件(内存攒批落库,保留期滚动清理)
type VisitEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `gorm:"index:idx_visit_created" json:"created_at"`
Kind string `gorm:"size:16;index:idx_visit_kind_time,priority:1;not null;default:'pageview'" json:"kind"`
Path string `gorm:"size:512;not null;default:''" json:"path"`
RefHost string `gorm:"size:128;not null;default:''" json:"ref_host"`
RefKind string `gorm:"size:16;not null;default:'direct'" json:"ref_kind"` // direct|internal|search|social|external
VidHash string `gorm:"size:64;not null;default:''" json:"-"`
UserID uint `gorm:"not null;default:0" json:"user_id"`
IP string `gorm:"size:45;not null;default:''" json:"ip"`
Device string `gorm:"size:16;not null;default:''" json:"device"` // computer|mobile|tablet
OS string `gorm:"size:32;not null;default:''" json:"os"`
Browser string `gorm:"size:32;not null;default:''" json:"browser"`
Bot string `gorm:"size:64;not null;default:''" json:"bot"` // 非空 = 爬虫名
Status int `gorm:"not null;default:0" json:"status"` // probe 的响应状态码
}
// 附件类型
const (
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
URL string `gorm:"size:512;not null" json:"url"`
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
Width int `gorm:"not null;default:0" json:"width"`
Height int `gorm:"not null;default:0" json:"height"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
type UserBoard struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_board;not null" json:"user_id"`
BoardID uint `gorm:"uniqueIndex:idx_user_board;not null" json:"board_id"`
CreatedAt time.Time `json:"created_at"`
User User `gorm:"foreignKey:UserID" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
}
// LoginLog 登录历史(成功与失败都记录;失败时用户名可能不存在,UserID 为 0)
type LoginLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null;default:0" json:"user_id"`
Username string `gorm:"size:128;index;not null;default:''" json:"username"`
IP string `gorm:"size:45;not null;default:''" json:"ip"` // IPv4/IPv6 最长 45 字符
UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"`
Success bool `gorm:"index;not null;default:false" json:"success"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// 群聊成员角色
const (
ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言
ChatRoleAdmin = "admin" // 群管理员(仅站长可任命):本群撤回他人消息/踢人/禁言
ChatRoleMember = "member" // 普通成员
)
// ValidChatMemberRole 群内角色白名单(任命接口仅允许 admin/member)
func ValidChatMemberRole(r string) bool {
switch r {
case ChatRoleOwner, ChatRoleAdmin, ChatRoleMember:
return true
}
return false
}
// ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅
type ChatRoom struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;default:''" json:"name"`
Description string `gorm:"size:256;not null;default:''" json:"description"`
OwnerID uint `gorm:"index;not null" json:"owner_id"`
RoomType string `gorm:"size:16;not null;default:group;index" json:"room_type"` // group | direct
DirectKey string `gorm:"size:64;not null;default:'';index" json:"-"` // 私聊双方 ID 排序键 "min:max"
IsPrivate bool `gorm:"not null;default:false;index" json:"is_private"`
IsDefault bool `gorm:"not null;default:false;index" json:"is_default"`
MemberCount int `gorm:"not null;default:0" json:"member_count"`
LastMessageID uint `gorm:"index;not null;default:0" json:"last_message_id"` // 最后一条消息(列表排序/未读基准)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Owner User `gorm:"foreignKey:OwnerID" json:"owner,omitempty"`
LastMessage *ChatMessage `gorm:"foreignKey:LastMessageID;constraint:-" json:"last_message,omitempty"`
}
const (
ChatRoomTypeGroup = "group"
ChatRoomTypeDirect = "direct"
)
// ChatRoomMember 群成员(房间+用户联合唯一;LastReadMessageID 为未读对账水位)
type ChatRoomMember struct {
ID uint `gorm:"primaryKey" json:"id"`
RoomID uint `gorm:"uniqueIndex:idx_chat_member;index;not null" json:"room_id"`
UserID uint `gorm:"uniqueIndex:idx_chat_member;not null" json:"user_id"`
Role string `gorm:"size:16;not null;default:member" json:"role"`
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// ChatMessage 群聊消息(先落库再经 WS 广播,WS 不承担可靠投递;撤回保留行供审计)
type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
SenderID uint `gorm:"index;not null" json:"sender_id"`
Content string `gorm:"type:varchar(2000);not null" json:"content"`
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
RecalledAt *time.Time `json:"recalled_at,omitempty"`
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
}