perf(search): Delete FTS fallback, use Bleve only.

This commit is contained in:
Symb0x76
2025-10-10 13:59:50 +08:00
parent 99442d8eb7
commit f31c39c40f
9 changed files with 668 additions and 569 deletions
+11 -14
View File
@@ -640,8 +640,9 @@
<div class="tab-content" id="search-tab">
<div class="api-description">
<p>
使用全文索引在指定会话中查找消息。<span
class="badge"
使用
<strong>Bleve</strong>
在指定会话中查找消息。<span class="badge"
>GET /api/v1/search</span
>
</p>
@@ -649,18 +650,18 @@
<div class="form-group">
<label for="search-query"
>关键词:<span class="required-field"
>必填</span
>*</span
></label
>
<input
id="search-query"
type="text"
placeholder="支持空格分词,可使用 AND/OR/NOT 等语法"
placeholder="支持空格分词"
/>
</div>
<div class="form-group">
<label for="search-talker"
>会话 ID(可多个,英文逗号分隔):<span
>聊天对象(可多个,英文逗号分隔):<span
class="optional-param"
>可选</span
></label
@@ -668,16 +669,12 @@
<input
id="search-talker"
type="text"
placeholder="留空默认搜索所有联系人与群聊"
placeholder="wxid、群ID、备注名或昵称 (留空输出所有)"
/>
<div class="form-hint">
可以从“最近会话”或“群聊”接口获取对应的
ID;如留空将检索所有会话。
</div>
</div>
<div class="form-group">
<label for="search-sender"
>发送者筛选:<span class="optional-param"
>发送者:<span class="optional-param"
>可选</span
></label
>
@@ -699,12 +696,12 @@
<input type="date" id="search-end-date" />
</div>
<div class="form-hint">
可选择开始日期和结束日期;留空表示不限制时间范围。
留空表示不限制时间范围。
</div>
</div>
<div class="form-group">
<label for="search-limit"
>Limit:<span class="optional-param"
>返回数量:<span class="optional-param"
>可选</span
></label
>
@@ -718,7 +715,7 @@
</div>
<div class="form-group">
<label for="search-offset"
>Offset:<span class="optional-param"
>偏移量:<span class="optional-param"
>可选</span
></label
>
@@ -511,10 +511,6 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
return filteredMessages, nil
}
func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error) {
return nil, errors.SearchNotSupported("darwin", 3)
}
// 从表名中提取 talker
func extractTalkerFromTableName(tableName string) string {
@@ -17,7 +17,6 @@ type DataSource interface {
// 消息
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error)
SearchMessages(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error)
GetDatasetFingerprint(ctx context.Context) (string, error)
// 联系人
+169 -256
View File
@@ -6,7 +6,6 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"math"
"regexp"
"sort"
"strings"
@@ -369,279 +368,193 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
return filteredMessages, nil
}
func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error) {
if req == nil {
return nil, errors.InvalidArg("request")
func (ds *DataSource) ListTalkers(ctx context.Context) ([]string, error) {
talkerSet := make(map[string]struct{})
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
if _, ok := talkerSet[name]; ok {
return
}
talkerSet[name] = struct{}{}
}
ftsQuery := util.BuildFTSQuery(req.Query)
if ftsQuery == "" {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: 0,
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
talkers := util.Str2List(req.Talker, ",")
if len(talkers) == 0 {
return nil, errors.ErrTalkerEmpty
}
limit := req.Limit
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
offset := req.Offset
if offset < 0 {
offset = 0
}
req.Limit = limit
req.Offset = offset
budget := limit + offset + 20
if budget < 50 {
budget = 50
}
if budget > 1000 {
budget = 1000
}
startRange := req.Start
endRange := req.End
if startRange.IsZero() {
startRange = time.Unix(0, 0)
}
if endRange.IsZero() {
endRange = time.Now().Add(24 * time.Hour)
}
if endRange.Before(startRange) {
startRange, endRange = endRange, startRange
}
startUnix := int64(0)
if !req.Start.IsZero() {
startUnix = req.Start.Unix()
}
endUnix := int64(math.MaxInt64)
if !req.End.IsZero() {
endUnix = req.End.Unix()
}
if endUnix < startUnix {
startUnix, endUnix = endUnix, startUnix
}
senders := util.Str2List(req.Sender, ",")
senderFilter := len(senders) > 0
senderSet := make(map[string]struct{}, len(senders))
for _, s := range senders {
senderSet[s] = struct{}{}
}
startTs := time.Now()
allHits := make([]*model.SearchHit, 0, limit+offset)
seenSeq := make(map[int64]struct{})
dbInfos := ds.getDBInfosForTimeRange(startRange, endRange)
if len(dbInfos) == 0 {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
for _, dbInfo := range dbInfos {
if err := ctx.Err(); err != nil {
return nil, err
db, err := ds.dbm.GetDB(Contact)
if err != nil {
log.Debug().Err(err).Msg("query contact usernames failed")
} else if db != nil {
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 scanErr := rows.Scan(&username); scanErr == nil {
add(username)
} else {
log.Debug().Err(scanErr).Msg("scan contact username failed")
}
}
if err := rows.Err(); err != nil {
log.Debug().Err(err).Msg("iterate contact usernames failed")
}
rows.Close()
} else {
log.Debug().Err(err).Msg("query contact usernames failed")
}
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
roomRows, err := db.QueryContext(ctx, `SELECT username FROM chat_room WHERE IFNULL(username,'') <> ''`)
if err == nil {
for roomRows.Next() {
if err := ctx.Err(); err != nil {
roomRows.Close()
return nil, err
}
var username string
if scanErr := roomRows.Scan(&username); scanErr == nil {
add(username)
} else {
log.Debug().Err(scanErr).Msg("scan chat_room username failed")
}
}
if err := roomRows.Err(); err != nil {
log.Debug().Err(err).Msg("iterate chat_room usernames failed")
}
roomRows.Close()
} else {
log.Debug().Err(err).Msg("query chat_room usernames failed")
}
}
db, err = ds.dbm.GetDB(Session)
if err != nil {
log.Debug().Err(err).Msg("query session usernames failed")
} else if db != nil {
rows, err := db.QueryContext(ctx, `SELECT username FROM SessionTable WHERE IFNULL(username,'') <> ''`)
if err == nil {
for rows.Next() {
if err := ctx.Err(); err != nil {
rows.Close()
return nil, err
}
var username string
if scanErr := rows.Scan(&username); scanErr == nil {
add(username)
} else {
log.Debug().Err(scanErr).Msg("scan session username failed")
}
}
if err := rows.Err(); err != nil {
log.Debug().Err(err).Msg("iterate session usernames failed")
}
rows.Close()
} else {
log.Debug().Err(err).Msg("query session usernames failed")
}
}
talkers := make([]string, 0, len(talkerSet))
for username := range talkerSet {
talkers = append(talkers, username)
}
sort.Strings(talkers)
return talkers, nil
}
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.ListTalkers(ctx)
if err != nil {
return err
}
}
if len(talkers) == 0 {
return nil
}
tableNames := make(map[string]string, len(talkers))
for _, talker := range talkers {
hash := md5.Sum([]byte(talker))
tableNames[talker] = "Msg_" + hex.EncodeToString(hash[:])
}
for _, info := range ds.messageInfos {
if err := ctx.Err(); err != nil {
return err
}
db, err := ds.dbm.OpenDB(info.FilePath)
if err != nil {
log.Err(err).Msgf("数据库 %s 未打开", dbInfo.FilePath)
continue
}
for _, talker := range talkers {
if err := ctx.Err(); err != nil {
return nil, err
return err
}
tableName := tableNames[talker]
err := func() error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return errors.QueryFailed("begin tx", err)
}
defer func() {
_ = tx.Rollback()
}()
talkerHash := md5.Sum([]byte(talker))
tableName := "Msg_" + hex.EncodeToString(talkerHash[:])
var exists int
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&exists); err != nil {
if err == sql.ErrNoRows {
return nil
}
return errors.QueryFailed("check table", err)
}
if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS chatlog_msg_base(seq INTEGER PRIMARY KEY, content TEXT)`); err != nil {
return errors.QueryFailed("create temp table", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM chatlog_msg_base`); err != nil {
return errors.QueryFailed("cleanup temp table", err)
}
insertSQL := fmt.Sprintf(`INSERT OR IGNORE INTO chatlog_msg_base(seq, content)
SELECT sort_seq, CAST(message_content AS TEXT)
FROM %s
WHERE create_time >= ? AND create_time <= ?
AND message_content IS NOT NULL AND LENGTH(message_content) > 0`, tableName)
if _, err := tx.ExecContext(ctx, insertSQL, startUnix, endUnix); err != nil {
return errors.QueryFailed("insert temp base", err)
}
if _, err := tx.ExecContext(ctx, `CREATE VIRTUAL TABLE IF NOT EXISTS temp.chatlog_msg_fts USING fts5(content, tokenize='unicode61')`); err != nil {
return errors.QueryFailed("create fts", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM temp.chatlog_msg_fts`); err != nil {
return errors.QueryFailed("cleanup fts", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO temp.chatlog_msg_fts(rowid, content) SELECT seq, content FROM chatlog_msg_base`); err != nil {
return errors.QueryFailed("insert fts", err)
}
querySQL := fmt.Sprintf(`
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name,
m.create_time, m.message_content, m.packed_info_data, m.status,
highlight(chatlog_msg_fts, 0, '<mark>', '</mark>') AS snippet,
bm25(chatlog_msg_fts) AS score
FROM temp.chatlog_msg_fts
JOIN %s AS m ON m.sort_seq = chatlog_msg_fts.rowid
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
WHERE chatlog_msg_fts MATCH ?
ORDER BY score
LIMIT ?
`, tableName)
rows, err := tx.QueryContext(ctx, querySQL, ftsQuery, budget)
if err != nil {
return errors.QueryFailed("fts query", err)
}
defer rows.Close()
for rows.Next() {
var msg model.MessageV4
var messageContent []byte
var snippet sql.NullString
var score sql.NullFloat64
if err := rows.Scan(&msg.SortSeq, &msg.ServerID, &msg.LocalType, &msg.UserName,
&msg.CreateTime, &messageContent, &msg.PackedInfoData, &msg.Status, &snippet, &score); err != nil {
return errors.ScanRowFailed(err)
}
msg.MessageContent = messageContent
message := msg.Wrap(talker)
if senderFilter {
if _, ok := senderSet[message.Sender]; !ok {
continue
}
}
if _, dup := seenSeq[message.Seq]; dup {
continue
}
seenSeq[message.Seq] = struct{}{}
hitSnippet := ""
if snippet.Valid {
hitSnippet = snippet.String
}
hitScore := 0.0
if score.Valid {
hitScore = score.Float64
}
allHits = append(allHits, &model.SearchHit{Message: message, Snippet: hitSnippet, Score: hitScore})
}
if err := rows.Err(); err != nil {
return errors.QueryFailed("fts rows", err)
}
if err := tx.Commit(); err != nil {
return errors.QueryFailed("commit tx", err)
}
return nil
}()
query := fmt.Sprintf(`
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name,
m.create_time, m.message_content, m.packed_info_data, m.status
FROM %s AS m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
ORDER BY m.sort_seq ASC
`, tableName)
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
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.MessageV4
var messageContent []byte
if scanErr := rows.Scan(
&msg.SortSeq,
&msg.ServerID,
&msg.LocalType,
&msg.UserName,
&msg.CreateTime,
&messageContent,
&msg.PackedInfoData,
&msg.Status,
); scanErr != nil {
rows.Close()
return errors.ScanRowFailed(scanErr)
}
msg.MessageContent = messageContent
message := msg.Wrap(talker)
if err := handler(message); err != nil {
rows.Close()
return err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return errors.QueryFailed("iterate message rows", err)
}
rows.Close()
}
}
if len(allHits) == 0 {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
sort.SliceStable(allHits, func(i, j int) bool {
if allHits[i].Score == allHits[j].Score {
return allHits[i].Message.Seq < allHits[j].Message.Seq
}
return allHits[i].Score < allHits[j].Score
})
total := len(allHits)
if offset > total {
offset = total
}
endIndex := offset + limit
if endIndex > total {
endIndex = total
}
paged := allHits[offset:endIndex]
return &model.SearchResponse{
Total: total,
Hits: paged,
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
return nil
}
func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
@@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"math"
"regexp"
"sort"
"strings"
@@ -423,294 +422,6 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
return filteredMessages, nil
}
func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error) {
if req == nil {
return nil, errors.InvalidArg("request")
}
ftsQuery := util.BuildFTSQuery(req.Query)
if ftsQuery == "" {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: 0,
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
talkers := util.Str2List(req.Talker, ",")
if len(talkers) == 0 {
var err error
talkers, err = ds.collectAllTalkers(ctx)
if err != nil {
return nil, err
}
}
if len(talkers) == 0 {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: 0,
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
limit := req.Limit
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
offset := req.Offset
if offset < 0 {
offset = 0
}
req.Limit = limit
req.Offset = offset
budget := limit + offset + 20
if budget < 50 {
budget = 50
}
if budget > 1000 {
budget = 1000
}
startRange := req.Start
endRange := req.End
if startRange.IsZero() {
startRange = time.Unix(0, 0)
}
if endRange.IsZero() {
endRange = time.Now().Add(24 * time.Hour)
}
if endRange.Before(startRange) {
startRange, endRange = endRange, startRange
}
startSeq := int64(0)
if !req.Start.IsZero() {
startSeq = req.Start.Unix() * 1000
}
endSeq := int64(math.MaxInt64)
if !req.End.IsZero() {
endSeq = req.End.Unix() * 1000
}
if endSeq < startSeq {
startSeq, endSeq = endSeq, startSeq
}
senders := util.Str2List(req.Sender, ",")
senderFilter := len(senders) > 0
senderSet := make(map[string]struct{}, len(senders))
for _, s := range senders {
senderSet[s] = struct{}{}
}
startTs := time.Now()
allHits := make([]*model.SearchHit, 0, limit+offset)
seenSeq := make(map[int64]struct{})
dbInfos := ds.getDBInfosForTimeRange(startRange, endRange)
if len(dbInfos) == 0 {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
for _, dbInfo := range dbInfos {
if err := ctx.Err(); err != nil {
return nil, err
}
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
if err != nil {
log.Err(err).Msgf("数据库 %s 未打开", dbInfo.FilePath)
continue
}
for _, talker := range talkers {
if err := ctx.Err(); err != nil {
return nil, err
}
err := func() error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return errors.QueryFailed("begin tx", err)
}
defer func() {
_ = tx.Rollback()
}()
if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE IF NOT EXISTS chatlog_msg_base(seq INTEGER PRIMARY KEY, content TEXT)`); err != nil {
return errors.QueryFailed("create temp table", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM chatlog_msg_base`); err != nil {
return errors.QueryFailed("cleanup temp table", err)
}
conditions := []string{"Sequence >= ?", "Sequence <= ?", "StrContent IS NOT NULL", "LENGTH(StrContent) > 0"}
args := []interface{}{startSeq, endSeq}
if talkerID, ok := dbInfo.TalkerMap[talker]; ok {
conditions = append(conditions, "TalkerId = ?")
args = append(args, talkerID)
} else {
conditions = append(conditions, "StrTalker = ?")
args = append(args, talker)
}
insertSQL := fmt.Sprintf(`INSERT OR IGNORE INTO chatlog_msg_base(seq, content)
SELECT Sequence, StrContent FROM MSG WHERE %s`, strings.Join(conditions, " AND "))
if _, err := tx.ExecContext(ctx, insertSQL, args...); err != nil {
return errors.QueryFailed("insert temp base", err)
}
if _, err := tx.ExecContext(ctx, `CREATE VIRTUAL TABLE IF NOT EXISTS temp.chatlog_msg_fts USING fts5(content, tokenize='unicode61')`); err != nil {
return errors.QueryFailed("create fts", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM temp.chatlog_msg_fts`); err != nil {
return errors.QueryFailed("cleanup fts", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO temp.chatlog_msg_fts(rowid, content) SELECT seq, content FROM chatlog_msg_base`); err != nil {
return errors.QueryFailed("insert fts", err)
}
rows, err := tx.QueryContext(ctx, `
SELECT m.MsgSvrID, m.Sequence, m.CreateTime, m.StrTalker, m.IsSender,
m.Type, m.SubType, m.StrContent, m.CompressContent, m.BytesExtra,
highlight(chatlog_msg_fts, 0, '<mark>', '</mark>') AS snippet,
bm25(chatlog_msg_fts) AS score
FROM temp.chatlog_msg_fts
JOIN MSG m ON m.Sequence = chatlog_msg_fts.rowid
WHERE chatlog_msg_fts MATCH ?
ORDER BY score
LIMIT ?
`, ftsQuery, budget)
if err != nil {
return errors.QueryFailed("fts query", err)
}
defer rows.Close()
for rows.Next() {
var msg model.MessageV3
var compressContent []byte
var bytesExtra []byte
var snippet sql.NullString
var score sql.NullFloat64
if err := rows.Scan(&msg.MsgSvrID, &msg.Sequence, &msg.CreateTime, &msg.StrTalker, &msg.IsSender,
&msg.Type, &msg.SubType, &msg.StrContent, &compressContent, &bytesExtra, &snippet, &score); err != nil {
return errors.ScanRowFailed(err)
}
msg.CompressContent = compressContent
msg.BytesExtra = bytesExtra
message := msg.Wrap()
if senderFilter {
if _, ok := senderSet[message.Sender]; !ok {
continue
}
}
if _, dup := seenSeq[message.Seq]; dup {
continue
}
seenSeq[message.Seq] = struct{}{}
hitSnippet := ""
if snippet.Valid {
hitSnippet = snippet.String
}
hitScore := 0.0
if score.Valid {
hitScore = score.Float64
}
allHits = append(allHits, &model.SearchHit{Message: message, Snippet: hitSnippet, Score: hitScore})
}
if err := rows.Err(); err != nil {
return errors.QueryFailed("fts rows", err)
}
if err := tx.Commit(); err != nil {
return errors.QueryFailed("commit tx", err)
}
return nil
}()
if err != nil {
return nil, err
}
}
}
if len(allHits) == 0 {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
sort.SliceStable(allHits, func(i, j int) bool {
if allHits[i].Score == allHits[j].Score {
return allHits[i].Message.Seq < allHits[j].Message.Seq
}
return allHits[i].Score < allHits[j].Score
})
total := len(allHits)
if offset > total {
offset = total
}
endIndex := offset + limit
if endIndex > total {
endIndex = total
}
paged := allHits[offset:endIndex]
return &model.SearchResponse{
Total: total,
Hits: paged,
DurationMs: time.Since(startTs).Milliseconds(),
Limit: limit,
Offset: offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
}, nil
}
func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
return ds.dbm.FingerprintForGroups(Message)
}
@@ -822,6 +533,130 @@ func (ds *DataSource) collectAllTalkers(ctx context.Context) ([]string, error) {
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
+315
View File
@@ -0,0 +1,315 @@
package repository
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/indexer"
"github.com/sjzar/chatlog/pkg/util"
)
type bleveIndexable interface {
ListTalkers(ctx context.Context) ([]string, error)
IterateMessages(ctx context.Context, talkers []string, fn func(*model.Message) error) error
}
func (r *Repository) initIndex() error {
if r.indexPath == "" {
return nil
}
idx, err := indexer.Open(r.indexPath)
if err != nil {
return err
}
r.index = idx
r.indexCtx, r.indexCancel = context.WithCancel(context.Background())
go func() {
ready, err := r.ensureIndex(r.indexCtx)
if err != nil && !errors.Is(err, context.Canceled) {
log.Warn().Err(err).Msg("ensure bleve index failed")
return
}
if ready {
log.Info().Msg("bleve index ready")
}
}()
return nil
}
func (r *Repository) ensureIndex(ctx context.Context) (bool, error) {
if r.index == nil {
return false, nil
}
if ctx == nil {
ctx = context.Background()
}
versionMatched, err := r.index.EnsureVersion()
if err != nil {
return false, err
}
if !versionMatched {
r.indexMu.Lock()
r.indexStatus.Ready = false
r.indexStatus.Progress = 0
r.indexFingerprint = ""
r.indexMu.Unlock()
}
fp, err := r.ds.GetDatasetFingerprint(ctx)
if err != nil {
return false, err
}
if strings.TrimSpace(fp) == "" {
return false, fmt.Errorf("dataset fingerprint is empty")
}
r.indexMu.Lock()
if r.indexFingerprint == fp && r.indexStatus.Ready && !r.indexStatus.InProgress {
status := r.indexStatus
r.indexMu.Unlock()
if status.Progress < 1 {
r.updateIndexProgress(1)
}
return true, nil
}
if r.indexStatus.InProgress {
r.indexMu.Unlock()
return false, nil
}
r.indexStatus.InProgress = true
r.indexStatus.Ready = false
r.indexStatus.Progress = 0
r.indexStatus.LastStartedAt = time.Now()
r.indexStatus.LastError = ""
r.indexMu.Unlock()
err = r.rebuildIndex(ctx, fp)
if err != nil {
if err == context.Canceled || errors.Is(err, context.Canceled) {
r.indexMu.Lock()
r.indexStatus.InProgress = false
r.indexMu.Unlock()
return false, err
}
r.indexMu.Lock()
r.indexStatus.InProgress = false
r.indexStatus.LastError = err.Error()
r.indexMu.Unlock()
return false, err
}
r.indexMu.Lock()
r.indexFingerprint = fp
r.indexStatus.InProgress = false
r.indexStatus.Ready = true
r.indexStatus.Progress = 1
r.indexStatus.LastCompletedAt = time.Now()
r.indexMu.Unlock()
return true, nil
}
func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
indexable, ok := r.ds.(bleveIndexable)
if !ok {
return fmt.Errorf("datasource does not support bleve indexing")
}
if err := r.index.Reset(); err != nil {
return err
}
if _, err := r.index.EnsureVersion(); err != nil {
return err
}
talkers, err := indexable.ListTalkers(ctx)
if err != nil {
return err
}
if len(talkers) == 0 {
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
return r.index.UpdateLastBuilt(time.Now())
}
sort.Strings(talkers)
batch := make([]*model.Message, 0, 512)
flush := func() error {
if len(batch) == 0 {
return nil
}
if err := r.index.IndexMessages(batch); err != nil {
return err
}
batch = batch[:0]
return nil
}
total := float64(len(talkers))
for i, talker := range talkers {
if err := ctx.Err(); err != nil {
return err
}
handler := func(msg *model.Message) error {
if msg == nil {
return nil
}
batch = append(batch, msg)
if len(batch) >= cap(batch) {
return flush()
}
return nil
}
if err := indexable.IterateMessages(ctx, []string{talker}, handler); err != nil {
return err
}
if err := flush(); err != nil {
return err
}
r.updateIndexProgress(float64(i+1) / total)
}
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
if err := r.index.UpdateLastBuilt(time.Now()); err != nil {
return err
}
return nil
}
func (r *Repository) updateIndexProgress(progress float64) {
if r.index == nil {
return
}
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
r.indexMu.Lock()
r.indexStatus.Progress = progress
r.indexMu.Unlock()
}
func (r *Repository) indexStatusSnapshot() *model.SearchIndexStatus {
if r.index == nil {
return nil
}
r.indexMu.Lock()
status := r.indexStatus
r.indexMu.Unlock()
copied := status
return &copied
}
func (r *Repository) searchMessagesWithIndex(ctx context.Context, req *model.SearchRequest) (*model.SearchResponse, error) {
makeEmpty := func() *model.SearchResponse {
return &model.SearchResponse{
Total: 0,
Hits: []*model.SearchHit{},
DurationMs: 0,
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
Index: r.indexStatusSnapshot(),
}
}
if strings.TrimSpace(req.Query) == "" {
return makeEmpty(), nil
}
if r.index == nil {
return makeEmpty(), nil
}
ready, err := r.ensureIndex(ctx)
if err != nil {
return nil, err
}
if !ready {
return makeEmpty(), nil
}
talkers := util.Str2List(req.Talker, ",")
senders := util.Str2List(req.Sender, ",")
startUnix := int64(0)
if !req.Start.IsZero() {
startUnix = req.Start.Unix()
}
endUnix := int64(0)
if !req.End.IsZero() {
endUnix = req.End.Unix()
}
if startUnix > 0 && endUnix > 0 && endUnix < startUnix {
startUnix, endUnix = endUnix, startUnix
}
begin := time.Now()
hits, total, err := r.index.Search(req, talkers, senders, startUnix, endUnix, req.Offset, req.Limit)
if err != nil {
return nil, err
}
mapped := make([]*model.SearchHit, 0, len(hits))
for _, hit := range hits {
if hit == nil || hit.Message == nil {
continue
}
mapped = append(mapped, &model.SearchHit{
Message: hit.Message,
Snippet: hit.Snippet,
Score: hit.Score,
})
}
resp := &model.SearchResponse{
Total: total,
Hits: mapped,
DurationMs: time.Since(begin).Milliseconds(),
Limit: req.Limit,
Offset: req.Offset,
Query: req.Query,
Talker: req.Talker,
Sender: req.Sender,
Start: req.Start,
End: req.End,
Index: r.indexStatusSnapshot(),
}
return resp, nil
}
+34 -2
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
@@ -9,12 +10,22 @@ import (
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource"
"github.com/sjzar/chatlog/internal/wechatdb/indexer"
)
// Repository 实现了 repository.Repository 接口
type Repository struct {
ds datasource.DataSource
indexPath string
index *indexer.Index
indexMu sync.Mutex
indexStatus model.SearchIndexStatus
indexFingerprint string
indexBuildMu sync.Mutex
indexCtx context.Context
indexCancel context.CancelFunc
// Cache for contact
contactCache map[string]*model.Contact
aliasToContact map[string][]*model.Contact
@@ -39,9 +50,10 @@ type Repository struct {
}
// New 创建一个新的 Repository
func New(ds datasource.DataSource) (*Repository, error) {
func New(ds datasource.DataSource, indexPath string) (*Repository, error) {
r := &Repository{
ds: ds,
indexPath: indexPath,
contactCache: make(map[string]*model.Contact),
aliasToContact: make(map[string][]*model.Contact),
remarkToContact: make(map[string][]*model.Contact),
@@ -67,6 +79,10 @@ func New(ds datasource.DataSource) (*Repository, error) {
ds.SetCallback("contact", r.contactCallback)
ds.SetCallback("chatroom", r.chatroomCallback)
if err := r.initIndex(); err != nil {
log.Warn().Err(err).Msg("init bleve index failed")
}
return r, nil
}
@@ -107,7 +123,23 @@ func (r *Repository) chatroomCallback(event fsnotify.Event) error {
// Close 实现 Repository 接口的 Close 方法
func (r *Repository) Close() error {
return r.ds.Close()
if r.indexCancel != nil {
r.indexCancel()
r.indexCancel = nil
}
var firstErr error
if r.index != nil {
if err := r.index.Close(); err != nil {
firstErr = err
}
r.index = nil
}
if err := r.ds.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// GetAvatar proxies to datasource
+12 -2
View File
@@ -25,12 +25,22 @@ func (r *Repository) SearchMessages(ctx context.Context, req *model.SearchReques
nReq.Talker = normalizedTalker
nReq.Sender = normalizedSender
resp, err := r.ds.SearchMessages(ctx, nReq)
if nReq.Limit <= 0 {
nReq.Limit = 20
}
if nReq.Limit > 200 {
nReq.Limit = 200
}
if nReq.Offset < 0 {
nReq.Offset = 0
}
resp, err := r.searchMessagesWithIndex(ctx, nReq)
if err != nil {
return nil, err
}
if resp == nil {
return &model.SearchResponse{Hits: []*model.SearchHit{}, Limit: nReq.Limit, Offset: nReq.Offset}, nil
resp = &model.SearchResponse{Hits: []*model.SearchHit{}, Limit: nReq.Limit, Offset: nReq.Offset}
}
// Enrich message metadata(头像、群昵称、显示名等)
+3 -1
View File
@@ -2,6 +2,7 @@ package wechatdb
import (
"context"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
@@ -50,7 +51,8 @@ func (w *DB) Initialize() error {
return err
}
w.repo, err = repository.New(w.ds)
indexPath := filepath.Join(w.path, "indexes", "messages")
w.repo, err = repository.New(w.ds, indexPath)
if err != nil {
return err
}