315 lines
14 KiB
Go
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)
|
|
}
|
|
}
|