feat:聊天信息支持 文件、图片、语音发送,增加一键启停脚本

This commit is contained in:
bujinyuan
2026-04-20 11:53:27 +08:00
parent 1a5aac9998
commit 0c1540bdee
116 changed files with 20277 additions and 144 deletions

View File

@@ -0,0 +1,137 @@
// Package controller 提供管理端消息管理的 HTTP 接口处理
package controller
import (
"strconv"
"github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
)
// MessageManageController 管理端消息管理控制器
type MessageManageController struct {
msgService *service.MessageManageService
}
// NewMessageManageController 创建消息管理控制器
func NewMessageManageController(msgService *service.MessageManageService) *MessageManageController {
return &MessageManageController{msgService: msgService}
}
// GetMessageList 获取消息列表(分页+多条件筛选)
// GET /api/v1/admin/messages
func (ctl *MessageManageController) GetMessageList(c *gin.Context) {
ctx := c.Request.Context()
var req dto.AdminMessageListRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ResponseBadRequest(c, "参数格式错误")
return
}
result, err := ctl.msgService.GetMessageList(ctx, &req)
if err != nil {
ctl.handleError(c, err, "获取消息列表失败")
return
}
utils.ResponseOK(c, result)
}
// GetMessageDetail 获取消息详情
// GET /api/v1/admin/messages/:id
func (ctl *MessageManageController) GetMessageDetail(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
utils.ResponseBadRequest(c, "无效的消息 ID")
return
}
result, err := ctl.msgService.GetMessageDetail(ctx, id)
if err != nil {
ctl.handleError(c, err, "获取消息详情失败")
return
}
utils.ResponseOK(c, result)
}
// DeleteMessage 删除消息(软删除)
// DELETE /api/v1/admin/messages/:id
func (ctl *MessageManageController) DeleteMessage(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
utils.ResponseBadRequest(c, "无效的消息 ID")
return
}
if err := ctl.msgService.DeleteMessage(ctx, id); err != nil {
ctl.handleError(c, err, "删除消息失败")
return
}
utils.ResponseOK(c, nil)
}
// RecallMessage 管理员撤回消息
// PUT /api/v1/admin/messages/:id/recall
func (ctl *MessageManageController) RecallMessage(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
utils.ResponseBadRequest(c, "无效的消息 ID")
return
}
if err := ctl.msgService.RecallMessage(ctx, id); err != nil {
ctl.handleError(c, err, "撤回消息失败")
return
}
utils.ResponseOK(c, nil)
}
// GetMessageStats 获取消息统计数据
// GET /api/v1/admin/messages/stats
func (ctl *MessageManageController) GetMessageStats(c *gin.Context) {
ctx := c.Request.Context()
var req dto.AdminMessageStatsRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ResponseBadRequest(c, "参数格式错误")
return
}
result, err := ctl.msgService.GetStats(ctx, &req)
if err != nil {
ctl.handleError(c, err, "获取消息统计失败")
return
}
utils.ResponseOK(c, result)
}
// handleError 统一业务错误映射
func (ctl *MessageManageController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
switch err {
case service.ErrMessageNotFound:
utils.ResponseNotFound(c, err.Error())
case service.ErrMessageRecalled:
utils.ResponseBadRequest(c, err.Error())
case service.ErrMessageDeleted:
utils.ResponseBadRequest(c, err.Error())
default:
msg := "服务器内部错误"
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
msg = fallbackMsg[0]
}
utils.ResponseError(c, msg)
}
}

View File

