Files
brother7 17d296263c 修复账号禁用及队列场次状态规则
问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。

修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
2026-07-30 12:02:05 +08:00

780 lines
30 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,
CallMode: model.CallModeBoth, MaxCallTicketCount: 100,
DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
MinPartySize: 1, MaxPartySize: 10,
AverageBatchIntervalSeconds: 300, ContinuousRatePerMinute: 1, ETABufferMinutes: 0,
ETAIntervalSeconds: 60,
ExperiencedPeopleStart: 0,
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 {
if user.Username == model.PublicVisitorUsername {
continue
}
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: "超级管理员账号不能编辑"}
}
if user.Username == model.PublicVisitorUsername {
return &apiError{Status: 403, Code: "PROTECTED_SYSTEM_USER", 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: "必须保留至少一个启用的管理员账号"}
}
}
now := s.now()
authChanged := nextRole != user.Role || nextActive != user.Active || input.Password != ""
updates := map[string]any{"updated_at": 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 authChanged {
if err := tx.Model(&model.AuthSession{}).
Where("user_id = ? AND revoked_at IS NULL", id).
Update("revoked_at", now).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
WaitingTickets int64
WaitingPeople int64
CalledTickets int64
CalledPeople int64
}
var totals totalRow
if err := s.db.WithContext(r.Context()).Raw(`
WITH current_sessions AS (
SELECT session.id
FROM queue_sessions AS session
JOIN projects AS project ON project.id = session.project_id
WHERE session.status IN ('RUNNING', 'PAUSED')
AND session.business_date = (? AT TIME ZONE project.timezone)::date
)
SELECT
(SELECT count(*) FROM projects) AS projects,
(SELECT count(*) FROM current_sessions) AS active_sessions,
(SELECT count(*) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status = 'WAITING') AS waiting_tickets,
(SELECT COALESCE(sum(ticket.party_size), 0) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status = 'WAITING') AS waiting_people,
(SELECT count(*) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status IN ('CALLED','ARRIVED')) AS called_tickets,
(SELECT COALESCE(sum(ticket.party_size), 0) FROM queue_tickets AS ticket JOIN current_sessions AS session ON session.id = ticket.queue_session_id WHERE ticket.status IN ('CALLED','ARRIVED')) AS called_people
`, s.now()).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()).Model(&model.QueueTicket{}).
Select("queue_tickets.*").
Joins("JOIN queue_sessions AS current_session ON current_session.id = queue_tickets.queue_session_id AND current_session.project_id = queue_tickets.project_id").
Joins("JOIN projects AS current_project ON current_project.id = queue_tickets.project_id").
Where("queue_tickets.status IN ?", []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}).
Where("current_session.status IN ?", []string{"RUNNING", "PAUSED"}).
Where("current_session.business_date = (? AT TIME ZONE current_project.timezone)::date", s.now()).
Order("queue_tickets.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, projectedWaitingTickets, projectedWaitingPeople int64
for _, project := range projects {
projection, anomaly, offline, waitingTickets, waitingPeople, err := s.adminProjectProjection(r.Context(), project)
if err != nil {
writeError(w, err)
return
}
projectProjections = append(projectProjections, projection)
projectedWaitingTickets += waitingTickets
projectedWaitingPeople += waitingPeople
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": projectedWaitingTickets,
"waiting_ticket_count": projectedWaitingTickets, "waiting_people_count": projectedWaitingPeople,
"anomaly_projects": anomalyProjects, "offline_devices": offlineDevices,
},
"totals": map[string]int64{
"projects": totals.Projects, "active_sessions": totals.ActiveSessions,
"waiting": totals.WaitingTickets, "called": totals.CalledTickets,
"waiting_ticket_count": totals.WaitingTickets, "waiting_people_count": totals.WaitingPeople,
"called_ticket_count": totals.CalledTickets, "called_people_count": totals.CalledPeople,
},
"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, "party_size": ticket.PartySize, "status": ticket.Status, "created_at": ticket.CreatedAt,
}, nil
}
func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, 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, 0, simulationErr
}
if project.DeviceSimulationMode == "DISABLED" {
deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"}
}
session, sessionErr := s.currentQueueSession(s.db.WithContext(ctx), project)
if errors.Is(sessionErr, gorm.ErrRecordNotFound) {
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil)
if err != nil {
return nil, false, false, 0, 0, err
}
view["waiting_count"] = int64(0)
view["waiting_ticket_count"] = int64(0)
view["waiting_people_count"] = int64(0)
view["issued_ticket_count"] = int64(0)
view["latest_ticket_number"] = nil
view["experienced_people"] = experiencedPeople
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, 0, nil
}
if sessionErr != nil {
return nil, false, false, 0, 0, sessionErr
}
if session.UpdatedAt.After(lastUpdated) {
lastUpdated = session.UpdatedAt
}
waiting, err := queueTotals(s.db.WithContext(ctx), project.ID, session.ID, model.TicketWaiting)
if err != nil {
return nil, false, false, 0, 0, err
}
currentBatch, err := s.currentDisplayBatch(ctx, project.ID, session.ID)
if err != nil {
return nil, false, false, 0, 0, err
}
var lastWaiting model.QueueTicket
lastWaitingErr := s.db.WithContext(ctx).Select("id", "party_size").
Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting).
Order("ticket_number DESC").First(&lastWaiting).Error
if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) {
return nil, false, false, 0, 0, lastWaitingErr
}
peopleAhead := int(waiting.PeopleCount)
if lastWaitingErr == nil {
peopleAhead = max(0, peopleAhead-lastWaiting.PartySize)
}
estimatedWait, err := domain.CalculateETA(domain.ETAInput{
PeopleAhead: peopleAhead, IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second,
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waiting.TicketCount > 0,
})
if err != nil {
return nil, false, false, 0, 0, err
}
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, &session)
if err != nil {
return nil, false, false, 0, 0, err
}
issuedTicketCount := max(0, session.NextTicketNumber-1)
var latestTicketNumber any
if issuedTicketCount > 0 {
latestTicketNumber, err = domain.DisplayNumber("", issuedTicketCount)
if err != nil {
return nil, false, false, 0, 0, err
}
}
view["waiting_count"] = waiting.TicketCount
view["waiting_ticket_count"] = waiting.TicketCount
view["waiting_people_count"] = waiting.PeopleCount
view["issued_ticket_count"] = issuedTicketCount
view["latest_ticket_number"] = latestTicketNumber
view["experienced_people"] = experiencedPeople
view["current_batch"] = currentBatch
view["estimated_wait"] = estimatedWait
view["last_updated_at"] = lastUpdated
view["device_status"] = deviceStatus
return view, anomaly, offline, waiting.TicketCount, waiting.PeopleCount, nil
}
type updateProjectSettingsRequest struct {
Status *string `json:"status"`
CallBatchSize *int `json:"call_batch_size"`
DefaultCallTicketCount *int `json:"default_call_ticket_count"`
MaxCallTicketCount *int `json:"max_call_ticket_count"`
DefaultCallPeopleCount *int `json:"default_call_people_count"`
MaxCallPeopleCount *int `json:"max_call_people_count"`
CallMode *string `json:"call_mode"`
MinPartySize *int `json:"min_party_size"`
MaxPartySize *int `json:"max_party_size"`
GracePeriodMinutes *int `json:"grace_period_minutes"`
ExperiencedPeopleStart *int `json:"experienced_people_start"`
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)
if err := validateProjectSettingsCombination(project, updates); err != nil {
return err
}
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
}
ticketDefault := input.DefaultCallTicketCount
if ticketDefault == nil {
ticketDefault = input.CallBatchSize
} else if input.CallBatchSize != nil && *input.CallBatchSize != *ticketDefault {
return nil, invalidSetting("default_call_ticket_count")
}
if ticketDefault != nil {
if *ticketDefault < 1 || *ticketDefault > 10000 {
return nil, invalidSetting("default_call_ticket_count")
}
updates["call_batch_size"] = *ticketDefault
}
if input.MaxCallTicketCount != nil {
if *input.MaxCallTicketCount < 1 || *input.MaxCallTicketCount > 10000 {
return nil, invalidSetting("max_call_ticket_count")
}
updates["max_call_ticket_count"] = *input.MaxCallTicketCount
}
if input.DefaultCallPeopleCount != nil {
if *input.DefaultCallPeopleCount < 1 || *input.DefaultCallPeopleCount > 10000 {
return nil, invalidSetting("default_call_people_count")
}
updates["default_call_people_count"] = *input.DefaultCallPeopleCount
}
if input.MaxCallPeopleCount != nil {
if *input.MaxCallPeopleCount < 1 || *input.MaxCallPeopleCount > 10000 {
return nil, invalidSetting("max_call_people_count")
}
updates["max_call_people_count"] = *input.MaxCallPeopleCount
}
if input.CallMode != nil {
value := strings.ToUpper(strings.TrimSpace(*input.CallMode))
if value != model.CallModeTicket && value != model.CallModePeople && value != model.CallModeBoth {
return nil, invalidSetting("call_mode")
}
updates["call_mode"] = value
}
if input.MinPartySize != nil {
if *input.MinPartySize < 1 || *input.MinPartySize > 10000 {
return nil, invalidSetting("min_party_size")
}
updates["min_party_size"] = *input.MinPartySize
}
if input.MaxPartySize != nil {
if *input.MaxPartySize < 1 || *input.MaxPartySize > 10000 {
return nil, invalidSetting("max_party_size")
}
updates["max_party_size"] = *input.MaxPartySize
}
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.ExperiencedPeopleStart != nil {
if *input.ExperiencedPeopleStart < 0 || *input.ExperiencedPeopleStart > 1000000000 {
return nil, invalidSetting("experienced_people_start")
}
updates["experienced_people_start"] = *input.ExperiencedPeopleStart
}
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 validateProjectSettingsCombination(project model.Project, updates map[string]any) error {
ticketDefault := project.CallBatchSize
maxTicketCount := project.MaxCallTicketCount
peopleDefault := project.DefaultCallPeopleCount
maxPeopleCount := project.MaxCallPeopleCount
minPartySize := project.MinPartySize
maxPartySize := project.MaxPartySize
if value, ok := updates["call_batch_size"].(int); ok {
ticketDefault = value
}
if value, ok := updates["max_call_ticket_count"].(int); ok {
maxTicketCount = value
}
if value, ok := updates["default_call_people_count"].(int); ok {
peopleDefault = value
}
if value, ok := updates["max_call_people_count"].(int); ok {
maxPeopleCount = value
}
if value, ok := updates["min_party_size"].(int); ok {
minPartySize = value
}
if value, ok := updates["max_party_size"].(int); ok {
maxPartySize = value
}
if ticketDefault > maxTicketCount {
return invalidSetting("default_call_ticket_count")
}
if peopleDefault > maxPeopleCount {
return invalidSetting("default_call_people_count")
}
if minPartySize > maxPartySize {
return invalidSetting("min_party_size")
}
return nil
}
func invalidSetting(field string) error {
return &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_SETTING", Message: "项目设置值不正确", Details: map[string]string{"field": field}}
}