Files
rond-with-you/internal/fog/fog.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

334 lines
9.8 KiB
Go

// Package fog 解析与生成世界迷雾(Fog of World)的快照数据。
//
// 快照是一个 zip,其中 Model/*/ 下的每个文件是一块「瓦片」:全球 512x512 块瓦片,
// 每块瓦片 128x128 个块,每块是 64x64 的位图,一个位对应全球 2^22 x 2^22 网格里
// 约 9.55m*cos(纬度) 宽的一格。网格走网页墨卡托,经纬度即 WGS-84(已实测确认)。
// 文件内容 zlib 压缩:头 32768 字节是 16384 个 uint16(LE),值 = 该块在块体里的
// 序号(1 基,0 表示无数据);块体每块 512 字节位图 + 3 字节附加(地区码与置位校验)。
package fog
import (
"archive/zip"
"bytes"
"compress/zlib"
"crypto/md5"
"encoding/binary"
"fmt"
"io"
"math"
"path"
"strconv"
"strings"
)
const (
// MapTiles 全球瓦片数(每边)。
MapTiles = 512
// TileBlocks 每块瓦片的块数(每边)。
TileBlocks = 128
// BlockCells 每块的格子数(每边)。
BlockCells = 64
// WorldCells 全球格子数(每边),2^22。
WorldCells = MapTiles * TileBlocks * BlockCells
bitmapSize = BlockCells * BlockCells / 8 // 512
extraSize = 3
blockSize = bitmapSize + extraSize // 515
headerLen = TileBlocks * TileBlocks
headerSize = headerLen * 2 // 32768
mask1 = "olhwjsktri"
mask2 = "eizxdwknmo"
)
// Block 是一片 64x64 的已探索位图。GX/GY 为全局块坐标(0..65535),
// Bitmap 固定 512 字节,行优先、每行 8 字节、字节内高位在前。
type Block struct {
GX, GY int
Bitmap []byte
}
// CellCount 返回位图里的置位数(已探索格子数)。
func (b *Block) CellCount() int {
n := 0
for _, v := range b.Bitmap {
n += int(popcount[v])
}
return n
}
var popcount = func() [256]byte {
var t [256]byte
for i := range t {
t[i] = byte(strings.Count(strconv.FormatUint(uint64(i), 2), "1"))
}
return t
}()
// tileDigits 返回瓦片编号的规范十进制串,不足两位补前导 0。
// 文件名格式固定以 2 位 M2 尾缀结尾(见 tileName),单数字编号不补齐就无法往返。
func tileDigits(id int) string {
s := strconv.Itoa(id)
if len(s) < 2 {
return "0" + s
}
return s
}
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
func tileIDFromName(name string) (int, bool) {
if len(name) < 8 || len(name[4:]) < 4 {
return 0, false
}
digits := make([]byte, 0, len(name))
for _, c := range name[4 : len(name)-2] {
v := strings.IndexRune(mask1, c)
if v < 0 {
return 0, false
}
digits = append(digits, byte('0'+v))
}
id, err := strconv.Atoi(string(digits))
if err != nil {
return 0, false
}
s := tileDigits(id)
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
return 0, false
}
tail := name[len(name)-2:]
for i := 0; i < 2; i++ {
if int(s[len(s)-2+i]-'0') != strings.IndexByte(mask2, tail[i]) {
return 0, false
}
}
return id, true
}
// tileName 把瓦片编号编码回文件名。
func tileName(id int) string {
s := tileDigits(id)
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
var b strings.Builder
b.WriteString(sum[:4])
for i := 0; i < len(s); i++ {
b.WriteByte(mask1[s[i]-'0'])
}
for i := len(s) - 2; i < len(s); i++ {
b.WriteByte(mask2[s[i]-'0'])
}
return b.String()
}
// maxTileRawBytes / maxTileZBytes 是单个迷雾瓦片的「压缩后 / 解压后」上限。
// 实测瓦片压缩后约 250KB;解压后结构上最多 headerSize + 65535*blockSize ≈ 33MB
// (头部的每个索引是 uint16),给到 64MB 已足够宽松。不设上限的话,
// 一个几 KB 的 zip bomb 就能把 1C1G 的机器打爆。
const (
maxTileRawBytes = 16 << 20
maxTileZBytes = 64 << 20
)
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
func Decode(data []byte) ([]Block, int, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, 0, fmt.Errorf("打开迷雾快照: %w", err)
}
merged := map[[2]int][]byte{}
cells := 0
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
id, ok := tileIDFromName(path.Base(f.Name))
if !ok {
continue
}
rc, err := f.Open()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
raw, err := io.ReadAll(io.LimitReader(rc, maxTileRawBytes+1))
rc.Close()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
if len(raw) > maxTileRawBytes {
return nil, 0, fmt.Errorf("瓦片 %s 压缩后超过 %d 字节,疑似异常快照", f.Name, maxTileRawBytes)
}
zdata, err := zlibDecompress(raw)
if err != nil {
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
}
if len(zdata) < headerSize {
continue
}
tx, ty := id%MapTiles, id/MapTiles
for i := 0; i < headerLen; i++ {
idx := int(binary.LittleEndian.Uint16(zdata[2*i:]))
if idx == 0 {
continue
}
off := headerSize + (idx-1)*blockSize
if off+blockSize > len(zdata) {
return nil, 0, fmt.Errorf("%s: 块体越界", f.Name)
}
bm := make([]byte, bitmapSize)
copy(bm, zdata[off:off+bitmapSize])
key := [2]int{tx*TileBlocks + i%TileBlocks, ty*TileBlocks + i/TileBlocks}
if old, dup := merged[key]; dup {
for j := range bm {
old[j] |= bm[j]
}
continue
}
merged[key] = bm
cells += popcountSum(bm)
}
}
blocks := make([]Block, 0, len(merged))
for k, bm := range merged {
blocks = append(blocks, Block{GX: k[0], GY: k[1], Bitmap: bm})
}
sortBlocks(blocks)
return blocks, cells, nil
}
func popcountSum(bm []byte) int {
n := 0
for _, v := range bm {
n += int(popcount[v])
}
return n
}
func sortBlocks(bs []Block) {
for i := 1; i < len(bs); i++ {
for j := i; j > 0 && (bs[j-1].GY > bs[j].GY || (bs[j-1].GY == bs[j].GY && bs[j-1].GX > bs[j].GX)); j-- {
bs[j-1], bs[j] = bs[j], bs[j-1]
}
}
}
func zlibDecompress(raw []byte) ([]byte, error) {
zr, err := zlib.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer zr.Close()
out, err := io.ReadAll(io.LimitReader(zr, maxTileZBytes+1))
if err != nil {
return nil, err
}
// zlib 流本身可以解出任意长度,必须在读的时候掐住
if len(out) > maxTileZBytes {
return nil, fmt.Errorf("解压后超过 %d 字节,疑似 zip bomb", maxTileZBytes)
}
return out, nil
}
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
// layers 是留存的非已探索层原始文件(Model/~ 与 Model/#),原样写回——
// 它们不参与渲染,但少了它们 fog of world 那边看到的就是残缺快照。
func Encode(blocks []Block, layers map[string][]byte) ([]byte, error) {
byTile := map[[2]int][]Block{}
for _, b := range blocks {
tx, ty := b.GX/TileBlocks, b.GY/TileBlocks
byTile[[2]int{tx, ty}] = append(byTile[[2]int{tx, ty}], b)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for t, list := range byTile {
sortByLinear(list)
header := make([]byte, headerSize)
body := make([]byte, 0, len(list)*blockSize)
for k, b := range list {
i := (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks
binary.LittleEndian.PutUint16(header[2*i:], uint16(k+1))
body = append(body, b.Bitmap...)
body = append(body, extraBytes(b)...)
}
var raw bytes.Buffer
zc := zlib.NewWriter(&raw)
if _, err := zc.Write(header); err != nil {
return nil, err
}
if _, err := zc.Write(body); err != nil {
return nil, err
}
if err := zc.Close(); err != nil {
return nil, err
}
id := t[0] + t[1]*MapTiles
name := "Model/*/" + tileName(id)
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write(raw.Bytes()); err != nil {
return nil, err
}
}
// 其它层直接写原始字节,不做二次压缩,免得多一层格式风险
for p, data := range layers {
w, err := zw.Create(p)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sortByLinear(list []Block) {
lin := func(b Block) int { return (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks }
for i := 1; i < len(list); i++ {
for j := i; j > 0 && lin(list[j-1]) > lin(list[j]); j-- {
list[j-1], list[j] = list[j], list[j-1]
}
}
}
// extraBytes 生成块尾那 3 字节。真机存的是一个**大端 24 位数**,值为 置位数*2+1:
// 实测真机包 8701/8701 个块精确满足(如 118 格 -> 0x0000ED = 237 = 118*2+1)。
// 一个 64x64 块最多 4096 格,该值 <= 0x2001,所以最高字节在数学上恒为 0。
// (旧实现写的是 0x10 / 0x80|…,纯属臆造,会让导出包里每个块的元信息都与真机对不上。)
func extraBytes(b Block) []byte {
sum := b.CellCount()*2 + 1
return []byte{0x00, byte(sum >> 8), byte(sum & 0xFF)}
}
// CellSideM 是格子在赤道的边长(米)。宽随纬度 cos 收缩,高恒定(网页墨卡托)。
const CellSideM = 9.55462861
// CellOf 把 WGS-84 经纬度投到全球格坐标(网页墨卡托,2^22 网格)。
func CellOf(lat, lon float64) (cx, cy int) {
cx = int((lon + 180.0) / 360.0 * WorldCells)
rad := lat * math.Pi / 180
cy = int((math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells)
return
}
// AreaKm2 估算块集覆盖的实地面积:每格面积按块中心纬度做 cos 修正。
func AreaKm2(blocks []Block) float64 {
var m2 float64
for _, b := range blocks {
m2 += float64(b.CellCount()) * cellAreaM2(b.GX, b.GY)
}
return m2 / 1e6
}
// cellAreaM2 是某块内单格的面积(m²):一格宽 CellSideM、随纬度乘 cos 收缩,高恒定。
func cellAreaM2(gx, gy int) float64 {
_, lat := cellLonLat(gx*BlockCells+BlockCells/2, gy*BlockCells+BlockCells/2)
return CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
}