From ef86e77a920bce4fa86b6337c247d8867fca4c5b Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Tue, 23 Sep 2025 10:44:00 +0800 Subject: [PATCH] feat(avatar): Avatar support for html rendering --- Makefile | 25 +- internal/chatlog/app.go | 2 +- internal/chatlog/database/service.go | 13 + internal/chatlog/http/route.go | 298 +++++++++++++++++- internal/chatlog/http/static/index.htm | 69 +++- internal/errors/wechatdb_errors.go | 1 + internal/model/avatar.go | 9 + internal/model/contact.go | 2 + .../datasource/darwinv3/datasource.go | 5 + internal/wechatdb/datasource/datasource.go | 3 + internal/wechatdb/datasource/v4/datasource.go | 24 ++ .../datasource/windowsv3/datasource.go | 28 ++ internal/wechatdb/repository/repository.go | 5 + internal/wechatdb/wechatdb.go | 4 + 14 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 internal/model/avatar.go diff --git a/Makefile b/Makefile index 5a66d55..0faf369 100644 --- a/Makefile +++ b/Makefile @@ -58,20 +58,27 @@ build: buildcross: @echo "[cross] Interactive cross-compilation selection (Windows/macOS)" @mkdir -p bin - @printf "\nSelect target platforms to build (multiple choices, space separated):\n 1) windows/amd64\n 2) windows/arm64\n 3) darwin/amd64 (macOS Intel)\n 4) darwin/arm64 (Apple Silicon)\n> "; \ + @printf "\nSelect target platforms to build (enter digits, e.g., 134):\n 1) windows/amd64\n 2) windows/arm64\n 3) darwin/amd64 (macOS Intel)\n 4) darwin/arm64 (Apple Silicon)\n> "; \ read choices; \ - for ch in $$choices; do \ + opts=$$(printf "%s" "$$choices" | tr -cd '1234' | sed 's/./& /g'); \ + for ch in $$opts; do \ case $$ch in \ 1) \ ccbin=$${CC_WIN_AMD64:-x86_64-w64-mingw32-gcc}; \ : # On Windows host: do not force CC, use local toolchain \ if [ "$(OS)" = "Windows_NT" ]; then \ echo "🧭 Host: Windows"; \ - GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_amd64.exe main.go; \ + tmp="bin/$(BINARY_NAME)_windows_amd64.exe.new.$$RANDOM"; \ + GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \ + { mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_amd64.exe" 2>/dev/null || \ + echo "⚠️ Target locked, kept: $$tmp"; }; \ else \ if command -v "$$ccbin" >/dev/null 2>&1; then \ echo "⚙️ Using CC=$$ccbin"; \ - env CC="$$ccbin" GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_amd64.exe main.go; \ + tmp="bin/$(BINARY_NAME)_windows_amd64.exe.new.$$RANDOM"; \ + env CC="$$ccbin" GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \ + { mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_amd64.exe" 2>/dev/null || \ + echo "⚠️ Target locked, kept: $$tmp"; }; \ else \ echo "⏭️ Skip windows/amd64: $$ccbin not found (Arch: sudo pacman -S --needed mingw-w64-gcc)"; \ fi; \ @@ -83,14 +90,20 @@ buildcross: if [ "$(OS)" = "Windows_NT" ]; then \ if command -v "$$ccbin" >/dev/null 2>&1; then \ echo "⚙️ Using CC=$$ccbin"; \ - env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_arm64.exe main.go; \ + tmp="bin/$(BINARY_NAME)_windows_arm64.exe.new.$$RANDOM"; \ + env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \ + { mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_arm64.exe" 2>/dev/null || \ + echo "⚠️ Target locked, kept: $$tmp"; }; \ else \ echo "⏭️ Skip windows/arm64: $$ccbin not found (MSYS2: pacman -S mingw-w64-aarch64-gcc)"; \ fi; \ else \ if command -v "$$ccbin" >/dev/null 2>&1; then \ echo "⚙️ Using CC=$$ccbin"; \ - env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_arm64.exe main.go; \ + tmp="bin/$(BINARY_NAME)_windows_arm64.exe.new.$$RANDOM"; \ + env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \ + { mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_arm64.exe" 2>/dev/null || \ + echo "⚠️ Target locked, kept: $$tmp"; }; \ else \ echo "⏭️ Skip windows/arm64: $$ccbin not found (Arch: sudo pacman -S --needed mingw-w64-gcc)"; \ fi; \ diff --git a/internal/chatlog/app.go b/internal/chatlog/app.go index 87db9b5..2a0ae7d 100644 --- a/internal/chatlog/app.go +++ b/internal/chatlog/app.go @@ -492,7 +492,7 @@ func (a *App) settingSelected(i *menu.Item) { }, { name: "切换局域网监听", - description: "在 127.0.0.1 与 0.0.0.0 之间切换,便于开启/关闭局域网访问", + description: "切换局域网访问限制", action: a.toggleListen, }, { diff --git a/internal/chatlog/database/service.go b/internal/chatlog/database/service.go index 2a31cd9..88689fe 100644 --- a/internal/chatlog/database/service.go +++ b/internal/chatlog/database/service.go @@ -87,6 +87,15 @@ func (s *Service) GetDB() *wechatdb.DB { return s.db } +// GetWorkDir exposes the underlying work directory where decrypted DB files are stored. +// This is useful for higher layers (HTTP) to compute DB sizes for summary statistics. +func (s *Service) GetWorkDir() string { + if s == nil || s.conf == nil { + return "" + } + return s.conf.GetWorkDir() +} + func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) { return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset) } @@ -108,6 +117,10 @@ func (s *Service) GetMedia(_type string, key string) (*model.Media, error) { return s.db.GetMedia(_type, key) } +func (s *Service) GetAvatar(username string, size string) (*model.Avatar, error) { + return s.db.GetAvatar(username, size) +} + func (s *Service) initWebhook() error { if s.webhook == nil { return nil diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index 68bd40f..677586e 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -3,6 +3,7 @@ package http import ( "embed" "encoding/csv" + "encoding/json" "fmt" "html/template" "io/fs" @@ -61,6 +62,7 @@ func (s *Service) initMediaRouter() { s.router.GET("/file/*key", func(c *gin.Context) { s.handleMedia(c, "file") }) s.router.GET("/voice/*key", func(c *gin.Context) { s.handleMedia(c, "voice") }) s.router.GET("/data/*path", s.handleMediaData) + s.router.GET("/avatar/:username", s.handleAvatar) } func (s *Service) initAPIRouter() { @@ -71,6 +73,7 @@ func (s *Service) initAPIRouter() { api.GET("/chatroom", s.handleChatRooms) api.GET("/session", s.handleSessions) api.GET("/diary", s.handleDiary) + api.GET("/summary", s.handleSummary) } } @@ -80,6 +83,215 @@ func (s *Service) initMCPRouter() { s.router.Any("/message", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) }) } +// handleSummary outputs a dashboard summary JSON. For now it serves a template JSON +// compatible with tools/json/index.json. In future we can compute real data. +// GET /api/v1/summary +func (s *Service) handleSummary(c *gin.Context) { + // dynamic=1 triggers dynamic summary generation; otherwise fall back to template JSON + dynamic := c.Query("dynamic") == "1" + + // Try to load a template JSON from tools/json/index.json if present + // Otherwise, return an empty structure with HTTP 200. + workdir := s.conf.GetDataDir() + candidates := []string{ + filepath.Join("tools", "json", "index.json"), + filepath.Join(workdir, "tools", "json", "index.json"), + } + var raw []byte + for _, p := range candidates { + if b, err := os.ReadFile(p); err == nil && len(b) > 0 { + raw = b + break + } + } + + var v any + if dynamic { + // compute dynamic summary + sum := s.computeDynamicSummary() + v = sum + } else { + if len(raw) > 0 { + if err := json.Unmarshal(raw, &v); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid summary template", "detail": err.Error()}) + return + } + } else { + v = gin.H{"dashboard_report": gin.H{}} + } + } + + // Optional: save to file in workdir when save=1 + if c.Query("save") == "1" { + // default filename + filename := c.DefaultQuery("filename", "summary.json") + if filename == "" { filename = "summary.json" } + outPath := filepath.Join(s.conf.GetDataDir(), filename) + if dir := filepath.Dir(outPath); dir != "." { _ = os.MkdirAll(dir, 0o755) } + var out []byte + var err error + if !dynamic && len(raw) > 0 { out = raw } else { out, err = json.MarshalIndent(v, "", " "); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}); return } } + if err := os.WriteFile(outPath, out, 0o644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary", "detail": err.Error()}) + return + } + // If download=1, stream the file as attachment + if c.Query("download") == "1" { + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", "attachment; filename="+filepath.Base(outPath)) + c.Data(http.StatusOK, "application/json", out) + return + } + c.JSON(http.StatusOK, gin.H{"saved": true, "path": outPath}) + return + } + + // Optional: direct download when download=1 + if c.Query("download") == "1" { + b := raw + if dynamic || len(b) == 0 { var err error; b, err = json.MarshalIndent(v, "", " "); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}); return } } + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", "attachment; filename=summary.json") + c.Data(http.StatusOK, "application/json", b) + return + } + + c.JSON(http.StatusOK, v) +} + +// computeDynamicSummary builds a lightweight dynamic dashboard JSON with basic metrics. +// It avoids heavy full DB scans and uses existing repository APIs for acceptable performance. +func (s *Service) computeDynamicSummary() any { + // Sizes + dataDir := s.conf.GetDataDir() + workDir := dataDir // prefer dataDir for media; if database layer exposes workDir, use it + if s.db != nil { + if wd := s.db.GetWorkDir(); wd != "" { workDir = wd } + } + dirSize := safeDirSize(dataDir) + dbSize := estimateDBSize(workDir) + + // Sessions timeline (approximate earliest/latest by NTime) + minTime := time.Time{} + maxTime := time.Time{} + if sessions, err := s.db.GetSessions("", 0, 0); err == nil { + for _, it := range sessions.Items { + t := it.NTime + if t.IsZero() { continue } + if minTime.IsZero() || t.Before(minTime) { minTime = t } + if maxTime.IsZero() || t.After(maxTime) { maxTime = t } + } + } + + // Contacts basic stats + totalContacts := 0 + friends := 0 + nonFriends := 0 + if contacts, err := s.db.GetContacts("", 0, 0); err == nil { + totalContacts = len(contacts.Items) + for _, c := range contacts.Items { if c.IsFriend { friends++ } else { nonFriends++ } } + } + + // Chatrooms top by member count + roomsList := make([]map[string]any, 0) + if rooms, err := s.db.GetChatRooms("", 0, 0); err == nil { + for _, r := range rooms.Items { + roomsList = append(roomsList, map[string]any{ + "name": r.Name, + "nick": r.NickName, + "owner": r.Owner, + "members": len(r.Users), + }) + } + // simple sort: descending by members + // do inline selection sort to avoid importing sort for tiny lists + for i := 0; i < len(roomsList); i++ { + maxIdx := i + for j := i + 1; j < len(roomsList); j++ { + if roomsList[j]["members"].(int) > roomsList[maxIdx]["members"].(int) { maxIdx = j } + } + if maxIdx != i { roomsList[i], roomsList[maxIdx] = roomsList[maxIdx], roomsList[i] } + } + if len(roomsList) > 20 { roomsList = roomsList[:20] } + } + + // Build JSON structure + dash := map[string]any{ + "db_stats": map[string]any{ + "db_size_mb": roundMB(dbSize), + "dir_size_mb": roundMB(dirSize), + }, + "timeline": map[string]any{ + "start": formatTime(minTime), + "end": formatTime(maxTime), + "days": diffDays(minTime, maxTime), + }, + "contact_stats": map[string]any{ + "total": totalContacts, + "friends": friends, + "non_friends": nonFriends, + }, + "group_stats": map[string]any{ + "top_member_groups": roomsList, + }, + // Placeholders for future dynamic enrichment + "message_stats": map[string]any{ + "total": 0, + "by_type": map[string]int{}, + }, + } + return map[string]any{"dashboard_report": dash} +} + +func roundMB(bytes int64) float64 { + if bytes <= 0 { return 0 } + // 1 MB = 1024*1024 + mb := float64(bytes) / (1024.0 * 1024.0) + // round to 2 decimals + return float64(int(mb*100+0.5)) / 100.0 +} + +func diffDays(a, b time.Time) int { + if a.IsZero() || b.IsZero() { return 0 } + if b.Before(a) { a, b = b, a } + d := b.Sub(a).Hours() / 24.0 + if d < 0 { return 0 } + return int(d + 0.5) +} + +func formatTime(t time.Time) string { + if t.IsZero() { return "" } + return t.Format("2006-01-02 15:04:05") +} + +// safeDirSize walks a directory and sums file sizes; returns 0 on error. +func safeDirSize(path string) int64 { + var total int64 + if path == "" { return 0 } + _ = filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err != nil { return nil } + if info == nil || info.IsDir() { return nil } + total += info.Size() + return nil + }) + return total +} + +// estimateDBSize sums sizes of common DB files under workDir +func estimateDBSize(workDir string) int64 { + if workDir == "" { return 0 } + var total int64 + _ = filepath.Walk(workDir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { return nil } + name := strings.ToLower(info.Name()) + if strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".sqlite") || strings.HasSuffix(name, ".sqlite3") || strings.HasSuffix(name, ".db-wal") || strings.HasSuffix(name, ".db-shm") { + total += info.Size() + } + return nil + }) + return total +} + func (s *Service) handleChatlog(c *gin.Context) { q := struct { Time string `form:"time"` @@ -116,7 +328,7 @@ func (s *Service) handleChatlog(c *gin.Context) { switch strings.ToLower(q.Format) { case "html": c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") - c.Writer.WriteString("Chatlog") + c.Writer.WriteString("Chatlog") c.Writer.WriteString(fmt.Sprintf("

