package main import ( "context" "encoding/json" "fmt" "log/slog" "os" "strings" "time" "calllinesystem/server/internal/config" "calllinesystem/server/internal/database" "calllinesystem/server/internal/domain" "calllinesystem/server/internal/model" "calllinesystem/server/internal/security" "github.com/google/uuid" "gorm.io/gorm" ) type projectSeed struct { Code, Name, Status, Prefix, ETAMode, DeviceMode, DisplayToken string BatchSize, Grace, BatchInterval, Buffer int Rate float64 Tickets []ticketSeed SessionStatus string } type ticketSeed struct { Number int Phone, LastName, Honorific, Status string MinutesAgo int Batch int } type seedResult struct { AdminUsername string `json:"admin_username"` StaffUsername string `json:"staff_username"` Password string `json:"password"` Projects []map[string]any `json:"projects"` Visitors map[string]string `json:"visitor_urls"` } func main() { logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) cfg, err := config.Load() fatalIf(logger, err) password := envOr("ADMIN_PASSWORD", "ChangeMe123!") passwordHash, err := security.HashPassword(password) fatalIf(logger, err) cipher, err := security.NewCipher(cfg.EncryptionKey, cfg.PhoneHMACKey) fatalIf(logger, err) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() db, err := database.Open(ctx, cfg.DatabaseURL, logger) fatalIf(logger, err) defer database.Close(db) sqlDB, err := database.SQLDB(db) fatalIf(logger, err) fatalIf(logger, database.Migrate(ctx, sqlDB, logger)) now := time.Now().UTC().Truncate(time.Second) result := seedResult{ AdminUsername: strings.ToLower(strings.TrimSpace(envOr("ADMIN_USERNAME", model.SuperAdminUsername))), StaffUsername: strings.ToLower(strings.TrimSpace(envOr("STAFF_USERNAME", "staff"))), Password: password, Visitors: map[string]string{}, } seeds := demoProjects() err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { admin, err := upsertUser(tx, result.AdminUsername, passwordHash, model.RoleAdmin, now) if err != nil { return err } staff, err := upsertUser(tx, result.StaffUsername, passwordHash, model.RoleStaff, now) if err != nil { return err } for index, spec := range seeds { project, err := resetProject(tx, spec, now) if err != nil { return err } for _, user := range []model.User{admin, staff} { if index == 2 && user.Role == model.RoleStaff { continue } if err := tx.Create(&model.UserProject{UserID: user.ID, ProjectID: project.ID, CreatedAt: now}).Error; err != nil { return err } } projectView := map[string]any{"code": spec.Code, "name": spec.Name, "status": spec.Status, "display_url": "/display/" + spec.DisplayToken} if spec.SessionStatus != "" { visitors, err := seedQueue(tx, cipher, project, admin.ID, spec, now) if err != nil { return err } for key, value := range visitors { result.Visitors[key] = value } projectView["ticket_count"] = len(spec.Tickets) } result.Projects = append(result.Projects, projectView) } return nil }) fatalIf(logger, err) encoded, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(encoded)) } func demoProjects() []projectSeed { projects := []projectSeed{ {Code: envOr("PROJECT_CODE", "DEMO"), Name: envOr("PROJECT_NAME", "云栖观光车"), Status: model.ProjectRunning, Prefix: "A", ETAMode: model.ETAFixedBatch, DeviceMode: "SUCCESS", DisplayToken: envOr("DEMO_DISPLAY_TOKEN", "demo-display-yunqi"), BatchSize: 5, Grace: 5, BatchInterval: 480, Buffer: 3, Rate: 2, SessionStatus: "RUNNING", Tickets: []ticketSeed{ {1, "13800001001", "张", "先生", model.TicketCompleted, 95, 1}, {2, "13800001002", "李", "女士", model.TicketCompleted, 93, 1}, {3, "13800001003", "王", "先生", model.TicketCompleted, 91, 1}, {4, "13800001004", "赵", "女士", model.TicketCompleted, 89, 1}, {5, "13800001005", "陈", "先生", model.TicketCompleted, 87, 1}, {6, "13800001006", "刘", "女士", model.TicketMissed, 72, 2}, {7, "13800001007", "杨", "先生", model.TicketCalled, 18, 2}, {8, "13800001008", "黄", "女士", model.TicketCalled, 17, 2}, {9, "13800001009", "周", "先生", model.TicketCalled, 16, 2}, {10, "13800001010", "吴", "女士", model.TicketArrived, 15, 2}, {11, "13800001011", "徐", "先生", model.TicketWaiting, 14, 0}, {12, "13800001012", "孙", "女士", model.TicketWaiting, 13, 0}, {13, "13800001013", "胡", "先生", model.TicketWaiting, 12, 0}, {14, "13800001014", "朱", "女士", model.TicketWaiting, 11, 0}, {15, "13800001015", "高", "先生", model.TicketWaiting, 10, 0}, {16, "13800001016", "林", "女士", model.TicketWaiting, 9, 0}, {17, "13800001017", "何", "先生", model.TicketWaiting, 8, 0}, {18, "13800001018", "郭", "女士", model.TicketWaiting, 7, 0}, {19, "13800001019", "马", "先生", model.TicketWaiting, 6, 0}, {20, "13800001020", "罗", "女士", model.TicketWaiting, 5, 0}, {21, "13800001021", "梁", "游客", model.TicketCanceled, 4, 0}, }}, {Code: "RAFT", Name: "峡谷漂流", Status: model.ProjectPaused, Prefix: "B", ETAMode: model.ETAContinuous, DeviceMode: "FAILURE", DisplayToken: envOr("RAFT_DISPLAY_TOKEN", "demo-display-raft"), BatchSize: 4, Grace: 8, BatchInterval: 600, Buffer: 5, Rate: 1.5, SessionStatus: "PAUSED", Tickets: []ticketSeed{ {1, "13900002001", "郑", "女士", model.TicketWaiting, 35, 0}, {2, "13900002002", "谢", "先生", model.TicketWaiting, 30, 0}, {3, "13900002003", "宋", "女士", model.TicketWaiting, 25, 0}, {4, "13900002004", "唐", "先生", model.TicketWaiting, 20, 0}, {5, "13900002005", "许", "女士", model.TicketWaiting, 15, 0}, {6, "13900002006", "韩", "先生", model.TicketWaiting, 10, 0}, }}, {Code: "CABLE", Name: "云顶索道", Status: model.ProjectNotOpen, Prefix: "C", ETAMode: model.ETAFixedBatch, DeviceMode: "DISABLED", DisplayToken: envOr("CABLE_DISPLAY_TOKEN", "demo-display-cable"), BatchSize: 8, Grace: 5, BatchInterval: 720, Buffer: 5, Rate: 2, SessionStatus: ""}, } if strings.EqualFold(envOr("SEED_MODE", "demo"), "smoke") { projects[0].Tickets = nil projects[0].SessionStatus = "RUNNING" } else { projects[0].Tickets = append(projects[0].Tickets, waitingTickets(22, 300)...) } return projects } func waitingTickets(first, last int) []ticketSeed { lastNames := []string{"张", "李", "王", "赵", "陈", "刘", "杨", "黄", "周", "吴", "徐", "孙"} tickets := make([]ticketSeed, 0, last-first+1) for number := first; number <= last; number++ { honorific := "先生" if number%2 == 0 { honorific = "女士" } tickets = append(tickets, ticketSeed{ Number: number, Phone: fmt.Sprintf("138000%05d", number), LastName: lastNames[(number-first)%len(lastNames)], Honorific: honorific, Status: model.TicketWaiting, MinutesAgo: max(1, last-number+1), }) } return tickets } func upsertUser(tx *gorm.DB, username, hash, role string, now time.Time) (model.User, error) { var user model.User err := tx.Where("username = ?", username).First(&user).Error if err == gorm.ErrRecordNotFound { user = model.User{ID: uuid.NewString(), Username: username, PasswordHash: hash, Role: role, Active: true, CreatedAt: now, UpdatedAt: now} err = tx.Create(&user).Error } else if err == nil { if user.Username != model.SuperAdminUsername { err = tx.Model(&user).Updates(map[string]any{"password_hash": hash, "role": role, "active": true, "updated_at": now}).Error } } return user, err } func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project, error) { spec.Code = strings.ToUpper(strings.TrimSpace(spec.Code)) var project model.Project err := tx.Where("code = ?", spec.Code).First(&project).Error if err == gorm.ErrRecordNotFound { project.ID, project.CreatedAt = uuid.NewString(), now } else if err != nil { return project, err } // Only demo projects are reset; unrelated project data is never touched. for _, statement := range []string{ "DELETE FROM idempotency_keys WHERE project_id = ?", "DELETE FROM audit_entries WHERE project_id = ?", "DELETE FROM device_simulations WHERE project_id = ?", "DELETE FROM call_batch_tickets WHERE project_id = ?", "DELETE FROM call_batches WHERE project_id = ?", "DELETE FROM queue_tickets WHERE project_id = ?", "DELETE FROM queue_sessions WHERE project_id = ?", "DELETE FROM user_projects WHERE project_id = ?", } { if err := tx.Exec(statement, project.ID).Error; err != nil { return project, err } } hash := security.HashToken(spec.DisplayToken) project.Code, project.Name, project.Status, project.Timezone, project.TicketPrefix = spec.Code, spec.Name, spec.Status, "Asia/Shanghai", spec.Prefix project.CallBatchSize, project.GracePeriodMinutes, project.ETAMode = spec.BatchSize, spec.Grace, spec.ETAMode project.AverageBatchIntervalSeconds, project.ContinuousRatePerMinute, project.ETABufferMinutes = spec.BatchInterval, spec.Rate, spec.Buffer if spec.ETAMode == model.ETAContinuous { project.ETAIntervalSeconds = max(1, int(60/spec.Rate)) } else { project.ETAIntervalSeconds = max(1, spec.BatchInterval/spec.BatchSize) } project.VisitorNotice = model.DefaultVisitorNotice project.DisplayTokenHash, project.DeviceSimulationMode, project.UpdatedAt = &hash, spec.DeviceMode, now if err == gorm.ErrRecordNotFound { err = tx.Create(&project).Error } else { err = tx.Save(&project).Error } return project, err } func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, creator string, spec projectSeed, now time.Time) (map[string]string, error) { location, err := time.LoadLocation(project.Timezone) if err != nil { return nil, err } dateText := now.In(location).Format("2006-01-02") businessDate, _ := time.ParseInLocation("2006-01-02", dateText, location) session := model.QueueSession{ID: uuid.NewString(), ProjectID: project.ID, BusinessDate: businessDate, Status: spec.SessionStatus, NextTicketNumber: len(spec.Tickets) + 1, Revision: int64(len(spec.Tickets) + 2), OpenedAt: now.Add(-2 * time.Hour), CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now} if err := tx.Create(&session).Error; err != nil { return nil, err } byBatch := map[int][]model.QueueTicket{} visitors := map[string]string{} for _, item := range spec.Tickets { joined := now.Add(-time.Duration(item.MinutesAgo) * time.Minute) token := fmt.Sprintf("demo-visitor-%s-%03d", strings.ToLower(project.Code), item.Number) phoneCipher, phoneNonce, err := cipher.Encrypt(item.Phone, []byte("phone:"+project.ID)) if err != nil { return nil, err } lastCipher, lastNonce, err := cipher.Encrypt(item.LastName, []byte("last_name:"+project.ID)) if err != nil { return nil, err } displayNumber, err := domain.DisplayNumber("", item.Number) if err != nil { return nil, err } phoneDigest := cipher.Digest(item.Phone) ticket := model.QueueTicket{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: item.Number, DisplayNumber: displayNumber, PublicTokenHash: security.HashToken(token), PhoneCiphertext: phoneCipher, PhoneNonce: phoneNonce, PhoneHMAC: &phoneDigest, LastNameCiphertext: lastCipher, LastNameNonce: lastNonce, Honorific: item.Honorific, Status: item.Status, JoinedAt: joined, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: creator, CreatedAt: joined, UpdatedAt: now} called := joined.Add(8 * time.Minute) arrived := called.Add(3 * time.Minute) completed := called.Add(8 * time.Minute) if item.Batch > 0 { ticket.CalledAt = &called } if item.Status == model.TicketArrived { ticket.ArrivedAt = &arrived } if item.Status == model.TicketCompleted { ticket.ArrivedAt, ticket.CompletedAt = &arrived, &completed } if item.Status == model.TicketMissed { ticket.MissedAt = &completed } if err := tx.Create(&ticket).Error; err != nil { return nil, err } if item.Batch > 0 { byBatch[item.Batch] = append(byBatch[item.Batch], ticket) } active := item.Status == model.TicketWaiting || item.Status == model.TicketCalled || item.Status == model.TicketArrived if active && (item.Number <= 25 || item.Number == len(spec.Tickets)) { visitors[ticket.DisplayNumber] = "/visitor/" + token } } for sequence := 1; sequence <= 2; sequence++ { tickets := byBatch[sequence] if len(tickets) == 0 { continue } calledAt := *tickets[0].CalledAt status := "COMPLETED" var completedAt *time.Time if sequence == 2 && project.Status == model.ProjectRunning { status = "CALLED" } else { done := calledAt.Add(12 * time.Minute) completedAt = &done } batch := model.CallBatch{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, BatchSequence: sequence, Revision: int64(sequence), Status: status, RequestedBy: creator, CalledAt: calledAt, CompletedAt: completedAt, CreatedAt: calledAt} if err := tx.Create(&batch).Error; err != nil { return nil, err } for position, ticket := range tickets { if err := tx.Create(&model.CallBatchTicket{CallBatchID: batch.ID, TicketID: ticket.ID, ProjectID: project.ID, Position: position + 1, CreatedAt: calledAt}).Error; err != nil { return nil, err } } outcome, detail := spec.DeviceMode, "设备模拟呼叫成功" if outcome == "FAILURE" { detail = "设备模拟器连接失败" } if outcome == "DISABLED" { outcome, detail = "SKIPPED", "设备模拟器已停用" } if err := tx.Create(&model.DeviceSimulation{ID: uuid.NewString(), ProjectID: project.ID, CallBatchID: batch.ID, Adapter: "SIMULATOR", Outcome: outcome, Detail: detail, AttemptedAt: calledAt, CompletedAt: calledAt.Add(time.Second), CreatedAt: calledAt}).Error; err != nil { return nil, err } } details, _ := json.Marshal(map[string]any{"source": "demo_seed", "ticket_count": len(spec.Tickets)}) projectID, actorID := project.ID, creator audit := model.AuditEntry{ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &actorID, Action: "DEMO_DATA_SEEDED", EntityType: "PROJECT", EntityID: &projectID, Details: details, RequestID: "seed", UserAgent: "cmd/seed", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now} if err := tx.Create(&audit).Error; err != nil { return nil, err } return visitors, nil } func envOr(name, fallback string) string { if value := os.Getenv(name); value != "" { return value } return fallback } func fatalIf(logger *slog.Logger, err error) { if err != nil { logger.Error("seed failed", "error", err) os.Exit(1) } }