复制项目

This commit is contained in:
kim.dev.6789
2026-01-14 22:16:44 +08:00
parent e2577b8cee
commit e50142a3b9
691 changed files with 97009 additions and 1 deletions

View File

@@ -0,0 +1,851 @@
package cron
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"git.imall.cloud/openim/open-im-server-deploy/pkg/apistruct"
mgo "git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database/mgo"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/model"
"git.imall.cloud/openim/protocol/constant"
"git.imall.cloud/openim/protocol/msg"
"git.imall.cloud/openim/protocol/sdkws"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
)
// clearGroupMsg 清理群聊消息
// 注意:每次执行时都会重新从数据库读取配置,确保配置的实时性
func (c *cronServer) clearGroupMsg() {
now := time.Now()
operationID := fmt.Sprintf("cron_clear_group_msg_%d_%d", os.Getpid(), now.UnixMilli())
ctx := mcontext.SetOperationID(c.ctx, operationID)
log.ZDebug(ctx, "[CLEAR_MSG] 定时任务触发:检查清理群聊消息配置", "operationID", operationID, "time", now.Format("2006-01-02 15:04:05"))
// 每次执行时都重新读取配置,确保获取最新的配置值
config, err := c.systemConfigDB.FindByKey(ctx, "clear_group_msg")
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 读取配置失败", err, "key", "clear_group_msg")
return
}
// 如果配置不存在,跳过执行
if config == nil {
log.ZDebug(ctx, "[CLEAR_MSG] 配置不存在,跳过执行", "key", "clear_group_msg")
return
}
// 记录从数据库读取到的配置详细信息(用于排查问题)
log.ZInfo(ctx, "[CLEAR_MSG] 从数据库读取到群聊清理配置",
"key", config.Key,
"value", config.Value,
"enabled", config.Enabled,
"title", config.Title,
"valueType", config.ValueType,
"createTime", config.CreateTime.Format("2006-01-02 15:04:05"),
"updateTime", config.UpdateTime.Format("2006-01-02 15:04:05"))
// 如果配置未启用,跳过执行
if !config.Enabled {
log.ZDebug(ctx, "[CLEAR_MSG] 配置未启用,跳过执行", "key", config.Key, "enabled", config.Enabled)
return
}
log.ZInfo(ctx, "[CLEAR_MSG] ====== 开始执行清理群聊消息任务 ======", "key", config.Key, "value", config.Value, "enabled", config.Enabled)
// 值为空也跳过,避免解析错误
if strings.TrimSpace(config.Value) == "" {
log.ZInfo(ctx, "[CLEAR_MSG] 配置值为空,跳过执行", "key", config.Key)
return
}
log.ZInfo(ctx, "[CLEAR_MSG] 配置读取成功,配置已启用", "key", config.Key, "value", config.Value, "enabled", config.Enabled)
// 解析配置值(单位:分钟)
minutes, err := strconv.ParseInt(config.Value, 10, 64)
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 解析配置值失败", err, "value", config.Value)
return
}
if minutes <= 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 配置分钟数无效,跳过执行", "minutes", minutes)
return
}
// 计算删除时间点:查询当前时间减去配置分钟数之前的消息
// 例如配置30分钟当前时间09:35:00则查询09:05:00之前的所有消息
deltime := now.Add(-time.Duration(minutes) * time.Minute)
log.ZInfo(ctx, "[CLEAR_MSG] 配置检查通过,开始查询消息",
"配置分钟数", minutes,
"当前时间", now.Format("2006-01-02 15:04:05"),
"查询时间点", deltime.Format("2006-01-02 15:04:05"),
"查询时间戳", deltime.UnixMilli(),
"说明", fmt.Sprintf("将查询send_time <= %d (即%s之前)的所有消息", deltime.UnixMilli(), deltime.Format("2006-01-02 15:04:05")))
const (
deleteCount = 10000
deleteLimit = 50
)
var totalCount int
var fileDeleteCount int
for i := 1; i <= deleteCount; i++ {
ctx := mcontext.SetOperationID(c.ctx, fmt.Sprintf("%s_%d", operationID, i))
// 先查询消息提取文件信息并删除S3文件
log.ZInfo(ctx, "[CLEAR_MSG] 开始查询消息", "iteration", i, "timestamp", deltime.UnixMilli(), "limit", deleteLimit, "deltime", deltime.Format("2006-01-02 15:04:05"))
docs, err := c.msgDocDB.GetRandBeforeMsg(ctx, deltime.UnixMilli(), deleteLimit)
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 查询消息失败", err, "iteration", i, "timestamp", deltime.UnixMilli())
break
}
log.ZInfo(ctx, "[CLEAR_MSG] 查询消息结果", "iteration", i, "docCount", len(docs), "timestamp", deltime.UnixMilli())
if len(docs) == 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 没有更多消息需要删除", "iteration", i)
break
}
// 处理每个文档中的消息,提取文件信息并删除
// 同时收集要删除的消息信息conversationID -> seqs用于发送通知
var processedDocs int
var deletedDocCount int
conversationSeqsMap := make(map[string][]int64) // conversationID -> []seq
conversationDocsMap := make(map[string]*model.MsgDocModel) // conversationID -> doc
docIDsToDelete := make([]string, 0, len(docs)) // 收集需要删除的文档ID
log.ZInfo(ctx, "[CLEAR_MSG] 开始处理文档", "iteration", i, "totalDocs", len(docs))
for docIdx, doc := range docs {
log.ZInfo(ctx, "[CLEAR_MSG] 处理文档", "iteration", i, "docIndex", docIdx+1, "totalDocs", len(docs), "docID", doc.DocID)
// 判断是否为群聊消息
conversationID := extractConversationID(doc.DocID)
log.ZInfo(ctx, "[CLEAR_MSG] 提取会话ID", "docID", doc.DocID, "conversationID", conversationID, "isGroup", isGroupConversationID(conversationID))
if !isGroupConversationID(conversationID) {
log.ZInfo(ctx, "[CLEAR_MSG] 跳过非群聊消息", "docID", doc.DocID, "conversationID", conversationID)
continue
}
// 获取完整的消息内容
log.ZInfo(ctx, "[CLEAR_MSG] 获取完整消息文档", "docID", doc.DocID)
fullDoc, err := c.msgDocDB.FindOneByDocID(ctx, doc.DocID)
if err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 获取完整消息文档失败", err, "docID", doc.DocID)
continue
}
log.ZInfo(ctx, "[CLEAR_MSG] 获取完整消息文档成功", "docID", doc.DocID, "msgCount", len(fullDoc.Msg))
// 收集要删除的消息seq只收集send_time <= deltime的消息
var seqs []int64
var beforeTimeCount int
var afterTimeCount int
log.ZInfo(ctx, "[CLEAR_MSG] 开始收集消息seq", "docID", doc.DocID, "msgCount", len(fullDoc.Msg), "查询时间戳", deltime.UnixMilli())
for msgIdx, msgInfo := range fullDoc.Msg {
if msgInfo.Msg != nil {
isBeforeTime := msgInfo.Msg.SendTime <= deltime.UnixMilli()
if isBeforeTime {
beforeTimeCount++
} else {
afterTimeCount++
}
log.ZInfo(ctx, "[CLEAR_MSG] 处理消息",
"docID", doc.DocID,
"msgIndex", msgIdx+1,
"totalMsgs", len(fullDoc.Msg),
"seq", msgInfo.Msg.Seq,
"sendID", msgInfo.Msg.SendID,
"contentType", msgInfo.Msg.ContentType,
"sendTime", msgInfo.Msg.SendTime,
"sendTimeFormatted", time.Unix(msgInfo.Msg.SendTime/1000, 0).Format("2006-01-02 15:04:05"),
"查询时间戳", deltime.UnixMilli(),
"是否在查询时间点之前", isBeforeTime)
if msgInfo.Msg.Seq > 0 && isBeforeTime {
seqs = append(seqs, msgInfo.Msg.Seq)
}
} else {
log.ZWarn(ctx, "[CLEAR_MSG] 消息数据为空", nil, "docID", doc.DocID, "msgIndex", msgIdx+1)
}
}
log.ZInfo(ctx, "[CLEAR_MSG] 收集消息seq完成",
"docID", doc.DocID,
"seqCount", len(seqs),
"seqs", seqs,
"在查询时间点之前的消息数", beforeTimeCount,
"在查询时间点之后的消息数", afterTimeCount,
"说明", fmt.Sprintf("文档中有%d条消息在查询时间点之前%d条消息在查询时间点之后", beforeTimeCount, afterTimeCount))
if len(seqs) > 0 {
conversationSeqsMap[conversationID] = append(conversationSeqsMap[conversationID], seqs...)
conversationDocsMap[conversationID] = fullDoc
log.ZInfo(ctx, "[CLEAR_MSG] 已添加到通知列表", "conversationID", conversationID, "totalSeqs", len(conversationSeqsMap[conversationID]))
}
// 提取文件信息并删除S3文件
deletedFiles := c.extractAndDeleteFiles(ctx, fullDoc, true) // true表示只处理群聊消息
fileDeleteCount += deletedFiles
// 如果文档中所有消息都在查询时间点之前,则删除整个文档
// 如果文档中只有部分消息在查询时间点之前则只删除那些消息通过DeleteMsgsPhysicalBySeqs
if afterTimeCount == 0 {
// 文档中所有消息都需要删除,删除整个文档
docIDsToDelete = append(docIDsToDelete, doc.DocID)
log.ZInfo(ctx, "[CLEAR_MSG] 文档标记为删除(所有消息都在查询时间点之前)", "docID", doc.DocID, "beforeTimeCount", beforeTimeCount)
} else {
// 文档中只有部分消息需要删除使用RPC调用DeleteMsgPhysicalBySeq删除指定消息
if len(seqs) > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 开始删除文档中的部分消息", "docID", doc.DocID, "conversationID", conversationID, "seqs", seqs)
_, err := c.msgClient.DeleteMsgPhysicalBySeq(ctx, &msg.DeleteMsgPhysicalBySeqReq{
ConversationID: conversationID,
Seqs: seqs,
})
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 删除文档中的部分消息失败", err, "docID", doc.DocID, "conversationID", conversationID, "seqs", seqs)
} else {
log.ZInfo(ctx, "[CLEAR_MSG] 删除文档中的部分消息成功", "docID", doc.DocID, "conversationID", conversationID, "seqCount", len(seqs))
totalCount += len(seqs)
}
}
}
processedDocs++
}
if processedDocs > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 文档处理完成(群聊)", "processedDocs", processedDocs, "totalDocs", len(docs), "deletedFiles", fileDeleteCount, "docIDsToDelete", len(docIDsToDelete), "iteration", i)
}
// 删除整个文档(如果文档中所有消息都在查询时间点之前)
if len(docIDsToDelete) > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 开始删除整个文档", "iteration", i, "docCount", len(docIDsToDelete))
for _, docID := range docIDsToDelete {
if err := c.msgDocDB.DeleteDoc(ctx, docID); err != nil {
log.ZError(ctx, "[CLEAR_MSG] 删除文档失败", err, "docID", docID)
} else {
deletedDocCount++
totalCount++ // 每个文档算作一条删除记录
log.ZInfo(ctx, "[CLEAR_MSG] 删除文档成功", "docID", docID)
}
}
log.ZInfo(ctx, "[CLEAR_MSG] 批次删除文档完成", "deletedDocCount", deletedDocCount, "totalDocCount", len(docIDsToDelete), "totalCount", totalCount, "iteration", i)
}
// 发送删除通知
if len(conversationSeqsMap) > 0 {
c.sendDeleteNotifications(ctx, conversationSeqsMap, conversationDocsMap, true)
}
if deletedDocCount < deleteLimit && len(docIDsToDelete) == 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 已处理完所有消息", "lastBatchCount", deletedDocCount)
break
}
}
log.ZInfo(ctx, "[CLEAR_MSG] ====== 清理群聊消息任务完成 ======", "deltime", deltime.Format("2006-01-02 15:04:05"), "duration", time.Since(now), "totalCount", totalCount, "fileDeleteCount", fileDeleteCount, "operationID", operationID)
}
// clearUserMsg 清理个人聊天消息
// 注意:每次执行时都会重新从数据库读取配置,确保配置的实时性
func (c *cronServer) clearUserMsg() {
now := time.Now()
operationID := fmt.Sprintf("cron_clear_user_msg_%d_%d", os.Getpid(), now.UnixMilli())
ctx := mcontext.SetOperationID(c.ctx, operationID)
log.ZDebug(ctx, "[CLEAR_MSG] 定时任务触发:检查清理个人聊天消息配置", "operationID", operationID, "time", now.Format("2006-01-02 15:04:05"))
// 每次执行时都重新读取配置,确保获取最新的配置值
config, err := c.systemConfigDB.FindByKey(ctx, "clear_user_msg")
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 读取配置失败", err, "key", "clear_user_msg")
return
}
// 如果配置不存在,跳过执行
if config == nil {
log.ZDebug(ctx, "[CLEAR_MSG] 配置不存在,跳过执行", "key", "clear_user_msg")
return
}
// 记录从数据库读取到的配置详细信息(用于排查问题)
log.ZInfo(ctx, "[CLEAR_MSG] 从数据库读取到个人消息清理配置",
"key", config.Key,
"value", config.Value,
"enabled", config.Enabled,
"title", config.Title,
"valueType", config.ValueType,
"createTime", config.CreateTime.Format("2006-01-02 15:04:05"),
"updateTime", config.UpdateTime.Format("2006-01-02 15:04:05"))
// 如果配置未启用,跳过执行
if !config.Enabled {
log.ZDebug(ctx, "[CLEAR_MSG] 配置未启用,跳过执行", "key", config.Key, "enabled", config.Enabled)
return
}
log.ZInfo(ctx, "[CLEAR_MSG] ====== 开始执行清理个人聊天消息任务 ======", "key", config.Key, "value", config.Value, "enabled", config.Enabled)
// 值为空也跳过,避免解析错误
if strings.TrimSpace(config.Value) == "" {
log.ZInfo(ctx, "[CLEAR_MSG] 配置值为空,跳过执行", "key", config.Key)
return
}
log.ZInfo(ctx, "[CLEAR_MSG] 配置读取成功,配置已启用", "key", config.Key, "value", config.Value, "enabled", config.Enabled)
// 解析配置值(单位:分钟)
minutes, err := strconv.ParseInt(config.Value, 10, 64)
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 解析配置值失败", err, "value", config.Value)
return
}
if minutes <= 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 配置分钟数无效,跳过执行", "minutes", minutes)
return
}
// 计算删除时间点:查询当前时间减去配置分钟数之前的消息
// 例如配置30分钟当前时间09:35:00则查询09:05:00之前的所有消息
deltime := now.Add(-time.Duration(minutes) * time.Minute)
log.ZInfo(ctx, "[CLEAR_MSG] 配置检查通过,开始查询消息(个人聊天)",
"配置分钟数", minutes,
"当前时间", now.Format("2006-01-02 15:04:05"),
"查询时间点", deltime.Format("2006-01-02 15:04:05"),
"查询时间戳", deltime.UnixMilli(),
"说明", fmt.Sprintf("将查询send_time <= %d (即%s之前)的所有消息", deltime.UnixMilli(), deltime.Format("2006-01-02 15:04:05")))
const (
deleteCount = 10000
deleteLimit = 50
)
var totalCount int
var fileDeleteCount int
for i := 1; i <= deleteCount; i++ {
ctx := mcontext.SetOperationID(c.ctx, fmt.Sprintf("%s_%d", operationID, i))
// 先查询消息提取文件信息并删除S3文件
log.ZInfo(ctx, "[CLEAR_MSG] 开始查询消息(个人聊天)", "iteration", i, "timestamp", deltime.UnixMilli(), "limit", deleteLimit, "deltime", deltime.Format("2006-01-02 15:04:05"))
docs, err := c.msgDocDB.GetRandBeforeMsg(ctx, deltime.UnixMilli(), deleteLimit)
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 查询消息失败(个人聊天)", err, "iteration", i, "timestamp", deltime.UnixMilli())
break
}
log.ZInfo(ctx, "[CLEAR_MSG] 查询消息结果(个人聊天)", "iteration", i, "docCount", len(docs), "timestamp", deltime.UnixMilli())
if len(docs) == 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 没有更多消息需要删除(个人聊天)", "iteration", i)
break
}
// 处理每个文档中的消息,提取文件信息并删除
// 同时收集要删除的消息信息conversationID -> seqs用于发送通知
var processedDocs int
var deletedDocCount int
conversationSeqsMap := make(map[string][]int64) // conversationID -> []seq
conversationDocsMap := make(map[string]*model.MsgDocModel) // conversationID -> doc
docIDsToDelete := make([]string, 0, len(docs)) // 收集需要删除的文档ID
log.ZInfo(ctx, "[CLEAR_MSG] 开始处理文档(个人聊天)", "iteration", i, "totalDocs", len(docs))
for docIdx, doc := range docs {
log.ZInfo(ctx, "[CLEAR_MSG] 处理文档(个人聊天)", "iteration", i, "docIndex", docIdx+1, "totalDocs", len(docs), "docID", doc.DocID)
// 判断是否为个人聊天消息
conversationID := extractConversationID(doc.DocID)
log.ZInfo(ctx, "[CLEAR_MSG] 提取会话ID个人聊天", "docID", doc.DocID, "conversationID", conversationID, "isSingle", isSingleConversationID(conversationID))
if !isSingleConversationID(conversationID) {
log.ZInfo(ctx, "[CLEAR_MSG] 跳过非个人聊天消息", "docID", doc.DocID, "conversationID", conversationID)
continue
}
// 获取完整的消息内容
log.ZInfo(ctx, "[CLEAR_MSG] 获取完整消息文档(个人聊天)", "docID", doc.DocID)
fullDoc, err := c.msgDocDB.FindOneByDocID(ctx, doc.DocID)
if err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 获取完整消息文档失败(个人聊天)", err, "docID", doc.DocID)
continue
}
log.ZInfo(ctx, "[CLEAR_MSG] 获取完整消息文档成功(个人聊天)", "docID", doc.DocID, "msgCount", len(fullDoc.Msg))
// 收集要删除的消息seq只收集send_time <= deltime的消息
var seqs []int64
var beforeTimeCount int
var afterTimeCount int
log.ZInfo(ctx, "[CLEAR_MSG] 开始收集消息seq个人聊天", "docID", doc.DocID, "msgCount", len(fullDoc.Msg), "查询时间戳", deltime.UnixMilli())
for msgIdx, msgInfo := range fullDoc.Msg {
if msgInfo.Msg != nil {
isBeforeTime := msgInfo.Msg.SendTime <= deltime.UnixMilli()
if isBeforeTime {
beforeTimeCount++
} else {
afterTimeCount++
}
log.ZInfo(ctx, "[CLEAR_MSG] 处理消息(个人聊天)",
"docID", doc.DocID,
"msgIndex", msgIdx+1,
"totalMsgs", len(fullDoc.Msg),
"seq", msgInfo.Msg.Seq,
"sendID", msgInfo.Msg.SendID,
"contentType", msgInfo.Msg.ContentType,
"sendTime", msgInfo.Msg.SendTime,
"sendTimeFormatted", time.Unix(msgInfo.Msg.SendTime/1000, 0).Format("2006-01-02 15:04:05"),
"查询时间戳", deltime.UnixMilli(),
"是否在查询时间点之前", isBeforeTime)
if msgInfo.Msg.Seq > 0 && isBeforeTime {
seqs = append(seqs, msgInfo.Msg.Seq)
}
} else {
log.ZWarn(ctx, "[CLEAR_MSG] 消息数据为空(个人聊天)", nil, "docID", doc.DocID, "msgIndex", msgIdx+1)
}
}
log.ZInfo(ctx, "[CLEAR_MSG] 收集消息seq完成个人聊天",
"docID", doc.DocID,
"seqCount", len(seqs),
"seqs", seqs,
"在查询时间点之前的消息数", beforeTimeCount,
"在查询时间点之后的消息数", afterTimeCount,
"说明", fmt.Sprintf("文档中有%d条消息在查询时间点之前%d条消息在查询时间点之后", beforeTimeCount, afterTimeCount))
if len(seqs) > 0 {
conversationSeqsMap[conversationID] = append(conversationSeqsMap[conversationID], seqs...)
conversationDocsMap[conversationID] = fullDoc
log.ZInfo(ctx, "[CLEAR_MSG] 已添加到通知列表(个人聊天)", "conversationID", conversationID, "totalSeqs", len(conversationSeqsMap[conversationID]))
}
// 提取文件信息并删除S3文件
deletedFiles := c.extractAndDeleteFiles(ctx, fullDoc, false) // false表示只处理个人聊天消息
fileDeleteCount += deletedFiles
// 如果文档中所有消息都在查询时间点之前,则删除整个文档
// 如果文档中只有部分消息在查询时间点之前则只删除那些消息通过DeleteMsgPhysicalBySeq
if afterTimeCount == 0 {
// 文档中所有消息都需要删除,删除整个文档
docIDsToDelete = append(docIDsToDelete, doc.DocID)
log.ZInfo(ctx, "[CLEAR_MSG] 文档标记为删除(所有消息都在查询时间点之前)(个人聊天)", "docID", doc.DocID, "beforeTimeCount", beforeTimeCount)
} else {
// 文档中只有部分消息需要删除使用RPC调用DeleteMsgPhysicalBySeq删除指定消息
if len(seqs) > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 开始删除文档中的部分消息(个人聊天)", "docID", doc.DocID, "conversationID", conversationID, "seqs", seqs)
_, err := c.msgClient.DeleteMsgPhysicalBySeq(ctx, &msg.DeleteMsgPhysicalBySeqReq{
ConversationID: conversationID,
Seqs: seqs,
})
if err != nil {
log.ZError(ctx, "[CLEAR_MSG] 删除文档中的部分消息失败(个人聊天)", err, "docID", doc.DocID, "conversationID", conversationID, "seqs", seqs)
} else {
log.ZInfo(ctx, "[CLEAR_MSG] 删除文档中的部分消息成功(个人聊天)", "docID", doc.DocID, "conversationID", conversationID, "seqCount", len(seqs))
totalCount += len(seqs)
}
}
}
processedDocs++
}
if processedDocs > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 文档处理完成(个人)", "processedDocs", processedDocs, "totalDocs", len(docs), "deletedFiles", fileDeleteCount, "docIDsToDelete", len(docIDsToDelete), "iteration", i)
}
// 删除整个文档(如果文档中所有消息都在查询时间点之前)
if len(docIDsToDelete) > 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 开始删除整个文档(个人聊天)", "iteration", i, "docCount", len(docIDsToDelete))
for _, docID := range docIDsToDelete {
if err := c.msgDocDB.DeleteDoc(ctx, docID); err != nil {
log.ZError(ctx, "[CLEAR_MSG] 删除文档失败(个人聊天)", err, "docID", docID)
} else {
deletedDocCount++
totalCount++ // 每个文档算作一条删除记录
log.ZInfo(ctx, "[CLEAR_MSG] 删除文档成功(个人聊天)", "docID", docID)
}
}
log.ZInfo(ctx, "[CLEAR_MSG] 批次删除文档完成(个人聊天)", "deletedDocCount", deletedDocCount, "totalDocCount", len(docIDsToDelete), "totalCount", totalCount, "iteration", i)
}
// 发送删除通知
if len(conversationSeqsMap) > 0 {
c.sendDeleteNotifications(ctx, conversationSeqsMap, conversationDocsMap, false)
}
if deletedDocCount < deleteLimit && len(docIDsToDelete) == 0 {
log.ZInfo(ctx, "[CLEAR_MSG] 已处理完所有消息(个人聊天)", "lastBatchCount", deletedDocCount)
break
}
}
log.ZInfo(ctx, "[CLEAR_MSG] ====== 清理个人聊天消息任务完成 ======", "deltime", deltime.Format("2006-01-02 15:04:05"), "duration", time.Since(now), "totalCount", totalCount, "fileDeleteCount", fileDeleteCount, "operationID", operationID)
}
// isGroupConversationID 判断是否为群聊会话ID
func isGroupConversationID(conversationID string) bool {
return strings.HasPrefix(conversationID, "g_") || strings.HasPrefix(conversationID, "sg_")
}
// isSingleConversationID 判断是否为个人聊天会话ID
func isSingleConversationID(conversationID string) bool {
return strings.HasPrefix(conversationID, "si_")
}
// extractConversationID 从docID中提取conversationID
func extractConversationID(docID string) string {
index := strings.LastIndex(docID, ":")
if index < 0 {
return ""
}
return docID[:index]
}
// extractAndDeleteFiles 从消息中提取文件信息并删除S3文件
// isGroupMsg: true表示只处理群聊消息false表示只处理个人聊天消息
func (c *cronServer) extractAndDeleteFiles(ctx context.Context, doc *model.MsgDocModel, isGroupMsg bool) int {
if doc == nil || len(doc.Msg) == 0 {
return 0
}
// 判断conversationID类型
conversationID := extractConversationID(doc.DocID)
if isGroupMsg && !isGroupConversationID(conversationID) {
return 0
}
if !isGroupMsg && !isSingleConversationID(conversationID) {
return 0
}
var fileNames []string
fileNamesMap := make(map[string]bool) // 用于去重
var fileTypeStats = map[string]int{
"picture": 0,
"video": 0,
"file": 0,
"voice": 0,
}
// 遍历消息,提取文件信息
totalMsgs := len(doc.Msg)
var processedMsgs int
for _, msgInfo := range doc.Msg {
if msgInfo.Msg == nil {
continue
}
contentType := msgInfo.Msg.ContentType
content := msgInfo.Msg.Content
processedMsgs++
// 根据消息类型提取文件URL
switch contentType {
case constant.Picture:
// 图片消息
var pictureElem apistruct.PictureElem
if err := json.Unmarshal([]byte(content), &pictureElem); err == nil {
var extractedCount int
if pictureElem.SourcePicture.Url != "" {
if name := extractFileNameFromURL(pictureElem.SourcePicture.Url); name != "" {
fileNamesMap[name] = true
extractedCount++
}
}
if pictureElem.BigPicture.Url != "" {
if name := extractFileNameFromURL(pictureElem.BigPicture.Url); name != "" {
fileNamesMap[name] = true
extractedCount++
}
}
if pictureElem.SnapshotPicture.Url != "" {
if name := extractFileNameFromURL(pictureElem.SnapshotPicture.Url); name != "" {
fileNamesMap[name] = true
extractedCount++
}
}
if extractedCount > 0 {
fileTypeStats["picture"]++
}
} else {
log.ZDebug(ctx, "[CLEAR_MSG] 解析图片消息失败", "err", err, "seq", msgInfo.Msg.Seq)
}
case constant.Video:
// 视频消息
var videoElem apistruct.VideoElem
if err := json.Unmarshal([]byte(content), &videoElem); err == nil {
var extractedCount int
if videoElem.VideoURL != "" {
if name := extractFileNameFromURL(videoElem.VideoURL); name != "" {
fileNamesMap[name] = true
extractedCount++
}
}
if videoElem.SnapshotURL != "" {
if name := extractFileNameFromURL(videoElem.SnapshotURL); name != "" {
fileNamesMap[name] = true
extractedCount++
}
}
if extractedCount > 0 {
fileTypeStats["video"]++
}
} else {
log.ZDebug(ctx, "[CLEAR_MSG] 解析视频消息失败", "err", err, "seq", msgInfo.Msg.Seq)
}
case constant.File:
// 文件消息
var fileElem apistruct.FileElem
if err := json.Unmarshal([]byte(content), &fileElem); err == nil {
if fileElem.SourceURL != "" {
if name := extractFileNameFromURL(fileElem.SourceURL); name != "" {
fileNamesMap[name] = true
fileTypeStats["file"]++
}
}
} else {
log.ZDebug(ctx, "[CLEAR_MSG] 解析文件消息失败", "err", err, "seq", msgInfo.Msg.Seq)
}
case constant.Voice:
// 音频消息
var soundElem apistruct.SoundElem
if err := json.Unmarshal([]byte(content), &soundElem); err == nil {
if soundElem.SourceURL != "" {
if name := extractFileNameFromURL(soundElem.SourceURL); name != "" {
fileNamesMap[name] = true
fileTypeStats["voice"]++
}
}
} else {
log.ZDebug(ctx, "[CLEAR_MSG] 解析音频消息失败", "err", err, "seq", msgInfo.Msg.Seq)
}
}
}
log.ZInfo(ctx, "[CLEAR_MSG] 消息处理统计", "docID", doc.DocID, "totalMsgs", totalMsgs, "processedMsgs", processedMsgs, "fileTypeStats", fileTypeStats)
// 将map转换为slice
for name := range fileNamesMap {
fileNames = append(fileNames, name)
}
if len(fileNames) == 0 {
log.ZDebug(ctx, "[CLEAR_MSG] 消息中未找到文件", "docID", doc.DocID)
return 0
}
log.ZInfo(ctx, "[CLEAR_MSG] 提取到文件列表", "docID", doc.DocID, "conversationID", conversationID, "fileCount", len(fileNames), "fileNames", fileNames[:min(10, len(fileNames))])
// 删除S3文件
// 通过objectDB查询文件信息然后删除数据库记录
// 直接按文件名查询不指定engine再使用记录中的engine/key处理
deletedCount := 0
notFoundCount := 0
failedCount := 0
var deletedFiles []string
var notFoundFiles []string
log.ZInfo(ctx, "[CLEAR_MSG] 开始删除文件记录", "docID", doc.DocID, "totalFiles", len(fileNames))
for i, fileName := range fileNames {
obj, err := c.objectDB.Take(ctx, "", fileName)
if err != nil || obj == nil {
// 检查是否是"未找到"错误(正常情况)还是真正的错误
if err != nil && !mgo.IsNotFound(err) {
// 真正的错误,记录为警告
log.ZWarn(ctx, "[CLEAR_MSG] 查询文件记录出错", err, "fileName", fileName, "index", i+1, "total", len(fileNames))
} else {
// 文件不存在是正常情况只记录debug日志
log.ZDebug(ctx, "[CLEAR_MSG] 文件记录不存在(正常)", "fileName", fileName, "index", i+1, "total", len(fileNames))
}
notFoundCount++
notFoundFiles = append(notFoundFiles, fileName)
continue
}
engine := obj.Engine
// 在删除前获取key引用计数用于判断是否需要删除S3文件
keyCountBeforeDelete, err := c.objectDB.GetKeyCount(ctx, engine, obj.Key)
if err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 获取key引用计数失败", err, "engine", engine, "key", obj.Key, "fileName", fileName)
keyCountBeforeDelete = 0 // 如果获取失败假设为0后续会尝试删除
}
log.ZInfo(ctx, "[CLEAR_MSG] 准备删除文件记录", "engine", engine, "fileName", fileName, "key", obj.Key, "index", i+1, "total", len(fileNames), "size", obj.Size, "contentType", obj.ContentType, "keyCountBeforeDelete", keyCountBeforeDelete)
// 删除数据库记录
if err := c.objectDB.Delete(ctx, engine, []string{fileName}); err != nil {
failedCount++
log.ZWarn(ctx, "[CLEAR_MSG] 删除文件记录失败", err, "engine", engine, "fileName", fileName, "key", obj.Key, "index", i+1, "total", len(fileNames))
continue
}
deletedCount++
deletedFiles = append(deletedFiles, fileName)
// 删除数据库记录后再次检查key引用计数
keyCountAfterDelete, err := c.objectDB.GetKeyCount(ctx, engine, obj.Key)
if err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 删除后获取key引用计数失败", err, "engine", engine, "key", obj.Key, "fileName", fileName)
}
// 删除缓存
if c.s3Cache != nil {
if err := c.s3Cache.DelS3Key(ctx, engine, fileName); err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 删除S3缓存失败", err, "engine", engine, "fileName", fileName)
} else {
log.ZInfo(ctx, "[CLEAR_MSG] S3缓存删除成功", "engine", engine, "fileName", fileName)
}
}
// 如果删除前引用计数<=1说明删除后应该为0S3文件应该被删除
if keyCountBeforeDelete <= 1 {
// 删除S3文件
if c.s3Client != nil {
if err := c.s3Client.DeleteObject(ctx, obj.Key); err != nil {
log.ZWarn(ctx, "[CLEAR_MSG] 删除S3文件失败", err, "engine", engine, "key", obj.Key, "fileName", fileName)
} else {
log.ZInfo(ctx, "[CLEAR_MSG] S3文件删除成功",
"engine", engine,
"key", obj.Key,
"fileName", fileName,
"keyCountBeforeDelete", keyCountBeforeDelete,
"keyCountAfterDelete", keyCountAfterDelete)
}
} else {
log.ZWarn(ctx, "[CLEAR_MSG] S3客户端未初始化无法删除S3文件", nil,
"engine", engine,
"key", obj.Key,
"fileName", fileName,
"keyCountBeforeDelete", keyCountBeforeDelete,
"keyCountAfterDelete", keyCountAfterDelete)
}
} else {
log.ZInfo(ctx, "[CLEAR_MSG] 文件key仍有其他引用S3文件保留",
"engine", engine,
"key", obj.Key,
"fileName", fileName,
"keyCountBeforeDelete", keyCountBeforeDelete,
"keyCountAfterDelete", keyCountAfterDelete)
}
log.ZInfo(ctx, "[CLEAR_MSG] 文件记录删除成功", "engine", engine, "fileName", fileName, "key", obj.Key, "index", i+1, "total", len(fileNames), "size", obj.Size, "contentType", obj.ContentType)
}
log.ZInfo(ctx, "[CLEAR_MSG] 文件删除汇总", "docID", doc.DocID, "conversationID", conversationID,
"totalFiles", len(fileNames),
"deletedCount", deletedCount,
"notFoundCount", notFoundCount,
"failedCount", failedCount,
"deletedFiles", deletedFiles[:min(5, len(deletedFiles))],
"notFoundFiles", notFoundFiles[:min(5, len(notFoundFiles))])
return deletedCount
}
// extractFileNameFromURL 从URL中提取文件名
func extractFileNameFromURL(fileURL string) string {
if fileURL == "" {
return ""
}
// 解析URL
parsedURL, err := url.Parse(fileURL)
if err != nil {
// 如果解析失败尝试从URL路径中提取
parts := strings.Split(fileURL, "/")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
// 移除查询参数
if idx := strings.Index(lastPart, "?"); idx >= 0 {
lastPart = lastPart[:idx]
}
return lastPart
}
return ""
}
// 从URL路径中提取文件名
path := parsedURL.Path
parts := strings.Split(path, "/")
if len(parts) > 0 {
fileName := parts[len(parts)-1]
// 移除查询参数
if idx := strings.Index(fileName, "?"); idx >= 0 {
fileName = fileName[:idx]
}
return fileName
}
return ""
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// sendDeleteNotifications 发送消息删除通知
// conversationSeqsMap: conversationID -> []seq
// conversationDocsMap: conversationID -> doc
// isGroupMsg: true表示群聊消息false表示个人聊天消息
func (c *cronServer) sendDeleteNotifications(ctx context.Context, conversationSeqsMap map[string][]int64, conversationDocsMap map[string]*model.MsgDocModel, isGroupMsg bool) {
log.ZInfo(ctx, "[CLEAR_MSG] 开始发送删除通知", "conversationCount", len(conversationSeqsMap), "isGroupMsg", isGroupMsg)
adminUserID := c.config.Share.IMAdminUser.UserIDs[0]
for conversationID, seqs := range conversationSeqsMap {
if len(seqs) == 0 {
continue
}
// 从conversationDocsMap获取原始消息文档参考撤销消息的实现
doc, ok := conversationDocsMap[conversationID]
if !ok || doc == nil || len(doc.Msg) == 0 {
log.ZWarn(ctx, "[CLEAR_MSG] 无法获取原始消息", nil, "conversationID", conversationID)
continue
}
// 获取第一条消息的信息参考撤销消息的实现使用原始消息的SessionType、GroupID、RecvID
var firstMsg *model.MsgDataModel
for _, msgInfo := range doc.Msg {
if msgInfo.Msg != nil {
firstMsg = msgInfo.Msg
break
}
}
if firstMsg == nil {
log.ZWarn(ctx, "[CLEAR_MSG] 无法获取原始消息数据", nil, "conversationID", conversationID)
continue
}
// 构建删除通知
tips := &sdkws.DeleteMsgsTips{
UserID: adminUserID,
ConversationID: conversationID,
Seqs: seqs,
}
// 参考撤销消息的实现根据原始消息的SessionType确定recvID
var recvID string
var sessionType int32
if firstMsg.SessionType == constant.ReadGroupChatType {
recvID = firstMsg.GroupID
sessionType = firstMsg.SessionType
} else {
recvID = firstMsg.RecvID
sessionType = firstMsg.SessionType
}
if recvID == "" {
log.ZWarn(ctx, "[CLEAR_MSG] 无法确定通知接收者", nil, "conversationID", conversationID, "sessionType", sessionType, "groupID", firstMsg.GroupID, "recvID", firstMsg.RecvID)
continue
}
// 使用NotificationSender发送通知参考撤销消息的实现
c.notificationSender.NotificationWithSessionType(ctx, adminUserID, recvID,
constant.DeleteMsgsNotification, sessionType, tips)
log.ZInfo(ctx, "[CLEAR_MSG] 发送删除通知", "conversationID", conversationID, "recvID", recvID, "sessionType", sessionType, "seqCount", len(seqs))
}
log.ZInfo(ctx, "[CLEAR_MSG] 删除通知发送完成", "conversationCount", len(conversationSeqsMap), "isGroupMsg", isGroupMsg)
}

