Files

1497 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package windowsv3
import (
"context"
"database/sql"
"encoding/hex"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/pkg/util"
)
const (
Message = "message"
Contact = "contact"
Image = "image"
Video = "video"
File = "file"
Voice = "voice"
talkerCacheTTL = 30 * time.Second
)
var Groups = []*dbm.Group{
{
Name: Message,
Pattern: `^MSG([0-9]?[0-9])?\.db$`,
BlackList: []string{},
},
{
Name: Contact,
Pattern: `^MicroMsg\.db$`,
BlackList: []string{},
},
{
Name: Image,
Pattern: `^HardLinkImage\.db$`,
BlackList: []string{},
},
{
Name: Video,
Pattern: `^HardLinkVideo\.db$`,
BlackList: []string{},
},
{
Name: File,
Pattern: `^HardLinkFile\.db$`,
BlackList: []string{},
},
{
Name: Voice,
Pattern: `^MediaMSG([0-9]?[0-9])?\.db$`,
BlackList: []string{},
},
}
// MessageDBInfo 保存消息数据库的信息
type MessageDBInfo struct {
FilePath string
StartTime time.Time
EndTime time.Time
TalkerMap map[string]int
}
// DataSource 实现了 DataSource 接口
type DataSource struct {
path string
dbm *dbm.DBManager
// 消息数据库信息
messageInfos []MessageDBInfo
talkerCacheMu sync.RWMutex
talkerCache []string
talkerCacheExpiry time.Time
}
// New 创建一个新的 WindowsV3DataSource
func New(path string) (*DataSource, error) {
ds := &DataSource{
path: path,
dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0),
}
for _, g := range Groups {
ds.dbm.AddGroup(g)
}
if err := ds.dbm.Start(); err != nil {
return nil, err
}
if err := ds.initMessageDbs(); err != nil {
return nil, errors.DBInitFailed(err)
}
ds.dbm.AddCallback(Message, func(event fsnotify.Event) error {
if !(event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Rename)) {
return nil
}
if err := ds.initMessageDbs(); err != nil {
log.Err(err).Msgf("Failed to reinitialize message DBs: %s", event.Name)
}
ds.invalidateTalkerCache()
return nil
})
ds.dbm.AddCallback(Contact, func(event fsnotify.Event) error {
if !(event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Rename) || event.Op.Has(fsnotify.Remove)) {
return nil
}
ds.invalidateTalkerCache()
return nil
})
return ds, nil
}
func (ds *DataSource) SetCallback(group string, callback func(event fsnotify.Event) error) error {
if group == "chatroom" {
group = Contact
}
return ds.dbm.AddCallback(group, callback)
}
// initMessageDbs 初始化消息数据库
func (ds *DataSource) initMessageDbs() error {
// 获取所有消息数据库文件路径
dbPaths, err := ds.dbm.GetDBPath(Message)
if err != nil {
if strings.Contains(err.Error(), "db file not found") {
ds.messageInfos = make([]MessageDBInfo, 0)
return nil
}
return err
}
// 处理每个数据库文件
infos := make([]MessageDBInfo, 0)
for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath)
if err != nil {
log.Err(err).Msgf("获取数据库 %s 失败", filePath)
continue
}
// 获取 DBInfo 表中的开始时间
var startTime time.Time
rows, err := db.Query("SELECT tableIndex, tableVersion, tableDesc FROM DBInfo")
if err != nil {
log.Err(err).Msgf("查询数据库 %s 的 DBInfo 表失败", filePath)
continue
}
for rows.Next() {
var tableIndex int
var tableVersion int64
var tableDesc string
if err := rows.Scan(&tableIndex, &tableVersion, &tableDesc); err != nil {
log.Err(err).Msg("扫描 DBInfo 行失败")
continue
}
// 查找描述为 "Start Time" 的记录
if strings.Contains(tableDesc, "Start Time") {
startTime = time.Unix(tableVersion/1000, (tableVersion%1000)*1000000)
break
}
}
rows.Close()
// 组织 TalkerMap
talkerMap := make(map[string]int)
rows, err = db.Query("SELECT UsrName FROM Name2ID")
if err != nil {
log.Err(err).Msgf("查询数据库 %s 的 Name2ID 表失败", filePath)
continue
}
i := 1
for rows.Next() {
var userName string
if err := rows.Scan(&userName); err != nil {
log.Err(err).Msg("扫描 Name2ID 行失败")
continue
}
talkerMap[userName] = i
i++
}
rows.Close()
// 保存数据库信息
infos = append(infos, MessageDBInfo{
FilePath: filePath,
StartTime: startTime,
TalkerMap: talkerMap,
})
}
// 按照 StartTime 排序数据库文件
sort.Slice(infos, func(i, j int) bool {
return infos[i].StartTime.Before(infos[j].StartTime)
})
// 设置结束时间
for i := range infos {
if i == len(infos)-1 {
infos[i].EndTime = time.Now()
} else {
infos[i].EndTime = infos[i+1].StartTime
}
}
if len(ds.messageInfos) > 0 && len(infos) < len(ds.messageInfos) {
log.Warn().Msgf("message db count decreased from %d to %d, skip init", len(ds.messageInfos), len(infos))
return nil
}
ds.messageInfos = infos
ds.invalidateTalkerCache()
return nil
}
// getDBInfosForTimeRange 获取时间范围内的数据库信息
func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
var dbs []MessageDBInfo
for _, info := range ds.messageInfos {
if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
dbs = append(dbs, info)
}
}
return dbs
}
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
if talker == "" {
return nil, errors.ErrTalkerEmpty
}
// 解析talker参数,支持多个talker(以英文逗号分隔)
talkers := util.Str2List(talker, ",")
if len(talkers) == 0 {
return nil, errors.ErrTalkerEmpty
}
// 找到时间范围内的数据库文件
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
if len(dbInfos) == 0 {
return nil, errors.TimeRangeNotFound(startTime, endTime)
}
// 解析sender参数,支持多个发送者(以英文逗号分隔)
senders := util.Str2List(sender, ",")
// 预编译正则表达式(如果有keyword)
var regex *regexp.Regexp
if keyword != "" {
var err error
regex, err = regexp.Compile(keyword)
if err != nil {
return nil, errors.QueryFailed("invalid regex pattern", err)
}
}
// 从每个相关数据库中查询消息
filteredMessages := []*model.Message{}
for _, dbInfo := range dbInfos {
// 检查上下文是否已取消
if err := ctx.Err(); err != nil {
return nil, err
}
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
if err != nil {
log.Error().Msgf("数据库 %s 未打开", dbInfo.FilePath)
continue
}
// 对每个talker进行查询
for _, talkerItem := range talkers {
// 构建查询条件
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
// 添加talker条件
talkerID, ok := dbInfo.TalkerMap[talkerItem]
if ok {
conditions = append(conditions, "TalkerId = ?")
args = append(args, talkerID)
} else {
conditions = append(conditions, "StrTalker = ?")
args = append(args, talkerItem)
}
query := fmt.Sprintf(`
SELECT MsgSvrID, Sequence, CreateTime, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG
WHERE %s
ORDER BY Sequence ASC
`, strings.Join(conditions, " AND "))
// 执行查询
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
// 如果表不存在,跳过此talker
if strings.Contains(err.Error(), "no such table") {
continue
}
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbInfo.FilePath)
continue
}
// 处理查询结果,在读取时进行过滤
for rows.Next() {
var msg model.MessageV3
var compressContent []byte
var bytesExtra []byte
err := rows.Scan(
&msg.MsgSvrID,
&msg.Sequence,
&msg.CreateTime,
&msg.StrTalker,
&msg.IsSender,
&msg.Type,
&msg.SubType,
&msg.StrContent,
&compressContent,
&bytesExtra,
)
if err != nil {
rows.Close()
return nil, errors.ScanRowFailed(err)
}
msg.CompressContent = compressContent
msg.BytesExtra = bytesExtra
// 将消息转换为标准格式
message := msg.Wrap()
// 应用sender过滤
if len(senders) > 0 {
senderMatch := false
for _, s := range senders {
if message.Sender == s {
senderMatch = true
break
}
}
if !senderMatch {
continue // 不匹配sender,跳过此消息
}
}
// 应用keyword过滤
if regex != nil {
plainText := message.PlainTextContent()
if !regex.MatchString(plainText) {
continue // 不匹配keyword,跳过此消息
}
}
// 通过所有过滤条件,保留此消息
filteredMessages = append(filteredMessages, message)
// 检查是否已经满足分页处理数量
if limit > 0 && len(filteredMessages) >= offset+limit {
// 已经获取了足够的消息,可以提前返回
rows.Close()
// 对所有消息按时间排序
sort.Slice(filteredMessages, func(i, j int) bool {
return filteredMessages[i].Seq < filteredMessages[j].Seq
})
// 处理分页
if offset >= len(filteredMessages) {
return []*model.Message{}, nil
}
end := offset + limit
if end > len(filteredMessages) {
end = len(filteredMessages)
}
return filteredMessages[offset:end], nil
}
}
rows.Close()
}
}
// 对所有消息按时间排序
sort.Slice(filteredMessages, func(i, j int) bool {
return filteredMessages[i].Seq < filteredMessages[j].Seq
})
// 处理分页
if limit > 0 {
if offset >= len(filteredMessages) {
return []*model.Message{}, nil
}
end := offset + limit
if end > len(filteredMessages) {
end = len(filteredMessages)
}
return filteredMessages[offset:end], nil
}
return filteredMessages, nil
}
func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
return ds.dbm.FingerprintForGroups(Message)
}
func (ds *DataSource) getCachedTalkers() []string {
ds.talkerCacheMu.RLock()
if ds.talkerCacheExpiry.IsZero() || time.Now().After(ds.talkerCacheExpiry) {
ds.talkerCacheMu.RUnlock()
return nil
}
cached := append([]string(nil), ds.talkerCache...)
ds.talkerCacheMu.RUnlock()
return cached
}
func (ds *DataSource) cacheTalkers(talkers []string) {
copySlice := append([]string(nil), talkers...)
ds.talkerCacheMu.Lock()
ds.talkerCache = copySlice
ds.talkerCacheExpiry = time.Now().Add(talkerCacheTTL)
ds.talkerCacheMu.Unlock()
}
func (ds *DataSource) invalidateTalkerCache() {
ds.talkerCacheMu.Lock()
ds.talkerCache = nil
ds.talkerCacheExpiry = time.Time{}
ds.talkerCacheMu.Unlock()
}
func (ds *DataSource) collectAllTalkers(ctx context.Context) ([]string, error) {
if cached := ds.getCachedTalkers(); cached != nil {
return cached, nil
}
talkerSet := make(map[string]struct{})
talkers := make([]string, 0)
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
if _, exists := talkerSet[name]; exists {
return
}
talkerSet[name] = struct{}{}
talkers = append(talkers, name)
}
if db, err := ds.dbm.GetDB(Contact); err == nil && db != nil {
rows, err := db.QueryContext(ctx, `SELECT strUsrName FROM Session WHERE IFNULL(strUsrName,'') <> ''`)
if err == nil {
for rows.Next() {
if err := ctx.Err(); err != nil {
rows.Close()
return nil, err
}
var username string
if err := rows.Scan(&username); err != nil {
log.Debug().Err(err).Msg("scan session talker failed")
continue
}
add(username)
}
if err := rows.Err(); err != nil {
log.Debug().Err(err).Msg("iterate session talkers failed")
}
rows.Close()
} else {
log.Debug().Err(err).Msg("query session talkers failed")
}
if len(talkers) == 0 {
rows, err = db.QueryContext(ctx, `SELECT UserName FROM Contact WHERE IFNULL(UserName,'') <> ''`)
if err == nil {
for rows.Next() {
if err := ctx.Err(); err != nil {
rows.Close()
return nil, err
}
var username string
if err := rows.Scan(&username); err != nil {
log.Debug().Err(err).Msg("scan contact talker failed")
continue
}
add(username)
}
if err := rows.Err(); err != nil {
log.Debug().Err(err).Msg("iterate contact talkers failed")
}
rows.Close()
} else {
log.Debug().Err(err).Msg("query contact talkers failed")
}
}
}
if len(talkers) == 0 {
for _, info := range ds.messageInfos {
for username := range info.TalkerMap {
add(username)
}
}
}
sort.Strings(talkers)
ds.cacheTalkers(talkers)
return talkers, nil
}
// ListTalkers 返回所有对话用户名(联系人 + 群聊),供索引使用
func (ds *DataSource) ListTalkers(ctx context.Context) ([]string, error) {
return ds.collectAllTalkers(ctx)
}
// IterateMessages 按 talker 枚举消息并交给处理函数,供 Bleve 索引使用
func (ds *DataSource) IterateMessages(ctx context.Context, talkers []string, handler func(*model.Message) error) error {
if handler == nil {
return errors.InvalidArg("handler")
}
if len(talkers) == 0 {
var err error
talkers, err = ds.collectAllTalkers(ctx)
if err != nil {
return err
}
}
if len(talkers) == 0 {
return nil
}
uniqueTalkers := make([]string, 0, len(talkers))
seen := make(map[string]struct{}, len(talkers))
for _, t := range talkers {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
uniqueTalkers = append(uniqueTalkers, t)
}
if len(uniqueTalkers) == 0 {
return nil
}
for _, info := range ds.messageInfos {
if err := ctx.Err(); err != nil {
return err
}
db, err := ds.dbm.OpenDB(info.FilePath)
if err != nil {
log.Debug().Err(err).Msgf("open message db failed: %s", info.FilePath)
continue
}
for _, talker := range uniqueTalkers {
if err := ctx.Err(); err != nil {
return err
}
conditions := []string{"StrContent IS NOT NULL"}
args := make([]interface{}, 0, 1)
if talkerID, ok := info.TalkerMap[talker]; ok {
conditions = append(conditions, "TalkerId = ?")
args = append(args, talkerID)
} else {
conditions = append(conditions, "StrTalker = ?")
args = append(args, talker)
}
query := fmt.Sprintf(`
SELECT MsgSvrID, Sequence, CreateTime, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG
WHERE %s
ORDER BY Sequence ASC
`, strings.Join(conditions, " AND "))
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
continue
}
return errors.QueryFailed("iterate messages", err)
}
for rows.Next() {
if err := ctx.Err(); err != nil {
rows.Close()
return err
}
var msg model.MessageV3
var compressContent []byte
var bytesExtra []byte
if scanErr := rows.Scan(
&msg.MsgSvrID,
&msg.Sequence,
&msg.CreateTime,
&msg.StrTalker,
&msg.IsSender,
&msg.Type,
&msg.SubType,
&msg.StrContent,
&compressContent,
&bytesExtra,
); scanErr != nil {
rows.Close()
return errors.ScanRowFailed(scanErr)
}
msg.CompressContent = compressContent
msg.BytesExtra = bytesExtra
wrapped := msg.Wrap()
if err := handler(wrapped); err != nil {
rows.Close()
return err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return errors.QueryFailed("iterate message rows", err)
}
rows.Close()
}
}
return nil
}
// GetContacts 实现获取联系人信息的方法
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
var query string
var args []interface{}
if key != "" {
// 按照关键字查询
query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact
WHERE UserName = ? OR Alias = ? OR Remark = ? OR NickName = ?`
args = []interface{}{key, key, key, key}
} else {
// 查询所有联系人
query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact`
}
// 添加排序、分页
query += ` ORDER BY UserName`
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
contacts := []*model.Contact{}
for rows.Next() {
var contactV3 model.ContactV3
err := rows.Scan(
&contactV3.UserName,
&contactV3.Alias,
&contactV3.Remark,
&contactV3.NickName,
&contactV3.Reserved1,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
contacts = append(contacts, contactV3.Wrap())
}
return contacts, nil
}
// GetChatRooms 实现获取群聊信息的方法
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
var query string
var args []interface{}
if key != "" {
// 按照关键字查询
query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`
args = []interface{}{key}
// 执行查询
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
chatRooms := []*model.ChatRoom{}
for rows.Next() {
var chatRoomV3 model.ChatRoomV3
err := rows.Scan(
&chatRoomV3.ChatRoomName,
&chatRoomV3.Reserved2,
&chatRoomV3.RoomData,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
chatRooms = append(chatRooms, chatRoomV3.Wrap())
}
// 如果没有找到群聊,尝试通过联系人查找
if len(chatRooms) == 0 {
contacts, err := ds.GetContacts(ctx, key, 1, 0)
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
// 再次尝试通过用户名查找群聊
rows, err := db.QueryContext(ctx,
`SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`,
contacts[0].UserName)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
for rows.Next() {
var chatRoomV3 model.ChatRoomV3
err := rows.Scan(
&chatRoomV3.ChatRoomName,
&chatRoomV3.Reserved2,
&chatRoomV3.RoomData,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
chatRooms = append(chatRooms, chatRoomV3.Wrap())
}
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
if len(chatRooms) == 0 {
chatRooms = append(chatRooms, &model.ChatRoom{
Name: contacts[0].UserName,
Users: make([]model.ChatRoomUser, 0),
User2DisplayName: make(map[string]string),
})
}
}
}
return chatRooms, nil
} else {
// 查询所有群聊
query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom`
// 添加排序、分页
query += ` ORDER BY ChatRoomName`
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
chatRooms := []*model.ChatRoom{}
for rows.Next() {
var chatRoomV3 model.ChatRoomV3
err := rows.Scan(
&chatRoomV3.ChatRoomName,
&chatRoomV3.Reserved2,
&chatRoomV3.RoomData,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
chatRooms = append(chatRooms, chatRoomV3.Wrap())
}
return chatRooms, nil
}
}
// GetSessions 实现获取会话信息的方法
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
var query string
var args []interface{}
if key != "" {
// 按照关键字查询
query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
FROM Session
WHERE strUsrName = ? OR strNickName = ?
ORDER BY nOrder DESC`
args = []interface{}{key, key}
} else {
// 查询所有会话
query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
FROM Session
ORDER BY nOrder DESC`
}
// 添加分页
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
sessions := []*model.Session{}
for rows.Next() {
var sessionV3 model.SessionV3
err := rows.Scan(
&sessionV3.StrUsrName,
&sessionV3.NOrder,
&sessionV3.StrNickName,
&sessionV3.StrContent,
&sessionV3.NTime,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
sessions = append(sessions, sessionV3.Wrap())
}
return sessions, nil
}
func (ds *DataSource) GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) {
if key == "" {
return nil, errors.ErrKeyEmpty
}
if _type == "voice" {
return ds.GetVoice(ctx, key)
}
md5key, err := hex.DecodeString(key)
if err != nil {
return nil, errors.DecodeKeyFailed(err)
}
var dbType string
var table1, table2 string
switch _type {
case "image":
dbType = Image
table1 = "HardLinkImageAttribute"
table2 = "HardLinkImageID"
case "video":
dbType = Video
table1 = "HardLinkVideoAttribute"
table2 = "HardLinkVideoID"
case "file":
dbType = File
table1 = "HardLinkFileAttribute"
table2 = "HardLinkFileID"
default:
return nil, errors.MediaTypeUnsupported(_type)
}
db, err := ds.dbm.GetDB(dbType)
if err != nil {
return nil, err
}
query := fmt.Sprintf(`
SELECT
a.FileName,
a.ModifyTime,
IFNULL(d1.Dir,"") AS Dir1,
IFNULL(d2.Dir,"") AS Dir2
FROM
%s a
LEFT JOIN
%s d1 ON a.DirID1 = d1.DirId
LEFT JOIN
%s d2 ON a.DirID2 = d2.DirId
WHERE
a.Md5 = ?
`, table1, table2, table2)
args := []interface{}{md5key}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
var media *model.Media
for rows.Next() {
var mediaV3 model.MediaV3
err := rows.Scan(
&mediaV3.Name,
&mediaV3.ModifyTime,
&mediaV3.Dir1,
&mediaV3.Dir2,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
mediaV3.Type = _type
mediaV3.Key = key
media = mediaV3.Wrap()
}
if media == nil {
return nil, errors.ErrMediaNotFound
}
return media, nil
}
func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, error) {
if key == "" {
return nil, errors.ErrKeyEmpty
}
query := `
SELECT Buf
FROM Media
WHERE Reserved0 = ?
`
args := []interface{}{key}
dbs, err := ds.dbm.GetDBs(Voice)
if err != nil {
return nil, errors.DBConnectFailed("", err)
}
for _, db := range dbs {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
for rows.Next() {
var voiceData []byte
err := rows.Scan(
&voiceData,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
if len(voiceData) > 0 {
return &model.Media{
Type: "voice",
Key: key,
Data: voiceData,
}, nil
}
}
}
return nil, errors.ErrMediaNotFound
}
// Close 实现 DataSource 接口的 Close 方法
func (ds *DataSource) Close() error {
return ds.dbm.Close()
}
// GetAvatar returns avatar info for a username on Windows v3 (MicroMsg.db -> ContactHeadImgUrl)
func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
if username == "" {
return nil, errors.ErrKeyEmpty
}
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
query := `SELECT IFNULL(smallHeadImgUrl, ''), IFNULL(bigHeadImgUrl, '') FROM ContactHeadImgUrl WHERE usrName = ?`
row := db.QueryRowContext(ctx, query, username)
var small, big string
if err := row.Scan(&small, &big); err != nil {
return nil, errors.ErrAvatarNotFound
}
url := small
if strings.ToLower(size) == "big" && big != "" {
url = big
}
if url == "" {
url = big
}
if url == "" {
return nil, errors.ErrAvatarNotFound
}
return &model.Avatar{Username: username, URL: url}, nil
}
// GlobalMessageStats 聚合统计(Windows v3)
func (ds *DataSource) GlobalMessageStats(ctx context.Context) (*model.GlobalMessageStats, error) {
stats := &model.GlobalMessageStats{ByType: make(map[string]int64)}
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return stats, nil
}
for _, db := range dbs {
// total/sent/recv/min/max
row := db.QueryRowContext(ctx, `SELECT
COUNT(*) AS total,
SUM(CASE WHEN IsSender=1 THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN IsSender=0 THEN 1 ELSE 0 END) AS recv,
MIN(CreateTime) AS minct,
MAX(CreateTime) AS maxct
FROM MSG`)
var total, sent, recv, minct, maxct int64
if err := row.Scan(&total, &sent, &recv, &minct, &maxct); err == nil {
stats.Total += total
stats.Sent += sent
stats.Received += recv
if stats.EarliestUnix == 0 || (minct > 0 && minct < stats.EarliestUnix) {
stats.EarliestUnix = minct
}
if maxct > stats.LatestUnix {
stats.LatestUnix = maxct
}
}
// By type/subtype
rows, err := db.QueryContext(ctx, `SELECT Type, SubType, COUNT(*) FROM MSG GROUP BY Type, SubType`)
if err == nil {
for rows.Next() {
var t int64
var st int
var cnt int64
if err := rows.Scan(&t, &st, &cnt); err == nil {
label := mapV3TypeToLabel(t, int64(st))
if label != "" {
stats.ByType[label] += cnt
}
}
}
rows.Close()
}
}
return stats, nil
}
// GroupMessageCounts 统计群聊消息数量(Windows v3)
func (ds *DataSource) GroupMessageCounts(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT StrTalker, COUNT(*) FROM MSG WHERE StrTalker LIKE '%@chatroom' GROUP BY StrTalker`)
if err != nil {
continue
}
for rows.Next() {
var talker string
var cnt int64
if err := rows.Scan(&talker, &cnt); err == nil {
result[talker] += cnt
}
}
rows.Close()
}
return result, nil
}
// GroupTodayMessageCounts 统计群聊今日消息数(Windows v3):MSG 表中 StrTalker LIKE '%@chatroom' 且 CreateTime >= 今日零点
func (ds *DataSource) GroupTodayMessageCounts(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
// 今日零点(使用本地时区)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
since := today.Unix()
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT StrTalker, COUNT(*) FROM MSG WHERE StrTalker LIKE '%@chatroom' AND CreateTime >= ? GROUP BY StrTalker`, since)
if err != nil {
continue
}
for rows.Next() {
var talker string
var cnt int64
if err := rows.Scan(&talker, &cnt); err == nil {
result[talker] += cnt
}
}
rows.Close()
}
return result, nil
}
// GroupTodayHourly 统计群聊今日按小时消息数(Windows v3)
func (ds *DataSource) GroupTodayHourly(ctx context.Context) (map[string][24]int64, error) {
result := make(map[string][24]int64)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
end := start + 86400
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT StrTalker, CAST(strftime('%H', datetime(CreateTime,'unixepoch')) AS INTEGER) AS h, COUNT(*) FROM MSG WHERE StrTalker LIKE '%@chatroom' AND CreateTime >= ? AND CreateTime < ? GROUP BY StrTalker, h`, start, end)
if err != nil {
continue
}
for rows.Next() {
var talker string
var hour int
var cnt int64
if rows.Scan(&talker, &hour, &cnt) == nil {
if hour >= 0 && hour < 24 {
bucket := result[talker]
bucket[hour] += cnt
result[talker] = bucket
}
}
}
rows.Close()
}
return result, nil
}
// GroupWeekMessageCount 统计本周(周一00:00起)所有群聊消息总数(Windows v3)
func (ds *DataSource) GroupWeekMessageCount(ctx context.Context) (int64, error) {
var total int64
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return 0, nil
}
now := time.Now()
wday := int(now.Weekday())
offset := wday - 1
if wday == 0 {
offset = -6
}
monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -offset)
since := monday.Unix()
for _, db := range dbs {
row := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM MSG WHERE StrTalker LIKE '%@chatroom' AND CreateTime >= ?`, since)
var cnt int64
if row.Scan(&cnt) == nil {
total += cnt
}
}
return total, nil
}
// GroupMessageTypeStats 统计群聊消息类型分布(Windows v3)
func (ds *DataSource) GroupMessageTypeStats(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT Type, SubType, COUNT(*) FROM MSG WHERE StrTalker LIKE '%@chatroom' GROUP BY Type, SubType`)
if err != nil {
continue
}
for rows.Next() {
var t int64
var st int64
var cnt int64
if rows.Scan(&t, &st, &cnt) == nil {
label := mapV3TypeToLabel(t, st)
if label != "" {
result[label] += cnt
}
}
}
rows.Close()
}
return result, nil
}
// MonthlyTrend 返回每月 sent/received(近 months 月,若 months<=0 则返回全部)
func (ds *DataSource) MonthlyTrend(ctx context.Context, months int) ([]model.MonthlyTrend, error) {
agg := make(map[string][2]int64)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return []model.MonthlyTrend{}, nil
}
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT strftime('%Y-%m', datetime(CreateTime, 'unixepoch')) AS ym,
SUM(CASE WHEN IsSender=1 THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN IsSender=0 THEN 1 ELSE 0 END) AS recv
FROM MSG GROUP BY ym ORDER BY ym`)
if err != nil {
continue
}
for rows.Next() {
var ym string
var sent, recv int64
if err := rows.Scan(&ym, &sent, &recv); err == nil {
cur := agg[ym]
cur[0] += sent
cur[1] += recv
agg[ym] = cur
}
}
rows.Close()
}
trends := make([]model.MonthlyTrend, 0, len(agg))
// order is not guaranteed; we'll reconstruct sorted keys
// but to keep simple, iterate map and collect; sorting can be added later
for ym, v := range agg {
trends = append(trends, model.MonthlyTrend{Date: ym, Sent: v[0], Received: v[1]})
}
// optional: limit to recent months by trimming after sort
return trends, nil
}
// Heatmap 小时x星期(wday: 0=Sunday..6)
func (ds *DataSource) Heatmap(ctx context.Context) ([24][7]int64, error) {
var grid [24][7]int64
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return grid, nil
}
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT CAST(strftime('%H', datetime(CreateTime,'unixepoch')) AS INTEGER) AS h,
CAST(strftime('%w', datetime(CreateTime,'unixepoch')) AS INTEGER) AS d,
COUNT(*) FROM MSG GROUP BY h,d`)
if err != nil {
continue
}
for rows.Next() {
var h, d int
var cnt int64
if err := rows.Scan(&h, &d, &cnt); err == nil {
if h >= 0 && h < 24 && d >= 0 && d < 7 {
grid[h][d] += cnt
}
}
}
rows.Close()
}
return grid, nil
}
// GlobalTodayHourly 返回今日(本地时区)每小时全部消息量(含私聊+群聊)
func (ds *DataSource) GlobalTodayHourly(ctx context.Context) ([24]int64, error) {
var hours [24]int64
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return hours, nil
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
end := start + 86400
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT CAST(strftime('%H', datetime(CreateTime,'unixepoch')) AS INTEGER) AS h, COUNT(*) FROM MSG WHERE CreateTime >= ? AND CreateTime < ? GROUP BY h`, start, end)
if err != nil {
continue
}
for rows.Next() {
var h int
var cnt int64
if rows.Scan(&h, &cnt) == nil {
if h >= 0 && h < 24 {
hours[h] += cnt
}
}
}
rows.Close()
}
return hours, nil
}
// IntimacyBase 统计按联系人(非群聊)聚合的亲密度基础数据(Windows v3)
func (ds *DataSource) IntimacyBase(ctx context.Context) (map[string]*model.IntimacyBase, error) {
result := make(map[string]*model.IntimacyBase)
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
// 先获取全局最新时间戳
var maxCT int64
for _, db := range dbs {
row := db.QueryRowContext(ctx, `SELECT MAX(CreateTime) FROM MSG`)
var v sql.NullInt64
if err := row.Scan(&v); err == nil && v.Valid && v.Int64 > maxCT {
maxCT = v.Int64
}
}
if maxCT == 0 {
return result, nil
}
since90 := maxCT - 90*86400
since7 := maxCT - 7*86400
for _, db := range dbs {
// 基础计数
rows, err := db.QueryContext(ctx, `SELECT StrTalker,
COUNT(*) AS msg_count,
MIN(CreateTime) AS minct,
MAX(CreateTime) AS maxct,
SUM(CASE WHEN IsSender=1 THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN IsSender=0 THEN 1 ELSE 0 END) AS recv
FROM MSG WHERE StrTalker NOT LIKE '%@chatroom' GROUP BY StrTalker`)
if err == nil {
for rows.Next() {
var talker string
var msgCnt, minct, maxct, sent, recv int64
if err := rows.Scan(&talker, &msgCnt, &minct, &maxct, &sent, &recv); err == nil {
base := result[talker]
if base == nil {
base = &model.IntimacyBase{UserName: talker}
result[talker] = base
}
base.MsgCount += msgCnt
base.SentCount += sent
base.ReceivedCount += recv
if base.MinCreateUnix == 0 || (minct > 0 && minct < base.MinCreateUnix) {
base.MinCreateUnix = minct
}
if maxct > base.MaxCreateUnix {
base.MaxCreateUnix = maxct
}
}
}
rows.Close()
}
// 活跃天数(全期间)
rows2, err := db.QueryContext(ctx, `SELECT StrTalker, COUNT(DISTINCT date(datetime(CreateTime,'unixepoch'))) AS days
FROM MSG WHERE StrTalker NOT LIKE '%@chatroom' GROUP BY StrTalker`)
if err == nil {
for rows2.Next() {
var talker string
var days int64
if err := rows2.Scan(&talker, &days); err == nil {
base := result[talker]
if base == nil {
base = &model.IntimacyBase{UserName: talker}
result[talker] = base
}
base.MessagingDays += days
}
}
rows2.Close()
}
// 最近90天消息数
rows3, err := db.QueryContext(ctx, `SELECT StrTalker, COUNT(*) FROM MSG WHERE CreateTime>=? AND StrTalker NOT LIKE '%@chatroom' GROUP BY StrTalker`, since90)
if err == nil {
for rows3.Next() {
var talker string
var cnt int64
if err := rows3.Scan(&talker, &cnt); err == nil {
base := result[talker]
if base == nil {
base = &model.IntimacyBase{UserName: talker}
result[talker] = base
}
base.Last90DaysMsg += cnt
}
}
rows3.Close()
}
// 过去7天发送数
rows4, err := db.QueryContext(ctx, `SELECT StrTalker, SUM(CASE WHEN IsSender=1 THEN 1 ELSE 0 END) FROM MSG WHERE CreateTime>=? AND StrTalker NOT LIKE '%@chatroom' GROUP BY StrTalker`, since7)
if err == nil {
for rows4.Next() {
var talker string
var cnt sql.NullInt64
if err := rows4.Scan(&talker, &cnt); err == nil {
base := result[talker]
if base == nil {
base = &model.IntimacyBase{UserName: talker}
result[talker] = base
}
if cnt.Valid {
base.Past7DaysSentMsg += cnt.Int64
}
}
}
rows4.Close()
}
}
return result, nil
}
// mapV3TypeToLabel 将v3 (Type,SubType) 映射为中文类别
func mapV3TypeToLabel(t int64, st int64) string {
// Windows v3:在统一文档分类基础上补充文件/链接细分(Type=49 根据 SubType 区分)
switch t {
case 1:
return "文本消息"
case 3:
return "图片消息"
case 34:
return "语音消息"
case 37:
return "好友验证消息"
case 42:
return "好友推荐消息"
case 47:
return "聊天表情"
case 48:
return "位置消息"
case 49:
if st == 6 { // 文件
return "文件消息"
}
if st == 4 || st == 5 { // 链接
return "链接消息"
}
return "XML消息" // 其他未识别子类型归类为通用 XML
case 50:
return "音视频通话"
case 51:
return "手机端操作消息"
case 10000:
return "系统通知"
case 10002:
return "撤回消息"
default:
return ""
}
}