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) } }