View File

@@ -0,0 +1,307 @@
package cron
import (
"context"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/config"
disetcd "git.imall.cloud/openim/open-im-server-deploy/pkg/common/discovery/etcd"
mcache "git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/cache/mcache"
redisCache "git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/cache/redis"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database/mgo"
"git.imall.cloud/openim/open-im-server-deploy/pkg/dbbuild"
"git.imall.cloud/openim/open-im-server-deploy/pkg/notification"
"git.imall.cloud/openim/open-im-server-deploy/pkg/rpcli"
pbconversation "git.imall.cloud/openim/protocol/conversation"
"git.imall.cloud/openim/protocol/msg"
"git.imall.cloud/openim/protocol/third"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/discovery/etcd"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
"github.com/openimsdk/tools/s3"
"github.com/openimsdk/tools/s3/cont"
"github.com/openimsdk/tools/s3/disable"
"github.com/openimsdk/tools/s3/minio"
"github.com/openimsdk/tools/utils/runtimeenv"
"github.com/robfig/cron/v3"
"google.golang.org/grpc"
)
type Config struct {
CronTask config.CronTask
Share config.Share
Discovery config.Discovery
Mongo config.Mongo
Redis config.Redis
Minio config.Minio
Third config.Third
Notification config.Notification
}
func Start(ctx context.Context, conf *Config, client discovery.SvcDiscoveryRegistry, service grpc.ServiceRegistrar) error {
log.CInfo(ctx, "CRON-TASK server is initializing", "runTimeEnv", runtimeenv.RuntimeEnvironment(), "chatRecordsClearTime", conf.CronTask.CronExecuteTime, "msgDestructTime", conf.CronTask.RetainChatRecords)
if conf.CronTask.RetainChatRecords < 1 {
log.ZInfo(ctx, "disable cron")
<-ctx.Done()
return nil
}
ctx = mcontext.SetOpUserID(ctx, conf.Share.IMAdminUser.UserIDs[0])
msgConn, err := client.GetConn(ctx, conf.Discovery.RpcService.Msg)
if err != nil {
return err
}
thirdConn, err := client.GetConn(ctx, conf.Discovery.RpcService.Third)
if err != nil {
return err
}
conversationConn, err := client.GetConn(ctx, conf.Discovery.RpcService.Conversation)
if err != nil {
return err
}
groupConn, err := client.GetConn(ctx, conf.Discovery.RpcService.Group)
if err != nil {
return err
}
// 初始化数据库连接(用于会议群聊解散)
dbb := dbbuild.NewBuilder(&conf.Mongo, &conf.Redis)
mgocli, err := dbb.Mongo(ctx)
if err != nil {
return err
}
meetingDB, err := mgo.NewMeetingMongo(mgocli.GetDB())
if err != nil {
return err
}
systemConfigDB, err := mgo.NewSystemConfigMongo(mgocli.GetDB())
if err != nil {
return err
}
msgDocDB, err := mgo.NewMsgMongo(mgocli.GetDB())
if err != nil {
return err
}
objectDB, err := mgo.NewS3Mongo(mgocli.GetDB())
if err != nil {
return err
}
// 初始化S3客户端和缓存用于删除S3文件
rdb, err := dbb.Redis(ctx)
if err != nil {
log.ZWarn(ctx, "Redis连接失败S3文件删除功能可能受限", err)
rdb = nil
}
var s3Client s3.Interface
var s3Cache cont.S3Cache
switch enable := conf.Third.Object.Enable; enable {
case "minio":
var minioCache minio.Cache
if rdb == nil {
mc, err := mgo.NewCacheMgo(mgocli.GetDB())
if err != nil {
log.ZWarn(ctx, "Mongo缓存初始化失败S3文件删除功能可能受限", err)
s3Client = disable.NewDisable()
s3Cache = nil
} else {
minioCache = mcache.NewMinioCache(mc)
s3Client, err = minio.NewMinio(ctx, minioCache, *conf.Minio.Build())
if err != nil {
log.ZError(ctx, "Minio初始化失败", err)
return err
}
s3Cache = nil // MongoDB缓存模式下S3Cache为nil
}
} else {
minioCache = redisCache.NewMinioCache(rdb)
s3Client, err = minio.NewMinio(ctx, minioCache, *conf.Minio.Build())
if err != nil {
log.ZError(ctx, "Minio初始化失败", err)
return err
}
s3Cache = redisCache.NewS3Cache(rdb, s3Client)
}
case "":
s3Client = disable.NewDisable()
s3Cache = nil
default:
// 其他S3类型暂不支持使用disable模式
log.ZWarn(ctx, "S3类型不支持使用disable模式", nil, "enable", enable)
s3Client = disable.NewDisable()
s3Cache = nil
}
var locker Locker
if conf.Discovery.Enable == config.ETCD {
cm := disetcd.NewConfigManager(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient(), []string{
conf.CronTask.GetConfigFileName(),
conf.Share.GetConfigFileName(),
conf.Discovery.GetConfigFileName(),
})
cm.Watch(ctx)
locker, err = NewEtcdLocker(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient())
if err != nil {
return err
}
}
if locker == nil {
locker = emptyLocker{}
}
// 初始化NotificationSender用于发送删除消息通知
notificationSender := notification.NewNotificationSender(&conf.Notification,
notification.WithRpcClient(func(ctx context.Context, req *msg.SendMsgReq) (*msg.SendMsgResp, error) {
return msg.NewMsgClient(msgConn).SendMsg(ctx, req)
}),
)
srv := &cronServer{
ctx: ctx,
config: conf,
cron: cron.New(),
msgClient: msg.NewMsgClient(msgConn),
conversationClient: pbconversation.NewConversationClient(conversationConn),
thirdClient: third.NewThirdClient(thirdConn),
groupClient: rpcli.NewGroupClient(groupConn),
meetingDB: meetingDB,
systemConfigDB: systemConfigDB,
msgDocDB: msgDocDB,
objectDB: objectDB,
s3Client: s3Client,
s3Cache: s3Cache,
notificationSender: notificationSender,
locker: locker,
}
if err := srv.registerClearS3(); err != nil {
return err
}
if err := srv.registerDeleteMsg(); err != nil {
return err
}
if err := srv.registerClearUserMsg(); err != nil {
return err
}
if err := srv.registerDismissMeetingGroups(); err != nil {
return err
}
if err := srv.registerClearGroupMsgByConfig(); err != nil {
return err
}
if err := srv.registerClearUserMsgByConfig(); err != nil {
return err
}
log.ZDebug(ctx, "start cron task", "CronExecuteTime", conf.CronTask.CronExecuteTime)
srv.cron.Start()
log.ZDebug(ctx, "cron task server is running")
<-ctx.Done()
log.ZDebug(ctx, "cron task server is shutting down")
srv.cron.Stop()
return nil
}
type Locker interface {
ExecuteWithLock(ctx context.Context, taskName string, task func())
}
type emptyLocker struct{}
func (emptyLocker) ExecuteWithLock(ctx context.Context, taskName string, task func()) {
task()
}
type cronServer struct {
ctx context.Context
config *Config
cron *cron.Cron
msgClient msg.MsgClient
conversationClient pbconversation.ConversationClient
thirdClient third.ThirdClient
groupClient *rpcli.GroupClient
meetingDB database.Meeting
systemConfigDB database.SystemConfig
msgDocDB database.Msg
objectDB database.ObjectInfo
s3Client s3.Interface
s3Cache cont.S3Cache
notificationSender *notification.NotificationSender
locker Locker
}
func (c *cronServer) registerClearS3() error {
if c.config.CronTask.FileExpireTime <= 0 || len(c.config.CronTask.DeleteObjectType) == 0 {
log.ZInfo(c.ctx, "disable scheduled cleanup of s3", "fileExpireTime", c.config.CronTask.FileExpireTime, "deleteObjectType", c.config.CronTask.DeleteObjectType)
return nil
}
_, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, func() {
c.locker.ExecuteWithLock(c.ctx, "clearS3", c.clearS3)
})
return errs.WrapMsg(err, "failed to register clear s3 cron task")
}
func (c *cronServer) registerDeleteMsg() error {
if c.config.CronTask.RetainChatRecords <= 0 {
log.ZInfo(c.ctx, "disable scheduled cleanup of chat records", "retainChatRecords", c.config.CronTask.RetainChatRecords)
return nil
}
_, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, func() {
c.locker.ExecuteWithLock(c.ctx, "deleteMsg", c.deleteMsg)
})
return errs.WrapMsg(err, "failed to register delete msg cron task")
}
func (c *cronServer) registerClearUserMsg() error {
_, err := c.cron.AddFunc(c.config.CronTask.CronExecuteTime, func() {
c.locker.ExecuteWithLock(c.ctx, "clearUserMsg", c.clearUserMsg)
})
return errs.WrapMsg(err, "failed to register clear user msg cron task")
}
func (c *cronServer) registerDismissMeetingGroups() error {
// 每分钟执行一次检查已结束超过10分钟的会议并解散群聊
_, err := c.cron.AddFunc("*/1 * * * *", func() {
c.locker.ExecuteWithLock(c.ctx, "dismissMeetingGroups", c.dismissMeetingGroups)
})
return errs.WrapMsg(err, "failed to register dismiss meeting groups cron task")
}
func (c *cronServer) registerClearGroupMsgByConfig() error {
// 使用配置文件中的执行时间,清理群聊消息(根据系统配置)
cronExpr := c.config.CronTask.CronExecuteTime
log.ZInfo(c.ctx, "[CLEAR_MSG] 注册清理群聊消息定时任务", "cron", cronExpr)
_, err := c.cron.AddFunc(cronExpr, func() {
c.locker.ExecuteWithLock(c.ctx, "clearGroupMsgByConfig", c.clearGroupMsg)
})
if err != nil {
log.ZError(c.ctx, "[CLEAR_MSG] 注册清理群聊消息定时任务失败", err)
} else {
log.ZInfo(c.ctx, "[CLEAR_MSG] 清理群聊消息定时任务注册成功", "cron", cronExpr)
}
return errs.WrapMsg(err, "failed to register clear group msg by config cron task")
}
func (c *cronServer) registerClearUserMsgByConfig() error {
// 使用配置文件中的执行时间,清理个人聊天消息(根据系统配置)
cronExpr := c.config.CronTask.CronExecuteTime
log.ZInfo(c.ctx, "[CLEAR_MSG] 注册清理个人聊天消息定时任务", "cron", cronExpr)
_, err := c.cron.AddFunc(cronExpr, func() {
c.locker.ExecuteWithLock(c.ctx, "clearUserMsgByConfig", c.clearUserMsg)
})
if err != nil {
log.ZError(c.ctx, "[CLEAR_MSG] 注册清理个人聊天消息定时任务失败", err)
} else {
log.ZInfo(c.ctx, "[CLEAR_MSG] 清理个人聊天消息定时任务注册成功", "cron", cronExpr)
}
return errs.WrapMsg(err, "failed to register clear user msg by config cron task")
}

