Files
rond-with-you/internal/notes/notes.go
T
tamakyi 30c502ce7b 备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」:

- 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上
  用绿/灰/红标出会新增 / 不变 / 会丢的区域
- 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人),
  新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行;
  合入的行写回 entity_raw,保证再传同一份备份时差异归零
- 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的
  到访全变成假冲突
- 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联;
  访客票没有 dataset_id,回填按活跃数据集认领
- 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释

导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
2026-09-19 16:04:00 +08:00

155 lines
6.4 KiB
Go
Raw 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)
}
// 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
}
// 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
}