Initial commit
This commit is contained in:
311
server/internal/httpapi/public.go
Normal file
311
server/internal/httpapi/public.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/domain"
|
||||
"calllinesystem/server/internal/model"
|
||||
"calllinesystem/server/internal/security"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.PathValue("token"))
|
||||
if (len(token) < 40 || len(token) > 128) && !(s.config.Environment == "development" && strings.HasPrefix(token, "demo-visitor-") && len(token) <= 64) {
|
||||
writeError(w, &apiError{Status: http.StatusNotFound, Code: "STATUS_NOT_FOUND", Message: "排队状态不存在或链接已失效"})
|
||||
return
|
||||
}
|
||||
var ticket model.QueueTicket
|
||||
err := s.db.WithContext(r.Context()).Where("public_token_hash = ?", security.HashToken(token)).First(&ticket).Error
|
||||
if err != nil {
|
||||
writeError(w, mapNotFound(err, "STATUS_NOT_FOUND", "排队状态不存在或链接已失效"))
|
||||
return
|
||||
}
|
||||
var project model.Project
|
||||
var session model.QueueSession
|
||||
if err := s.db.WithContext(r.Context()).First(&project, "id = ?", ticket.ProjectID).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(r.Context()).First(&session, "id = ? AND project_id = ?", ticket.QueueSessionID, ticket.ProjectID).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
phoneSuffix, err := s.ticketPhoneLast4(ticket)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
peopleAhead := 0
|
||||
if ticket.Status == model.TicketWaiting {
|
||||
var count int64
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||
Where("project_id = ? AND queue_session_id = ? AND status = ? AND ticket_number < ?",
|
||||
ticket.ProjectID, ticket.QueueSessionID, model.TicketWaiting, ticket.TicketNumber).Count(&count).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
peopleAhead = int(count)
|
||||
}
|
||||
var latestCalledTicket struct {
|
||||
DisplayNumber string `gorm:"column:display_number"`
|
||||
}
|
||||
latestCalledNumber := any(nil)
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||
Select("display_number").
|
||||
Where("project_id = ? AND queue_session_id = ? AND called_at IS NOT NULL", ticket.ProjectID, ticket.QueueSessionID).
|
||||
Order("called_at DESC, ticket_number DESC").First(&latestCalledTicket).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
writeError(w, err)
|
||||
return
|
||||
} else if latestCalledTicket.DisplayNumber != "" {
|
||||
latestCalledNumber = latestCalledTicket.DisplayNumber
|
||||
}
|
||||
eta, err := domain.CalculateETA(domain.ETAInput{
|
||||
PeopleAhead: peopleAhead, IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second,
|
||||
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && ticket.Status == model.TicketWaiting,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if ticket.Status != model.TicketWaiting {
|
||||
eta = domain.ETAResult{Available: false, Reason: "ticket_not_waiting"}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket_number": ticket.DisplayNumber, "display_number": ticket.DisplayNumber,
|
||||
"project_name": project.Name, "status": ticket.Status, "phone_last4": phoneSuffix, "estimated_wait": eta,
|
||||
"visitor_notice": project.VisitorNotice,
|
||||
"last_updated_at": s.now(), "called_at": ticket.CalledAt,
|
||||
"ticket": map[string]any{
|
||||
"display_number": ticket.DisplayNumber, "status": ticket.Status, "joined_at": ticket.JoinedAt,
|
||||
"called_at": ticket.CalledAt, "arrived_at": ticket.ArrivedAt, "completed_at": ticket.CompletedAt, "missed_at": ticket.MissedAt,
|
||||
},
|
||||
"project": map[string]any{"id": project.ID, "name": project.Name, "status": project.Status},
|
||||
"people_ahead": peopleAhead, "queue_position": queuePosition(ticket.Status, peopleAhead),
|
||||
"latest_called_number": latestCalledNumber,
|
||||
"eta": eta, "revision": session.Revision, "server_time": s.now(),
|
||||
})
|
||||
}
|
||||
|
||||
func queuePosition(status string, peopleAhead int) any {
|
||||
if status != model.TicketWaiting {
|
||||
return nil
|
||||
}
|
||||
return peopleAhead + 1
|
||||
}
|
||||
|
||||
// displayTicketDTO is the complete public ticket shape for a display. Keeping
|
||||
// this separate from staff ticketView makes it impossible to accidentally
|
||||
// serialize honorifics, encrypted contact fields, internal IDs or timestamps.
|
||||
type displayTicketDTO struct {
|
||||
TicketNumber string `json:"ticket_number"`
|
||||
DisplayNumber string `json:"display_number"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type displayBatchDTO struct {
|
||||
BatchNumber int `json:"batch_number"`
|
||||
Sequence int `json:"sequence"`
|
||||
Status string `json:"status"`
|
||||
CalledAt time.Time `json:"called_at"`
|
||||
Tickets []displayTicketDTO `json:"tickets"`
|
||||
}
|
||||
|
||||
type displayProjectDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type displaySnapshotDTO struct {
|
||||
ProjectName string `json:"project_name"`
|
||||
Status string `json:"status"`
|
||||
Project displayProjectDTO `json:"project"`
|
||||
Revision int64 `json:"revision"`
|
||||
WaitingCount int64 `json:"waiting_count"`
|
||||
CurrentBatch *displayBatchDTO `json:"current_batch"`
|
||||
RecentBatches []displayBatchDTO `json:"recent_batches"`
|
||||
EstimatedWait domain.ETAResult `json:"estimated_wait"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
LastUpdatedAt time.Time `json:"last_updated_at"`
|
||||
}
|
||||
|
||||
func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.PathValue("token"))
|
||||
if len(token) < 40 || len(token) > 128 {
|
||||
writeError(w, &apiError{Status: http.StatusNotFound, Code: "DISPLAY_NOT_FOUND", Message: "公示屏绑定不存在"})
|
||||
return
|
||||
}
|
||||
var project model.Project
|
||||
err := s.db.WithContext(r.Context()).Where("display_token_hash = ?", security.HashToken(token)).First(&project).Error
|
||||
if err != nil {
|
||||
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
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
writeJSON(w, http.StatusOK, displaySnapshotDTO{
|
||||
ProjectName: project.Name, Status: project.Status,
|
||||
Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status},
|
||||
RecentBatches: []displayBatchDTO{}, EstimatedWait: domain.ETAResult{Available: false, Reason: "queue_not_running"},
|
||||
ServerTime: s.now(), LastUpdatedAt: project.UpdatedAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var waitingCount int64
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||
Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting).
|
||||
Count(&waitingCount).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
batch, err := s.currentDisplayBatch(r.Context(), project.ID, session.ID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
recentBatches, err := s.recentDisplayBatches(r, project.ID, session.ID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
peopleAhead := max(0, int(waitingCount)-1)
|
||||
estimatedWait, err := domain.CalculateETA(domain.ETAInput{
|
||||
PeopleAhead: peopleAhead, IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second,
|
||||
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waitingCount > 0,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
lastUpdated := project.UpdatedAt
|
||||
if session.UpdatedAt.After(lastUpdated) {
|
||||
lastUpdated = session.UpdatedAt
|
||||
}
|
||||
writeJSON(w, http.StatusOK, displaySnapshotDTO{
|
||||
ProjectName: project.Name, Status: project.Status,
|
||||
Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status},
|
||||
Revision: session.Revision, WaitingCount: waitingCount, CurrentBatch: batch, RecentBatches: recentBatches,
|
||||
EstimatedWait: estimatedWait, ServerTime: s.now(), LastUpdatedAt: lastUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) currentDisplayBatch(ctx context.Context, projectID, sessionID string) (*displayBatchDTO, error) {
|
||||
var batch model.CallBatch
|
||||
err := s.db.WithContext(ctx).Where("project_id = ? AND queue_session_id = ? AND status = 'CALLED'", projectID, sessionID).
|
||||
Order("batch_sequence DESC").First(&batch).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tickets, err := displayBatchTickets(s.db.WithContext(ctx), batch.ID, projectID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := newDisplayBatchDTO(batch, tickets)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *Server) recentDisplayBatches(r *http.Request, projectID, sessionID string) ([]displayBatchDTO, error) {
|
||||
var batches []model.CallBatch
|
||||
if err := s.db.WithContext(r.Context()).Where("project_id = ? AND queue_session_id = ? AND status = 'COMPLETED'", projectID, sessionID).
|
||||
Order("batch_sequence DESC").Limit(5).Find(&batches).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]displayBatchDTO, 0, len(batches))
|
||||
for _, batch := range batches {
|
||||
tickets, err := displayBatchTickets(s.db.WithContext(r.Context()), batch.ID, projectID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views = append(views, newDisplayBatchDTO(batch, tickets))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func displayBatchTickets(db *gorm.DB, batchID, projectID string, calledOnly bool) ([]displayTicketDTO, error) {
|
||||
query := db.Table("queue_tickets").
|
||||
Select("queue_tickets.display_number, queue_tickets.status").
|
||||
Joins("JOIN call_batch_tickets cbt ON cbt.ticket_id = queue_tickets.id AND cbt.project_id = queue_tickets.project_id").
|
||||
Where("cbt.call_batch_id = ? AND cbt.project_id = ?", batchID, projectID).
|
||||
Order("cbt.position ASC")
|
||||
if calledOnly {
|
||||
query = query.Where("queue_tickets.status = ?", model.TicketCalled)
|
||||
}
|
||||
type row struct {
|
||||
DisplayNumber string
|
||||
Status string
|
||||
}
|
||||
var rows []row
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tickets := make([]displayTicketDTO, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
tickets = append(tickets, displayTicketDTO{
|
||||
TicketNumber: item.DisplayNumber, DisplayNumber: item.DisplayNumber, Status: item.Status,
|
||||
})
|
||||
}
|
||||
return tickets, nil
|
||||
}
|
||||
|
||||
func newDisplayBatchDTO(batch model.CallBatch, tickets []displayTicketDTO) displayBatchDTO {
|
||||
return displayBatchDTO{
|
||||
BatchNumber: batch.BatchSequence, Sequence: batch.BatchSequence, Status: batch.Status,
|
||||
CalledAt: batch.CalledAt, Tickets: tickets,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
|
||||
if err := validateUUID(projectID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.authorizeProject(r.Context(), projectID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, fmt.Errorf("streaming unsupported"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
_, _ = fmt.Fprint(w, "retry: 5000\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
events, unsubscribe := s.hub.subscribe(projectID)
|
||||
defer unsubscribe()
|
||||
keepAlive := time.NewTicker(15 * time.Second)
|
||||
defer keepAlive.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case body := <-events:
|
||||
_, _ = fmt.Fprintf(w, "event: queue\ndata: %s\n\n", body)
|
||||
flusher.Flush()
|
||||
case <-keepAlive.C:
|
||||
_, _ = fmt.Fprint(w, ": keep-alive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user