View File

@@ -0,0 +1,64 @@
package cron
import (
"context"
"testing"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/config"
kdisc "git.imall.cloud/openim/open-im-server-deploy/pkg/common/discovery"
pbconversation "git.imall.cloud/openim/protocol/conversation"
"git.imall.cloud/openim/protocol/msg"
"git.imall.cloud/openim/protocol/third"
"github.com/openimsdk/tools/mcontext"
"github.com/openimsdk/tools/mw"
"github.com/robfig/cron/v3"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestName(t *testing.T) {
conf := &config.Discovery{
Enable: config.ETCD,
Etcd: config.Etcd{
RootDirectory: "openim",
Address: []string{"localhost:12379"},
},
}
client, err := kdisc.NewDiscoveryRegister(conf, nil)
if err != nil {
panic(err)
}
client.AddOption(mw.GrpcClient(), grpc.WithTransportCredentials(insecure.NewCredentials()))
ctx := mcontext.SetOpUserID(context.Background(), "imAdmin")
msgConn, err := client.GetConn(ctx, "msg-rpc-service")
if err != nil {
panic(err)
}
thirdConn, err := client.GetConn(ctx, "third-rpc-service")
if err != nil {
panic(err)
}
conversationConn, err := client.GetConn(ctx, "conversation-rpc-service")
if err != nil {
panic(err)
}
srv := &cronServer{
ctx: ctx,
config: &Config{
CronTask: config.CronTask{
RetainChatRecords: 1,
FileExpireTime: 1,
DeleteObjectType: []string{"msg-picture", "msg-file", "msg-voice", "msg-video", "msg-video-snapshot", "sdklog", ""},
},
},
cron: cron.New(),
msgClient: msg.NewMsgClient(msgConn),
conversationClient: pbconversation.NewConversationClient(conversationConn),
thirdClient: third.NewThirdClient(thirdConn),
}
srv.deleteMsg()
//srv.clearS3()
//srv.clearUserMsg()
}

View File

@@ -0,0 +1,86 @@
package cron
import (
"context"
"fmt"
"os"
"time"
"github.com/openimsdk/tools/log"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
const (
lockLeaseTTL = 300
)
type EtcdLocker struct {
client *clientv3.Client
instanceID string
}
// NewEtcdLocker creates a new etcd distributed lock
func NewEtcdLocker(client *clientv3.Client) (*EtcdLocker, error) {
hostname, _ := os.Hostname()
pid := os.Getpid()
instanceID := fmt.Sprintf("%s-pid-%d-%d", hostname, pid, time.Now().UnixNano())
locker := &EtcdLocker{
client: client,
instanceID: instanceID,
}
return locker, nil
}
func (e *EtcdLocker) ExecuteWithLock(ctx context.Context, taskName string, task func()) {
session, err := concurrency.NewSession(e.client, concurrency.WithTTL(lockLeaseTTL))
if err != nil {
log.ZWarn(ctx, "Failed to create etcd session", err,
"taskName", taskName,
"instanceID", e.instanceID)
return
}
defer session.Close()
lockKey := fmt.Sprintf("openim/crontask/%s", taskName)
mutex := concurrency.NewMutex(session, lockKey)
ctxWithTimeout, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
err = mutex.TryLock(ctxWithTimeout)
if err != nil {
// errors.Is(err, concurrency.ErrLocked)
log.ZDebug(ctx, "Task is being executed by another instance, skipping",
"taskName", taskName,
"instanceID", e.instanceID,
"error", err.Error())
return
}
defer func() {
if err := mutex.Unlock(ctx); err != nil {
log.ZWarn(ctx, "Failed to release task lock", err,
"taskName", taskName,
"instanceID", e.instanceID)
} else {
log.ZInfo(ctx, "Successfully released task lock",
"taskName", taskName,
"instanceID", e.instanceID)
}
}()
log.ZInfo(ctx, "Successfully acquired task lock, starting execution",
"taskName", taskName,
"instanceID", e.instanceID,
"sessionID", session.Lease())
task()
log.ZInfo(ctx, "Task execution completed",
"taskName", taskName,
"instanceID", e.instanceID)
}

View File

@@ -0,0 +1,152 @@
package cron
import (
"context"
"fmt"
"os"
"time"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/model"
"git.imall.cloud/openim/open-im-server-deploy/pkg/common/webhook"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
)
// meetingPagination 简单的分页实现,供定时任务批量扫描使用
type meetingPagination struct {
pageNumber int32
showNumber int32
}
func (p *meetingPagination) GetPageNumber() int32 {
if p.pageNumber <= 0 {
return 1
}
return p.pageNumber
}
func (p *meetingPagination) GetShowNumber() int32 {
if p.showNumber <= 0 {
return 200
}
return p.showNumber
}
// dismissMeetingGroups 解散已结束超过10分钟的会议群聊
func (c *cronServer) dismissMeetingGroups() {
now := time.Now()
// 计算10分钟前的时间
beforeTime := now.Add(-10 * time.Minute)
operationID := fmt.Sprintf("cron_dismiss_meeting_groups_%d_%d", os.Getpid(), now.UnixMilli())
ctx := mcontext.SetOperationID(c.ctx, operationID)
log.ZDebug(ctx, "Start dismissing meeting groups", "beforeTime", beforeTime)
// 先将已过期但状态仍为已预约/进行中的会议标记为已结束
c.finishExpiredMeetings(ctx, now)
// 查询已结束且结束时间在10分钟前的会议
// 结束时间 = scheduledTime + duration分钟
meetings, err := c.meetingDB.FindFinishedMeetingsBefore(ctx, beforeTime)
if err != nil {
log.ZError(ctx, "Failed to find finished meetings", err)
return
}
if len(meetings) == 0 {
log.ZDebug(ctx, "No finished meetings to dismiss groups")
return
}
log.ZInfo(ctx, "Found finished meetings to dismiss groups", "count", len(meetings))
dismissedCount := 0
failedCount := 0
for _, meeting := range meetings {
if meeting.GroupID == "" {
log.ZWarn(ctx, "Meeting has no group ID, skip", nil, "meetingID", meeting.MeetingID)
continue
}
// 计算会议结束时间
endTime := meeting.ScheduledTime.Add(time.Duration(meeting.Duration) * time.Minute)
// 检查是否已经超过10分钟
if now.Sub(endTime) < 10*time.Minute {
log.ZDebug(ctx, "Meeting ended less than 10 minutes ago, skip", "meetingID", meeting.MeetingID, "endTime", endTime)
continue
}
// 解散群聊deleteMember设为true表示删除所有成员
ctx := mcontext.SetOperationID(c.ctx, fmt.Sprintf("%s_%s", operationID, meeting.MeetingID))
err := c.groupClient.DismissGroup(ctx, meeting.GroupID, true)
if err != nil {
// 如果群不存在或找不到群主RecordNotFoundError说明群可能已经被解散或数据不一致
if errs.ErrRecordNotFound.Is(err) {
log.ZWarn(ctx, "Group not found or owner not found, may already be dismissed, clear groupID", nil, "meetingID", meeting.MeetingID, "groupID", meeting.GroupID)
// 清空groupID避免下次重复处理
if updateErr := c.meetingDB.Update(ctx, meeting.MeetingID, map[string]any{"group_id": ""}); updateErr != nil {
log.ZWarn(ctx, "Failed to clear groupID after group not found", updateErr, "meetingID", meeting.MeetingID)
}
// 不增加失败计数,因为这不是真正的失败
continue
}
log.ZError(ctx, "Failed to dismiss meeting group", err, "meetingID", meeting.MeetingID, "groupID", meeting.GroupID)
failedCount++
continue
}
// 从webhook配置的attentionIds中移除会议群ID
if c.systemConfigDB != nil {
if err := webhook.UpdateAttentionIds(ctx, c.systemConfigDB, meeting.GroupID, false); err != nil {
log.ZWarn(ctx, "dismissMeetingGroups: failed to remove groupID from webhook attentionIds", err, "meetingID", meeting.MeetingID, "groupID", meeting.GroupID)
}
}
// 解散群成功后清空会议的groupID避免下次重复处理
if updateErr := c.meetingDB.Update(ctx, meeting.MeetingID, map[string]any{"group_id": ""}); updateErr != nil {
log.ZWarn(ctx, "Failed to clear groupID after dismissing group", updateErr, "meetingID", meeting.MeetingID, "groupID", meeting.GroupID)
} else {
log.ZInfo(ctx, "Successfully dismissed meeting group and cleared groupID", "meetingID", meeting.MeetingID, "groupID", meeting.GroupID, "endTime", endTime)
}
dismissedCount++
}
log.ZInfo(ctx, "Finished dismissing meeting groups", "total", len(meetings), "dismissed", dismissedCount, "failed", failedCount, "duration", time.Since(now))
}
// finishExpiredMeetings 将已过结束时间的会议状态更新为已结束
func (c *cronServer) finishExpiredMeetings(ctx context.Context, now time.Time) {
statuses := []int32{model.MeetingStatusScheduled, model.MeetingStatusOngoing}
for _, status := range statuses {
page := int32(1)
for {
total, meetings, err := c.meetingDB.FindByStatus(ctx, status, &meetingPagination{pageNumber: page, showNumber: 200})
if err != nil {
log.ZWarn(ctx, "finishExpiredMeetings: failed to list meetings", err, "status", status, "page", page)
break
}
if len(meetings) == 0 {
break
}
for _, meeting := range meetings {
endTime := meeting.ScheduledTime.Add(time.Duration(meeting.Duration) * time.Minute)
if now.After(endTime) {
if err := c.meetingDB.UpdateStatus(ctx, meeting.MeetingID, model.MeetingStatusFinished); err != nil {
log.ZWarn(ctx, "finishExpiredMeetings: failed to update status", err, "meetingID", meeting.MeetingID)
continue
}
log.ZInfo(ctx, "finishExpiredMeetings: meeting marked finished", "meetingID", meeting.MeetingID, "endTime", endTime)
}
}
// 分页结束条件
if int64(page*200) >= total {
break
}
page++
}
}
}

View File

@@ -0,0 +1,37 @@
package cron
import (
"fmt"
"os"
"time"
"git.imall.cloud/openim/protocol/msg"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
)
func (c *cronServer) deleteMsg() {
now := time.Now()
deltime := now.Add(-time.Hour * 24 * time.Duration(c.config.CronTask.RetainChatRecords))
operationID := fmt.Sprintf("cron_msg_%d_%d", os.Getpid(), deltime.UnixMilli())
ctx := mcontext.SetOperationID(c.ctx, operationID)
log.ZDebug(ctx, "Destruct chat records", "deltime", deltime, "timestamp", deltime.UnixMilli())
const (
deleteCount = 10000
deleteLimit = 50
)
var count int
for i := 1; i <= deleteCount; i++ {
ctx := mcontext.SetOperationID(c.ctx, fmt.Sprintf("%s_%d", operationID, i))
resp, err := c.msgClient.DestructMsgs(ctx, &msg.DestructMsgsReq{Timestamp: deltime.UnixMilli(), Limit: deleteLimit})
if err != nil {
log.ZError(ctx, "cron destruct chat records failed", err)
break
}
count += int(resp.Count)
if resp.Count < deleteLimit {
break
}
}
log.ZDebug(ctx, "cron destruct chat records end", "deltime", deltime, "cont", time.Since(now), "count", count)
}

80
internal/tools/cron/s3.go Normal file
View File

@@ -0,0 +1,80 @@
package cron
import (
"fmt"
"os"
"time"
"git.imall.cloud/openim/protocol/third"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
)
func (c *cronServer) clearS3() {
start := time.Now()
deleteTime := start.Add(-time.Hour * 24 * time.Duration(c.config.CronTask.FileExpireTime))
operationID := fmt.Sprintf("cron_s3_%d_%d", os.Getpid(), deleteTime.UnixMilli())
ctx := mcontext.SetOperationID(c.ctx, operationID)
log.ZDebug(ctx, "deleteoutDatedData", "deletetime", deleteTime, "timestamp", deleteTime.UnixMilli())
const (
deleteCount = 10000
deleteLimit = 100
)
var count int
for i := 1; i <= deleteCount; i++ {
resp, err := c.thirdClient.DeleteOutdatedData(ctx, &third.DeleteOutdatedDataReq{ExpireTime: deleteTime.UnixMilli(), ObjectGroup: c.config.CronTask.DeleteObjectType, Limit: deleteLimit})
if err != nil {
log.ZError(ctx, "cron deleteoutDatedData failed", err)
return
}
count += int(resp.Count)
if resp.Count < deleteLimit {
break
}
}
log.ZDebug(ctx, "cron deleteoutDatedData success", "deltime", deleteTime, "cont", time.Since(start), "count", count)
}
// var req *third.DeleteOutdatedDataReq
// count1, err := ExtractField(ctx, c.thirdClient.DeleteOutdatedData, req, (*third.DeleteOutdatedDataResp).GetCount)
//
// c.thirdClient.DeleteOutdatedData(ctx, &third.DeleteOutdatedDataReq{})
// msggateway.GetUsersOnlineStatusCaller.Invoke(ctx, &msggateway.GetUsersOnlineStatusReq{})
//
// var cli ThirdClient
//
// c111, err := cli.DeleteOutdatedData(ctx, 100)
//
// cli.ThirdClient.DeleteOutdatedData(ctx, &third.DeleteOutdatedDataReq{})
//
// cli.AuthSign(ctx, &third.AuthSignReq{})
//
// cli.SetAppBadge()
//
//}
//
//func extractField[A, B, C any](ctx context.Context, fn func(ctx context.Context, req *A, opts ...grpc.CallOption) (*B, error), req *A, get func(*B) C) (C, error) {
// resp, err := fn(ctx, req)
// if err != nil {
// var c C
// return c, err
// }
// return get(resp), nil
//}
//
//func ignore(_ any, err error) error {
// return err
//}
//
//type ThirdClient struct {
// third.ThirdClient
//}
//
//func (c *ThirdClient) DeleteOutdatedData(ctx context.Context, expireTime int64) (int32, error) {
// return extractField(ctx, c.ThirdClient.DeleteOutdatedData, &third.DeleteOutdatedDataReq{ExpireTime: expireTime}, (*third.DeleteOutdatedDataResp).GetCount)
//}
//
//func (c *ThirdClient) DeleteOutdatedData1(ctx context.Context, expireTime int64) error {
// return ignore(c.ThirdClient.DeleteOutdatedData(ctx, &third.DeleteOutdatedDataReq{ExpireTime: expireTime}))
//}