!refactor(search): Use Bleve in replace of SQLite FTS5

This commit is contained in:
Symb0x76
2025-10-09 20:09:07 +08:00
parent 17e63d1af5
commit 687ee63aa6
13 changed files with 1350 additions and 118 deletions
+25
View File
@@ -4,6 +4,7 @@ go 1.24.0
require (
github.com/Eyevinn/mp4ff v0.49.0
github.com/blevesearch/bleve/v2 v2.4.2
github.com/cespare/xxhash v1.1.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gdamore/tcell/v2 v2.8.1
@@ -29,7 +30,26 @@ require (
)
require (
github.com/RoaringBitmap/roaring v1.9.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bits-and-blooms/bitset v1.12.0 // indirect
github.com/blevesearch/bleve_index_api v1.1.10 // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.20 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.2.15 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.0.10 // indirect
github.com/blevesearch/zapx/v11 v11.3.10 // indirect
github.com/blevesearch/zapx/v12 v12.3.10 // indirect
github.com/blevesearch/zapx/v13 v13.3.10 // indirect
github.com/blevesearch/zapx/v14 v14.3.10 // indirect
github.com/blevesearch/zapx/v15 v15.3.13 // indirect
github.com/blevesearch/zapx/v16 v16.1.5 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
@@ -44,6 +64,9 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -57,6 +80,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -73,6 +97,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.7 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/term v0.34.0 // indirect
+53
View File
@@ -2,8 +2,48 @@ github.com/Eyevinn/mp4ff v0.49.0 h1:00eRg5/KwcLGWUbv+hlifldf74qg46G1IxoWXHrgDvw=
github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM=
github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.4.2 h1:NooYP1mb3c0StkiY9/xviiq2LGSaE8BQBCc/pirMx0U=
github.com/blevesearch/bleve/v2 v2.4.2/go.mod h1:ATNKj7Yl2oJv/lGuF4kx39bST2dveX6w0th2FFYLkc8=
github.com/blevesearch/bleve_index_api v1.1.10 h1:PDLFhVjrjQWr6jCuU7TwlmByQVCSEURADHdCqVS9+g0=
github.com/blevesearch/bleve_index_api v1.1.10/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.20 h1:AIkdTQFWuZ5LQmKQSebgMR4RynGNw8ZseJXaan5kvtI=
github.com/blevesearch/go-faiss v1.0.20/go.mod h1:jrxHrbl42X/RnDPI+wBoZU8joxxuRwedrxqswQ3xfU8=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.2.15 h1:prV17iU/o+A8FiZi9MXmqbagd8I0bCqM7OKUYPbnb5Y=
github.com/blevesearch/scorch_segment_api/v2 v2.2.15/go.mod h1:db0cmP03bPNadXrCDuVkKLV6ywFSiRgPFT1YVrestBc=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.0.10 h1:HGPJDT2bTva12hrHepVT3rOyIKFFF4t7Gf6yMxyMIPI=
github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k=
github.com/blevesearch/zapx/v11 v11.3.10 h1:hvjgj9tZ9DeIqBCxKhi70TtSZYMdcFn7gDb71Xo/fvk=
github.com/blevesearch/zapx/v11 v11.3.10/go.mod h1:0+gW+FaE48fNxoVtMY5ugtNHHof/PxCqh7CnhYdnMzQ=
github.com/blevesearch/zapx/v12 v12.3.10 h1:yHfj3vXLSYmmsBleJFROXuO08mS3L1qDCdDK81jDl8s=
github.com/blevesearch/zapx/v12 v12.3.10/go.mod h1:0yeZg6JhaGxITlsS5co73aqPtM04+ycnI6D1v0mhbCs=
github.com/blevesearch/zapx/v13 v13.3.10 h1:0KY9tuxg06rXxOZHg3DwPJBjniSlqEgVpxIqMGahDE8=
github.com/blevesearch/zapx/v13 v13.3.10/go.mod h1:w2wjSDQ/WBVeEIvP0fvMJZAzDwqwIEzVPnCPrz93yAk=
github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU=
github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
github.com/blevesearch/zapx/v15 v15.3.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wyhnpDHHQ=
github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
github.com/blevesearch/zapx/v16 v16.1.5 h1:b0sMcarqNFxuXvjoXsF8WtwVahnxyhEvBSRJi/AUHjU=
github.com/blevesearch/zapx/v16 v16.1.5/go.mod h1:J4mSF39w1QELc11EWRSBFkPeZuO7r/NPKkHzDCoiaI8=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
@@ -53,6 +93,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -103,6 +150,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
@@ -176,6 +225,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -255,6 +306,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+236 -10
View File
@@ -722,6 +722,7 @@ func (s *Service) handleSearch(c *gin.Context) {
End string `form:"end"`
Limit int `form:"limit"`
Offset int `form:"offset"`
Format string `form:"format"`
}{}
if err := c.BindQuery(&params); err != nil {
@@ -730,16 +731,8 @@ func (s *Service) handleSearch(c *gin.Context) {
}
query := strings.TrimSpace(params.Query)
if query == "" {
errors.Err(c, errors.InvalidArg("q"))
return
}
talker := strings.TrimSpace(params.Talker)
if talker == "" {
errors.Err(c, errors.ErrTalkerEmpty)
return
}
limit := params.Limit
if limit <= 0 {
@@ -818,7 +811,163 @@ func (s *Service) handleSearch(c *gin.Context) {
resp.Limit = limit
resp.Offset = offset
c.JSON(http.StatusOK, resp)
format := strings.ToLower(strings.TrimSpace(params.Format))
terms := extractQueryTerms(resp.Query)
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString(`<html><head><meta charset="utf-8"><title>Search Result</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;background:#f8f9fb;padding:24px;color:#2c3e50;}h1{margin:0 0 16px;font-size:22px;}h2{margin:24px 0 12px;font-size:18px;}p.meta{margin:4px 0;color:#5f6c7b;} .summary{background:#fff;padding:18px;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);margin-bottom:18px;} .result-list{display:flex;flex-direction:column;gap:16px;} .hit{background:#fff;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(18,38,63,0.08);} .hit-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px;color:#5f6c7b;} .hit-header .talker{font-weight:600;color:#2c3e50;} .hit-header .score{font-family:monospace;color:#a0aec0;} .snippet{margin-bottom:10px;font-size:13px;color:#4a5568;padding:10px;border-left:3px solid #3498db;background:rgba(52,152,219,0.08);border-radius:6px;} .snippet code{background:rgba(27,31,35,0.05);padding:2px 4px;border-radius:4px;} .msg-content pre{white-space:pre-wrap;word-break:break-word;margin:0;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:13px;color:#1f2933;} .meta-row{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:8px;font-size:13px;color:#5f6c7b;} .meta-row span{display:inline-flex;align-items:center;gap:4px;} .search-highlight{background:#fff3b0;color:inherit;padding:0 2px;border-radius:3px;} .empty{padding:28px;text-align:center;color:#768390;background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);} a.media{color:#2c3e50;text-decoration:none;border-bottom:1px dashed #2c3e50;} a.media:hover{color:#0f4c81;} </style></head><body>`)
c.Writer.WriteString("<div class=\"summary\">")
c.Writer.WriteString("<h1>搜索结果</h1>")
if resp.Query != "" {
c.Writer.WriteString("<p class=\"meta\"><strong>关键词:</strong>" + template.HTMLEscapeString(resp.Query) + "</p>")
}
talkerLabel := "全部会话"
if resp.Talker != "" {
talkerLabel = template.HTMLEscapeString(resp.Talker)
}
c.Writer.WriteString("<p class=\"meta\"><strong>会话:</strong>" + talkerLabel + "</p>")
if resp.Sender != "" {
c.Writer.WriteString("<p class=\"meta\"><strong>发送者:</strong>" + template.HTMLEscapeString(resp.Sender) + "</p>")
}
timeLabel := "不限"
if !resp.Start.IsZero() && !resp.End.IsZero() {
timeLabel = resp.Start.Format("2006-01-02 15:04:05") + " ~ " + resp.End.Format("2006-01-02 15:04:05")
} else if !resp.Start.IsZero() {
timeLabel = ">= " + resp.Start.Format("2006-01-02 15:04:05")
} else if !resp.End.IsZero() {
timeLabel = "<= " + resp.End.Format("2006-01-02 15:04:05")
}
c.Writer.WriteString("<p class=\"meta\"><strong>时间范围:</strong>" + template.HTMLEscapeString(timeLabel) + "</p>")
c.Writer.WriteString(fmt.Sprintf("<p class=\"meta\"><strong>命中条数:</strong>%d(本页 %d 条)</p>", resp.Total, len(resp.Hits)))
c.Writer.WriteString("</div>")
if len(resp.Hits) == 0 {
c.Writer.WriteString("<div class=\"empty\">暂无搜索结果</div>")
} else {
c.Writer.WriteString("<div class=\"result-list\">")
for idx, hit := range resp.Hits {
if hit == nil || hit.Message == nil {
continue
}
msg := hit.Message
msg.SetContent("host", c.Request.Host)
talkerDisplay := msg.Talker
if msg.TalkerName != "" {
talkerDisplay = fmt.Sprintf("%s (%s)", msg.TalkerName, msg.Talker)
}
senderDisplay := msg.Sender
if msg.IsSelf {
senderDisplay = "我"
}
if msg.SenderName != "" {
senderDisplay = fmt.Sprintf("%s(%s)", msg.SenderName, msg.Sender)
}
c.Writer.WriteString("<div class=\"hit\">")
c.Writer.WriteString("<div class=\"hit-header\"><span class=\"talker\">#" + fmt.Sprintf("%d", idx+1) + " · " + template.HTMLEscapeString(talkerDisplay) + "</span>")
if hit.Score > 0 {
c.Writer.WriteString("<span class=\"score\">score: " + fmt.Sprintf("%.4f", hit.Score) + "</span>")
}
c.Writer.WriteString("</div>")
c.Writer.WriteString("<div class=\"meta-row\"><span>时间:" + template.HTMLEscapeString(msg.Time.Format("2006-01-02 15:04:05")) + "</span><span>发送者:" + template.HTMLEscapeString(senderDisplay) + "</span></div>")
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {
h := highlightPlainTextToHTML(snippet, terms)
h = strings.ReplaceAll(h, "\n", "<br/>")
c.Writer.WriteString("<div class=\"snippet\">" + h + "</div>")
}
c.Writer.WriteString("<div class=\"msg-content\"><pre>" + messageHTMLPlaceholder(msg) + "</pre></div>")
c.Writer.WriteString("</div>")
}
c.Writer.WriteString("</div>")
}
c.Writer.WriteString(previewHTMLSnippet)
c.Writer.WriteString("</body></html>")
return
case "text", "plain":
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
fmt.Fprintf(c.Writer, "关键词: %s\n", resp.Query)
talkerLabel := resp.Talker
if talkerLabel == "" {
talkerLabel = "全部会话"
}
fmt.Fprintf(c.Writer, "会话: %s\n", talkerLabel)
if resp.Sender != "" {
fmt.Fprintf(c.Writer, "发送者: %s\n", resp.Sender)
}
switch {
case !resp.Start.IsZero() && !resp.End.IsZero():
fmt.Fprintf(c.Writer, "时间: %s ~ %s\n", resp.Start.Format("2006-01-02 15:04:05"), resp.End.Format("2006-01-02 15:04:05"))
case !resp.Start.IsZero():
fmt.Fprintf(c.Writer, "时间: >= %s\n", resp.Start.Format("2006-01-02 15:04:05"))
case !resp.End.IsZero():
fmt.Fprintf(c.Writer, "时间: <= %s\n", resp.End.Format("2006-01-02 15:04:05"))
default:
fmt.Fprintln(c.Writer, "时间: 不限")
}
fmt.Fprintf(c.Writer, "总命中: %d, 本页: %d\n", resp.Total, len(resp.Hits))
fmt.Fprintln(c.Writer, strings.Repeat("-", 60))
for idx, hit := range resp.Hits {
if hit == nil || hit.Message == nil {
continue
}
msg := hit.Message
msg.SetContent("host", c.Request.Host)
title := msg.Talker
if msg.TalkerName != "" {
title = fmt.Sprintf("%s (%s)", msg.TalkerName, msg.Talker)
}
sender := msg.Sender
if msg.IsSelf {
sender = "我"
}
if msg.SenderName != "" {
sender = fmt.Sprintf("%s(%s)", msg.SenderName, msg.Sender)
}
fmt.Fprintf(c.Writer, "[%d] %s @ %s\n", idx+1, msg.Time.Format("2006-01-02 15:04:05"), title)
fmt.Fprintf(c.Writer, "发送者: %s\n", sender)
fmt.Fprintf(c.Writer, "%s\n", msg.PlainTextContent())
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {
fmt.Fprintf(c.Writer, "Snippet: %s\n", snippet)
}
fmt.Fprintln(c.Writer, strings.Repeat("-", 60))
}
return
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=search_%s.csv", time.Now().Format("20060102_150405")))
csvWriter := csv.NewWriter(c.Writer)
csvWriter.Write([]string{"Seq", "Time", "Talker", "TalkerName", "Sender", "SenderName", "Content", "Snippet"})
for _, hit := range resp.Hits {
if hit == nil || hit.Message == nil {
continue
}
msg := hit.Message
msg.SetContent("host", c.Request.Host)
csvWriter.Write([]string{
fmt.Sprintf("%d", msg.Seq),
msg.Time.Format("2006-01-02 15:04:05"),
msg.Talker,
msg.TalkerName,
msg.Sender,
msg.SenderName,
msg.PlainTextContent(),
strings.ReplaceAll(hit.Snippet, "\n", " "),
})
}
csvWriter.Flush()
return
case "json", "":
c.JSON(http.StatusOK, resp)
return
default:
c.JSON(http.StatusOK, resp)
return
}
}
func (s *Service) handleChatlog(c *gin.Context) {
@@ -1501,7 +1650,10 @@ func (s *Service) HandleVoice(c *gin.Context, data []byte) {
}
// 统一占位符:将 PlainTextContent 里形如 ![标签](url) 或 [标签](url) 的模式转成超链接形式,仅显示 [标签]。
var placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
var (
placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
searchTermSplitPattern = regexp.MustCompile(`[\s,,;;、]+`)
)
func messageHTMLPlaceholder(m *model.Message) string {
content := m.PlainTextContent()
@@ -1536,3 +1688,77 @@ func messageHTMLPlaceholder(m *model.Message) string {
return `<a class="` + className + `" href="` + template.HTMLEscapeString(url) + `" target="_blank">` + anchorText + `</a>`
})
}
func extractQueryTerms(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := searchTermSplitPattern.Split(raw, -1)
terms := make([]string, 0, len(parts))
seen := make(map[string]struct{})
for _, part := range parts {
term := strings.Trim(part, "\"'()[]{}")
if term == "" {
continue
}
switch strings.ToUpper(term) {
case "AND", "OR", "NOT":
continue
}
key := strings.ToLower(term)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
terms = append(terms, term)
}
return terms
}
func highlightPlainTextToHTML(text string, terms []string) string {
if text == "" {
return ""
}
if len(terms) == 0 {
return template.HTMLEscapeString(text)
}
unique := make(map[string]struct{})
escaped := make([]string, 0, len(terms))
for _, term := range terms {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := unique[key]; ok {
continue
}
unique[key] = struct{}{}
escaped = append(escaped, regexp.QuoteMeta(trimmed))
}
if len(escaped) == 0 {
return template.HTMLEscapeString(text)
}
sort.Slice(escaped, func(i, j int) bool { return len(escaped[i]) > len(escaped[j]) })
pattern := strings.Join(escaped, "|")
re, err := regexp.Compile("(?i)" + pattern)
if err != nil {
return template.HTMLEscapeString(text)
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return template.HTMLEscapeString(text)
}
var builder strings.Builder
last := 0
for _, loc := range matches {
builder.WriteString(template.HTMLEscapeString(text[last:loc[0]]))
builder.WriteString(`<mark class="search-highlight">`)
builder.WriteString(template.HTMLEscapeString(text[loc[0]:loc[1]]))
builder.WriteString("</mark>")
last = loc[1]
}
builder.WriteString(template.HTMLEscapeString(text[last:]))
return builder.String()
}
+289 -91
View File
@@ -190,6 +190,15 @@
position: relative;
}
.result-block {
margin: 0;
white-space: pre-wrap;
font-family: "SFMono-Regular", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 14px;
line-height: 1.6;
}
.request-url {
background-color: #f0f0f0;
padding: 10px;
@@ -345,6 +354,13 @@
font-style: italic;
}
.search-highlight {
background-color: #fff3b0;
color: inherit;
padding: 0 2px;
border-radius: 3px;
}
.required-field {
color: var(--error-color);
font-weight: bold;
@@ -636,24 +652,27 @@
>必填</span
></label
>
<input id="search-query" type="text"
placeholder="支持空格分词,利用 AND/OR/\"
组合高级语法" />
<input
id="search-query"
type="text"
placeholder="支持空格分词,可使用 AND/OR/NOT 等语法"
/>
</div>
<div class="form-group">
<label for="search-talker"
>会话 ID(可多个,英文逗号分隔):<span
class="required-field"
>必填</span
class="optional-param"
>可选</span
></label
>
<input
id="search-talker"
type="text"
placeholder="例如:wxid_xxx@chatroom"
placeholder="留空默认搜索所有联系人与群聊"
/>
<div class="form-hint">
可以从“最近会话”或“群聊”接口获取对应的 ID。
可以从“最近会话”或“群聊”接口获取对应的
ID;如留空将检索所有会话。
</div>
</div>
<div class="form-group">
@@ -669,18 +688,18 @@
/>
</div>
<div class="form-group">
<label for="search-time"
<label for="search-start-date"
>时间范围:<span class="optional-param"
>可选</span
></label
>
<input
id="search-time"
type="text"
placeholder="支持 2024-01-01~2024-01-31、last-30d 等格式"
/>
<div class="date-range">
<input type="date" id="search-start-date" />
<span class="date-separator">至</span>
<input type="date" id="search-end-date" />
</div>
<div class="form-hint">
留空表示搜索全量消息;支持与聊天记录接口相同的时间表达式。
可选择开始日期和结束日期;留空表示不限制时间范围。
</div>
</div>
<div class="form-group">
@@ -710,6 +729,20 @@
placeholder="默认 0"
/>
</div>
<div class="form-group">
<label for="search-format"
>输出格式:<span class="optional-param"
>可选</span
></label
>
<select id="search-format">
<option value="">默认</option>
<option value="json">JSON</option>
<option value="text">纯文本</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
</select>
</div>
</div>
<button id="test-api">执行查询</button>
@@ -833,28 +866,32 @@
errorMessage.style.display = "none";
errorMessage.textContent = "";
let highlightTerms = [];
let responseFormat = "";
try {
// 获取当前活动的标签
const activeTab = document
.querySelector(".tab.active")
.getAttribute("data-tab");
let url = "/api/v1/";
let params = new URLSearchParams();
const params = new URLSearchParams();
// 根据不同的标签构建不同的请求
switch (activeTab) {
case "chatlog":
case "chatlog": {
url += "chatlog";
const startDate =
document.getElementById("start-date").value;
const endDate =
document.getElementById("end-date").value;
const talker =
document.getElementById("talker").value;
const sender =
document.getElementById("sender").value;
const keyword =
document.getElementById("keyword").value;
const talker = document
.getElementById("talker")
.value.trim();
const sender = document
.getElementById("sender")
.value.trim();
const keyword = document
.getElementById("keyword")
.value.trim();
const limit =
document.getElementById("limit").value;
const offset =
@@ -862,7 +899,6 @@
const format =
document.getElementById("format").value;
// 验证必填项:开始日期
if (!startDate && !endDate) {
errorMessage.textContent =
"错误: 请至少选择开始日期!";
@@ -878,7 +914,6 @@
}
let timeValue = "";
if (startDate && endDate) {
if (
new Date(startDate) > new Date(endDate)
@@ -901,70 +936,85 @@
}
params.append("time", timeValue);
// talker 可为空,留空表示全部按会话分组
if (talker) params.append("talker", talker);
if (sender) params.append("sender", sender);
if (keyword) params.append("keyword", keyword);
if (keyword) {
params.append("keyword", keyword);
highlightTerms =
extractSearchTerms(keyword);
}
if (limit) params.append("limit", limit);
if (offset) params.append("offset", offset);
if (format) params.append("format", format);
if (format) {
params.append("format", format);
responseFormat = format;
}
break;
case "contact":
}
case "contact": {
url += "contact";
const contactKeyword =
document.getElementById(
"contact-keyword"
).value;
const contactKeyword = document
.getElementById("contact-keyword")
.value.trim();
const contactFormat =
document.getElementById(
"contact-format"
).value;
if (contactKeyword)
if (contactKeyword) {
params.append("keyword", contactKeyword);
if (contactFormat)
highlightTerms =
extractSearchTerms(contactKeyword);
}
if (contactFormat) {
params.append("format", contactFormat);
responseFormat = contactFormat;
}
break;
case "chatroom":
}
case "chatroom": {
url += "chatroom";
const chatroomKeyword =
document.getElementById(
"chatroom-keyword"
).value;
const chatroomKeyword = document
.getElementById("chatroom-keyword")
.value.trim();
const chatroomFormat =
document.getElementById(
"chatroom-format"
).value;
if (chatroomKeyword)
if (chatroomKeyword) {
params.append("keyword", chatroomKeyword);
if (chatroomFormat)
highlightTerms =
extractSearchTerms(chatroomKeyword);
}
if (chatroomFormat) {
params.append("format", chatroomFormat);
responseFormat = chatroomFormat;
}
break;
case "session":
}
case "session": {
url += "session";
const sessionFormat =
document.getElementById(
"session-format"
).value;
if (sessionFormat)
if (sessionFormat) {
params.append("format", sessionFormat);
responseFormat = sessionFormat;
}
break;
case "diary":
}
case "diary": {
url += "diary";
const diaryDateInput =
document.getElementById("diary-date");
const diaryDate = diaryDateInput
? diaryDateInput.value
: "";
const diaryTalker =
document.getElementById(
"diary-talker"
).value;
const diaryTalker = document
.getElementById("diary-talker")
.value.trim();
const diaryFormat =
document.getElementById(
"diary-format"
@@ -978,12 +1028,18 @@
}
params.append("date", diaryDate);
if (diaryTalker)
if (diaryTalker) {
params.append("talker", diaryTalker);
if (diaryFormat)
highlightTerms =
extractSearchTerms(diaryTalker);
}
if (diaryFormat) {
params.append("format", diaryFormat);
responseFormat = diaryFormat;
}
break;
case "search":
}
case "search": {
url += "search";
const searchQuery =
document.getElementById(
@@ -997,9 +1053,13 @@
document.getElementById(
"search-sender"
).value;
const searchTime =
const searchStartDate =
document.getElementById(
"search-time"
"search-start-date"
).value;
const searchEndDate =
document.getElementById(
"search-end-date"
).value;
const searchLimit =
document.getElementById(
@@ -1009,6 +1069,11 @@
document.getElementById(
"search-offset"
).value;
const searchFormatSelect =
document.getElementById("search-format");
const searchFormatValue = searchFormatSelect
? searchFormatSelect.value
: "";
if (!searchQuery.trim()) {
errorMessage.textContent =
@@ -1016,74 +1081,101 @@
errorMessage.style.display = "block";
return;
}
if (!searchTalker.trim()) {
const trimmedQuery = searchQuery.trim();
highlightTerms =
extractSearchTerms(trimmedQuery);
params.append("q", trimmedQuery);
const trimmedTalker = searchTalker.trim();
if (trimmedTalker) {
params.append("talker", trimmedTalker);
}
let searchTimeValue = "";
if (searchStartDate && searchEndDate) {
if (
new Date(searchStartDate) >
new Date(searchEndDate)
) {
errorMessage.textContent =
"错误: 结束日期不能早于开始日期!";
errorMessage.style.display = "block";
return;
}
searchTimeValue = `${searchStartDate}~${searchEndDate}`;
} else if (searchStartDate) {
searchTimeValue = searchStartDate;
} else if (!searchStartDate && searchEndDate) {
errorMessage.textContent =
"错误: 请输入会话 ID!";
"错误: 请先选择开始日期!";
errorMessage.style.display = "block";
return;
}
params.append("q", searchQuery.trim());
params.append("talker", searchTalker.trim());
if (searchSender)
params.append(
"sender",
searchSender.trim()
);
if (searchTime)
params.append("time", searchTime.trim());
if (searchTimeValue)
params.append("time", searchTimeValue);
if (searchLimit)
params.append("limit", searchLimit);
if (searchOffset)
params.append("offset", searchOffset);
if (searchFormatValue) {
params.append("format", searchFormatValue);
responseFormat = searchFormatValue;
}
break;
}
}
// 添加参数到URL
const apiUrl = params.toString()
? `${url}?${params.toString()}`
: url;
// 获取完整URL(包含域名部分)
const fullUrl = window.location.origin + apiUrl;
// 显示完整请求URL
requestUrlContainer.textContent = fullUrl;
resultWrapper.style.display = "block";
// 显示加载中
resultContainer.innerHTML =
'<div class="loading">加载中</div>';
// 发送请求
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(
`HTTP error! Status: ${response.status}`
);
}
// 获取响应内容类型
const contentType =
response.headers.get("content-type");
let result;
if (
contentType &&
contentType.includes("application/json")
) {
// 如果是JSON,格式化显示
result = await response.json();
resultContainer.innerHTML = JSON.stringify(
result,
null,
2
response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const data = await response.json();
const text = JSON.stringify(data, null, 2);
const highlighted = highlightPlainText(
text,
highlightTerms
);
resultContainer.innerHTML = `<pre class="result-block">${highlighted}</pre>`;
} else {
// 其他格式直接显示文本
result = await response.text();
resultContainer.innerHTML = result;
const textResult = await response.text();
const expectsHtml =
(responseFormat || "").toLowerCase() ===
"html" || contentType.includes("text/html");
if (expectsHtml) {
resultContainer.innerHTML = textResult;
highlightHtmlContent(
resultContainer,
highlightTerms
);
} else {
const highlighted = highlightPlainText(
textResult,
highlightTerms
);
resultContainer.innerHTML = `<pre class="result-block">${highlighted}</pre>`;
}
}
} catch (error) {
resultContainer.innerHTML = "";
@@ -1093,6 +1185,112 @@
}
});
function escapeHtml(str) {
if (str == null) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function extractSearchTerms(raw) {
if (!raw) return [];
return Array.from(
new Set(
raw
.split(/[\s,,;;、]+/)
.map((term) => term.trim())
.filter(Boolean)
)
);
}
function buildHighlightPattern(terms) {
if (!terms || !terms.length) return "";
const escaped = Array.from(
new Set(
terms
.map((term) =>
term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
)
.filter(Boolean)
)
).sort((a, b) => b.length - a.length);
if (!escaped.length) return "";
return `(${escaped.join("|")})`;
}
function highlightPlainText(text, terms) {
const pattern = buildHighlightPattern(terms);
if (!pattern) {
return escapeHtml(text);
}
const regex = new RegExp(pattern, "gi");
let lastIndex = 0;
let highlighted = "";
text.replace(regex, (match, _group, offset) => {
highlighted += escapeHtml(text.slice(lastIndex, offset));
highlighted += `<mark class="search-highlight">${escapeHtml(
match
)}</mark>`;
lastIndex = offset + match.length;
return match;
});
highlighted += escapeHtml(text.slice(lastIndex));
return highlighted;
}
function highlightHtmlContent(container, terms) {
const pattern = buildHighlightPattern(terms);
if (!pattern) return;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
const nodes = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (!node || !node.nodeValue || !node.nodeValue.trim())
continue;
nodes.push(node);
}
nodes.forEach((textNode) => {
const text = textNode.nodeValue;
const regex = new RegExp(pattern, "gi");
if (!regex.test(text)) {
return;
}
regex.lastIndex = 0;
const fragment = document.createDocumentFragment();
let lastIndex = 0;
text.replace(regex, (match, _group, offset) => {
if (offset > lastIndex) {
fragment.appendChild(
document.createTextNode(
text.slice(lastIndex, offset)
)
);
}
const mark = document.createElement("mark");
mark.className = "search-highlight";
mark.textContent = match;
fragment.appendChild(mark);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(text.slice(lastIndex))
);
}
textNode.parentNode.replaceChild(fragment, textNode);
});
}
const diaryDateInput = document.getElementById("diary-date");
if (diaryDateInput) {
const today = new Date();
+1 -2
View File
@@ -98,8 +98,7 @@ func NewGroup(ctx context.Context, group string, hooks []Webhook, delayMs int64)
}
func (g *Group) Callback(event fsnotify.Event) error {
// skip remove event
if !event.Op.Has(fsnotify.Create) {
if !(event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Rename)) {
return nil
}
+22 -11
View File
@@ -6,7 +6,7 @@ import "time"
// Start 和 End 为闭区间;如未提供则由调用方决定默认范围
// Limit/Offset 由调用链路在进入数据源前进行裁剪
// Sender 使用英文逗号分隔多个筛选条件
// Talker 目前要求调用方传入具体会话(可多选,英文逗号分隔)
// Talker 可选:留空时后端会遍历所有会话;如需限定多个会话,使用英文逗号分隔
type SearchRequest struct {
Query string `json:"query"`
Talker string `json:"talker"`
@@ -39,14 +39,25 @@ type SearchHit struct {
// Limit / Offset 为实际生效的分页参数
// Hits 序列按相关度排序,命中数可能小于 limit(例如过滤后不足)
type SearchResponse struct {
Total int `json:"total"`
Hits []*SearchHit `json:"hits"`
DurationMs int64 `json:"duration_ms"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Query string `json:"query"`
Talker string `json:"talker"`
Sender string `json:"sender"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Total int `json:"total"`
Hits []*SearchHit `json:"hits"`
DurationMs int64 `json:"duration_ms"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Query string `json:"query"`
Talker string `json:"talker"`
Sender string `json:"sender"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Index *SearchIndexStatus `json:"index_status,omitempty"`
}
// SearchIndexStatus 表示 Bleve 索引的构建状态
type SearchIndexStatus struct {
Ready bool `json:"ready"`
InProgress bool `json:"in_progress"`
Progress float64 `json:"progress"`
LastStartedAt time.Time `json:"last_started_at"`
LastCompletedAt time.Time `json:"last_completed_at"`
LastError string `json:"last_error,omitempty"`
}
@@ -819,6 +819,10 @@ 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
@@ -18,6 +18,7 @@ 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)
// 联系人
GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error)
@@ -0,0 +1,83 @@
package dbm
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// FingerprintForGroups calculates a stable fingerprint for the provided file groups.
// It inspects the current file list managed by DBManager, collects their size and
// modification timestamp, and hashes the aggregated metadata. If none of the
// requested groups contain files, an empty string is returned without error.
func (d *DBManager) FingerprintForGroups(names ...string) (string, error) {
if len(names) == 0 {
return "", nil
}
type fileFingerprint struct {
group string
rel string
size int64
mod int64
}
entries := make([]fileFingerprint, 0)
for _, name := range names {
paths, err := d.GetDBPath(name)
if err != nil {
// When the underlying error indicates the files are simply missing,
// treat it as an empty group rather than a hard failure.
if strings.Contains(err.Error(), "db file not found") {
continue
}
return "", err
}
for _, p := range paths {
info, statErr := os.Stat(p)
if statErr != nil {
if os.IsNotExist(statErr) {
continue
}
return "", statErr
}
rel := p
if relPath, relErr := filepath.Rel(d.path, p); relErr == nil {
rel = relPath
}
rel = filepath.ToSlash(rel)
entries = append(entries, fileFingerprint{
group: name,
rel: rel,
size: info.Size(),
mod: info.ModTime().UnixNano(),
})
}
}
if len(entries) == 0 {
return "", nil
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].group == entries[j].group {
return entries[i].rel < entries[j].rel
}
return entries[i].group < entries[j].group
})
hasher := sha256.New()
for _, e := range entries {
fmt.Fprintf(hasher, "%s|%s|%d|%d;", e.group, e.rel, e.size, e.mod)
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
@@ -644,6 +644,10 @@ func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchReque
}, nil
}
func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
return ds.dbm.FingerprintForGroups(Message)
}
// 联系人
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
var query string
@@ -9,6 +9,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
@@ -28,6 +29,8 @@ const (
Video = "video"
File = "file"
Voice = "voice"
talkerCacheTTL = 30 * time.Second
)
var Groups = []*dbm.Group{
@@ -78,6 +81,10 @@ type DataSource struct {
// 消息数据库信息
messageInfos []MessageDBInfo
talkerCacheMu sync.RWMutex
talkerCache []string
talkerCacheExpiry time.Time
}
// New 创建一个新的 WindowsV3DataSource
@@ -101,12 +108,21 @@ func New(path string) (*DataSource, error) {
}
ds.dbm.AddCallback(Message, func(event fsnotify.Event) error {
if !event.Op.Has(fsnotify.Create) {
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
})
@@ -214,6 +230,7 @@ func (ds *DataSource) initMessageDbs() error {
return nil
}
ds.messageInfos = infos
ds.invalidateTalkerCache()
return nil
}
@@ -429,7 +446,25 @@ func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchReque
talkers := util.Str2List(req.Talker, ",")
if len(talkers) == 0 {
return nil, errors.ErrTalkerEmpty
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
@@ -676,6 +711,117 @@ func (ds *DataSource) SearchMessages(ctx context.Context, req *model.SearchReque
}, 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
}
// GetContacts 实现获取联系人信息的方法
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
var query string
+482
View File
@@ -0,0 +1,482 @@
package indexer
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/mapping"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/sjzar/chatlog/internal/model"
)
const (
runtimeIndexVersionKey = "chatlog_index_version"
runtimeIndexVersion = "1"
fingerprintKey = "chatlog_index_fingerprint"
lastBuiltKey = "chatlog_index_last_built"
)
// SearchHit represents a single Bleve search hit mapped back to chatlog's domain model.
type SearchHit struct {
Message *model.Message
Snippet string
Score float64
}
// Index wraps a Bleve index with concurrency control and helper utilities.
type Index struct {
mu sync.RWMutex
idx bleve.Index
path string
}
// Open opens an existing index at the given path or creates a new one with the default mapping.
func Open(path string) (*Index, error) {
parent := filepath.Dir(path)
if err := os.MkdirAll(parent, 0o755); err != nil {
return nil, fmt.Errorf("create index parent dir: %w", err)
}
var (
idx bleve.Index
err error
)
if _, statErr := os.Stat(path); statErr == nil {
idx, err = bleve.Open(path)
if err != nil {
return nil, fmt.Errorf("open bleve index: %w", err)
}
} else if errors.Is(statErr, os.ErrNotExist) {
mapping, mapErr := buildMapping()
if mapErr != nil {
return nil, mapErr
}
idx, err = bleve.New(path, mapping)
if err != nil {
return nil, fmt.Errorf("create bleve index: %w", err)
}
} else {
return nil, fmt.Errorf("stat index: %w", statErr)
}
return &Index{idx: idx, path: path}, nil
}
// Close closes the underlying Bleve index.
func (i *Index) Close() error {
if i == nil {
return nil
}
i.mu.Lock()
defer i.mu.Unlock()
if i.idx == nil {
return nil
}
return i.idx.Close()
}
// Reset drops all existing data and recreates the index with the default mapping.
func (i *Index) Reset() error {
i.mu.Lock()
defer i.mu.Unlock()
if i.idx != nil {
_ = i.idx.Close()
}
if err := os.RemoveAll(i.path); err != nil {
return fmt.Errorf("remove index dir: %w", err)
}
mapping, err := buildMapping()
if err != nil {
return err
}
idx, err := bleve.New(i.path, mapping)
if err != nil {
return fmt.Errorf("recreate bleve index: %w", err)
}
i.idx = idx
return nil
}
// SetMetadata stores an internal key/value pair within the index (not searchable).
func (i *Index) SetMetadata(key string, value []byte) error {
i.mu.Lock()
defer i.mu.Unlock()
if i.idx == nil {
return errors.New("index not initialized")
}
return i.idx.SetInternal([]byte(key), value)
}
// GetMetadata retrieves an internal key/value pair.
func (i *Index) GetMetadata(key string) ([]byte, error) {
i.mu.RLock()
defer i.mu.RUnlock()
if i.idx == nil {
return nil, errors.New("index not initialized")
}
return i.idx.GetInternal([]byte(key))
}
// IndexMessages indexes a batch of messages using Bleve batches for efficiency.
func (i *Index) IndexMessages(messages []*model.Message) error {
if len(messages) == 0 {
return nil
}
i.mu.Lock()
defer i.mu.Unlock()
if i.idx == nil {
return errors.New("index not initialized")
}
batch := i.idx.NewBatch()
const batchSize = 250
for idx, msg := range messages {
doc, err := newDocument(msg)
if err != nil {
return err
}
if err := batch.Index(doc.ID, doc); err != nil {
return fmt.Errorf("batch index: %w", err)
}
if (idx+1)%batchSize == 0 {
if err := i.idx.Batch(batch); err != nil {
return fmt.Errorf("flush batch: %w", err)
}
batch = i.idx.NewBatch()
}
}
if batch.Size() > 0 {
if err := i.idx.Batch(batch); err != nil {
return fmt.Errorf("flush final batch: %w", err)
}
}
return nil
}
// Search executes a Bleve search over the indexed content applying optional filters.
func (i *Index) Search(req *model.SearchRequest, talkers []string, senders []string, startUnix, endUnix int64, offset, limit int) ([]*SearchHit, int, error) {
if req == nil {
return nil, 0, errors.New("search request is nil")
}
queryObj := buildQuery(req.Query, talkers, senders, startUnix, endUnix)
if queryObj == nil {
return []*SearchHit{}, 0, nil
}
i.mu.RLock()
idx := i.idx
i.mu.RUnlock()
if idx == nil {
return nil, 0, errors.New("index not initialized")
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
searchRequest := bleve.NewSearchRequestOptions(queryObj, limit, offset, false)
searchRequest.Highlight = bleve.NewHighlightWithStyle("html")
searchRequest.Fields = []string{"message_json"}
searchRequest.IncludeLocations = false
result, err := idx.Search(searchRequest)
if err != nil {
return nil, 0, fmt.Errorf("bleve search: %w", err)
}
hits := make([]*SearchHit, 0, len(result.Hits))
for _, hit := range result.Hits {
messageJSON, ok := hit.Fields["message_json"].(string)
if !ok || messageJSON == "" {
continue
}
var msg model.Message
if err := json.Unmarshal([]byte(messageJSON), &msg); err != nil {
return nil, 0, fmt.Errorf("decode message: %w", err)
}
snippet := ""
if frags, ok := hit.Fragments["content"]; ok && len(frags) > 0 {
snippet = strings.Join(frags, " … ")
}
hits = append(hits, &SearchHit{
Message: &msg,
Snippet: snippet,
Score: hit.Score,
})
}
return hits, int(result.Total), nil
}
// Document representation stored inside Bleve.
type document struct {
ID string `json:"id"`
Talker string `json:"talker"`
Sender string `json:"sender"`
Unix int64 `json:"unix"`
Seq int64 `json:"seq"`
Content string `json:"content"`
MessageJSON string `json:"message_json"`
}
func newDocument(msg *model.Message) (*document, error) {
if msg == nil {
return nil, errors.New("nil message")
}
content := msg.PlainTextContent()
messageJSON, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal message: %w", err)
}
return &document{
ID: fmt.Sprintf("%s:%d", msg.Talker, msg.Seq),
Talker: msg.Talker,
Sender: msg.Sender,
Unix: msg.Time.Unix(),
Seq: msg.Seq,
Content: content,
MessageJSON: string(messageJSON),
}, nil
}
func buildMapping() (*mapping.IndexMappingImpl, error) {
indexMapping := bleve.NewIndexMapping()
indexMapping.TypeField = "type"
indexMapping.DefaultAnalyzer = "standard"
docMapping := mapping.NewDocumentMapping()
contentField := mapping.NewTextFieldMapping()
contentField.Analyzer = "standard"
contentField.Store = false
docMapping.AddFieldMappingsAt("content", contentField)
talkerField := mapping.NewTextFieldMapping()
talkerField.Analyzer = "keyword"
talkerField.Store = true
talkerField.IncludeInAll = false
docMapping.AddFieldMappingsAt("talker", talkerField)
senderField := mapping.NewTextFieldMapping()
senderField.Analyzer = "keyword"
senderField.Store = true
senderField.IncludeInAll = false
docMapping.AddFieldMappingsAt("sender", senderField)
unixField := mapping.NewNumericFieldMapping()
unixField.Store = true
docMapping.AddFieldMappingsAt("unix", unixField)
seqField := mapping.NewNumericFieldMapping()
seqField.Store = true
docMapping.AddFieldMappingsAt("seq", seqField)
messageField := mapping.NewTextFieldMapping()
messageField.Analyzer = "keyword"
messageField.Store = true
messageField.Index = false
docMapping.AddFieldMappingsAt("message_json", messageField)
indexMapping.DefaultMapping = docMapping
return indexMapping, nil
}
func buildQuery(input string, talkers []string, senders []string, startUnix, endUnix int64) query.Query {
contentQuery := buildContentQuery(input)
var must []query.Query
if contentQuery != nil {
must = append(must, contentQuery)
}
if len(talkers) > 0 {
must = append(must, buildTermsFilter("talker", talkers))
}
if len(senders) > 0 {
must = append(must, buildTermsFilter("sender", senders))
}
if startUnix > 0 || endUnix > 0 {
var minPtr, maxPtr *float64
if startUnix > 0 {
min := float64(startUnix)
minPtr = &min
}
if endUnix > 0 {
max := float64(endUnix)
maxPtr = &max
}
rangeQuery := query.NewNumericRangeQuery(minPtr, maxPtr)
rangeQuery.SetField("unix")
must = append(must, rangeQuery)
}
if len(must) == 0 {
return nil
}
if len(must) == 1 {
return must[0]
}
return query.NewConjunctionQuery(must)
}
func buildContentQuery(input string) query.Query {
s := strings.TrimSpace(input)
if s == "" {
return nil
}
upper := strings.ToUpper(s)
advanced := strings.ContainsAny(s, "\"'*()") ||
strings.Contains(upper, " AND ") ||
strings.Contains(upper, " OR ") ||
strings.Contains(upper, " NEAR ") ||
strings.HasPrefix(upper, "NOT ")
if advanced {
return query.NewQueryStringQuery(s)
}
tokens := strings.Fields(s)
if len(tokens) == 0 {
return nil
}
if len(tokens) == 1 {
mq := query.NewMatchQuery(tokens[0])
mq.SetField("content")
return mq
}
conj := make([]query.Query, 0, len(tokens))
for _, token := range tokens {
t := strings.TrimSpace(token)
if t == "" {
continue
}
mq := query.NewMatchQuery(t)
mq.SetField("content")
conj = append(conj, mq)
}
if len(conj) == 0 {
return nil
}
if len(conj) == 1 {
return conj[0]
}
return query.NewConjunctionQuery(conj)
}
func buildTermsFilter(field string, values []string) query.Query {
sanitized := make([]query.Query, 0, len(values))
for _, val := range values {
trimmed := strings.TrimSpace(val)
if trimmed == "" {
continue
}
tq := query.NewTermQuery(trimmed)
tq.SetField(field)
sanitized = append(sanitized, tq)
}
if len(sanitized) == 0 {
return nil
}
if len(sanitized) == 1 {
return sanitized[0]
}
return query.NewDisjunctionQuery(sanitized)
}
// EnsureVersion ensures the stored index version matches current runtime version.
func (i *Index) EnsureVersion() (bool, error) {
current, _ := i.GetMetadata(runtimeIndexVersionKey)
if string(current) == runtimeIndexVersion {
return true, nil
}
if err := i.SetMetadata(runtimeIndexVersionKey, []byte(runtimeIndexVersion)); err != nil {
return false, err
}
return false, nil
}
// UpdateFingerprint persists the dataset fingerprint into index metadata.
func (i *Index) UpdateFingerprint(fp string) error {
return i.SetMetadata(fingerprintKey, []byte(fp))
}
// Fingerprint reads the stored dataset fingerprint from index metadata.
func (i *Index) Fingerprint() string {
b, err := i.GetMetadata(fingerprintKey)
if err != nil {
return ""
}
return string(b)
}
// FingerprintMatches reports whether the stored dataset fingerprint equals the provided value.
func (i *Index) FingerprintMatches(fp string) bool {
if fp == "" {
return false
}
return i.Fingerprint() == fp
}
// EnsureFingerprint compares the stored fingerprint with the provided value.
// It returns true when the fingerprint already matches. When it differs, the
// metadata is updated to the new fingerprint and false is returned to signal
// callers that a rebuild just occurred.
func (i *Index) EnsureFingerprint(fp string) (bool, error) {
if fp == "" {
return false, nil
}
current := i.Fingerprint()
if current == fp {
return true, nil
}
if err := i.UpdateFingerprint(fp); err != nil {
return false, err
}
return false, nil
}
// UpdateLastBuilt 记录最近一次索引构建完成时间(Unix 秒)
func (i *Index) UpdateLastBuilt(t time.Time) error {
return i.SetMetadata(lastBuiltKey, []byte(strconv.FormatInt(t.Unix(), 10)))
}
// LastBuilt 返回最近一次索引构建完成时间
func (i *Index) LastBuilt() time.Time {
b, err := i.GetMetadata(lastBuiltKey)
if err != nil || len(b) == 0 {
return time.Time{}
}
sec, parseErr := strconv.ParseInt(string(b), 10, 64)
if parseErr != nil {
return time.Time{}
}
return time.Unix(sec, 0)
}
+2 -2
View File
@@ -86,7 +86,7 @@ func (r *Repository) initCache(ctx context.Context) error {
}
func (r *Repository) contactCallback(event fsnotify.Event) error {
if !event.Op.Has(fsnotify.Create) {
if !(event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Rename) || event.Op.Has(fsnotify.Remove)) {
return nil
}
if err := r.initContactCache(context.Background()); err != nil {
@@ -96,7 +96,7 @@ func (r *Repository) contactCallback(event fsnotify.Event) error {
}
func (r *Repository) chatroomCallback(event fsnotify.Event) error {
if !event.Op.Has(fsnotify.Create) {
if !(event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) || event.Op.Has(fsnotify.Rename) || event.Op.Has(fsnotify.Remove)) {
return nil
}
if err := r.initChatRoomCache(context.Background()); err != nil {