重复到访:第二类(补录行被合并回来)不依赖基线,基线不可用时也照样能清

「补录行被合并回来」这一类的判据全在站点自己这边(派生 UUID 命中),本来不需要基线;
但原先的实现在读基线之前就 return 了,于是基线缺失 / 对不上的数据集连这一类都清不了。
现在先判第二类,基线的可用性检查只挡住第一类(站点侧副本),不可用时把说明补在卡片里
(有第二类就列出来 + 一行说明,没有就只给说明)。

模板相应合并成一个卡片:标题带对数、说明行只在有对时出现、判不了第一类时补一行灰字。
This commit is contained in:
tamakyi
2026-09-30 15:19:42 +08:00
parent aaf65f654a
commit 5b8699a03a
2 changed files with 59 additions and 54 deletions
+52 -44
View File
@@ -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] {