修复账号禁用及队列场次状态规则

问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。

修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
This commit is contained in:
2026-07-30 12:02:05 +08:00
parent 0e1ac400dc
commit 17d296263c
14 changed files with 752 additions and 54 deletions

View File

@@ -0,0 +1,237 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"calllinesystem/server/internal/config"
"calllinesystem/server/internal/database"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"github.com/google/uuid"
)
func TestCurrentDayViewsIgnoreYesterdayRunningSessionPostgresIntegration(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: "development",
EncryptionKey: bytes.Repeat([]byte{0x71}, 32),
PhoneHMACKey: bytes.Repeat([]byte{0x72}, 32),
}, logger)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 30, 2, 0, 0, 0, time.UTC)
server.now = func() time.Time { return now }
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
yesterday := time.Date(2026, 7, 29, 0, 0, 0, 0, shanghai)
displayToken := uuid.NewString() + uuid.NewString()
displayTokenHash := security.HashToken(displayToken)
projectID := uuid.NewString()
project := model.Project{
ID: projectID, Code: "ROLL" + strings.ToUpper(uuid.NewString()[:6]), Name: "Rollover regression",
Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A",
CallBatchSize: 1, CallMode: model.CallModeBoth,
MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5,
ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60,
ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60,
ExperiencedPeopleStart: 10, DisplayTokenHash: &displayTokenHash,
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&project).Error; err != nil {
t.Fatal(err)
}
user := model.User{
ID: uuid.NewString(), Username: "roll_" + uuid.NewString()[:8], PasswordHash: "unused",
Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.UserProject{UserID: user.ID, ProjectID: projectID, CreatedAt: now}).Error; err != nil {
t.Fatal(err)
}
session := model.QueueSession{
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: yesterday,
Status: "RUNNING", NextTicketNumber: 2, Revision: 1,
OpenedAt: yesterday, CreatedAt: yesterday, UpdatedAt: yesterday,
}
if err := db.Create(&session).Error; err != nil {
t.Fatal(err)
}
phone, err := security.NormalizePhone("13800138000")
if err != nil {
t.Fatal(err)
}
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
if err != nil {
t.Fatal(err)
}
phoneHMAC := server.cipher.Digest(phone)
ticket := model.QueueTicket{
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID,
TicketNumber: 1, DisplayNumber: "00001", PartySize: 2,
PublicTokenHash: security.HashToken(uuid.NewString()),
PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
Honorific: "游客", Status: model.TicketWaiting, JoinedAt: yesterday,
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: user.ID,
CreatedAt: yesterday, UpdatedAt: yesterday,
}
if err := db.Create(&ticket).Error; err != nil {
t.Fatal(err)
}
t.Run("staff queue", func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/staff/projects/"+projectID+"/queue", nil)
request.SetPathValue("id", projectID)
request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: user}))
recorder := httptest.NewRecorder()
server.queueSnapshot(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response struct {
Session any `json:"session"`
Metrics struct {
WaitingTickets int64 `json:"waiting_ticket_count"`
WaitingPeople int64 `json:"waiting_people_count"`
} `json:"metrics"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Session != nil || response.Metrics.WaitingTickets != 0 || response.Metrics.WaitingPeople != 0 {
t.Fatalf("today staff queue reused yesterday: %#v", response)
}
})
t.Run("display", func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/display/"+displayToken+"/snapshot", nil)
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response displaySnapshotDTO
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.WaitingTicketCount != 0 || response.WaitingPeopleCount != 0 {
t.Fatalf("today display reused yesterday: %#v", response)
}
if response.ExperiencedPeople != int64(project.ExperiencedPeopleStart) {
t.Fatalf("experienced people = %d, want configured start %d", response.ExperiencedPeople, project.ExperiencedPeopleStart)
}
})
t.Run("admin project", func(t *testing.T) {
projection, _, _, _, _, err := server.adminProjectProjection(context.Background(), project)
if err != nil {
t.Fatal(err)
}
if projection["waiting_ticket_count"] != int64(0) || projection["waiting_people_count"] != int64(0) {
t.Fatalf("today admin project reused yesterday: %#v", projection)
}
if projection["experienced_people"] != int64(project.ExperiencedPeopleStart) {
t.Fatalf("experienced people = %#v, want configured start %d", projection["experienced_people"], project.ExperiencedPeopleStart)
}
})
t.Run("admin overview", func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/admin/overview", nil)
server.adminOverview(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response struct {
Summary struct {
WaitingTickets int64 `json:"waiting_ticket_count"`
WaitingPeople int64 `json:"waiting_people_count"`
} `json:"summary"`
Totals struct {
ActiveSessions int64 `json:"active_sessions"`
WaitingTickets int64 `json:"waiting_ticket_count"`
WaitingPeople int64 `json:"waiting_people_count"`
} `json:"totals"`
Projects []struct {
ID string `json:"id"`
WaitingTickets int64 `json:"waiting_ticket_count"`
WaitingPeople int64 `json:"waiting_people_count"`
} `json:"projects"`
ActiveTickets []struct {
ID string `json:"id"`
} `json:"active_tickets"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Summary.WaitingTickets != 0 || response.Summary.WaitingPeople != 0 ||
response.Totals.ActiveSessions != 0 || response.Totals.WaitingTickets != 0 || response.Totals.WaitingPeople != 0 {
t.Fatalf("today admin overview totals reused yesterday data: %#v", response)
}
for _, item := range response.Projects {
if item.ID == projectID && (item.WaitingTickets != 0 || item.WaitingPeople != 0) {
t.Fatalf("today admin overview reused yesterday project data: %#v", item)
}
}
for _, item := range response.ActiveTickets {
if item.ID == ticket.ID {
t.Fatalf("today admin overview returned yesterday active ticket: %#v", item)
}
}
})
t.Run("phone lookup", func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/internal/status/search",
strings.NewReader(`{"phone":"13800138000"}`))
server.statusByPhone(recorder, request, false)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response struct {
Tickets []json.RawMessage `json:"tickets"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Tickets) != 0 {
t.Fatalf("today phone lookup returned yesterday tickets: %s", recorder.Body.String())
}
})
}