Files
HomeDash/history-server/main.go
JianFeeeee 292e77097d fix(history): 访问计数按设备去重 + 后台返回全量
- Entry 增加 DeviceVisits:同一 URL 多次/多设备上报取各设备最大值再求和,
  不再平方级放大(30 次访问曾被计成 465 次)
- handleGet 不再截断 100 条,返回 {items,total},后台显示当前条数
- 清理死代码(hostVisits/hostOf/visitCount/isCodeHostVal/dashySection)
- 守门测试:重复上报、多设备求和、陈旧数据、持久化、统计、门户文案、第 101 条可见性
2026-10-02 11:24:55 +08:00

714 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
var (
dataDir = envOr("NAVI_DATA_DIR", "/app/data")
historyFile = filepath.Join(dataDir, "history.json")
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
mu sync.RWMutex
history = map[string]*Entry{}
)
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"`
DeviceVisits map[string]int `json:"device_visits,omitempty"`
}
type SyncItem struct {
URL string `json:"url"`
Title string `json:"title"`
Device string `json:"device"`
Timestamp float64 `json:"timestamp"`
VisitCount int `json:"visit_count"`
}
type SyncRequest struct {
Items []SyncItem `json:"items"`
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func load() {
data, err := os.ReadFile(historyFile)
if err != nil {
return
}
var entries []*Entry
if err := json.Unmarshal(data, &entries); err != nil {
log.Printf("load error: %v", err)
return
}
for _, e := range entries {
history[e.URL] = e
}
log.Printf("history: loaded %d entries", len(history))
}
func save() {
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
data, _ := json.MarshalIndent(entries, "", " ")
os.MkdirAll(filepath.Dir(historyFile), 0755)
os.WriteFile(historyFile, data, 0644)
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
next(w, r)
}
}
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
if expected == "" {
return true // 未配置 token 则不校验
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
return true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid or missing token"})
return false
}
func handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
// 浏览器插件回传:必须携带 NAVI_SYNC_TOKEN(免 portal 登录,改用自定义 token)
if !requireToken(w, r, syncToken) {
return
}
var req SyncRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if len(req.Items) == 0 || len(req.Items) > 1000 {
http.Error(w, "items must be 1-1000", 400)
return
}
mu.Lock()
for _, item := range req.Items {
normalized, err := normalizeURL(item.URL)
if err != nil {
continue
}
if item.VisitCount <= 0 {
item.VisitCount = 1
}
if item.Device == "" {
item.Device = "unknown"
}
if e, ok := history[normalized]; ok {
// 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
}
if len(item.Title) > len(e.Title) {
e.Title = item.Title
}
found := false
for _, d := range e.Devices {
if d == item.Device {
found = true
break
}
}
if !found {
e.Devices = append(e.Devices, item.Device)
}
} else {
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},
}
}
}
save()
mu.Unlock() // 写锁释放后再排序,避免 sortDashyFavorites 内 RLock 死锁
rebuildDashySections()
mu.RLock()
total := len(history)
mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
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()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"items": entries, "total": len(entries)})
}
func handleClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", 405)
return
}
mu.Lock()
history = map[string]*Entry{}
save()
mu.Unlock()
rebuildDashySections() // 清空后重置 conf.yml 历史 section 为占位
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
}
// handleDeleteItem 删除单条历史(?url=),随后重建 Dashy 分组。
// 与「清空全部」分开:管理后台需要能逐条删。
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("url")
if raw == "" {
http.Error(w, "url query parameter is required", 400)
return
}
key, err := normalizeURL(raw)
if err != nil {
http.Error(w, "invalid url", 400)
return
}
mu.Lock()
_, existed := history[key]
if existed {
delete(history, key)
save()
}
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if !existed {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]any{"error": "not found", "url": key})
return
}
rebuildDashySections()
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "url": key})
}
func handleStats(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
total := 0
for _, e := range history {
total += e.TotalVisits
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{"unique_urls": len(history), "total_visits": total}); err != nil {
log.Printf("stats response: %v", err)
}
}
func normalizeURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid url: %s", raw)
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
return u.String(), nil
}
// navItem 是 conf.yml 里一个导航项(任一 section)
type dashyNav struct {
title, desc, icon, url string
}
// ── 导航项点击计数(独立统计,不与浏览器历史混用)──
//
// 用户要求:导航项自己的访问频率要单独统计,而不是拿浏览器历史反推
// (浏览器历史里混着大量站外页面,反推出来的"常用"并不等于"我常点哪个服务")。
// 客户端在点击导航项时向 /api/history/nav-visit 上报一次,这里计数并落盘。
var navVisitsMu sync.Mutex
func navVisitsPath() string { return filepath.Join(dataDir, "nav-visits.json") }
func loadNavVisits() map[string]int {
out := map[string]int{}
b, err := os.ReadFile(navVisitsPath())
if err != nil {
return out
}
if err := json.Unmarshal(b, &out); err != nil {
fmt.Println("[nav] nav-visits.json 解析失败,按空计数:", err)
return map[string]int{}
}
return out
}
func bumpNavVisit(host string) int {
navVisitsMu.Lock()
defer navVisitsMu.Unlock()
m := loadNavVisits()
m[host]++
b, _ := json.Marshal(m)
tmp := navVisitsPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err == nil {
_ = os.Rename(tmp, navVisitsPath())
}
return m[host]
}
func handleNavVisit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
http.Error(w, "url required", 400)
return
}
u, err := url.Parse(req.URL)
if err != nil || u.Hostname() == "" {
http.Error(w, "bad url", 400)
return
}
host := strings.ToLower(u.Hostname())
n := bumpNavVisit(host)
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "host": host, "visits": n})
rebuildDashySections() // 让「常用服务」立即按新计数重排
}
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
const (
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
favSectionEn = "Favorites"
histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
histSectionEn = "History"
)
// extensionInstallURL 是「一键安装浏览历史插件」入口。
// 服务端始终把它放在「历史访问」第一条;已装插件的浏览器由 navi.js 隐藏。
const extensionInstallURL = "/extension/install.html"
func isFavSection(name string) bool {
return name == favSectionZh || strings.EqualFold(name, favSectionEn)
}
func isHistSection(name string) bool {
return name == histSectionZh || strings.EqualFold(name, histSectionEn)
}
// extensionInstallItem 返回「历史访问」首条的插件安装入口。
func extensionInstallItem() map[string]any {
return map[string]any{
"title": "\u5b89\u88c5\u6d4f\u89c8\u5386\u53f2\u63d2\u4ef6",
"description": "\u4e00\u952e\u5b89\u88c5\uff0c\u81ea\u52a8\u6309\u8bbf\u95ee\u9891\u6b21\u6392\u5e8f",
"icon": "fas fa-download",
"url": extensionInstallURL,
}
}
// rebuildDashySections 读 conf.yml:
// 1. 收集除「历史访问」外所有导航项
// 2. 按各导航 host 在 history 的累计访问统计
// 3. 「常用服务」= 访问频次最高的 5 个导航(不置顶任何服务)
// 4. 「历史访问」= 全部浏览器历史(含站内服务;不设条数上限)
func rebuildDashySections() {
// 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。
rebuildMu.Lock()
defer rebuildMu.Unlock()
confFile := filepath.Join(dataDir, "conf.yml")
data, err := os.ReadFile(confFile)
if err != nil {
return
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return
}
sectionsRaw, ok := cfg["sections"].([]any)
if !ok || len(sectionsRaw) == 0 {
return
}
// 段落名匹配见 isFavSection / isHistSection(兼容中英文)
type secInfo struct {
raw map[string]any
name string
collapsed bool
}
secs := make([]secInfo, 0, len(sectionsRaw))
for _, s := range sectionsRaw {
m, ok := s.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
coll := false
if c, ok := m["collapsed"].(bool); ok {
coll = c
}
secs = append(secs, secInfo{raw: m, name: name, collapsed: coll})
}
// 收集所有导航项(排除历史访问 section 本身),带 host 访问量
navVisits := loadNavVisits() // 导航项点击计数:常用服务的唯一依据
type cand struct {
nav dashyNav
host string
visits int
}
candidates := make([]cand, 0)
for _, si := range secs {
items, _ := si.raw["items"].([]any)
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
desc, _ := m["description"].(string)
icon, _ := m["icon"].(string)
u, _ := m["url"].(string)
if u == "" {
continue
}
host := ""
if pu, err := url.Parse(u); err == nil {
host = strings.ToLower(pu.Hostname())
}
// 由 gateway 管理且勾选“自动获取图标”的导航项也复用同一套
// favicon 缓存。首次没有缓存先显示 globe,后台抓完会再次重建。
if auto, _ := m["naviGatewayAutoIcon"].(bool); auto {
if hp := hostPortOf(u); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name
m["icon"] = name
} else {
queueFavicon(hp)
}
}
}
v := 0
if host != "" {
v = navVisits[host] // 只认导航项自身的点击计数(不掺浏览器历史)
}
if isHistSection(si.name) {
continue
} // 所有导航项(含常用服务原本手动配置的)都进候选,
// 最终由访问量决定去留与顺序
candidates = append(candidates, cand{
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
host: host, visits: v,
})
}
}
// 排序:按访问量降序;同量保持原序
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].visits > candidates[j].visits
})
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
updateFavorites := true
{
anyNav := false
for _, c := range candidates {
if c.visits > 0 {
anyNav = true
break
}
}
// 导航项都没有访问数据时不重排「常用服务」,保留手工配置;
// 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。
updateFavorites = anyNav
}
// host 去重:同 host 只保留访问最高的一条导航项
// (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复)
{
seenH := map[string]bool{}
uniq := candidates[:0]
for _, c := range candidates {
if c.host == "" {
uniq = append(uniq, c)
continue
}
if seenH[c.host] {
continue
}
seenH[c.host] = true
uniq = append(uniq, c)
}
candidates = uniq
}
// 「常用服务」= 所有**被点击过**的导航项,按访问频次降序,**不设条数上限**。
// 曾有一版把 Gitea/代码托管强制放第一位;用户要求取消:每个服务都应归在
// 准确描述其功能的分组里,常用服务只做频次聚合。
// 用户要求(2026-09-13):尽最大可能显示,不要限制条目 —— 原先截断到 top5,
// 现在凡有点击记录(visits > 0)的导航项全部展示;0 次的仍不算"常用",不塞进来
// (否则等于把所有分组复制一份)。
finalNavs := make([]dashyNav, 0, len(candidates))
for _, c := range candidates {
if c.visits <= 0 {
continue
}
finalNavs = append(finalNavs, c.nav)
}
// 更新各 section
for _, si := range secs {
if updateFavorites && isFavSection(si.name) {
newItems := make([]any, 0, len(finalNavs))
for _, n := range finalNavs {
it := map[string]any{
"title": n.title, "description": n.desc,
"icon": n.icon, "url": n.url,
}
if n.desc == "" {
it["description"] = ""
}
newItems = append(newItems, it)
}
si.raw["items"] = newItems
}
}
// 历史访问 = 全部浏览器历史(不设条数上限)。
// 用户要求(2026-09-13):不再排除"已是导航项"的站点 —— 站内服务的真实访问记录
// 也要在这里出现(相当于"最近访问"列表)。此前排除是为了不与其它分组重复,
// 但结果是站内服务永远进不来,这一组几乎是空的。
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
}
}
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
}
}
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
// 所以若只靠用户手动建,功能就一直不会出现。
if !foundHist {
sectionsRaw = append(sectionsRaw, map[string]any{
"name": histSectionZh,
"icon": "fas fa-history",
"collapsed": false,
"items": histItems,
})
fmt.Println("[history] 已补建「历史访问」分组")
}
cfg["sections"] = sectionsRaw
out, err := yaml.Marshal(cfg)
if err != nil {
return
}
tmp := confFile + ".sort-tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return
}
os.Rename(tmp, confFile)
}
// friendlyHistoryTitle 避免把历史项标题直接显示成长 URL。
// 浏览器不给标题、或标题本身就是 URL 时,改用去掉 www. 的简洁域名。
func friendlyHistoryTitle(title, rawURL string) string {
clean := strings.TrimSpace(title)
low := strings.ToLower(clean)
if clean != "" && !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") {
return clean
}
if u, err := url.Parse(rawURL); err == nil {
host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
if host != "" {
return host
}
}
if clean != "" {
return clean
}
return "历史站点"
}
func main() {
os.MkdirAll(dataDir, 0755)
load()
initFavicon()
// 启动时重建一次:让「历史访问」首条的插件安装入口立即生效,
// 而不必等到下一次浏览器插件同步。
rebuildDashySections()
http.HandleFunc("/sync", cors(handleSync))
// 导航项点击计数(对外 /api/history/nav-visit,由 navi.js 上报)
http.HandleFunc("/nav-visit", cors(handleNavVisit))
http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireToken(w, r, syncToken) {
return
}
handleGet(w, r)
case http.MethodDelete:
if !requireToken(w, r, adminToken) {
return
}
handleClear(w, r)
default:
http.Error(w, "method not allowed", 405)
}
}))
http.HandleFunc("/stats", cors(handleStats))
// 管理后台保存 gateway 路由后触发一次导航/收藏重建;只允许容器内
// admin-server 用 admin token 调用,公网 nginx 没有暴露这个路径。
http.HandleFunc("/rebuild", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !requireToken(w, r, adminToken) {
return
}
rebuildDashySections()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "rebuilt"})
}))
// 逐条删除历史(?url=),需 admin token;供管理后台转发调用
http.HandleFunc("/item", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", 405)
return
}
if !requireToken(w, r, adminToken) {
return
}
handleDeleteItem(w, r)
}))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Println("history-server listening on :3004")
log.Fatal(http.ListenAndServe(":3004", nil))
}