feat: add historical data management
This commit is contained in:
@@ -45,8 +45,8 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) {
|
||||
if err := sqlDB.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if migrationCount < 12 {
|
||||
t.Fatalf("migration count = %d, want at least 12", migrationCount)
|
||||
if migrationCount < 14 {
|
||||
t.Fatalf("migration count = %d, want at least 14", migrationCount)
|
||||
}
|
||||
var experiencedPeopleStartColumn string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
@@ -78,6 +78,16 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) {
|
||||
if nullable != "YES" {
|
||||
t.Fatalf("phone_hmac is_nullable = %q, want YES after purge migration", nullable)
|
||||
}
|
||||
var historyAnonymizedNullable string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'queue_tickets' AND column_name = 'history_anonymized_at'`).Scan(&historyAnonymizedNullable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historyAnonymizedNullable != "YES" {
|
||||
t.Fatalf("history_anonymized_at is_nullable = %q, want YES", historyAnonymizedNullable)
|
||||
}
|
||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE username = $2`, "rotated-hash", "xqkwljtadmin"); err != nil {
|
||||
t.Fatalf("protected super admin password rotation should be allowed: %v", err)
|
||||
}
|
||||
@@ -90,7 +100,7 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.PersonalDataPurged != 1 || stats.IdempotencyDeleted != 1 || stats.SessionsDeleted != 1 || stats.AuditDeleted != 1 {
|
||||
if stats.PersonalDataPurged != 1 || stats.HistoryAnonymized != 1 || stats.IdempotencyDeleted != 1 || stats.SessionsDeleted != 1 || stats.AuditDeleted != 1 {
|
||||
t.Fatalf("maintenance stats = %#v, want one row in each category", stats)
|
||||
}
|
||||
|
||||
@@ -104,18 +114,32 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) {
|
||||
if phoneCiphertext != nil || phoneNonce != nil || phoneHMAC != nil || purgedAt == nil {
|
||||
t.Fatalf("purged ticket fields = %#v/%#v/%#v/%#v", phoneCiphertext, phoneNonce, phoneHMAC, purgedAt)
|
||||
}
|
||||
|
||||
var historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt any
|
||||
var historyHonorific string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT phone_ciphertext, phone_nonce, phone_hmac, last_name_ciphertext, last_name_nonce,
|
||||
personal_data_purged_at, history_anonymized_at, honorific
|
||||
FROM queue_tickets WHERE id = $1`, fixture.historyTicketID).
|
||||
Scan(&historyPhoneCiphertext, &historyPhoneNonce, &historyPhoneHMAC, &historyLastNameCiphertext, &historyLastNameNonce, &historyPurgedAt, &historyAnonymizedAt, &historyHonorific); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historyPhoneCiphertext != nil || historyPhoneNonce != nil || historyPhoneHMAC != nil || historyLastNameCiphertext != nil || historyLastNameNonce != nil || historyPurgedAt == nil || historyAnonymizedAt == nil || historyHonorific != "游客" {
|
||||
t.Fatalf("historical anonymization fields = %#v/%#v/%#v/%#v/%#v/%#v/%#v/%q", historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt, historyHonorific)
|
||||
}
|
||||
}
|
||||
|
||||
type maintenanceFixture struct {
|
||||
userID string
|
||||
projectID string
|
||||
ticketID string
|
||||
userID string
|
||||
projectID string
|
||||
ticketID string
|
||||
historyTicketID string
|
||||
}
|
||||
|
||||
func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) maintenanceFixture {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
userID, projectID, sessionID, ticketID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
userID, projectID, sessionID, ticketID, historyTicketID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
code := "MNT" + strings.ToUpper(strings.ReplaceAll(userID[:5], "-", ""))
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, password_hash, role, active)
|
||||
@@ -145,6 +169,16 @@ func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) mainte
|
||||
$8, $8, $9, $10)`, ticketID, projectID, sessionID, strings.Repeat("a", 64), []byte("ciphertext"), make([]byte, 12), strings.Repeat("b", 64), now.Add(-72*time.Hour), now.Add(-time.Hour), userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO queue_tickets (
|
||||
id, project_id, queue_session_id, ticket_number, display_number,
|
||||
public_token_hash, phone_ciphertext, phone_nonce, phone_hmac,
|
||||
last_name_ciphertext, last_name_nonce, honorific, status, joined_at,
|
||||
completed_at, personal_data_purge_at, created_by
|
||||
) VALUES ($1, $2, $3, 2, '00002', $4, $5, $6, $7, $8, $9, '女士', 'COMPLETED',
|
||||
$10, $10, $11, $12)`, historyTicketID, projectID, sessionID, strings.Repeat("e", 64), []byte("history-ciphertext"), make([]byte, 12), strings.Repeat("f", 64), []byte("history-last-name"), make([]byte, 12), now.Add(-100*24*time.Hour), now.Add(30*24*time.Hour), userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO idempotency_keys (project_id, user_id, scope, key, request_hash, response_code, response_body, expires_at)
|
||||
VALUES ($1, $2, 'TEST', $3, $4, 200, '{}', $5)`, projectID, userID, "expired-"+userID, strings.Repeat("c", 64), now.Add(-time.Hour)); err != nil {
|
||||
@@ -160,5 +194,5 @@ func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) mainte
|
||||
VALUES ($1, $2, 'TEST', 'TEST', '{}', $3, 'integration-test', $4)`, projectID, userID, "request-"+userID, now.Add(-time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return maintenanceFixture{userID: userID, projectID: projectID, ticketID: ticketID}
|
||||
return maintenanceFixture{userID: userID, projectID: projectID, ticketID: ticketID, historyTicketID: historyTicketID}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const maintenanceLockID int64 = 733081337591911
|
||||
// transaction while trying to clean an entire historical table.
|
||||
type CleanupStats struct {
|
||||
PersonalDataPurged int64
|
||||
HistoryAnonymized int64
|
||||
IdempotencyDeleted int64
|
||||
SessionsDeleted int64
|
||||
AuditDeleted int64
|
||||
@@ -83,6 +84,31 @@ func RunMaintenanceOnce(ctx context.Context, db *sql.DB, now time.Time, batchSiz
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("purge personal data: %w", err))
|
||||
}
|
||||
stats.HistoryAnonymized, err = boundedUpdate(ctx, tx, `
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM queue_tickets
|
||||
WHERE history_anonymized_at IS NULL
|
||||
AND joined_at <= ($1::timestamptz - interval '90 days')
|
||||
ORDER BY joined_at, id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE queue_tickets AS ticket
|
||||
SET phone_ciphertext = NULL,
|
||||
phone_nonce = NULL,
|
||||
phone_hmac = NULL,
|
||||
last_name_ciphertext = NULL,
|
||||
last_name_nonce = NULL,
|
||||
honorific = '游客',
|
||||
personal_data_purged_at = COALESCE(ticket.personal_data_purged_at, $1),
|
||||
history_anonymized_at = $1,
|
||||
updated_at = $1
|
||||
FROM candidates
|
||||
WHERE ticket.id = candidates.id`, now, batchSize)
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("mark historical data anonymized: %w", err))
|
||||
}
|
||||
stats.IdempotencyDeleted, err = boundedDelete(ctx, tx, `
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
@@ -160,9 +186,10 @@ func StartMaintenance(ctx context.Context, db *sql.DB, logger *slog.Logger, inte
|
||||
logger.Error("database maintenance failed", "error", err)
|
||||
return
|
||||
}
|
||||
if stats.PersonalDataPurged+stats.IdempotencyDeleted+stats.SessionsDeleted+stats.AuditDeleted > 0 {
|
||||
if stats.PersonalDataPurged+stats.HistoryAnonymized+stats.IdempotencyDeleted+stats.SessionsDeleted+stats.AuditDeleted > 0 {
|
||||
logger.Info("database maintenance completed",
|
||||
"personal_data_purged", stats.PersonalDataPurged,
|
||||
"history_anonymized", stats.HistoryAnonymized,
|
||||
"idempotency_deleted", stats.IdempotencyDeleted,
|
||||
"sessions_deleted", stats.SessionsDeleted,
|
||||
"audit_deleted", stats.AuditDeleted,
|
||||
|
||||
941
server/internal/httpapi/history.go
Normal file
941
server/internal/httpapi/history.go
Normal file
@@ -0,0 +1,941 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
139
server/internal/httpapi/history_integration_test.go
Normal file
139
server/internal/httpapi/history_integration_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/config"
|
||||
"calllinesystem/server/internal/database"
|
||||
"calllinesystem/server/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHistoryEndpointsPostgresIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
db, err := database.Open(ctx, dsn, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close(db)
|
||||
sqlDB, err := database.SQLDB(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := bytes.Repeat([]byte{0x41}, 32)
|
||||
hmacKey := bytes.Repeat([]byte{0x42}, 32)
|
||||
server, err := New(db, config.Config{EncryptionKey: keys, PhoneHMACKey: hmacKey}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
||||
server.now = func() time.Time { return now }
|
||||
userID, projectID, sessionID, ticketID, batchID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
user := model.User{ID: userID, Username: "history_admin_" + userID[:8], PasswordHash: "unused", Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now}
|
||||
project := model.Project{ID: projectID, Code: strings.ToUpper("HIST" + userID[:4]), Name: "历史测试项目", Status: model.ProjectEnded, Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 5, CallMode: model.CallModeBoth, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60, ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60, VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := model.QueueSession{ID: sessionID, ProjectID: projectID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 2, Revision: 2, OpenedAt: now.Add(-4 * time.Hour), ClosedAt: func() *time.Time { value := now.Add(-time.Hour); return &value }(), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phone := "+8613812345678"
|
||||
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phoneHMAC := server.cipher.Digest(phone)
|
||||
joinedAt := now.Add(-3 * time.Hour)
|
||||
calledAt := now.Add(-2 * time.Hour)
|
||||
completedAt := now.Add(-time.Hour)
|
||||
ticket := model.QueueTicket{ID: ticketID, ProjectID: projectID, QueueSessionID: sessionID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 3, PublicTokenHash: uuid.NewString()[:8] + string(bytes.Repeat([]byte("a"), 56)), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "先生", Status: model.TicketCompleted, JoinedAt: joinedAt, CalledAt: &calledAt, CompletedAt: &completedAt, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: userID, CreatedAt: joinedAt, UpdatedAt: completedAt}
|
||||
if err := db.Create(&ticket).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.CallBatch{ID: batchID, ProjectID: projectID, QueueSessionID: sessionID, BatchSequence: 1, Revision: 1, Status: "COMPLETED", CallMode: model.CallModeTicket, RequestedCount: 1, TicketCount: 1, PeopleCount: 3, RequestedBy: userID, CalledAt: calledAt, CompletedAt: &completedAt, CreatedAt: calledAt}
|
||||
if err := db.Create(&batch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.CallBatchTicket{CallBatchID: batchID, TicketID: ticketID, ProjectID: projectID, Position: 1, CreatedAt: calledAt}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestContext := context.WithValue(context.Background(), principalKey, principal{User: user})
|
||||
|
||||
request := httptest.NewRequest("GET", "/api/admin/history/tickets?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext)
|
||||
recorder := httptest.NewRecorder()
|
||||
server.adminHistoryTickets(recorder, request)
|
||||
if recorder.Code != 200 {
|
||||
t.Fatalf("history tickets status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var tickets map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &tickets); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := tickets["items"].([]any)
|
||||
item := items[0].(map[string]any)
|
||||
if item["ticket_number"] != "00001" || item["phone"] != nil || item["phone_masked"] != "****5678" || item["batch_sequence"] != float64(1) {
|
||||
t.Fatalf("history ticket projection = %#v", item)
|
||||
}
|
||||
|
||||
detailRequest := httptest.NewRequest("GET", "/api/admin/history/tickets/"+ticketID+"?reveal=true", nil).WithContext(requestContext)
|
||||
detailRequest.SetPathValue("id", ticketID)
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
server.adminHistoryTicket(detailRecorder, detailRequest)
|
||||
if detailRecorder.Code != 200 || !bytes.Contains(detailRecorder.Body.Bytes(), []byte(phone)) {
|
||||
t.Fatalf("history ticket detail status = %d, body = %s", detailRecorder.Code, detailRecorder.Body.String())
|
||||
}
|
||||
|
||||
batchRequest := httptest.NewRequest("GET", "/api/admin/history/batches/"+batchID, nil).WithContext(requestContext)
|
||||
batchRequest.SetPathValue("id", batchID)
|
||||
batchRecorder := httptest.NewRecorder()
|
||||
server.adminHistoryBatch(batchRecorder, batchRequest)
|
||||
if batchRecorder.Code != 200 || !bytes.Contains(batchRecorder.Body.Bytes(), []byte("00001")) {
|
||||
t.Fatalf("history batch detail status = %d, body = %s", batchRecorder.Code, batchRecorder.Body.String())
|
||||
}
|
||||
|
||||
summaryRequest := httptest.NewRequest("GET", "/api/admin/history/summary?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext)
|
||||
summaryRecorder := httptest.NewRecorder()
|
||||
server.adminHistorySummary(summaryRecorder, summaryRequest)
|
||||
if summaryRecorder.Code != 200 || !bytes.Contains(summaryRecorder.Body.Bytes(), []byte(`"total_tickets":1`)) {
|
||||
t.Fatalf("history summary status = %d, body = %s", summaryRecorder.Code, summaryRecorder.Body.String())
|
||||
}
|
||||
var summary map[string]any
|
||||
if err := json.Unmarshal(summaryRecorder.Body.Bytes(), &summary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summary["daily"].([]any)) != 1 || len(summary["projects"].([]any)) != 1 || len(summary["peak_hours"].([]any)) != 1 {
|
||||
t.Fatalf("history dashboard summary = %#v", summary)
|
||||
}
|
||||
hourly := summary["hourly"].([]any)
|
||||
if len(hourly) != 2 {
|
||||
t.Fatalf("history hourly summary = %#v, want separate issue and call hours", hourly)
|
||||
}
|
||||
firstHour := hourly[0].(map[string]any)
|
||||
secondHour := hourly[1].(map[string]any)
|
||||
if firstHour["hour"] != float64(17) || firstHour["issued_tickets"] != float64(1) || firstHour["called_tickets"] != float64(0) || secondHour["hour"] != float64(18) || secondHour["issued_tickets"] != float64(0) || secondHour["called_tickets"] != float64(1) {
|
||||
t.Fatalf("history hourly issue/call separation = %#v / %#v", firstHour, secondHour)
|
||||
}
|
||||
}
|
||||
78
server/internal/httpapi/history_test.go
Normal file
78
server/internal/httpapi/history_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseHistoryFilterUsesRecentSevenDaysByDefault(t *testing.T) {
|
||||
server := &Server{now: func() time.Time { return time.Date(2026, 7, 16, 8, 0, 0, 0, time.UTC) }}
|
||||
filter, err := server.parseHistoryFilter(httptest.NewRequest("GET", "/api/admin/history/tickets", nil), historyTicketStatuses)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.From != "2026-07-10" || filter.To != "2026-07-16" || filter.Page != 1 || filter.PageSize != 20 {
|
||||
t.Fatalf("default history filter = %#v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHistoryFilterRejectsUnsafeRangeAndPageSize(t *testing.T) {
|
||||
server := &Server{now: time.Now}
|
||||
for _, path := range []string{
|
||||
"/api/admin/history/tickets?from=2026-01-01&to=2026-02-01",
|
||||
"/api/admin/history/tickets?from=2026-07-10&to=2026-07-16&page_size=101",
|
||||
} {
|
||||
if _, err := server.parseHistoryFilter(httptest.NewRequest("GET", path, nil), historyTicketStatuses); err == nil {
|
||||
t.Fatalf("expected history filter to be rejected: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryTicketViewMasksPersonalDataAndHonorsAnonymization(t *testing.T) {
|
||||
server, ticket := encryptedTicketFixture(t)
|
||||
row := historyTicketRow{
|
||||
ID: ticket.ID, ProjectID: ticket.ProjectID, ProjectName: "云岭漂流", ProjectCode: "RIDE",
|
||||
QueueSessionID: "session-a", BusinessDate: time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC),
|
||||
TicketNumber: 42, DisplayNumber: ticket.DisplayNumber, PartySize: ticket.PartySize, Status: ticket.Status,
|
||||
JoinedAt: ticket.JoinedAt, PhoneCiphertext: ticket.PhoneCiphertext, PhoneNonce: ticket.PhoneNonce, PhoneHMAC: ticket.PhoneHMAC,
|
||||
LastNameCiphertext: ticket.LastNameCiphertext, LastNameNonce: ticket.LastNameNonce, Honorific: ticket.Honorific,
|
||||
}
|
||||
masked, err := server.historyTicketView(row, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if masked["phone"] != nil || masked["last_name"] != nil || masked["phone_masked"] != "****5678" {
|
||||
t.Fatalf("masked history view leaked personal data: %#v", masked)
|
||||
}
|
||||
revealed, err := server.historyTicketView(row, true)
|
||||
if err != nil || revealed["phone"] != "+8613812345678" || revealed["last_name"] != "林" {
|
||||
t.Fatalf("revealed history view = %#v, err = %v", revealed, err)
|
||||
}
|
||||
marked := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)
|
||||
row.HistoryAnonymizedAt = &marked
|
||||
anonymized, err := server.historyTicketView(row, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anonymized["personal_data_status"] != "ANONYMIZED" || anonymized["phone"] != nil || anonymized["phone_masked"] != nil {
|
||||
t.Fatalf("anonymized history view = %#v", anonymized)
|
||||
}
|
||||
if anonymized["honorific"] != nil {
|
||||
t.Fatalf("anonymized history view leaked honorific: %#v", anonymized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySummaryAlertsUseMinimumSampleAndOperationalThresholds(t *testing.T) {
|
||||
longWait := 15.0 * 60
|
||||
alerts := historySummaryAlerts([]historyProjectRow{
|
||||
{ProjectID: "project-alert", ProjectName: "峡谷漂流", TotalTickets: 10, MissedTickets: 3, CompletedTickets: 6, AverageWait: &longWait},
|
||||
{ProjectID: "project-small", ProjectName: "小样本项目", TotalTickets: 4, MissedTickets: 4, CompletedTickets: 0, AverageWait: &longWait},
|
||||
})
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("alerts = %#v, want one alert after minimum sample filtering", alerts)
|
||||
}
|
||||
if alerts[0]["type"] != "MISSED_RATE" || alerts[0]["project_id"] != "project-alert" {
|
||||
t.Fatalf("alert = %#v", alerts[0])
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject)))
|
||||
mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject)))
|
||||
mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings)))
|
||||
mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets)))
|
||||
mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket)))
|
||||
mux.Handle("GET /api/admin/history/batches", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatches)))
|
||||
mux.Handle("GET /api/admin/history/batches/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatch)))
|
||||
mux.Handle("GET /api/admin/history/summary", s.requireAdmin(http.HandlerFunc(s.adminHistorySummary)))
|
||||
mux.Handle("GET /api/admin/history/export.csv", s.requireAdmin(http.HandlerFunc(s.adminHistoryExport)))
|
||||
return s.recoverPanic(s.requestID(s.accessLog(s.securityHeaders(mux))))
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,8 @@ type QueueTicket struct {
|
||||
MissedAt *time.Time
|
||||
PersonalDataPurgedAt *time.Time
|
||||
PersonalDataPurgeAt time.Time `gorm:"not null"`
|
||||
CreatedBy string `gorm:"type:uuid;not null"`
|
||||
HistoryAnonymizedAt *time.Time
|
||||
CreatedBy string `gorm:"type:uuid;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
5
server/migrations/000013_history_indexes.down.sql
Normal file
5
server/migrations/000013_history_indexes.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS call_batch_tickets_history_batch_position_idx;
|
||||
DROP INDEX IF EXISTS call_batches_history_session_called_idx;
|
||||
DROP INDEX IF EXISTS queue_tickets_history_project_called_idx;
|
||||
DROP INDEX IF EXISTS queue_tickets_history_session_status_number_idx;
|
||||
DROP INDEX IF EXISTS queue_sessions_history_project_date_idx;
|
||||
14
server/migrations/000013_history_indexes.up.sql
Normal file
14
server/migrations/000013_history_indexes.up.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE INDEX IF NOT EXISTS queue_sessions_history_project_date_idx
|
||||
ON queue_sessions (project_id, business_date DESC, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queue_tickets_history_session_status_number_idx
|
||||
ON queue_tickets (queue_session_id, status, ticket_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queue_tickets_history_project_called_idx
|
||||
ON queue_tickets (project_id, called_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS call_batches_history_session_called_idx
|
||||
ON call_batches (queue_session_id, called_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS call_batch_tickets_history_batch_position_idx
|
||||
ON call_batch_tickets (call_batch_id, position);
|
||||
4
server/migrations/000014_history_anonymization.down.sql
Normal file
4
server/migrations/000014_history_anonymization.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS queue_tickets_history_anonymized_idx;
|
||||
|
||||
ALTER TABLE queue_tickets
|
||||
DROP COLUMN IF EXISTS history_anonymized_at;
|
||||
6
server/migrations/000014_history_anonymization.up.sql
Normal file
6
server/migrations/000014_history_anonymization.up.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE queue_tickets
|
||||
ADD COLUMN history_anonymized_at timestamptz;
|
||||
|
||||
CREATE INDEX queue_tickets_history_anonymized_idx
|
||||
ON queue_tickets (history_anonymized_at, id)
|
||||
WHERE history_anonymized_at IS NULL;
|
||||
Reference in New Issue
Block a user