修复账号禁用及队列场次状态规则
问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。 修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 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"
|
export TEST_DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable"
|
||||||
cd "$ROOT_DIR/server"
|
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
|
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: "必须保留至少一个启用的管理员账号"}
|
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 != "" {
|
if input.Role != "" {
|
||||||
updates["role"] = 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 {
|
if err := tx.Model(&user).Updates(updates).Error; err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Where("user_id = ?", id).Delete(&model.UserProject{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -335,14 +344,21 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var totals totalRow
|
var totals totalRow
|
||||||
if err := s.db.WithContext(r.Context()).Raw(`
|
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
|
||||||
(SELECT count(*) FROM projects) AS projects,
|
(SELECT count(*) FROM projects) AS projects,
|
||||||
(SELECT count(*) FROM queue_sessions WHERE status IN ('RUNNING','PAUSED')) AS active_sessions,
|
(SELECT count(*) FROM current_sessions) AS active_sessions,
|
||||||
(SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting_tickets,
|
(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(party_size), 0) FROM queue_tickets WHERE status = 'WAITING') AS waiting_people,
|
(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 WHERE status IN ('CALLED','ARRIVED')) AS called_tickets,
|
(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(party_size), 0) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_people
|
(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
|
||||||
`).Scan(&totals).Error; err != nil {
|
`, s.now()).Scan(&totals).Error; err != nil {
|
||||||
writeError(w, err)
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -357,9 +373,14 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var activeTickets []model.QueueTicket
|
var activeTickets []model.QueueTicket
|
||||||
if err := s.db.WithContext(r.Context()).
|
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||||
Where("status IN ?", []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}).
|
Select("queue_tickets.*").
|
||||||
Order("created_at ASC").Find(&activeTickets).Error; err != nil {
|
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)
|
writeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -456,9 +477,7 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje
|
|||||||
deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"}
|
deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"}
|
||||||
}
|
}
|
||||||
|
|
||||||
var session model.QueueSession
|
session, sessionErr := s.currentQueueSession(s.db.WithContext(ctx), project)
|
||||||
sessionErr := s.db.WithContext(ctx).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}).
|
|
||||||
Order("business_date DESC").First(&session).Error
|
|
||||||
if errors.Is(sessionErr, gorm.ErrRecordNotFound) {
|
if errors.Is(sessionErr, gorm.ErrRecordNotFound) {
|
||||||
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil)
|
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil)
|
||||||
if err != 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"calllinesystem/server/internal/domain"
|
"calllinesystem/server/internal/domain"
|
||||||
"calllinesystem/server/internal/model"
|
"calllinesystem/server/internal/model"
|
||||||
@@ -15,12 +13,11 @@ import (
|
|||||||
func (s *Server) displayedExperiencedPeople(ctx context.Context, project model.Project, session *model.QueueSession) (int64, error) {
|
func (s *Server) displayedExperiencedPeople(ctx context.Context, project model.Project, session *model.QueueSession) (int64, error) {
|
||||||
var actual int64
|
var actual int64
|
||||||
if session != nil {
|
if session != nil {
|
||||||
location, err := time.LoadLocation(project.Timezone)
|
current, err := s.queueSessionIsCurrent(project, *session)
|
||||||
if err != nil {
|
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 !current {
|
||||||
if session.BusinessDate.Format("2006-01-02") != businessDate {
|
|
||||||
return domain.DisplayExperiencedPeople(project.ExperiencedPeopleStart, 0), nil
|
return domain.DisplayExperiencedPeople(project.ExperiencedPeopleStart, 0), nil
|
||||||
}
|
}
|
||||||
if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}).
|
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
|
var tickets []model.QueueTicket
|
||||||
activeSessionStatuses := []string{"RUNNING", "PAUSED"}
|
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{}).
|
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||||
Joins("JOIN projects ON projects.id = queue_tickets.project_id").
|
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").
|
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},
|
[]string{model.TicketWaiting, model.TicketCalled, model.TicketArrived},
|
||||||
activeSessionStatuses,
|
activeSessionStatuses,
|
||||||
[]string{model.ProjectRunning, model.ProjectPaused}).
|
[]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").
|
Order("projects.name ASC, queue_tickets.ticket_number ASC").
|
||||||
Find(&tickets).Error; err != nil {
|
Find(&tickets).Error; err != nil {
|
||||||
writeError(w, err)
|
writeError(w, err)
|
||||||
@@ -306,9 +300,7 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, mapNotFound(err, "DISPLAY_NOT_FOUND", "公示屏绑定不存在"))
|
writeError(w, mapNotFound(err, "DISPLAY_NOT_FOUND", "公示屏绑定不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var session model.QueueSession
|
session, err := s.currentQueueSession(s.db.WithContext(r.Context()), project)
|
||||||
err = s.db.WithContext(r.Context()).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}).
|
|
||||||
Order("business_date DESC").First(&session).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
experiencedPeople, metricErr := s.displayedExperiencedPeople(r.Context(), project, nil)
|
experiencedPeople, metricErr := s.displayedExperiencedPeople(r.Context(), project, nil)
|
||||||
if metricErr != 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", "项目不存在"))
|
writeError(w, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var session model.QueueSession
|
session, err := s.currentQueueSession(s.db.WithContext(r.Context()), project)
|
||||||
err := s.db.WithContext(r.Context()).Where("project_id = ? AND status IN ?", projectID, []string{"RUNNING", "PAUSED"}).
|
|
||||||
Order("business_date DESC").First(&session).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"project": projectView(project), "session": nil, "revision": 0,
|
"project": projectView(project), "session": nil, "revision": 0,
|
||||||
@@ -447,7 +445,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
|
|||||||
responseCode := http.StatusOK
|
responseCode := http.StatusOK
|
||||||
var revision int64
|
var revision int64
|
||||||
err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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) {
|
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
|
var project model.Project
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil {
|
||||||
return project, model.QueueSession{}, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
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: "项目当前未运行,不能写入队列"}
|
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 {
|
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
|
var session model.QueueSession
|
||||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("project_id = ? AND business_date = ?", projectID, businessDate).First(&session).Error
|
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("project_id = ? AND business_date = ?", projectID, businessDate).First(&session).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
now := s.now()
|
now := s.now()
|
||||||
date, _ := time.ParseInLocation("2006-01-02", businessDate, location)
|
|
||||||
session = model.QueueSession{
|
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,
|
NextTicketNumber: 1, Revision: 0, OpenedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&session).Error; err != nil {
|
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 {
|
if err != nil {
|
||||||
return project, session, err
|
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, &apiError{Status: http.StatusConflict, Code: "QUEUE_NOT_RUNNING", Message: "队列当前未运行"}
|
||||||
}
|
}
|
||||||
return project, session, nil
|
return project, session, nil
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "";
|
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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -59,6 +66,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
: await response.text().catch(() => "");
|
: await response.text().catch(() => "");
|
||||||
|
|
||||||
if (!response.ok) {
|
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 record = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
|
||||||
const nestedError = record?.error && typeof record.error === "object"
|
const nestedError = record?.error && typeof record.error === "object"
|
||||||
? record.error as Record<string, unknown>
|
? 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 { 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";
|
import type { AuthPayload, ProjectDto, UserDto } from "../types";
|
||||||
|
|
||||||
interface AuthContextValue {
|
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(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [portal]);
|
}, [portal]);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" }));
|
||||||
|
|
||||||
vi.mock("../components/AppShell", () => ({
|
vi.mock("../components/AppShell", () => ({
|
||||||
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
|
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
|
||||||
<div data-testid="app-shell" data-variant={variant}>{children}</div>
|
<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
|
usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000
|
||||||
? {
|
? {
|
||||||
data: { projects: [
|
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 },
|
{ 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,
|
loading: false,
|
||||||
@@ -23,7 +25,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
data: {
|
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,
|
revision: 8,
|
||||||
waiting: Array.from({ length: 25 }, (_, index) => ({
|
waiting: Array.from({ length: 25 }, (_, index) => ({
|
||||||
id: `waiting-${index + 1}`,
|
id: `waiting-${index + 1}`,
|
||||||
@@ -82,7 +84,21 @@ function renderScene(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("StaffPage scene-focused H5", () => {
|
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", () => {
|
it("keeps the call scene focused on the active batch and the primary call action", () => {
|
||||||
renderScene("/staff");
|
renderScene("/staff");
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ export function StaffPage() {
|
|||||||
const maxPartySize = selectedProject?.max_party_size ?? minPartySize;
|
const maxPartySize = selectedProject?.max_party_size ?? minPartySize;
|
||||||
const freshnessTime = queue?.last_success_at ?? queue?.server_time ?? queueResource.lastClientSuccessAt;
|
const freshnessTime = queue?.last_success_at ?? queue?.server_time ?? queueResource.lastClientSuccessAt;
|
||||||
const stale = Boolean(queue) && isTimestampStale(freshnessTime, 20_000);
|
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 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 waitingPeopleCount = Math.max(0, Number(queue?.metrics.waiting_people_count) || 0);
|
||||||
const waitingTickets = queue?.waiting ?? [];
|
const waitingTickets = queue?.waiting ?? [];
|
||||||
@@ -153,11 +155,11 @@ export function StaffPage() {
|
|||||||
const latestCalledNumber = calledTickets[calledTickets.length - 1]?.ticket_number ?? "暂无";
|
const latestCalledNumber = calledTickets[calledTickets.length - 1]?.ticket_number ?? "暂无";
|
||||||
const nextWaitingNumber = queue?.waiting[0]?.ticket_number ?? "暂无";
|
const nextWaitingNumber = queue?.waiting[0]?.ticket_number ?? "暂无";
|
||||||
const nextWaitingPeopleCount = queue?.waiting[0]?.party_size;
|
const nextWaitingPeopleCount = queue?.waiting[0]?.party_size;
|
||||||
const writeBlockedReason = queueResource.offline
|
const callBlockedReason = queueResource.offline
|
||||||
? "网络连接中断,暂不可叫号"
|
? "网络连接中断,暂不可叫号"
|
||||||
: stale
|
: stale
|
||||||
? "队列数据更新延迟,请刷新后重试"
|
? "队列数据更新延迟,请刷新后重试"
|
||||||
: !isProjectRunning(selectedProject?.status)
|
: callBlocked
|
||||||
? "当前项目未开放叫号"
|
? "当前项目未开放叫号"
|
||||||
: waitingTicketCount === 0
|
: waitingTicketCount === 0
|
||||||
? "当前没有等待号码"
|
? "当前没有等待号码"
|
||||||
@@ -447,21 +449,21 @@ export function StaffPage() {
|
|||||||
<h2 id="staff-call-actions-title" className="staff-call-actions__title">按号数/人数叫号</h2>
|
<h2 id="staff-call-actions-title" className="staff-call-actions__title">按号数/人数叫号</h2>
|
||||||
<div className="staff-call-controls">
|
<div className="staff-call-controls">
|
||||||
{supportsTicketCall ? <div className="staff-call-mode" aria-label="按号码叫号">
|
{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" ? "正在叫号" : "快速叫下一个号"}
|
{busyAction === "call-next" ? "正在叫号" : "快速叫下一个号"}
|
||||||
</button>
|
</button>
|
||||||
<div className="staff-batch-action">
|
<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>
|
<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>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">
|
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">
|
||||||
<div className="staff-batch-action">
|
<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>
|
<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>
|
||||||
</div> : null}
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,8 +586,8 @@ export function StaffPage() {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{writeBlocked ? <span className="sr-only" id="create-ticket-state">当前不可取号</span> : null}
|
{takeTicketBlocked ? <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}>
|
<button className="button button--primary button--wide" type="submit" aria-describedby={takeTicketBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || takeTicketBlocked}>
|
||||||
{busyAction === "create-ticket"
|
{busyAction === "create-ticket"
|
||||||
? "正在创建排队单"
|
? "正在创建排队单"
|
||||||
: confirmDuplicatePhone
|
: confirmDuplicatePhone
|
||||||
|
|||||||
Reference in New Issue
Block a user