All Messages %s ~ %s

", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))) for _, g := range groups { title := g.Talker @@ -127,7 +339,8 @@ func (s *Service) handleChatlog(c *gin.Context) { senderDisplay := m.Sender if m.IsSelf { senderDisplay = "我" } if m.SenderName != "" { senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")" } else { senderDisplay = template.HTMLEscapeString(senderDisplay) } - c.Writer.WriteString("
"+ senderDisplay +""+ m.Time.Format("2006-01-02 15:04:05") +"
"+ messageHTMLPlaceholder(m) +"
") + aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big") + c.Writer.WriteString("
\"avatar\"
"+ senderDisplay +""+ m.Time.Format("2006-01-02 15:04:05") +"
"+ messageHTMLPlaceholder(m) +"
") } c.Writer.WriteString("") } @@ -172,17 +385,20 @@ func (s *Service) handleChatlog(c *gin.Context) { switch strings.ToLower(q.Format) { case "html": c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") - c.Writer.WriteString("Chatlog") + c.Writer.WriteString("Chatlog") c.Writer.WriteString(fmt.Sprintf("

Messages %s ~ %s (%s)

", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker))) for _, m := range messages { m.SetContent("host", c.Request.Host) - c.Writer.WriteString("
") + c.Writer.WriteString("
") + aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big") + c.Writer.WriteString("\"avatar\"") + c.Writer.WriteString("
") if m.SenderName != "" { c.Writer.WriteString(template.HTMLEscapeString(m.SenderName)+"(") } c.Writer.WriteString(template.HTMLEscapeString(m.Sender)) if m.SenderName != "" { c.Writer.WriteString(")") } c.Writer.WriteString(""+m.Time.Format("2006-01-02 15:04:05")+"
")
 				c.Writer.WriteString(messageHTMLPlaceholder(m))
-				c.Writer.WriteString("
") + c.Writer.WriteString("
") } c.Writer.WriteString(previewHTMLSnippet) c.Writer.WriteString("") @@ -231,8 +447,37 @@ func (s *Service) handleContacts(c *gin.Context) { format := strings.ToLower(q.Format) switch format { + case "html": + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.Write([]byte(`
`)) + for _, contact := range list.Items { + uname := template.HTMLEscapeString(contact.UserName) + nick := template.HTMLEscapeString(contact.NickName) + remark := template.HTMLEscapeString(contact.Remark) + alias := template.HTMLEscapeString(contact.Alias) + // compose avatar URL + aurl := template.HTMLEscapeString(s.composeAvatarURL(contact.UserName)) + c.Writer.WriteString(`
`) + c.Writer.WriteString(``) + c.Writer.WriteString(`
`) + c.Writer.WriteString(`
` + nick + `
`) + c.Writer.WriteString(`
` + uname) + if remark != "" { c.Writer.WriteString(` · ` + remark) } + if alias != "" { c.Writer.WriteString(` · alias:` + alias) } + c.Writer.WriteString(`
`) + } + c.Writer.WriteString(`
`) + return case "json": - // json + // fill avatar urls + for _, item := range list.Items { item.AvatarURL = s.composeAvatarURL(item.UserName) } c.JSON(http.StatusOK, list) default: // csv @@ -245,15 +490,45 @@ func (s *Service) handleContacts(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Flush() - - c.Writer.WriteString("UserName,Alias,Remark,NickName\n") + c.Writer.WriteString("UserName,Alias,Remark,NickName,AvatarURL\n") for _, contact := range list.Items { - c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName)) + avatarURL := s.composeAvatarURL(contact.UserName) + c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName, avatarURL)) } c.Writer.Flush() } } +// composeAvatarURL builds a relative URL that the server can serve for any username +func (s *Service) composeAvatarURL(username string) string { + if username == "" { return "" } + return "/avatar/" + username +} + +// handleAvatar serves avatar by username. For v3 returns redirect to remote URL; for v4 streams bytes. +func (s *Service) handleAvatar(c *gin.Context) { + username := c.Param("username") + size := c.Query("size") // optional: small|big + avatar, err := s.db.GetAvatar(username, size) + if err != nil { + errors.Err(c, err) + return + } + if avatar == nil { + errors.Err(c, errors.ErrAvatarNotFound) + return + } + if avatar.URL != "" { + // external URL, redirect + c.Redirect(http.StatusFound, avatar.URL) + return + } + // inline bytes + ct := avatar.ContentType + if ct == "" { ct = "image/jpeg" } + c.Data(http.StatusOK, ct, avatar.Data) +} + func (s *Service) handleChatRooms(c *gin.Context) { q := struct { @@ -413,7 +688,7 @@ func (s *Service) handleDiary(c *gin.Context) { switch format { case "html": c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") - c.Writer.WriteString(`Diary`) + c.Writer.WriteString(`Diary`) c.Writer.WriteString(fmt.Sprintf("

最近%dh我参与过的会话全部消息(%s ~ %s)

", hours, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))) for _, g := range groups { title := g.Talker @@ -428,7 +703,8 @@ func (s *Service) handleDiary(c *gin.Context) { } else { senderDisplay = template.HTMLEscapeString(senderDisplay) } - c.Writer.WriteString("
" + senderDisplay + "" + m.Time.Format("2006-01-02 15:04:05") + "
" + messageHTMLPlaceholder(m) + "
") + aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big") + c.Writer.WriteString("
\"avatar\"
" + senderDisplay + "" + m.Time.Format("2006-01-02 15:04:05") + "
" + messageHTMLPlaceholder(m) + "
") } c.Writer.WriteString("") } diff --git a/internal/chatlog/http/static/index.htm b/internal/chatlog/http/static/index.htm index 3d8dcbc..62661f6 100644 --- a/internal/chatlog/http/static/index.htm +++ b/internal/chatlog/http/static/index.htm @@ -903,20 +903,63 @@ copyToClipboard(urlText, this, "已复制URL!"); }); - // 通用复制功能 + // 通用复制功能(支持非安全上下文的回退方案) function copyToClipboard(text, button, successMessage) { - navigator.clipboard - .writeText(text) - .then(() => { - const originalText = button.textContent; - button.textContent = successMessage; - setTimeout(() => { - button.textContent = originalText; - }, 2000); - }) - .catch((err) => { - console.error("复制失败:", err); - }); + const originalText = button.textContent; + const toast = (msg) => { + button.textContent = msg; + setTimeout(() => { + button.textContent = originalText; + }, 2000); + }; + + // 无可复制内容 + if (!text || text.trim() === "") { + toast("无可复制内容"); + return; + } + + // 现代 API,要求安全上下文(https 或 localhost) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard + .writeText(text) + .then(() => { + toast(successMessage); + }) + .catch((err) => { + // 失败时尝试回退 + const ok = fallbackCopyTextToClipboard(text); + toast(ok ? successMessage : "复制失败"); + if (!ok) console.error("复制失败:", err); + }); + return; + } + + // 非安全上下文或不支持 navigator.clipboard 时,使用回退方案 + const ok = fallbackCopyTextToClipboard(text); + toast(ok ? successMessage : "复制失败"); + } + + // 回退复制方案:创建隐藏 textarea,使用 execCommand('copy') + function fallbackCopyTextToClipboard(text) { + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + // 避免页面滚动抖动 + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.left = "-9999px"; + textarea.setAttribute("readonly", ""); + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch (e) { + console.error("回退复制失败:", e); + return false; + } } diff --git a/internal/errors/wechatdb_errors.go b/internal/errors/wechatdb_errors.go index d6a2e46..004baed 100644 --- a/internal/errors/wechatdb_errors.go +++ b/internal/errors/wechatdb_errors.go @@ -9,6 +9,7 @@ var ( ErrTalkerEmpty = New(nil, http.StatusBadRequest, "talker empty").WithStack() ErrKeyEmpty = New(nil, http.StatusBadRequest, "key empty").WithStack() ErrMediaNotFound = New(nil, http.StatusNotFound, "media not found").WithStack() + ErrAvatarNotFound = New(nil, http.StatusNotFound, "avatar not found").WithStack() ErrKeyLengthMust32 = New(nil, http.StatusBadRequest, "key length must be 32 bytes").WithStack() ) diff --git a/internal/model/avatar.go b/internal/model/avatar.go new file mode 100644 index 0000000..b0e79b2 --- /dev/null +++ b/internal/model/avatar.go @@ -0,0 +1,9 @@ +package model + +// Avatar represents a user's avatar, either as a remote URL (v3) or inline bytes (v4) +type Avatar struct { + Username string `json:"username"` + URL string `json:"url,omitempty"` + ContentType string `json:"-"` + Data []byte `json:"-"` +} diff --git a/internal/model/contact.go b/internal/model/contact.go index 67b7359..8d99338 100644 --- a/internal/model/contact.go +++ b/internal/model/contact.go @@ -6,6 +6,8 @@ type Contact struct { Remark string `json:"remark"` NickName string `json:"nickName"` IsFriend bool `json:"isFriend"` + // AvatarURL is a service-composed URL to fetch avatar image + AvatarURL string `json:"avatarUrl,omitempty"` } // CREATE TABLE Contact( diff --git a/internal/wechatdb/datasource/darwinv3/datasource.go b/internal/wechatdb/datasource/darwinv3/datasource.go index e8cc796..c932039 100644 --- a/internal/wechatdb/datasource/darwinv3/datasource.go +++ b/internal/wechatdb/datasource/darwinv3/datasource.go @@ -652,3 +652,8 @@ WHERE func (ds *DataSource) Close() error { return ds.dbm.Close() } + +// 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 +} diff --git a/internal/wechatdb/datasource/datasource.go b/internal/wechatdb/datasource/datasource.go index bbd46a9..f8dd4ab 100644 --- a/internal/wechatdb/datasource/datasource.go +++ b/internal/wechatdb/datasource/datasource.go @@ -30,6 +30,9 @@ type DataSource interface { // 媒体 GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) + // 头像 + GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) + // 设置回调函数 SetCallback(group string, callback func(event fsnotify.Event) error) error diff --git a/internal/wechatdb/datasource/v4/datasource.go b/internal/wechatdb/datasource/v4/datasource.go index 4448ccb..68aa269 100644 --- a/internal/wechatdb/datasource/v4/datasource.go +++ b/internal/wechatdb/datasource/v4/datasource.go @@ -55,6 +55,11 @@ var Groups = []*dbm.Group{ Pattern: `^media_([0-9]?[0-9])?\.db$`, BlackList: []string{}, }, + { + Name: "headimg", + Pattern: `^head_image\.db$`, + BlackList: []string{}, + }, } // MessageDBInfo 存储消息数据库的信息 @@ -750,3 +755,22 @@ func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, e func (ds *DataSource) Close() error { return ds.dbm.Close() } + +// GetAvatar for v4: read head_image.db -> head_image(username, image_buffer) +func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) { + if username == "" { + return nil, errors.ErrKeyEmpty + } + // open head_image db + db, err := ds.dbm.GetDB("headimg") + if err != nil { + return nil, errors.ErrAvatarNotFound + } + // table may be head_image, columns: username, image_buffer (jfif), update_time, md5 + row := db.QueryRowContext(ctx, `SELECT image_buffer FROM head_image WHERE username = ?`, username) + var buf []byte + if err := row.Scan(&buf); err != nil || len(buf) == 0 { + return nil, errors.ErrAvatarNotFound + } + return &model.Avatar{Username: username, ContentType: "image/jpeg", Data: buf}, nil +} diff --git a/internal/wechatdb/datasource/windowsv3/datasource.go b/internal/wechatdb/datasource/windowsv3/datasource.go index 0a7220c..1fcaf23 100644 --- a/internal/wechatdb/datasource/windowsv3/datasource.go +++ b/internal/wechatdb/datasource/windowsv3/datasource.go @@ -776,3 +776,31 @@ func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, e func (ds *DataSource) Close() error { return ds.dbm.Close() } + +// GetAvatar returns avatar info for a username on Windows v3 (MicroMsg.db -> ContactHeadImgUrl) +func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) { + if username == "" { + return nil, errors.ErrKeyEmpty + } + db, err := ds.dbm.GetDB(Contact) + if err != nil { + return nil, err + } + query := `SELECT IFNULL(smallHeadImgUrl, ''), IFNULL(bigHeadImgUrl, '') FROM ContactHeadImgUrl WHERE usrName = ?` + row := db.QueryRowContext(ctx, query, username) + var small, big string + if err := row.Scan(&small, &big); err != nil { + return nil, errors.ErrAvatarNotFound + } + url := small + if strings.ToLower(size) == "big" && big != "" { + url = big + } + if url == "" { + url = big + } + if url == "" { + return nil, errors.ErrAvatarNotFound + } + return &model.Avatar{Username: username, URL: url}, nil +} diff --git a/internal/wechatdb/repository/repository.go b/internal/wechatdb/repository/repository.go index bf26803..b2873e1 100644 --- a/internal/wechatdb/repository/repository.go +++ b/internal/wechatdb/repository/repository.go @@ -109,3 +109,8 @@ func (r *Repository) chatroomCallback(event fsnotify.Event) error { func (r *Repository) Close() error { return r.ds.Close() } + +// GetAvatar proxies to datasource +func (r *Repository) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) { + return r.ds.GetAvatar(ctx, username, size) +} diff --git a/internal/wechatdb/wechatdb.go b/internal/wechatdb/wechatdb.go index 6d93b8b..612ae2a 100644 --- a/internal/wechatdb/wechatdb.go +++ b/internal/wechatdb/wechatdb.go @@ -129,3 +129,7 @@ func (w *DB) GetMedia(_type string, key string) (*model.Media, error) { func (w *DB) SetCallback(group string, callback func(event fsnotify.Event) error) error { return w.ds.SetCallback(group, callback) } + +func (w *DB) GetAvatar(username string, size string) (*model.Avatar, error) { + return w.repo.GetAvatar(context.Background(), username, size) +}