feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -105,6 +105,65 @@ func (h *Handlers) AdminUserLoginLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminGetUserAudit 站长:用户内容档案摘要
|
||||
func (h *Handlers) AdminGetUserAudit(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := h.AdminUser.GetAuditProfile(middleware.CurrentActor(c), id)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// AdminUserAuditPosts 站长:用户帖子(含驳回/软删)
|
||||
func (h *Handlers) AdminUserAuditPosts(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
items, total, page, err := h.AdminUser.ListAuditPosts(middleware.CurrentActor(c), id, page, 20)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminUserAuditComments 站长:用户评论(含驳回/软删)
|
||||
func (h *Handlers) AdminUserAuditComments(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
items, total, page, err := h.AdminUser.ListAuditComments(middleware.CurrentActor(c), id, page, 20)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminUserAuditMessages 站长:用户聊天消息(含撤回/软删)
|
||||
func (h *Handlers) AdminUserAuditMessages(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
items, total, page, err := h.AdminUser.ListAuditMessages(middleware.CurrentActor(c), id, page, 20)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// parseAdminUserID 解析路径中的用户 ID,失败时直接写出 400
|
||||
func parseAdminUserID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -120,8 +179,8 @@ func respondAdminUserError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
case errors.Is(err, service.ErrProtectedOwner):
|
||||
// 站长保护属于权限边界而非参数错误
|
||||
case errors.Is(err, service.ErrProtectedOwner),
|
||||
errors.Is(err, service.ErrOwnerOnly):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrAdminSelfAction),
|
||||
errors.Is(err, service.ErrInvalidRole),
|
||||
|
||||
@@ -117,6 +117,7 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.Chat.EnsureDefaultMembership(user.ID)
|
||||
// 自动登录:走与 Login 相同的 token 签发链路
|
||||
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
@@ -263,7 +264,7 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
||||
func (h *Handlers) Me(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
resp := gin.H{"user": nil, "unread_count": 0}
|
||||
resp := gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0}
|
||||
// OptionalAuth 识别到封禁:HTTP 仍为 200(/me 不阻断页面),
|
||||
// 但带 banned/code,客户端静默校正时据此强制下线并弹告知
|
||||
if c.GetBool(middleware.AccountBannedKey) {
|
||||
@@ -275,17 +276,22 @@ func (h *Handlers) Me(c *gin.Context) {
|
||||
}
|
||||
user, err := h.Auth.GetUserByID(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0})
|
||||
return
|
||||
}
|
||||
var unread int64
|
||||
if n, err := h.Notification.UnreadCount(claims.ID); err == nil {
|
||||
unread = n
|
||||
}
|
||||
chatUnread := h.Chat.TotalUnread(claims.ID)
|
||||
// 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染
|
||||
var boardIDs []uint
|
||||
if user.Role == model.RoleBoardAdmin {
|
||||
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": meUserBody(user, boardIDs), "unread_count": unread})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": meUserBody(user, boardIDs),
|
||||
"unread_count": unread,
|
||||
"chat_unread_count": chatUnread,
|
||||
})
|
||||
}
|
||||
|
||||
459
backend/handler/chat.go
Normal file
459
backend/handler/chat.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// chatErrToStatus 群聊业务错误 → HTTP 状态
|
||||
func chatErrToStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrChatNotFound), errors.Is(err, service.ErrChatMsgNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, service.ErrChatNotMember), errors.Is(err, service.ErrChatMuted),
|
||||
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
|
||||
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
|
||||
errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve),
|
||||
errors.Is(err, service.ErrChatHallPinFixed):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func chatRoomID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "群 ID 无效"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func (h *Handlers) chatOversee(userID uint) bool {
|
||||
actor, err := h.Auth.LoadActor(userID)
|
||||
if err != nil || actor == nil {
|
||||
return false
|
||||
}
|
||||
return service.CanOverseeChat(actor.Role)
|
||||
}
|
||||
|
||||
// CreateChatRoom 建群(可公开/私密,私密可带初始成员)
|
||||
func (h *Handlers) CreateChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
MemberUsernames []string `json:"member_usernames"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
memberIDs := append([]uint{}, req.MemberIDs...)
|
||||
for _, name := range req.MemberUsernames {
|
||||
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 && uid != claims.ID {
|
||||
memberIDs = append(memberIDs, uid)
|
||||
}
|
||||
}
|
||||
room, err := h.Chat.CreateRoom(claims.ID, req.Name, req.Description, req.IsPrivate, memberIDs)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 通知被拉入的初始成员
|
||||
seen := map[uint]bool{}
|
||||
for _, uid := range memberIDs {
|
||||
if uid == 0 || uid == claims.ID || seen[uid] {
|
||||
continue
|
||||
}
|
||||
seen[uid] = true
|
||||
h.Hub.BroadcastUser(uid, realtime.Envelope{
|
||||
Type: realtime.EventChatMembership,
|
||||
Data: gin.H{"room_id": room.ID, "status": "invited"},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"room": room})
|
||||
}
|
||||
|
||||
// ListChatRooms 会话列表(默认 IM 侧栏仅已加入;scope=all 可浏览公开群)
|
||||
func (h *Handlers) ListChatRooms(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
joinedOnly := c.DefaultQuery("scope", "joined") != "all"
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
rooms, total, err := h.Chat.ListRooms(claims.ID, oversee, joinedOnly, c.Query("q"), page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rooms": rooms, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// GetChatRoom 群详情(公开非成员可预览;私密非成员 404)
|
||||
func (h *Handlers) GetChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
room, err := h.Chat.RoomDetail(claims.ID, roomID, oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"room": room})
|
||||
}
|
||||
|
||||
// UpdateChatRoom 群主改群名/简介
|
||||
func (h *Handlers) UpdateChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
room, err := h.Chat.UpdateRoom(claims.ID, roomID, req.Name, req.Description)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"room": room})
|
||||
}
|
||||
|
||||
// PinChatRoom 当前用户置顶/取消置顶会话
|
||||
func (h *Handlers) PinChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
room, err := h.Chat.SetRoomPinned(claims.ID, roomID, req.Pinned)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"room": room})
|
||||
}
|
||||
|
||||
// DissolveChatRoom 群主/监管解散群
|
||||
func (h *Handlers) DissolveChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
members, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.Chat.Dissolve(claims.ID, roomID, oversee); err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
roomName := realtime.RoomChat(roomID)
|
||||
for _, m := range members {
|
||||
h.Hub.RemoveUserRoom(m.UserID, roomName)
|
||||
h.Hub.BroadcastUser(m.UserID, realtime.Envelope{
|
||||
Type: realtime.EventChatMembership,
|
||||
Data: gin.H{"room_id": roomID, "status": "dissolved"},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// JoinChatRoom 加入公开群
|
||||
func (h *Handlers) JoinChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _, err := h.Chat.Join(claims.ID, roomID)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// LeaveChatRoom 退出群
|
||||
func (h *Handlers) LeaveChatRoom(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Chat.Leave(claims.ID, roomID); err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Hub.RemoveUserRoom(claims.ID, realtime.RoomChat(roomID))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// InviteChatMembers 群主/监管邀请入群
|
||||
func (h *Handlers) InviteChatMembers(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
Usernames []string `json:"usernames"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
|
||||
return
|
||||
}
|
||||
ids := append([]uint{}, req.UserIDs...)
|
||||
for _, name := range req.Usernames {
|
||||
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 {
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
added, err := h.Chat.InviteMembers(claims.ID, roomID, ids, oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, uid := range added {
|
||||
h.Hub.BroadcastUser(uid, realtime.Envelope{
|
||||
Type: realtime.EventChatMembership,
|
||||
Data: gin.H{"room_id": roomID, "status": "invited"},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"added": added})
|
||||
}
|
||||
|
||||
// ListChatMembers 成员列表
|
||||
func (h *Handlers) ListChatMembers(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
list, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": list})
|
||||
}
|
||||
|
||||
// KickChatMember 群主踢人
|
||||
func (h *Handlers) KickChatMember(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil || targetID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Chat.Kick(claims.ID, roomID, uint(targetID)); err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Hub.RemoveUserRoom(uint(targetID), realtime.RoomChat(roomID))
|
||||
h.Hub.BroadcastUser(uint(targetID), realtime.Envelope{
|
||||
Type: realtime.EventChatMembership,
|
||||
Data: gin.H{"room_id": roomID, "status": "kicked"},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ListChatMessages 历史消息
|
||||
func (h *Handlers) ListChatMessages(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, _ := strconv.ParseUint(c.Query("before_id"), 10, 64)
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
msgs, hasMore, err := h.Chat.ListMessages(claims.ID, roomID, uint(beforeID), size, oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": msgs, "has_more": hasMore})
|
||||
}
|
||||
|
||||
// SendChatMessage 发消息
|
||||
func (h *Handlers) SendChatMessage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
ReplyToID uint `json:"reply_to_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
msg, _, err := h.Chat.SendMessage(claims.ID, roomID, req.Content, req.ReplyToID)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatMessage,
|
||||
Data: msg,
|
||||
})
|
||||
// 向未在房内订阅的成员推未读增量(Header / 会话列表实时角标)
|
||||
if memberIDs, mErr := h.Chat.ListMemberIDs(roomID); mErr == nil {
|
||||
preview := req.Content
|
||||
runes := []rune(preview)
|
||||
if len(runes) > 80 {
|
||||
preview = string(runes[:80])
|
||||
}
|
||||
for _, uid := range memberIDs {
|
||||
if uid == claims.ID {
|
||||
continue
|
||||
}
|
||||
h.Hub.BroadcastUser(uid, realtime.Envelope{
|
||||
Type: realtime.EventChatUnread,
|
||||
Data: gin.H{
|
||||
"room_id": roomID,
|
||||
"message_id": msg.ID,
|
||||
"sender_id": claims.ID,
|
||||
"preview": preview,
|
||||
"delta": 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
// @ 通知的 WS 红点由 Notification.CreateMention → OnNotifyNew 统一推送
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
// RecallChatMessage 撤回消息
|
||||
func (h *Handlers) RecallChatMessage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mid, err := strconv.ParseUint(c.Param("mid"), 10, 64)
|
||||
if err != nil || mid == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
msg, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatRecalled,
|
||||
Data: gin.H{
|
||||
"id": msg.ID,
|
||||
"room_id": roomID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recalled_by": msg.RecalledBy,
|
||||
"recalled_at": msg.RecalledAt,
|
||||
},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
// MarkChatRead 推进已读水位
|
||||
func (h *Handlers) MarkChatRead(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MessageID uint `json:"message_id"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.Chat.MarkRead(claims.ID, roomID, req.MessageID); err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// OpenDirectChat 获取或创建与指定用户的私聊
|
||||
func (h *Handlers) OpenDirectChat(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
var req struct {
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定用户"})
|
||||
return
|
||||
}
|
||||
room, err := h.Chat.GetOrCreateDM(claims.ID, req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"room": room})
|
||||
}
|
||||
|
||||
// SearchChatUsers 消息页搜索用户以发起私聊
|
||||
func (h *Handlers) SearchChatUsers(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
users, err := h.Chat.SearchUsersForChat(claims.ID, c.Query("q"), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
// ChatUnreadSummary 未读汇总
|
||||
func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
total, rooms, err := h.Chat.UnreadSummary(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取未读失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
|
||||
}
|
||||
@@ -23,17 +23,22 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
||||
|
||||
// 待审/被拒帖子的评论不对公众开放
|
||||
var viewerID uint
|
||||
var actor *service.Actor
|
||||
var loadActor func() *service.Actor
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
viewerID = claims.ID
|
||||
loadActor = h.actorLoader(claims.ID)
|
||||
if a, err := h.Auth.LoadActor(claims.ID); err == nil {
|
||||
actor = a
|
||||
}
|
||||
}
|
||||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
boardID, _ := h.Post.GetBoardID(uint(id))
|
||||
|
||||
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), page, size)
|
||||
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), boardID, viewerID, actor, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -86,6 +91,13 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
||||
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
|
||||
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
|
||||
}
|
||||
if boardID, e := h.Post.GetBoardID(uint(id)); e == nil {
|
||||
h.broadcastFeedChanged("comment", uint(id), boardID, claims.ID)
|
||||
}
|
||||
} else if comment.Status == model.ContentStatusPending {
|
||||
if boardID, e := h.Post.GetBoardID(uint(id)); e == nil {
|
||||
h.notifyPendingReview(boardID, claims.ID, uint(id), comment.ID, "有新评论待审核")
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package handler
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Handlers 聚合所有服务引用
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Hub *realtime.Hub
|
||||
Auth *service.AuthService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
@@ -21,4 +25,46 @@ type Handlers struct {
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
}
|
||||
|
||||
// broadcastFeedChanged 帖子流有公开发布内容时全员广播(三期)
|
||||
func (h *Handlers) broadcastFeedChanged(kind string, postID, boardID, actorID uint) {
|
||||
if h.Hub == nil {
|
||||
return
|
||||
}
|
||||
h.Hub.BroadcastAll(realtime.Envelope{
|
||||
Type: realtime.EventFeedChanged,
|
||||
Data: gin.H{
|
||||
"kind": kind,
|
||||
"post_id": postID,
|
||||
"board_id": boardID,
|
||||
"actor_id": actorID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// notifyPendingReview 待审内容通知该板可审者(WS 红点由 Notification.OnNotifyNew 统一推送)
|
||||
func (h *Handlers) notifyPendingReview(boardID, actorID, postID, commentID uint, preview string) {
|
||||
ids, err := h.Auth.ListBoardModeratorIDs(boardID)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
for _, uid := range ids {
|
||||
if uid == actorID {
|
||||
continue
|
||||
}
|
||||
h.Notification.Create(uid, actorID, model.NotificationTypePendingReview, postID, commentID, preview)
|
||||
}
|
||||
h.broadcastModerationChanged()
|
||||
}
|
||||
|
||||
// broadcastModerationChanged 通知管理团队待审队列有变化(角标由客户端按权限再拉一次)
|
||||
func (h *Handlers) broadcastModerationChanged() {
|
||||
if h.Hub == nil {
|
||||
return
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomStaff, realtime.Envelope{
|
||||
Type: realtime.EventModerationChanged,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,20 @@ func (h *Handlers) AdminPendingCounts(c *gin.Context) {
|
||||
|
||||
// AdminApprovePost 通过帖子
|
||||
func (h *Handlers) AdminApprovePost(c *gin.Context) {
|
||||
h.execModeration(c, h.Moderation.ApprovePost)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
actor := middleware.CurrentActor(c)
|
||||
boardID, err := h.Moderation.ApprovePost(actor, uint(id))
|
||||
if err != nil {
|
||||
h.writeModerationError(c, err)
|
||||
return
|
||||
}
|
||||
h.broadcastFeedChanged("post", uint(id), boardID, actor.ID)
|
||||
h.broadcastModerationChanged()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||
}
|
||||
|
||||
// AdminRejectPost 拒绝帖子
|
||||
@@ -53,7 +66,20 @@ func (h *Handlers) AdminRejectPost(c *gin.Context) {
|
||||
|
||||
// AdminApproveComment 通过评论
|
||||
func (h *Handlers) AdminApproveComment(c *gin.Context) {
|
||||
h.execModeration(c, h.Moderation.ApproveComment)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
actor := middleware.CurrentActor(c)
|
||||
postID, boardID, err := h.Moderation.ApproveComment(actor, uint(id))
|
||||
if err != nil {
|
||||
h.writeModerationError(c, err)
|
||||
return
|
||||
}
|
||||
h.broadcastFeedChanged("comment", postID, boardID, actor.ID)
|
||||
h.broadcastModerationChanged()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||
}
|
||||
|
||||
// AdminRejectComment 拒绝评论
|
||||
@@ -61,7 +87,21 @@ func (h *Handlers) AdminRejectComment(c *gin.Context) {
|
||||
h.execModeration(c, h.Moderation.RejectComment)
|
||||
}
|
||||
|
||||
// execModeration 统一解析 :id 并映射审核业务错误
|
||||
// writeModerationError 映射审核业务错误为 HTTP 响应
|
||||
func (h *Handlers) writeModerationError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
|
||||
case errors.Is(err, service.ErrModerationForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrNotPending):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||
}
|
||||
}
|
||||
|
||||
// execModeration 统一解析 :id 并映射审核业务错误(拒绝等无额外副作用的操作)
|
||||
func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint) error) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
@@ -69,17 +109,9 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint)
|
||||
return
|
||||
}
|
||||
if err := fn(middleware.CurrentActor(c), uint(id)); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"})
|
||||
case errors.Is(err, service.ErrModerationForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrNotPending):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||
}
|
||||
h.writeModerationError(c, err)
|
||||
return
|
||||
}
|
||||
h.broadcastModerationChanged()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ func (h *Handlers) Posts(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
recommended := c.Query("recommended") == "true"
|
||||
|
||||
var viewerID uint
|
||||
var actor *service.Actor
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
viewerID = claims.ID
|
||||
if a, err := h.Auth.LoadActor(claims.ID); err == nil {
|
||||
actor = a
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := h.Post.List(service.PostListQuery{
|
||||
BoardID: uint(boardID),
|
||||
Page: page,
|
||||
@@ -27,6 +36,8 @@ func (h *Handlers) Posts(c *gin.Context) {
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
Recommended: recommended,
|
||||
ViewerID: viewerID,
|
||||
Actor: actor,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -109,6 +120,11 @@ func (h *Handlers) CreatePost(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if post.Status == model.ContentStatusPublished {
|
||||
h.broadcastFeedChanged("post", post.ID, post.BoardID, claims.ID)
|
||||
} else if post.Status == model.ContentStatusPending {
|
||||
h.notifyPendingReview(post.BoardID, claims.ID, post.ID, 0, "有新帖待审核:"+post.Title)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
||||
126
backend/handler/realtime.go
Normal file
126
backend/handler/realtime.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// parseWSChatRoom 解析 WS 房间名 chat:{roomID}
|
||||
func parseWSChatRoom(room string) (uint, bool) {
|
||||
idStr := strings.TrimPrefix(room, "chat:")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// 实时总线 WebSocket 入口:GET /api/ws
|
||||
//
|
||||
// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
|
||||
// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
|
||||
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
|
||||
// 防止跨站页面在用户浏览器中发起握手。
|
||||
func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||
tokenStr, err := c.Cookie(service.CookieName)
|
||||
if err != nil || tokenStr == "" {
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
if tokenStr == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
claims, err := h.Auth.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态无效"})
|
||||
return
|
||||
}
|
||||
if _, err := h.Auth.ValidateClaims(claims); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态已失效"})
|
||||
return
|
||||
}
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
HandshakeTimeout: 5 * time.Second,
|
||||
CheckOrigin: h.checkWSOrigin,
|
||||
// 子协议不传:鉴权完全走 cookie,禁止 Sec-WebSocket-Protocol 携带 token
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return // Upgrade 内部已写错误响应
|
||||
}
|
||||
|
||||
// 建连时取实时角色快照决定能否进入 staff 房间(角色变更后旧 JWT 会失效,
|
||||
// 用户重连即按新角色重新入房)
|
||||
isStaff := false
|
||||
canOverseeChat := false
|
||||
if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil {
|
||||
isStaff = actor.IsStaff()
|
||||
canOverseeChat = service.CanOverseeChat(actor.Role)
|
||||
}
|
||||
|
||||
client := realtime.NewClient(
|
||||
h.Hub,
|
||||
conn,
|
||||
claims.ID,
|
||||
isStaff,
|
||||
func() bool {
|
||||
_, verr := h.Auth.ValidateClaims(claims)
|
||||
return verr == nil
|
||||
},
|
||||
func() { h.Auth.TouchLastSeen(claims.ID) },
|
||||
// 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群
|
||||
func(room string) bool {
|
||||
roomID, ok := parseWSChatRoom(room)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if canOverseeChat {
|
||||
return true
|
||||
}
|
||||
_, err := h.Chat.Membership(roomID, claims.ID)
|
||||
return err == nil
|
||||
},
|
||||
)
|
||||
client.Serve(func(cl *realtime.Client) {
|
||||
// hello 帧:全员带 user_id/ts;staff 额外带在线用户快照用于校正绿点
|
||||
data := gin.H{
|
||||
"user_id": claims.ID,
|
||||
"ts": time.Now().Unix(),
|
||||
}
|
||||
if isStaff {
|
||||
data["online_user_ids"] = h.Hub.OnlineUserIDs()
|
||||
}
|
||||
cl.Send(realtime.Envelope{Type: realtime.EventHello, Data: data})
|
||||
})
|
||||
}
|
||||
|
||||
// checkWSOrigin 校验握手 Origin:
|
||||
// - 无 Origin(非浏览器/native 客户端)放行
|
||||
// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
|
||||
// - 生产:Origin 主机必须与请求 Host 同源
|
||||
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
if h.Cfg.DevMode {
|
||||
host := u.Hostname()
|
||||
return host == "localhost" || host == "127.0.0.1"
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,5 +42,11 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "accent": req.Accent})
|
||||
// 归一化后的值实时广播:所有在线客户端立即热换肤,无需 F5 或等轮询
|
||||
saved := strings.ToLower(strings.TrimSpace(req.Accent))
|
||||
h.Hub.BroadcastAll(realtime.Envelope{
|
||||
Type: realtime.EventSettingsChanged,
|
||||
Data: map[string]string{"accent": saved},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "accent": saved})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -62,18 +63,27 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
posts, postsTotal, err := h.Post.ListByUser(user.ID, page, size)
|
||||
var viewerID uint
|
||||
var actor *service.Actor
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
viewerID = claims.ID
|
||||
if a, err := h.Auth.LoadActor(claims.ID); err == nil {
|
||||
actor = a
|
||||
}
|
||||
}
|
||||
|
||||
posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 填充点赞状态(仅登录用户)
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
if viewerID > 0 {
|
||||
ids := make([]uint, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
likedMap := h.Like.BatchHasLiked(ids, viewerID)
|
||||
for i := range posts {
|
||||
posts[i].Liked = likedMap[posts[i].ID]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user