This commit is contained in:
Shen Junzheng
2025-03-12 01:19:35 +08:00
parent 160040f3e1
commit 78cce92ce3
70 changed files with 10134 additions and 1 deletions

View File

@@ -0,0 +1,269 @@
package wechatdb
import (
"database/sql"
"fmt"
"github.com/sjzar/chatlog/pkg/model"
"github.com/sjzar/chatlog/pkg/util"
log "github.com/sirupsen/logrus"
)
var (
ContactFileV3 = "^MicroMsg.db$"
ContactFileV4 = "contact.db$"
)
type Contact struct {
version int
dbFile string
db *sql.DB
Contact map[string]*model.Contact // 好友和群聊信息Key UserName
ChatRoom map[string]*model.ChatRoom // 群聊信息Key UserName
Sessions []*model.Session // 历史会话,按时间倒序
// Quick Search
ChatRoomUsers map[string]*model.Contact // 群聊成员信息Key UserName
Alias2Contack map[string]*model.Contact // 别名到联系人的映射
Remark2Contack map[string]*model.Contact // 备注名到联系人的映射
NickName2Contack map[string]*model.Contact // 昵称到联系人的映射
}
func NewContact(path string, version int) (*Contact, error) {
c := &Contact{
version: version,
}
files, err := util.FindFilesWithPatterns(path, ContactFileV3, true)
if err != nil {
return nil, fmt.Errorf("查找数据库文件失败: %v", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
}
c.dbFile = files[0]
c.db, err = sql.Open("sqlite3", c.dbFile)
if err != nil {
log.Printf("警告: 连接数据库 %s 失败: %v", c.dbFile, err)
return nil, fmt.Errorf("连接数据库失败: %v", err)
}
c.loadContact()
c.loadChatRoom()
c.loadSession()
c.fillChatRoomInfo()
return c, nil
}
func (c *Contact) loadContact() {
contactMap := make(map[string]*model.Contact)
chatRoomUserMap := make(map[string]*model.Contact)
aliasMap := make(map[string]*model.Contact)
remarkMap := make(map[string]*model.Contact)
nickNameMap := make(map[string]*model.Contact)
rows, err := c.db.Query("SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact")
if err != nil {
log.Errorf("查询联系人失败: %v", err)
return
}
for rows.Next() {
var contactv3 model.ContactV3
if err := rows.Scan(
&contactv3.UserName,
&contactv3.Alias,
&contactv3.Remark,
&contactv3.NickName,
&contactv3.Reserved1,
); err != nil {
log.Printf("警告: 扫描联系人行失败: %v", err)
continue
}
contact := contactv3.Wrap()
if contact.IsFriend {
contactMap[contact.UserName] = contact
if contact.Alias != "" {
aliasMap[contact.Alias] = contact
}
if contact.Remark != "" {
remarkMap[contact.Remark] = contact
}
if contact.NickName != "" {
nickNameMap[contact.NickName] = contact
}
} else {
chatRoomUserMap[contact.UserName] = contact
}
}
rows.Close()
c.Contact = contactMap
c.ChatRoomUsers = chatRoomUserMap
c.Alias2Contack = aliasMap
c.Remark2Contack = remarkMap
c.NickName2Contack = nickNameMap
}
func (c *Contact) loadChatRoom() {
chatRoomMap := make(map[string]*model.ChatRoom)
rows, err := c.db.Query("SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom")
if err != nil {
log.Errorf("查询群聊失败: %v", err)
return
}
for rows.Next() {
var chatRoom model.ChatRoomV3
if err := rows.Scan(
&chatRoom.ChatRoomName,
&chatRoom.Reserved2,
&chatRoom.RoomData,
); err != nil {
log.Printf("警告: 扫描群聊行失败: %v", err)
continue
}
chatRoomMap[chatRoom.ChatRoomName] = chatRoom.Wrap()
}
rows.Close()
c.ChatRoom = chatRoomMap
}
func (c *Contact) loadSession() {
sessions := make([]*model.Session, 0)
rows, err := c.db.Query("SELECT strUsrName, nOrder, strNickName, strContent, nTime FROM Session ORDER BY nOrder DESC")
if err != nil {
log.Errorf("查询群聊失败: %v", err)
return
}
for rows.Next() {
var sessionV3 model.SessionV3
if err := rows.Scan(
&sessionV3.StrUsrName,
&sessionV3.NOrder,
&sessionV3.StrNickName,
&sessionV3.StrContent,
&sessionV3.NTime,
); err != nil {
log.Printf("警告: 扫描历史会话失败: %v", err)
continue
}
session := sessionV3.Wrap()
sessions = append(sessions, session)
}
rows.Close()
c.Sessions = sessions
}
func (c *Contact) ListContact() ([]*model.Contact, error) {
contacts := make([]*model.Contact, 0, len(c.Contact))
for _, contact := range c.Contact {
contacts = append(contacts, contact)
}
return contacts, nil
}
func (c *Contact) ListChatRoom() ([]*model.ChatRoom, error) {
chatRooms := make([]*model.ChatRoom, 0, len(c.ChatRoom))
for _, chatRoom := range c.ChatRoom {
chatRooms = append(chatRooms, chatRoom)
}
return chatRooms, nil
}
func (c *Contact) GetContact(key string) *model.Contact {
if contact, ok := c.Contact[key]; ok {
return contact
}
if contact, ok := c.Alias2Contack[key]; ok {
return contact
}
if contact, ok := c.Remark2Contack[key]; ok {
return contact
}
if contact, ok := c.NickName2Contack[key]; ok {
return contact
}
return nil
}
func (c *Contact) GetChatRoom(name string) *model.ChatRoom {
if chatRoom, ok := c.ChatRoom[name]; ok {
return chatRoom
}
if contact := c.GetContact(name); contact != nil {
if chatRoom, ok := c.ChatRoom[contact.UserName]; ok {
return chatRoom
} else {
// 被删除的群聊,在 ChatRoom 记录中没有了,但是能找到 Contact做下 Mock
return &model.ChatRoom{
Name: contact.UserName,
Remark: contact.Remark,
NickName: contact.NickName,
Users: make([]model.ChatRoomUser, 0),
User2DisplayName: make(map[string]string),
}
}
}
return nil
}
func (c *Contact) GetSession(limit int) []*model.Session {
if limit <= 0 {
limit = len(c.Sessions)
}
if len(c.Sessions) < limit {
limit = len(c.Sessions)
}
return c.Sessions[:limit]
}
func (c *Contact) getFullContact(userName string) *model.Contact {
if contact := c.GetContact(userName); contact != nil {
return contact
}
if contact, ok := c.ChatRoomUsers[userName]; ok {
return contact
}
return nil
}
func (c *Contact) fillChatRoomInfo() {
for i := range c.ChatRoom {
if contact := c.GetContact(c.ChatRoom[i].Name); contact != nil {
c.ChatRoom[i].Remark = contact.Remark
c.ChatRoom[i].NickName = contact.NickName
}
}
}
func (c *Contact) MessageFillInfo(msg *model.Message) {
talker := msg.Talker
if msg.IsChatRoom {
talker = msg.ChatRoomSender
if chatRoom := c.GetChatRoom(msg.Talker); chatRoom != nil {
msg.CharRoomName = chatRoom.DisplayName()
if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
msg.DisplayName = displayName
}
}
}
if msg.DisplayName == "" && msg.IsSender != 1 {
if contact := c.getFullContact(talker); contact != nil {
msg.DisplayName = contact.DisplayName()
}
}
}

