audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
545 lines
20 KiB
Go
545 lines
20 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」,
|
||
// 迷雾不落库、由地图瓦片按视口自然呈现——所以专题只要把地图视野限定在
|
||
// 区域(或地点包围盒)上,「区域内的迷雾」就成立了。
|
||
|
||
const (
|
||
// 表单现在精确到分钟;纯日期形式仍然接受(老表单缓存 / 手工拼接参数)
|
||
topicTimeLayout = "2006-01-02"
|
||
topicDateTimeLayout = "2006-01-02T15:04"
|
||
)
|
||
|
||
// parseTopicTime 解析表单时间,两种格式都认。
|
||
func parseTopicTime(raw string) (time.Time, bool) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return time.Time{}, false
|
||
}
|
||
if t, err := time.ParseInLocation(topicDateTimeLayout, raw, time.Local); err == nil {
|
||
return t, true
|
||
}
|
||
if t, err := time.ParseInLocation(topicTimeLayout, raw, time.Local); err == nil {
|
||
return t, true
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// ---------- 查询 ----------
|
||
|
||
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
||
func (s *Server) loadTopics(ctx context.Context, datasetID int64, onlyEnabled bool) ([]Topic, error) {
|
||
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
||
radius_km, show_fog, enabled, sort_order
|
||
FROM topics WHERE dataset_id=$1`
|
||
if onlyEnabled {
|
||
q += ` AND enabled`
|
||
}
|
||
q += ` ORDER BY sort_order, id`
|
||
rows, err := s.db.QueryContext(ctx, q, datasetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []Topic
|
||
for rows.Next() {
|
||
var t Topic
|
||
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder); err != nil {
|
||
return nil, err
|
||
}
|
||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// topicRangeLabel 给完整时间范围。整天(00:00 ~ 23:59)时只显示日期——
|
||
// 这种情况多是「按天圈的范围」,写成「00:00 ~ 23:59」只是噪音;
|
||
// 只要有一端带了具体时刻,就完整显示到分钟。
|
||
func topicRangeLabel(start, end time.Time) string {
|
||
a, b := start.Local(), end.Local()
|
||
wholeDays := a.Hour() == 0 && a.Minute() == 0 && b.Hour() == 23 && b.Minute() >= 59
|
||
if wholeDays {
|
||
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
|
||
return a.Format("2006-01-02")
|
||
}
|
||
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02"), b.Format("2006-01-02"))
|
||
}
|
||
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
|
||
return fmt.Sprintf("%s %s ~ %s", a.Format("2006-01-02"), a.Format("15:04"), b.Format("15:04"))
|
||
}
|
||
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02 15:04"), b.Format("2006-01-02 15:04"))
|
||
}
|
||
|
||
// topicHaversineSQL 是写进 SQL 的球面距离(公里)表达式,参数依次是中心纬度、中心经度。
|
||
// 放在 SQL 里是为了让「区域过滤」和聚合统计一次算完,不必先查一遍再回 Go 过滤。
|
||
const topicHaversineSQL = `6371 * 2 * asin(sqrt(
|
||
power(sin(radians((%s - p.lat) / 2)), 2) +
|
||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||
|
||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
||
func topicAreaCond(t *Topic, args *[]any) string {
|
||
if !t.HasArea() {
|
||
return ""
|
||
}
|
||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||
n := len(*args)
|
||
return fmt.Sprintf(" AND %s <= $%d",
|
||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||
}
|
||
|
||
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
|
||
regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
|
||
args := []any{datasetID, t.StartAt, t.EndAt}
|
||
area := topicAreaCond(t, &args)
|
||
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
|
||
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
|
||
// 不能把这份(已被追加过的)args 整个传进去。
|
||
baseArgs := len(args)
|
||
q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
|
||
COALESCE(p.city,''),
|
||
COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
|
||
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
|
||
GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
|
||
COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
|
||
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
|
||
GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
|
||
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival)
|
||
FROM places p JOIN visits v ON v.place_id = p.id
|
||
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3` + area +
|
||
regionSQL("p", regions, exclude, &args) + `
|
||
GROUP BY p.id ORDER BY count(*) DESC, p.id`
|
||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, 0, 0, 0, err
|
||
}
|
||
defer rows.Close()
|
||
var points []MapPoint
|
||
cities := map[string]bool{}
|
||
totalVisits := 0
|
||
var totalDwell int64
|
||
for rows.Next() {
|
||
var m MapPoint
|
||
var last time.Time
|
||
if err := rows.Scan(&m.ID, &m.SrcPK, &m.Name, &m.Lat, &m.Lon, &m.City,
|
||
&m.Act, &m.Color, &m.Count, &m.Dwell, &last); err != nil {
|
||
return nil, 0, 0, 0, err
|
||
}
|
||
m.LastAt = last.Local().Format("2006-01-02")
|
||
points = append(points, m)
|
||
totalVisits += m.Count
|
||
totalDwell += m.Dwell
|
||
if m.City != "" {
|
||
cities[m.City] = true
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, 0, 0, err
|
||
}
|
||
|
||
// 天数:按本地日期去重(专题可能只覆盖几天,直接在 SQL 里算更准)。
|
||
//
|
||
// 参数必须按**这条 SQL 真正用到的占位符**来凑:$1..$3 固定,area 用 $4..$6。
|
||
// 以前这里直接把上面那条 SQL 的 args 整个传进来,而它已经被 regionSQL 追加过区域
|
||
// 参数 —— 这条 SQL 当时又没有区域条件,参数就比占位符多,pgx 直接报
|
||
// 「expected 3 arguments, got 4」。而且专题列表的统计会把这个错误吞掉,
|
||
// 表现成「专题内容全变 0、点进去才 500」,很难查。
|
||
days := 0
|
||
dargs := append([]any{}, args[:baseArgs]...)
|
||
daysRegion := regionSQL("p", regions, exclude, &dargs)
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date)
|
||
FROM visits v JOIN places p ON p.id = v.place_id
|
||
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3`+area+daysRegion, dargs...).Scan(&days); err != nil {
|
||
return nil, 0, 0, 0, err
|
||
}
|
||
return points, totalVisits, days, len(cities), nil
|
||
}
|
||
|
||
// fillTopicStatsBatch 一次算完所有专题的内容统计。
|
||
//
|
||
// 列表页原本每个专题跑 2 条查询(点位 + 天数),专题一多就是 2N 次往返;而列表
|
||
// 只需要汇总数字、不需要点位。这里用 UNION ALL 把 N 个专题拼成一条查询:每个子查询
|
||
// 自带一套占位符(从 base 往后编),args 顺序必须与拼接顺序一致。口径与详情页
|
||
// (topicPage → topicContent)完全一致 —— 同一专题在列表页和详情页必须数字相同。
|
||
func (s *Server) fillTopicStatsBatch(ctx context.Context, list []Topic, datasetID int64,
|
||
regions []string, exclude bool) error {
|
||
if len(list) == 0 {
|
||
return nil
|
||
}
|
||
var b strings.Builder
|
||
var args []any
|
||
for i := range list {
|
||
t := &list[i]
|
||
if i > 0 {
|
||
b.WriteString(" UNION ALL ")
|
||
}
|
||
base := len(args)
|
||
args = append(args, datasetID, t.StartAt, t.EndAt)
|
||
area := topicAreaCond(t, &args)
|
||
region := regionSQL("p", regions, exclude, &args)
|
||
fmt.Fprintf(&b, `SELECT %d AS tid, count(DISTINCT p.id), count(*),
|
||
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date),
|
||
count(DISTINCT NULLIF(p.city,'')),
|
||
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0)
|
||
FROM places p JOIN visits v ON v.place_id = p.id
|
||
WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d%s%s`,
|
||
i, base+1, base+2, base+3, area, region)
|
||
}
|
||
rows, err := s.db.QueryContext(ctx, b.String(), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var i, places, visits, days, cities int
|
||
var dwell int64
|
||
if err := rows.Scan(&i, &places, &visits, &days, &cities, &dwell); err != nil {
|
||
return err
|
||
}
|
||
if i < 0 || i >= len(list) {
|
||
return fmt.Errorf("专题统计返回了越界序号 %d", i)
|
||
}
|
||
list[i].PlaceCount, list[i].VisitCount, list[i].DayCount, list[i].CityCount = places, visits, days, cities
|
||
list[i].DwellMin = dwell
|
||
}
|
||
return rows.Err()
|
||
}
|
||
|
||
// parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59,
|
||
// 这样「9月1日到9月3日」能把 3 号整天的记录都收进来。
|
||
func parseTopicForm(r *http.Request) (Topic, error) {
|
||
var t Topic
|
||
t.Title = strings.TrimSpace(r.PostFormValue("title"))
|
||
t.Subtitle = strings.TrimSpace(r.PostFormValue("subtitle"))
|
||
t.Description = strings.TrimSpace(r.PostFormValue("description"))
|
||
if t.Title == "" {
|
||
return t, fmt.Errorf("请填写专题标题")
|
||
}
|
||
startRaw := strings.TrimSpace(r.PostFormValue("start_at"))
|
||
start, ok := parseTopicTime(startRaw)
|
||
if !ok {
|
||
return t, fmt.Errorf("请填写有效的开始时间")
|
||
}
|
||
endRaw := strings.TrimSpace(r.PostFormValue("end_at"))
|
||
end, ok := parseTopicTime(endRaw)
|
||
if !ok {
|
||
return t, fmt.Errorf("请填写有效的结束时间")
|
||
}
|
||
// 只给了日期(没给时刻)时把结束补到当天最后一刻,不然那天整天的记录会被漏掉
|
||
if len(endRaw) <= len(topicTimeLayout) {
|
||
end = end.Add(24*time.Hour - time.Second)
|
||
}
|
||
if end.Before(start) {
|
||
return t, fmt.Errorf("结束时间不能早于开始时间")
|
||
}
|
||
t.StartAt, t.EndAt = start, end
|
||
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
|
||
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
|
||
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
|
||
if t.RadiusKm < 0 {
|
||
t.RadiusKm = 0
|
||
}
|
||
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
|
||
return t, fmt.Errorf("限定区域时要填中心点坐标")
|
||
}
|
||
t.ShowFog = r.PostFormValue("show_fog") == "on"
|
||
t.Enabled = r.PostFormValue("enabled") == "on"
|
||
t.SortOrder = atoiOr(r.PostFormValue("sort_order"), 0)
|
||
t.StartDate, t.EndDate = start.Format(topicDateTimeLayout), t.EndAt.Format(topicDateTimeLayout)
|
||
return t, nil
|
||
}
|
||
|
||
// ---------- 后台:专题管理 ----------
|
||
|
||
func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
p, err := s.pageBase(r, "admin", "专题管理")
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
p.Flatpickr = true
|
||
d := TopicAdminData{Page: p}
|
||
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if ds != nil {
|
||
list, err := s.loadTopics(ctx, ds.ID, false)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 后台管理页:站长看全部
|
||
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
|
||
log.Printf("专题统计失败: %v", err)
|
||
}
|
||
d.Topics = list
|
||
// 编辑模式:?edit=<id>
|
||
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
|
||
for _, t := range list {
|
||
if t.ID == int64(id) {
|
||
d.Form = t
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if d.Form.Title == "" {
|
||
// 新建时给个合理默认:最近 30 天
|
||
now := time.Now()
|
||
d.Form.StartDate = now.AddDate(0, 0, -30).Format(topicDateTimeLayout)
|
||
d.Form.EndDate = now.Format(topicDateTimeLayout)
|
||
d.Form.ShowFog, d.Form.Enabled = true, true
|
||
d.IsNew = true
|
||
}
|
||
s.render(w, "admin/topics", d)
|
||
}
|
||
|
||
func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
u := userFrom(ctx)
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectTopics(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
t, err := parseTopicForm(r)
|
||
if err != nil {
|
||
redirectTopics(w, r, "err", err.Error())
|
||
return
|
||
}
|
||
id := atoiOr(r.PostFormValue("id"), 0)
|
||
if id > 0 {
|
||
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
||
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
||
show_fog=$11, enabled=$12, sort_order=$13
|
||
WHERE id=$1 AND dataset_id=$2`,
|
||
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectTopics(w, r, "ok", "专题已更新")
|
||
return
|
||
}
|
||
var newID int64
|
||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectTopics(w, r, "ok", "专题已创建")
|
||
}
|
||
|
||
func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
|
||
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
|
||
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND dataset_id=$2`, id, dsID)
|
||
return err
|
||
}, "专题已删除")
|
||
}
|
||
|
||
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
|
||
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
|
||
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND dataset_id=$2`, id, dsID)
|
||
return err
|
||
}, "展示状态已切换")
|
||
}
|
||
|
||
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
|
||
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectTopics(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
id := atoiOr(r.PostFormValue("id"), 0)
|
||
if id <= 0 {
|
||
redirectTopics(w, r, "err", "专题标识无效")
|
||
return
|
||
}
|
||
if err := fn(ctx, ds.ID, id); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectTopics(w, r, "ok", okMsg)
|
||
}
|
||
|
||
// ---------- 前台:专题列表与详情 ----------
|
||
|
||
func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
p, err := s.pageBase(r, "topics", "专题")
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d := TopicListData{Page: p}
|
||
if p.Dataset != nil {
|
||
list, err := s.loadTopics(ctx, p.Dataset.ID, !p.IsAdmin())
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
regions, exclude := visitorRegions(p)
|
||
if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil {
|
||
log.Printf("专题统计失败: %v", err)
|
||
}
|
||
d.Topics = list
|
||
}
|
||
s.render(w, "topics", d)
|
||
}
|
||
|
||
func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
p, err := s.pageBase(r, "topics", "专题")
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
p.HasMap = true
|
||
id := atoiOr(r.PathValue("id"), 0)
|
||
if id <= 0 || p.Dataset == nil {
|
||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||
return
|
||
}
|
||
var t Topic
|
||
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
|
||
FROM topics WHERE id=$1 AND dataset_id=$2`, id, p.Dataset.ID).
|
||
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
|
||
if err == sql.ErrNoRows {
|
||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||
if !t.Enabled && !p.IsAdmin() {
|
||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||
return
|
||
}
|
||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
||
|
||
regions, exclude := visitorRegions(p)
|
||
points, visits, days, cities, err := s.topicContent(ctx, &t, p.Dataset.ID, regions, exclude)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
|
||
for _, mp := range points {
|
||
t.DwellMin += mp.Dwell
|
||
}
|
||
p.Title = t.Title
|
||
|
||
// 站点设置里的「备注对外可见」同样适用于专题页
|
||
if p.IsAdmin() || p.Settings.NotesPublic {
|
||
s.applyNotesToPoints(ctx, p.Dataset.ID, points)
|
||
}
|
||
|
||
js, err := json.Marshal(points)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
fitCircle, fitBBox := fitParams(t, points)
|
||
d := TopicData{
|
||
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
|
||
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, FitBBoxJSON: fitBBox,
|
||
}
|
||
s.render(w, "topic", d)
|
||
}
|
||
|
||
// applyNotesToPoints 把别名/备注贴到地图点上(专题页的迷你地点列表也用)。
|
||
func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points []MapPoint) {
|
||
ns, err := s.notes.All(ctx, datasetID)
|
||
if err != nil || len(ns) == 0 {
|
||
return
|
||
}
|
||
for i := range points {
|
||
if n, ok := ns[points[i].SrcPK]; ok {
|
||
if n.Alias != "" {
|
||
points[i].Name = n.Alias
|
||
}
|
||
points[i].Note = n.Content
|
||
}
|
||
}
|
||
}
|
||
|
||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
|
||
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
|
||
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
|
||
if t.HasArea() {
|
||
b, _ := json.Marshal(map[string]any{
|
||
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
|
||
})
|
||
return template.JS(b), template.JS("null")
|
||
}
|
||
if len(points) == 0 {
|
||
return template.JS("null"), template.JS("null")
|
||
}
|
||
minLat, maxLat := points[0].Lat, points[0].Lat
|
||
minLon, maxLon := points[0].Lon, points[0].Lon
|
||
for _, p := range points {
|
||
minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat)
|
||
minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon)
|
||
}
|
||
b, _ := json.Marshal(map[string]any{
|
||
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||
})
|
||
return template.JS("null"), template.JS(b)
|
||
}
|
||
|
||
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
|
||
func redirectTopics(w http.ResponseWriter, r *http.Request, kind, msg string) {
|
||
back := r.Referer()
|
||
if !strings.Contains(back, "/admin/topics") {
|
||
back = "/admin/topics"
|
||
}
|
||
sep := "?"
|
||
if strings.Contains(back, "?") {
|
||
sep = "&"
|
||
}
|
||
http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
|
||
}
|