fix(history): 访问计数按设备去重 + 后台返回全量
- Entry 增加 DeviceVisits:同一 URL 多次/多设备上报取各设备最大值再求和,
不再平方级放大(30 次访问曾被计成 465 次)
- handleGet 不再截断 100 条,返回 {items,total},后台显示当前条数
- 清理死代码(hostVisits/hostOf/visitCount/isCodeHostVal/dashySection)
- 守门测试:重复上报、多设备求和、陈旧数据、持久化、统计、门户文案、第 101 条可见性
This commit is contained in:
77
history-server/countbug_test.go
Normal file
77
history-server/countbug_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 复现「访问次数被平方级放大」:扩展每 30s 上报一次该 URL 的
|
||||
// chrome.history.search visitCount —— 那是**绝对累计值**(1,2,3,4,5…),
|
||||
// 而 handleSync 用 `e.TotalVisits += item.VisitCount` 把它当增量累加。
|
||||
// 结果 5 次真实访问被记成 15 次。
|
||||
func TestSyncDoubleCountsAbsoluteVisitCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldDir, oldFile, oldTok := dataDir, historyFile, syncToken
|
||||
dataDir = dir
|
||||
historyFile = filepath.Join(dir, "history.json")
|
||||
syncToken = "t"
|
||||
defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }()
|
||||
|
||||
history = map[string]*Entry{}
|
||||
|
||||
// 模拟 5 轮同步:同一 URL 的绝对 visitCount 依次为 1..5
|
||||
for round, vc := range []int{1, 2, 3, 4, 5} {
|
||||
body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{
|
||||
URL: "https://ex.com/a", Title: "A", Device: "desktop",
|
||||
Timestamp: float64(1700000000 + round*30), VisitCount: vc,
|
||||
}}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer t")
|
||||
w := httptest.NewRecorder()
|
||||
handleSync(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("round %d: status = %d", round, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
e := history["https://ex.com/a"]
|
||||
if e == nil {
|
||||
t.Fatal("entry not created")
|
||||
}
|
||||
t.Logf("真实访问 5 次 → 落盘 TotalVisits = %d", e.TotalVisits)
|
||||
if e.TotalVisits != 5 {
|
||||
t.Errorf("TotalVisits = %d; 真实只访问 5 次,被平方级放大(期望 5)", e.TotalVisits)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报 100 次同一 URL 会放大到什么程度 —— 量化后果。
|
||||
func TestSyncInflationScalesQuadratically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldDir, oldFile, oldTok := dataDir, historyFile, syncToken
|
||||
dataDir = dir
|
||||
historyFile = filepath.Join(dir, "history.json")
|
||||
syncToken = "t"
|
||||
defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }()
|
||||
|
||||
history = map[string]*Entry{}
|
||||
// 真实访问 30 次,但扩展每 30s 同步一次 → 上报 30 次绝对值
|
||||
for round := 1; round <= 30; round++ {
|
||||
body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{
|
||||
URL: "https://ex.com/b", Device: "desktop",
|
||||
Timestamp: float64(1700000000 + round*30), VisitCount: round,
|
||||
}}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer t")
|
||||
handleSync(httptest.NewRecorder(), req)
|
||||
}
|
||||
got := history["https://ex.com/b"].TotalVisits
|
||||
want := 30
|
||||
t.Logf("真实 30 次访问 → 落盘 %d 次(虚高 %.1f×)", got, float64(got)/float64(want))
|
||||
if got != want {
|
||||
t.Errorf("TotalVisits = %d; want %d", got, want)
|
||||
}
|
||||
}
|
||||
135
history-server/countgate_test.go
Normal file
135
history-server/countgate_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 本文件是一组**守门测试**:把 2026-09-13 定位到的那个真实缺陷
|
||||
// (visit_count 绝对值被当增量累加)钉死,防止以后再改回去。
|
||||
|
||||
// 守门 1:绝不出现平方级放大。
|
||||
func TestNoQuadraticInflation(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
// 真实访问 20 次;扩展每 30s 同步一次,30 秒内反复上报绝对值
|
||||
for round := 1; round <= 20; round++ {
|
||||
postSync(t, SyncItem{
|
||||
URL: "https://ex.com/gate", Device: "laptop",
|
||||
Timestamp: float64(1700000000 + round*30), VisitCount: round,
|
||||
})
|
||||
}
|
||||
if got := history["https://ex.com/gate"].TotalVisits; got != 20 {
|
||||
t.Fatalf("TotalVisits = %d,真实只访问 20 次;出现放大即回归", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 守门 2:多设备各自的绝对值要相加(这是"取 max"的错误修法会失败的用例)。
|
||||
func TestMultiDeviceStillSums(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "laptop", Timestamp: 1700000000, VisitCount: 8})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "phone", Timestamp: 1700000030, VisitCount: 3})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "tablet", Timestamp: 1700000060, VisitCount: 5})
|
||||
if got := history["https://ex.com/m"].TotalVisits; got != 16 {
|
||||
t.Errorf("TotalVisits = %d;期望 8+3+5=16(全局 max 会错成 8)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 守门 3:落盘后能读回,且计数不变(DeviceVisits 参与序列化)。
|
||||
func TestCountsSurviveReload(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
postSync(t, SyncItem{URL: "https://ex.com/r", Device: "laptop", Timestamp: 1700000000, VisitCount: 6})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/r", Device: "phone", Timestamp: 1700000030, VisitCount: 2})
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dataDir, "history.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("未落盘: %v", err)
|
||||
}
|
||||
var entries []*Entry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
t.Fatalf("history.json 解析失败: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].TotalVisits != 8 {
|
||||
t.Fatalf("重载后 TotalVisits = %v,期望 8", entries)
|
||||
}
|
||||
if len(entries[0].DeviceVisits) != 2 {
|
||||
t.Errorf("device_visits 未落盘: %v", entries[0].DeviceVisits)
|
||||
}
|
||||
}
|
||||
|
||||
// 守门 4:handleStats 的 total_visits 同样不得放大。
|
||||
func TestStatsNotInflated(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
for round := 1; round <= 10; round++ {
|
||||
postSync(t, SyncItem{URL: "https://ex.com/s", Device: "laptop",
|
||||
Timestamp: float64(1700000000 + round*30), VisitCount: round})
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleStats(w, req)
|
||||
var out struct {
|
||||
Unique int `json:"unique_urls"`
|
||||
Total int `json:"total_visits"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("stats 解析失败: %v", err)
|
||||
}
|
||||
if out.Total != 10 {
|
||||
t.Errorf("total_visits = %d;真实 10 次访问,期望 10", out.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// 守门 5:rebuildDashySections 产出的「N次访问」文案要反映真实计数。
|
||||
func TestHistoryDescriptionUsesRealCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldDir := dataDir
|
||||
dataDir = dir
|
||||
defer func() { dataDir = oldDir }()
|
||||
|
||||
// conf.yml 需含「常用服务」与「历史访问」两个分组
|
||||
conf := `sections:
|
||||
- name: 常用服务
|
||||
items: []
|
||||
- name: 历史访问
|
||||
items: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "conf.yml"), []byte(conf), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history = map[string]*Entry{
|
||||
"https://ex.com/x": {URL: "https://ex.com/x", Title: "X", TotalVisits: 9},
|
||||
}
|
||||
rebuildDashySections()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "conf.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg struct {
|
||||
Sections []struct {
|
||||
Name string `yaml:"name"`
|
||||
Items []struct {
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
} `yaml:"items"`
|
||||
} `yaml:"sections"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range cfg.Sections {
|
||||
if s.Name != histSectionZh {
|
||||
continue
|
||||
}
|
||||
for _, it := range s.Items {
|
||||
if it.Title == "X" && it.Description != "9次访问" {
|
||||
t.Errorf("历史项文案 = %q,期望 %q", it.Description, "9次访问")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
history-server/getcap_test.go
Normal file
67
history-server/getcap_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 守门:读取侧不得再截断到 100 条。
|
||||
// 此前 handleGet 硬砍 100 条,而门户「历史访问」已不限条数 —— 后台因此
|
||||
// 看不见也删不掉第 101 条之后的记录。
|
||||
func TestHandleGetReturnsAllEntries(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
const n = 250
|
||||
for i := 0; i < n; i++ {
|
||||
history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{
|
||||
URL: fmt.Sprintf("https://ex.com/%03d", i),
|
||||
TotalVisits: n - i, // 降序
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleGet(w, req)
|
||||
|
||||
var out struct {
|
||||
Items []Entry `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if len(out.Items) != n {
|
||||
t.Errorf("返回 %d 条,期望 %d 条(后台必须能删到每一条)", len(out.Items), n)
|
||||
}
|
||||
if out.Total != n {
|
||||
t.Errorf("total = %d,期望 %d", out.Total, n)
|
||||
}
|
||||
// 顺序:按访问次数降序
|
||||
for i := 1; i < len(out.Items); i++ {
|
||||
if out.Items[i-1].TotalVisits < out.Items[i].TotalVisits {
|
||||
t.Fatalf("第 %d/%d 条顺序错乱:%d < %d", i, len(out.Items), out.Items[i-1].TotalVisits, out.Items[i].TotalVisits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超过 100 条时,第 101 条仍可被逐条删除(后台删除按钮按 URL 定位)。
|
||||
func TestEntryBeyondOldCapIsDeletable(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
const n = 150
|
||||
for i := 0; i < n; i++ {
|
||||
history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{
|
||||
URL: fmt.Sprintf("https://ex.com/%03d", i), TotalVisits: 1,
|
||||
}
|
||||
}
|
||||
target := "https://ex.com/149" // 旧截断下后台永远看不到这条
|
||||
req := httptest.NewRequest(http.MethodDelete, "/item?url="+target, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleDeleteItem(w, req)
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Fatalf("第 150 条无法删除:%s", w.Body.String())
|
||||
}
|
||||
if _, ok := history[target]; ok {
|
||||
t.Error("删除后仍存在于 history")
|
||||
}
|
||||
}
|
||||
@ -25,11 +25,12 @@ var (
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
TotalVisits int `json:"total_visits"`
|
||||
LastVisit float64 `json:"last_visit"`
|
||||
Devices []string `json:"devices"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
TotalVisits int `json:"total_visits"`
|
||||
LastVisit float64 `json:"last_visit"`
|
||||
Devices []string `json:"devices"`
|
||||
DeviceVisits map[string]int `json:"device_visits,omitempty"`
|
||||
}
|
||||
|
||||
type SyncItem struct {
|
||||
@ -137,7 +138,33 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
item.Device = "unknown"
|
||||
}
|
||||
if e, ok := history[normalized]; ok {
|
||||
e.TotalVisits += item.VisitCount
|
||||
// visit_count 是**绝对累计值**,不是增量。
|
||||
//
|
||||
// 浏览器扩展每 30s 跑一次 `chrome.history.search`,其 `visitCount`
|
||||
// 字段是「该 URL 有史以来被访问的总次数」,每轮都是一个更大的
|
||||
// 绝对数(1,2,3,4,5…),而不是「这一轮新增几次」。早期这里写成
|
||||
// `e.TotalVisits += item.VisitCount`,把绝对值当增量反复累加:
|
||||
// n 次真实访问被记成 n(n+1)/2(实测 30 次 → 465 次,虚高 15.5×),
|
||||
// 而「常用服务」正是按该字段排序的,等于把排名整个算错。
|
||||
//
|
||||
// 正确语义:**按设备分别取绝对值的最大值,再求和**。
|
||||
// 同一设备重复上报被 max 吸收(不会重复累加),多设备各报各的
|
||||
// 累计值,相加才是真实总数。
|
||||
if e.DeviceVisits == nil {
|
||||
e.DeviceVisits = map[string]int{}
|
||||
// 兼容没有 DeviceVisits 的旧记录:把已有总量记在第一个设备上,
|
||||
// 避免升级后凭空归零。
|
||||
if len(e.Devices) > 0 && e.TotalVisits > 0 {
|
||||
e.DeviceVisits[e.Devices[0]] = e.TotalVisits
|
||||
}
|
||||
}
|
||||
if item.VisitCount > e.DeviceVisits[item.Device] {
|
||||
e.DeviceVisits[item.Device] = item.VisitCount
|
||||
}
|
||||
e.TotalVisits = 0
|
||||
for _, v := range e.DeviceVisits {
|
||||
e.TotalVisits += v
|
||||
}
|
||||
if item.Timestamp > e.LastVisit {
|
||||
e.LastVisit = item.Timestamp
|
||||
}
|
||||
@ -158,6 +185,7 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
history[normalized] = &Entry{
|
||||
URL: normalized, Title: item.Title, TotalVisits: item.VisitCount,
|
||||
LastVisit: item.Timestamp, Devices: []string{item.Device},
|
||||
DeviceVisits: map[string]int{item.Device: item.VisitCount},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -174,6 +202,15 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": total})
|
||||
}
|
||||
|
||||
// handleGet 返回全部历史(按访问次数降序)。
|
||||
//
|
||||
// 此前这里硬截断到 100 条,而门户的「历史访问」分组早已改为**不限条数**
|
||||
// (用户 2026-09-13 要求)。两者不一致带来一个真实缺陷:管理后台只能
|
||||
// 删除它能看到的 100 条,第 101 条之后既看不见也删不掉,只能「清空全部」。
|
||||
// 既然展示侧已经不限制,读取侧就不该再截断;total 一并返回给后台显示总数。
|
||||
//
|
||||
// 体积可控:这里返回的是唯一 URL 列表(不是访问流水),条数等于历史里
|
||||
// 不同站点的数量,与门户「历史访问」分组的条目数完全一致。
|
||||
func handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
@ -185,12 +222,9 @@ func handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||||
})
|
||||
if len(entries) > 100 {
|
||||
entries = entries[:100]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"items": entries})
|
||||
json.NewEncoder(w).Encode(map[string]any{"items": entries, "total": len(entries)})
|
||||
}
|
||||
|
||||
func handleClear(w http.ResponseWriter, r *http.Request) {
|
||||
@ -269,13 +303,6 @@ type dashyNav struct {
|
||||
title, desc, icon, url string
|
||||
}
|
||||
|
||||
// dashySection 是 conf.yml 里的一个 section
|
||||
type dashySection struct {
|
||||
name, icon string
|
||||
collapsed bool
|
||||
items []dashyNav
|
||||
}
|
||||
|
||||
// ── 导航项点击计数(独立统计,不与浏览器历史混用)──
|
||||
//
|
||||
// 用户要求:导航项自己的访问频率要单独统计,而不是拿浏览器历史反推
|
||||
@ -334,19 +361,6 @@ func handleNavVisit(w http.ResponseWriter, r *http.Request) {
|
||||
rebuildDashySections() // 让「常用服务」立即按新计数重排
|
||||
}
|
||||
|
||||
// hostVisits 统计 history 中每个 host 的累计访问
|
||||
func hostVisits() map[string]int {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
out := map[string]int{}
|
||||
for _, e := range history {
|
||||
if u, err := url.Parse(e.URL); err == nil {
|
||||
out[strings.ToLower(u.Hostname())] += e.TotalVisits
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
|
||||
const (
|
||||
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
|
||||
@ -469,7 +483,7 @@ func rebuildDashySections() {
|
||||
if isHistSection(si.name) {
|
||||
continue
|
||||
} // 所有导航项(含常用服务原本手动配置的)都进候选,
|
||||
// 由访问量 + code-host 置顶决定最终 Top4
|
||||
// 最终由访问量决定去留与顺序
|
||||
candidates = append(candidates, cand{
|
||||
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
|
||||
host: host, visits: v,
|
||||
@ -552,58 +566,56 @@ func rebuildDashySections() {
|
||||
// 用户要求(2026-09-13):不再排除"已是导航项"的站点 —— 站内服务的真实访问记录
|
||||
// 也要在这里出现(相当于"最近访问"列表)。此前排除是为了不与其它分组重复,
|
||||
// 但结果是站内服务永远进不来,这一组几乎是空的。
|
||||
if true {
|
||||
mu.RLock()
|
||||
entries := make([]*Entry, 0, len(history))
|
||||
for _, e := range history {
|
||||
entries = append(entries, e)
|
||||
mu.RLock()
|
||||
entries := make([]*Entry, 0, len(history))
|
||||
for _, e := range history {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
mu.RUnlock()
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||||
})
|
||||
// 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示,
|
||||
// 排序仍按访问次数降序;首条固定为插件安装入口。
|
||||
histItems := make([]any, 0, len(entries)+1)
|
||||
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
|
||||
// 已装的浏览器由 navi.js 隐藏。
|
||||
histItems = append(histItems, extensionInstallItem())
|
||||
for _, e := range entries {
|
||||
title := friendlyHistoryTitle(e.Title, e.URL)
|
||||
icon := "fas fa-globe"
|
||||
if hp := hostPortOf(e.URL); hp != "" {
|
||||
if name := cachedFavicon(hp); name != "" {
|
||||
icon = name // 与手工导航项同一套本地图标机制
|
||||
} else {
|
||||
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
|
||||
}
|
||||
}
|
||||
mu.RUnlock()
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||||
"icon": icon, "url": e.URL,
|
||||
})
|
||||
// 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示,
|
||||
// 排序仍按访问次数降序;首条固定为插件安装入口。
|
||||
histItems := make([]any, 0, len(entries)+1)
|
||||
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
|
||||
// 已装的浏览器由 navi.js 隐藏。
|
||||
histItems = append(histItems, extensionInstallItem())
|
||||
for _, e := range entries {
|
||||
title := friendlyHistoryTitle(e.Title, e.URL)
|
||||
icon := "fas fa-globe"
|
||||
if hp := hostPortOf(e.URL); hp != "" {
|
||||
if name := cachedFavicon(hp); name != "" {
|
||||
icon = name // 与手工导航项同一套本地图标机制
|
||||
} else {
|
||||
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
|
||||
}
|
||||
}
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||||
"icon": icon, "url": e.URL,
|
||||
})
|
||||
|
||||
}
|
||||
foundHist := false
|
||||
for _, si := range secs {
|
||||
if isHistSection(si.name) {
|
||||
si.raw["items"] = histItems
|
||||
foundHist = true
|
||||
}
|
||||
foundHist := false
|
||||
for _, si := range secs {
|
||||
if isHistSection(si.name) {
|
||||
si.raw["items"] = histItems
|
||||
foundHist = true
|
||||
}
|
||||
}
|
||||
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
|
||||
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
|
||||
// 所以若只靠用户手动建,功能就一直不会出现。
|
||||
if !foundHist {
|
||||
sectionsRaw = append(sectionsRaw, map[string]any{
|
||||
"name": histSectionZh,
|
||||
"icon": "fas fa-history",
|
||||
"collapsed": false,
|
||||
"items": histItems,
|
||||
})
|
||||
fmt.Println("[history] 已补建「历史访问」分组")
|
||||
}
|
||||
}
|
||||
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
|
||||
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
|
||||
// 所以若只靠用户手动建,功能就一直不会出现。
|
||||
if !foundHist {
|
||||
sectionsRaw = append(sectionsRaw, map[string]any{
|
||||
"name": histSectionZh,
|
||||
"icon": "fas fa-history",
|
||||
"collapsed": false,
|
||||
"items": histItems,
|
||||
})
|
||||
fmt.Println("[history] 已补建「历史访问」分组")
|
||||
}
|
||||
|
||||
cfg["sections"] = sectionsRaw
|
||||
@ -638,29 +650,6 @@ func friendlyHistoryTitle(title, rawURL string) string {
|
||||
return "历史站点"
|
||||
}
|
||||
|
||||
func hostOf(rawURL string) string {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func visitCount(rawURL string) int {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
u.Fragment = ""
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
key := u.String()
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if e, ok := history[key]; ok {
|
||||
return e.TotalVisits
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
load()
|
||||
@ -722,25 +711,3 @@ func main() {
|
||||
log.Println("history-server listening on :3004")
|
||||
log.Fatal(http.ListenAndServe(":3004", nil))
|
||||
}
|
||||
|
||||
// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词,
|
||||
// 避免 "git." 子串误匹配域名中段(如 your-dify.example)。
|
||||
func isCodeHostVal(title, host, url string) bool {
|
||||
lowT := strings.ToLower(title)
|
||||
if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") ||
|
||||
strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") ||
|
||||
strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") {
|
||||
return true
|
||||
}
|
||||
h := strings.ToLower(host)
|
||||
if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") ||
|
||||
strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") ||
|
||||
strings.HasPrefix(h, "github.") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(h, "gitea") || strings.Contains(h, "github") ||
|
||||
strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
81
history-server/synccount_test.go
Normal file
81
history-server/synccount_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 辅助:切换到临时数据目录并重置全局 history。
|
||||
func withTempHistory(t *testing.T) {
|
||||
t.Helper()
|
||||
oldDir, oldFile := dataDir, historyFile
|
||||
dataDir = t.TempDir()
|
||||
historyFile = filepath.Join(dataDir, "history.json")
|
||||
history = map[string]*Entry{}
|
||||
t.Cleanup(func() { dataDir, historyFile = oldDir, oldFile })
|
||||
}
|
||||
|
||||
func postSync(t *testing.T, items ...SyncItem) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(SyncRequest{Items: items})
|
||||
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer t")
|
||||
old := syncToken
|
||||
syncToken = "t"
|
||||
defer func() { syncToken = old }()
|
||||
w := httptest.NewRecorder()
|
||||
handleSync(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 多设备:每台设备各报自己的绝对累计值,应求和而非取全局 max。
|
||||
func TestSyncSumsAcrossDevices(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
// 笔记本上该 URL 累计 10 次;手机上是 4 次
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 10})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000060, VisitCount: 4})
|
||||
if got := history["https://ex.com/a"].TotalVisits; got != 14 {
|
||||
t.Errorf("TotalVisits = %d; 两设备绝对值应求和 10+4=14", got)
|
||||
}
|
||||
// 两台设备各自继续增长,重复上报不重复累加
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000120, VisitCount: 12})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000180, VisitCount: 4})
|
||||
if got := history["https://ex.com/a"].TotalVisits; got != 16 {
|
||||
t.Errorf("TotalVisits = %d; 期望 12+4=16(phone 的 4 不应再加一次)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报值变小(用户清了浏览器历史)时,不得把已有计数拉低。
|
||||
func TestSyncNeverDecreasesOnStaleReport(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 20})
|
||||
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000060, VisitCount: 3})
|
||||
if got := history["https://ex.com/a"].TotalVisits; got != 20 {
|
||||
t.Errorf("TotalVisits = %d; 迟到的陈旧上报不应把 20 拉低到 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧版 history.json 没有 device_visits 字段,升级后不能归零。
|
||||
func TestSyncLegacyEntryWithoutDeviceVisits(t *testing.T) {
|
||||
withTempHistory(t)
|
||||
// 模拟旧记录:只有 devices + total_visits
|
||||
history["https://ex.com/old"] = &Entry{
|
||||
URL: "https://ex.com/old", Title: "Old", TotalVisits: 8,
|
||||
Devices: []string{"laptop"}, LastVisit: 1700000000,
|
||||
}
|
||||
postSync(t, SyncItem{URL: "https://ex.com/old", Device: "laptop", Timestamp: 1700000060, VisitCount: 9})
|
||||
if got := history["https://ex.com/old"].TotalVisits; got != 9 {
|
||||
t.Errorf("TotalVisits = %d; 旧记录的 8 次应在同一设备上被 9 接续,得到 9", got)
|
||||
}
|
||||
// 另一台设备加入 → 9 + 其自身绝对值
|
||||
postSync(t, SyncItem{URL: "https://ex.com/old", Device: "phone", Timestamp: 1700000120, VisitCount: 2})
|
||||
if got := history["https://ex.com/old"].TotalVisits; got != 11 {
|
||||
t.Errorf("TotalVisits = %d; 期望 9+2=11", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user