audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
1075 lines
36 KiB
Go
1075 lines
36 KiB
Go
// 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 ")
|
||
}
|
||
|
||
// visitsWhere 构造作用于 visits 表别名 v 的过滤条件。
|
||
// off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。
|
||
func (f Filter) visitsWhere(off int) *where {
|
||
w := &where{off: off}
|
||
w.cond("v.dataset_id = ?", f.DatasetID)
|
||
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.visitsWhere(len(w.args))
|
||
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 会松成「任一端不是」。
|
||
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.visitsWhere(off)
|
||
}
|
||
|
||
// 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 必然相同),纯粹是给计划器的。
|
||
const 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, p.first_visit_at, p.last_visit_at,
|
||
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 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 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 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),
|
||
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work),
|
||
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 配对,供包外构造只取单个地点的查询。
|
||
const 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 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 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
|
||
}
|