Files
XQKqueue/server/cmd/seed/main.go
2026-07-12 15:53:24 +08:00

315 lines
14 KiB
Go

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