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

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

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

View File

@@ -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

View File

@@ -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 {

View 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())
}
}

View File

@@ -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{}).

View 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)
}
})
}

View File

@@ -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 {

View 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
}

View File

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

View File

@@ -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

View File

@@ -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>

View 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());
});
});

View File

@@ -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]);

View File

@@ -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");

View File

@@ -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