新增客服「封存订单」动作,用于号主失联、无法联系的场景:单事务内 完成关闭订单、全量原路退款、永久封存关联商品、解散发布群、双方通知 与审计日志。 - 新增 listing 终态 sealed(封存),区别于可恢复的 offline - listingLockedForOwnerMutation 锁定 sealed,号主无法再编辑/提审/上架 - 解散群聊按 listing_id 查发布群(listing_group)置 archived, 阻断所有成员发言并留系统消息 - 新增 order.AdminSeal 及 /admin/orders/:id/seal 路由 - 前端:卖家页 PC/移动端 sealed 视为终态禁用操作,客服后台新增 「封存订单」按钮与二次确认 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
397 lines
14 KiB
Go
397 lines
14 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"gorm.io/gorm"
|
|
"hfb_sys/backend/internal/model"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
adminChatFilterAll = "all"
|
|
adminChatFilterMine = "mine"
|
|
adminChatFilterUnassigned = "unassigned"
|
|
|
|
adminChatStageAll = "all"
|
|
adminChatStagePending = "pending"
|
|
adminChatStageUnjoined = "unjoined"
|
|
adminChatStageHandoff = "handoff"
|
|
adminChatStageRenting = "renting"
|
|
adminChatStageAfterSale = "after_sale"
|
|
adminChatStageEnded = "ended"
|
|
)
|
|
|
|
var adminChatFilters = []string{adminChatFilterMine, adminChatFilterAll, adminChatFilterUnassigned}
|
|
var adminChatStages = []string{adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded}
|
|
|
|
type AdminConversationCountsDTO struct {
|
|
Ownership map[string]int64 `json:"ownership"`
|
|
Stages map[string]int64 `json:"stages"`
|
|
}
|
|
|
|
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 验证当前操作者是会话参与者
|
|
current, err := r.findParticipant(tx, principal, conversationID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Role != "support" || current.ParticipantType != "admin" {
|
|
return ErrPermissionDenied
|
|
}
|
|
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
|
|
if !adminIsSupport(tx, toAdminID) {
|
|
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
|
|
}
|
|
// 检查目标客服是否已有该会话
|
|
var count int64
|
|
if err := tx.Model(&model.ChatParticipant{}).
|
|
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID).
|
|
Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("该客服已在会话中")
|
|
}
|
|
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
|
|
if err := tx.Model(&model.ChatParticipant{}).
|
|
Where("id = ?", current.ID).
|
|
Updates(map[string]interface{}{
|
|
"participant_id": toAdminID,
|
|
"joined_at": time.Now(),
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
// 添加系统消息记录转接
|
|
message := model.ChatMessage{
|
|
ConversationID: conversationID,
|
|
SenderType: "system",
|
|
SenderRole: "system",
|
|
ContentType: "system",
|
|
Content: "会话已转接给其他客服",
|
|
AttachmentURLS: emptyJSONList(),
|
|
}
|
|
if err := tx.Create(&message).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
|
|
db := r.db.WithContext(ctx)
|
|
// 仅展示客服角色管理员,超级管理员即使有 chat:view 权限也不作为客服候选。
|
|
type adminRow struct {
|
|
ID uint64
|
|
Nickname string
|
|
SupportStatus string
|
|
}
|
|
var admins []adminRow
|
|
err := db.Table("admin_users AS au").
|
|
Select("au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname, au.support_status").
|
|
Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id").
|
|
Joins("JOIN roles AS r ON r.id = aur.role_id").
|
|
Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode).
|
|
Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC").
|
|
Scan(&admins).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 统计每个客服的会话数
|
|
type loadRow struct {
|
|
AdminID uint64
|
|
Count int64
|
|
}
|
|
var loads []loadRow
|
|
adminIDs := make([]uint64, len(admins))
|
|
for i, a := range admins {
|
|
adminIDs[i] = a.ID
|
|
}
|
|
if len(adminIDs) > 0 {
|
|
db.Table("chat_participants").
|
|
Select("participant_id AS admin_id, COUNT(*) AS count").
|
|
Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs).
|
|
Group("participant_id").
|
|
Scan(&loads)
|
|
}
|
|
loadMap := make(map[uint64]int64)
|
|
for _, l := range loads {
|
|
loadMap[l.AdminID] = l.Count
|
|
}
|
|
|
|
result := make([]SupportAdminDTO, len(admins))
|
|
for i, a := range admins {
|
|
result[i] = SupportAdminDTO{
|
|
ID: a.ID,
|
|
Nickname: a.Nickname,
|
|
SupportStatus: a.SupportStatus,
|
|
ChatCount: loadMap[a.ID],
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
|
page, pageSize = normalizePagination(page, pageSize)
|
|
filter = normalizeAdminChatFilter(filter)
|
|
stage = normalizeAdminChatStage(stage)
|
|
keyword = strings.TrimSpace(keyword)
|
|
|
|
if principal.Type == "admin" {
|
|
return r.listAdminConversations(ctx, principal, page, pageSize, filter, stage, keyword)
|
|
}
|
|
|
|
// 其他情况使用原有逻辑
|
|
var total int64
|
|
db := r.db.WithContext(ctx)
|
|
countDB := db.Table("chat_conversations AS c").
|
|
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
|
|
|
|
switch filter {
|
|
case "mine":
|
|
// 只看我的会话
|
|
countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
|
case "unassigned":
|
|
// 未分配客服的会话
|
|
countDB = countDB.Where("c.id NOT IN (?)",
|
|
db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
|
|
default:
|
|
// 普通用户的全部会话
|
|
countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
|
}
|
|
|
|
if err := countDB.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rows []conversationRow
|
|
offset := (page - 1) * pageSize
|
|
|
|
queryDB := r.conversationQuery(ctx, principal)
|
|
switch filter {
|
|
case "mine":
|
|
queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
|
case "unassigned":
|
|
queryDB = queryDB.Where("c.id NOT IN (?)",
|
|
db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
|
|
default:
|
|
// 普通用户的全部会话
|
|
queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
|
}
|
|
|
|
err := queryDB.
|
|
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
|
|
Offset(offset).
|
|
Limit(pageSize).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]ConversationDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
participants, err := r.participants(ctx, row.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, row.toDTO(participants))
|
|
}
|
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
|
var total int64
|
|
countDB := r.adminConversationBase(ctx, principal, keyword)
|
|
applyAdminChatOwnershipFilter(countDB, filter, principal)
|
|
applyAdminChatStageFilter(countDB, stage, principal)
|
|
if err := countDB.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rows []conversationRow
|
|
offset := (page - 1) * pageSize
|
|
queryDB := r.adminConversationBase(ctx, principal, keyword).
|
|
Select(`c.id, c.order_id, c.listing_id, c.type, c.title, c.status, c.last_message_id,
|
|
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
|
COALESCE(cp_me.role, 'admin') AS role,
|
|
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
|
|
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
|
|
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
|
|
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
|
|
SELECT COUNT(1)
|
|
FROM chat_messages AS cm
|
|
WHERE cm.conversation_id = c.id
|
|
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
|
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
|
|
) END AS unread_count`, principal.Type, principal.ID)
|
|
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
|
applyAdminChatStageFilter(queryDB, stage, principal)
|
|
if err := queryDB.
|
|
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
|
|
Offset(offset).
|
|
Limit(pageSize).
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]ConversationDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
participants, err := r.participants(ctx, row.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, row.toDTO(participants))
|
|
}
|
|
|
|
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
|
|
}
|
|
|
|
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
|
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
|
|
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
|
|
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
|
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
|
|
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
|
|
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
|
|
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
|
|
if keyword != "" {
|
|
like := "%" + keyword + "%"
|
|
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
|
|
OR renter.phone LIKE ? OR owner.phone LIKE ?
|
|
OR EXISTS (
|
|
SELECT 1 FROM chat_participants AS cp_kw
|
|
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
|
|
)`, like, like, like, like, like, like, like)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
|
counts := &AdminConversationCountsDTO{
|
|
Ownership: make(map[string]int64, len(adminChatFilters)),
|
|
Stages: make(map[string]int64, len(adminChatStages)),
|
|
}
|
|
for _, item := range adminChatFilters {
|
|
db := r.adminConversationBase(ctx, principal, keyword)
|
|
applyAdminChatOwnershipFilter(db, item, principal)
|
|
applyAdminChatStageFilter(db, stage, principal)
|
|
var count int64
|
|
if err := db.Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
counts.Ownership[item] = count
|
|
}
|
|
for _, item := range adminChatStages {
|
|
db := r.adminConversationBase(ctx, principal, keyword)
|
|
applyAdminChatOwnershipFilter(db, filter, principal)
|
|
applyAdminChatStageFilter(db, item, principal)
|
|
var count int64
|
|
if err := db.Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
counts.Stages[item] = count
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func normalizeAdminChatFilter(filter string) string {
|
|
switch filter {
|
|
case adminChatFilterAll, adminChatFilterMine, adminChatFilterUnassigned:
|
|
return filter
|
|
default:
|
|
return adminChatFilterMine
|
|
}
|
|
}
|
|
|
|
func normalizeAdminChatStage(stage string) string {
|
|
switch stage {
|
|
case adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded:
|
|
return stage
|
|
default:
|
|
return adminChatStageAll
|
|
}
|
|
}
|
|
|
|
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
|
|
switch filter {
|
|
case adminChatFilterMine:
|
|
db.Where("cp_me.id IS NOT NULL")
|
|
case adminChatFilterUnassigned:
|
|
db.Where(`NOT EXISTS (
|
|
SELECT 1 FROM chat_participants AS cp_support
|
|
WHERE cp_support.conversation_id = c.id
|
|
AND cp_support.participant_type = ?
|
|
AND cp_support.role = ?
|
|
)`, "admin", "support")
|
|
case adminChatFilterAll:
|
|
return
|
|
default:
|
|
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
|
|
}
|
|
}
|
|
|
|
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
|
|
switch stage {
|
|
case adminChatStagePending:
|
|
db.Where(`(
|
|
lm.sender_type = ?
|
|
OR (
|
|
cp_me.id IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM chat_messages AS cm_pending
|
|
WHERE cm_pending.conversation_id = c.id
|
|
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
|
|
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
|
|
)
|
|
)
|
|
)`, "user", principal.Type, principal.ID)
|
|
case adminChatStageUnjoined:
|
|
db.Where("lo.id IS NULL")
|
|
case adminChatStageHandoff:
|
|
db.Where("lo.status = ?", "pending_handoff")
|
|
case adminChatStageRenting:
|
|
db.Where("lo.status IN ?", []string{"renting", "overdue"})
|
|
case adminChatStageAfterSale:
|
|
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
|
|
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
|
|
case adminChatStageEnded:
|
|
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
|
|
case adminChatStageAll:
|
|
return
|
|
}
|
|
}
|
|
|
|
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
|
|
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
|
|
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
|
|
func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error {
|
|
var conversation model.ChatConversation
|
|
err := tx.Where("listing_id = ?", listingID).First(&conversation).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if conversation.Status == "archived" {
|
|
return nil
|
|
}
|
|
if err := tx.Model(&model.ChatConversation{}).
|
|
Where("id = ?", conversation.ID).
|
|
Update("status", "archived").Error; err != nil {
|
|
return err
|
|
}
|
|
content := "群聊已由客服解散,订单已封存。"
|
|
if trimmed := strings.TrimSpace(reason); trimmed != "" {
|
|
content += "原因:" + trimmed
|
|
}
|
|
return sendSystemMessage(tx, conversation.ID, content)
|
|
}
|