Files
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

189 lines
8.0 KiB
Go
Raw Permalink 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 notes 存放站长为地点加的别名与备注。
//
// 备注挂的是**坐标键**(place_key)而不是 rond 的地点编号:src_pk 只在单个数据集内有效
// (rond 每次导出都会重排 Z_PK,实测两份备份里 822 条到访的 UUID 一致而 Z_PK 100% 不同),
// 换个数据集同编号就是另一个地点,备注会全部失联。坐标是地理事实,跨数据集稳定。
package notes
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
type Note struct {
SrcPK int
Alias string
Content string
RondNote string
UpdatedAt time.Time
}
type Store struct{ DB *sql.DB }
// placeKeyExpr 把某个 places 别名下的坐标换算成 place_key。
// **必须与 Go 侧的 rond.PlaceCoordKey 逐位一致**:
//
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
//
// 已在真实数据上逐条比对(368/368 相同)。改任何一边都要重新比。
func placeKeyExpr(alias string) string {
return fmt.Sprintf(
"to_char(%[1]s.lat,'FM999990.000000')||'|'||to_char(%[1]s.lon,'FM999990.000000')", alias)
}
// All 返回某个数据集里「有备注的地点」,键是地点在该数据集内的编号。
// 站点只有一个站长账号,读的时候不按 user_id 过滤(匿名访客也要看到别名)。
func (s *Store) All(ctx context.Context, datasetID int64) (map[int]Note, error) {
out := map[int]Note{}
rows, err := s.DB.QueryContext(ctx, `
SELECT p.src_pk, n.alias, n.note, n.rond_note, n.updated_at
FROM places p
JOIN place_notes n ON n.place_key = `+placeKeyExpr("p")+`
WHERE p.dataset_id = $1 AND p.lat IS NOT NULL AND p.lon IS NOT NULL`, datasetID)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var n Note
if err := rows.Scan(&n.SrcPK, &n.Alias, &n.Content, &n.RondNote, &n.UpdatedAt); err != nil {
return out, err
}
out[n.SrcPK] = n
}
return out, rows.Err()
}
// SetNote 只改备注、保留别名(批量「清空备注」用);两者都为空则删除记录。
func (s *Store) SetNote(ctx context.Context, userID, datasetID int64, srcPK int, content string) error {
var alias string
err := s.DB.QueryRowContext(ctx, `SELECT n.alias FROM place_notes n
JOIN places p ON n.place_key = `+placeKeyExpr("p")+`
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk=$3`, userID, datasetID, srcPK).Scan(&alias)
if err != nil && err != sql.ErrNoRows {
return err
}
return s.Save(ctx, userID, datasetID, srcPK, alias, content)
}
// SetNoteBulk 批量「清空备注、保留别名」,语义与逐条 SetNote(..., "") 一致,但只发两条语句
// (逐个来是 2N 次往返,勾几十个地点在 1C1G 的机器上是能感觉到的):
// 先删掉「别名与 rond 备注都为空」的行,再把其余行的手写 note 清空。
func (s *Store) SetNoteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error {
if len(srcPKs) == 0 {
return nil
}
if _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
AND n.place_key = `+placeKeyExpr("p")+`
AND btrim(COALESCE(n.alias,''))='' AND n.rond_note=''`,
userID, datasetID, srcPKs); err != nil {
return err
}
// 顺带把 dataset_id/src_pk 刷成当前数据集的值,与逐条 Save 的 upsert 行为对齐
_, err := s.DB.ExecContext(ctx, `UPDATE place_notes n
SET note='', dataset_id=p.dataset_id, src_pk=p.src_pk, updated_at=now()
FROM places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs)
return err
}
// Delete 删除某地点的整条备注与别名。
func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk=$3
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPK)
return err
}
// DeleteBulk 一次删掉多个地点的整条备注与别名(后台批量勾选时用,一条语句代替 N 次往返)。
func (s *Store) DeleteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error {
if len(srcPKs) == 0 {
return nil
}
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs)
return err
}
// Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。
func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error {
alias = strings.TrimSpace(alias)
content = strings.TrimSpace(content)
if alias == "" && content == "" {
// 没有 rond 备注时直接删行;有则只清空手写部分,别把回填结果一起删掉
if _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk=$3
AND n.place_key = `+placeKeyExpr("p")+` AND n.rond_note=''`,
userID, datasetID, srcPK); err != nil {
return err
}
_, err := s.DB.ExecContext(ctx, `UPDATE place_notes n
SET alias='', note='', updated_at=now()
FROM places p
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk=$3
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPK)
return err
}
// 先按坐标键算出这条地点该挂在哪,找不到该地点就什么也不写
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_notes
(user_id, place_key, dataset_id, src_pk, alias, note, updated_at)
SELECT $1, `+placeKeyExpr("p")+`, $2, $3, $4, $5, now()
FROM places p
WHERE p.dataset_id=$2 AND p.src_pk=$3 AND p.lat IS NOT NULL AND p.lon IS NOT NULL
ON CONFLICT (user_id, place_key) DO UPDATE SET
alias=EXCLUDED.alias, note=EXCLUDED.note,
dataset_id=EXCLUDED.dataset_id, src_pk=EXCLUDED.src_pk, updated_at=now()`,
userID, datasetID, srcPK, alias, content)
return err
}
// BackfillRond 把 rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总进 rond_note:
// 同一地点多条备注全部保留,去重后按时间升序、每条一行并带上日期。
// 只重写 rond_note 一列,站长手写的 note 一概不动。返回写了多少行。
func (s *Store) BackfillRond(ctx context.Context, userID, datasetID int64) (int, error) {
// DISTINCT 在「日期 + 内容」这一层做,同一天重复写的同一句只留一条
agg := `
WITH raw AS (
SELECT ` + placeKeyExpr("p") + ` AS key,
to_char(v.arrival, 'YYYY-MM-DD') || ' ' || btrim(v.remark) AS line
FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id = $1 AND btrim(COALESCE(v.remark, '')) <> ''
AND p.lat IS NOT NULL AND p.lon IS NOT NULL
), uniq AS (
SELECT DISTINCT key, line, left(line, 10) AS day FROM raw
), agg AS (
SELECT key, string_agg(line, E'\n' ORDER BY day, line) AS txt FROM uniq GROUP BY key
)`
res, err := s.DB.ExecContext(ctx, agg+`
INSERT INTO place_notes (user_id, place_key, dataset_id, src_pk, alias, note, rond_note, updated_at)
SELECT $2, key, $1, 0, '', '', txt, now() FROM agg
ON CONFLICT (user_id, place_key) DO UPDATE SET rond_note = EXCLUDED.rond_note`,
datasetID, userID)
if err != nil {
return 0, err
}
affected, _ := res.RowsAffected()
// 备注被删掉的地点要清掉旧值,否则会一直留着过期的 rond 内容。
// 限定在本数据集的地点里:datasetID 不合法时子查询为空,一行都不会动。
if _, err := s.DB.ExecContext(ctx, `
UPDATE place_notes n SET rond_note = ''
WHERE n.user_id = $2 AND n.rond_note <> ''
AND n.place_key IN (SELECT `+placeKeyExpr("p")+`
FROM places p WHERE p.dataset_id = $1 AND p.lat IS NOT NULL AND p.lon IS NOT NULL)
AND NOT EXISTS (
SELECT 1 FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id = $1 AND `+placeKeyExpr("p")+` = n.place_key
AND btrim(COALESCE(v.remark, '')) <> ''
)`, datasetID, userID); err != nil {
return 0, err
}
return int(affected), nil
}