feat: add party-size queueing and call modes

This commit is contained in:
wangxuming
2026-07-16 11:40:34 +08:00
parent 7f751bebae
commit 66951b4dc3
53 changed files with 3348 additions and 533 deletions

View File

@@ -20,11 +20,12 @@ import (
)
type projectSeed struct {
Code, Name, Status, Prefix, ETAMode, DeviceMode, DisplayToken string
BatchSize, Grace, BatchInterval, Buffer int
Rate float64
Tickets []ticketSeed
SessionStatus string
Code, Name, Status, Prefix, ETAMode, DeviceMode, DisplayToken, CallMode string
BatchSize, MaxTicketCall, DefaultPeopleCall, MaxPeopleCall int
MinPartySize, MaxPartySize, Grace, BatchInterval, Buffer int
Rate float64
Tickets []ticketSeed
SessionStatus string
}
type ticketSeed struct {
@@ -86,7 +87,8 @@ func main() {
if index == 2 && user.Role == model.RoleStaff {
continue
}
if err := tx.Create(&model.UserProject{UserID: user.ID, ProjectID: project.ID, CreatedAt: now}).Error; err != nil {
assignment := model.UserProject{UserID: user.ID, ProjectID: project.ID, CreatedAt: now}
if err := tx.Where("user_id = ? AND project_id = ?", user.ID, project.ID).FirstOrCreate(&assignment).Error; err != nil {
return err
}
}
@@ -112,15 +114,15 @@ func main() {
func demoProjects() []projectSeed {
projects := []projectSeed{
{Code: envOr("PROJECT_CODE", "DEMO"), Name: envOr("PROJECT_NAME", "云栖观光车"), Status: model.ProjectRunning, Prefix: "A", ETAMode: model.ETAFixedBatch, DeviceMode: "SUCCESS", DisplayToken: envOr("DEMO_DISPLAY_TOKEN", "demo-display-yunqi"), BatchSize: 5, Grace: 5, BatchInterval: 480, Buffer: 3, Rate: 2, SessionStatus: "RUNNING", Tickets: []ticketSeed{
{Code: envOr("PROJECT_CODE", "DEMO"), Name: envOr("PROJECT_NAME", "云栖观光车"), Status: model.ProjectRunning, Prefix: "A", ETAMode: model.ETAFixedBatch, DeviceMode: "SUCCESS", DisplayToken: envOr("DEMO_DISPLAY_TOKEN", "demo-display-yunqi"), CallMode: model.CallModeBoth, BatchSize: 5, MaxTicketCall: 20, DefaultPeopleCall: 10, MaxPeopleCall: 50, MinPartySize: 1, MaxPartySize: 10, Grace: 5, BatchInterval: 480, Buffer: 3, Rate: 2, SessionStatus: "RUNNING", Tickets: []ticketSeed{
{1, "13800001001", "张", "先生", model.TicketCompleted, 95, 1}, {2, "13800001002", "李", "女士", model.TicketCompleted, 93, 1}, {3, "13800001003", "王", "先生", model.TicketCompleted, 91, 1}, {4, "13800001004", "赵", "女士", model.TicketCompleted, 89, 1}, {5, "13800001005", "陈", "先生", model.TicketCompleted, 87, 1},
{6, "13800001006", "刘", "女士", model.TicketMissed, 72, 2}, {7, "13800001007", "杨", "先生", model.TicketCalled, 18, 2}, {8, "13800001008", "黄", "女士", model.TicketCalled, 17, 2}, {9, "13800001009", "周", "先生", model.TicketCalled, 16, 2}, {10, "13800001010", "吴", "女士", model.TicketArrived, 15, 2},
{11, "13800001011", "徐", "先生", model.TicketWaiting, 14, 0}, {12, "13800001012", "孙", "女士", model.TicketWaiting, 13, 0}, {13, "13800001013", "胡", "先生", model.TicketWaiting, 12, 0}, {14, "13800001014", "朱", "女士", model.TicketWaiting, 11, 0}, {15, "13800001015", "高", "先生", model.TicketWaiting, 10, 0}, {16, "13800001016", "林", "女士", model.TicketWaiting, 9, 0}, {17, "13800001017", "何", "先生", model.TicketWaiting, 8, 0}, {18, "13800001018", "郭", "女士", model.TicketWaiting, 7, 0}, {19, "13800001019", "马", "先生", model.TicketWaiting, 6, 0}, {20, "13800001020", "罗", "女士", model.TicketWaiting, 5, 0}, {21, "13800001021", "梁", "游客", model.TicketCanceled, 4, 0},
}},
{Code: "RAFT", Name: "峡谷漂流", Status: model.ProjectPaused, Prefix: "B", ETAMode: model.ETAContinuous, DeviceMode: "FAILURE", DisplayToken: envOr("RAFT_DISPLAY_TOKEN", "demo-display-raft"), BatchSize: 4, Grace: 8, BatchInterval: 600, Buffer: 5, Rate: 1.5, SessionStatus: "PAUSED", Tickets: []ticketSeed{
{Code: "RAFT", Name: "峡谷漂流", Status: model.ProjectPaused, Prefix: "B", ETAMode: model.ETAContinuous, DeviceMode: "FAILURE", DisplayToken: envOr("RAFT_DISPLAY_TOKEN", "demo-display-raft"), CallMode: model.CallModePeople, BatchSize: 4, MaxTicketCall: 20, DefaultPeopleCall: 8, MaxPeopleCall: 24, MinPartySize: 1, MaxPartySize: 6, Grace: 8, BatchInterval: 600, Buffer: 5, Rate: 1.5, SessionStatus: "PAUSED", Tickets: []ticketSeed{
{1, "13900002001", "郑", "女士", model.TicketWaiting, 35, 0}, {2, "13900002002", "谢", "先生", model.TicketWaiting, 30, 0}, {3, "13900002003", "宋", "女士", model.TicketWaiting, 25, 0}, {4, "13900002004", "唐", "先生", model.TicketWaiting, 20, 0}, {5, "13900002005", "许", "女士", model.TicketWaiting, 15, 0}, {6, "13900002006", "韩", "先生", model.TicketWaiting, 10, 0},
}},
{Code: "CABLE", Name: "云顶索道", Status: model.ProjectNotOpen, Prefix: "C", ETAMode: model.ETAFixedBatch, DeviceMode: "DISABLED", DisplayToken: envOr("CABLE_DISPLAY_TOKEN", "demo-display-cable"), BatchSize: 8, Grace: 5, BatchInterval: 720, Buffer: 5, Rate: 2, SessionStatus: ""},
{Code: "CABLE", Name: "云顶索道", Status: model.ProjectNotOpen, Prefix: "C", ETAMode: model.ETAFixedBatch, DeviceMode: "DISABLED", DisplayToken: envOr("CABLE_DISPLAY_TOKEN", "demo-display-cable"), CallMode: model.CallModeTicket, BatchSize: 8, MaxTicketCall: 16, DefaultPeopleCall: 8, MaxPeopleCall: 32, MinPartySize: 1, MaxPartySize: 4, Grace: 5, BatchInterval: 720, Buffer: 5, Rate: 2, SessionStatus: ""},
}
if strings.EqualFold(envOr("SEED_MODE", "demo"), "smoke") {
projects[0].Tickets = nil
@@ -176,7 +178,7 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project,
"DELETE FROM idempotency_keys WHERE project_id = ?", "DELETE FROM audit_entries WHERE project_id = ?",
"DELETE FROM device_simulations WHERE project_id = ?", "DELETE FROM call_batch_tickets WHERE project_id = ?",
"DELETE FROM call_batches WHERE project_id = ?", "DELETE FROM queue_tickets WHERE project_id = ?",
"DELETE FROM queue_sessions WHERE project_id = ?", "DELETE FROM user_projects WHERE project_id = ?",
"DELETE FROM queue_sessions WHERE project_id = ?",
} {
if err := tx.Exec(statement, project.ID).Error; err != nil {
return project, err
@@ -185,6 +187,9 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project,
hash := security.HashToken(spec.DisplayToken)
project.Code, project.Name, project.Status, project.Timezone, project.TicketPrefix = spec.Code, spec.Name, spec.Status, "Asia/Shanghai", spec.Prefix
project.CallBatchSize, project.GracePeriodMinutes, project.ETAMode = spec.BatchSize, spec.Grace, spec.ETAMode
project.CallMode, project.MaxCallTicketCount = spec.CallMode, spec.MaxTicketCall
project.DefaultCallPeopleCount, project.MaxCallPeopleCount = spec.DefaultPeopleCall, spec.MaxPeopleCall
project.MinPartySize, project.MaxPartySize = spec.MinPartySize, spec.MaxPartySize
project.AverageBatchIntervalSeconds, project.ContinuousRatePerMinute, project.ETABufferMinutes = spec.BatchInterval, spec.Rate, spec.Buffer
if spec.ETAMode == model.ETAContinuous {
project.ETAIntervalSeconds = max(1, int(60/spec.Rate))
@@ -192,7 +197,13 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project,
project.ETAIntervalSeconds = max(1, spec.BatchInterval/spec.BatchSize)
}
project.VisitorNotice = model.DefaultVisitorNotice
project.DisplayTokenHash, project.DeviceSimulationMode, project.UpdatedAt = &hash, spec.DeviceMode, now
// Existing display bindings are operational configuration and survive a
// queue-data rebuild. Newly created demo projects still receive the stable
// seed token so local and smoke environments remain reproducible.
if err == gorm.ErrRecordNotFound || project.DisplayTokenHash == nil {
project.DisplayTokenHash = &hash
}
project.DeviceSimulationMode, project.UpdatedAt = spec.DeviceMode, now
if err == gorm.ErrRecordNotFound {
err = tx.Create(&project).Error
} else {
@@ -214,6 +225,7 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea
}
byBatch := map[int][]model.QueueTicket{}
visitors := map[string]string{}
totalPeople := 0
for _, item := range spec.Tickets {
joined := now.Add(-time.Duration(item.MinutesAgo) * time.Minute)
token := fmt.Sprintf("demo-visitor-%s-%03d", strings.ToLower(project.Code), item.Number)
@@ -230,7 +242,9 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea
return nil, err
}
phoneDigest := cipher.Digest(item.Phone)
ticket := model.QueueTicket{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: item.Number, DisplayNumber: displayNumber, PublicTokenHash: security.HashToken(token), PhoneCiphertext: phoneCipher, PhoneNonce: phoneNonce, PhoneHMAC: &phoneDigest, LastNameCiphertext: lastCipher, LastNameNonce: lastNonce, Honorific: item.Honorific, Status: item.Status, JoinedAt: joined, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: creator, CreatedAt: joined, UpdatedAt: now}
partySize := spec.MinPartySize + (item.Number-1)%(spec.MaxPartySize-spec.MinPartySize+1)
totalPeople += partySize
ticket := model.QueueTicket{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: item.Number, DisplayNumber: displayNumber, PartySize: partySize, PublicTokenHash: security.HashToken(token), PhoneCiphertext: phoneCipher, PhoneNonce: phoneNonce, PhoneHMAC: &phoneDigest, LastNameCiphertext: lastCipher, LastNameNonce: lastNonce, Honorific: item.Honorific, Status: item.Status, JoinedAt: joined, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: creator, CreatedAt: joined, UpdatedAt: now}
called := joined.Add(8 * time.Minute)
arrived := called.Add(3 * time.Minute)
completed := called.Add(8 * time.Minute)
@@ -271,7 +285,15 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea
done := calledAt.Add(12 * time.Minute)
completedAt = &done
}
batch := model.CallBatch{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, BatchSequence: sequence, Revision: int64(sequence), Status: status, RequestedBy: creator, CalledAt: calledAt, CompletedAt: completedAt, CreatedAt: calledAt}
peopleCount := 0
for _, ticket := range tickets {
peopleCount += ticket.PartySize
}
callMode, requestedCount := model.CallModeTicket, len(tickets)
if project.CallMode == model.CallModePeople {
callMode, requestedCount = model.CallModePeople, peopleCount
}
batch := model.CallBatch{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, BatchSequence: sequence, Revision: int64(sequence), Status: status, CallMode: callMode, RequestedCount: requestedCount, TicketCount: len(tickets), PeopleCount: peopleCount, RequestedBy: creator, CalledAt: calledAt, CompletedAt: completedAt, CreatedAt: calledAt}
if err := tx.Create(&batch).Error; err != nil {
return nil, err
}
@@ -291,7 +313,7 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea
return nil, err
}
}
details, _ := json.Marshal(map[string]any{"source": "demo_seed", "ticket_count": len(spec.Tickets)})
details, _ := json.Marshal(map[string]any{"source": "demo_seed", "ticket_count": len(spec.Tickets), "people_count": totalPeople})
projectID, actorID := project.ID, creator
audit := model.AuditEntry{ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &actorID, Action: "DEMO_DATA_SEEDED", EntityType: "PROJECT", EntityID: &projectID, Details: details, RequestID: "seed", UserAgent: "cmd/seed", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now}
if err := tx.Create(&audit).Error; err != nil {