Files
rond-with-you/internal/votes/votes.go
T
tamakyi ff46a9cbb8 统计页把「我的结论」与「访客点赞」拆成两块
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。
他看到的没错:那张卡的**列表全是站长结论**(verdictPlaces = place_votes),访客票只在副标题里
露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。

- 新增 votes.TopVisitorVoted:按访客票数列地点榜。HAVING 只算**至少有一票的那个方向**,
  否则 0 票的地点会把榜塞满;同票数按 visit_count DESC, p.id 排,刷新之间顺序不跳。
  票按坐标键挂,所以 join 回当前数据集的地点才定位得到(与 Tallies/verdictPlaces 同一口径)。
- 「推荐 & 踩雷(我的结论)」只讲站长,副标题只留「推荐 N · 踩雷 M」,标题与列表都改成「我」的口吻。
- 新增「访客点赞」卡:副标题总票数,两列列 👍/👎 票最多的地点并带票数,各有空态。
- 加载拆成两个 pageJob(verdicts / visitorvotes)并行,两块互不依赖就不该串着等。
- 两块共用同一个可见性开关(IsAdmin || Settings.MarksPublic),与地点页投票区一致。

用例(stats_votes_split_test.go)按卡片标题切出两段 HTML,断言「我标的地点只出现在站长块、
访客顶的地点只出现在访客块」,并核对两边各自的计数——比单看总数更能证明没串台。

⚠️ 又一次同一个坑:两张票表都按坐标键读、与数据集无关,place_visitor_votes 还没有 user_id、
不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。新增 isolateVoteState 按 place_key
清两张表(前置 + cleanup 各一次)。

顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。
全量验收 PASS=338 FAIL=0 SKIP=0。
2026-10-01 01:47:48 +08:00

275 lines
10 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
}
// SaveVerdictBulk 一次给多个地点设置(verdict=1/-1)或取消(verdict=0)站长结论,
// 语义同逐条 SaveVerdict,但一条语句代替 N 次往返。
func (s *Store) SaveVerdictBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64, verdict int) error {
if len(srcPKs) == 0 {
return nil
}
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 = ANY($3::bigint[])
AND v.place_key = `+placeKeyExpr, userID, datasetID, srcPKs)
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, p.src_pk, $4, now()
FROM places p
WHERE p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
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, srcPKs, 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
}
// VotedPlace 是「访客票榜」上的一条:某个地点收到的 👍/👎 票数。
type VotedPlace struct {
ID int64
SrcPK int
Name string
City string
Up int
Down int
}
// TopVisitorVoted 按访客票数排地点榜:vote 传 1 取「👍 最多」、传 -1 取「👎 最多」。
//
// 只算**至少有一票**的那个方向(HAVING),否则「0 票」的地点会把榜单塞满;
// 同票数时按到访次数、再按编号排,保证顺序稳定(不同页面刷新之间不会跳来跳去)。
//
// 票按坐标键挂(换数据集仍跟着同一地点),所以得 join 回当前数据集的地点才定位得到。
func (s *Store) TopVisitorVoted(ctx context.Context, datasetID int64, vote, limit int) ([]VotedPlace, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.city,''),
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.id
HAVING count(*) FILTER (WHERE vv.vote=$2) > 0
ORDER BY count(*) FILTER (WHERE vv.vote=$2) DESC, p.visit_count DESC, p.id
LIMIT $3`, datasetID, vote, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VotedPlace
for rows.Next() {
var vp VotedPlace
if err := rows.Scan(&vp.ID, &vp.SrcPK, &vp.Name, &vp.City, &vp.Up, &vp.Down); err != nil {
return nil, err
}
out = append(out, vp)
}
return out, rows.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
}