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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
109 lines
3.5 KiB
Go
109 lines
3.5 KiB
Go
package ingest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
|
|
"rond-with-you/internal/db"
|
|
"rond-with-you/internal/rond"
|
|
)
|
|
|
|
// ingestTestDSN 把 ROND_DSN 的库名换成专用库。Import 会把该用户的 is_active 切到自己那份,
|
|
// 而 stats 的夹具是「WHERE is_active LIMIT 1」找数据集(不带 user 过滤)——共用库时,
|
|
// 本用例运行期间那份临时数据集会被别的包选中,直接串味。换个库谁都不影响谁(库不存在 db.Open 会建)。
|
|
func ingestTestDSN(base string) string {
|
|
u, err := url.Parse(base)
|
|
if err != nil || u.Path == "" {
|
|
return base
|
|
}
|
|
u.Path = strings.TrimSuffix(u.Path, "/") + "_ingest"
|
|
return u.String()
|
|
}
|
|
|
|
// 脏时间(parse 阶段被 appleTime 判成 nil 的到访)导入后必须:
|
|
// - 不进站点模型(visit_count 不含它);
|
|
// - 按「跳过」留档,导出时原样补回(否则一趟导入导出就少一条);
|
|
// - 绝不污染 datasets.first_visit_at —— 旧实现会把它回绕成 2001,时间轴直接从 2001 起算。
|
|
func TestImportKeepsDirtyTimeOutOfTimeline(t *testing.T) {
|
|
base := os.Getenv("ROND_DSN")
|
|
if base == "" {
|
|
t.Skip("未设置 ROND_DSN")
|
|
}
|
|
ctx := context.Background()
|
|
d, err := db.Open(ingestTestDSN(base))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 先注册 close、后注册删用户:Cleanup 是后进先出,删用户时连接还没关。
|
|
t.Cleanup(func() { d.Close() })
|
|
if err := db.Migrate(ctx, d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var userID int64
|
|
uname := fmt.Sprintf("__dirty_%d", time.Now().UnixNano())
|
|
if err := d.QueryRowContext(ctx,
|
|
`INSERT INTO users (username, password_hash) VALUES ($1,'x') RETURNING id`, uname).Scan(&userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { d.Exec(`DELETE FROM users WHERE id=$1`, userID) })
|
|
|
|
good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC)
|
|
b := &rond.Backup{
|
|
VisitUUID: map[int]string{1: strings.Repeat("a", 32)},
|
|
ActivityUUID: map[int]string{},
|
|
Locations: []rond.Location{{SrcPK: 1, Name: "家", Lat: 24.1, Lon: 109.1}},
|
|
Visits: []rond.Visit{
|
|
{SrcPK: 1, Arrival: &good, LocationSrcPK: intp(1)},
|
|
{SrcPK: 2, Arrival: nil, LocationSrcPK: intp(1)}, // 脏时间被 appleTime 判成 nil
|
|
},
|
|
RawRows: map[string]map[int]map[string]any{
|
|
"ZVISIT": {2: {"Z_PK": int64(2), "ZARRIVALDATE_": 1e30}},
|
|
},
|
|
}
|
|
|
|
datasetID, st, err := Import(ctx, d, userID, "dirty.rondbackup", strings.Repeat("0", 64), 1, b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if st.Visits != 1 {
|
|
t.Errorf("只该导入 1 条到访(脏时间那条跳过),实际 %d", st.Visits)
|
|
}
|
|
|
|
var stored int
|
|
if err := d.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM visits WHERE dataset_id=$1`, datasetID).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored != 1 {
|
|
t.Errorf("库里到访应为 1 条,实际 %d", stored)
|
|
}
|
|
|
|
var archived int
|
|
if err := d.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND skipped`, datasetID).Scan(&archived); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if archived != 1 {
|
|
t.Errorf("脏时间的到访应当留档为 skipped,实际 %d 条", archived)
|
|
}
|
|
|
|
var first, last sql.NullTime
|
|
if err := d.QueryRowContext(ctx,
|
|
`SELECT first_visit_at, last_visit_at FROM datasets WHERE id=$1`, datasetID).Scan(&first, &last); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !first.Valid || !first.Time.Equal(good) {
|
|
t.Errorf("first_visit_at 应等于合法到访的时间 %s,实际 %v", good.Format(time.RFC3339), first)
|
|
}
|
|
}
|