From 17d296263c3c086c8f4957e5e8d7f43148cffc03 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 30 Jul 2026 12:02:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E5=8F=8A=E9=98=9F=E5=88=97=E5=9C=BA=E6=AC=A1=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。 修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。 --- scripts/test-postgres.sh | 2 +- server/internal/httpapi/admin.go | 45 +++- .../internal/httpapi/auth_integration_test.go | 142 +++++++++++ server/internal/httpapi/experienced_people.go | 9 +- .../httpapi/pause_integration_test.go | 175 +++++++++++++ server/internal/httpapi/public.go | 12 +- server/internal/httpapi/queue_session.go | 42 ++++ .../httpapi/rollover_integration_test.go | 237 ++++++++++++++++++ server/internal/httpapi/staff.go | 26 +- web/src/api.ts | 11 + web/src/auth/AuthContext.test.tsx | 48 ++++ web/src/auth/AuthContext.tsx | 15 +- web/src/pages/StaffPage.test.tsx | 22 +- web/src/pages/StaffPage.tsx | 20 +- 14 files changed, 752 insertions(+), 54 deletions(-) create mode 100644 server/internal/httpapi/auth_integration_test.go create mode 100644 server/internal/httpapi/pause_integration_test.go create mode 100644 server/internal/httpapi/queue_session.go create mode 100644 server/internal/httpapi/rollover_integration_test.go create mode 100644 web/src/auth/AuthContext.test.tsx diff --git a/scripts/test-postgres.sh b/scripts/test-postgres.sh index b6b509d..6050ccc 100755 --- a/scripts/test-postgres.sh +++ b/scripts/test-postgres.sh @@ -14,7 +14,7 @@ trap cleanup EXIT INT TERM ( export TEST_DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable" cd "$ROOT_DIR/server" - go test ./internal/database ./internal/httpapi -run 'TestPostgresMigrationAndMaintenanceIntegration|TestHistoryEndpointsPostgresIntegration' -count=1 + go test -p 1 ./internal/database ./internal/httpapi -run 'TestPostgresMigrationAndMaintenanceIntegration|TestHistoryEndpointsPostgresIntegration|TestDisablingStaffRevokesSessionsAndBlocksLoginPostgresIntegration|TestCurrentDayViewsIgnoreYesterdayRunningSessionPostgresIntegration|TestPausedProjectBlocksNewTicketsButAllowsCallingPostgresIntegration' -count=1 ) set -a diff --git a/server/internal/httpapi/admin.go b/server/internal/httpapi/admin.go index 1ef8cf0..068102c 100644 --- a/server/internal/httpapi/admin.go +++ b/server/internal/httpapi/admin.go @@ -283,7 +283,9 @@ func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) { return &apiError{Status: 409, Code: "LAST_ACTIVE_ADMIN", Message: "必须保留至少一个启用的管理员账号"} } } - updates := map[string]any{"updated_at": s.now()} + now := s.now() + authChanged := nextRole != user.Role || nextActive != user.Active || input.Password != "" + updates := map[string]any{"updated_at": now} if input.Role != "" { updates["role"] = input.Role } @@ -303,6 +305,13 @@ func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) { if err := tx.Model(&user).Updates(updates).Error; err != nil { return err } + if authChanged { + if err := tx.Model(&model.AuthSession{}). + Where("user_id = ? AND revoked_at IS NULL", id). + Update("revoked_at", now).Error; err != nil { + return err + } + } if err := tx.Where("user_id = ?", id).Delete(&model.UserProject{}).Error; err != nil { return err } @@ -335,14 +344,21 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { } var totals totalRow if err := s.db.WithContext(r.Context()).Raw(` + WITH current_sessions AS ( + SELECT session.id + FROM queue_sessions AS session + JOIN projects AS project ON project.id = session.project_id + WHERE session.status IN ('RUNNING', 'PAUSED') + AND session.business_date = (? AT TIME ZONE project.timezone)::date + ) SELECT (SELECT count(*) FROM projects) AS projects, - (SELECT count(*) FROM queue_sessions WHERE status IN ('RUNNING','PAUSED')) AS active_sessions, - (SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting_tickets, - (SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status = 'WAITING') AS waiting_people, - (SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_tickets, - (SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_people - `).Scan(&totals).Error; err != nil { + (SELECT count(*) FROM current_sessions) AS active_sessions, + (SELECT count(*) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status = 'WAITING') AS waiting_tickets, + (SELECT COALESCE(sum(ticket.party_size), 0) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status = 'WAITING') AS waiting_people, + (SELECT count(*) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status IN ('CALLED','ARRIVED')) AS called_tickets, + (SELECT COALESCE(sum(ticket.party_size), 0) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status IN ('CALLED','ARRIVED')) AS called_people + `, s.now()).Scan(&totals).Error; err != nil { writeError(w, err) return } @@ -357,9 +373,14 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { return } var activeTickets []model.QueueTicket - if err := s.db.WithContext(r.Context()). - Where("status IN ?", []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}). - Order("created_at ASC").Find(&activeTickets).Error; err != nil { + if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). + Select("queue_tickets.*"). + Joins("JOIN queue_sessions AS current_session ON current_session.id = queue_tickets.queue_session_id AND current_session.project_id = queue_tickets.project_id"). + Joins("JOIN projects AS current_project ON current_project.id = queue_tickets.project_id"). + Where("queue_tickets.status IN ?", []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}). + Where("current_session.status IN ?", []string{"RUNNING", "PAUSED"}). + Where("current_session.business_date = (? AT TIME ZONE current_project.timezone)::date", s.now()). + Order("queue_tickets.created_at ASC").Find(&activeTickets).Error; err != nil { writeError(w, err) return } @@ -456,9 +477,7 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"} } - var session model.QueueSession - sessionErr := s.db.WithContext(ctx).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}). - Order("business_date DESC").First(&session).Error + session, sessionErr := s.currentQueueSession(s.db.WithContext(ctx), project) if errors.Is(sessionErr, gorm.ErrRecordNotFound) { experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil) if err != nil { diff --git a/server/internal/httpapi/auth_integration_test.go b/server/internal/httpapi/auth_integration_test.go new file mode 100644 index 0000000..b6fdf01 --- /dev/null +++ b/server/internal/httpapi/auth_integration_test.go @@ -0,0 +1,142 @@ +package httpapi + +import ( + "bytes" + "context" + "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 TestDisablingStaffRevokesSessionsAndBlocksLoginPostgresIntegration(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{ + EncryptionKey: bytes.Repeat([]byte{0x61}, 32), + PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32), + SessionCookieName: "queue_session", + SessionTTL: time.Hour, + }, logger) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + server.now = func() time.Time { return now } + + password := "StaffPassword123!" + passwordHash, err := security.HashPassword(password) + if err != nil { + t.Fatal(err) + } + admin := model.User{ + ID: uuid.NewString(), Username: "auth_admin_" + uuid.NewString()[:8], + PasswordHash: passwordHash, Role: model.RoleAdmin, Active: true, + CreatedAt: now, UpdatedAt: now, + } + staff := model.User{ + ID: uuid.NewString(), Username: "auth_staff_" + uuid.NewString()[:8], + PasswordHash: passwordHash, Role: model.RoleStaff, Active: true, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&admin).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&staff).Error; err != nil { + t.Fatal(err) + } + + login := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/staff/auth/login", + strings.NewReader(`{"username":"`+staff.Username+`","password":"`+password+`"}`)) + server.Handler().ServeHTTP(recorder, request) + return recorder + } + + firstLogin := login() + if firstLogin.Code != http.StatusOK { + t.Fatalf("initial login status = %d, want 200; body = %s", firstLogin.Code, firstLogin.Body.String()) + } + var staffCookie *http.Cookie + for _, cookie := range firstLogin.Result().Cookies() { + if cookie.Name == server.authCookieName(model.RoleStaff) { + staffCookie = cookie + break + } + } + if staffCookie == nil { + t.Fatal("initial login did not set the staff session cookie") + } + + updateRequest := httptest.NewRequest(http.MethodPut, "/api/admin/users/"+staff.ID, + strings.NewReader(`{"role":"STAFF","active":false,"project_ids":[]}`)) + updateRequest.SetPathValue("id", staff.ID) + updateRequest = updateRequest.WithContext(context.WithValue(updateRequest.Context(), principalKey, principal{User: admin})) + updateRecorder := httptest.NewRecorder() + server.updateAdminUser(updateRecorder, updateRequest) + if updateRecorder.Code != http.StatusOK { + t.Fatalf("disable status = %d, want 200; body = %s", updateRecorder.Code, updateRecorder.Body.String()) + } + + var disabledStaff model.User + if err := db.First(&disabledStaff, "id = ?", staff.ID).Error; err != nil { + t.Fatal(err) + } + if disabledStaff.Active { + t.Error("staff remains active after disabling") + } + + var activeSessions int64 + if err := db.Model(&model.AuthSession{}). + Where("user_id = ? AND revoked_at IS NULL", staff.ID). + Count(&activeSessions).Error; err != nil { + t.Fatal(err) + } + if activeSessions != 0 { + t.Errorf("active sessions after disabling staff = %d, want 0", activeSessions) + } + + meRecorder := httptest.NewRecorder() + meRequest := httptest.NewRequest(http.MethodGet, "/api/staff/auth/me", nil) + meRequest.AddCookie(staffCookie) + server.Handler().ServeHTTP(meRecorder, meRequest) + if meRecorder.Code != http.StatusUnauthorized { + t.Errorf("existing session after disabling status = %d, want 401; body = %s", meRecorder.Code, meRecorder.Body.String()) + } + + secondLogin := login() + if secondLogin.Code != http.StatusUnauthorized { + t.Fatalf("login after disabling status = %d, want 401; body = %s", secondLogin.Code, secondLogin.Body.String()) + } +} diff --git a/server/internal/httpapi/experienced_people.go b/server/internal/httpapi/experienced_people.go index 5c82c03..807561d 100644 --- a/server/internal/httpapi/experienced_people.go +++ b/server/internal/httpapi/experienced_people.go @@ -2,8 +2,6 @@ package httpapi import ( "context" - "fmt" - "time" "calllinesystem/server/internal/domain" "calllinesystem/server/internal/model" @@ -15,12 +13,11 @@ import ( func (s *Server) displayedExperiencedPeople(ctx context.Context, project model.Project, session *model.QueueSession) (int64, error) { var actual int64 if session != nil { - location, err := time.LoadLocation(project.Timezone) + current, err := s.queueSessionIsCurrent(project, *session) if err != nil { - return 0, fmt.Errorf("invalid project timezone: %w", err) + return 0, err } - businessDate := s.now().In(location).Format("2006-01-02") - if session.BusinessDate.Format("2006-01-02") != businessDate { + if !current { return domain.DisplayExperiencedPeople(project.ExperiencedPeopleStart, 0), nil } if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). diff --git a/server/internal/httpapi/pause_integration_test.go b/server/internal/httpapi/pause_integration_test.go new file mode 100644 index 0000000..8b445d9 --- /dev/null +++ b/server/internal/httpapi/pause_integration_test.go @@ -0,0 +1,175 @@ +package httpapi + +import ( + "bytes" + "context" + "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 TestPausedProjectBlocksNewTicketsButAllowsCallingPostgresIntegration(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) + } + testDB := db.Begin() + if testDB.Error != nil { + t.Fatal(testDB.Error) + } + defer testDB.Rollback() + + server, err := New(testDB, config.Config{ + Environment: "development", + EncryptionKey: bytes.Repeat([]byte{0x81}, 32), + PhoneHMACKey: bytes.Repeat([]byte{0x82}, 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 } + + projectID := uuid.NewString() + project := model.Project{ + ID: projectID, Code: "PAUSE" + strings.ToUpper(uuid.NewString()[:6]), Name: "Pause calling 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, + DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, + } + if err := testDB.Create(&project).Error; err != nil { + t.Fatal(err) + } + staff := model.User{ + ID: uuid.NewString(), Username: "pause_staff_" + uuid.NewString()[:8], PasswordHash: "unused", + Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now, + } + if err := testDB.Create(&staff).Error; err != nil { + t.Fatal(err) + } + if err := testDB.Create(&model.UserProject{UserID: staff.ID, ProjectID: projectID, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + businessDate, err := server.businessDateFor(project) + if err != nil { + t.Fatal(err) + } + session := model.QueueSession{ + ID: uuid.NewString(), ProjectID: projectID, BusinessDate: businessDate, + Status: "RUNNING", NextTicketNumber: 2, Revision: 1, + OpenedAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := testDB.Create(&session).Error; err != nil { + t.Fatal(err) + } + phone := "13800138000" + 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: now, + PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: staff.ID, + CreatedAt: now, UpdatedAt: now, + } + if err := testDB.Create(&ticket).Error; err != nil { + t.Fatal(err) + } + + if err := testDB.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"status": model.ProjectPaused, "updated_at": now}).Error; err != nil { + t.Fatal(err) + } + + t.Run("blocks staff ticket creation", func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/staff/projects/"+projectID+"/tickets", + strings.NewReader(`{"phone":"13900139000","honorific":"游客","party_size":1}`)) + request.SetPathValue("id", projectID) + request.Header.Set("Idempotency-Key", "paused-staff-ticket-"+uuid.NewString()) + request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: staff})) + server.createTicket(recorder, request) + + if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), `"code":"PROJECT_NOT_RUNNING"`) { + t.Fatalf("status = %d, want 409 PROJECT_NOT_RUNNING; body = %s", recorder.Code, recorder.Body.String()) + } + }) + + t.Run("blocks public ticket creation", func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", + strings.NewReader(`{"phone":"13700137000","honorific":"游客","party_size":1}`)) + request.Header.Set("Idempotency-Key", "paused-public-ticket-"+uuid.NewString()) + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), `"code":"PROJECT_NOT_RUNNING"`) { + t.Fatalf("status = %d, want 409 PROJECT_NOT_RUNNING; body = %s", recorder.Code, recorder.Body.String()) + } + }) + + var ticketCount int64 + if err := testDB.Model(&model.QueueTicket{}).Where("project_id = ?", projectID).Count(&ticketCount).Error; err != nil { + t.Fatal(err) + } + if ticketCount != 1 { + t.Fatalf("ticket count = %d, want the original ticket only", ticketCount) + } + + t.Run("allows calling the existing queue", func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/staff/projects/"+projectID+"/call-next", + strings.NewReader(`{"expected_revision":1,"mode":"TICKET","count":1}`)) + request.SetPathValue("id", projectID) + request.Header.Set("Idempotency-Key", "paused-call-next-"+uuid.NewString()) + request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: staff})) + server.callNext(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", recorder.Code, recorder.Body.String()) + } + var updatedTicket model.QueueTicket + if err := testDB.First(&updatedTicket, "id = ?", ticket.ID).Error; err != nil { + t.Fatal(err) + } + if updatedTicket.Status != model.TicketCalled { + t.Fatalf("ticket status = %s, want %s", updatedTicket.Status, model.TicketCalled) + } + }) +} diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index ea8bcb8..3c7bb24 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -116,12 +116,6 @@ 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"). @@ -130,7 +124,7 @@ func (s *Server) statusByPhone(w http.ResponseWriter, r *http.Request, limitByCl []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}, activeSessionStatuses, []string{model.ProjectRunning, model.ProjectPaused}). - Where("queue_tickets.queue_session_id = (?)", latestActiveSession). + Where("queue_sessions.business_date = (? AT TIME ZONE projects.timezone)::date", s.now()). Order("projects.name ASC, queue_tickets.ticket_number ASC"). Find(&tickets).Error; err != nil { writeError(w, err) @@ -306,9 +300,7 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { writeError(w, mapNotFound(err, "DISPLAY_NOT_FOUND", "公示屏绑定不存在")) return } - var session model.QueueSession - err = s.db.WithContext(r.Context()).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}). - Order("business_date DESC").First(&session).Error + session, err := s.currentQueueSession(s.db.WithContext(r.Context()), project) if errors.Is(err, gorm.ErrRecordNotFound) { experiencedPeople, metricErr := s.displayedExperiencedPeople(r.Context(), project, nil) if metricErr != nil { diff --git a/server/internal/httpapi/queue_session.go b/server/internal/httpapi/queue_session.go new file mode 100644 index 0000000..8d52a91 --- /dev/null +++ b/server/internal/httpapi/queue_session.go @@ -0,0 +1,42 @@ +package httpapi + +import ( + "fmt" + "time" + + "calllinesystem/server/internal/model" + + "gorm.io/gorm" +) + +func (s *Server) businessDateFor(project model.Project) (time.Time, error) { + location, err := time.LoadLocation(project.Timezone) + if err != nil { + return time.Time{}, fmt.Errorf("invalid project timezone: %w", err) + } + localNow := s.now().In(location) + return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location), nil +} + +func (s *Server) currentQueueSession(db *gorm.DB, project model.Project) (model.QueueSession, error) { + businessDate, err := s.businessDateFor(project) + if err != nil { + return model.QueueSession{}, err + } + var session model.QueueSession + err = db.Where( + "project_id = ? AND business_date = ? AND status IN ?", + project.ID, + businessDate, + []string{"RUNNING", "PAUSED"}, + ).First(&session).Error + return session, err +} + +func (s *Server) queueSessionIsCurrent(project model.Project, session model.QueueSession) (bool, error) { + businessDate, err := s.businessDateFor(project) + if err != nil { + return false, err + } + return session.BusinessDate.Format("2006-01-02") == businessDate.Format("2006-01-02"), nil +} diff --git a/server/internal/httpapi/rollover_integration_test.go b/server/internal/httpapi/rollover_integration_test.go new file mode 100644 index 0000000..e03c40d --- /dev/null +++ b/server/internal/httpapi/rollover_integration_test.go @@ -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()) + } + }) +} diff --git a/server/internal/httpapi/staff.go b/server/internal/httpapi/staff.go index 2997473..a88107e 100644 --- a/server/internal/httpapi/staff.go +++ b/server/internal/httpapi/staff.go @@ -69,9 +69,7 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) { writeError(w, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")) return } - var session model.QueueSession - err := s.db.WithContext(r.Context()).Where("project_id = ? AND status IN ?", projectID, []string{"RUNNING", "PAUSED"}). - Order("business_date DESC").First(&session).Error + session, err := s.currentQueueSession(s.db.WithContext(r.Context()), project) if errors.Is(err, gorm.ErrRecordNotFound) { writeJSON(w, http.StatusOK, map[string]any{ "project": projectView(project), "session": nil, "revision": 0, @@ -447,7 +445,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { responseCode := http.StatusOK var revision int64 err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { - project, session, err := s.lockRunningProjectAndSession(tx, projectID) + project, session, err := s.lockCallableProjectAndSession(tx, projectID) if err != nil { return err } @@ -715,25 +713,31 @@ func (s *Server) transitionTicket(w http.ResponseWriter, r *http.Request) { } func (s *Server) lockRunningProjectAndSession(tx *gorm.DB, projectID string) (model.Project, model.QueueSession, error) { + return s.lockProjectAndSession(tx, projectID, false) +} + +func (s *Server) lockCallableProjectAndSession(tx *gorm.DB, projectID string) (model.Project, model.QueueSession, error) { + return s.lockProjectAndSession(tx, projectID, true) +} + +func (s *Server) lockProjectAndSession(tx *gorm.DB, projectID string, allowPaused bool) (model.Project, model.QueueSession, error) { var project model.Project if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil { return project, model.QueueSession{}, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") } - if project.Status != model.ProjectRunning { + if project.Status != model.ProjectRunning && (!allowPaused || project.Status != model.ProjectPaused) { return project, model.QueueSession{}, &apiError{Status: http.StatusConflict, Code: "PROJECT_NOT_RUNNING", Message: "项目当前未运行,不能写入队列"} } - location, err := time.LoadLocation(project.Timezone) + businessDate, err := s.businessDateFor(project) if err != nil { - return project, model.QueueSession{}, fmt.Errorf("invalid project timezone: %w", err) + return project, model.QueueSession{}, err } - businessDate := s.now().In(location).Format("2006-01-02") var session model.QueueSession err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("project_id = ? AND business_date = ?", projectID, businessDate).First(&session).Error if errors.Is(err, gorm.ErrRecordNotFound) { now := s.now() - date, _ := time.ParseInLocation("2006-01-02", businessDate, location) session = model.QueueSession{ - ID: uuid.NewString(), ProjectID: projectID, BusinessDate: date, Status: "RUNNING", + ID: uuid.NewString(), ProjectID: projectID, BusinessDate: businessDate, Status: "RUNNING", NextTicketNumber: 1, Revision: 0, OpenedAt: now, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&session).Error; err != nil { @@ -744,7 +748,7 @@ func (s *Server) lockRunningProjectAndSession(tx *gorm.DB, projectID string) (mo if err != nil { return project, session, err } - if session.Status != "RUNNING" { + if session.Status != "RUNNING" && (!allowPaused || session.Status != "PAUSED") { return project, session, &apiError{Status: http.StatusConflict, Code: "QUEUE_NOT_RUNNING", Message: "队列当前未运行"} } return project, session, nil diff --git a/web/src/api.ts b/web/src/api.ts index 46351b4..ecbd8fd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -21,6 +21,13 @@ import type { } from "./types"; const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? ""; +export const AUTH_INVALID_EVENT = "queue:auth-invalid"; + +function authPortalForPath(path: string): "staff" | "admin" | null { + if (path.startsWith("/api/staff/")) return "staff"; + if (path.startsWith("/api/admin/")) return "admin"; + return null; +} export class ApiError extends Error { readonly status: number; @@ -59,6 +66,10 @@ async function request(path: string, init: RequestInit = {}): Promise { : await response.text().catch(() => ""); if (!response.ok) { + const portal = authPortalForPath(path); + if (response.status === 401 && portal && !path.endsWith("/auth/login")) { + window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT, { detail: { portal } })); + } const record = body && typeof body === "object" ? (body as Record) : null; const nestedError = record?.error && typeof record.error === "object" ? record.error as Record diff --git a/web/src/auth/AuthContext.test.tsx b/web/src/auth/AuthContext.test.tsx new file mode 100644 index 0000000..8d0c796 --- /dev/null +++ b/web/src/auth/AuthContext.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api"; +import { AuthProvider, useAuth } from "./AuthContext"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function AuthProbe() { + const { user } = useAuth(); + return ( + <> + {user ? user.username : "signed-out"} + + + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("AuthProvider session invalidation", () => { + it("clears the current staff when a protected staff request returns 401", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + user: { id: "staff-1", username: "staff01", display_name: "Staff", role: "STAFF" }, + projects: [], + })) + .mockResolvedValueOnce(jsonResponse({ + error: { code: "SESSION_INVALID", message: "Session expired" }, + }, 401)); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByText("staff01")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "load protected resource" })); + + await waitFor(() => expect(screen.getByText("signed-out")).toBeVisible()); + }); +}); diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index c20a127..bb090f6 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; -import { ApiError, api } from "../api"; +import { AUTH_INVALID_EVENT, ApiError, api } from "../api"; import type { AuthPayload, ProjectDto, UserDto } from "../types"; interface AuthContextValue { @@ -39,6 +39,19 @@ export function AuthProvider({ children, portal }: { children: ReactNode; portal } }; + useEffect(() => { + const handleInvalidSession = (event: Event) => { + const invalidPortal = (event as CustomEvent<{ portal?: AuthPortal }>).detail?.portal; + if (invalidPortal !== portal) return; + setUser(null); + setProjects([]); + setError(null); + setLoading(false); + }; + window.addEventListener(AUTH_INVALID_EVENT, handleInvalidSession); + return () => window.removeEventListener(AUTH_INVALID_EVENT, handleInvalidSession); + }, [portal]); + useEffect(() => { void refresh(); }, [portal]); diff --git a/web/src/pages/StaffPage.test.tsx b/web/src/pages/StaffPage.test.tsx index 738041a..08a01e4 100644 --- a/web/src/pages/StaffPage.test.tsx +++ b/web/src/pages/StaffPage.test.tsx @@ -2,6 +2,8 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; +const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" })); + vi.mock("../components/AppShell", () => ({ AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
{children}
@@ -11,7 +13,7 @@ vi.mock("../hooks/usePollingResource", () => ({ usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000 ? { data: { projects: [ - { id: "project-1", name: "东门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, + { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, { id: "project-2", name: "西门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, ] }, loading: false, @@ -23,7 +25,7 @@ vi.mock("../hooks/usePollingResource", () => ({ } : { data: { - project: { id: "project-1", name: "东门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, + project: { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, revision: 8, waiting: Array.from({ length: 25 }, (_, index) => ({ id: `waiting-${index + 1}`, @@ -82,7 +84,21 @@ function renderScene(path: string) { } describe("StaffPage scene-focused H5", () => { - beforeEach(() => sessionStorage.setItem("scenic-current-project", "project-1")); + beforeEach(() => { + staffPageTestState.projectStatus = "RUNNING"; + sessionStorage.setItem("scenic-current-project", "project-1"); + }); + + it("allows calling but blocks ticket creation while the project is paused", () => { + staffPageTestState.projectStatus = "PAUSED"; + renderScene("/staff"); + + expect(screen.getByRole("button", { name: "快速叫下一个号" })).toBeEnabled(); + expect(screen.queryByText("当前项目未开放叫号")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("link", { name: "取号" })); + expect(screen.getByRole("button", { name: "创建排队号码" })).toBeDisabled(); + expect(screen.getByText("当前不可取号")).toBeInTheDocument(); + }); it("keeps the call scene focused on the active batch and the primary call action", () => { renderScene("/staff"); diff --git a/web/src/pages/StaffPage.tsx b/web/src/pages/StaffPage.tsx index ce2b2fe..5517111 100644 --- a/web/src/pages/StaffPage.tsx +++ b/web/src/pages/StaffPage.tsx @@ -142,7 +142,9 @@ export function StaffPage() { const maxPartySize = selectedProject?.max_party_size ?? minPartySize; const freshnessTime = queue?.last_success_at ?? queue?.server_time ?? queueResource.lastClientSuccessAt; const stale = Boolean(queue) && isTimestampStale(freshnessTime, 20_000); - const writeBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status); + const callBlocked = queueResource.offline || stale || + (selectedProject?.status !== "RUNNING" && selectedProject?.status !== "PAUSED"); + const takeTicketBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status); const waitingTicketCount = Math.max(0, Number(queue?.metrics.waiting_ticket_count ?? queue?.metrics.waiting_count) || 0); const waitingPeopleCount = Math.max(0, Number(queue?.metrics.waiting_people_count) || 0); const waitingTickets = queue?.waiting ?? []; @@ -153,11 +155,11 @@ export function StaffPage() { const latestCalledNumber = calledTickets[calledTickets.length - 1]?.ticket_number ?? "暂无"; const nextWaitingNumber = queue?.waiting[0]?.ticket_number ?? "暂无"; const nextWaitingPeopleCount = queue?.waiting[0]?.party_size; - const writeBlockedReason = queueResource.offline + const callBlockedReason = queueResource.offline ? "网络连接中断,暂不可叫号" : stale ? "队列数据更新延迟,请刷新后重试" - : !isProjectRunning(selectedProject?.status) + : callBlocked ? "当前项目未开放叫号" : waitingTicketCount === 0 ? "当前没有等待号码" @@ -447,21 +449,21 @@ export function StaffPage() {

按号数/人数叫号

{supportsTicketCall ?
-
- +
: null} {supportsPeopleCall ?
- +
: null} - {writeBlockedReason ?

{writeBlockedReason}

: null} + {callBlockedReason ?

{callBlockedReason}

: null}
@@ -584,8 +586,8 @@ export function StaffPage() { - {writeBlocked ? 当前不可取号 : null} -