package httpapi import ( "context" "errors" "net/http" "regexp" "strings" "time" _ "time/tzdata" "unicode/utf8" "calllinesystem/server/internal/domain" "calllinesystem/server/internal/model" "calllinesystem/server/internal/security" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) var projectCodePattern = regexp.MustCompile(`^[A-Z0-9][A-Z0-9_-]{1,23}$`) var ticketPrefixPattern = regexp.MustCompile(`^[A-Z0-9]{1,8}$`) type adminProjectRequest struct { Name string `json:"name"` Code string `json:"code"` Timezone string `json:"timezone"` TicketPrefix string `json:"ticket_prefix"` } func validateAdminProjectRequest(input adminProjectRequest) (adminProjectRequest, error) { input.Name = strings.TrimSpace(input.Name) input.Code = strings.ToUpper(strings.TrimSpace(input.Code)) input.Timezone = strings.TrimSpace(input.Timezone) input.TicketPrefix = strings.ToUpper(strings.TrimSpace(input.TicketPrefix)) if input.Name == "" || len([]rune(input.Name)) > 120 { return input, &apiError{Status: 422, Code: "INVALID_PROJECT_NAME", Message: "项目名称不能为空且不能超过 120 个字符"} } if !projectCodePattern.MatchString(input.Code) { return input, &apiError{Status: 422, Code: "INVALID_PROJECT_CODE", Message: "项目编码需为 2 到 24 位大写字母、数字、下划线或连字符"} } if input.Timezone == "" { input.Timezone = "Asia/Shanghai" } if _, err := time.LoadLocation(input.Timezone); err != nil { return input, &apiError{Status: 422, Code: "INVALID_TIMEZONE", Message: "项目时区无效"} } if input.TicketPrefix == "" { input.TicketPrefix = "A" } if !ticketPrefixPattern.MatchString(input.TicketPrefix) { return input, &apiError{Status: 422, Code: "INVALID_TICKET_PREFIX", Message: "票号前缀需为 1 到 8 位大写字母或数字"} } return input, nil } func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { var input adminProjectRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } input, err := validateAdminProjectRequest(input) if err != nil { writeError(w, err) return } now := s.now() project := model.Project{ ID: uuid.NewString(), Name: input.Name, Code: input.Code, Timezone: input.Timezone, TicketPrefix: input.TicketPrefix, Status: model.ProjectNotOpen, CallBatchSize: 1, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300, ContinuousRatePerMinute: 1, ETABufferMinutes: 0, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice, DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&project).Error; err != nil { return &apiError{Status: 409, Code: "PROJECT_CODE_EXISTS", Message: "该项目编码已存在"} } actor := currentPrincipal(r.Context()).User return s.addAudit(tx, r, &project.ID, &actor.ID, "PROJECT_CREATED", "PROJECT", &project.ID, map[string]any{"project": projectView(project)}) }) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusCreated, map[string]any{"project": projectView(project)}) } func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") if err := validateUUID(projectID); err != nil { writeError(w, err) return } var input adminProjectRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } input, err := validateAdminProjectRequest(input) if err != nil { writeError(w, err) return } var project model.Project err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil { return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") } before := projectView(project) updates := map[string]any{"name": input.Name, "code": input.Code, "timezone": input.Timezone, "ticket_prefix": input.TicketPrefix, "updated_at": s.now()} if err := tx.Model(&project).Updates(updates).Error; err != nil { return &apiError{Status: 409, Code: "PROJECT_CODE_EXISTS", Message: "该项目编码已存在"} } if err := tx.First(&project, "id = ?", projectID).Error; err != nil { return err } actor := currentPrincipal(r.Context()).User return s.addAudit(tx, r, &projectID, &actor.ID, "PROJECT_UPDATED", "PROJECT", &projectID, map[string]any{"before": before, "after": projectView(project)}) }) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"project": projectView(project)}) } type adminUserRequest struct { Username string `json:"username"` Password string `json:"password"` Role string `json:"role"` Active *bool `json:"active"` ProjectIDs []string `json:"project_ids"` } func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request) { var users []model.User if err := s.db.WithContext(r.Context()).Order("username ASC").Find(&users).Error; err != nil { writeError(w, err) return } var grants []model.UserProject if err := s.db.WithContext(r.Context()).Find(&grants).Error; err != nil { writeError(w, err) return } projects := map[string][]string{} for _, grant := range grants { projects[grant.UserID] = append(projects[grant.UserID], grant.ProjectID) } views := make([]map[string]any, 0, len(users)) for _, user := range users { views = append(views, adminUserView(user, projects[user.ID])) } writeJSON(w, http.StatusOK, map[string]any{"users": views}) } func adminUserView(user model.User, projectIDs []string) map[string]any { if projectIDs == nil { projectIDs = []string{} } return map[string]any{"id": user.ID, "username": user.Username, "role": user.Role, "active": user.Active, "protected": user.Username == model.SuperAdminUsername, "project_ids": projectIDs, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt} } func removesLastActiveAdmin(user model.User, nextRole string, nextActive bool, otherActiveAdmins int64) bool { return user.Role == model.RoleAdmin && user.Active && (nextRole != model.RoleAdmin || !nextActive) && otherActiveAdmins == 0 } func validateAdminUserRequest(input adminUserRequest, creating bool) (adminUserRequest, error) { input.Username = strings.ToLower(strings.TrimSpace(input.Username)) input.Role = strings.ToUpper(strings.TrimSpace(input.Role)) if creating && (len(input.Username) < 3 || len(input.Username) > 80) { return input, &apiError{Status: 422, Code: "INVALID_USERNAME", Message: "账号长度必须在 3 到 80 个字符之间"} } if input.Role != "" && input.Role != model.RoleAdmin && input.Role != model.RoleStaff { return input, &apiError{Status: 422, Code: "INVALID_ROLE", Message: "角色只能是管理员或员工"} } if creating && len(input.Password) < 8 { return input, &apiError{Status: 422, Code: "INVALID_PASSWORD", Message: "密码至少需要 8 个字符"} } for _, id := range input.ProjectIDs { if validateUUID(id) != nil { return input, &apiError{Status: 422, Code: "INVALID_PROJECT", Message: "所属项目无效"} } } return input, nil } func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { var input adminUserRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } input, err := validateAdminUserRequest(input, true) if err != nil { writeError(w, err) return } hash, err := security.HashPassword(input.Password) if err != nil { writeError(w, err) return } active := true if input.Active != nil { active = *input.Active } if input.Role == "" { input.Role = model.RoleStaff } user := model.User{ID: uuid.NewString(), Username: input.Username, PasswordHash: hash, Role: input.Role, Active: active, CreatedAt: s.now(), UpdatedAt: s.now()} err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return &apiError{Status: 409, Code: "USERNAME_EXISTS", Message: "该账号已存在"} } for _, projectID := range input.ProjectIDs { if err := tx.Create(&model.UserProject{UserID: user.ID, ProjectID: projectID, CreatedAt: s.now()}).Error; err != nil { return err } } actor := currentPrincipal(r.Context()).User return s.addAudit(tx, r, nil, &actor.ID, "USER_CREATED", "USER", &user.ID, map[string]any{"username": user.Username, "role": user.Role, "project_ids": input.ProjectIDs}) }) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusCreated, map[string]any{"user": adminUserView(user, input.ProjectIDs)}) } func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := validateUUID(id); err != nil { writeError(w, err) return } var input adminUserRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } input, err := validateAdminUserRequest(input, false) if err != nil { writeError(w, err) return } var user model.User err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", id).Error; err != nil { return mapNotFound(err, "USER_NOT_FOUND", "账号不存在") } if user.Username == model.SuperAdminUsername { return &apiError{Status: 403, Code: "PROTECTED_SUPER_ADMIN", Message: "超级管理员账号不能编辑"} } nextRole := user.Role if input.Role != "" { nextRole = input.Role } nextActive := user.Active if input.Active != nil { nextActive = *input.Active } if user.Role == model.RoleAdmin && user.Active && (nextRole != model.RoleAdmin || !nextActive) { var otherActiveAdmins int64 if err := tx.Model(&model.User{}).Where("role = ? AND active = ? AND id <> ?", model.RoleAdmin, true, id).Count(&otherActiveAdmins).Error; err != nil { return err } if removesLastActiveAdmin(user, nextRole, nextActive, otherActiveAdmins) { return &apiError{Status: 409, Code: "LAST_ACTIVE_ADMIN", Message: "必须保留至少一个启用的管理员账号"} } } updates := map[string]any{"updated_at": s.now()} if input.Role != "" { updates["role"] = input.Role } if input.Active != nil { updates["active"] = *input.Active } if input.Password != "" { if len(input.Password) < 8 { return &apiError{Status: 422, Code: "INVALID_PASSWORD", Message: "密码至少需要 8 个字符"} } hash, err := security.HashPassword(input.Password) if err != nil { return err } updates["password_hash"] = hash } if err := tx.Model(&user).Updates(updates).Error; err != nil { return err } if err := tx.Where("user_id = ?", id).Delete(&model.UserProject{}).Error; err != nil { return err } for _, projectID := range input.ProjectIDs { if err := tx.Create(&model.UserProject{UserID: id, ProjectID: projectID, CreatedAt: s.now()}).Error; err != nil { return err } } if err := tx.First(&user, "id = ?", id).Error; err != nil { return err } actor := currentPrincipal(r.Context()).User return s.addAudit(tx, r, nil, &actor.ID, "USER_UPDATED", "USER", &id, map[string]any{"role": user.Role, "active": user.Active, "project_ids": input.ProjectIDs}) }) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"user": adminUserView(user, input.ProjectIDs)}) } func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { type totalRow struct { Projects int64 ActiveSessions int64 Waiting int64 Called int64 } var totals totalRow if err := s.db.WithContext(r.Context()).Raw(` SELECT (SELECT count(*) FROM projects) AS projects, (SELECT count(*) FROM queue_sessions WHERE status IN ('RUNNING','PAUSED')) AS active_sessions, (SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting, (SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called `).Scan(&totals).Error; err != nil { writeError(w, err) return } var projects []model.Project if err := s.db.WithContext(r.Context()).Order("name ASC").Find(&projects).Error; err != nil { writeError(w, err) return } var simulations []model.DeviceSimulation if err := s.db.WithContext(r.Context()).Order("created_at DESC").Limit(20).Find(&simulations).Error; err != nil { writeError(w, err) return } var activeTickets []model.QueueTicket if err := s.db.WithContext(r.Context()). Where("status IN ?", []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}). Order("created_at ASC").Find(&activeTickets).Error; err != nil { writeError(w, err) return } projectNames := make(map[string]string, len(projects)) for _, project := range projects { projectNames[project.ID] = project.Name } activeTicketViews := make([]map[string]any, 0, len(activeTickets)) for _, ticket := range activeTickets { view, err := s.adminActiveTicketView(ticket, projectNames[ticket.ProjectID]) if err != nil { writeError(w, err) return } activeTicketViews = append(activeTicketViews, view) } projectProjections := make([]map[string]any, 0, len(projects)) var runningProjects, anomalyProjects, offlineDevices, projectedWaiting int64 for _, project := range projects { projection, anomaly, offline, waiting, err := s.adminProjectProjection(r.Context(), project) if err != nil { writeError(w, err) return } projectProjections = append(projectProjections, projection) projectedWaiting += waiting if project.Status == model.ProjectRunning { runningProjects++ } if anomaly { anomalyProjects++ } if offline { offlineDevices++ } } writeJSON(w, http.StatusOK, map[string]any{ "summary": map[string]int64{ "running_projects": runningProjects, "waiting_count": projectedWaiting, "anomaly_projects": anomalyProjects, "offline_devices": offlineDevices, }, "totals": map[string]int64{ "projects": totals.Projects, "active_sessions": totals.ActiveSessions, "waiting": totals.Waiting, "called": totals.Called, }, "projects": projectProjections, "active_tickets": activeTicketViews, "recent_device_simulations": simulations, "server_time": s.now(), }) } func (s *Server) adminActiveTicketView(ticket model.QueueTicket, projectName string) (map[string]any, error) { phone, lastName, err := s.decryptTicketPersonal(ticket) if err != nil { return nil, err } return map[string]any{ "id": ticket.ID, "project_id": ticket.ProjectID, "project_name": projectName, "ticket_number": ticket.DisplayNumber, "phone": phone, "last_name": lastName, "honorific": ticket.Honorific, "status": ticket.Status, "created_at": ticket.CreatedAt, }, nil } func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, error) { view := projectView(project) lastUpdated := project.UpdatedAt deviceStatus := map[string]any{ "mode": project.DeviceSimulationMode, "status": "NOT_RUN", "label": "尚无设备模拟记录", } anomaly := project.Status == model.ProjectPaused offline := false var latestSimulation model.DeviceSimulation simulationErr := s.db.WithContext(ctx).Where("project_id = ?", project.ID). Order("created_at DESC").First(&latestSimulation).Error if simulationErr == nil { deviceStatus = map[string]any{ "mode": project.DeviceSimulationMode, "status": latestSimulation.Outcome, "label": latestSimulation.Detail, "last_updated_at": latestSimulation.CompletedAt, } if latestSimulation.CompletedAt.After(lastUpdated) { lastUpdated = latestSimulation.CompletedAt } if latestSimulation.Outcome == "FAILURE" { anomaly = true } } else if !errors.Is(simulationErr, gorm.ErrRecordNotFound) { return nil, false, false, 0, simulationErr } if project.DeviceSimulationMode == "DISABLED" { deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"} } var session model.QueueSession sessionErr := s.db.WithContext(ctx).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}). Order("business_date DESC").First(&session).Error if errors.Is(sessionErr, gorm.ErrRecordNotFound) { view["waiting_count"] = int64(0) view["current_batch"] = nil view["estimated_wait"] = domain.ETAResult{Available: false, Reason: "queue_not_running"} view["last_updated_at"] = lastUpdated view["device_status"] = deviceStatus return view, anomaly, offline, 0, nil } if sessionErr != nil { return nil, false, false, 0, sessionErr } if session.UpdatedAt.After(lastUpdated) { lastUpdated = session.UpdatedAt } var waitingCount int64 if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting). Count(&waitingCount).Error; err != nil { return nil, false, false, 0, err } currentBatch, err := s.currentDisplayBatch(ctx, project.ID, session.ID) if err != nil { return nil, false, false, 0, err } estimatedWait, err := domain.CalculateETA(domain.ETAInput{ PeopleAhead: max(0, int(waitingCount)-1), IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second, Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waitingCount > 0, }) if err != nil { return nil, false, false, 0, err } view["waiting_count"] = waitingCount view["current_batch"] = currentBatch view["estimated_wait"] = estimatedWait view["last_updated_at"] = lastUpdated view["device_status"] = deviceStatus return view, anomaly, offline, waitingCount, nil } type updateProjectSettingsRequest struct { Status *string `json:"status"` CallBatchSize *int `json:"call_batch_size"` GracePeriodMinutes *int `json:"grace_period_minutes"` ETAMode *string `json:"eta_mode"` AverageBatchIntervalSeconds *int `json:"average_batch_interval_seconds"` ContinuousRatePerMinute *float64 `json:"continuous_rate_per_minute"` ETABufferMinutes *int `json:"eta_buffer_minutes"` ETAIntervalSeconds *int `json:"eta_interval_seconds"` DeviceSimulationMode *string `json:"device_simulation_mode"` VisitorNotice *string `json:"visitor_notice"` } func (s *Server) updateProjectSettings(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") if err := validateUUID(projectID); err != nil { writeError(w, err) return } var input updateProjectSettingsRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } updates, err := validateProjectSettings(input) if err != nil { writeError(w, err) return } user := currentPrincipal(r.Context()).User var project model.Project err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil { return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") } before := projectView(project) updates["updated_at"] = s.now() if err := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(updates).Error; err != nil { return err } if err := tx.First(&project, "id = ?", projectID).Error; err != nil { return err } return s.addAudit(tx, r, &projectID, &user.ID, "PROJECT_SETTINGS_UPDATED", "PROJECT", &projectID, map[string]any{"before": before, "after": projectView(project)}) }) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"project": projectView(project)}) } func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any, error) { updates := make(map[string]any) if input.Status != nil { value := strings.ToUpper(strings.TrimSpace(*input.Status)) if value != model.ProjectNotOpen && value != model.ProjectRunning && value != model.ProjectPaused && value != model.ProjectEnded { return nil, invalidSetting("status") } updates["status"] = value } if input.CallBatchSize != nil { if *input.CallBatchSize < 1 || *input.CallBatchSize > 100 { return nil, invalidSetting("call_batch_size") } updates["call_batch_size"] = *input.CallBatchSize } if input.GracePeriodMinutes != nil { if *input.GracePeriodMinutes < 0 || *input.GracePeriodMinutes > 120 { return nil, invalidSetting("grace_period_minutes") } updates["grace_period_minutes"] = *input.GracePeriodMinutes } if input.ETAMode != nil { value := strings.ToUpper(strings.TrimSpace(*input.ETAMode)) if value != model.ETAFixedBatch && value != model.ETAContinuous { return nil, invalidSetting("eta_mode") } updates["eta_mode"] = value } if input.AverageBatchIntervalSeconds != nil { if *input.AverageBatchIntervalSeconds < 1 || *input.AverageBatchIntervalSeconds > int((24*time.Hour).Seconds()) { return nil, invalidSetting("average_batch_interval_seconds") } updates["average_batch_interval_seconds"] = *input.AverageBatchIntervalSeconds } if input.ContinuousRatePerMinute != nil { if *input.ContinuousRatePerMinute <= 0 || *input.ContinuousRatePerMinute > 10000 { return nil, invalidSetting("continuous_rate_per_minute") } updates["continuous_rate_per_minute"] = *input.ContinuousRatePerMinute } if input.ETABufferMinutes != nil { if *input.ETABufferMinutes < 0 || *input.ETABufferMinutes > 1440 { return nil, invalidSetting("eta_buffer_minutes") } updates["eta_buffer_minutes"] = *input.ETABufferMinutes } if input.ETAIntervalSeconds != nil { if *input.ETAIntervalSeconds < 1 || *input.ETAIntervalSeconds > int((24*time.Hour).Seconds()) { return nil, invalidSetting("eta_interval_seconds") } updates["eta_interval_seconds"] = *input.ETAIntervalSeconds } if input.DeviceSimulationMode != nil { value := strings.ToUpper(strings.TrimSpace(*input.DeviceSimulationMode)) if value != "DISABLED" && value != "SUCCESS" && value != "FAILURE" { return nil, invalidSetting("device_simulation_mode") } updates["device_simulation_mode"] = value } if input.VisitorNotice != nil { value := strings.TrimSpace(*input.VisitorNotice) if utf8.RuneCountInString(value) > 240 { return nil, invalidSetting("visitor_notice") } updates["visitor_notice"] = value } if len(updates) == 0 { return nil, &apiError{Status: http.StatusUnprocessableEntity, Code: "EMPTY_SETTINGS", Message: "至少提供一个需要修改的设置"} } return updates, nil } func invalidSetting(field string) error { return &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_SETTING", Message: "项目设置值不正确", Details: map[string]string{"field": field}} }