@@ -0,0 +1,242 @@
// Package dao 提供 admin 模块的数据库访问操作
package dao
import (
"context"
"fmt"
"time"
"github.com/echochat/backend/app/dto"
imModel "github.com/echochat/backend/app/im/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MessageManageDAO 管理端消息数据访问对象
type MessageManageDAO struct {
db *gorm.DB
}
// NewMessageManageDAO 创建 MessageManageDAO 实例
func NewMessageManageDAO(db *gorm.DB) *MessageManageDAO {
return &MessageManageDAO{db: db}
}
// ListMessages 分页查询消息列表(支持多条件筛选)
func (d *MessageManageDAO) ListMessages(ctx context.Context, req *dto.AdminMessageListRequest) ([]imModel.Message, int64, error) {
funcName := "dao.message_manage_dao.ListMessages"
logs.Debug(ctx, funcName, "查询消息列表")
query := d.db.WithContext(ctx).Model(&imModel.Message{})
if req.Keyword != "" {
query = query.Where("content ILIKE ?", fmt.Sprintf("%%%s%%", req.Keyword))
}
if req.Type != nil {
query = query.Where("type = ?", *req.Type)
}
if req.SenderID != nil {
query = query.Where("sender_id = ?", *req.SenderID)
}
if req.ConversationID != nil {
query = query.Where("conversation_id = ?", *req.ConversationID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.StartTime != "" {
if t, err := time.Parse("2006-01-02", req.StartTime); err == nil {
query = query.Where("created_at >= ?", t)
}
}
if req.EndTime != "" {
if t, err := time.Parse("2006-01-02", req.EndTime); err == nil {
query = query.Where("created_at < ?", t.AddDate(0, 0, 1))
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
logs.Error(ctx, funcName, "统计消息总数失败", zap.Error(err))
return nil, 0, err
}
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
var messages []imModel.Message
err := query.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&messages).Error
if err != nil {
logs.Error(ctx, funcName, "查询消息列表失败", zap.Error(err))
return nil, 0, err
}
return messages, total, nil
}
// GetMessageByID 根据 ID 查询单条消息
func (d *MessageManageDAO) GetMessageByID(ctx context.Context, id int64) (*imModel.Message, error) {
var msg imModel.Message
err := d.db.WithContext(ctx).First(&msg, id).Error
if err != nil {
return nil, err
}
return &msg, nil
}
// UpdateMessageStatus 更新消息状态
func (d *MessageManageDAO) UpdateMessageStatus(ctx context.Context, id int64, status int) error {
return d.db.WithContext(ctx).
Model(&imModel.Message{}).
Where("id = ?", id).
Update("status", status).Error
}
// GetTotalCount 获取消息总数
func (d *MessageManageDAO) GetTotalCount(ctx context.Context) (int64, error) {
var count int64
err := d.db.WithContext(ctx).Model(&imModel.Message{}).Count(&count).Error
return count, err
}
// GetTodayCount 获取今日消息数
func (d *MessageManageDAO) GetTodayCount(ctx context.Context) (int64, error) {
var count int64
today := time.Now().Truncate(24 * time.Hour)
err := d.db.WithContext(ctx).Model(&imModel.Message{}).
Where("created_at >= ?", today).Count(&count).Error
return count, err
}
// GetTypeDistribution 获取消息类型分布
func (d *MessageManageDAO) GetTypeDistribution(ctx context.Context) ([]dto.TypeDistItem, error) {
type row struct {
Type int `gorm:"column:type"`
Count int64 `gorm:"column:count"`
}
var rows []row
err := d.db.WithContext(ctx).
Model(&imModel.Message{}).
Select("type, count(*) as count").
Group("type").
Order("count DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
result := make([]dto.TypeDistItem, 0, len(rows))
for _, r := range rows {
result = append(result, dto.TypeDistItem{
Type: r.Type,
Count: r.Count,
})
}
return result, nil
}
// GetDailyTrend 获取每日消息趋势
func (d *MessageManageDAO) GetDailyTrend(ctx context.Context, days int) ([]dto.DailyTrendItem, error) {
startDate := time.Now().AddDate(0, 0, -days).Truncate(24 * time.Hour)
type row struct {
Date string `gorm:"column:date"`
Count int64 `gorm:"column:count"`
}
var rows []row
err := d.db.WithContext(ctx).
Model(&imModel.Message{}).
Select("TO_CHAR(created_at, 'YYYY-MM-DD') as date, count(*) as count").
Where("created_at >= ?", startDate).
Group("date").
Order("date ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
result := make([]dto.DailyTrendItem, 0, len(rows))
for _, r := range rows {
result = append(result, dto.DailyTrendItem{Date: r.Date, Count: r.Count})
}
return result, nil
}
// GetActiveUsers 获取活跃用户排行Top 10
func (d *MessageManageDAO) GetActiveUsers(ctx context.Context, days int) ([]dto.ActiveUserItem, error) {
startDate := time.Now().AddDate(0, 0, -days).Truncate(24 * time.Hour)
type row struct {
UserID int64 `gorm:"column:sender_id"`
Nickname string `gorm:"column:nickname"`
Count int64 `gorm:"column:count"`
}
var rows []row
err := d.db.WithContext(ctx).
Raw(`SELECT m.sender_id, u.nickname, count(*) as count
FROM im_messages m
JOIN auth_users u ON u.id = m.sender_id
WHERE m.created_at >= ? AND m.sender_id > 0
GROUP BY m.sender_id, u.nickname
ORDER BY count DESC
LIMIT 10`, startDate).
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make([]dto.ActiveUserItem, 0, len(rows))
for _, r := range rows {
result = append(result, dto.ActiveUserItem{
UserID: r.UserID,
Nickname: r.Nickname,
Count: r.Count,
})
}
return result, nil
}
// GetActiveGroups 获取活跃群组排行Top 10
func (d *MessageManageDAO) GetActiveGroups(ctx context.Context, days int) ([]dto.ActiveGroupItem, error) {
startDate := time.Now().AddDate(0, 0, -days).Truncate(24 * time.Hour)
type row struct {
GroupID int64 `gorm:"column:id"`
Name string `gorm:"column:name"`
Count int64 `gorm:"column:count"`
}
var rows []row
err := d.db.WithContext(ctx).
Raw(`SELECT g.id, g.name, count(*) as count
FROM im_messages m
JOIN im_conversations c ON c.id = m.conversation_id
JOIN im_groups g ON g.conversation_id = c.id
WHERE m.created_at >= ? AND c.type = 2
GROUP BY g.id, g.name
ORDER BY count DESC
LIMIT 10`, startDate).
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make([]dto.ActiveGroupItem, 0, len(rows))
for _, r := range rows {
result = append(result, dto.ActiveGroupItem{
GroupID: r.GroupID,
Name: r.Name,
Count: r.Count,
})
}
return result, nil
}

View File

@@ -16,6 +16,7 @@ func RegisterRoutes(
onlineCtrl *controller.OnlineController,
contactManageCtrl *controller.ContactManageController,
groupManageCtrl *controller.GroupManageController,
msgManageCtrl *controller.MessageManageController,
jwtAuth gin.HandlerFunc,
) {
// 管理端路由组JWT 认证 + admin/super_admin 角色检查
@@ -44,5 +45,12 @@ func RegisterRoutes(
adminGroup.GET("/groups", groupManageCtrl.GetGroupList)
adminGroup.GET("/groups/:id", groupManageCtrl.GetGroupDetail)
adminGroup.DELETE("/groups/:id", groupManageCtrl.DissolveGroup)
// 消息管理
adminGroup.GET("/messages/stats", msgManageCtrl.GetMessageStats)
adminGroup.GET("/messages", msgManageCtrl.GetMessageList)
adminGroup.GET("/messages/:id", msgManageCtrl.GetMessageDetail)
adminGroup.DELETE("/messages/:id", msgManageCtrl.DeleteMessage)
adminGroup.PUT("/messages/:id/recall", msgManageCtrl.RecallMessage)
}
}

View File

@@ -0,0 +1,270 @@
// Package service 提供 admin 模块的核心业务逻辑
package service
import (
"context"
"errors"
adminDAO "github.com/echochat/backend/app/admin/dao"
authDAO "github.com/echochat/backend/app/auth/dao"
imDAO "github.com/echochat/backend/app/im/dao"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/ws"
"go.uber.org/zap"
)
var (
ErrMessageNotFound = errors.New("消息不存在")
ErrMessageRecalled = errors.New("消息已被撤回")
ErrMessageDeleted = errors.New("消息已被删除")
)
// MessageManageService 管理端消息管理服务
type MessageManageService struct {
msgDAO *adminDAO.MessageManageDAO
userDAO *authDAO.UserDAO
convDAO *imDAO.ConversationDAO
pubSub *ws.PubSub
}
// NewMessageManageService 创建 MessageManageService 实例
func NewMessageManageService(msgDAO *adminDAO.MessageManageDAO, userDAO *authDAO.UserDAO, convDAO *imDAO.ConversationDAO, pubSub *ws.PubSub) *MessageManageService {
return &MessageManageService{
msgDAO: msgDAO,
userDAO: userDAO,
convDAO: convDAO,
pubSub: pubSub,
}
}
// GetMessageList 获取消息列表(分页+筛选)
func (s *MessageManageService) GetMessageList(ctx context.Context, req *dto.AdminMessageListRequest) (*dto.AdminMessageListResponse, error) {
funcName := "service.message_manage_service.GetMessageList"
logs.Debug(ctx, funcName, "获取消息列表")
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
messages, total, err := s.msgDAO.ListMessages(ctx, req)
if err != nil {
return nil, err
}
senderIDs := make([]int64, 0, len(messages))
senderSet := make(map[int64]bool)
for _, m := range messages {
if m.SenderID > 0 && !senderSet[m.SenderID] {
senderIDs = append(senderIDs, m.SenderID)
senderSet[m.SenderID] = true
}
}
userMap := make(map[int64]struct {
Nickname string
Avatar string
})
if len(senderIDs) > 0 {
users, uErr := s.userDAO.FindByIDs(ctx, senderIDs)
if uErr != nil {
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
} else {
for _, u := range users {
userMap[u.ID] = struct {
Nickname string
Avatar string
}{Nickname: u.Nickname, Avatar: u.Avatar}
}
}
}
list := make([]dto.AdminMessageDTO, 0, len(messages))
for _, m := range messages {
item := dto.AdminMessageDTO{
ID: m.ID,
ConversationID: m.ConversationID,
SenderID: m.SenderID,
Type: m.Type,
TypeLabel: constants.MessageTypeMap[m.Type],
Content: m.Content,
Extra: m.Extra,
Status: m.Status,
StatusLabel: constants.MessageStatusMap[m.Status],
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
}
if info, ok := userMap[m.SenderID]; ok {
item.SenderNickname = info.Nickname
item.SenderAvatar = info.Avatar
}
list = append(list, item)
}
return &dto.AdminMessageListResponse{
Total: total,
List: list,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// GetMessageDetail 获取消息详情
func (s *MessageManageService) GetMessageDetail(ctx context.Context, id int64) (*dto.AdminMessageDTO, error) {
funcName := "service.message_manage_service.GetMessageDetail"
logs.Debug(ctx, funcName, "获取消息详情", zap.Int64("id", id))
msg, err := s.msgDAO.GetMessageByID(ctx, id)
if err != nil {
return nil, ErrMessageNotFound
}
item := &dto.AdminMessageDTO{
ID: msg.ID,
ConversationID: msg.ConversationID,
SenderID: msg.SenderID,
Type: msg.Type,
TypeLabel: constants.MessageTypeMap[msg.Type],
Content: msg.Content,
Extra: msg.Extra,
Status: msg.Status,
StatusLabel: constants.MessageStatusMap[msg.Status],
CreatedAt: msg.CreatedAt.Format("2006-01-02 15:04:05"),
}
if msg.SenderID > 0 {
users, uErr := s.userDAO.FindByIDs(ctx, []int64{msg.SenderID})
if uErr == nil && len(users) > 0 {
item.SenderNickname = users[0].Nickname
item.SenderAvatar = users[0].Avatar
}
}
return item, nil
}
// DeleteMessage 删除消息(软删除,状态改为已删除)
func (s *MessageManageService) DeleteMessage(ctx context.Context, id int64) error {
funcName := "service.message_manage_service.DeleteMessage"
logs.Info(ctx, funcName, "删除消息", zap.Int64("id", id))
msg, err := s.msgDAO.GetMessageByID(ctx, id)
if err != nil {
return ErrMessageNotFound
}
if msg.Status == constants.MessageStatusDeleted {
return ErrMessageDeleted
}
return s.msgDAO.UpdateMessageStatus(ctx, id, constants.MessageStatusDeleted)
}
// RecallMessage 管理员撤回消息(更新状态 + 推送 WS 通知给会话成员)
func (s *MessageManageService) RecallMessage(ctx context.Context, id int64) error {
funcName := "service.message_manage_service.RecallMessage"
logs.Info(ctx, funcName, "撤回消息", zap.Int64("id", id))
msg, err := s.msgDAO.GetMessageByID(ctx, id)
if err != nil {
return ErrMessageNotFound
}
if msg.Status == constants.MessageStatusRecalled {
return ErrMessageRecalled
}
if msg.Status == constants.MessageStatusDeleted {
return ErrMessageDeleted
}
if err := s.msgDAO.UpdateMessageStatus(ctx, id, constants.MessageStatusRecalled); err != nil {
return err
}
go s.pushRecallNotification(ctx, msg.ID, msg.ConversationID, msg.SenderID)
return nil
}
// pushRecallNotification 向会话成员推送撤回通知
func (s *MessageManageService) pushRecallNotification(ctx context.Context, messageID, conversationID, senderID int64) {
funcName := "service.message_manage_service.pushRecallNotification"
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, conversationID)
if err != nil {
logs.Error(ctx, funcName, "获取会话成员失败", zap.Error(err))
return
}
pushData := map[string]interface{}{
"message_id": messageID,
"conversation_id": conversationID,
"operator_id": int64(0),
"sender_id": senderID,
"recall_text": "管理员撤回了一条消息",
}
for _, uid := range memberIDs {
pushMsg := ws.NewPushMessage("im.message.recalled", pushData)
if err := s.pubSub.PublishToUser(ctx, uid, pushMsg); err != nil {
logs.Error(ctx, funcName, "推送撤回通知失败", zap.Int64("user_id", uid), zap.Error(err))
}
}
}
// GetStats 获取消息统计数据
func (s *MessageManageService) GetStats(ctx context.Context, req *dto.AdminMessageStatsRequest) (*dto.AdminMessageStatsResponse, error) {
funcName := "service.message_manage_service.GetStats"
logs.Debug(ctx, funcName, "获取消息统计")
days := req.Days
if days <= 0 {
days = 7
}
if days > 90 {
days = 90
}
totalCount, err := s.msgDAO.GetTotalCount(ctx)
if err != nil {
return nil, err
}
todayCount, err := s.msgDAO.GetTodayCount(ctx)
if err != nil {
return nil, err
}
typeDist, err := s.msgDAO.GetTypeDistribution(ctx)
if err != nil {
return nil, err
}
for i := range typeDist {
typeDist[i].Label = constants.MessageTypeMap[typeDist[i].Type]
}
dailyTrend, err := s.msgDAO.GetDailyTrend(ctx, days)
if err != nil {
return nil, err
}
activeUsers, err := s.msgDAO.GetActiveUsers(ctx, days)
if err != nil {
return nil, err
}
activeGroups, err := s.msgDAO.GetActiveGroups(ctx, days)
if err != nil {
return nil, err
}
return &dto.AdminMessageStatsResponse{
TotalCount: totalCount,
TodayCount: todayCount,
TypeDistribution: typeDist,
DailyTrend: dailyTrend,
ActiveUsers: activeUsers,
ActiveGroups: activeGroups,
}, nil
}