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

@@ -39,11 +39,17 @@ func projectViews(projects []model.Project) []map[string]any {
func projectView(project model.Project) map[string]any {
return map[string]any{
"id": project.ID, "code": project.Code, "name": project.Name, "status": project.Status,
"timezone": project.Timezone, "ticket_prefix": project.TicketPrefix, "call_batch_size": project.CallBatchSize, "batch_size": project.CallBatchSize,
"grace_period_minutes": project.GracePeriodMinutes,
"visitor_notice": project.VisitorNotice,
"timezone": project.Timezone, "ticket_prefix": project.TicketPrefix,
"call_mode": project.CallMode,
"default_call_ticket_count": project.CallBatchSize, "call_batch_size": project.CallBatchSize, "batch_size": project.CallBatchSize,
"max_call_ticket_count": project.MaxCallTicketCount,
"default_call_people_count": project.DefaultCallPeopleCount, "max_call_people_count": project.MaxCallPeopleCount,
"min_party_size": project.MinPartySize, "max_party_size": project.MaxPartySize,
"grace_period_minutes": project.GracePeriodMinutes,
"experienced_people_start": project.ExperiencedPeopleStart,
"visitor_notice": project.VisitorNotice,
"eta": map[string]any{
"interval_per_number_seconds": project.ETAIntervalSeconds,
"interval_per_person_seconds": project.ETAIntervalSeconds,
},
}
}
@@ -69,8 +75,12 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, gorm.ErrRecordNotFound) {
writeJSON(w, http.StatusOK, map[string]any{
"project": projectView(project), "session": nil, "revision": 0,
"counts": map[string]int64{}, "waiting": []any{}, "current_batch": nil, "recent_batches": []any{},
"metrics": map[string]any{"waiting_count": 0, "called_count": 0, "estimated_wait": nil, "last_ticket_number": nil, "next_ticket_number": nil},
"counts": map[string]int64{}, "people_counts": map[string]int64{}, "waiting": []any{}, "current_batch": nil, "recent_batches": []any{},
"metrics": map[string]any{
"waiting_count": 0, "waiting_ticket_count": 0, "waiting_people_count": 0,
"called_count": 0, "called_ticket_count": 0, "called_people_count": 0,
"estimated_wait": nil, "last_ticket_number": nil, "next_ticket_number": nil,
},
"server_time": s.now(), "last_success_at": s.now(),
})
return
@@ -81,19 +91,22 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) {
}
type statusCount struct {
Status string
Count int64
Status string
TicketCount int64 `gorm:"column:ticket_count"`
PeopleCount int64 `gorm:"column:people_count"`
}
var grouped []statusCount
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
Select("status, count(*) AS count").Where("project_id = ? AND queue_session_id = ?", projectID, session.ID).
Select("status, count(*) AS ticket_count, COALESCE(sum(party_size), 0) AS people_count").Where("project_id = ? AND queue_session_id = ?", projectID, session.ID).
Group("status").Scan(&grouped).Error; err != nil {
writeError(w, err)
return
}
counts := make(map[string]int64, len(grouped))
ticketCounts := make(map[string]int64, len(grouped))
peopleCounts := make(map[string]int64, len(grouped))
for _, row := range grouped {
counts[row.Status] = row.Count
ticketCounts[row.Status] = row.TicketCount
peopleCounts[row.Status] = row.PeopleCount
}
var waiting []model.QueueTicket
if err := s.db.WithContext(r.Context()).Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting).
@@ -130,10 +143,24 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) {
}
lastTicketNumber = formatted
}
var lastWaiting model.QueueTicket
lastWaitingErr := s.db.WithContext(r.Context()).
Select("id", "party_size").
Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting).
Order("ticket_number DESC").First(&lastWaiting).Error
if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) {
writeError(w, lastWaitingErr)
return
}
waitingPeople := peopleCounts[model.TicketWaiting]
peopleAheadOfLast := int(waitingPeople)
if lastWaitingErr == nil {
peopleAheadOfLast = max(0, peopleAheadOfLast-lastWaiting.PartySize)
}
estimatedWait, err := domain.CalculateETA(domain.ETAInput{
PeopleAhead: max(0, int(counts[model.TicketWaiting])-1),
IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second,
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && counts[model.TicketWaiting] > 0,
PeopleAhead: peopleAheadOfLast,
IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second,
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && ticketCounts[model.TicketWaiting] > 0,
})
if err != nil {
writeError(w, err)
@@ -142,13 +169,17 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"project": projectView(project),
"session": map[string]any{"id": session.ID, "business_date": session.BusinessDate.Format("2006-01-02"), "status": session.Status},
"revision": session.Revision, "counts": counts, "waiting": waitingViews, "current_batch": batchView,
"revision": session.Revision, "counts": ticketCounts, "people_counts": peopleCounts, "waiting": waitingViews, "current_batch": batchView,
"metrics": map[string]any{
"waiting_count": counts[model.TicketWaiting],
"called_count": counts[model.TicketCalled] + counts[model.TicketArrived],
"estimated_wait": estimatedWait,
"last_ticket_number": lastTicketNumber,
"next_ticket_number": nextTicketNumber,
"waiting_count": ticketCounts[model.TicketWaiting],
"waiting_ticket_count": ticketCounts[model.TicketWaiting],
"waiting_people_count": peopleCounts[model.TicketWaiting],
"called_count": ticketCounts[model.TicketCalled] + ticketCounts[model.TicketArrived],
"called_ticket_count": ticketCounts[model.TicketCalled] + ticketCounts[model.TicketArrived],
"called_people_count": peopleCounts[model.TicketCalled] + peopleCounts[model.TicketArrived],
"estimated_wait": estimatedWait,
"last_ticket_number": lastTicketNumber,
"next_ticket_number": nextTicketNumber,
},
"server_time": s.now(), "last_success_at": s.now(),
})
@@ -175,6 +206,7 @@ type createTicketRequest struct {
Phone string `json:"phone"`
LastName string `json:"last_name"`
Honorific string `json:"honorific"`
PartySize *int `json:"party_size"`
AllowDuplicate bool `json:"allow_duplicate"`
}
@@ -188,6 +220,12 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, err)
return
}
user := currentPrincipal(r.Context()).User
s.createTicketForActor(w, r, user.ID, false)
}
func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, actorID string, publicView bool) {
projectID := r.PathValue("id")
key, err := readIdempotencyKey(r)
if err != nil {
writeError(w, err)
@@ -198,6 +236,10 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, err)
return
}
if input.PartySize == nil || *input.PartySize < 1 {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PARTY_SIZE", Message: "请选择本号人数"})
return
}
phone, err := security.NormalizePhone(input.Phone)
if err != nil {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PHONE", Message: err.Error()})
@@ -223,16 +265,30 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
return
}
user := currentPrincipal(r.Context()).User
scope := "CREATE_TICKET"
auditAction := "TICKET_CREATED"
duplicateMessage := "该手机号已有活动号码,请员工确认后继续"
if publicView {
scope = "PUBLIC_CREATE_TICKET"
auditAction = "PUBLIC_TICKET_CREATED"
duplicateMessage = "该手机号已有活动号码,请确认后继续"
}
var responseBody []byte
responseCode := http.StatusCreated
var revision int64
err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
_, session, err := s.lockRunningProjectAndSession(tx, projectID)
project, session, err := s.lockRunningProjectAndSession(tx, projectID)
if err != nil {
return err
}
if stored, code, found, err := loadIdempotent(tx, projectID, user.ID, "CREATE_TICKET", key, hash, s.now()); err != nil {
if *input.PartySize < project.MinPartySize || *input.PartySize > project.MaxPartySize {
return &apiError{
Status: http.StatusUnprocessableEntity, Code: "INVALID_PARTY_SIZE",
Message: fmt.Sprintf("本项目每个号码可绑定 %d 到 %d 人", project.MinPartySize, project.MaxPartySize),
Details: map[string]any{"min_party_size": project.MinPartySize, "max_party_size": project.MaxPartySize},
}
}
if stored, code, found, err := loadIdempotent(tx, projectID, actorID, scope, key, hash, s.now()); err != nil {
return err
} else if found {
responseBody, responseCode = stored, code
@@ -252,7 +308,11 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
for _, duplicate := range duplicates {
existing = append(existing, map[string]any{"id": duplicate.ID, "display_number": duplicate.DisplayNumber, "status": duplicate.Status})
}
return &apiError{Status: http.StatusConflict, Code: "DUPLICATE_PHONE", Message: "该手机号已有活动号码,请员工确认后继续", Details: map[string]any{"tickets": existing}}
var details any = map[string]any{"tickets": existing}
if publicView {
details = nil
}
return &apiError{Status: http.StatusConflict, Code: "DUPLICATE_PHONE", Message: duplicateMessage, Details: details}
}
publicToken, err := security.GenerateToken()
@@ -277,11 +337,11 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
now := s.now()
ticket := model.QueueTicket{
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID,
TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber,
TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, PartySize: *input.PartySize,
PublicTokenHash: security.HashToken(publicToken), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce,
PhoneHMAC: stringPointer(phoneDigest), LastNameCiphertext: lastNameCiphertext, LastNameNonce: lastNameNonce,
Honorific: input.Honorific, Status: model.TicketWaiting, JoinedAt: now,
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: user.ID, CreatedAt: now, UpdatedAt: now,
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actorID, CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&ticket).Error; err != nil {
return err
@@ -291,25 +351,39 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
Updates(map[string]any{"next_ticket_number": session.NextTicketNumber + 1, "revision": revision, "updated_at": now}).Error; err != nil {
return err
}
if err := s.addAudit(tx, r, &projectID, &user.ID, "TICKET_CREATED", "QUEUE_TICKET", &ticket.ID,
map[string]any{"display_number": displayNumber, "duplicate_count": len(duplicates), "duplicate_confirmed": input.AllowDuplicate}); err != nil {
if err := s.addAudit(tx, r, &projectID, &actorID, auditAction, "QUEUE_TICKET", &ticket.ID,
map[string]any{"display_number": displayNumber, "party_size": ticket.PartySize, "duplicate_count": len(duplicates), "duplicate_confirmed": input.AllowDuplicate}); err != nil {
return err
}
ticketResponse, err := s.staffTicketView(ticket)
if err != nil {
return err
var ticketResponse map[string]any
if publicView {
phoneLast4, err := s.ticketPhoneLast4(ticket)
if err != nil {
return err
}
ticketResponse = map[string]any{
"id": ticket.ID, "display_number": ticket.DisplayNumber, "ticket_number": ticket.DisplayNumber,
"status": ticket.Status, "party_size": ticket.PartySize, "phone_last4": phoneLast4, "created_at": ticket.JoinedAt,
}
} else {
ticketResponse, err = s.staffTicketView(ticket)
if err != nil {
return err
}
}
if !publicView {
ticketResponse["public_token"] = publicToken
ticketResponse["public_url"] = "/visitor/" + publicToken
}
ticketResponse["public_token"] = publicToken
ticketResponse["public_url"] = "/visitor/" + publicToken
response := map[string]any{
"ticket": ticketResponse, "public_token": publicToken,
"ticket": ticketResponse, "public_token": publicToken, "public_url": "/visitor/" + publicToken,
"status_path": "/api/public/status/" + publicToken, "revision": revision,
}
responseBody, err = marshalResponse(response)
if err != nil {
return err
}
return saveIdempotent(tx, projectID, user.ID, "CREATE_TICKET", key, hash, responseCode, responseBody, &ticket.ID, now)
return saveIdempotent(tx, projectID, actorID, scope, key, hash, responseCode, responseBody, &ticket.ID, now)
})
if err != nil {
writeError(w, err)
@@ -323,6 +397,7 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
type callNextRequest struct {
ExpectedRevision *int64 `json:"expected_revision"`
Mode string `json:"mode"`
Count *int `json:"count"`
}
@@ -345,12 +420,20 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPECTED_REVISION_REQUIRED", Message: "必须提供当前队列 revision"})
return
}
mode := strings.ToUpper(strings.TrimSpace(input.Mode))
if mode == "" {
mode = model.CallModeTicket
}
if mode != model.CallModeTicket && mode != model.CallModePeople {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_MODE", Message: "叫号方式不正确"})
return
}
count := 1
if input.Count != nil {
count = *input.Count
}
if count < 1 || count > 100 {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_COUNT", Message: "叫号数量必须在 1 到 100 之间"})
if count < 1 || count > 10000 {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_COUNT", Message: "叫号数量必须是有效的正整数"})
return
}
key, err := readIdempotencyKey(r)
@@ -358,7 +441,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
writeError(w, err)
return
}
hash, _ := requestHash(map[string]any{"operation": "CALL_NEXT", "project_id": projectID, "expected_revision": *input.ExpectedRevision, "count": count})
hash, _ := requestHash(map[string]any{"operation": "CALL_NEXT", "project_id": projectID, "expected_revision": *input.ExpectedRevision, "mode": mode, "count": count})
user := currentPrincipal(r.Context()).User
var responseBody []byte
responseCode := http.StatusOK
@@ -380,15 +463,42 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
Details: map[string]any{"expected_revision": *input.ExpectedRevision, "current_revision": session.Revision},
}
}
var tickets []model.QueueTicket
if !projectAllowsCallMode(project.CallMode, mode) {
return &apiError{Status: http.StatusUnprocessableEntity, Code: "CALL_MODE_NOT_ALLOWED", Message: "当前项目不支持该叫号方式"}
}
limit := project.MaxCallTicketCount
unit := "个号码"
if mode == model.CallModePeople {
limit = project.MaxCallPeopleCount
unit = "人"
}
if count > limit {
return &apiError{
Status: http.StatusUnprocessableEntity, Code: "CALL_COUNT_EXCEEDS_LIMIT",
Message: fmt.Sprintf("本次最多可输入 %d %s", limit, unit),
Details: map[string]any{"mode": mode, "max_count": limit},
}
}
var waitingTickets []model.QueueTicket
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting).
Order("ticket_number ASC").Limit(count).Find(&tickets).Error; err != nil {
Order("ticket_number ASC").Limit(count).Find(&waitingTickets).Error; err != nil {
return err
}
if len(tickets) == 0 {
if len(waitingTickets) == 0 {
return &apiError{Status: http.StatusConflict, Code: "QUEUE_EMPTY", Message: "当前没有等待中的号码"}
}
tickets, peopleCount, err := domain.SelectTicketsForCall(waitingTickets, mode, count)
if errors.Is(err, domain.ErrFirstTicketExceedsPeopleTarget) {
return &apiError{
Status: http.StatusConflict, Code: "NEXT_TICKET_EXCEEDS_PEOPLE_TARGET",
Message: fmt.Sprintf("队首号码有 %d 人,请输入不少于 %d 人", waitingTickets[0].PartySize, waitingTickets[0].PartySize),
Details: map[string]any{"next_ticket_number": waitingTickets[0].DisplayNumber, "party_size": waitingTickets[0].PartySize},
}
}
if err != nil {
return err
}
now := s.now()
autoCompletedBatchIDs, autoCompletedTicketCount, err := autoCompleteActiveBatches(tx, projectID, now)
if err != nil {
@@ -403,6 +513,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
batch := model.CallBatch{
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID,
BatchSequence: lastSequence + 1, Revision: revision, Status: "CALLED",
CallMode: mode, RequestedCount: count, TicketCount: len(tickets), PeopleCount: peopleCount,
RequestedBy: user.ID, CalledAt: now, CreatedAt: now,
}
if err := tx.Create(&batch).Error; err != nil {
@@ -438,7 +549,8 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
}
if err := s.addAudit(tx, r, &projectID, &user.ID, "CALL_NEXT", "CALL_BATCH", &batch.ID,
map[string]any{
"ticket_count": len(tickets), "revision": revision, "device_outcome": outcome,
"call_mode": mode, "requested_count": count, "ticket_count": len(tickets), "people_count": peopleCount,
"revision": revision, "device_outcome": outcome,
"auto_completed_batch_ids": autoCompletedBatchIDs, "auto_completed_ticket_count": autoCompletedTicketCount,
}); err != nil {
return err
@@ -469,6 +581,10 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) {
writeRawJSON(w, responseCode, responseBody)
}
func projectAllowsCallMode(projectMode, requestedMode string) bool {
return projectMode == model.CallModeBoth || projectMode == requestedMode
}
func autoCompleteActiveBatches(tx *gorm.DB, projectID string, now time.Time) ([]string, int64, error) {
var batches []model.CallBatch
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -693,6 +809,7 @@ func (s *Server) staffTicketView(ticket model.QueueTicket) (map[string]any, erro
}
return map[string]any{
"id": ticket.ID, "display_number": ticket.DisplayNumber, "ticket_number": ticket.DisplayNumber, "status": ticket.Status,
"party_size": ticket.PartySize,
"reissued_from_ticket_id": ticket.ReissuedFromTicketID,
"phone": phone, "last_name": lastName, "honorific": ticket.Honorific,
"joined_at": ticket.JoinedAt, "called_at": ticket.CalledAt,
@@ -712,6 +829,8 @@ func (s *Server) staffCallBatchView(batch model.CallBatch, tickets []model.Queue
}
return map[string]any{
"id": batch.ID, "sequence": batch.BatchSequence, "batch_number": batch.BatchSequence, "status": batch.Status,
"call_mode": batch.CallMode, "requested_count": batch.RequestedCount,
"ticket_count": batch.TicketCount, "people_count": batch.PeopleCount,
"called_at": batch.CalledAt, "revision": batch.Revision, "tickets": views,
}, nil
}