// Package stats 汇总所有面向页面的查询。 // 所有查询都以 dataset_id 为根作用域,并共享同一套筛选条件,保证各页面口径一致。 package stats import ( "context" "database/sql" "fmt" "math" "strings" "time" ) // TZ 显示用时区。备份数据全部来自国内,按北京时间切分自然日 / 小时。 const TZ = "Asia/Shanghai" // maxDwellMin 是统计停留时长时的单次上限(7 天)。 // rond 会把「一直待在同一个地方」记成一条超长到访(实测有 35 天的), // 不封顶的话一条就能把总时长和平均时长整个带偏;这类记录本身是真的, // 所以是裁剪而不是剔除。与 handlers_edit.go 的 refreshPlaceAgg 保持一致。 const maxDwellMin = 10080 type Filter struct { DatasetID int64 From *time.Time To *time.Time ActivityIDs []int64 City string Province string TagID int64 MinDwell int ExcludeHome bool ExcludeWork bool Keyword string // Regions 是访客的区域限制列表:省 / 市 / 区任意层级,空表示不限制。 // RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。 Regions []string RegionsExclude bool // Verdict 非 0 时只保留站长标记为该结论的地点:**连同它们名下的到访与位移一起** // (1 推荐 / -1 踩雷)。它不是「只筛地点列表」的镜头——判据下推到 visitsWhere / // movementsWhere,页面上所有数字跟着一起变,否则会出现「点没了、里程还在」这种 // 自相矛盾的视图。 Verdict int } // placeKeyExpr 把 places 某个别名下的坐标换算成 place_key。 // **必须与 Go 侧 rond.PlaceCoordKey 逐位一致**(notes / votes / handlers_images / stats // 各有一份,改一处搜全仓): // // fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000') func placeKeyExpr(alias string) string { return fmt.Sprintf( "to_char(%[1]s.lat,'FM999990.000000')||'|'||to_char(%[1]s.lon,'FM999990.000000')", alias) } type where struct { conds []string args []any // off 是占位符起始偏移,用于把同一套条件嵌进外层查询的子查询里。 off int } func (w *where) ph(v any) string { w.args = append(w.args, v) return fmt.Sprintf("$%d", len(w.args)+w.off) } func (w *where) cond(expr string, vals ...any) { parts := strings.Split(expr, "?") var b strings.Builder b.WriteString(parts[0]) for i, v := range vals { b.WriteString(w.ph(v)) if i+1 < len(parts) { b.WriteString(parts[i+1]) } } w.conds = append(w.conds, b.String()) } func (w *where) inList(col string, ids []int64) { if len(ids) == 0 { return } ph := make([]string, 0, len(ids)) for _, id := range ids { ph = append(ph, w.ph(id)) } w.conds = append(w.conds, col+" IN ("+strings.Join(ph, ",")+")") } // expr 返回不带 WHERE 前缀的条件表达式,供子查询拼接。 func (w *where) expr() string { return strings.Join(w.conds, " AND ") } func (w *where) SQL() string { if len(w.conds) == 0 { return "" } return " WHERE " + w.expr() } func (w *where) andSQL() string { if len(w.conds) == 0 { return "" } return " AND " + strings.Join(w.conds, " AND ") } // SoloVisitSQL 生成「这次到访是独立成立的」SQL 条件(alias 是 visits 表的别名)。 // // 真机把它**并入了别的到访**(ZVISIT.ZMERGEDTO),或它**只是某个父段的一部分**(ZVISIT.ZPARENT)时, // app 既不单独显示它、也不把它算进次数与停留——本站以前把这两种行当成普通到访, // 于是时间线上出现「同一时刻同一地点两条」(实测数据集 9 有 64 条,26 条的到达时间与原件完全相同), // 日停留也跟着重复计入。 // // 判据取派生列 `visits.is_merged`(导入 / 合并时从 entity_raw 算好,见 RefreshMergedFlagsSQL): // 早先是每行去 entity_raw 取回**整行 Core Data JSON** 再解析 raw->>'ZMERGEDTO', // 而 raw 是 TOAST 过的整行——地点库「每个地点 × 5 个子查询」的量级下,一个页面实测要 70 秒。 // // ⚠️ 只在**读**的一侧收口:导出包必须原样带着这些行(rond 靠它们认这次合并发生过), // 所以 internal/rond 与 backup_diff / backup_merge 的实体对齐一律不加这个条件。 // // 判据只此一份,别在别处手抄——抄一份迟早跟这里漂移(区域限制那套就犯过一次)。 func SoloVisitSQL(alias string) string { return "NOT " + alias + ".is_merged" } // RefreshMergedFlagsSQL 从 entity_raw 重算 visits.is_merged。 // 导入、合并(以及任何直接改过留档里 ZMERGEDTO / ZPARENT 的操作)之后都要跑一次。 const RefreshMergedFlagsSQL = `UPDATE visits v SET is_merged = TRUE FROM entity_raw er WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL) AND v.dataset_id = $1 AND NOT v.is_merged` // visitsWhere 构造作用于 visits 表别名 v 的过滤条件(**访客口径**:已并入 / 子段的到访不算数)。 // off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。 func (f Filter) visitsWhere(off int) *where { return f.visitConds(off, true) } // visitConds 是 visitsWhere 的实现。 // // solo=false 时不筛「已并入 / 子段」——天气与位移用的是这一支:那几小时、那段路是真的 // 发生过的,不该因为这次到访在 app 里不单独显示就从统计里消失(实测数据集 9 有 12 条 // 天气行、5 条位移挂在被并入的行上)。 func (f Filter) visitConds(off int, solo bool) *where { w := &where{off: off} w.cond("v.dataset_id = ?", f.DatasetID) // 已并入 / 子段不算一条独立到访(判据见 SoloVisitSQL)。放在这里是为了**全域一致**: // 时间线、首页、统计、城市 / 省份、活动计数、星期分布、地点列的到访数都从这份条件派生。 if solo { w.cond(SoloVisitSQL("v")) } if f.From != nil { w.cond("v.arrival >= ?", *f.From) } if f.To != nil { w.cond("v.arrival < ?", *f.To) } w.inList("v.activity_id", f.ActivityIDs) if f.MinDwell > 0 { w.cond("COALESCE(v.duration_min,0) >= ?", f.MinDwell) } if f.ExcludeHome { w.cond("NOT v.is_home") } if f.ExcludeWork { w.cond("NOT v.is_work") } if f.City != "" { w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city = ?)", f.City) } if f.Province != "" { w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.province = ?)", f.Province) } if f.TagID > 0 { w.cond("EXISTS (SELECT 1 FROM visit_tags vtx WHERE vtx.visit_id=v.id AND vtx.tag_id = ?)", f.TagID) } if len(f.Regions) > 0 { // 省 / 市 / 区三级各来一份占位符:填「广东省」命中全省,填「贵阳市」只命中该市。 // 这里不用 COALESCE:条件包在 EXISTS 里,NULL 只是让子查询查不到行, // 「NOT EXISTS」照样能正确保留不匹配的记录。 var prov, city, dist []string for _, r := range f.Regions { prov = append(prov, w.ph(r)) city = append(city, w.ph(r)) dist = append(dist, w.ph(r)) } expr := `EXISTS (SELECT 1 FROM places pxr WHERE pxr.id=v.place_id AND (pxr.province IN (` + strings.Join(prov, ",") + `) OR pxr.city IN (` + strings.Join(city, ",") + `) OR pxr.district IN (` + strings.Join(dist, ",") + `)))` if f.RegionsExclude { expr = "NOT " + expr } w.conds = append(w.conds, expr) } if f.Keyword != "" { kw := "%" + f.Keyword + "%" w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw) } if f.Verdict != 0 { // 结论挂坐标键(换数据集也跟得住),所以判据是「这次到访所属地点的键」。 // // 放在 visitsWhere 而不是 Places 里,是为了**全域一致**:地图点、城市/省份统计、 // 类型计数、概览,以及靠 movementVisitConds 派生的里程 / 出行数,全都从这一份 // 条件派生出来,不会各认各的。 w.cond(`EXISTS (SELECT 1 FROM place_votes pv JOIN places pvp ON pvp.id = v.place_id WHERE pv.place_key = `+placeKeyExpr("pvp")+` AND pv.verdict = ?)`, f.Verdict) } return w } // hasVisitConds 判断除时间范围外,是否还有「必须落到某次到访上」才能判定的条件。 // // 用于天气:删掉到访时 weather.visit_src 会被置空而不是删行(见 editVisitDelete), // 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该 // 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。 func (f Filter) hasVisitConds() bool { // Verdict 也算:movements/weather 只在「有这类条件」时才会包一层 EXISTS 到访, // 漏掉它就别指望位移会跟着筛选走。 return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork || f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" || f.Verdict != 0 } // weatherWhere 构造作用于 weather 表别名 w 的过滤条件。 // // 时间用**观测时刻 w.at**(不是所属到访的 arrival):筛一段时间,要的是「这段时间里 // 经历的天气」,横跨边界的那次到访也只算落在区间内的那些小时。 // 城市 / 类型 / 标签 / 区域 / 关键词这些条件则挂到它所属的那次到访上判定 // (实测 557/557 行都带 visit_src 且能落到 places)。 func (f Filter) weatherWhere() *where { w := &where{} w.cond("w.dataset_id = ?", f.DatasetID) if f.From != nil { w.cond("w.at >= ?", *f.From) } if f.To != nil { w.cond("w.at < ?", *f.To) } if f.hasVisitConds() { rest := f rest.From, rest.To = nil, nil // 时间已经按 w.at 判过了 vw := rest.visitConds(len(w.args), false) w.conds = append(w.conds, "EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")") w.args = append(w.args, vw.args...) } return w } // movementVisitConds 是「必须落到某次到访上才能判定」的那几档条件(活动 / 最短停留 / // 城市 / 省份 / 标签 / 关键词),专门给位移用——movements 自己只有两端到访, // 没有城市、活动这些字段。 // // 这里**复用 visitsWhere 拼出来的表达式**,而不是再抄一遍:抄一遍迟早跟 visitsWhere // 漂移,漏掉一个维度就是「到访数变了、里程还是全量」(区域那档已经犯过一次)。 // 清掉三类条件,剩下的正是可以按到访判的: // - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径; // - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere; // - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」; // - 已并入 / 子段:位移照算(里程与轨迹是真实发生过的),见 visitConds 的 solo 参数。 func (f Filter) movementVisitConds(off int) *where { rest := f rest.From, rest.To = nil, nil rest.Regions = nil rest.ExcludeHome, rest.ExcludeWork = false, false return rest.visitConds(off, false) } // hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。 // 从 hasVisitConds 派生,免得两处再各写一份清单;减掉的正是 movementVisitConds // 里被清掉的那两类(家工作、区域),它们由 movementsWhere 自己判。 func (f Filter) hasMovementVisitConds() bool { rest := f rest.ExcludeHome, rest.ExcludeWork = false, false rest.Regions = nil return rest.hasVisitConds() } // movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。 // // 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places // 判定——任一端落在被隐藏的区域,这段里程 / 这次出行就不该出现在访客看到的数据里 // (地图上的轨迹图层用的是同一套判定)。 func (f Filter) movementsWhere(off int) *where { w := &where{off: off} w.cond("movements.dataset_id = ?", f.DatasetID) if f.From != nil { w.cond("movements.started_at >= ?", *f.From) } if f.To != nil { w.cond("movements.started_at < ?", *f.To) } if f.ExcludeHome || f.ExcludeWork { var flags []string if f.ExcludeHome { flags = append(flags, "vf.is_home") } if f.ExcludeWork { flags = append(flags, "vf.is_work") } w.cond(`NOT EXISTS ( SELECT 1 FROM visits vf WHERE vf.dataset_id = movements.dataset_id AND (vf.src_pk = movements.from_visit_src OR vf.src_pk = movements.to_visit_src) AND (` + strings.Join(flags, " OR ") + `))`) } if len(f.Regions) > 0 { // 每一段都要**两端分别**过同一套判定,与地图轨迹图层保持一致:apiTrack 就是 // 用 regionSQL 拼出 pf / pt 两段 AND 起来的。只按「任一端命中」的话,白名单下 // 同一段里程在图层里画不出来、却算进了总里程(黑名单下两种写法恰好等价, // 所以这个洞只在白名单时露出来)。 phs := make([]string, 0, len(f.Regions)) for _, r := range f.Regions { phs = append(phs, w.ph(r)) } endpoint := func(col string) string { var conds []string for _, ph := range phs { // 必须 COALESCE:district 常为 NULL,NULL = 'x' 得 NULL, // 黑名单的 NOT (...) 也跟着变 NULL,整行被误删(regionSQL 同样处理)。 conds = append(conds, "COALESCE(pmv.province,'') = "+ph, "COALESCE(pmv.city,'') = "+ph, "COALESCE(pmv.district,'') = "+ph) } expr := "EXISTS (SELECT 1 FROM places pmv WHERE pmv.id = movements." + col + " AND (" + strings.Join(conds, " OR ") + "))" if f.RegionsExclude { return "NOT " + expr } return expr } w.conds = append(w.conds, endpoint("from_place_id"), endpoint("to_place_id")) } // 活动 / 城市 / 省份 / 标签 / 最短停留 / 关键词这几档只长在到访上,位移得靠两端 // 的到访带过来,否则筛完之后「到访数变了、里程和出行数还是全量」。 // 判定用「**任一端**命中即算」——和上面区域那条同一个理由:位移横跨两个地点, // 改成「两端都得命中」会让城际段在每张筛选视图里都消失,而「任一端」才和到访列表 // 对得上(一次通勤的起点和终点,在两边各自的视图里都该看得到)。 // 两端都关联不到到访的位移(真机有 31/791 这种)在带这类筛选时不参与: // 它们没法证明自己落在筛选范围内。 if f.hasMovementVisitConds() { vc := f.movementVisitConds(len(w.args)) w.conds = append(w.conds, `EXISTS ( SELECT 1 FROM visits v WHERE v.dataset_id = movements.dataset_id AND (v.src_pk = movements.from_visit_src OR v.src_pk = movements.to_visit_src) AND `+vc.expr()+`)`) w.args = append(w.args, vc.args...) } return w } type DB struct{ *sql.DB } // ---------- 总览 ---------- type Overview struct { Places int Cities int Provinces int Days int Visits int DwellMin int64 DistanceKm float64 Movements int FirstAt *time.Time LastAt *time.Time } func (d DB) Overview(ctx context.Context, f Filter) (Overview, error) { var o Overview w := f.visitsWhere(0) q := `SELECT count(DISTINCT v.place_id), count(DISTINCT p.city), count(DISTINCT p.province), count(DISTINCT (v.arrival AT TIME ZONE '` + TZ + `')::date), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), min(v.arrival), max(v.arrival) FROM visits v LEFT JOIN places p ON p.id = v.place_id` + w.SQL() err := d.QueryRowContext(ctx, q, w.args...).Scan(&o.Places, &o.Cities, &o.Provinces, &o.Days, &o.Visits, &o.DwellMin, &o.FirstAt, &o.LastAt) if err != nil { return o, err } mw := f.movementsWhere(0) if err := d.QueryRowContext(ctx, `SELECT COALESCE(sum(distance_km),0), count(*) FROM movements`+mw.SQL(), mw.args...).Scan(&o.DistanceKm, &o.Movements); err != nil { return o, err } return o, nil } // ---------- 类型分布 ---------- type ActivityStat struct { ID int64 Name string Color string Icon string Count int Dwell int64 Places int } func (d DB) ActivityStats(ctx context.Context, f Filter) ([]ActivityStat, error) { w := f.visitsWhere(0) q := `SELECT COALESCE(a.id,0), COALESCE(a.name,'未分类'), COALESCE(a.color,''), COALESCE(a.icon,''), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), count(DISTINCT v.place_id) FROM visits v LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() + ` GROUP BY 1,2,3,4 ORDER BY 5 DESC` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []ActivityStat for rows.Next() { var a ActivityStat if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell, &a.Places); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // Activities 列出全部活动类型,供筛选器使用。 func (d DB) Activities(ctx context.Context, datasetID int64) ([]ActivityStat, error) { rows, err := d.QueryContext(ctx, `SELECT a.id, a.name, COALESCE(a.color,''), COALESCE(a.icon,''), (SELECT count(*) FROM visits v WHERE v.activity_id=a.id) n, COALESCE((SELECT sum(LEAST(COALESCE(v.duration_min,0), 10080)) FROM visits v WHERE v.activity_id=a.id),0) FROM activities a WHERE a.dataset_id=$1 ORDER BY n DESC, a.name`, datasetID) if err != nil { return nil, err } defer rows.Close() var out []ActivityStat for rows.Next() { var a ActivityStat if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // ActivityCounts 返回「类型 → 到访次数」,给筛选器上的类型标签显示数字用。 // // 用的是**除类型自身以外**的全部当前条件。以前标签上的数字取自 Activities(datasetID)、 // 一个条件都不带,选了城市之后还是全库的数(站长实测反馈:「这个城市下家/工作各去过 // 几次」看不出来);但也不能把 ActivityIDs 一起套进去——那样没选中的类型会全变成 0, // 标签就失去意义了。 // 区域限制照常生效(Regions 在 visitsWhere 里),访客不该从计数反推被隐藏区域的量级。 func (d DB) ActivityCounts(ctx context.Context, f Filter) (map[int64]int, error) { f.ActivityIDs = nil w := f.visitsWhere(0) rows, err := d.QueryContext(ctx, `SELECT COALESCE(v.activity_id,0), count(*) FROM visits v`+w.SQL()+` GROUP BY 1`, w.args...) if err != nil { return nil, err } defer rows.Close() out := map[int64]int{} for rows.Next() { var id int64 var n int if err := rows.Scan(&id, &n); err != nil { return nil, err } out[id] = n } return out, rows.Err() } // ---------- 城市 / 省份 ---------- type CityStat struct { City string Count int Places int Dwell int64 } func (d DB) CityStats(ctx context.Context, f Filter, limit int) ([]CityStat, error) { w := f.visitsWhere(0) w.cond("p.city IS NOT NULL AND p.city <> ''") q := `SELECT p.city, count(*), count(DISTINCT v.place_id), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0) FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + ` GROUP BY p.city ORDER BY 2 DESC LIMIT ` + fmt.Sprint(limit) rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []CityStat for rows.Next() { var c CityStat if err := rows.Scan(&c.City, &c.Count, &c.Places, &c.Dwell); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } type ProvinceStat struct { Province string Cities int Count int Places int } func (d DB) ProvinceStats(ctx context.Context, f Filter) ([]ProvinceStat, error) { w := f.visitsWhere(0) w.cond("p.province IS NOT NULL AND p.province <> ''") q := `SELECT p.province, count(DISTINCT p.city), count(*), count(DISTINCT v.place_id) FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + ` GROUP BY p.province ORDER BY 3 DESC` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []ProvinceStat for rows.Next() { var p ProvinceStat if err := rows.Scan(&p.Province, &p.Cities, &p.Count, &p.Places); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // Cities 返回去重后的城市名列表(按到访地点数降序),供筛选下拉框使用。 // regions 非空时对访客按区域过滤:exclude=true 为黑名单(命中即排除), // false 为白名单(命中才保留)。省/市/区三级都参与匹配,且必须 COALESCE—— // district 常为 NULL,NULL = 'x' 得到 NULL,黑名单的 NOT (...) 会变 NULL, // 整行被静默丢弃。传空 regions 表示不限制。 func (d DB) Cities(ctx context.Context, datasetID int64, regions []string, exclude bool) ([]string, error) { args := []any{datasetID} regSQL := "" if len(regions) > 0 { var conds []string for _, r := range regions { args = append(args, r) ph := fmt.Sprintf("$%d", len(args)) conds = append(conds, "COALESCE(p.province,'') = "+ph, "COALESCE(p.city,'') = "+ph, "COALESCE(p.district,'') = "+ph) } cond := "(" + strings.Join(conds, " OR ") + ")" if exclude { cond = "NOT " + cond } regSQL = " AND " + cond } rows, err := d.QueryContext(ctx, `SELECT p.city FROM places p WHERE p.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''`+regSQL+` GROUP BY p.city ORDER BY count(*) DESC`, args...) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var c string if err := rows.Scan(&c); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // ---------- 时间分布 ---------- type Bucket struct { Key string Label string Count int Dwell int64 } func (d DB) Monthly(ctx context.Context, f Filter) ([]Bucket, error) { w := f.visitsWhere(0) q := `SELECT to_char(date_trunc('month', v.arrival AT TIME ZONE '` + TZ + `'), 'YYYY-MM'), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0) FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1` return d.buckets(ctx, q, w.args, false) } // Calendar 返回每天的记录数,用于活跃日历。 func (d DB) Calendar(ctx context.Context, f Filter) (map[string]int, error) { w := f.visitsWhere(0) q := `SELECT to_char((v.arrival AT TIME ZONE '` + TZ + `')::date, 'YYYY-MM-DD'), count(*) FROM visits v` + w.SQL() + ` GROUP BY 1` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() out := map[string]int{} for rows.Next() { var k string var n int if err := rows.Scan(&k, &n); err != nil { return nil, err } out[k] = n } return out, rows.Err() } func (d DB) Hourly(ctx context.Context, f Filter) ([]Bucket, error) { w := f.visitsWhere(0) q := `SELECT to_char(extract(hour from v.arrival AT TIME ZONE '` + TZ + `')::int, 'FM00'), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0) FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1` return d.buckets(ctx, q, w.args, false) } func (d DB) Weekday(ctx context.Context, f Filter) ([]Bucket, error) { w := f.visitsWhere(0) q := `SELECT extract(isodow from v.arrival AT TIME ZONE '` + TZ + `')::int::text, count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0) FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1` b, err := d.buckets(ctx, q, w.args, false) if err != nil { return nil, err } names := map[string]string{"1": "周一", "2": "周二", "3": "周三", "4": "周四", "5": "周五", "6": "周六", "7": "周日"} for i := range b { b[i].Label = names[b[i].Key] } return b, nil } func (d DB) buckets(ctx context.Context, q string, args []any, withLabel bool) ([]Bucket, error) { rows, err := d.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var out []Bucket for rows.Next() { var b Bucket if err := rows.Scan(&b.Key, &b.Count, &b.Dwell); err != nil { return nil, err } b.Label = b.Key out = append(out, b) } return out, rows.Err() } // ---------- 出行方式 ---------- type TransportStat struct { Name string Color string Icon string Count int Distance float64 DwellMin int64 } func (d DB) TransportStats(ctx context.Context, f Filter) ([]TransportStat, error) { w := f.movementsWhere(0) q := `SELECT COALESCE(transport_name,'未知'), COALESCE(transport_color,''), COALESCE(transport_icon,''), count(*), COALESCE(sum(distance_km),0), COALESCE(sum(duration_min),0) FROM movements` + w.SQL() + ` GROUP BY 1,2,3 ORDER BY 4 DESC` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []TransportStat for rows.Next() { var t TransportStat if err := rows.Scan(&t.Name, &t.Color, &t.Icon, &t.Count, &t.Distance, &t.DwellMin); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // ---------- 标签 ---------- type TagStat struct { ID int64 Name string Color string Group string Count int } func (d DB) TagStats(ctx context.Context, f Filter) ([]TagStat, error) { w := f.visitsWhere(0) q := `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''), count(*) FROM visit_tags vt JOIN tags t ON t.id = vt.tag_id JOIN visits v ON v.id = vt.visit_id` + w.SQL() + ` GROUP BY 1,2,3,4 ORDER BY 5 DESC` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []TagStat for rows.Next() { var t TagStat if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func (d DB) Tags(ctx context.Context, datasetID int64) ([]TagStat, error) { rows, err := d.QueryContext(ctx, `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''), (SELECT count(*) FROM visit_tags vt WHERE vt.tag_id=t.id) FROM tags t WHERE t.dataset_id=$1 ORDER BY 5 DESC, 2`, datasetID) if err != nil { return nil, err } defer rows.Close() var out []TagStat for rows.Next() { var t TagStat if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // ---------- 地点 ---------- type Place struct { ID int64 Name string POICategory string Lat float64 Lon float64 City string Province string District string Timezone string VisitCount int DwellMinutes int64 FirstAt *time.Time LastAt *time.Time TopActivity string TopColor string TopIcon string IsHome bool IsWork bool // HasImage 报告该地点有没有上传过图片(地点库列表的那一列)。 // 走 place_images 的 (dataset_id, src_pk) 索引,不必把图片行取回来。 HasImage bool // SrcPK 是 rond 内部的地点 ID,跨数据集快照稳定,用于挂备注与别名 SrcPK int // Alias / Note 来自 place_notes:别名对外替代原名显示,备注仅站长可见 Alias string Note string // Verdict / Up / Down 是「推荐 / 踩雷」的站长结论与访客票数, // 由查询后单独填充(它们按 src_pk 存在另外两张表里)。 Verdict int Up int Down int } // 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id) // 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。 // 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。 var placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''), p.visit_count, p.dwell_minutes, -- 首末到访这两列**存的是全量**(last_visit_at 会写成导出包里的 ZLOCATION.ZLASTARRIVALDATE_, -- 不能在库里改口径)。于是「到访数按访客口径、日期按全量」会凑出「0 次却有最近到访」这种 -- 自相矛盾的行(实测数据集 9 有 4 个地点名下只剩已并入 / 子段到访),这里按可见到访数兜一下。 CASE WHEN p.visit_count > 0 THEN p.first_visit_at END, CASE WHEN p.visit_count > 0 THEN p.last_visit_at END, COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''), COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''), COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''), EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home AND ` + SoloVisitSQL("v3") + `), EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work AND ` + SoloVisitSQL("v4") + `), p.src_pk, EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key = to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))` // PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。 var PlaceCols = placeCols func scanPlace(s interface{ Scan(...any) error }) (Place, error) { var p Place err := s.Scan(&p.ID, &p.Name, &p.POICategory, &p.Lat, &p.Lon, &p.City, &p.Province, &p.District, &p.Timezone, &p.VisitCount, &p.DwellMinutes, &p.FirstAt, &p.LastAt, &p.TopActivity, &p.TopColor, &p.TopIcon, &p.IsHome, &p.IsWork, &p.SrcPK, &p.HasImage) return p, err } // ScanPlace 把一行按 placeCols 列序扫描进 Place。 func ScanPlace(s interface{ Scan(...any) error }, p *Place) error { got, err := scanPlace(s) if err != nil { return err } *p = got return nil } // Places 分页返回地点库;筛选条件最终落在 visits 上,确保与地图口径一致。 func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset int) ([]Place, int, error) { inner := f.visitsWhere(1) inner.cond("v.place_id = p.id") order := "p.visit_count DESC, p.dwell_minutes DESC, p.name" switch sortBy { case "dwell": order = "p.dwell_minutes DESC, p.visit_count DESC" case "recent": order = "p.last_visit_at DESC NULLS LAST" case "name": order = "p.name" case "first": order = "p.first_visit_at ASC NULLS LAST" } args := append([]any{f.DatasetID}, inner.args...) // verdict 条件已经在 visitsWhere 里,这里不用再补一份(补了就是同一个 EXISTS 跑两遍)。 base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)` q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset) rows, err := d.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var out []Place for rows.Next() { p, err := scanPlace(rows) if err != nil { return nil, 0, err } out = append(out, p) } if err := rows.Err(); err != nil { return nil, 0, err } var total int if err := d.QueryRowContext(ctx, `SELECT count(*) `+base, args...).Scan(&total); err != nil { return nil, 0, err } return out, total, nil } // MapPlaces 返回地图所需的轻量地点集合。 func (d DB) MapPlaces(ctx context.Context, f Filter, limit int) ([]Place, error) { inner := f.visitsWhere(1) inner.cond("v.place_id = p.id") q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `) ORDER BY p.visit_count DESC LIMIT ` + fmt.Sprint(limit) args := append([]any{f.DatasetID}, inner.args...) rows, err := d.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var out []Place for rows.Next() { p, err := scanPlace(rows) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (d DB) Place(ctx context.Context, datasetID, id int64) (Place, error) { q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id=$1 AND p.id=$2` return scanPlace(d.QueryRowContext(ctx, q, datasetID, id)) } // ---------- 访问记录 ---------- type VisitRow struct { ID int64 SrcPK int Arrival time.Time Departure *time.Time DurationMin int PlaceID *int64 PlaceName string City string POICategory string ActivityName string Color string Icon string IsHome bool IsWork bool Remark string Tags []string } const visitCols = `v.id, v.src_pk, v.arrival, v.departure, COALESCE(v.duration_min,0), v.place_id, COALESCE(p.name,''), COALESCE(p.city,''), COALESCE(p.poi_category,''), COALESCE(a.name,''), COALESCE(a.color,''), COALESCE(a.icon,''), v.is_home, v.is_work, COALESCE(v.remark,''), COALESCE((SELECT string_agg(t.name, '、' ORDER BY t.name) FROM visit_tags vt JOIN tags t ON t.id=vt.tag_id WHERE vt.visit_id = v.id), '')` func scanVisit(s interface{ Scan(...any) error }) (VisitRow, error) { var v VisitRow var tags string err := s.Scan(&v.ID, &v.SrcPK, &v.Arrival, &v.Departure, &v.DurationMin, &v.PlaceID, &v.PlaceName, &v.City, &v.POICategory, &v.ActivityName, &v.Color, &v.Icon, &v.IsHome, &v.IsWork, &v.Remark, &tags) if tags != "" { v.Tags = strings.Split(tags, "、") } return v, err } func (d DB) Visits(ctx context.Context, f Filter, limit, offset int, order string) ([]VisitRow, int, error) { w := f.visitsWhere(0) dir := "DESC" if order == "asc" { dir = "ASC" } q := `SELECT ` + visitCols + ` FROM visits v LEFT JOIN places p ON p.id = v.place_id LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() + ` ORDER BY v.arrival ` + dir + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset) rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, 0, err } defer rows.Close() var out []VisitRow for rows.Next() { v, err := scanVisit(rows) if err != nil { return nil, 0, err } out = append(out, v) } if err := rows.Err(); err != nil { return nil, 0, err } var total int cq := `SELECT count(*) FROM visits v` + w.SQL() if err := d.QueryRowContext(ctx, cq, w.args...).Scan(&total); err != nil { return nil, 0, err } return out, total, nil } // RecentVisits 最近足迹,主页使用。 func (d DB) RecentVisits(ctx context.Context, f Filter, limit int) ([]VisitRow, error) { w := f.visitsWhere(0) q := `SELECT ` + visitCols + ` FROM visits v LEFT JOIN places p ON p.id = v.place_id LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() + ` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit) rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []VisitRow for rows.Next() { v, err := scanVisit(rows) if err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func (d DB) VisitsByPlace(ctx context.Context, datasetID, placeID int64, limit int) ([]VisitRow, error) { q := `SELECT ` + visitCols + ` FROM visits v LEFT JOIN places p ON p.id = v.place_id LEFT JOIN activities a ON a.id = v.activity_id WHERE v.dataset_id=$1 AND v.place_id=$2 AND ` + SoloVisitSQL("v") + ` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit) rows, err := d.QueryContext(ctx, q, datasetID, placeID) if err != nil { return nil, err } defer rows.Close() var out []VisitRow for rows.Next() { v, err := scanVisit(rows) if err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func (d DB) Bookmarked(ctx context.Context, datasetID int64, limit int) ([]VisitRow, error) { q := `SELECT ` + visitCols + ` FROM visits v LEFT JOIN places p ON p.id = v.place_id LEFT JOIN activities a ON a.id = v.activity_id WHERE v.dataset_id=$1 AND v.bookmarked AND ` + SoloVisitSQL("v") + ` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit) rows, err := d.QueryContext(ctx, q, datasetID) if err != nil { return nil, err } defer rows.Close() var out []VisitRow for rows.Next() { v, err := scanVisit(rows) if err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // ---------- 天气 ---------- type WeatherStat struct { Condition string Symbol string Count int AvgTemp float64 MinTemp float64 MaxTemp float64 } func (d DB) WeatherStats(ctx context.Context, f Filter) ([]WeatherStat, error) { // 这里以前只用了 f.DatasetID,筛选(时间范围 / 城市 / 类型 …)对它完全不起作用—— // 统计页筛完之后「气温」还是全量汇总,就是这么来的。 w := f.weatherWhere() q := `SELECT COALESCE(w.condition,'未知'), COALESCE(w.symbol,''), count(*), COALESCE(avg(w.temperature_c),0)-273.15, COALESCE(min(w.temperature_c),0)-273.15, COALESCE(max(w.temperature_c),0)-273.15 FROM weather w` + w.SQL() + ` GROUP BY 1,2 ORDER BY 3 DESC LIMIT 12` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []WeatherStat for rows.Next() { var w WeatherStat if err := rows.Scan(&w.Condition, &w.Symbol, &w.Count, &w.AvgTemp, &w.MinTemp, &w.MaxTemp); err != nil { return nil, err } out = append(out, w) } return out, rows.Err() } // TempBucket 是逐月平均气温。 // // 原先这里借用 Bucket,把「平均气温」塞进了 Dwell 字段(因为 buckets() 的第三列一律读成 // Dwell),读代码的人根本猜不到,所以单开一个结构。 type TempBucket struct { Key string // 2026-09 Label string // 标签用短写(26-09),十几根柱子横排时 7 个字符会挤在一起 Hours int // 这个月有多少小时有记录 AvgC float64 // 平均气温(摄氏度) } // HourlyTemp 逐月平均气温(同样跟着筛选走)。 func (d DB) HourlyTemp(ctx context.Context, f Filter) ([]TempBucket, error) { w := f.weatherWhere() w.conds = append(w.conds, "w.temperature_c IS NOT NULL") q := `SELECT to_char(date_trunc('month', w.at AT TIME ZONE '` + TZ + `'), 'YYYY-MM'), count(*), COALESCE(avg(w.temperature_c)-273.15, 0) FROM weather w` + w.SQL() + ` GROUP BY 1 ORDER BY 1` rows, err := d.QueryContext(ctx, q, w.args...) if err != nil { return nil, err } defer rows.Close() var out []TempBucket for rows.Next() { var b TempBucket if err := rows.Scan(&b.Key, &b.Hours, &b.AvgC); err != nil { return nil, err } b.AvgC = math.Round(b.AvgC*10) / 10 if len(b.Key) >= 7 { // 2026-09 → 26-09 b.Label = b.Key[2:] } else { b.Label = b.Key } out = append(out, b) } return out, rows.Err() } // ConditionName 把 Apple 的天气枚举翻成中文。 func ConditionName(c string) string { m := map[string]string{ "clear": "晴", "mostlyClear": "晴间多云", "partlyCloudy": "多云", "mostlyCloudy": "阴天多云", "cloudy": "阴", "foggy": "有雾", "haze": "霾", "mostlyCloudyNight": "夜间多云", "drizzle": "毛毛雨", "rain": "雨", "heavyRain": "大雨", "showers": "阵雨", "thunderstorms": "雷雨", "snow": "雪", "heavySnow": "大雪", "sleet": "雨夹雪", "windy": "大风", "blizzard": "暴风雪", "freezingRain": "冻雨", "hot": "高温", "cold": "寒冷", } if v, ok := m[c]; ok { return v } return c }