559 lines
27 KiB
Go
559 lines
27 KiB
Go
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"`
|
||
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"` // 可用积分余额
|
||
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:"-"`
|
||
}
|
||
|
||
// 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"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||
}
|
||
|
||
// 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"`
|
||
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"`
|
||
}
|
||
|
||
// 积分流水原因
|
||
const (
|
||
PointReasonCheckin = "checkin"
|
||
PointReasonUnlockPost = "unlock_post"
|
||
PointReasonDownloadFile = "download_file"
|
||
PointReasonMigrateCheckin = "migrate_checkin"
|
||
PointReasonBountyEscrow = "bounty_escrow"
|
||
PointReasonBountyRefund = "bounty_refund"
|
||
PointReasonBountyAward = "bounty_award"
|
||
)
|
||
|
||
// PostPollVote 投票记录(多选时同一用户多行)
|
||
type PostPollVote struct {
|
||
ID uint `gorm:"primaryKey" json:"id"`
|
||
PostID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"post_id"`
|
||
UserID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"user_id"`
|
||
OptionIndex int `gorm:"uniqueIndex:idx_poll_vote;not null" json:"option_index"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// PostLotteryEntry 抽奖报名(主评入池时登记;同 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"`
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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" // 有新内容待审核(发给可审者)
|
||
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
|
||
)
|
||
|
||
// 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"`
|
||
Post Post `gorm:"foreignKey:PostID" 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"`
|
||
}
|
||
|
||
// 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:"-"`
|
||
}
|
||
|
||
// 附件类型
|
||
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"`
|
||
}
|