View File

@@ -0,0 +1,321 @@
package wechatdb
import (
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/sjzar/chatlog/pkg/model"
"github.com/sjzar/chatlog/pkg/util"
_ "github.com/mattn/go-sqlite3"
)
const (
MessageFileV3 = "^MSG([0-9]?[0-9])?\\.db$"
MessageFileV4 = "^messages_([0-9]?[0-9])+\\.db$"
)
type Message struct {
version int
files []MsgDBInfo
dbs map[string]*sql.DB
}
type MsgDBInfo struct {
FilePath string
StartTime time.Time
EndTime time.Time
TalkerMap map[string]int
}
func NewMessage(path string, version int) (*Message, error) {
m := &Message{
version: version,
files: make([]MsgDBInfo, 0),
dbs: make(map[string]*sql.DB),
}
// 查找所有 MSG[0-13].db 文件
files, err := util.FindFilesWithPatterns(path, MessageFileV3, true)
if err != nil {
return nil, fmt.Errorf("查找数据库文件失败: %v", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
}
// 处理每个数据库文件
for _, filePath := range files {
// 连接数据库
db, err := sql.Open("sqlite3", filePath)
if err != nil {
log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
continue
}
// 获取 DBInfo 表中的开始时间
// 首先检查表结构
var startTime time.Time
// 尝试从 DBInfo 表中查找 Start Time 对应的记录
rows, err := db.Query("SELECT tableIndex, tableVersion, tableDesc FROM DBInfo")
if err != nil {
log.Printf("警告: 查询数据库 %s 的 DBInfo 表失败: %v", filePath, err)
db.Close()
continue
}
for rows.Next() {
var tableIndex int
var tableVersion int64
var tableDesc string
if err := rows.Scan(&tableIndex, &tableVersion, &tableDesc); err != nil {
log.Printf("警告: 扫描 DBInfo 行失败: %v", err)
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.Printf("警告: 查询数据库 %s 的 Name2ID 表失败: %v", filePath, err)
db.Close()
continue
}
i := 1
for rows.Next() {
var userName string
if err := rows.Scan(&userName); err != nil {
log.Printf("警告: 扫描 Name2ID 行失败: %v", err)
continue
}
talkerMap[userName] = i
i++
}
// 保存数据库信息
m.files = append(m.files, MsgDBInfo{
FilePath: filePath,
StartTime: startTime,
TalkerMap: talkerMap,
})
// 保存数据库连接
m.dbs[filePath] = db
}
// 按照 StartTime 排序数据库文件
sort.Slice(m.files, func(i, j int) bool {
return m.files[i].StartTime.Before(m.files[j].StartTime)
})
for i := range m.files {
if i == len(m.files)-1 {
m.files[i].EndTime = time.Now()
} else {
m.files[i].EndTime = m.files[i+1].StartTime
}
}
return m, nil
}
// GetMessages 根据时间段和 talker 查询聊天记录
func (m *Message) GetMessages(startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
// 找到时间范围内的数据库文件
dbInfos := m.getDBInfosForTimeRange(startTime, endTime)
if len(dbInfos) == 0 {
return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
}
if len(dbInfos) == 1 {
// LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
return m.getMessagesSingleFile(dbInfos[0], startTime, endTime, talker, limit, offset)
}
// 从每个相关数据库中查询消息
totalMessages := []*model.Message{}
for _, dbInfo := range dbInfos {
db, ok := m.dbs[dbInfo.FilePath]
if !ok {
log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
continue
}
// 构建查询条件
// 使用 Sequence 查询,有索引
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
if len(talker) > 0 {
talkerID, ok := dbInfo.TalkerMap[talker]
if ok {
conditions = append(conditions, "TalkerId = ?")
args = append(args, talkerID)
} else {
conditions = append(conditions, "StrTalker = ?")
args = append(args, talker)
}
}
query := fmt.Sprintf(`
SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG
WHERE %s
ORDER BY Sequence ASC
`, strings.Join(conditions, " AND "))
// 执行查询
rows, err := db.Query(query, args...)
if err != nil {
log.Printf("警告: 查询数据库 %s 失败: %v", dbInfo.FilePath, err)
continue
}
// 处理查询结果
for rows.Next() {
var msg model.MessageV3
var compressContent []byte
var bytesExtra []byte
err := rows.Scan(
&msg.Sequence,
&msg.CreateTime,
&msg.TalkerID,
&msg.StrTalker,
&msg.IsSender,
&msg.Type,
&msg.SubType,
&msg.StrContent,
&compressContent,
&bytesExtra,
)
if err != nil {
log.Printf("警告: 扫描消息行失败: %v", err)
continue
}
msg.CompressContent = compressContent
msg.BytesExtra = bytesExtra
totalMessages = append(totalMessages, msg.Wrap())
}
rows.Close()
if limit+offset > 0 && len(totalMessages) >= limit+offset {
break
}
}
// 对所有消息按时间排序
sort.Slice(totalMessages, func(i, j int) bool {
return totalMessages[i].Sequence < totalMessages[j].Sequence
})
// FIXME limit 和 offset 逻辑,在多文件边界条件下不好处理,直接查询全量数据后在进程里处理
if limit > 0 {
if offset >= len(totalMessages) {
return []*model.Message{}, nil
}
end := offset + limit
if end > len(totalMessages) || limit == 0 {
end = len(totalMessages)
}
return totalMessages[offset:end], nil
}
return totalMessages, nil
}
func (m *Message) getMessagesSingleFile(dbInfo MsgDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
// 构建查询条件
// 使用 Sequence 查询,有索引
conditions := []string{"Sequence >= ? AND Sequence <= ?"}
args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
if len(talker) > 0 {
// TalkerId 有索引,优先使用
talkerID, ok := dbInfo.TalkerMap[talker]
if ok {
conditions = append(conditions, "TalkerId = ?")
args = append(args, talkerID)
} else {
conditions = append(conditions, "StrTalker = ?")
args = append(args, talker)
}
}
query := fmt.Sprintf(`
SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG
WHERE %s
ORDER BY Sequence ASC
`, strings.Join(conditions, " AND "))
if limit > 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
}
// 执行查询
rows, err := m.dbs[dbInfo.FilePath].Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询数据库 %s 失败: %v", dbInfo.FilePath, err)
}
defer rows.Close()
// 处理查询结果
totalMessages := []*model.Message{}
for rows.Next() {
var msg model.MessageV3
var compressContent []byte
var bytesExtra []byte
err := rows.Scan(
&msg.Sequence,
&msg.CreateTime,
&msg.TalkerID,
&msg.StrTalker,
&msg.IsSender,
&msg.Type,
&msg.SubType,
&msg.StrContent,
&compressContent,
&bytesExtra,
)
if err != nil {
return nil, fmt.Errorf("扫描消息行失败: %v", err)
}
msg.CompressContent = compressContent
msg.BytesExtra = bytesExtra
totalMessages = append(totalMessages, msg.Wrap())
}
return totalMessages, nil
}
func (m *Message) getDBInfosForTimeRange(startTime, endTime time.Time) []MsgDBInfo {
var dbs []MsgDBInfo
for _, info := range m.files {
if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
dbs = append(dbs, info)
}
}
return dbs
}

