修复账号禁用及队列场次状态规则
问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。 修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
142
server/internal/httpapi/auth_integration_test.go
Normal file
142
server/internal/httpapi/auth_integration_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{}).
|
||||
|
||||
175
server/internal/httpapi/pause_integration_test.go
Normal file
175
server/internal/httpapi/pause_integration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
42
server/internal/httpapi/queue_session.go
Normal file
42
server/internal/httpapi/queue_session.go
Normal file
@@ -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
|
||||
}
|
||||
237
server/internal/httpapi/rollover_integration_test.go
Normal file
237
server/internal/httpapi/rollover_integration_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
: 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<string, unknown>) : null;
|
||||
const nestedError = record?.error && typeof record.error === "object"
|
||||
? record.error as Record<string, unknown>
|
||||
|
||||
48
web/src/auth/AuthContext.test.tsx
Normal file
48
web/src/auth/AuthContext.test.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<span>{user ? user.username : "signed-out"}</span>
|
||||
<button type="button" onClick={() => void api.staffProjects().catch(() => undefined)}>
|
||||
load protected resource
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(<AuthProvider portal="staff"><AuthProbe /></AuthProvider>);
|
||||
|
||||
expect(await screen.findByText("staff01")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "load protected resource" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("signed-out")).toBeVisible());
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="app-shell" data-variant={variant}>{children}</div>
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
<h2 id="staff-call-actions-title" className="staff-call-actions__title">按号数/人数叫号</h2>
|
||||
<div className="staff-call-controls">
|
||||
{supportsTicketCall ? <div className="staff-call-mode" aria-label="按号码叫号">
|
||||
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}>
|
||||
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}>
|
||||
{busyAction === "call-next" ? "正在叫号" : "快速叫下一个号"}
|
||||
</button>
|
||||
<div className="staff-batch-action">
|
||||
<label className="field"><span>号码数量</span><input aria-label="按号码叫号数量" type="number" min="1" max={selectedProject?.max_call_ticket_count ?? 100} value={ticketCallCount} onChange={(event) => setTicketCallCount(Number(event.target.value) || 1)} /></label>
|
||||
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}>批量叫号</button>
|
||||
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}>批量叫号</button>
|
||||
</div>
|
||||
</div> : null}
|
||||
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">
|
||||
<div className="staff-batch-action">
|
||||
<label className="field"><span>目标人数</span><input aria-label="按人数叫号数量" type="number" min="1" max={selectedProject?.max_call_people_count ?? 100} value={peopleCallCount} onChange={(event) => setPeopleCallCount(Number(event.target.value) || 1)} /></label>
|
||||
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}>批量叫人</button>
|
||||
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}>批量叫人</button>
|
||||
</div>
|
||||
</div> : null}
|
||||
{writeBlockedReason ? <p className="staff-action-reason" role="status">{writeBlockedReason}</p> : null}
|
||||
{callBlockedReason ? <p className="staff-action-reason" role="status">{callBlockedReason}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -584,8 +586,8 @@ export function StaffPage() {
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{writeBlocked ? <span className="sr-only" id="create-ticket-state">当前不可取号</span> : null}
|
||||
<button className="button button--primary button--wide" type="submit" aria-describedby={writeBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || writeBlocked}>
|
||||
{takeTicketBlocked ? <span className="sr-only" id="create-ticket-state">当前不可取号</span> : null}
|
||||
<button className="button button--primary button--wide" type="submit" aria-describedby={takeTicketBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || takeTicketBlocked}>
|
||||
{busyAction === "create-ticket"
|
||||
? "正在创建排队单"
|
||||
: confirmDuplicatePhone
|
||||
|
||||
Reference in New Issue
Block a user