diff --git a/internal/web/edit_duplicates.go b/internal/web/edit_duplicates.go index 08a1d44..66194ca 100644 --- a/internal/web/edit_duplicates.go +++ b/internal/web/edit_duplicates.go @@ -212,60 +212,20 @@ func blobHex(s string) string { // detectDuplicateVisits 检出站点侧的重复到访,并给出每条该保留哪一行。 // -// 第二个返回值非空表示**判定不了**(基线不可用 / 跟本站到访对不上),此时页面只给一行 -// 说明、不给按钮——宁可不动,也不能拿一份对不上的参照去删用户的数据。 +// 第二个返回值非空表示**第一类判定不了**(基线不可用 / 跟本站到访对不上)——那一类要拿基线 +// 当参照,参照不可靠就宁可不动;但第二类(补录行被合并回来)不依赖基线,照样要报出来。 func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([]EditDupVisit, string, error) { - path := s.baselinePath(datasetID) - if _, err := os.Stat(path); err != nil { - return nil, "这份数据集还没有可用的基线库(当初上传的原始 .rondbackup 已不在),没法当参照,判定不了重复到访。", nil - } - base, err := readDupBaseline(ctx, path) - if err != nil { - log.Printf("读取基线库失败(数据集 %d,%s): %v", datasetID, path, err) - return nil, "读取基线库失败,判定不了重复到访。", nil - } site, err := s.loadDupSiteVisits(ctx, datasetID) if err != nil { return nil, "", err } - var withUUID, matched int - for _, sv := range site { - if sv.uuid == "" { - continue - } - withUUID++ - if base.uuids[sv.uuid] { - matched++ - } - } - // 两道闸都防着「拿一份不相干的库当参照」:基线的 UUID 一个都比不上时, - // 所有站内到访都会被算成副本,照着删就是把整库搬走。 - if len(base.uuids) == 0 { - return nil, "基线里没有带 UUID 的到访,没法当参照,判定不了重复到访。", nil - } - if withUUID > 0 && matched == 0 { - return nil, "基线跟本站到访一条都对不上,它多半不是这份数据集的手机包,判定不可靠,先不动它。", nil - } - - // 保留项:站点侧 UUID 在基线里能找到的那些(手机包里也有这条),按「时间 + 地点」建索引。 - keepByKey := map[string]int{} - for _, sv := range site { - if sv.uuid == "" || !base.uuids[sv.uuid] { - continue - } - secs := appleSeconds(sv.arrival) - dupSet(keepByKey, dupKey(secs, sv.place), sv.srcPK) - if sv.lat != 0 || sv.lon != 0 { - dupSet(keepByKey, dupKey(secs, dupCoordKey(sv.lat, sv.lon)), sv.srcPK) - } - } - var out []EditDupVisit seen := map[int]bool{} // 第二类:站点补录的行(留档里没有 UUID)导出过一次之后,包里那条带的是站点给它补的 // 派生 UUID;这份包合并回站点时对不上(站点这侧本来是空的),于是又插了一条。 - // 判据:站内某一行的 UUID 恰好等于「另一条无留档行」的派生 UUID。 + // 判据全在站点自己这边(不需要基线),所以先判它,基线不可用时也不受影响。 + // 派生 UUID 命中是很强的证据,排在「站点侧副本」之前。 soloByUUID := map[string]siteDupVisit{} for _, sv := range site { if sv.uuid == "" && sv.srcPK > 0 { @@ -283,6 +243,54 @@ func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([] seen[sv.srcPK] = true } } + + // 以下第一类需要基线:缺失 / 读不动 / 一条 UUID 都对不上,就只报第二类 + 一行说明。 + fail := func(msg string) ([]EditDupVisit, string, error) { + sort.SliceStable(out, func(i, j int) bool { return out[i].Arrival.After(out[j].Arrival) }) + return out, msg, nil + } + path := s.baselinePath(datasetID) + if _, err := os.Stat(path); err != nil { + return fail("这份数据集还没有可用的基线库(当初上传的原始 .rondbackup 已不在),没法当参照,判定不了「站点侧副本」那一类。") + } + base, err := readDupBaseline(ctx, path) + if err != nil { + log.Printf("读取基线库失败(数据集 %d,%s): %v", datasetID, path, err) + return fail("读取基线库失败,判定不了「站点侧副本」那一类。") + } + + var withUUID, matched int + for _, sv := range site { + if sv.uuid == "" { + continue + } + withUUID++ + if base.uuids[sv.uuid] { + matched++ + } + } + // 两道闸都防着「拿一份不相干的库当参照」:基线的 UUID 一个都比不上时, + // 所有站内到访都会被算成副本,照着删就是把整库搬走。 + if len(base.uuids) == 0 { + return fail("基线里没有带 UUID 的到访,没法当参照,判定不了「站点侧副本」那一类。") + } + if withUUID > 0 && matched == 0 { + return fail("基线跟本站到访一条都对不上,它多半不是这份数据集的手机包,判定不可靠,先不动它。") + } + + // 保留项:站点侧 UUID 在基线里能找到的那些(手机包里也有这条),按「时间 + 地点」建索引。 + keepByKey := map[string]int{} + for _, sv := range site { + if sv.uuid == "" || !base.uuids[sv.uuid] { + continue + } + secs := appleSeconds(sv.arrival) + dupSet(keepByKey, dupKey(secs, sv.place), sv.srcPK) + if sv.lat != 0 || sv.lon != 0 { + dupSet(keepByKey, dupKey(secs, dupCoordKey(sv.lat, sv.lon)), sv.srcPK) + } + } + // 第一类:站点的 UUID 在基线里找不到、而基线里有一条「同时刻同地点」的——站点侧副本 for _, sv := range site { if sv.uuid == "" || base.uuids[sv.uuid] || seen[sv.srcPK] { diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index bf7a2db..137b076 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -346,17 +346,13 @@ {{else}}
没有匹配的地点
{{end}} - {{/* 重复到访:站点侧跟手机包「同一到达时间 + 同一地点」的历史副本。判定见 - edit_duplicates.go(与 tools/dedup_site_visits.py 同一套语义)。 - 只在检出 >0 对时出现;判定不了时只给一行说明、不给按钮。 */}} - {{if .DupUnavailable}} + {{/* 重复到访:两类(见 edit_duplicates.go)——站点侧副本、补录行被合并回来。 + 第二类不依赖基线,所以基线不可用时它照样列出来,另给一行说明解释第一类判不了。 */}} + {{if or .DupVisits .DupUnavailable}}
-

重复到访

-
{{.DupUnavailable}}
-
- {{else if .DupVisits}} -
-

重复到访 {{num (len .DupVisits)}} 对

+

重复到访{{if .DupVisits}} {{num (len .DupVisits)}} 对{{end}}

+ {{if .DupUnavailable}}
{{.DupUnavailable}}
{{end}} + {{if .DupVisits}}
同一刻同一地点存了两条。成因有两种:站点侧副本(手机包里也有这条,但 UUID 不同——合并 / 差异按 UUID 认人, 把它当成了「备份新增」)与补录行被合并回来(补录的行没有 UUID,导出时会被补一个派生 UUID, @@ -391,6 +387,7 @@ 「全部保留」什么都不改,只是取消勾选。
+ {{end}}
{{end}}