管理员可以看到所有会话了

This commit is contained in:
yml
2026-06-05 19:43:55 +08:00
parent 59093f8fd5
commit 92c603794a
5 changed files with 236 additions and 31 deletions
+156 -22
View File
@@ -149,6 +149,38 @@ func (r *Repository) ListConversations(principal Principal, page, pageSize int)
} }
func (r *Repository) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) { func (r *Repository) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) {
// 管理员可以查看任意会话,无需是 participant
if principal.Type == "admin" {
var conversation model.ChatConversation
if err := r.db.First(&conversation, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConversationNotFound
}
return nil, err
}
participants, err := r.participants(conversation.ID)
if err != nil {
return nil, err
}
dto := ConversationDTO{
ID: conversation.ID,
OrderID: conversation.OrderID,
Type: conversation.Type,
Title: conversation.Title,
Status: conversation.Status,
Role: "admin", // 管理员角色
Participants: participants,
LastMessageID: conversation.LastMessageID,
LastMessagePreview: conversation.LastMessagePreview,
LastMessageAt: conversation.LastMessageAt,
UnreadCount: 0, // 管理员不计未读
CreatedAt: conversation.CreatedAt,
UpdatedAt: conversation.UpdatedAt,
}
return &dto, nil
}
// 普通用户需要是 participant
var row conversationRow var row conversationRow
err := r.conversationQuery(principal).Where("c.id = ?", id).First(&row).Error err := r.conversationQuery(principal).Where("c.id = ?", id).First(&row).Error
if err != nil { if err != nil {
@@ -269,9 +301,23 @@ func (r *Repository) EnsureSupportConversation(userID uint64) (*ConversationDTO,
func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize) page, pageSize = normalizePagination(page, pageSize)
if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil {
return nil, err // 管理员可以查看任意会话的消息,普通用户需要是 participant
if principal.Type != "admin" {
if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil {
return nil, err
}
} else {
// 管理员需要验证会话存在
var count int64
if err := r.db.Model(&model.ChatConversation{}).Where("id = ?", conversationID).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, ErrConversationNotFound
}
} }
var total int64 var total int64
if err := r.db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil { if err := r.db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil {
return nil, err return nil, err
@@ -295,10 +341,6 @@ func (r *Repository) Messages(principal Principal, conversationID uint64, page,
func (r *Repository) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { func (r *Repository) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) {
var messageID uint64 var messageID uint64
err := r.db.Transaction(func(tx *gorm.DB) error { err := r.db.Transaction(func(tx *gorm.DB) error {
participant, err := r.findParticipant(tx, principal, conversationID, true)
if err != nil {
return err
}
var conversation model.ChatConversation var conversation model.ChatConversation
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil {
return err return err
@@ -306,11 +348,41 @@ func (r *Repository) SendMessage(principal Principal, conversationID uint64, req
if conversation.Status != "active" { if conversation.Status != "active" {
return ErrPermissionDenied return ErrPermissionDenied
} }
var senderRole string
var participant *model.ChatParticipant
// 管理员可以在任意会话发送消息,无需是 participant
if principal.Type == "admin" {
// 尝试查找管理员的 participant 记录
var p model.ChatParticipant
err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
conversationID, principal.Type, principal.ID).First(&p).Error
if err == nil {
// 管理员是 participant,使用其角色
participant = &p
senderRole = p.Role
} else if errors.Is(err, gorm.ErrRecordNotFound) {
// 管理员不是 participant,使用特殊角色 "admin"
senderRole = "admin"
} else {
return err
}
} else {
// 普通用户必须是 participant
p, err := r.findParticipant(tx, principal, conversationID, true)
if err != nil {
return err
}
participant = p
senderRole = p.Role
}
message := model.ChatMessage{ message := model.ChatMessage{
ConversationID: conversation.ID, ConversationID: conversation.ID,
SenderType: principal.Type, SenderType: principal.Type,
SenderID: principal.ID, SenderID: principal.ID,
SenderRole: participant.Role, SenderRole: senderRole,
ContentType: "text", ContentType: "text",
Content: req.Content, Content: req.Content,
AttachmentURLS: encodeStringList(req.AttachmentURLS), AttachmentURLS: encodeStringList(req.AttachmentURLS),
@@ -324,10 +396,15 @@ func (r *Repository) SendMessage(principal Principal, conversationID uint64, req
if err := tx.Save(&conversation).Error; err != nil { if err := tx.Save(&conversation).Error; err != nil {
return err return err
} }
now := time.Now()
if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil { // 更新 participant 的已读时间(仅当是 participant 时)
return err if participant != nil {
now := time.Now()
if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil {
return err
}
} }
messageID = message.ID messageID = message.ID
return nil return nil
}) })
@@ -370,6 +447,24 @@ func (r *Repository) SendMessage(principal Principal, conversationID uint64, req
func (r *Repository) MarkRead(principal Principal, conversationID uint64) error { func (r *Repository) MarkRead(principal Principal, conversationID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error { return r.db.Transaction(func(tx *gorm.DB) error {
// 管理员可以不是 participant,直接返回成功
if principal.Type == "admin" {
// 尝试查找 participant 记录,如果有就更新
var participant model.ChatParticipant
err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
conversationID, principal.Type, principal.ID).First(&participant).Error
if err == nil {
// 有 participant 记录,更新已读时间
now := time.Now()
return tx.Model(&participant).Update("last_read_at", now).Error
} else if errors.Is(err, gorm.ErrRecordNotFound) {
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
return nil
}
return err
}
// 普通用户必须是 participant
participant, err := r.findParticipant(tx, principal, conversationID, true) participant, err := r.findParticipant(tx, principal, conversationID, true)
if err != nil { if err != nil {
return err return err
@@ -475,6 +570,10 @@ func (r *Repository) toMessageDTOs(principal Principal, rows []model.ChatMessage
} }
if row.SenderType == "admin" { if row.SenderType == "admin" {
name = adminNames[row.SenderID] name = adminNames[row.SenderID]
// 如果角色是 "admin"(不是 participant 的管理员),在名字后添加标识
if row.SenderRole == "admin" {
name = name + " (管理员)"
}
} }
items = append(items, MessageDTO{ items = append(items, MessageDTO{
ID: row.ID, ID: row.ID,
@@ -830,8 +929,50 @@ func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) {
// ListConversationsWithFilter 支持筛选的会话列表 // ListConversationsWithFilter 支持筛选的会话列表
func (r *Repository) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { func (r *Repository) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize) page, pageSize = normalizePagination(page, pageSize)
var total int64
// 管理员在"全部"模式下直接查询所有会话
if principal.Type == "admin" && filter == "all" {
var total int64
if err := r.db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil {
return nil, err
}
var conversations []model.ChatConversation
offset := (page - 1) * pageSize
if err := r.db.Order("COALESCE(last_message_at, created_at) DESC, id DESC").
Offset(offset).
Limit(pageSize).
Find(&conversations).Error; err != nil {
return nil, err
}
items := make([]ConversationDTO, 0, len(conversations))
for _, conv := range conversations {
participants, err := r.participants(conv.ID)
if err != nil {
return nil, err
}
items = append(items, ConversationDTO{
ID: conv.ID,
OrderID: conv.OrderID,
Type: conv.Type,
Title: conv.Title,
Status: conv.Status,
Role: "admin", // 管理员角色
Participants: participants,
LastMessageID: conv.LastMessageID,
LastMessagePreview: conv.LastMessagePreview,
LastMessageAt: conv.LastMessageAt,
UnreadCount: 0, // 管理员不计未读
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
})
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// 其他情况使用原有逻辑
var total int64
countDB := r.db.Table("chat_conversations AS c"). countDB := r.db.Table("chat_conversations AS c").
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id") Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
@@ -844,12 +985,8 @@ func (r *Repository) ListConversationsWithFilter(principal Principal, page, page
countDB = countDB.Where("c.id NOT IN (?)", countDB = countDB.Where("c.id NOT IN (?)",
r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
default: default:
// 全部会话admin 可以看所有) // 普通用户的全部会话
if principal.Type == "admin" { countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
// 管理员看所有会话
} else {
countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
}
} }
if err := countDB.Count(&total).Error; err != nil { if err := countDB.Count(&total).Error; err != nil {
@@ -867,11 +1004,8 @@ func (r *Repository) ListConversationsWithFilter(principal Principal, page, page
queryDB = queryDB.Where("c.id NOT IN (?)", queryDB = queryDB.Where("c.id NOT IN (?)",
r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
default: default:
if principal.Type == "admin" { // 普通用户的全部会话
// 管理员看所有会话 queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
} else {
queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
}
} }
err := queryDB. err := queryDB.
@@ -276,12 +276,17 @@ function roleLabel(role: string) {
support: '客服', support: '客服',
customer: '咨询', customer: '咨询',
system: '系统', system: '系统',
admin: '管理员',
} }
return map[role] || '成员' return map[role] || '成员'
} }
function senderLabel(item: ChatMessage) { function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统' if (item.sender_type === 'system') return '系统'
// 管理员消息特殊标识
if (item.sender_role === 'admin') {
return `管理员 · ${item.sender_name}`
}
return `${roleLabel(item.sender_role)} · ${item.sender_name}` return `${roleLabel(item.sender_role)} · ${item.sender_name}`
} }
@@ -374,13 +379,19 @@ function getSupportName(item: ChatConversation) {
v-for="item in messages" v-for="item in messages"
:key="item.id" :key="item.id"
class="message-row" class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }" :class="{
self: item.is_self,
system: item.sender_type === 'system',
admin: item.sender_role === 'admin'
}"
> >
<template v-if="item.sender_type === 'system'"> <template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span> <span>{{ item.content }}</span>
</template> </template>
<template v-else> <template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small> <small :class="{ 'admin-label': item.sender_role === 'admin' }">
{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}
</small>
<p v-if="item.content">{{ item.content }}</p> <p v-if="item.content">{{ item.content }}</p>
<div v-if="item.attachment_urls.length > 0" class="message-attachments"> <div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage <ChatAttachmentImage
@@ -678,6 +689,16 @@ function getSupportName(item: ChatConversation) {
background: #dff5eb; background: #dff5eb;
} }
.message-row.admin p {
background: #fff4e6;
border-left: 3px solid #ff9800;
}
.admin-label {
color: #ff9800 !important;
font-weight: 600;
}
.message-attachments { .message-attachments {
display: grid; display: grid;
justify-items: start; justify-items: start;
+3 -3
View File
@@ -6,7 +6,7 @@ export interface ChatParticipant {
conversation_id: number conversation_id: number
participant_type: 'user' | 'admin' participant_type: 'user' | 'admin'
participant_id: number participant_id: number
role: 'renter' | 'owner' | 'support' | 'customer' role: 'renter' | 'owner' | 'support' | 'customer' | 'admin'
remark: string remark: string
display_name: string display_name: string
avatar_url: string avatar_url: string
@@ -20,7 +20,7 @@ export interface ChatConversation {
type: string type: string
title: string title: string
status: string status: string
role: 'renter' | 'owner' | 'support' | 'customer' role: 'renter' | 'owner' | 'support' | 'customer' | 'admin'
participants?: ChatParticipant[] participants?: ChatParticipant[]
last_message_id?: number last_message_id?: number
last_message_preview: string last_message_preview: string
@@ -35,7 +35,7 @@ export interface ChatMessage {
conversation_id: number conversation_id: number
sender_type: 'user' | 'admin' | 'system' sender_type: 'user' | 'admin' | 'system'
sender_id: number sender_id: number
sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system' sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system' | 'admin'
sender_name: string sender_name: string
sender_avatar: string sender_avatar: string
is_self: boolean is_self: boolean
+27 -2
View File
@@ -204,12 +204,17 @@ function roleLabel(role: string) {
support: '客服', support: '客服',
customer: '咨询', customer: '咨询',
system: '系统', system: '系统',
admin: '管理员',
} }
return map[role] || '成员' return map[role] || '成员'
} }
function senderLabel(message: ChatMessage) { function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统' if (message.sender_type === 'system') return '系统'
// 管理员消息特殊标识
if (message.sender_role === 'admin') {
return `管理员 · ${message.sender_name}`
}
return `${roleLabel(message.sender_role)} · ${message.sender_name}` return `${roleLabel(message.sender_role)} · ${message.sender_name}`
} }
@@ -255,7 +260,11 @@ function handleKeydown(e: KeyboardEvent) {
v-for="item in messages" v-for="item in messages"
:key="item.id" :key="item.id"
class="message-row" class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }" :class="{
self: item.is_self,
system: item.sender_type === 'system',
admin: item.sender_role === 'admin' && !item.is_self
}"
> >
<template v-if="item.sender_type === 'system'"> <template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span> <span class="system-message">{{ item.content }}</span>
@@ -263,7 +272,9 @@ function handleKeydown(e: KeyboardEvent) {
<template v-else> <template v-else>
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div> <div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap" :class="{ self: item.is_self }"> <div class="bubble-wrap" :class="{ self: item.is_self }">
<span class="sender-name">{{ senderLabel(item) }}</span> <span class="sender-name" :class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }">
{{ senderLabel(item) }}
</span>
<div v-if="item.content" class="bubble">{{ item.content }}</div> <div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments"> <div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage <ChatAttachmentImage
@@ -469,6 +480,20 @@ function handleKeydown(e: KeyboardEvent) {
color: #0f5132; color: #0f5132;
} }
.message-row.admin .bubble {
background: #fff4e6;
border-left: 3px solid #ff9800;
}
.message-row.admin .avatar {
background: #ff9800;
}
.admin-label {
color: #ff9800;
font-weight: 600;
}
.message-attachments { .message-attachments {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -196,12 +196,17 @@ function roleLabel(role: string) {
support: '客服', support: '客服',
customer: '咨询', customer: '咨询',
system: '系统', system: '系统',
admin: '管理员',
} }
return map[role] || '成员' return map[role] || '成员'
} }
function senderLabel(message: ChatMessage) { function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统' if (message.sender_type === 'system') return '系统'
// 管理员消息特殊标识
if (message.sender_role === 'admin') {
return `管理员 · ${message.sender_name}`
}
return `${roleLabel(message.sender_role)} · ${message.sender_name}` return `${roleLabel(message.sender_role)} · ${message.sender_name}`
} }
</script> </script>
@@ -228,7 +233,11 @@ function senderLabel(message: ChatMessage) {
v-for="item in messages" v-for="item in messages"
:key="item.id" :key="item.id"
class="message-row" class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }" :class="{
self: item.is_self,
system: item.sender_type === 'system',
admin: item.sender_role === 'admin' && !item.is_self
}"
> >
<template v-if="item.sender_type === 'system'"> <template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span> <span class="system-message">{{ item.content }}</span>
@@ -236,7 +245,9 @@ function senderLabel(message: ChatMessage) {
<template v-else> <template v-else>
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div> <div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap"> <div class="bubble-wrap">
<span class="sender-name">{{ senderLabel(item) }}</span> <span class="sender-name" :class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }">
{{ senderLabel(item) }}
</span>
<div v-if="item.content" class="bubble">{{ item.content }}</div> <div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments"> <div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage <ChatAttachmentImage
@@ -421,6 +432,20 @@ function senderLabel(message: ChatMessage) {
background: #dff5eb; background: #dff5eb;
} }
.message-row.admin .bubble {
background: #fff4e6;
border-left: 3px solid #ff9800;
}
.message-row.admin .avatar {
background: #ff9800;
}
.admin-label {
color: #ff9800;
font-weight: 600;
}
.message-attachments { .message-attachments {
display: grid; display: grid;
gap: 6px; gap: 6px;