Files
rond-with-you/internal/votes/votes.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

209 lines
7.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 votes 存放地点的「推荐 / 踩雷」两类表态:
// - place_votes 站长对地点的官方结论(单值)
// - place_visitor_votes 访客点赞 / 点踩(一人一票,可改可撤)
//
// 站长的「官方结论」按**坐标键**(place_key)存储:src_pk 只在单个数据集内有效,
// 换个数据集同编号就是另一个地点,结论会挂错人。
// 访客票仍按 src_pk 存——那张表没有 dataset_id,无从判断票是投给哪个数据集的,
// 迁移有歧义,留待单独处理。
package votes
import (
"context"
"database/sql"
"fmt"
)
// placeKeyExpr 把 places p 这一行的坐标换算成 place_key。
// **必须与 Go 侧的 rond.PlaceCoordKey 以及 notes 包里的同款表达式逐位一致**:
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
const placeKeyExpr = `to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')`
// VerdictRecommend / VerdictAvoid 是官方结论的取值。
const (
VerdictAvoid = -1
VerdictRecommend = 1
)
type Store struct{ DB *sql.DB }
// Tally 是一个地点的访客票数汇总。
type Tally struct {
Up int `json:"up"`
Down int `json:"down"`
}
// Verdicts 返回某个数据集的站长结论:该数据集里的 src_pk -> verdict(1/-1)。
func (s *Store) Verdicts(ctx context.Context, datasetID int64) (map[int]int, error) {
out := map[int]int{}
rows, err := s.DB.QueryContext(ctx, `
SELECT p.src_pk, v.verdict FROM places p
JOIN place_votes v ON v.place_key = `+placeKeyExpr+`
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 pk, v int
if err := rows.Scan(&pk, &v); err != nil {
return out, err
}
out[pk] = v
}
return out, rows.Err()
}
// Verdict 取单个地点的站长结论;未标注返回 0。
func (s *Store) Verdict(ctx context.Context, datasetID int64, srcPK int) (int, error) {
var v int
err := s.DB.QueryRowContext(ctx, `SELECT v.verdict FROM place_votes v
JOIN places p ON v.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND p.src_pk=$2`, datasetID, srcPK).Scan(&v)
if err == sql.ErrNoRows {
return 0, nil
}
return v, err
}
// SaveVerdict 设置站长结论;verdict 为 0 表示取消结论。
func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK, verdict int) error {
if verdict == 0 {
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_votes v USING places p
WHERE v.user_id=$1 AND p.dataset_id=$2 AND p.src_pk=$3
AND v.place_key = `+placeKeyExpr, userID, datasetID, srcPK)
return err
}
// 先按坐标键算出这条地点该挂在哪,找不到该地点就什么也不写
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_votes
(user_id, place_key, dataset_id, src_pk, verdict, updated_at)
SELECT $1, `+placeKeyExpr+`, $2, $3, $4, 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
verdict=EXCLUDED.verdict, dataset_id=EXCLUDED.dataset_id,
src_pk=EXCLUDED.src_pk, updated_at=now()`,
userID, datasetID, srcPK, verdict)
return err
}
// placeKeyOf 把某个数据集里的地点编号换算成坐标键——访客票也是挂在坐标上的,
// 换数据集之后票仍然跟着同一个地点。
func (s *Store) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) {
var key string
err := s.DB.QueryRowContext(ctx, `SELECT `+placeKeyExpr+` FROM places p
WHERE p.dataset_id=$1 AND p.src_pk=$2 AND p.lat IS NOT NULL AND p.lon IS NOT NULL`,
datasetID, srcPK).Scan(&key)
if err == sql.ErrNoRows {
return "", fmt.Errorf("地点不存在或没有坐标")
}
return key, err
}
// Tallies 返回某个数据集里各地点的访客票数:src_pk -> Tally。
// 票表规模很小(个人站点),一次读全更省事,也避免给 pgx 传数组参数带来的类型歧义。
func (s *Store) Tallies(ctx context.Context, datasetID int64) (map[int]Tally, error) {
out := map[int]Tally{}
rows, err := s.DB.QueryContext(ctx, `SELECT p.src_pk,
count(*) FILTER (WHERE vv.vote=1), count(*) FILTER (WHERE vv.vote=-1)
FROM places p JOIN place_visitor_votes vv ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND p.lat IS NOT NULL AND p.lon IS NOT NULL
GROUP BY p.src_pk`, datasetID)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var pk int
var t Tally
if err := rows.Scan(&pk, &t.Up, &t.Down); err != nil {
return out, err
}
out[pk] = t
}
return out, rows.Err()
}
// Tally 取单个地点的访客票数。
func (s *Store) Tally(ctx context.Context, datasetID int64, srcPK int) (Tally, error) {
var t Tally
err := s.DB.QueryRowContext(ctx, `SELECT
count(*) FILTER (WHERE vv.vote=1), count(*) FILTER (WHERE vv.vote=-1)
FROM place_visitor_votes vv JOIN places p ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND p.src_pk=$2`, datasetID, srcPK).Scan(&t.Up, &t.Down)
return t, err
}
// VoterAll 返回该访客投过的全部票:src_pk -> vote,供地图一次性标注「我投的」。
func (s *Store) VoterAll(ctx context.Context, datasetID int64, voterKey string) (map[int]int, error) {
out := map[int]int{}
if voterKey == "" {
return out, nil
}
rows, err := s.DB.QueryContext(ctx, `SELECT p.src_pk, vv.vote
FROM place_visitor_votes vv JOIN places p ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND vv.voter_key=$2`, datasetID, voterKey)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var pk, v int
if err := rows.Scan(&pk, &v); err != nil {
return out, err
}
out[pk] = v
}
return out, rows.Err()
}
// Voter 返回该访客对某地点的既有投票(1/-1/0)。voterKey 为空表示拿不到身份。
func (s *Store) Voter(ctx context.Context, datasetID int64, srcPK int, voterKey string) int {
if voterKey == "" {
return 0
}
var v int
if err := s.DB.QueryRowContext(ctx, `SELECT vv.vote FROM place_visitor_votes vv
JOIN places p ON vv.place_key = `+placeKeyExpr+`
WHERE p.dataset_id=$1 AND p.src_pk=$2 AND vv.voter_key=$3`,
datasetID, srcPK, voterKey).Scan(&v); err != nil {
return 0
}
return v
}
// Cast 记录访客投票:未投过则新增;投同一票则撤销(再点一次取消);投另一票则改为该票。
// 返回更新后的票数与该访客最终的选择。
func (s *Store) Cast(ctx context.Context, datasetID int64, srcPK int, voterKey string, vote int) (Tally, int, error) {
key, err := s.placeKeyOf(ctx, datasetID, srcPK)
if err != nil {
return Tally{}, 0, err
}
var cur int
err = s.DB.QueryRowContext(ctx,
`SELECT vote FROM place_visitor_votes WHERE place_key=$1 AND voter_key=$2`, key, voterKey).Scan(&cur)
switch {
case err == sql.ErrNoRows:
_, err = s.DB.ExecContext(ctx,
`INSERT INTO place_visitor_votes (place_key, voter_key, vote, src_pk) VALUES ($1,$2,$3,$4)`,
key, voterKey, vote, srcPK)
cur = vote
case err != nil:
return Tally{}, 0, err
case cur == vote:
_, err = s.DB.ExecContext(ctx,
`DELETE FROM place_visitor_votes WHERE place_key=$1 AND voter_key=$2`, key, voterKey)
cur = 0
default:
_, err = s.DB.ExecContext(ctx,
`UPDATE place_visitor_votes SET vote=$3, src_pk=$4, updated_at=now()
WHERE place_key=$1 AND voter_key=$2`, key, voterKey, vote, srcPK)
cur = vote
}
if err != nil {
return Tally{}, 0, err
}
t, err := s.Tally(ctx, datasetID, srcPK)
return t, cur, err
}