feat:聊天信息支持 文件、图片、语音发送,增加一键启停脚本
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
242
backend/go-service/app/admin/dao/message_manage_dao.go
Normal file
242
backend/go-service/app/admin/dao/message_manage_dao.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
270
backend/go-service/app/admin/service/message_manage_service.go
Normal file
270
backend/go-service/app/admin/service/message_manage_service.go
Normal 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
|
||||
}
|
||||
@@ -115,6 +115,20 @@ func (d *UserDAO) UpdateLastLogin(ctx context.Context, userID int64, ip string)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindByIDs 批量按 ID 查询用户
|
||||
func (d *UserDAO) FindByIDs(ctx context.Context, ids []int64) ([]model.User, error) {
|
||||
funcName := "dao.user_dao.FindByIDs"
|
||||
logs.Debug(ctx, funcName, "批量查询用户", zap.Int("count", len(ids)))
|
||||
|
||||
var users []model.User
|
||||
err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "批量查询用户失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// FindByAccount 按用户名或邮箱查找用户(登录时使用)
|
||||
func (d *UserDAO) FindByAccount(ctx context.Context, account string) (*model.User, error) {
|
||||
funcName := "dao.user_dao.FindByAccount"
|
||||
|
||||
@@ -15,10 +15,10 @@ var ConversationTypeMap = map[int]string{
|
||||
// 消息类型
|
||||
const (
|
||||
MessageTypeText = 1 // 文本消息
|
||||
MessageTypeImage = 2 // 图片消息(预留)
|
||||
MessageTypeVoice = 3 // 语音消息(预留)
|
||||
MessageTypeImage = 2 // 图片消息
|
||||
MessageTypeVoice = 3 // 语音消息
|
||||
MessageTypeVideo = 4 // 视频消息(预留)
|
||||
MessageTypeFile = 5 // 文件消息(预留)
|
||||
MessageTypeFile = 5 // 文件消息
|
||||
MessageTypeSystem = 10 // 系统消息(群聊操作通知)
|
||||
)
|
||||
|
||||
|
||||
@@ -59,3 +59,84 @@ type AdminCreateUserRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=50"` // 昵称(选填)
|
||||
RoleCode string `json:"role_code" binding:"omitempty"` // 初始角色(选填,默认 user)
|
||||
}
|
||||
|
||||
// ====== 管理端消息管理 DTO ======
|
||||
|
||||
// AdminMessageListRequest 管理端消息列表查询请求
|
||||
type AdminMessageListRequest struct {
|
||||
Keyword string `form:"keyword"` // 搜索关键词(模糊匹配消息内容)
|
||||
Type *int `form:"type"` // 消息类型筛选(1/2/3/5/10)
|
||||
SenderID *int64 `form:"sender_id"` // 发送者 ID
|
||||
ConversationID *int64 `form:"conversation_id"` // 会话 ID
|
||||
Status *int `form:"status"` // 消息状态(1=正常/2=已撤回/3=已删除)
|
||||
StartTime string `form:"start_time"` // 开始时间(YYYY-MM-DD)
|
||||
EndTime string `form:"end_time"` // 结束时间(YYYY-MM-DD)
|
||||
Page int `form:"page"` // 页码(默认1)
|
||||
PageSize int `form:"page_size"` // 每页条数(默认20)
|
||||
}
|
||||
|
||||
// AdminMessageListResponse 管理端消息列表响应
|
||||
type AdminMessageListResponse struct {
|
||||
Total int64 `json:"total"` // 总条数
|
||||
List []AdminMessageDTO `json:"list"` // 消息列表
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// AdminMessageDTO 管理端消息条目
|
||||
type AdminMessageDTO struct {
|
||||
ID int64 `json:"id"` // 消息 ID
|
||||
ConversationID int64 `json:"conversation_id"` // 会话 ID
|
||||
SenderID int64 `json:"sender_id"` // 发送者 ID
|
||||
SenderNickname string `json:"sender_nickname"` // 发送者昵称
|
||||
SenderAvatar string `json:"sender_avatar"` // 发送者头像
|
||||
Type int `json:"type"` // 消息类型
|
||||
TypeLabel string `json:"type_label"` // 类型中文标签
|
||||
Content string `json:"content"` // 消息内容
|
||||
Extra *string `json:"extra,omitempty"` // 扩展数据 JSON
|
||||
Status int `json:"status"` // 消息状态
|
||||
StatusLabel string `json:"status_label"` // 状态中文标签
|
||||
CreatedAt string `json:"created_at"` // 发送时间
|
||||
}
|
||||
|
||||
// AdminMessageStatsRequest 管理端消息统计请求
|
||||
type AdminMessageStatsRequest struct {
|
||||
Days int `form:"days"` // 统计天数(默认7,最大90)
|
||||
}
|
||||
|
||||
// AdminMessageStatsResponse 管理端消息统计响应
|
||||
type AdminMessageStatsResponse struct {
|
||||
TotalCount int64 `json:"total_count"` // 消息总数
|
||||
TodayCount int64 `json:"today_count"` // 今日消息数
|
||||
TypeDistribution []TypeDistItem `json:"type_distribution"` // 类型分布
|
||||
DailyTrend []DailyTrendItem `json:"daily_trend"` // 每日趋势
|
||||
ActiveUsers []ActiveUserItem `json:"active_users"` // 活跃用户排行
|
||||
ActiveGroups []ActiveGroupItem `json:"active_groups"` // 活跃群组排行
|
||||
}
|
||||
|
||||
// TypeDistItem 消息类型分布条目
|
||||
type TypeDistItem struct {
|
||||
Type int `json:"type"` // 消息类型
|
||||
Label string `json:"label"` // 类型中文标签
|
||||
Count int64 `json:"count"` // 数量
|
||||
}
|
||||
|
||||
// DailyTrendItem 每日消息趋势条目
|
||||
type DailyTrendItem struct {
|
||||
Date string `json:"date"` // 日期(YYYY-MM-DD)
|
||||
Count int64 `json:"count"` // 数量
|
||||
}
|
||||
|
||||
// ActiveUserItem 活跃用户排行条目
|
||||
type ActiveUserItem struct {
|
||||
UserID int64 `json:"user_id"` // 用户 ID
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Count int64 `json:"count"` // 消息数
|
||||
}
|
||||
|
||||
// ActiveGroupItem 活跃群组排行条目
|
||||
type ActiveGroupItem struct {
|
||||
GroupID int64 `json:"group_id"` // 群组 ID
|
||||
Name string `json:"name"` // 群名称
|
||||
Count int64 `json:"count"` // 消息数
|
||||
}
|
||||
|
||||
@@ -6,23 +6,25 @@ package dto
|
||||
type SendMessageRequest struct {
|
||||
ConversationID int64 `json:"conversation_id"` // 会话 ID(与 TargetUserID 二选一)
|
||||
TargetUserID int64 `json:"target_user_id"` // 对方用户 ID(首次发消息时使用,自动创建会话)
|
||||
Type int `json:"type"` // 消息类型:1=文本
|
||||
Content string `json:"content"` // 消息内容
|
||||
Type int `json:"type"` // 消息类型:1=文本, 2=图片, 3=语音, 5=文件
|
||||
Content string `json:"content"` // 消息内容(富媒体消息时为空字符串)
|
||||
Extra string `json:"extra"` // 扩展数据 JSON 字符串(图片/语音/文件元信息)
|
||||
ClientMsgID string `json:"client_msg_id"` // 客户端消息唯一 ID,用于幂等去重
|
||||
AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表(含 0 表示 @所有人)
|
||||
}
|
||||
|
||||
// MessageDTO 消息传输对象(返回给前端)
|
||||
type MessageDTO struct {
|
||||
ID int64 `json:"id"` // 消息 ID
|
||||
ConversationID int64 `json:"conversation_id"` // 所属会话 ID
|
||||
SenderID int64 `json:"sender_id"` // 发送者用户 ID
|
||||
Type int `json:"type"` // 消息类型
|
||||
Content string `json:"content"` // 消息内容
|
||||
Status int `json:"status"` // 消息状态:1=正常,2=已撤回
|
||||
ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID
|
||||
AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表
|
||||
CreatedAt string `json:"created_at"` // 发送时间
|
||||
ID int64 `json:"id"` // 消息 ID
|
||||
ConversationID int64 `json:"conversation_id"` // 所属会话 ID
|
||||
SenderID int64 `json:"sender_id"` // 发送者用户 ID
|
||||
Type int `json:"type"` // 消息类型
|
||||
Content string `json:"content"` // 消息内容
|
||||
Extra *string `json:"extra,omitempty"` // 扩展数据 JSON(图片/语音/文件元信息)
|
||||
Status int `json:"status"` // 消息状态:1=正常,2=已撤回
|
||||
ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID
|
||||
AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表
|
||||
CreatedAt string `json:"created_at"` // 发送时间
|
||||
}
|
||||
|
||||
// RecallMessageRequest 撤回消息请求(WS 事件 im.message.recall 的 data 字段)
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/echochat/backend/app/file/service"
|
||||
"github.com/echochat/backend/pkg/middleware"
|
||||
"github.com/echochat/backend/pkg/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 << 20 // 10 MB
|
||||
const (
|
||||
maxUploadSize = 50 << 20 // 50 MB(通用上传)
|
||||
maxImageUploadSize = 20 << 20 // 20 MB(图片上传)
|
||||
maxVoiceUploadSize = 5 << 20 // 5 MB(语音上传)
|
||||
)
|
||||
|
||||
// FileController 文件上传控制器
|
||||
type FileController struct {
|
||||
@@ -20,9 +26,9 @@ func NewFileController(fileService *service.FileService) *FileController {
|
||||
return &FileController{fileService: fileService}
|
||||
}
|
||||
|
||||
// Upload 处理文件上传请求
|
||||
// Upload 处理通用文件上传请求
|
||||
// POST /api/v1/upload
|
||||
// 支持 multipart/form-data,字段名为 "file"
|
||||
// 支持 multipart/form-data,字段名为 "file",最大 50MB
|
||||
func (ctl *FileController) Upload(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
_, ok := middleware.GetCurrentUserID(c)
|
||||
@@ -38,7 +44,7 @@ func (ctl *FileController) Upload(c *gin.Context) {
|
||||
}
|
||||
|
||||
if fileHeader.Size > maxUploadSize {
|
||||
utils.ResponseBadRequest(c, "文件大小不能超过 10MB")
|
||||
utils.ResponseBadRequest(c, "文件大小不能超过 50MB")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,14 +57,94 @@ func (ctl *FileController) Upload(c *gin.Context) {
|
||||
utils.ResponseOK(c, result)
|
||||
}
|
||||
|
||||
// UploadImage 处理图片上传请求,自动生成缩略图
|
||||
// POST /api/v1/upload/image
|
||||
// 支持 multipart/form-data,字段名为 "file",最大 20MB
|
||||
// 返回原图 URL + 缩略图 URL + 宽高信息
|
||||
func (ctl *FileController) UploadImage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
_, ok := middleware.GetCurrentUserID(c)
|
||||
if !ok {
|
||||
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ResponseBadRequest(c, "请选择要上传的图片")
|
||||
return
|
||||
}
|
||||
|
||||
if fileHeader.Size > maxImageUploadSize {
|
||||
utils.ResponseBadRequest(c, "图片大小不能超过 20MB")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctl.fileService.UploadImage(ctx, fileHeader)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, "图片上传失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.ResponseOK(c, result)
|
||||
}
|
||||
|
||||
// UploadVoice 处理语音上传请求,校验格式和时长
|
||||
// POST /api/v1/upload/voice
|
||||
// 支持 multipart/form-data,字段名为 "file",另需 "duration" 字段(秒)
|
||||
// 最大 5MB,时长不超过 60 秒
|
||||
func (ctl *FileController) UploadVoice(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
_, ok := middleware.GetCurrentUserID(c)
|
||||
if !ok {
|
||||
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ResponseBadRequest(c, "请选择要上传的语音文件")
|
||||
return
|
||||
}
|
||||
|
||||
if fileHeader.Size > maxVoiceUploadSize {
|
||||
utils.ResponseBadRequest(c, "语音文件大小不能超过 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
durationStr := c.PostForm("duration")
|
||||
duration := 0
|
||||
if durationStr != "" {
|
||||
duration, err = strconv.Atoi(durationStr)
|
||||
if err != nil || duration < 0 {
|
||||
utils.ResponseBadRequest(c, "语音时长参数无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := ctl.fileService.UploadVoice(ctx, fileHeader, duration)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, "语音上传失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.ResponseOK(c, result)
|
||||
}
|
||||
|
||||
// handleError 统一业务错误映射
|
||||
// 已知业务错误 → 返回 Service 层定义的具体提示
|
||||
// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误")
|
||||
func (ctl *FileController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
|
||||
switch err {
|
||||
case service.ErrFileOpen:
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case service.ErrUploadFailed:
|
||||
case service.ErrInvalidImage:
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case service.ErrInvalidVoice:
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case service.ErrVoiceTooLong:
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case service.ErrFileTooLarge:
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case service.ErrUploadFailed, service.ErrThumbnailGen:
|
||||
utils.ResponseError(c, err.Error())
|
||||
default:
|
||||
msg := "服务器内部错误"
|
||||
|
||||
@@ -12,5 +12,7 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.FileController, jwtAuth gin.
|
||||
authed.Use(jwtAuth)
|
||||
{
|
||||
authed.POST("/upload", ctrl.Upload)
|
||||
authed.POST("/upload/image", ctrl.UploadImage)
|
||||
authed.POST("/upload/voice", ctrl.UploadVoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/echochat/backend/config"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
"github.com/google/uuid"
|
||||
@@ -19,8 +25,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFileOpen = errors.New("打开上传文件失败")
|
||||
ErrUploadFailed = errors.New("文件上传失败")
|
||||
ErrFileOpen = errors.New("打开上传文件失败")
|
||||
ErrUploadFailed = errors.New("文件上传失败")
|
||||
ErrFileTooLarge = errors.New("文件大小超出限制")
|
||||
ErrInvalidImage = errors.New("无效的图片文件")
|
||||
ErrInvalidVoice = errors.New("不支持的语音格式,仅支持 mp3/wav/aac/m4a")
|
||||
ErrVoiceTooLong = errors.New("语音时长不能超过 60 秒")
|
||||
ErrThumbnailGen = errors.New("缩略图生成失败")
|
||||
)
|
||||
|
||||
// FileService 文件上传服务
|
||||
@@ -37,13 +48,41 @@ func NewFileService(client *minio.Client, cfg *config.MinioConfig) *FileService
|
||||
}
|
||||
}
|
||||
|
||||
// UploadResult 文件上传结果
|
||||
// UploadResult 通用文件上传结果
|
||||
type UploadResult struct {
|
||||
URL string `json:"url"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ImageUploadResult 图片上传结果(含缩略图和尺寸)
|
||||
type ImageUploadResult struct {
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// VoiceUploadResult 语音上传结果(含时长)
|
||||
type VoiceUploadResult struct {
|
||||
URL string `json:"url"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
Duration int `json:"duration"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// allowedVoiceExts 允许的语音文件扩展名
|
||||
var allowedVoiceExts = map[string]bool{
|
||||
".mp3": true,
|
||||
".wav": true,
|
||||
".aac": true,
|
||||
".m4a": true,
|
||||
}
|
||||
|
||||
// Upload 上传文件到 MinIO,返回文件访问 URL
|
||||
// 文件按日期目录组织:uploads/2026/03/04/{uuid}.{ext}
|
||||
func (s *FileService) Upload(ctx context.Context, fileHeader *multipart.FileHeader) (*UploadResult, error) {
|
||||
@@ -76,11 +115,7 @@ func (s *FileService) Upload(ctx context.Context, fileHeader *multipart.FileHead
|
||||
return nil, ErrUploadFailed
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if s.minioCfg.UseSSL {
|
||||
scheme = "https"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectName)
|
||||
url := s.buildURL(objectName)
|
||||
|
||||
return &UploadResult{
|
||||
URL: url,
|
||||
@@ -88,3 +123,142 @@ func (s *FileService) Upload(ctx context.Context, fileHeader *multipart.FileHead
|
||||
Size: fileHeader.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadImage 上传图片并生成缩略图
|
||||
// 原图和缩略图均存储到 MinIO,缩略图宽度 200px 等比缩放,JPEG quality 80
|
||||
func (s *FileService) UploadImage(ctx context.Context, fileHeader *multipart.FileHeader) (*ImageUploadResult, error) {
|
||||
funcName := "service.file_service.UploadImage"
|
||||
logs.Info(ctx, funcName, "上传图片",
|
||||
zap.String("file_name", fileHeader.Filename),
|
||||
zap.Int64("size", fileHeader.Size))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "打开图片文件失败", zap.Error(err))
|
||||
return nil, ErrFileOpen
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "解码图片失败", zap.Error(err))
|
||||
return nil, ErrInvalidImage
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
now := time.Now()
|
||||
fileID := uuid.New().String()
|
||||
dateDir := now.Format("2006/01/02")
|
||||
|
||||
// 上传原图(回到文件开头)
|
||||
if _, err = file.Seek(0, 0); err != nil {
|
||||
logs.Error(ctx, funcName, "重置文件指针失败", zap.Error(err))
|
||||
return nil, ErrFileOpen
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
objectName := fmt.Sprintf("uploads/%s/%s%s", dateDir, fileID, ext)
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
|
||||
_, err = s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectName, file, fileHeader.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "上传原图到 MinIO 失败", zap.Error(err))
|
||||
return nil, ErrUploadFailed
|
||||
}
|
||||
|
||||
// 生成缩略图:宽度 200px,等比缩放
|
||||
thumb := imaging.Resize(img, 200, 0, imaging.Lanczos)
|
||||
|
||||
var thumbBuf bytes.Buffer
|
||||
if err = imaging.Encode(&thumbBuf, thumb, imaging.JPEG, imaging.JPEGQuality(80)); err != nil {
|
||||
logs.Error(ctx, funcName, "编码缩略图失败", zap.Error(err))
|
||||
return nil, ErrThumbnailGen
|
||||
}
|
||||
|
||||
thumbObjectName := fmt.Sprintf("uploads/%s/%s_thumb.jpg", dateDir, fileID)
|
||||
_, err = s.minioClient.PutObject(ctx, s.minioCfg.Bucket, thumbObjectName, &thumbBuf, int64(thumbBuf.Len()), minio.PutObjectOptions{
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "上传缩略图到 MinIO 失败", zap.Error(err))
|
||||
return nil, ErrThumbnailGen
|
||||
}
|
||||
|
||||
return &ImageUploadResult{
|
||||
URL: s.buildURL(objectName),
|
||||
ThumbnailURL: s.buildURL(thumbObjectName),
|
||||
FileName: fileHeader.Filename,
|
||||
Size: fileHeader.Size,
|
||||
Width: width,
|
||||
Height: height,
|
||||
MimeType: contentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadVoice 上传语音文件,校验格式和前端传入的时长
|
||||
// duration 由前端录音回调提供(秒),后端仅做范围校验
|
||||
func (s *FileService) UploadVoice(ctx context.Context, fileHeader *multipart.FileHeader, duration int) (*VoiceUploadResult, error) {
|
||||
funcName := "service.file_service.UploadVoice"
|
||||
logs.Info(ctx, funcName, "上传语音",
|
||||
zap.String("file_name", fileHeader.Filename),
|
||||
zap.Int64("size", fileHeader.Size),
|
||||
zap.Int("duration", duration))
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
if !allowedVoiceExts[ext] {
|
||||
return nil, ErrInvalidVoice
|
||||
}
|
||||
|
||||
if duration > 60 {
|
||||
return nil, ErrVoiceTooLong
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "打开语音文件失败", zap.Error(err))
|
||||
return nil, ErrFileOpen
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
now := time.Now()
|
||||
objectName := fmt.Sprintf("uploads/%s/%s%s", now.Format("2006/01/02"), uuid.New().String(), ext)
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
|
||||
_, err = s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectName, file, fileHeader.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
logs.Error(ctx, funcName, "上传语音到 MinIO 失败", zap.Error(err))
|
||||
return nil, ErrUploadFailed
|
||||
}
|
||||
|
||||
return &VoiceUploadResult{
|
||||
URL: s.buildURL(objectName),
|
||||
FileName: fileHeader.Filename,
|
||||
Size: fileHeader.Size,
|
||||
Duration: duration,
|
||||
MimeType: contentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildURL 根据 MinIO 配置构建文件访问 URL
|
||||
func (s *FileService) buildURL(objectName string) string {
|
||||
scheme := "http"
|
||||
if s.minioCfg.UseSSL {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectName)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -82,13 +83,19 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
||||
zap.Int64("conversation_id", req.ConversationID),
|
||||
zap.Int64("target_user_id", req.TargetUserID))
|
||||
|
||||
if req.Content == "" {
|
||||
return nil, ErrEmptyContent
|
||||
}
|
||||
if req.Type == 0 {
|
||||
req.Type = constants.MessageTypeText
|
||||
}
|
||||
if req.Type != constants.MessageTypeText {
|
||||
switch req.Type {
|
||||
case constants.MessageTypeText:
|
||||
if req.Content == "" {
|
||||
return nil, ErrEmptyContent
|
||||
}
|
||||
case constants.MessageTypeImage, constants.MessageTypeVoice, constants.MessageTypeFile:
|
||||
if req.Extra == "" {
|
||||
return nil, ErrEmptyContent
|
||||
}
|
||||
default:
|
||||
return nil, ErrInvalidMsgType
|
||||
}
|
||||
|
||||
@@ -171,12 +178,16 @@ func (s *IMService) writeAndPushPrivateMessage(ctx context.Context, senderID, co
|
||||
Status: constants.MessageStatusNormal,
|
||||
ClientMsgID: req.ClientMsgID,
|
||||
}
|
||||
if req.Extra != "" {
|
||||
msg.Extra = &req.Extra
|
||||
}
|
||||
if err := s.msgDAO.Create(ctx, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, truncateContent(req.Content, 100), senderID, now); err != nil {
|
||||
preview := generateLastMsgPreview(req.Type, req.Content, msg.Extra)
|
||||
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, preview, senderID, now); err != nil {
|
||||
logs.Error(ctx, funcName, "更新最后消息失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -244,6 +255,9 @@ func (s *IMService) sendGroupMessage(ctx context.Context, senderID int64, req *d
|
||||
Status: constants.MessageStatusNormal,
|
||||
ClientMsgID: req.ClientMsgID,
|
||||
}
|
||||
if req.Extra != "" {
|
||||
msg.Extra = &req.Extra
|
||||
}
|
||||
if len(req.AtUserIDs) > 0 {
|
||||
msg.AtUserIDs = req.AtUserIDs
|
||||
}
|
||||
@@ -252,7 +266,8 @@ func (s *IMService) sendGroupMessage(ctx context.Context, senderID int64, req *d
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, truncateContent(req.Content, 100), senderID, now); err != nil {
|
||||
preview := generateLastMsgPreview(req.Type, req.Content, msg.Extra)
|
||||
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, preview, senderID, now); err != nil {
|
||||
logs.Error(ctx, funcName, "更新最后消息失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -944,6 +959,7 @@ func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
||||
SenderID: m.SenderID,
|
||||
Type: m.Type,
|
||||
Content: m.Content,
|
||||
Extra: m.Extra,
|
||||
Status: m.Status,
|
||||
ClientMsgID: m.ClientMsgID,
|
||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
@@ -965,6 +981,9 @@ func (s *IMService) buildMessagePushData(ctx context.Context, msg *model.Message
|
||||
"client_msg_id": msg.ClientMsgID,
|
||||
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if msg.Extra != nil && *msg.Extra != "" {
|
||||
pushData["extra"] = *msg.Extra
|
||||
}
|
||||
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
||||
pushData["sender_name"] = senderUsers[0].Nickname
|
||||
pushData["sender_avatar"] = senderUsers[0].Avatar
|
||||
@@ -978,6 +997,60 @@ type userBrief struct {
|
||||
Avatar string
|
||||
}
|
||||
|
||||
// generateLastMsgPreview 根据消息类型生成会话列表预览文案
|
||||
func generateLastMsgPreview(msgType int, content string, extra *string) string {
|
||||
switch msgType {
|
||||
case constants.MessageTypeImage:
|
||||
count := 1
|
||||
if extra != nil && *extra != "" {
|
||||
var parsed struct {
|
||||
Images []json.RawMessage `json:"images"`
|
||||
}
|
||||
if json.Unmarshal([]byte(*extra), &parsed) == nil && len(parsed.Images) > 1 {
|
||||
count = len(parsed.Images)
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
return fmt.Sprintf("[图片x%d]", count)
|
||||
}
|
||||
return "[图片]"
|
||||
case constants.MessageTypeVoice:
|
||||
duration := 0
|
||||
if extra != nil && *extra != "" {
|
||||
var parsed struct {
|
||||
Voice struct {
|
||||
Duration int `json:"duration"`
|
||||
} `json:"voice"`
|
||||
}
|
||||
if json.Unmarshal([]byte(*extra), &parsed) == nil {
|
||||
duration = parsed.Voice.Duration
|
||||
}
|
||||
}
|
||||
if duration > 0 {
|
||||
return fmt.Sprintf("[语音 %d\"]", duration)
|
||||
}
|
||||
return "[语音]"
|
||||
case constants.MessageTypeFile:
|
||||
fileName := ""
|
||||
if extra != nil && *extra != "" {
|
||||
var parsed struct {
|
||||
File struct {
|
||||
FileName string `json:"file_name"`
|
||||
} `json:"file"`
|
||||
}
|
||||
if json.Unmarshal([]byte(*extra), &parsed) == nil {
|
||||
fileName = parsed.File.FileName
|
||||
}
|
||||
}
|
||||
if fileName != "" {
|
||||
return fmt.Sprintf("[文件] %s", fileName)
|
||||
}
|
||||
return "[文件]"
|
||||
default:
|
||||
return truncateContent(content, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateContent 截断消息内容用于预览
|
||||
func truncateContent(s string, maxRunes int) string {
|
||||
runes := []rune(s)
|
||||
|
||||
@@ -35,6 +35,7 @@ type App struct {
|
||||
OnlineController *adminController.OnlineController // 管理端在线监控控制器
|
||||
ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器
|
||||
GroupManageController *adminController.GroupManageController // 管理端群组管理控制器
|
||||
MessageManageController *adminController.MessageManageController // 管理端消息管理控制器
|
||||
WSHandler *wsApp.Handler // WebSocket 连接处理器
|
||||
Hub *ws.Hub // WebSocket Hub 连接管理
|
||||
PubSub *ws.PubSub // Redis Pub/Sub 消息路由
|
||||
@@ -60,6 +61,7 @@ func NewApp(
|
||||
onlineCtrl *adminController.OnlineController,
|
||||
contactManageCtrl *adminController.ContactManageController,
|
||||
groupManageCtrl *adminController.GroupManageController,
|
||||
msgManageCtrl *adminController.MessageManageController,
|
||||
wsHandler *wsApp.Handler,
|
||||
hub *ws.Hub,
|
||||
pubsub *ws.PubSub,
|
||||
@@ -86,6 +88,7 @@ func NewApp(
|
||||
OnlineController: onlineCtrl,
|
||||
ContactManageController: contactManageCtrl,
|
||||
GroupManageController: groupManageCtrl,
|
||||
MessageManageController: msgManageCtrl,
|
||||
WSHandler: wsHandler,
|
||||
Hub: hub,
|
||||
PubSub: pubsub,
|
||||
|
||||
@@ -93,6 +93,11 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
groupManageService := service2.NewGroupManageService(gormDB, groupDAO)
|
||||
groupManageController := controller2.NewGroupManageController(groupManageService)
|
||||
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController)
|
||||
// Admin 消息管理初始化
|
||||
messageManageDAO := dao2.NewMessageManageDAO(gormDB)
|
||||
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
||||
messageManageController := controller2.NewMessageManageController(messageManageService)
|
||||
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user