审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
+100
-26
@@ -36,10 +36,23 @@ type Filter struct {
|
||||
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
|
||||
Regions []string
|
||||
RegionsExclude bool
|
||||
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。
|
||||
// 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
|
||||
@@ -148,6 +161,15 @@ func (f Filter) visitsWhere(off int) *where {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -157,8 +179,11 @@ func (f Filter) visitsWhere(off int) *where {
|
||||
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
|
||||
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
|
||||
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.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" ||
|
||||
f.Verdict != 0
|
||||
}
|
||||
|
||||
// weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
|
||||
@@ -187,6 +212,34 @@ func (f Filter) weatherWhere() *where {
|
||||
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
|
||||
@@ -215,20 +268,45 @@ func (f Filter) movementsWhere(off int) *where {
|
||||
AND (` + strings.Join(flags, " OR ") + `))`)
|
||||
}
|
||||
if len(f.Regions) > 0 {
|
||||
var prov, city, dist []string
|
||||
// 每一段都要**两端分别**过同一套判定,与地图轨迹图层保持一致:apiTrack 就是
|
||||
// 用 regionSQL 拼出 pf / pt 两段 AND 起来的。只按「任一端命中」的话,白名单下
|
||||
// 同一段里程在图层里画不出来、却算进了总里程(黑名单下两种写法恰好等价,
|
||||
// 所以这个洞只在白名单时露出来)。
|
||||
phs := make([]string, 0, len(f.Regions))
|
||||
for _, r := range f.Regions {
|
||||
prov = append(prov, w.ph(r))
|
||||
city = append(city, w.ph(r))
|
||||
dist = append(dist, w.ph(r))
|
||||
phs = append(phs, w.ph(r))
|
||||
}
|
||||
expr := `EXISTS (SELECT 1 FROM places pmv
|
||||
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id)
|
||||
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` +
|
||||
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))`
|
||||
if f.RegionsExclude {
|
||||
expr = "NOT " + expr
|
||||
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, 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
|
||||
}
|
||||
@@ -654,17 +732,20 @@ type Place struct {
|
||||
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 GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
|
||||
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 GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
|
||||
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 GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
|
||||
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home),
|
||||
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work),
|
||||
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'))`
|
||||
@@ -707,15 +788,8 @@ func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset i
|
||||
}
|
||||
|
||||
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() + `)`
|
||||
if f.Verdict != 0 {
|
||||
// 结论挂坐标键(换数据集也要跟得住),按本数据集的地点圈定范围;
|
||||
// 占位符接在内层条件之后。
|
||||
args = append(args, f.Verdict)
|
||||
base += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM place_votes pv WHERE pv.place_key =
|
||||
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')
|
||||
AND pv.verdict = $%d)`, len(args))
|
||||
}
|
||||
q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
|
||||
|
||||
rows, err := d.QueryContext(ctx, q, args...)
|
||||
|
||||
Reference in New Issue
Block a user