Files

1403 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 darwinv3
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"fmt"
"regexp"
"sort"
"strings"
"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"
ChatRoom = "chatroom"
Session = "session"
Media = "media"
)
var Groups = []*dbm.Group{
{
Name: Message,
Pattern: `^msg_([0-9]?[0-9])?\.db$`,
BlackList: []string{},
},
{
Name: Contact,
Pattern: `^wccontact_new2\.db$`,
BlackList: []string{},
},
{
Name: ChatRoom,
Pattern: `group_new\.db$`,
BlackList: []string{},
},
{
Name: Session,
Pattern: `^session_new\.db$`,
BlackList: []string{},
},
{
Name: Media,
Pattern: `^hldata\.db$`,
BlackList: []string{},
},
}
type DataSource struct {
path string
dbm *dbm.DBManager
talkerDBMap map[string]string
user2DisplayName map[string]string
}
func New(path string) (*DataSource, error) {
ds := &DataSource{
path: path,
dbm: dbm.NewDBManager(path),
talkerDBMap: make(map[string]string),
user2DisplayName: make(map[string]string),
}
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)
}
if err := ds.initChatRoomDb(); err != nil {
return nil, errors.DBInitFailed(err)
}
ds.dbm.AddCallback(Message, func(event fsnotify.Event) error {
if !event.Op.Has(fsnotify.Create) {
return nil
}
if err := ds.initMessageDbs(); err != nil {
log.Err(err).Msgf("Failed to reinitialize message DBs: %s", event.Name)
}
return nil
})
ds.dbm.AddCallback(ChatRoom, func(event fsnotify.Event) error {
if !event.Op.Has(fsnotify.Create) {
return nil
}
if err := ds.initChatRoomDb(); err != nil {
log.Err(err).Msgf("Failed to reinitialize chatroom DB: %s", event.Name)
}
return nil
})
return ds, nil
}
func (ds *DataSource) SetCallback(group string, callback func(event fsnotify.Event) error) error {
return ds.dbm.AddCallback(group, callback)
}
func (ds *DataSource) initMessageDbs() error {
dbPaths, err := ds.dbm.GetDBPath(Message)
if err != nil {
if strings.Contains(err.Error(), "db file not found") {
ds.talkerDBMap = make(map[string]string)
return nil
}
return err
}
// 处理每个数据库文件
talkerDBMap := make(map[string]string)
for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath)
if err != nil {
log.Err(err).Msgf("获取数据库 %s 失败", filePath)
continue
}
// 获取所有表名
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
if err != nil {
log.Err(err).Msgf("数据库 %s 中没有 Chat 表", filePath)
continue
}
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
log.Err(err).Msgf("数据库 %s 扫描表名失败", filePath)
continue
}
// 从表名中提取可能的talker信息
talkerMd5 := extractTalkerFromTableName(tableName)
if talkerMd5 == "" {
continue
}
talkerDBMap[talkerMd5] = filePath
}
rows.Close()
}
ds.talkerDBMap = talkerDBMap
return nil
}
// IntimacyBase 统计按联系人(非群聊)聚合的亲密度基础数据(darwin 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
type tbl struct {
db *sql.DB
name string
}
var tables []tbl
for _, db := range dbs {
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'`)
if err == nil {
for rows.Next() {
var name string
if err := rows.Scan(&name); err == nil {
tables = append(tables, tbl{db: db, name: name})
}
}
rows.Close()
}
}
for _, t := range tables {
row := t.db.QueryRowContext(ctx, `SELECT MAX(msgCreateTime) FROM `+t.name)
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 _, t := range tables {
// 基础统计:total, sent, recv, min, max;darwin v3: mesDes: 1=接收? 2=发送? 需按现有实现参考:在其它地方通常 IsSender=1 v3 windows;v3 darwin字段不同,这里按 status 字段近似处理:当 mesDes=2 视为发送
q := `SELECT COALESCE(n.user_name,''),
COUNT(*) AS msg_count,
MIN(m.msgCreateTime) AS minct,
MAX(m.msgCreateTime) AS maxct,
SUM(CASE WHEN m.mesDes=0 THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN m.mesDes!=0 THEN 1 ELSE 0 END) AS recv
FROM ` + t.name + ` m
LEFT JOIN Name2Id n ON m.realChatUsrNameId = n.rowid
WHERE (n.user_name IS NULL OR n.user_name NOT LIKE '%@chatroom')
GROUP BY COALESCE(n.user_name,'')`
rows, err := t.db.QueryContext(ctx, q)
if err == nil {
for rows.Next() {
var uname string
var cnt, minct, maxct, sent, recv int64
if err := rows.Scan(&uname, &cnt, &minct, &maxct, &sent, &recv); err == nil {
if strings.TrimSpace(uname) == "" {
continue
}
base := result[uname]
if base == nil {
base = &model.IntimacyBase{UserName: uname}
result[uname] = base
}
base.MsgCount += cnt
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()
}
// 活跃天数
q2 := `SELECT COALESCE(n.user_name,''), COUNT(DISTINCT date(datetime(m.msgCreateTime,'unixepoch'))) AS days
FROM ` + t.name + ` m LEFT JOIN Name2Id n ON m.realChatUsrNameId = n.rowid
WHERE (n.user_name IS NULL OR n.user_name NOT LIKE '%@chatroom')
GROUP BY COALESCE(n.user_name,'')`
rows2, err := t.db.QueryContext(ctx, q2)
if err == nil {
for rows2.Next() {
var uname string
var days int64
if err := rows2.Scan(&uname, &days); err == nil {
if strings.TrimSpace(uname) == "" {
continue
}
base := result[uname]
if base == nil {
base = &model.IntimacyBase{UserName: uname}
result[uname] = base
}
base.MessagingDays += days
}
}
rows2.Close()
}
// 最近90天消息
q3 := `SELECT COALESCE(n.user_name,''), COUNT(*)
FROM ` + t.name + ` m LEFT JOIN Name2Id n ON m.realChatUsrNameId = n.rowid
WHERE m.msgCreateTime>=? AND (n.user_name IS NULL OR n.user_name NOT LIKE '%@chatroom')
GROUP BY COALESCE(n.user_name,'')`
rows3, err := t.db.QueryContext(ctx, q3, since90)
if err == nil {
for rows3.Next() {
var uname string
var cnt int64
if err := rows3.Scan(&uname, &cnt); err == nil {
if strings.TrimSpace(uname) == "" {
continue
}
base := result[uname]
if base == nil {
base = &model.IntimacyBase{UserName: uname}
result[uname] = base
}
base.Last90DaysMsg += cnt
}
}
rows3.Close()
}
// 过去7天发送
q4 := `SELECT COALESCE(n.user_name,''), SUM(CASE WHEN m.mesDes=0 THEN 1 ELSE 0 END)
FROM ` + t.name + ` m LEFT JOIN Name2Id n ON m.realChatUsrNameId = n.rowid
WHERE m.msgCreateTime>=? AND (n.user_name IS NULL OR n.user_name NOT LIKE '%@chatroom')
GROUP BY COALESCE(n.user_name,'')`
rows4, err := t.db.QueryContext(ctx, q4, since7)
if err == nil {
for rows4.Next() {
var uname string
var cnt sql.NullInt64
if err := rows4.Scan(&uname, &cnt); err == nil {
if strings.TrimSpace(uname) == "" {
continue
}
base := result[uname]
if base == nil {
base = &model.IntimacyBase{UserName: uname}
result[uname] = base
}
if cnt.Valid {
base.Past7DaysSentMsg += cnt.Int64
}
}
}
rows4.Close()
}
}
return result, nil
}
func (ds *DataSource) initChatRoomDb() error {
db, err := ds.dbm.GetDB(ChatRoom)
if err != nil {
if strings.Contains(err.Error(), "db file not found") {
ds.user2DisplayName = make(map[string]string)
return nil
}
return err
}
rows, err := db.Query("SELECT m_nsUsrName, IFNULL(nickname,\"\") FROM GroupMember")
if err != nil {
log.Err(err).Msg("获取群聊成员失败")
return nil
}
user2DisplayName := make(map[string]string)
for rows.Next() {
var user string
var nickName string
if err := rows.Scan(&user, &nickName); err != nil {
log.Err(err).Msg("扫描表名失败")
continue
}
user2DisplayName[user] = nickName
}
rows.Close()
ds.user2DisplayName = user2DisplayName
return nil
}
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
}
// 解析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{}
// 对每个talker进行查询
for _, talkerItem := range talkers {
// 检查上下文是否已取消
if err := ctx.Err(); err != nil {
return nil, err
}
// 在 darwinv3 中,需要先找到对应的数据库
_talkerMd5Bytes := md5.Sum([]byte(talkerItem))
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
dbPath, ok := ds.talkerDBMap[talkerMd5]
if !ok {
// 如果找不到对应的数据库,跳过此talker
continue
}
db, err := ds.dbm.OpenDB(dbPath)
if err != nil {
log.Error().Msgf("数据库 %s 未打开", dbPath)
continue
}
tableName := fmt.Sprintf("Chat_%s", talkerMd5)
// 构建查询条件
query := fmt.Sprintf(`
SELECT msgCreateTime, msgContent, messageType, mesDes
FROM %s
WHERE msgCreateTime >= ? AND msgCreateTime <= ?
ORDER BY msgCreateTime ASC
`, tableName)
// 执行查询
rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
if err != nil {
// 如果表不存在,跳过此talker
if strings.Contains(err.Error(), "no such table") {
continue
}
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbPath)
continue
}
// 处理查询结果,在读取时进行过滤
for rows.Next() {
var msg model.MessageDarwinV3
err := rows.Scan(
&msg.MsgCreateTime,
&msg.MsgContent,
&msg.MessageType,
&msg.MesDes,
)
if err != nil {
rows.Close()
log.Err(err).Msgf("扫描消息行失败")
continue
}
// 将消息包装为通用模型
message := msg.Wrap(talkerItem)
// 应用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()
}
// 对所有消息按时间排序
// FIXME 不同 talker 需要使用 Time 排序
sort.Slice(filteredMessages, func(i, j int) bool {
return filteredMessages[i].Time.Before(filteredMessages[j].Time)
})
// 处理分页
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
}
// 从表名中提取 talker
func extractTalkerFromTableName(tableName string) string {
if !strings.HasPrefix(tableName, "Chat_") {
return ""
}
if strings.HasSuffix(tableName, "_dels") {
return ""
}
return strings.TrimPrefix(tableName, "Chat_")
}
// 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 IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
FROM WCContact
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ? OR m_nsAliasName = ?`
args = []interface{}{key, key, key, key}
} else {
// 查询所有联系人
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
FROM WCContact`
}
// 添加排序、分页
query += ` ORDER BY m_nsUsrName`
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 contactDarwinV3 model.ContactDarwinV3
err := rows.Scan(
&contactDarwinV3.M_nsUsrName,
&contactDarwinV3.Nickname,
&contactDarwinV3.M_nsRemark,
&contactDarwinV3.M_uiSex,
&contactDarwinV3.M_nsAliasName,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
contacts = append(contacts, contactDarwinV3.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 IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
FROM GroupContact
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ?`
args = []interface{}{key, key, key}
} else {
// 查询所有群聊
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
FROM GroupContact`
}
// 添加排序、分页
query += ` ORDER BY m_nsUsrName`
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
db, err := ds.dbm.GetDB(ChatRoom)
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 chatRoomDarwinV3 model.ChatRoomDarwinV3
err := rows.Scan(
&chatRoomDarwinV3.M_nsUsrName,
&chatRoomDarwinV3.Nickname,
&chatRoomDarwinV3.M_nsRemark,
&chatRoomDarwinV3.M_nsChatRoomMemList,
&chatRoomDarwinV3.M_nsChatRoomAdminList,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
}
// 如果没有找到群聊,尝试通过联系人查找
if len(chatRooms) == 0 && key != "" {
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 IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
FROM GroupContact
WHERE m_nsUsrName = ?`,
contacts[0].UserName)
if err != nil {
return nil, errors.QueryFailed(query, err)
}
defer rows.Close()
for rows.Next() {
var chatRoomDarwinV3 model.ChatRoomDarwinV3
err := rows.Scan(
&chatRoomDarwinV3.M_nsUsrName,
&chatRoomDarwinV3.Nickname,
&chatRoomDarwinV3.M_nsRemark,
&chatRoomDarwinV3.M_nsChatRoomMemList,
&chatRoomDarwinV3.M_nsChatRoomAdminList,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
}
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
if len(chatRooms) == 0 {
chatRooms = append(chatRooms, &model.ChatRoom{
Name: contacts[0].UserName,
Users: make([]model.ChatRoomUser, 0),
})
}
}
}
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 m_nsUserName, m_uLastTime
FROM SessionAbstract
WHERE m_nsUserName = ?`
args = []interface{}{key}
} else {
// 查询所有会话
query = `SELECT m_nsUserName, m_uLastTime
FROM SessionAbstract`
}
// 添加排序、分页
query += ` ORDER BY m_uLastTime DESC`
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
db, err := ds.dbm.GetDB(Session)
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 sessionDarwinV3 model.SessionDarwinV3
err := rows.Scan(
&sessionDarwinV3.M_nsUserName,
&sessionDarwinV3.M_uLastTime,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
// 包装成通用模型
session := sessionDarwinV3.Wrap()
// 尝试获取联系人信息以补充会话信息
contacts, err := ds.GetContacts(ctx, session.UserName, 1, 0)
if err == nil && len(contacts) > 0 {
session.NickName = contacts[0].DisplayName()
} else {
// 尝试获取群聊信息
chatRooms, err := ds.GetChatRooms(ctx, session.UserName, 1, 0)
if err == nil && len(chatRooms) > 0 {
session.NickName = chatRooms[0].DisplayName()
}
}
sessions = append(sessions, session)
}
return sessions, nil
}
func (ds *DataSource) GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) {
if key == "" {
return nil, errors.ErrKeyEmpty
}
query := `SELECT
r.mediaMd5,
r.mediaSize,
r.inodeNumber,
r.modifyTime,
d.relativePath,
d.fileName
FROM
HlinkMediaRecord r
JOIN
HlinkMediaDetail d ON r.inodeNumber = d.inodeNumber
WHERE
r.mediaMd5 = ?`
args := []interface{}{key}
// 执行查询
db, err := ds.dbm.GetDB(Media)
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()
var media *model.Media
for rows.Next() {
var mediaDarwinV3 model.MediaDarwinV3
err := rows.Scan(
&mediaDarwinV3.MediaMd5,
&mediaDarwinV3.MediaSize,
&mediaDarwinV3.InodeNumber,
&mediaDarwinV3.ModifyTime,
&mediaDarwinV3.RelativePath,
&mediaDarwinV3.FileName,
)
if err != nil {
return nil, errors.ScanRowFailed(err)
}
// 包装成通用模型
media = mediaDarwinV3.Wrap()
}
if media == nil {
return nil, errors.ErrMediaNotFound
}
return media, nil
}
// Close 实现关闭数据库连接的方法
func (ds *DataSource) Close() error {
return ds.dbm.Close()
}
func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
return ds.dbm.FingerprintForGroups(Message)
}
// GetAvatar returns not found for darwin v3 (no head image source known here)
func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
return nil, errors.ErrAvatarNotFound
}
// GlobalMessageStats 聚合统计(Darwin v3)
func (ds *DataSource) GlobalMessageStats(ctx context.Context) (*model.GlobalMessageStats, error) {
stats := &model.GlobalMessageStats{ByType: make(map[string]int64)}
// 遍历所有消息库,枚举 Chat_% 表
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return stats, nil
}
for _, db := range dbs {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
var tables []string
for trows.Next() {
var name string
if err := trows.Scan(&name); err == nil {
tables = append(tables, name)
}
}
trows.Close()
for _, tbl := range tables {
q := fmt.Sprintf(`SELECT COUNT(*) AS total,
SUM(CASE WHEN mesDes=0 THEN 1 ELSE 0 END) AS sent,
MIN(msgCreateTime) AS minct,
MAX(msgCreateTime) AS maxct FROM %s`, tbl)
row := db.QueryRowContext(ctx, q)
var total, sent, minct, maxct int64
if err := row.Scan(&total, &sent, &minct, &maxct); err == nil {
stats.Total += total
stats.Sent += sent
stats.Received += (total - sent)
if stats.EarliestUnix == 0 || (minct > 0 && minct < stats.EarliestUnix) {
stats.EarliestUnix = minct
}
if maxct > stats.LatestUnix {
stats.LatestUnix = maxct
}
}
// by type(先统计非 49)
q2 := fmt.Sprintf(`SELECT messageType, COUNT(*) FROM %s WHERE messageType != 49 GROUP BY messageType`, tbl)
rows, err := db.QueryContext(ctx, q2)
if err == nil {
for rows.Next() {
var t int64
var cnt int64
if err := rows.Scan(&t, &cnt); err == nil {
label := mapV4TypeToLabel(t)
if label != "" {
stats.ByType[label] += cnt
}
}
}
rows.Close()
}
// 细分 49
q49 := fmt.Sprintf(`SELECT msgContent FROM %s WHERE messageType = 49`, tbl)
orows, err := db.QueryContext(ctx, q49)
if err == nil {
for orows.Next() {
var mc string
if err := orows.Scan(&mc); err == nil {
lc := strings.ToLower(mc)
if strings.Contains(lc, "<appmsg") {
if strings.Contains(lc, "<type>") && strings.Contains(lc, "</type>") {
i1 := strings.Index(lc, "<type>")
i2 := strings.Index(lc[i1+6:], "</type>")
if i1 >= 0 && i2 > 0 {
val := lc[i1+6 : i1+6+i2]
if strings.TrimSpace(val) == "6" {
stats.ByType["文件消息"]++
continue
}
if strings.TrimSpace(val) == "5" || strings.TrimSpace(val) == "33" {
stats.ByType["链接消息"]++
continue
}
}
}
}
if strings.Contains(lc, "http://") || strings.Contains(lc, "https://") {
stats.ByType["链接消息"]++
continue
}
stats.ByType["XML消息"]++
}
}
orows.Close()
}
}
}
return stats, nil
}
// GroupMessageCounts 统计群聊消息数量(Darwin v3)
func (ds *DataSource) GroupMessageCounts(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
// 先获取所有群聊用户名,构建 md5 -> username 映射
mapping := make(map[string]string)
if cdb, err := ds.dbm.GetDB(ChatRoom); err == nil {
rows, err := cdb.QueryContext(ctx, `SELECT IFNULL(m_nsUsrName,"") FROM GroupContact`)
if err == nil {
for rows.Next() {
var uname string
if err := rows.Scan(&uname); err == nil && uname != "" {
sum := md5.Sum([]byte(uname))
mapping["Chat_"+hex.EncodeToString(sum[:])] = uname
}
}
rows.Close()
}
}
// 遍历消息库,按表计数并映射回 username
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
for _, db := range dbs {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
for trows.Next() {
var tbl string
if err := trows.Scan(&tbl); err != nil {
continue
}
var cnt int64
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, tbl)
if err := db.QueryRowContext(ctx, q).Scan(&cnt); err == nil {
key := tbl
if uname, ok := mapping[tbl]; ok {
key = uname
}
result[key] += cnt
}
}
trows.Close()
}
return result, nil
}
// GroupTodayMessageCounts 统计群聊今日消息数(Darwin v3):Chat_% 表按 msgCreateTime 过滤 >= 今日零点
func (ds *DataSource) GroupTodayMessageCounts(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
// 构建 md5->username 映射(来自 GroupContact)
mapping := make(map[string]string)
if cdb, err := ds.dbm.GetDB(ChatRoom); err == nil {
rows, err := cdb.QueryContext(ctx, `SELECT IFNULL(m_nsUsrName,"") FROM GroupContact`)
if err == nil {
for rows.Next() {
var uname string
if err := rows.Scan(&uname); err == nil && uname != "" {
sum := md5.Sum([]byte(uname))
mapping["Chat_"+hex.EncodeToString(sum[:])] = uname
}
}
rows.Close()
}
}
// 今日零点
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
since := today.Unix()
// 遍历消息库
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
for _, db := range dbs {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
for trows.Next() {
var tbl string
if err := trows.Scan(&tbl); err != nil {
continue
}
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE msgCreateTime >= ?`, tbl)
var cnt int64
if err := db.QueryRowContext(ctx, q, since).Scan(&cnt); err == nil {
key := tbl
if uname, ok := mapping[tbl]; ok {
key = uname
}
result[key] += cnt
}
}
trows.Close()
}
return result, nil
}
// GroupTodayHourly 统计群聊今日按小时消息数(Darwin v3)
func (ds *DataSource) GroupTodayHourly(ctx context.Context) (map[string][24]int64, error) {
result := make(map[string][24]int64)
mapping := make(map[string]string)
if cdb, err := ds.dbm.GetDB(ChatRoom); err == nil {
rows, err := cdb.QueryContext(ctx, `SELECT IFNULL(m_nsUsrName,"" ) FROM GroupContact`)
if err == nil {
for rows.Next() {
var uname string
if rows.Scan(&uname) == nil && uname != "" {
sum := md5.Sum([]byte(uname))
mapping["Chat_"+hex.EncodeToString(sum[:])] = uname
}
}
rows.Close()
}
}
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 {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
for trows.Next() {
var tbl string
if trows.Scan(&tbl) != nil {
continue
}
q := fmt.Sprintf(`SELECT CAST(strftime('%%H', datetime(msgCreateTime,'unixepoch')) AS INTEGER) AS h, COUNT(*) FROM %s WHERE msgCreateTime >= ? AND msgCreateTime < ? GROUP BY h`, tbl)
rows, err := db.QueryContext(ctx, q, start, end)
if err != nil {
continue
}
key := tbl
if uname, ok := mapping[tbl]; ok {
key = uname
}
for rows.Next() {
var hour int
var cnt int64
if rows.Scan(&hour, &cnt) == nil {
if hour >= 0 && hour < 24 {
bucket := result[key]
bucket[hour] += cnt
result[key] = bucket
}
}
}
rows.Close()
}
trows.Close()
}
return result, nil
}
// GroupWeekMessageCount 统计本周(周一00:00起)所有群聊消息总数(Darwin v3)
func (ds *DataSource) GroupWeekMessageCount(ctx context.Context) (int64, error) {
var total int64
// 构建映射便于遍历表名
mapping := make(map[string]string)
if cdb, err := ds.dbm.GetDB(ChatRoom); err == nil {
rows, err := cdb.QueryContext(ctx, `SELECT IFNULL(m_nsUsrName,"") FROM GroupContact`)
if err == nil {
for rows.Next() {
var uname string
if rows.Scan(&uname) == nil && uname != "" {
sum := md5.Sum([]byte(uname))
mapping["Chat_"+hex.EncodeToString(sum[:])] = uname
}
}
rows.Close()
}
}
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()
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return 0, nil
}
for _, db := range dbs {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
for trows.Next() {
var tbl string
if trows.Scan(&tbl) != nil {
continue
}
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE msgCreateTime >= ?`, tbl)
var cnt int64
if db.QueryRowContext(ctx, q, since).Scan(&cnt) == nil {
total += cnt
}
}
trows.Close()
}
return total, nil
}
// MonthlyTrend 返回每月 sent/received
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 {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
var tables []string
for trows.Next() {
var name string
if err := trows.Scan(&name); err == nil {
tables = append(tables, name)
}
}
trows.Close()
for _, tbl := range tables {
q := fmt.Sprintf(`SELECT strftime('%%Y-%%m', datetime(msgCreateTime, 'unixepoch')) AS ym,
SUM(CASE WHEN mesDes=0 THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN mesDes!=0 THEN 1 ELSE 0 END) AS recv
FROM %s GROUP BY ym ORDER BY ym`, tbl)
rows, err := db.QueryContext(ctx, q)
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()
}
}
// 排序输出
keys := make([]string, 0, len(agg))
for k := range agg {
keys = append(keys, k)
}
sort.Strings(keys)
trends := make([]model.MonthlyTrend, 0, len(keys))
for _, k := range keys {
v := agg[k]
trends = append(trends, model.MonthlyTrend{Date: k, Sent: v[0], Received: v[1]})
}
return trends, nil
}
// GroupMessageTypeStats 统计群聊消息类型分布(Darwin v3)
func (ds *DataSource) GroupMessageTypeStats(ctx context.Context) (map[string]int64, error) {
result := make(map[string]int64)
// 构建群聊 md5 映射
mapping := make(map[string]string)
if cdb, err := ds.dbm.GetDB(ChatRoom); err == nil {
if rows, err2 := cdb.QueryContext(ctx, `SELECT IFNULL(m_nsUsrName,"") FROM GroupContact`); err2 == nil {
for rows.Next() {
var uname string
if rows.Scan(&uname) == nil && uname != "" {
sum := md5.Sum([]byte(uname))
mapping["Chat_"+hex.EncodeToString(sum[:])] = uname
}
}
rows.Close()
}
}
dbs, err := ds.dbm.GetDBs(Message)
if err != nil {
return result, nil
}
for _, db := range dbs {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
var tables []string
for trows.Next() {
var name string
if trows.Scan(&name) == nil {
tables = append(tables, name)
}
}
trows.Close()
for _, tbl := range tables {
if _, ok := mapping[tbl]; !ok {
continue
}
// 非49
q := fmt.Sprintf(`SELECT messageType, COUNT(*) FROM %s WHERE messageType != 49 GROUP BY messageType`, tbl)
rows, err2 := db.QueryContext(ctx, q)
if err2 == nil {
for rows.Next() {
var t int64
var cnt int64
if rows.Scan(&t, &cnt) == nil {
label := mapV4TypeToLabel(t)
if label != "" {
result[label] += cnt
}
}
}
rows.Close()
}
// 49
q49 := fmt.Sprintf(`SELECT msgContent FROM %s WHERE messageType = 49`, tbl)
orows, err3 := db.QueryContext(ctx, q49)
if err3 == nil {
for orows.Next() {
var mc string
if orows.Scan(&mc) == nil {
lc := strings.ToLower(mc)
if strings.Contains(lc, "<appmsg") {
if strings.Contains(lc, "<type>") && strings.Contains(lc, "</type>") {
i1 := strings.Index(lc, "<type>")
i2 := strings.Index(lc[i1+6:], "</type>")
if i1 >= 0 && i2 > 0 {
val := strings.TrimSpace(lc[i1+6 : i1+6+i2])
if val == "6" {
result["文件消息"]++
continue
}
if val == "5" || val == "33" {
result["链接消息"]++
continue
}
}
}
}
if strings.Contains(lc, "http://") || strings.Contains(lc, "https://") {
result["链接消息"]++
continue
}
result["XML消息"]++
}
}
orows.Close()
}
}
}
return result, 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 {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
var tables []string
for trows.Next() {
var name string
if err := trows.Scan(&name); err == nil {
tables = append(tables, name)
}
}
trows.Close()
for _, tbl := range tables {
q := fmt.Sprintf(`SELECT CAST(strftime('%%H', datetime(msgCreateTime,'unixepoch')) AS INTEGER) AS h,
CAST(strftime('%%w', datetime(msgCreateTime,'unixepoch')) AS INTEGER) AS d,
COUNT(*) FROM %s GROUP BY h,d`, tbl)
rows, err := db.QueryContext(ctx, q)
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 返回今日(本地时区)每小时全部消息量(Darwin v3)
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 {
trows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%' AND name NOT LIKE '%_dels'`)
if err != nil {
continue
}
var tables []string
for trows.Next() {
var name string
if trows.Scan(&name) == nil {
tables = append(tables, name)
}
}
trows.Close()
for _, tbl := range tables {
q := fmt.Sprintf(`SELECT CAST(strftime('%%H', datetime(msgCreateTime,'unixepoch')) AS INTEGER) AS h, COUNT(*) FROM %s WHERE msgCreateTime >= ? AND msgCreateTime < ? GROUP BY h`, tbl)
rows, err := db.QueryContext(ctx, q, 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
}
// 本地定义简易类型映射,复用与 v4 类似的类别
func mapV4TypeToLabel(t int64) string {
// 依据文档统一的消息类型映射
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:
return "XML消息"
case 50:
return "音视频通话"
case 51:
return "手机端操作消息"
case 10000:
return "系统通知"
case 10002:
return "撤回消息"
default:
return ""
}
}