Files
XQKqueue/server/internal/httpapi/history.go
2026-07-22 10:18:49 +08:00

942 lines
38 KiB
Go

package httpapi
import (
"context"
"encoding/csv"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"gorm.io/gorm"
)
const historyDateLayout = "2006-01-02"
var historyTicketStatuses = map[string]bool{
model.TicketWaiting: true, model.TicketCalled: true, model.TicketArrived: true,
model.TicketCompleted: true, model.TicketMissed: true, model.TicketCanceled: true,
}
var historyBatchStatuses = map[string]bool{"CALLED": true, "COMPLETED": true}
type historyFilter struct {
ProjectID string
From string
To string
Status string
Query string
Page int
PageSize int
}
type historyTicketRow struct {
ID string `gorm:"column:id"`
ProjectID string `gorm:"column:project_id"`
ProjectName string `gorm:"column:project_name"`
ProjectCode string `gorm:"column:project_code"`
QueueSessionID string `gorm:"column:queue_session_id"`
BusinessDate time.Time `gorm:"column:business_date"`
TicketNumber int `gorm:"column:ticket_number"`
DisplayNumber string `gorm:"column:display_number"`
PartySize int `gorm:"column:party_size"`
Status string `gorm:"column:status"`
JoinedAt time.Time `gorm:"column:joined_at"`
CalledAt *time.Time `gorm:"column:called_at"`
ArrivedAt *time.Time `gorm:"column:arrived_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
MissedAt *time.Time `gorm:"column:missed_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
PersonalDataPurgedAt *time.Time `gorm:"column:personal_data_purged_at"`
HistoryAnonymizedAt *time.Time `gorm:"column:history_anonymized_at"`
PhoneCiphertext []byte `gorm:"column:phone_ciphertext"`
PhoneNonce []byte `gorm:"column:phone_nonce"`
PhoneHMAC *string `gorm:"column:phone_hmac"`
LastNameCiphertext []byte `gorm:"column:last_name_ciphertext"`
LastNameNonce []byte `gorm:"column:last_name_nonce"`
Honorific string `gorm:"column:honorific"`
CreatedBy string `gorm:"column:created_by"`
CreatedByName string `gorm:"column:created_by_name"`
BatchID *string `gorm:"column:batch_id"`
BatchSequence *int `gorm:"column:batch_sequence"`
BatchPosition *int `gorm:"column:batch_position"`
}
type historyBatchRow struct {
ID string `gorm:"column:id"`
ProjectID string `gorm:"column:project_id"`
ProjectName string `gorm:"column:project_name"`
ProjectCode string `gorm:"column:project_code"`
QueueSessionID string `gorm:"column:queue_session_id"`
BusinessDate time.Time `gorm:"column:business_date"`
BatchSequence int `gorm:"column:batch_sequence"`
Revision int64 `gorm:"column:revision"`
Status string `gorm:"column:status"`
CallMode string `gorm:"column:call_mode"`
RequestedCount int `gorm:"column:requested_count"`
TicketCount int `gorm:"column:ticket_count"`
PeopleCount int `gorm:"column:people_count"`
RequestedBy string `gorm:"column:requested_by"`
RequestedByName string `gorm:"column:requested_by_name"`
CalledAt time.Time `gorm:"column:called_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
}
type historyBatchTicketLabel struct {
BatchID string `gorm:"column:batch_id"`
DisplayNumber string `gorm:"column:display_number"`
Position int `gorm:"column:position"`
}
type historySummaryRow struct {
TotalTickets int64 `gorm:"column:total_tickets"`
TotalPeople int64 `gorm:"column:total_people"`
CalledTickets int64 `gorm:"column:called_tickets"`
CalledPeople int64 `gorm:"column:called_people"`
CompletedTickets int64 `gorm:"column:completed_tickets"`
CompletedPeople int64 `gorm:"column:completed_people"`
MissedTickets int64 `gorm:"column:missed_tickets"`
CanceledTickets int64 `gorm:"column:canceled_tickets"`
AverageWait *float64 `gorm:"column:average_wait"`
MaxWait *float64 `gorm:"column:max_wait"`
}
type historyHourlyRow struct {
Hour int64 `gorm:"column:hour"`
IssuedTickets int64 `gorm:"column:issued_tickets"`
IssuedPeople int64 `gorm:"column:issued_people"`
CalledTickets int64 `gorm:"column:called_tickets"`
CalledPeople int64 `gorm:"column:called_people"`
}
type historyDailyRow struct {
BusinessDate time.Time `gorm:"column:business_date"`
TotalTickets int64 `gorm:"column:total_tickets"`
TotalPeople int64 `gorm:"column:total_people"`
CalledTickets int64 `gorm:"column:called_tickets"`
CompletedTickets int64 `gorm:"column:completed_tickets"`
MissedTickets int64 `gorm:"column:missed_tickets"`
CanceledTickets int64 `gorm:"column:canceled_tickets"`
AverageWait *float64 `gorm:"column:average_wait"`
MaxWait *float64 `gorm:"column:max_wait"`
}
type historyProjectRow struct {
ProjectID string `gorm:"column:project_id"`
ProjectName string `gorm:"column:project_name"`
ProjectCode string `gorm:"column:project_code"`
TotalTickets int64 `gorm:"column:total_tickets"`
TotalPeople int64 `gorm:"column:total_people"`
CalledTickets int64 `gorm:"column:called_tickets"`
CompletedTickets int64 `gorm:"column:completed_tickets"`
MissedTickets int64 `gorm:"column:missed_tickets"`
CanceledTickets int64 `gorm:"column:canceled_tickets"`
AverageWait *float64 `gorm:"column:average_wait"`
MaxWait *float64 `gorm:"column:max_wait"`
}
func (s *Server) adminHistoryTickets(w http.ResponseWriter, r *http.Request) {
filter, err := s.parseHistoryFilter(r, historyTicketStatuses)
if err != nil {
writeError(w, err)
return
}
if err := s.auditHistoryPhoneSearch(r, filter); err != nil {
writeError(w, err)
return
}
base := s.historyTicketQuery(r.Context())
base = applyHistoryTicketFilter(base, filter, s)
var total int64
if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil {
writeError(w, err)
return
}
var rows []historyTicketRow
query := s.historyTicketQuery(r.Context())
query = applyHistoryTicketFilter(query, filter, s)
if err := query.Select(historyTicketSelect()).Order("session.business_date DESC, ticket.ticket_number DESC").
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
writeError(w, err)
return
}
items, err := s.historyTicketViews(rows, false)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total,
"from": filter.From, "to": filter.To,
})
}
func (s *Server) adminHistoryTicket(w http.ResponseWriter, r *http.Request) {
ticketID := r.PathValue("id")
if err := validateUUID(ticketID); err != nil {
writeError(w, err)
return
}
var row historyTicketRow
query := s.historyTicketQuery(r.Context()).Where("ticket.id = ?", ticketID)
if err := query.Select(historyTicketSelect()).First(&row).Error; err != nil {
writeError(w, mapNotFound(err, "TICKET_NOT_FOUND", "历史号码不存在"))
return
}
if err := s.authorizeProject(r.Context(), row.ProjectID); err != nil {
writeError(w, err)
return
}
reveal := parseBoolQuery(r.URL.Query().Get("reveal"))
if err := s.auditHistoryAccess(r, "HISTORY_TICKET_VIEW", "QUEUE_TICKET", &row.ID, &row.ProjectID, map[string]any{"reveal_personal": reveal}); err != nil {
writeError(w, err)
return
}
view, err := s.historyTicketView(row, reveal)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ticket": view})
}
func (s *Server) adminHistoryBatches(w http.ResponseWriter, r *http.Request) {
filter, err := s.parseHistoryFilter(r, historyBatchStatuses)
if err != nil {
writeError(w, err)
return
}
base := s.historyBatchQuery(r.Context())
base = applyHistoryBatchFilter(base, filter)
var total int64
if err := base.Select("COUNT(batch.id)").Scan(&total).Error; err != nil {
writeError(w, err)
return
}
var rows []historyBatchRow
query := s.historyBatchQuery(r.Context())
query = applyHistoryBatchFilter(query, filter)
if err := query.Select(historyBatchSelect()).Order("session.business_date DESC, batch.batch_sequence DESC").
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
writeError(w, err)
return
}
labels, err := s.historyBatchLabels(r.Context(), rows)
if err != nil {
writeError(w, err)
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
items = append(items, historyBatchView(row, labels[row.ID]))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total,
"from": filter.From, "to": filter.To,
})
}
func (s *Server) adminHistoryBatch(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
if err := validateUUID(batchID); err != nil {
writeError(w, err)
return
}
var batch historyBatchRow
if err := s.historyBatchQuery(r.Context()).Where("batch.id = ?", batchID).Select(historyBatchSelect()).First(&batch).Error; err != nil {
writeError(w, mapNotFound(err, "BATCH_NOT_FOUND", "历史叫号记录不存在"))
return
}
if err := s.authorizeProject(r.Context(), batch.ProjectID); err != nil {
writeError(w, err)
return
}
var rows []historyTicketRow
query := s.historyTicketQuery(r.Context()).
Where("link.call_batch_id = ?", batchID).
Select(historyTicketSelect()).
Order("link.position ASC")
if err := query.Scan(&rows).Error; err != nil {
writeError(w, err)
return
}
items, err := s.historyTicketViews(rows, false)
if err != nil {
writeError(w, err)
return
}
if err := s.auditHistoryAccess(r, "HISTORY_BATCH_VIEW", "CALL_BATCH", &batch.ID, &batch.ProjectID, nil); err != nil {
writeError(w, err)
return
}
view := historyBatchView(batch, nil)
view["tickets"] = items
writeJSON(w, http.StatusOK, map[string]any{"batch": view})
}
func (s *Server) adminHistorySummary(w http.ResponseWriter, r *http.Request) {
filter, err := s.parseHistoryFilter(r, nil)
if err != nil {
writeError(w, err)
return
}
base := s.historyTicketQuery(r.Context())
base = applyHistoryTicketFilter(base, filter, s)
var summary historySummaryRow
if err := base.Select(`
COUNT(ticket.id) AS total_tickets,
COALESCE(SUM(ticket.party_size), 0) AS total_people,
COUNT(ticket.id) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')) AS called_tickets,
COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')), 0) AS called_people,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status = 'COMPLETED'), 0) AS completed_people,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).Scan(&summary).Error; err != nil {
writeError(w, err)
return
}
hourly, err := s.historyHourlySummary(r.Context(), filter)
if err != nil {
writeError(w, err)
return
}
daily, err := s.historyDailySummary(r.Context(), filter)
if err != nil {
writeError(w, err)
return
}
projects, err := s.historyProjectSummary(r.Context(), filter)
if err != nil {
writeError(w, err)
return
}
completionRate, missedRate, canceledRate := 0.0, 0.0, 0.0
if summary.TotalTickets > 0 {
completionRate = float64(summary.CompletedTickets) / float64(summary.TotalTickets)
missedRate = float64(summary.MissedTickets) / float64(summary.TotalTickets)
canceledRate = float64(summary.CanceledTickets) / float64(summary.TotalTickets)
}
writeJSON(w, http.StatusOK, map[string]any{
"from": filter.From, "to": filter.To,
"summary": map[string]any{
"total_tickets": summary.TotalTickets, "total_people": summary.TotalPeople,
"called_tickets": summary.CalledTickets, "called_people": summary.CalledPeople,
"completed_tickets": summary.CompletedTickets, "completed_people": summary.CompletedPeople,
"missed_tickets": summary.MissedTickets, "canceled_tickets": summary.CanceledTickets,
"completion_rate": completionRate, "missed_rate": missedRate, "canceled_rate": canceledRate,
"average_wait_seconds": nullableRoundedSeconds(summary.AverageWait),
"max_wait_seconds": nullableRoundedSeconds(summary.MaxWait),
},
"hourly": historyHourlyViews(hourly),
"peak_hours": historyPeakHourViews(hourly),
"daily": historyDailyViews(daily),
"projects": historyProjectViews(projects),
"alerts": historySummaryAlerts(projects),
})
}
func (s *Server) adminHistoryExport(w http.ResponseWriter, r *http.Request) {
filter, err := s.parseHistoryFilter(r, historyTicketStatuses)
if err != nil {
writeError(w, err)
return
}
includePersonal := parseBoolQuery(r.URL.Query().Get("include_personal"))
if includePersonal && !parseBoolQuery(r.URL.Query().Get("confirm")) {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_CONFIRMATION_REQUIRED", Message: "导出个人信息前需要二次确认"})
return
}
base := s.historyTicketQuery(r.Context())
base = applyHistoryTicketFilter(base, filter, s)
var total int64
if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil {
writeError(w, err)
return
}
if total > 10000 {
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_TOO_LARGE", Message: "导出结果超过 10000 行,请缩小日期范围或增加筛选条件", Details: map[string]any{"total": total, "max_rows": 10000}})
return
}
var rows []historyTicketRow
query := s.historyTicketQuery(r.Context())
query = applyHistoryTicketFilter(query, filter, s)
if err := query.Select(historyTicketSelect()).Order("session.business_date ASC, ticket.ticket_number ASC").Limit(10000).Scan(&rows).Error; err != nil {
writeError(w, err)
return
}
if err := s.auditHistoryAccess(r, "HISTORY_EXPORT", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{
"from": filter.From, "to": filter.To, "status": filter.Status, "query_kind": historyQueryKind(filter.Query), "rows": len(rows), "include_personal": includePersonal,
}); err != nil {
writeError(w, err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=history-tickets-%s-%s.csv", filter.From, filter.To))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
csvWriter := csv.NewWriter(w)
_ = csvWriter.Write([]string{"项目", "营业日", "号码", "同行人数", "状态", "取号时间", "叫号时间", "终态时间", "等待时长(秒)", "手机号", "姓氏", "称谓", "数据状态"})
for _, row := range rows {
view, err := s.historyTicketView(row, includePersonal)
if err != nil {
return
}
_ = csvWriter.Write([]string{
stringValue(view["project_name"]), stringValue(view["business_date"]), stringValue(view["ticket_number"]),
stringValue(view["party_size"]), stringValue(view["status"]), stringValue(view["joined_at"]),
stringValue(view["called_at"]), stringValue(view["terminal_at"]), stringValue(view["wait_seconds"]),
stringValue(view["phone"]), stringValue(view["last_name"]), stringValue(view["honorific"]), stringValue(view["personal_data_status"]),
})
}
csvWriter.Flush()
}
func (s *Server) parseHistoryFilter(r *http.Request, allowedStatuses map[string]bool) (historyFilter, error) {
query := r.URL.Query()
filter := historyFilter{From: query.Get("from"), To: query.Get("to"), Status: strings.ToUpper(strings.TrimSpace(query.Get("status"))), Query: strings.TrimSpace(query.Get("query")), Page: 1, PageSize: 20}
if filter.ProjectID = strings.TrimSpace(query.Get("project_id")); filter.ProjectID != "" {
if err := validateUUID(filter.ProjectID); err != nil {
return historyFilter{}, err
}
if err := s.authorizeProject(r.Context(), filter.ProjectID); err != nil {
return historyFilter{}, err
}
}
if filter.From == "" || filter.To == "" {
today := s.now().UTC().Truncate(24 * time.Hour)
filter.To = today.Format(historyDateLayout)
filter.From = today.AddDate(0, 0, -6).Format(historyDateLayout)
}
from, fromErr := time.Parse(historyDateLayout, filter.From)
to, toErr := time.Parse(historyDateLayout, filter.To)
if fromErr != nil || toErr != nil || to.Before(from) || to.Sub(from) > 30*24*time.Hour {
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_DATE_RANGE", Message: "历史数据日期范围必须有效且不超过 31 天"}
}
if filter.Status != "" && allowedStatuses != nil && !allowedStatuses[filter.Status] {
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_STATUS", Message: "历史数据状态筛选条件不正确"}
}
if len([]rune(filter.Query)) > 80 {
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "HISTORY_QUERY_TOO_LONG", Message: "搜索条件不能超过 80 个字符"}
}
if raw := query.Get("page"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100000 {
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE", Message: "页码不正确"}
}
filter.Page = value
}
if raw := query.Get("page_size"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE_SIZE", Message: "每页数量必须在 1 到 100 之间"}
}
filter.PageSize = value
}
return filter, nil
}
func (s *Server) historyTicketQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("queue_tickets AS ticket").
Joins("JOIN queue_sessions AS session ON session.id = ticket.queue_session_id AND session.project_id = ticket.project_id").
Joins("JOIN projects AS project ON project.id = ticket.project_id").
Joins("LEFT JOIN users AS creator ON creator.id = ticket.created_by").
Joins("LEFT JOIN call_batch_tickets AS link ON link.ticket_id = ticket.id AND link.project_id = ticket.project_id").
Joins("LEFT JOIN call_batches AS batch ON batch.id = link.call_batch_id AND batch.project_id = link.project_id")
}
func historyTicketSelect() string {
return `ticket.id, ticket.project_id, project.name AS project_name, project.code AS project_code,
ticket.queue_session_id, session.business_date, ticket.ticket_number, ticket.display_number, ticket.party_size,
ticket.status, ticket.joined_at, ticket.called_at, ticket.arrived_at, ticket.completed_at, ticket.missed_at,
ticket.updated_at,
ticket.personal_data_purged_at, ticket.phone_ciphertext, ticket.phone_nonce, ticket.phone_hmac,
ticket.history_anonymized_at, ticket.last_name_ciphertext, ticket.last_name_nonce, ticket.honorific, ticket.created_by,
creator.username AS created_by_name, batch.id AS batch_id, batch.batch_sequence, link.position AS batch_position`
}
func applyHistoryTicketFilter(db *gorm.DB, filter historyFilter, server *Server) *gorm.DB {
db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To)
if filter.ProjectID != "" {
db = db.Where("ticket.project_id = ?", filter.ProjectID)
}
if filter.Status != "" {
db = db.Where("ticket.status = ?", filter.Status)
}
if filter.Query != "" {
pattern := "%" + escapeHistoryLike(filter.Query) + "%"
phoneDigest := ""
if normalized, err := security.NormalizePhone(filter.Query); err == nil {
phoneDigest = server.cipher.Digest(normalized)
}
if phoneDigest != "" {
db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\' OR ticket.phone_hmac = ?)", pattern, pattern, phoneDigest)
} else {
db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\')", pattern, pattern)
}
}
return db
}
func (s *Server) historyBatchQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("call_batches AS batch").
Joins("JOIN queue_sessions AS session ON session.id = batch.queue_session_id AND session.project_id = batch.project_id").
Joins("JOIN projects AS project ON project.id = batch.project_id").
Joins("LEFT JOIN users AS requester ON requester.id = batch.requested_by")
}
func historyBatchSelect() string {
return `batch.id, batch.project_id, project.name AS project_name, project.code AS project_code,
batch.queue_session_id, session.business_date, batch.batch_sequence, batch.revision, batch.status,
batch.call_mode, batch.requested_count, batch.ticket_count, batch.people_count, batch.requested_by,
requester.username AS requested_by_name, batch.called_at, batch.completed_at`
}
func applyHistoryBatchFilter(db *gorm.DB, filter historyFilter) *gorm.DB {
db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To)
if filter.ProjectID != "" {
db = db.Where("batch.project_id = ?", filter.ProjectID)
}
if filter.Status != "" {
db = db.Where("batch.status = ?", filter.Status)
}
if filter.Query != "" {
pattern := "%" + escapeHistoryLike(filter.Query) + "%"
db = db.Where(`(
batch.batch_sequence::text ILIKE ? ESCAPE '\\'
OR EXISTS (
SELECT 1 FROM call_batch_tickets AS search_link
JOIN queue_tickets AS search_ticket ON search_ticket.id = search_link.ticket_id AND search_ticket.project_id = search_link.project_id
WHERE search_link.call_batch_id = batch.id AND search_ticket.display_number ILIKE ? ESCAPE '\\'
)`, pattern, pattern)
}
return db
}
func (s *Server) historyBatchLabels(ctx context.Context, rows []historyBatchRow) (map[string][]string, error) {
labels := make(map[string][]string)
if len(rows) == 0 {
return labels, nil
}
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
var values []historyBatchTicketLabel
err := s.db.WithContext(ctx).Table("call_batch_tickets AS link").
Select("link.call_batch_id AS batch_id, ticket.display_number, link.position").
Joins("JOIN queue_tickets AS ticket ON ticket.id = link.ticket_id AND ticket.project_id = link.project_id").
Where("link.call_batch_id IN ?", ids).Order("link.call_batch_id, link.position").Scan(&values).Error
if err != nil {
return nil, err
}
for _, value := range values {
labels[value.BatchID] = append(labels[value.BatchID], value.DisplayNumber)
}
return labels, nil
}
func (s *Server) historyHourlySummary(ctx context.Context, filter historyFilter) ([]historyHourlyRow, error) {
issuedBase := s.historyTicketQuery(ctx)
issuedBase = applyHistoryTicketFilter(issuedBase, filter, s)
var issuedRows []historyHourlyRow
if err := issuedBase.Select(`
EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour,
COUNT(ticket.id) AS issued_tickets,
COALESCE(SUM(ticket.party_size), 0) AS issued_people`).
Group("EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')").Scan(&issuedRows).Error; err != nil {
return nil, err
}
calledBase := s.historyTicketQuery(ctx)
calledBase = applyHistoryTicketFilter(calledBase, filter, s).Where("ticket.called_at IS NOT NULL")
var calledRows []historyHourlyRow
if err := calledBase.Select(`
EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour,
COUNT(ticket.id) AS called_tickets,
COALESCE(SUM(ticket.party_size), 0) AS called_people`).
Group("EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')").Scan(&calledRows).Error; err != nil {
return nil, err
}
byHour := make(map[int64]historyHourlyRow, len(issuedRows)+len(calledRows))
for _, row := range issuedRows {
byHour[row.Hour] = row
}
for _, row := range calledRows {
merged := byHour[row.Hour]
merged.Hour = row.Hour
merged.CalledTickets = row.CalledTickets
merged.CalledPeople = row.CalledPeople
byHour[row.Hour] = merged
}
rows := make([]historyHourlyRow, 0, len(byHour))
for _, row := range byHour {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Hour < rows[j].Hour })
return rows, nil
}
func (s *Server) historyDailySummary(ctx context.Context, filter historyFilter) ([]historyDailyRow, error) {
base := s.historyTicketQuery(ctx)
base = applyHistoryTicketFilter(base, filter, s)
var rows []historyDailyRow
err := base.Select(`
session.business_date AS business_date,
COUNT(ticket.id) AS total_tickets,
COALESCE(SUM(ticket.party_size), 0) AS total_people,
COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).
Group("session.business_date").Order("session.business_date ASC").Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (s *Server) historyProjectSummary(ctx context.Context, filter historyFilter) ([]historyProjectRow, error) {
base := s.historyTicketQuery(ctx)
base = applyHistoryTicketFilter(base, filter, s)
var rows []historyProjectRow
err := base.Select(`
project.id AS project_id, project.name AS project_name, project.code AS project_code,
COUNT(ticket.id) AS total_tickets,
COALESCE(SUM(ticket.party_size), 0) AS total_people,
COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).
Group("project.id, project.name, project.code").Order("total_tickets DESC, project.name ASC").Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func historyHourlyViews(rows []historyHourlyRow) []map[string]any {
result := make([]map[string]any, 0, len(rows))
for _, row := range rows {
result = append(result, map[string]any{"hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople})
}
return result
}
func historyPeakHourViews(rows []historyHourlyRow) []map[string]any {
peaks := append([]historyHourlyRow(nil), rows...)
sort.Slice(peaks, func(i, j int) bool {
if peaks[i].IssuedTickets != peaks[j].IssuedTickets {
return peaks[i].IssuedTickets > peaks[j].IssuedTickets
}
if peaks[i].CalledTickets != peaks[j].CalledTickets {
return peaks[i].CalledTickets > peaks[j].CalledTickets
}
return peaks[i].Hour < peaks[j].Hour
})
if len(peaks) > 3 {
peaks = peaks[:3]
}
result := make([]map[string]any, 0, len(peaks))
for index, row := range peaks {
if row.IssuedTickets == 0 {
continue
}
result = append(result, map[string]any{"rank": index + 1, "hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople})
}
return result
}
func historyDailyViews(rows []historyDailyRow) []map[string]any {
result := make([]map[string]any, 0, len(rows))
for _, row := range rows {
result = append(result, map[string]any{
"business_date": row.BusinessDate.Format(historyDateLayout), "total_tickets": row.TotalTickets, "total_people": row.TotalPeople,
"called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets, "missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets,
"completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets),
"average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait),
})
}
return result
}
func historyProjectViews(rows []historyProjectRow) []map[string]any {
result := make([]map[string]any, 0, len(rows))
for _, row := range rows {
result = append(result, map[string]any{
"project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
"total_tickets": row.TotalTickets, "total_people": row.TotalPeople, "called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets,
"missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets,
"completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets),
"average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait),
})
}
return result
}
func historySummaryAlerts(rows []historyProjectRow) []map[string]any {
alerts := make([]map[string]any, 0)
for _, row := range rows {
if row.TotalTickets < 5 {
continue
}
missedRate := historyRate(row.MissedTickets, row.TotalTickets)
canceledRate := historyRate(row.CanceledTickets, row.TotalTickets)
completionRate := historyRate(row.CompletedTickets, row.TotalTickets)
var alert map[string]any
switch {
case missedRate >= 0.20:
alert = map[string]any{"severity": 3, "type": "MISSED_RATE", "title": row.ProjectName + "过号率偏高", "detail": "过号率达到 " + formatHistoryRate(missedRate) + ",建议复盘叫号触达和现场提醒。"}
case row.AverageWait != nil && *row.AverageWait >= 900:
alert = map[string]any{"severity": 3, "type": "WAIT_TIME", "title": row.ProjectName + "等待时间偏长", "detail": "平均等待约 " + formatHistoryDuration(*row.AverageWait) + ",建议检查高峰时段的叫号吞吐。"}
case completionRate < 0.70:
alert = map[string]any{"severity": 2, "type": "COMPLETION_RATE", "title": row.ProjectName + "完成率偏低", "detail": "完成率为 " + formatHistoryRate(completionRate) + ",建议结合过号和取消明细进一步下钻。"}
case canceledRate >= 0.10:
alert = map[string]any{"severity": 2, "type": "CANCELED_RATE", "title": row.ProjectName + "取消率偏高", "detail": "取消率达到 " + formatHistoryRate(canceledRate) + ",建议确认营业安排或取号规则是否需要调整。"}
}
if alert != nil {
alert["project_id"] = row.ProjectID
alert["project_name"] = row.ProjectName
alert["total_tickets"] = row.TotalTickets
alerts = append(alerts, alert)
}
}
sort.Slice(alerts, func(i, j int) bool {
left, _ := alerts[i]["severity"].(int)
right, _ := alerts[j]["severity"].(int)
return left > right
})
if len(alerts) > 6 {
alerts = alerts[:6]
}
return alerts
}
func historyRate(numerator, denominator int64) float64 {
if denominator == 0 {
return 0
}
return float64(numerator) / float64(denominator)
}
func formatHistoryRate(value float64) string {
return strconv.FormatFloat(value*100, 'f', 1, 64) + "%"
}
func formatHistoryDuration(seconds float64) string {
minutes := int64(seconds) / 60
remaining := int64(seconds) % 60
if minutes == 0 {
return strconv.FormatInt(remaining, 10) + " 秒"
}
return strconv.FormatInt(minutes, 10) + " 分钟"
}
func (s *Server) historyTicketViews(rows []historyTicketRow, reveal bool) ([]map[string]any, error) {
views := make([]map[string]any, 0, len(rows))
for _, row := range rows {
view, err := s.historyTicketView(row, reveal)
if err != nil {
return nil, err
}
views = append(views, view)
}
return views, nil
}
func (s *Server) historyTicketView(row historyTicketRow, reveal bool) (map[string]any, error) {
ticket := model.QueueTicket{
ID: row.ID, ProjectID: row.ProjectID, DisplayNumber: row.DisplayNumber, PartySize: row.PartySize,
Status: row.Status, JoinedAt: row.JoinedAt, CalledAt: row.CalledAt, ArrivedAt: row.ArrivedAt,
CompletedAt: row.CompletedAt, MissedAt: row.MissedAt, PersonalDataPurgedAt: row.PersonalDataPurgedAt,
HistoryAnonymizedAt: row.HistoryAnonymizedAt,
UpdatedAt: func() time.Time {
if row.UpdatedAt != nil {
return *row.UpdatedAt
}
return row.JoinedAt
}(),
PhoneCiphertext: row.PhoneCiphertext, PhoneNonce: row.PhoneNonce, PhoneHMAC: row.PhoneHMAC,
LastNameCiphertext: row.LastNameCiphertext, LastNameNonce: row.LastNameNonce, Honorific: row.Honorific,
}
personal, err := s.historyPersonalView(ticket, reveal)
if err != nil {
return nil, err
}
view := map[string]any{
"id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
"queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout),
"ticket_number": row.DisplayNumber, "ticket_sequence": row.TicketNumber, "party_size": row.PartySize, "status": row.Status,
"joined_at": row.JoinedAt, "called_at": row.CalledAt, "arrived_at": row.ArrivedAt,
"completed_at": row.CompletedAt, "missed_at": row.MissedAt, "terminal_at": historyTerminalAt(ticket),
"wait_seconds": historyWaitSeconds(ticket), "created_by": row.CreatedByName, "personal_data_purged_at": row.PersonalDataPurgedAt,
"history_anonymized_at": row.HistoryAnonymizedAt,
"batch_id": row.BatchID, "batch_sequence": row.BatchSequence, "batch_position": row.BatchPosition,
}
for key, value := range personal {
view[key] = value
}
return view, nil
}
func (s *Server) historyPersonalView(ticket model.QueueTicket, reveal bool) (map[string]any, error) {
view := map[string]any{"phone": nil, "phone_last4": nil, "phone_masked": nil, "last_name": nil, "honorific": nil, "personal_data_status": "ANONYMIZED"}
if ticket.HistoryAnonymizedAt != nil || ticket.PhoneHMAC == nil || len(ticket.PhoneCiphertext) == 0 || len(ticket.PhoneNonce) == 0 {
return view, nil
}
last4, err := s.ticketPhoneLast4(ticket)
if err != nil {
return nil, err
}
view["phone_last4"] = last4
view["phone_masked"] = "****" + last4
view["personal_data_status"] = "AVAILABLE"
if !reveal {
return view, nil
}
phone, lastName, err := s.decryptTicketPersonal(ticket)
if err != nil {
return nil, err
}
view["phone"] = phone
view["last_name"] = lastName
view["honorific"] = ticket.Honorific
return view, nil
}
func historyBatchView(row historyBatchRow, ticketNumbers []string) map[string]any {
view := map[string]any{
"id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
"queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout),
"batch_sequence": row.BatchSequence, "status": row.Status, "call_mode": row.CallMode,
"requested_count": row.RequestedCount, "ticket_count": row.TicketCount, "people_count": row.PeopleCount,
"requested_by": row.RequestedByName, "called_at": row.CalledAt, "completed_at": row.CompletedAt, "revision": row.Revision,
}
if ticketNumbers != nil {
view["ticket_numbers"] = ticketNumbers
}
return view
}
func (s *Server) auditHistoryPhoneSearch(r *http.Request, filter historyFilter) error {
if filter.Query == "" {
return nil
}
if _, err := security.NormalizePhone(filter.Query); err != nil {
return nil
}
return s.auditHistoryAccess(r, "HISTORY_PHONE_SEARCH", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{
"from": filter.From, "to": filter.To, "query_kind": "phone",
})
}
func (s *Server) auditHistoryAccess(r *http.Request, action, entityType string, entityID, projectID *string, details map[string]any) error {
user := currentPrincipal(r.Context()).User
return s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
return s.addAudit(tx, r, projectID, &user.ID, action, entityType, entityID, details)
})
}
func historyTerminalAt(ticket model.QueueTicket) *time.Time {
switch ticket.Status {
case model.TicketCompleted:
return ticket.CompletedAt
case model.TicketMissed:
return ticket.MissedAt
case model.TicketCanceled:
return &ticket.UpdatedAt
default:
return nil
}
}
func historyWaitSeconds(ticket model.QueueTicket) any {
if ticket.CalledAt == nil {
return nil
}
return maxInt64(0, int64(ticket.CalledAt.Sub(ticket.JoinedAt).Seconds()))
}
func nullableRoundedSeconds(value *float64) any {
if value == nil {
return nil
}
return maxInt64(0, int64(*value+0.5))
}
func maxInt64(value, floor int64) int64 {
if value < floor {
return floor
}
return value
}
func parseBoolQuery(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes":
return true
default:
return false
}
}
func historyProjectIDPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func historyQueryKind(value string) string {
if _, err := security.NormalizePhone(value); err == nil {
return "phone"
}
if value == "" {
return "none"
}
return "ticket_or_text"
}
func escapeHistoryLike(value string) string {
return strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(value)
}
func stringValue(value any) string {
if value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
case *time.Time:
if typed == nil {
return ""
}
return typed.Format(time.RFC3339)
case time.Time:
return typed.Format(time.RFC3339)
case int64:
return strconv.FormatInt(typed, 10)
case int:
return strconv.Itoa(typed)
case float64:
return strconv.FormatFloat(typed, 'f', 0, 64)
default:
return fmt.Sprint(typed)
}
}