View File

@@ -0,0 +1,117 @@
package wechatdb
import (
"time"
"github.com/sjzar/chatlog/pkg/model"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
BasePath string
Version int
contact *Contact
message *Message
}
func New(path string, version int) (*DB, error) {
w := &DB{
BasePath: path,
Version: version,
}
// 初始化,加载数据库文件信息
if err := w.Initialize(); err != nil {
return nil, err
}
return w, nil
}
func (w *DB) Close() error {
return nil
}
func (w *DB) Initialize() error {
var err error
w.message, err = NewMessage(w.BasePath, w.Version)
if err != nil {
return err
}
w.contact, err = NewContact(w.BasePath, w.Version)
if err != nil {
return err
}
return nil
}
func (w *DB) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
if talker != "" {
if contact := w.contact.GetContact(talker); contact != nil {
talker = contact.UserName
}
}
messages, err := w.message.GetMessages(start, end, talker, limit, offset)
if err != nil {
return nil, err
}
for i := range messages {
w.contact.MessageFillInfo(messages[i])
}
return messages, nil
}
type ListContactResp struct {
Items []*model.Contact `json:"items"`
}
func (w *DB) ListContact() (*ListContactResp, error) {
list, err := w.contact.ListContact()
if err != nil {
return nil, err
}
return &ListContactResp{
Items: list,
}, nil
}
func (w *DB) GetContact(userName string) *model.Contact {
return w.contact.GetContact(userName)
}
type ListChatRoomResp struct {
Items []*model.ChatRoom `json:"items"`
}
func (w *DB) ListChatRoom() (*ListChatRoomResp, error) {
list, err := w.contact.ListChatRoom()
if err != nil {
return nil, err
}
return &ListChatRoomResp{
Items: list,
}, nil
}
func (w *DB) GetChatRoom(userName string) *model.ChatRoom {
return w.contact.GetChatRoom(userName)
}
type GetSessionResp struct {
Items []*model.Session `json:"items"`
}
func (w *DB) GetSession(limit int) (*GetSessionResp, error) {
sessions := w.contact.GetSession(limit)
return &GetSessionResp{
Items: sessions,
}, nil
}