Files
jiang13-bbs/backend/model/models.go
freefire b2d3d6dd26 feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验
统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:21:05 +08:00

508 lines
24 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
}
// 正文可见性
const (
ContentAccessPublic = "public" // 公开
ContentAccessLogin = "login" // 登录可见
ContentAccessReply = "reply" // 回复可见
ContentAccessPoints = "points" // 积分购买可见
)
// ValidContentAccess 可见性白名单
func ValidContentAccess(a string) bool {
switch a {
case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints:
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"`
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"`
TokenHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
TokenCipher string `gorm:"size:512;default:''" 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
AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 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 {
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"`
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
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
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:"size:255;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
// 附件类型
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" // 群主(建群者,每群唯一):改名/解散/踢人/禁言
ChatRoleMember = "member" // 普通成员
)
// 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"`
}