diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index 8555291..12a52e7 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -154,7 +154,11 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { 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, - COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon + 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 + LEFT JOIN activities a ON a.id=v.activity_id + WHERE v.place_id=p.id GROUP BY 1) x), '') FROM places p`+placeRawJoin+` WHERE %s ORDER BY p.visit_count DESC, p.id LIMIT $%d OFFSET $%d`, where, len(args)+1, len(args)+2), append(args, editPerPage, (d.PageNo-1)*editPerPage)...) @@ -166,7 +170,8 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { var e EditPlace if err := lrows.Scan(&e.ID, &e.SrcPK, &e.Name, &e.Category, &e.Province, &e.City, &e.District, &e.Thoroughfare, &e.Lat, &e.Lon, &e.VisitCount, &e.SuspectWGS, - &e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon); err != nil { + &e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon, + &e.ActivityText); err != nil { lrows.Close() s.serverError(w, r, err) return @@ -437,6 +442,10 @@ func pickCategory(r *http.Request) string { // categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。 const categoryCustomValue = "__custom__" +// activityNewValue 是活动下拉里「+ 新增活动…」这一项的提交值。前端有提交守卫, +// 服务端再拦一道:哨兵当 id 解析不出整数,会静默变成「无活动」。 +const activityNewValue = "__new__" + // poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。 // 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。 var poiCategoryText = map[string]string{ @@ -648,6 +657,10 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) { return } // 活动可选:历史数据里本就存在无活动的到访 + if strings.TrimSpace(r.PostFormValue("activity_id")) == activityNewValue { + redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」") + return + } actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id")) tags := r.PostForm["tags"] @@ -778,16 +791,65 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) { city: strings.TrimSpace(r.PostFormValue("city")), district: strings.TrimSpace(r.PostFormValue("district")), } - if _, err := s.updatePlaceRow(ctx, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil { + + // 「活动」是把该地点名下**全部到访**一起换类型(地点自己没有活动列,活动挂在到访上)。 + // 这一项不会被清空——空值表示「(不修改)」,行内表单每次都会提交它。 + var actID any + var actName string + var actHome, actWork bool + switch raw := strings.TrimSpace(r.PostFormValue("activity_id")); raw { + case "": + case activityNewValue: + redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」") + return + default: + id, home, work := s.resolveActivity(ctx, ds.ID, raw) + if id == nil { + redirectEdit(w, r, "err", "所选活动不存在") + return + } + actID, actHome, actWork = id, home, work + if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(name,'') FROM activities WHERE id=$1`, + id).Scan(&actName); err != nil { + actName = "" + } + } + + // 地点行与它名下到访的活动一起落库:只改完地点就失败,会留下「提示说改了、其实没改」的状态 + var actVisits int64 + if err := s.withTx(ctx, func(ex execer) error { + if _, err := s.updatePlaceRow(ctx, ex, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil { + return err + } + if actID == nil { + return nil + } + res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5 + WHERE dataset_id=$1 AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`, + ds.ID, src, actID, actHome, actWork) + if err != nil { + return err + } + actVisits, _ = res.RowsAffected() + return nil + }); err != nil { s.serverError(w, r, err) return } - if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 { - redirectEdit(w, r, "ok", fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)", - lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon)))) - return + // 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次 + if actID != nil { + s.refreshDatasetAgg(ctx, ds.ID) } - redirectEdit(w, r, "ok", "地点已更新") + + msg := "地点已更新" + if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 { + msg = fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)", + lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon))) + } + if actID != nil { + msg += fmt.Sprintf(";该地点 %d 条到访的活动已改为「%s」", actVisits, actName) + } + redirectEdit(w, r, "ok", msg) } // placeForm 是地点行编辑里必填的那几个字段。 @@ -858,7 +920,7 @@ func placeExtraFromForm(r *http.Request) placeExtra { // 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。 // 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉, // 行内那个「比导入时移动了 N 米」的对照就没了。 -func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f placeForm, +func (s *Server) updatePlaceRow(ctx context.Context, ex execer, datasetID int64, src int, f placeForm, e placeExtra, oldLat, oldLon float64) (int64, error) { set := []string{ "name=COALESCE(NULLIF($3,''), name)", "poi_category=NULLIF($4,'')", @@ -902,7 +964,7 @@ func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f add("src_lat=$%d", *e.srcLat) add("src_lon=$%d", *e.srcLon) } - res, err := s.db.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+ + res, err := ex.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+ ` WHERE dataset_id=$1 AND src_pk=$2`, args...) if err != nil { return 0, err diff --git a/internal/web/movement_e2e_test.go b/internal/web/movement_e2e_test.go index a6df734..e3e2fb7 100644 --- a/internal/web/movement_e2e_test.go +++ b/internal/web/movement_e2e_test.go @@ -545,6 +545,10 @@ func TestMovementCleanExport(t *testing.T) { t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包") } f := newMovementFixture(t) + // 目录不存在时直接 WriteFile 只会报「找不到路径」,看着像导出坏了——顺手建出来 + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(out, f.export(t), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/web/place_activity_test.go b/internal/web/place_activity_test.go new file mode 100644 index 0000000..e45ddb2 --- /dev/null +++ b/internal/web/place_activity_test.go @@ -0,0 +1,224 @@ +package web + +import ( + "database/sql" + "fmt" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "rond-with-you/internal/rond" +) + +// TestPlaceUpdateBatchActivity 盯住「地点行内编辑里的活动」这条路:地点自己没有活动列 +// (活动挂在到访上),所以那个下拉是「把该地点名下全部到访一起换类型」。 +// 写错的两种后果都很安静:空值被当成「改成无活动」→ 历史到访的类型被清掉; +// 不校验归属 → 能把别的数据集的活动塞进来。 +// +// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run BatchActivity -v +func TestPlaceUpdateBatchActivity(t *testing.T) { + f := newMovementFixture(t) + ctx, pg, dsID := f.ctx, f.pg, f.dsID + + // 挑到访最多的那个地点:批量的效果才看得出来 + var pk int + var placeID int64 + var n int + if err := pg.QueryRowContext(ctx, `SELECT p.id, p.src_pk, count(v.id) AS c FROM places p + JOIN visits v ON v.place_id = p.id + WHERE p.dataset_id=$1 GROUP BY p.id, p.src_pk ORDER BY c DESC, p.src_pk LIMIT 1`, + dsID).Scan(&placeID, &pk, &n); err != nil { + t.Fatalf("找不到带有访次的地点: %v", err) + } + if n < 2 { + t.Fatalf("这个数据集里没有多于一条到访的地点(最多 %d 条),验不出「批量」", n) + } + var name string + var lat, lon float64 + if err := pg.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0) + FROM places WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&name, &lat, &lon); err != nil { + t.Fatalf("读地点失败: %v", err) + } + // 挑一个该地点还没用过的活动,否则「改了」和「没改」分不出来 + var actID int64 + var actName string + var actHome, actWork bool + if err := pg.QueryRowContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work FROM activities a + WHERE a.dataset_id=$1 AND NOT EXISTS (SELECT 1 FROM visits v + WHERE v.place_id=$2 AND v.activity_id=a.id) + ORDER BY a.id LIMIT 1`, dsID, placeID).Scan(&actID, &actName, &actHome, &actWork); err != nil { + t.Fatalf("找不到没被这个地点用过的活动: %v", err) + } + + postRaw := func(activity string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{ + "src_pk": {fmt.Sprint(pk)}, "name": {name}, + "lat": {fmt.Sprintf("%.6f", lat)}, "lon": {fmt.Sprintf("%.6f", lon)}, + "province": {""}, "city": {""}, "district": {""}, + } + if activity != "" { + form.Set("activity_id", activity) + } + req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + f.srv.editPlaceUpdate(rec, req) + if rec.Code >= 500 { + t.Fatalf("处理失败: code=%d", rec.Code) + } + return rec + } + // is_home / is_work 是从活动上抄到到访的冗余标记,换活动时要跟着一起走 + matched := func() int { + t.Helper() + var c int + if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits + WHERE dataset_id=$1 AND place_id=$2 AND activity_id=$3 AND is_home=$4 AND is_work=$5`, + dsID, placeID, actID, actHome, actWork).Scan(&c); err != nil { + t.Fatalf("统计失败: %v", err) + } + return c + } + var othersBefore int + if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits + WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersBefore); err != nil { + t.Fatal(err) + } + + // 1) 选中一个活动:该地点名下全部到访一起换过去 + rec := postRaw(fmt.Sprint(actID)) + loc := rec.Header().Get("Location") + if msg, _ := url.QueryUnescape(loc); !strings.Contains(msg, "ok=") || !strings.Contains(msg, "条到访") { + t.Fatalf("提示里要说清改了几条到访,实际 %q", loc) + } + if got := matched(); got != n { + t.Fatalf("该地点有 %d 条到访,只有 %d 条换成了「%s」(含 is_home/is_work)", n, got, actName) + } + var othersAfter int + if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits + WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersAfter); err != nil { + t.Fatal(err) + } + if othersAfter != othersBefore { + t.Fatalf("改一个地点的活动,别的地点的到访也变了:%d → %d", othersBefore, othersAfter) + } + + // 2) 「(不修改)」:行内表单每次都会提交这一项,空值必须一个到访都不动 + postRaw("") + if got := matched(); got != n { + t.Fatalf("没选活动却动了到访:只剩 %d/%d 条还是「%s」", got, n, actName) + } + + // 3) 哨兵值「+ 新增活动…」:前端有守卫,服务端也要拦(当 id 会解析成「无活动」) + for _, raw := range []string{"__new__", "999999"} { + rec = postRaw(raw) + if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") { + t.Fatalf("提交 activity_id=%q 应当被拒绝,实际 %q", raw, l) + } + if got := matched(); got != n { + t.Fatalf("提交 activity_id=%q 之后到访被改了:只剩 %d/%d 条还是「%s」", raw, got, n, actName) + } + } + // 3b) 别的数据集的活动:越权也得拒(resolveActivity 带 dataset_id 条件就是为了这个) + var foreign int64 + if err := pg.QueryRowContext(ctx, `SELECT id FROM activities WHERE dataset_id<>$1 ORDER BY id LIMIT 1`, + dsID).Scan(&foreign); err == nil { + rec = postRaw(fmt.Sprint(foreign)) + if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") { + t.Fatalf("提交别的数据集的活动 #%d 应当被拒绝,实际 %q", foreign, l) + } + if got := matched(); got != n { + t.Fatalf("越权提交之后到访被改了:只剩 %d/%d 条还是「%s」", got, n, actName) + } + } else if err != sql.ErrNoRows { + t.Fatalf("查别的数据集的活动失败: %v", err) + } + + // 4) 模板:行内表单必须带上活动下拉 + 新建框,且下拉里三样齐全 + // (JS 靠 .activity-id-inline / .visit-act-new / .visit-act-add 三个类名找同伴元素) + req := httptest.NewRequest("GET", "/admin/edit", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + f.srv.editPage(w, req) + if w.Code != 200 { + t.Fatalf("编辑页渲染失败: code=%d", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `action="/admin/edit/place/update"`, `select name="activity_id" class="activity-id-inline"`, + `class="f visit-act-new"`, `class="btn visit-act-add"`, `(不修改)`, `+ 新增活动…`, + } { + if !strings.Contains(body, want) { + t.Fatalf("编辑页里找不到 %q:模板与 JS 的契约断了", want) + } + } + // 这一行的提示要落在它自己身上:地点按到访数倒序排,246 条的那个在第 1 页 + i := strings.Index(body, fmt.Sprintf(`id="place-row-%d"`, pk)) + if i < 0 { + t.Fatalf("编辑页第 1 页找不到 #%d 这一行", pk) + } + row := body[i:] + if j := strings.Index(row, `删除 - {{range .Activities}}{{end}} + -