diff --git a/findings.md b/findings.md index e725ce8..e4c119e 100644 --- a/findings.md +++ b/findings.md @@ -581,3 +581,15 @@ - 查询参数按路由白名单投影;历史手机号查询只保留尾四位,其他自由查询只记录是否存在。 - 账号名的 `username`、`display_name`、`created_by`、`requested_by` 等别名统一脱敏,UUID 形态用户名也不例外。 - ResponseWriter 仅在底层实际支持时暴露 `http.Flusher`;已提交响应后发生 panic 时不再追加错误 JSON。 + +# 2026-07-28 手机号查询返回重复排队号码 + +- 用户截图显示手机号查询共返回 3 张排队单,其中同一项目“鸳鸯湖下湖(玻璃钢船)”出现两条 `00001`。 +- 两条记录的 `revision` 分别为 `1` 与 `3`,服务端时间也略有差异;这说明重复项来自后端查询结果,不是 React 对同一数组元素重复渲染。 +- 重复记录均为 `WAITING`,项目 ID 相同、号码相同,但当前公开 DTO 未返回 ticket ID 或 queue session ID;优先核查同一项目多个活动场次被同时查询的情况。 +- 已用真实 PostgreSQL 构造“同项目昨日/今日两个 `RUNNING` 场次,各有同手机号 `00001`”场景;`TestInternalPhoneLookupUsesLatestActiveSessionPerProject` 在修复前稳定返回两条,revision 分别为 `3` 与 `1`。 +- `queue_tickets` 已有 `(project_id, queue_session_id, display_number)` 和 `(project_id, queue_session_id, ticket_number)` 唯一约束,因此这不是同一场次内的重复写入。 +- `statusByPhone` 当前直接筛选所有状态为 `RUNNING/PAUSED` 的场次;员工队列和公屏快照则都按 `business_date DESC` 只选择最新活动场次,查询口径不一致是首要根因。 +- 根因已确认:跨营业日创建新场次时,旧场次可能仍保留 `RUNNING/PAUSED`;手机号查询没有像员工队列和公屏一样选择最新场次,因此把旧、新场次中的同号同时返回。 +- 修复在数据库查询层增加按项目关联的“最新活动场次”子查询,不采用结果数组去重;这样旧场次同号会被排除,当前场次中同手机号的 `00001`、`00002` 等合法不同号码仍全部返回。 +- 本次不需要数据库迁移,也不修改历史数据;重新发布 API 后查询立即按新口径生效。 diff --git a/progress.md b/progress.md index aef179e..7489085 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,16 @@ # Progress Log +## 2026-07-28 手机号查询重复排队号码修复 + +- 查看用户截图,确认同一项目、同一号码由接口返回两次,且 revision 不同。 +- 新增真实 PostgreSQL 回归 `TestInternalPhoneLookupUsesLatestActiveSessionPerProject`。 +- 红灯命令:`go test ./internal/httpapi -run '^TestInternalPhoneLookupUsesLatestActiveSessionPerProject$' -count=1`。 +- 修复前结果:稳定返回两条 `00001`(revision 3、1),测试按预期失败。 +- 修改 `statusByPhone`:每个项目仅选择 `business_date` 最新的活动场次。 +- 回归扩展为旧场次 `00001` 加当前场次 `00001/00002`,验证返回当前两张不同号码且不返回旧场次同号。 +- 真实 PostgreSQL 聚焦回归通过;临时测试数据库残留为 0。 +- `go test ./... -count=1`、`go vet ./...`、`go build ./...` 与 `git diff --check` 均通过。 + ## 2026-07-16 - Phase 42 运营统计模块标题区呼吸空间优化 - 已读取用户提供的看板截图,并按 Product Design Audit 规范完成证据定位:四个模块的标题、副标题与卡片边框距离过小,标题区与首个数据区缺少缓冲。 diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 22d63df..ea8bcb8 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -115,14 +115,22 @@ func (s *Server) statusByPhone(w http.ResponseWriter, r *http.Request, limitByCl } var tickets []model.QueueTicket + activeSessionStatuses := []string{"RUNNING", "PAUSED"} + latestActiveSession := s.db.Table("queue_sessions AS latest_active_session"). + Select("latest_active_session.id"). + Where("latest_active_session.project_id = queue_tickets.project_id"). + Where("latest_active_session.status IN ?", activeSessionStatuses). + Order("latest_active_session.business_date DESC"). + Limit(1) if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). Joins("JOIN projects ON projects.id = queue_tickets.project_id"). Joins("JOIN queue_sessions ON queue_sessions.id = queue_tickets.queue_session_id AND queue_sessions.project_id = queue_tickets.project_id"). Where("queue_tickets.phone_hmac = ? AND queue_tickets.status IN ? AND queue_sessions.status IN ? AND projects.status IN ?", phoneDigest, []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}, - []string{"RUNNING", "PAUSED"}, + activeSessionStatuses, []string{model.ProjectRunning, model.ProjectPaused}). + Where("queue_tickets.queue_session_id = (?)", latestActiveSession). Order("projects.name ASC, queue_tickets.ticket_number ASC"). Find(&tickets).Error; err != nil { writeError(w, err) diff --git a/server/internal/httpapi/public_integration_test.go b/server/internal/httpapi/public_integration_test.go index a32ec9e..30b60b0 100644 --- a/server/internal/httpapi/public_integration_test.go +++ b/server/internal/httpapi/public_integration_test.go @@ -3,6 +3,7 @@ package httpapi import ( "bytes" "context" + "encoding/json" "io" "log/slog" "net/http" @@ -131,3 +132,132 @@ func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) { duplicateRecorder.Code, duplicateRecorder.Body.String()) } } + +func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) + if dsn == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + db, err := database.Open(ctx, dsn, logger) + if err != nil { + t.Fatal(err) + } + defer database.Close(db) + sqlDB, err := database.SQLDB(db) + if err != nil { + t.Fatal(err) + } + if err := database.Migrate(ctx, sqlDB, logger); err != nil { + t.Fatal(err) + } + + server, err := New(db, config.Config{ + Environment: "production", + EncryptionKey: bytes.Repeat([]byte{0x51}, 32), + PhoneHMACKey: bytes.Repeat([]byte{0x52}, 32), + }, logger) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 28, 2, 42, 35, 0, time.UTC) + server.now = func() time.Time { return now } + + var actor model.User + if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil { + t.Fatal(err) + } + projectID := uuid.NewString() + project := model.Project{ + ID: projectID, Code: strings.ToUpper("LOOK" + uuid.NewString()[:6]), Name: "Phone lookup duplicate regression", + Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A", + CallBatchSize: 5, CallMode: model.CallModeBoth, + MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, + MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, + ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60, + ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60, + VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + + staleSession := model.QueueSession{ + ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1), + Status: "RUNNING", NextTicketNumber: 2, Revision: 1, + OpenedAt: now.Add(-24 * time.Hour), CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: now.Add(-24 * time.Hour), + } + currentSession := model.QueueSession{ + ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now, + Status: "RUNNING", NextTicketNumber: 3, Revision: 3, + OpenedAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&staleSession).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(¤tSession).Error; err != nil { + t.Fatal(err) + } + + phone := "18286070628" + phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID)) + if err != nil { + t.Fatal(err) + } + phoneHMAC := server.cipher.Digest(phone) + ticketFixtures := []struct { + session model.QueueSession + ticketNumber int + displayNumber string + }{ + {session: staleSession, ticketNumber: 1, displayNumber: "00001"}, + {session: currentSession, ticketNumber: 1, displayNumber: "00001"}, + {session: currentSession, ticketNumber: 2, displayNumber: "00002"}, + } + for _, fixture := range ticketFixtures { + ticket := model.QueueTicket{ + ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: fixture.session.ID, + TicketNumber: fixture.ticketNumber, DisplayNumber: fixture.displayNumber, PartySize: 1, + PublicTokenHash: security.HashToken(uuid.NewString()), + PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, + Honorific: "游客", Status: model.TicketWaiting, JoinedAt: fixture.session.OpenedAt, + PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID, + CreatedAt: fixture.session.OpenedAt, UpdatedAt: fixture.session.OpenedAt, + } + if err := db.Create(&ticket).Error; err != nil { + t.Fatal(err) + } + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/internal/status/search", strings.NewReader(`{"phone":"18286070628"}`)) + request.RemoteAddr = "10.23.4.5:42000" + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", recorder.Code, recorder.Body.String()) + } + var response struct { + Tickets []struct { + TicketNumber string `json:"ticket_number"` + Revision int64 `json:"revision"` + } `json:"tickets"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Tickets) != 2 { + t.Fatalf("tickets = %#v, want only the two latest-session tickets", response.Tickets) + } + if response.Tickets[0].TicketNumber != "00001" || response.Tickets[1].TicketNumber != "00002" { + t.Fatalf("tickets = %#v, want distinct current-session tickets 00001 and 00002", response.Tickets) + } + for _, ticket := range response.Tickets { + if ticket.Revision != currentSession.Revision { + t.Fatalf("ticket = %#v, want current session revision %d", ticket, currentSession.Revision) + } + } +} diff --git a/task_plan.md b/task_plan.md index 914b37a..4fb86f0 100644 --- a/task_plan.md +++ b/task_plan.md @@ -531,3 +531,22 @@ Phase 42(运营统计模块标题区呼吸空间优化) | PowerShell 中复杂 `rg` 正则的双引号终止符与字符类转义失败 | 2 | 拆成单引号、小范围查询后完成敏感字段盘点 | | 新增日志回归首次运行按预期失败:原始路径泄露、无正文、状态码被覆盖、panic 无访问日志 | 1 | 实现模板路径、旁路捕获、首次状态保护并调整中间件顺序,聚焦测试转绿 | | Windows Go 环境关闭 CGO,`go test -race` 无法启动 | 1 | 不重复同一失败;使用全量测试、聚焦并发安全审查、`go vet` 和构建完成本轮验证 | + +### Phase 44: 手机号查询重复排队号码修复(2026-07-28) +- [x] 构造同项目多活动场次下的确定性回归测试 +- [x] 定位重复记录进入查询结果的根因 +- [x] 以最小改动收敛手机号查询结果 +- [x] 完成后端全量测试、静态检查与构建验证 +- [x] 更新故障结论和部署说明 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 手机号查询的场次口径 | 每个项目只查询 `business_date` 最新的 `RUNNING/PAUSED` 场次 | 与员工队列、公屏快照的当前场次口径一致,排除旧场次残留同号 | +| 同手机号多号处理 | 保留当前场次中号码不同的全部活动票 | 继续满足“一人一号、同手机号可持有多个活动号”的既有业务规则 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---:|---| +| PowerShell 下向 `rg` 传入 Unix 风格 `*_test.go` 路径通配符失败 | 1 | 后续改由 `rg` 自身的 `-g` 过滤文件,不再让 Windows 解析目录通配符 |