统计页把「我的结论」与「访客点赞」拆成两块
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。 他看到的没错:那张卡的**列表全是站长结论**(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。
This commit is contained in:
@@ -704,6 +704,18 @@ func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||
d.Avoided, err = s.verdictPlaces(ctx, p.Dataset.ID, votes.VerdictAvoid, 8)
|
||||
return err
|
||||
}},
|
||||
// 访客点赞榜另起一个任务:与站长结论互不依赖,串在同一个任务里只会白等
|
||||
pageJob{"visitorvotes", func(ctx context.Context) error {
|
||||
if !p.IsAdmin() && !p.Settings.MarksPublic {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if d.VisitorUp, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, 1, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
d.VisitorDown, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, -1, 8)
|
||||
return err
|
||||
}},
|
||||
); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/rond"
|
||||
"rond-with-you/internal/votes"
|
||||
)
|
||||
|
||||
// ---------- 统计页:站长结论 / 访客点赞 是两码事 ----------
|
||||
//
|
||||
// 早先两块挤在一张卡上:列表是**站长结论**,访客票只在副标题里露个总数。
|
||||
// 既看不出哪几个地点被访客顶上去,也容易被当成「站长 + 访客一起算」的榜单。
|
||||
// 现在拆成两张卡,这里钉住「两块各说各的、互不串台」。
|
||||
|
||||
type namedPlace struct {
|
||||
srcPK int
|
||||
name string
|
||||
lat, lon float64
|
||||
}
|
||||
|
||||
// pickTwoNamedPlaces 挑两个**有名字、有坐标**的不同地点(要按名字断言,没名字的渲染成「未命名」)。
|
||||
func pickTwoNamedPlaces(t *testing.T, f *movementFixture) (namedPlace, namedPlace) {
|
||||
t.Helper()
|
||||
rows, err := f.pg.QueryContext(f.ctx, `SELECT src_pk, COALESCE(name,''), lat, lon FROM places
|
||||
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat<>0 OR lon<>0)
|
||||
AND COALESCE(name,'') <> ''
|
||||
ORDER BY src_pk LIMIT 2`, f.dsID)
|
||||
if err != nil {
|
||||
t.Fatalf("取地点失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []namedPlace
|
||||
for rows.Next() {
|
||||
var p namedPlace
|
||||
if err := rows.Scan(&p.srcPK, &p.name, &p.lat, &p.lon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) < 2 {
|
||||
t.Fatalf("这个数据集里带名字又带坐标的地点不足两个(只有 %d 个),用例前提不成立", len(out))
|
||||
}
|
||||
return out[0], out[1]
|
||||
}
|
||||
|
||||
// isolateVoteState 清掉这几个地点上的历史表态。
|
||||
//
|
||||
// **两张票表都按坐标键读**(跟数据集无关),所以上一个用例、甚至上一轮跑剩下的行都会被算进来:
|
||||
// `place_visitor_votes` 没有 user_id、也不随用户删除而清理,不清就会把票数越堆越多
|
||||
// (实测图片那批用例就是这么被污染的:应当 2 张、实际 10 张)。
|
||||
func isolateVoteState(t *testing.T, f *movementFixture, places ...namedPlace) {
|
||||
t.Helper()
|
||||
keys := make([]string, 0, len(places))
|
||||
for _, p := range places {
|
||||
keys = append(keys, rond.PlaceCoordKey(p.lat, p.lon))
|
||||
}
|
||||
clear := func() {
|
||||
for _, k := range keys {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM place_visitor_votes WHERE place_key=$1`, k)
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE place_key=$1`, k)
|
||||
}
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
}
|
||||
|
||||
// htmlSection 取渲染结果里 from 到 to 之间的那一段(两端都必须出现,否则用例本身就不成立)。
|
||||
func htmlSection(t *testing.T, body, from, to string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(body, from)
|
||||
if i < 0 {
|
||||
t.Fatalf("页面里找不到 %q——那一块没渲染出来?", from)
|
||||
}
|
||||
rest := body[i:]
|
||||
j := strings.Index(rest, to)
|
||||
if j < 0 {
|
||||
t.Fatalf("页面里 %q 之后找不到 %q", from, to)
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
func renderStatsPage(t *testing.T, f *movementFixture) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/stats", nil).WithContext(f.ctx)
|
||||
f.srv.statsPage(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("统计页状态码 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
|
||||
t.Fatalf("统计页没渲染到底(模板中途报错了?):…%s", body[len(body)-300:])
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestStatsSplitsOwnerVerdictFromVisitorVotes(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
uid := userFrom(ctx).ID
|
||||
mine, their := pickTwoNamedPlaces(t, f)
|
||||
isolateVoteState(t, f, mine, their)
|
||||
|
||||
// 站长结论只标 A「推荐」;B 那侧一个站长结论都不打
|
||||
if err := srv.votes.SaveVerdict(ctx, uid, dsID, mine.srcPK, votes.VerdictRecommend); err != nil {
|
||||
t.Fatalf("写站长结论失败: %v", err)
|
||||
}
|
||||
// 访客票只投 B:两个不同访客各点一次 👍(都点同一处,票数才看得出是「榜」)
|
||||
for _, voter := range []string{"test-voter-1", "test-voter-2"} {
|
||||
if _, _, err := srv.votes.Cast(ctx, dsID, their.srcPK, voter, 1); err != nil {
|
||||
t.Fatalf("访客投票失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
body := renderStatsPage(t, f)
|
||||
owner := htmlSection(t, body, "推荐 & 踩雷(我的结论)", "访客点赞")
|
||||
visitor := htmlSection(t, body, "访客点赞", "月度分布")
|
||||
|
||||
// ① 站长结论块:只应出现我标的那一处
|
||||
if !strings.Contains(owner, mine.name) {
|
||||
t.Errorf("站长结论块里没有「%s」(我给它标了推荐)", mine.name)
|
||||
}
|
||||
if strings.Contains(owner, their.name) {
|
||||
t.Errorf("被访客点赞的「%s」串进了站长结论块", their.name)
|
||||
}
|
||||
if !strings.Contains(owner, "推荐 1 · 踩雷 0") {
|
||||
t.Errorf("站长结论块的总数不对(应当只有 1 条推荐、0 条踩雷)")
|
||||
}
|
||||
|
||||
// ② 访客块:只应出现访客投的那一处,并且要带票数
|
||||
if !strings.Contains(visitor, their.name) {
|
||||
t.Errorf("访客点赞块里没有「%s」(两个访客都点了赞)", their.name)
|
||||
}
|
||||
if strings.Contains(visitor, mine.name) {
|
||||
t.Errorf("我标了推荐的「%s」串进了访客点赞块", mine.name)
|
||||
}
|
||||
if !strings.Contains(visitor, "👍 2") {
|
||||
t.Errorf("访客点赞块里没显示票数(应能看出「👍 2」)")
|
||||
}
|
||||
if !strings.Contains(visitor, "👍 2 票 · 👎 0 票") {
|
||||
t.Errorf("访客点赞块的总票数不对(应当是 2 赞 0 踩)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsVisitorRankCountsRealVotes 榜单排序与票数要跟实际投票一致(两票的排在一票前面)。
|
||||
func TestStatsVisitorRankCountsRealVotes(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
first, second := pickTwoNamedPlaces(t, f)
|
||||
isolateVoteState(t, f, first, second)
|
||||
|
||||
// first 收 2 票、second 收 1 票(都是 👍)
|
||||
for _, voter := range []string{"test-voter-1", "test-voter-2"} {
|
||||
if _, _, err := srv.votes.Cast(ctx, dsID, first.srcPK, voter, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, err := srv.votes.Cast(ctx, dsID, second.srcPK, "test-voter-3", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := renderStatsPage(t, f)
|
||||
visitor := htmlSection(t, body, "访客点赞", "月度分布")
|
||||
i, j := strings.Index(visitor, first.name), strings.Index(visitor, second.name)
|
||||
if i < 0 || j < 0 {
|
||||
t.Fatalf("两处地点都该出现在访客榜里(%q:%d %q:%d)", first.name, i, second.name, j)
|
||||
}
|
||||
if i > j {
|
||||
t.Errorf("票多的「%s」应当排在票少的「%s」前面", first.name, second.name)
|
||||
}
|
||||
// 票数分别标出来
|
||||
seg := visitor[i:]
|
||||
if !strings.Contains(seg, "👍 2") {
|
||||
t.Errorf("「%s」的票数应显示 2:%s", first.name, seg[:min(len(seg), 200)])
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -21,14 +21,18 @@
|
||||
</div>
|
||||
|
||||
{{if or .IsAdmin .Settings.MarksPublic}}
|
||||
{{/* 第一块:站长自己的结论。只有站长能标,所以这里只说「我的」。 */}}
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h2>推荐 & 踩雷</h2>
|
||||
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
|
||||
<h2 style="margin:0">推荐 & 踩雷(我的结论)</h2>
|
||||
<span class="muted" style="font-size:12.5px">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
站长结论 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} | 访客票数 👍 {{num .VoteUp}} · 👎 {{num .VoteDown}}
|
||||
只统计<strong>你自己</strong>在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 推荐的地点</div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 我推荐的地点</div>
|
||||
{{if .Recommended}}
|
||||
<div class="chips">
|
||||
{{range .Recommended}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||
@@ -36,7 +40,7 @@
|
||||
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 踩雷的地点</div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 我踩雷的地点</div>
|
||||
{{if .Avoided}}
|
||||
<div class="chips">
|
||||
{{range .Avoided}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
|
||||
@@ -45,6 +49,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* 第二块:访客的点赞点踩。匿名一人一票、可改可撤,与上面的站长结论是两码事。 */}}
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
|
||||
<h2 style="margin:0">访客点赞</h2>
|
||||
<span class="muted" style="font-size:12.5px">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
访客在地点页点的 👍/👎,一人一票、可改可撤。与「我的结论」互不影响:你标你的,访客投访客的。
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 被点赞最多的地点</div>
|
||||
{{if .VisitorUp}}
|
||||
<div class="chips">
|
||||
{{range .VisitorUp}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👍 {{.Up}}{{if .Down}} · 👎 {{.Down}}{{end}}</span></a>{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty" style="padding:10px 0">还没有访客点赞</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 被点踩最多的地点</div>
|
||||
{{if .VisitorDown}}
|
||||
<div class="chips">
|
||||
{{range .VisitorDown}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👎 {{.Down}}{{if .Up}} · 👍 {{.Up}}{{end}}</span></a>{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty" style="padding:10px 0">还没有访客点踩</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -601,13 +601,18 @@ type StatsData struct {
|
||||
Weather []stats.WeatherStat
|
||||
Temps []TempPoint
|
||||
Fog *FogCoverage
|
||||
// 推荐 / 踩雷 汇总与榜单
|
||||
// 站长结论(推荐 / 踩雷)与访客票数 —— 两块分开:
|
||||
// VoteRec/VoteAvoid + Recommended/Avoided 是**站长的结论**(只由站长标注);
|
||||
// VoteUp/VoteDown + VisitorUp/VisitorDown 是**访客的点赞点踩**(匿名一人一票)。
|
||||
// 早先两块挤在一张卡上、访客票只在副标题里露个总数,看不出「哪几个地点被顶上去」。
|
||||
VoteRec int
|
||||
VoteAvoid int
|
||||
VoteUp int
|
||||
VoteDown int
|
||||
Recommended []stats.Place
|
||||
Avoided []stats.Place
|
||||
VisitorUp []votes.VotedPlace
|
||||
VisitorDown []votes.VotedPlace
|
||||
MaxMonthly int
|
||||
MaxHourly int
|
||||
MaxActivity int
|
||||
|
||||
Reference in New Issue
Block a user