审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
@@ -94,17 +94,23 @@ 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 := ""
|
||||
if t.HasArea() {
|
||||
args = append(args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
n := len(args)
|
||||
area = fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
}
|
||||
area := topicAreaCond(t, &args)
|
||||
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
|
||||
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
|
||||
// 不能把这份(已被追加过的)args 整个传进去。
|
||||
@@ -168,18 +174,54 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
|
||||
return points, totalVisits, days, len(cities), nil
|
||||
}
|
||||
|
||||
// fillTopicStats 给专题填上内容统计(列表页展示用)。
|
||||
func (s *Server) fillTopicStats(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) {
|
||||
points, visits, days, cities, err := s.topicContent(ctx, t, datasetID, regions, exclude)
|
||||
// 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:专题列表会静默显示成「0 个地点」,看不出是出错了。
|
||||
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
|
||||
for _, p := range points {
|
||||
t.DwellMin += p.Dwell
|
||||
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,
|
||||
@@ -249,8 +291,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range list {
|
||||
s.fillTopicStats(ctx, &list[i], ds.ID, nil, false) // 后台管理页:站长看全部
|
||||
// 后台管理页:站长看全部
|
||||
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
d.Topics = list
|
||||
// 编辑模式:?edit=<id>
|
||||
@@ -372,8 +415,8 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
regions, exclude := visitorRegions(p)
|
||||
for i := range list {
|
||||
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude)
|
||||
if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
d.Topics = list
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user