审计修复:后端(安全 / 数据一致性 / 性能)

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:
tamakyi
2026-09-24 20:37:16 +08:00
parent 78865fc736
commit b4c2a8452f
70 changed files with 5417 additions and 360 deletions
+261 -150
View File
@@ -14,6 +14,7 @@ import (
"time"
"rond-with-you/internal/geo"
"rond-with-you/internal/rond"
)
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
@@ -184,7 +185,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
`+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`,
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon,
COALESCE((SELECT string_agg(x.n || ' ' || x.c, ' · ' ORDER BY x.c DESC, x.n) FROM (
SELECT COALESCE(a.name,'(无)') AS n, count(*) AS c FROM visits v
@@ -631,8 +632,10 @@ func (s *Server) editOptions(ctx context.Context, query string, args ...any) ([]
// 就会复用同一个 src_pk,于是新行继承旧留档的 rond 私有字段(ZRADIUS/ZRAW 等)。
// skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。
// 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。
func (s *Server) pruneEntityRaw(ctx context.Context, datasetID int64) {
if _, err := s.db.ExecContext(ctx, `DELETE FROM entity_raw r
// 收 ex(*sql.DB 或事务)是为了让调用方把它和「删实体」放进同一个事务——
// 分两次提交的话,中间失败会留下「实体没了、留档还在」的错位。
func (s *Server) pruneEntityRaw(ctx context.Context, ex execer, datasetID int64) {
if _, err := ex.ExecContext(ctx, `DELETE FROM entity_raw r
WHERE r.dataset_id=$1 AND NOT r.skipped AND (
(r.entity='ZLOCATION' AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=r.dataset_id AND p.src_pk=r.src_pk))
OR (r.entity='ZVISIT' AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk))
@@ -695,23 +698,38 @@ func (s *Server) editActivityDelete(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": "请选择要删除的活动"})
return
}
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
// 先确认活动存在再动手,整组写进一个事务:清到访标记与删活动分开提交的话,
// 中间失败会留下「到访的活动被摘掉了、活动还在」的半截状态。
notFound := false
if err := s.withTx(ctx, func(ex execer) error {
var exists bool
if err := ex.QueryRowContext(ctx, `SELECT true FROM activities WHERE id=$1 AND dataset_id=$2`,
id, ds.ID).Scan(&exists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
res, err := s.db.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
if notFound {
s.json(w, map[string]any{"error": "活动不存在"})
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"ok": true})
}
@@ -807,7 +825,10 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
return errors.New("活动不存在")
}
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走
isHome, isWork := s.activityFlags(ctx, ex, to)
isHome, isWork, err := s.activityFlags(ctx, ex, to)
if err != nil {
return err
}
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
if err != nil {
@@ -820,8 +841,8 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": err.Error()})
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
s.pruneEntityRaw(ctx, s.db, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"ok": true, "visits": moved})
}
@@ -919,7 +940,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
redirectEdit(w, r, "ok", "已为所选地点新增到访")
return
}
@@ -975,7 +996,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
if hasVisit {
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc))
} else {
@@ -1077,7 +1098,7 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
}
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
if actID != nil {
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
}
msg := "地点已更新"
@@ -1228,27 +1249,31 @@ func (s *Server) editPlaceDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "地点标识无效")
return
}
// 该地点名下到访的 src_pk 集合(删地点会级联删掉这些到访)
// 三条写语句必须同一个事务:删到一半失败会留下「地点还在、它的行程没了」,
// 或者更糟——地点没了但行程还指着已经不存在的到访(rond 里的悬空引用就是这么来的)。
const visitSrcs = `SELECT src_pk FROM visits WHERE dataset_id=$1
AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
if err := s.withTx(ctx, func(ex execer) error {
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
return err
}
// 天气本身有独立价值,只解除关联,不连坐删除
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
// 天气本身有独立价值,只解除关联,不连坐删除
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除")
}
@@ -1289,7 +1314,7 @@ func (s *Server) editActivityCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"id": id, "name": name})
}
@@ -1316,38 +1341,55 @@ func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) {
}
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
var oldPlace int64
var curArrival time.Time
var curDeparture sql.NullTime
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
notFound := false
// 改到访、夹回引用它的行程、重写标签、刷聚合必须同一个事务:夹行程那步失败会留下
// 「到访时间已越界、行程还挂在旧窗口上」的坏数据,导出到 rond 里就是非法区间。
if err := s.withTx(ctx, func(ex execer) error {
var curArrival time.Time
var curDeparture sql.NullTime
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
arrival = keepStoredTime(arrival, &curArrival)
if departure != nil && curDeparture.Valid {
kept := keepStoredTime(*departure, &curDeparture.Time)
departure = &kept
}
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
r.PostFormValue("bookmarked") == "on",
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
return err
}
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
if err := s.clampVisitMovements(ctx, ex, ds.ID, src); err != nil {
return err
}
if err := s.saveVisitTags(ctx, ex, ds.ID, src, r.PostForm["tags"]); err != nil {
return err
}
if oldPlace > 0 {
if err := s.refreshPlaceAgg(ctx, ex, oldPlace); err != nil {
return err
}
}
return s.refreshDatasetAgg(ctx, ex, ds.ID)
}); err != nil {
s.serverError(w, r, err)
return
}
if notFound {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
arrival = keepStoredTime(arrival, &curArrival)
if departure != nil && curDeparture.Valid {
kept := keepStoredTime(*departure, &curDeparture.Time)
departure = &kept
}
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
r.PostFormValue("bookmarked") == "on",
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
s.serverError(w, r, err)
return
}
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
if err := s.clampVisitMovements(ctx, s.db, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"])
if oldPlace > 0 {
s.refreshPlaceAgg(ctx, s.db, oldPlace)
}
s.refreshDatasetAgg(ctx, ds.ID)
redirectEdit(w, r, "ok", "到访已更新")
}
@@ -1369,23 +1411,25 @@ func (s *Server) adminVisitBookmark(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "到访标识无效")
return
}
res, err := s.db.ExecContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src)
if err != nil {
// 一条语句搞定「翻转并回读」:以前是 UPDATE 之后再单独 SELECT,那次回读一旦失败
// 就落进 else 报「已取消收藏」——而它有一半是错的(状态根本不知道)。
// 顺带:翻转与回读在同一条语句里,中间也不再有人插进来的窗口。
var on bool
if err := s.db.QueryRowContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
WHERE dataset_id=$1 AND src_pk=$2
RETURNING bookmarked`, ds.ID, src).Scan(&on); err != nil {
if errors.Is(err, sql.ErrNoRows) {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
redirectEdit(w, r, "err", "到访记录不存在")
return
msg := "已取消收藏"
if on {
msg = "已标记为收藏"
}
var on bool
if err := s.db.QueryRowContext(ctx, `SELECT bookmarked FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src).Scan(&on); err == nil && on {
redirectEdit(w, r, "ok", "已标记为收藏")
return
}
redirectEdit(w, r, "ok", "已取消收藏")
redirectEdit(w, r, "ok", msg)
}
func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -1404,28 +1448,42 @@ func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
return
}
var placeID int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
notFound := false
// 删行程 / 解天气关联 / 删到访必须同一个事务:中间失败会留下指着不存在到访的悬空行程,
// 导出到 rond 里就是 8 组悬空引用那种坏数据。
if err := s.withTx(ctx, func(ex execer) error {
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
if placeID > 0 {
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
return err
}
}
return s.refreshDatasetAgg(ctx, ex, ds.ID)
}); err != nil {
s.serverError(w, r, err)
return
}
if notFound {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.pruneEntityRaw(ctx, ds.ID)
if placeID > 0 {
s.refreshPlaceAgg(ctx, s.db, placeID)
}
s.refreshDatasetAgg(ctx, ds.ID)
redirectEdit(w, r, "ok", "到访已删除")
}
@@ -1813,40 +1871,54 @@ func (s *Server) editMovementUpdate(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效")
return
}
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, pk)
if !ok {
redirectEdit(w, r, "err", msg)
return
}
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID,
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
if msg != "" {
redirectEdit(w, r, "err", msg)
return
}
var trSrc, trName, trColor, trIcon any
if tr.Src > 0 {
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
}
res, err := s.db.ExecContext(ctx, `UPDATE movements SET
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, pk, trSrc, trName, trColor, trIcon,
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
src.Start, src.End, src.DurationMin, src.DistanceKm)
if err != nil {
var tr movementTransport
var failMsg string
done := false
// 校验(读起点/终点到访)与两处写入必须同一事务,否则并发改动会让派生列与两端锚点对不上。
if err := s.withTx(ctx, func(ex execer) error {
src, ok, msg := s.validateMovementForm(ctx, ex, ds.ID, r, pk)
if !ok {
failMsg = msg
return nil
}
var tmsg string
tr, tmsg = s.resolveMovementTransport(ctx, ex, ds.ID,
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
if tmsg != "" {
failMsg = tmsg
return nil
}
var trSrc, trName, trColor, trIcon any
if tr.Src > 0 {
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
}
res, err := ex.ExecContext(ctx, `UPDATE movements SET
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, pk, trSrc, trName, trColor, trIcon,
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
src.Start, src.End, src.DurationMin, src.DistanceKm)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
failMsg = "行程不存在"
return nil
}
if err := syncTransport(ctx, ex, ds.ID, tr); err != nil {
return err
}
done = true
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
redirectEdit(w, r, "err", "行程不存在")
return
}
if err := syncTransport(ctx, s.db, ds.ID, tr); err != nil {
s.serverError(w, r, err)
if !done {
redirectEdit(w, r, "err", failMsg)
return
}
redirectEdit(w, r, "ok", "行程已更新")
@@ -1870,16 +1942,28 @@ func (s *Server) editMovementDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效")
return
}
res, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
if err != nil {
notFound := false
// 删行程与清留档必须同一事务:删除成功但清留档失败会留下「号已空、留档还在」的酸数据,
// 下一次新建复用该 src_pk 时会继承这条的 rond 私有字段。
if err := s.withTx(ctx, func(ex execer) error {
res, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
notFound = true
return nil
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
if notFound {
redirectEdit(w, r, "err", "行程不存在")
return
}
s.pruneEntityRaw(ctx, ds.ID)
redirectEdit(w, r, "ok", "行程已删除")
}
@@ -2138,43 +2222,55 @@ func (s *Server) insertVisit(ctx context.Context, ex execer, datasetID, userID,
bookmarked, remark, emoji); err != nil {
return 0, err
}
s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags)
s.refreshPlaceAgg(ctx, ex, placeID)
if err := s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags); err != nil {
return 0, err
}
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
return 0, err
}
return nextSrc, nil
}
// saveVisitTags 重写到访的标签关联。
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) {
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) error {
var visitID int64
if err := ex.QueryRowContext(ctx, `SELECT id FROM visits WHERE dataset_id=$1 AND src_pk=$2`, datasetID, visitSrc).Scan(&visitID); err != nil {
return
return fmt.Errorf("查待标标签的到访(src_pk=%d)失败: %w", visitSrc, err)
}
if _, err := ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID); err != nil {
return fmt.Errorf("清到访标签失败: %w", err)
}
ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID)
for _, t := range tagIDs {
id, err := strconv.ParseInt(t, 10, 64)
if err != nil || id <= 0 {
continue
}
ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID)
if _, err := ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID); err != nil {
return fmt.Errorf("写到访标签失败: %w", err)
}
}
return nil
}
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) {
ex.ExecContext(ctx, `UPDATE places p SET
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE places p SET
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
first_visit_at = v.f, last_visit_at = v.l
FROM (SELECT $1::bigint AS pid) x
LEFT JOIN (SELECT place_id, count(*) c, COALESCE(sum(LEAST(COALESCE(duration_min,0), 10080)),0) d, min(arrival) f, max(arrival) l
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid
WHERE p.id = x.pid`, placeID)
WHERE p.id = x.pid`, placeID); err != nil {
return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err)
}
return nil
}
// refreshDatasetAgg 重算数据集的到访数 / 地点数 / 时间跨度(页脚「数据截至」与后台 flash 用)。
// 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。
func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
s.db.ExecContext(ctx, `UPDATE datasets d SET
func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE datasets d SET
visit_count = COALESCE(v.c,0), first_visit_at = v.f, last_visit_at = v.l,
place_count = COALESCE(p.c,0)
FROM (SELECT $1::bigint AS did) x
@@ -2182,7 +2278,20 @@ func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
FROM visits WHERE dataset_id=$1 GROUP BY dataset_id) v ON v.dataset_id = x.did
LEFT JOIN (SELECT dataset_id, count(*) c
FROM places WHERE dataset_id=$1 GROUP BY dataset_id) p ON p.dataset_id = x.did
WHERE d.id = x.did`, datasetID)
WHERE d.id = x.did`, datasetID); err != nil {
return fmt.Errorf("重算数据集(id=%d)聚合失败: %w", datasetID, err)
}
return nil
}
// logAggFailure 专给「写入已经提交」的那几个刷新点用。
//
// 这时不能把请求判成失败:数据其实已经落库了,报错会让用户以为没保存、再点一次就多建一条——
// 比计数滞后严重得多。但也绝不能静默,否则计数会一路漂下去且日志里查不到。
func logAggFailure(what string, err error) {
if err != nil {
log.Printf("刷新%s失败(数据已写入,计数可能滞后): %v", what, err)
}
}
func parseFloat(v string) (float64, bool) {
@@ -2313,10 +2422,12 @@ func (s *Server) apiCoords(w http.ResponseWriter, r *http.Request) {
// 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标
// (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。
// 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。
const suspectWGS = `(r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'
AND abs((r.raw->>'ZRAWLATITUDE')::float8 - COALESCE(p.lat,0)) < 1e-9
AND abs((r.raw->>'ZRAWLONGITUDE')::float8 - COALESCE(p.lon,0)) < 1e-9
//
// 两边的取值都走 RawFloat8:留档值不是数字时给 NULL,比较结果也是 NULL = 不命中,
// 不会像以前那样因为 `::float8` 把整页 500 掉(旧版那条正则 '^-?[0-9.]+$' 连
// "1.2.3" 都放行,等于没护栏)。
var suspectWGS = `abs(` + rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`) + ` - COALESCE(p.lat,0)) < 1e-9
AND abs(` + rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`) + ` - COALESCE(p.lon,0)) < 1e-9
AND p.lat BETWEEN 3.86 AND 53.55 AND p.lon BETWEEN 73.66 AND 135.05`
// 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移),