611 lines
22 KiB
Go
611 lines
22 KiB
Go
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}}
|
|
}
|