修复项目无法删除问题
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Task: Resolve ended project deletion semantics
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260813-ended-project-delete-6a714b5b
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\PythonProjects\XQKqueue
|
||||
- Base commit: cb88a6901219c5a489eced946c8fe87427f3dedb
|
||||
- Owner: codex
|
||||
- Status: Complete; ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Replace the contradictory `PROJECT_HAS_HISTORY` outcome for ended projects with guarded archival while retaining hard deletion for projects without queue history.
|
||||
- Hide archived projects from every ordinary administrator, staff, visitor, browser-display, and Android-display project list or direct operational entry.
|
||||
- Preserve archived projects and their sessions, tickets, batches, simulations, and audit/history joins so administrators can still inspect retained history.
|
||||
- Add an explicit schema state, server transaction/query guards, accurate administrator UI wording, and regression coverage at the real PostgreSQL/HTTP seams.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User-confirmed product semantics: an ended project with history is archived, retains history, and must not appear in any ordinary project list.
|
||||
- Projects with no queue history remain eligible for irreversible hard deletion; projects with history must be `ENDED` before archival.
|
||||
- Use an explicit nullable `archived_at` field rather than implicit GORM soft-delete scopes; every ordinary project query must make visibility intentional, while history joins intentionally retain archived projects.
|
||||
- Archived projects cannot be operated, edited, granted to staff, resolved by public code/token, or restored through this task.
|
||||
- Project code and display-token uniqueness remain global after archival; this task does not add code reuse, restoration, a purge flow, or an archived-project management list.
|
||||
- PostgreSQL remains the authority for the archival transaction and state constraint. Preserve existing queue-history and personal-data retention rules.
|
||||
- Concurrent write ownership is split by file: archival core, ordinary query visibility, and web wording/tests. The root task owns integration, task documentation, any explicitly coordinated history authorization seam, and final verification.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added migration `000015_project_archival` and `Project.ArchivedAt`. PostgreSQL enforces that only `ENDED` projects may carry an archive timestamp; project codes and display tokens remain globally unique.
|
||||
- Changed the existing project removal transaction so an unused project is still hard-deleted, an operational project that is not ended returns `PROJECT_MUST_BE_ENDED`, and an ended project with queue history is archived atomically. Archival removes staff grants, writes `PROJECT_ARCHIVED`, and retains the project row, sessions, tickets, batches, simulations, and project-scoped audit entries.
|
||||
- Hid archived projects from administrator overview data, account grant lists, staff project/queue entry points, visitor project and active-phone lookup results, browser/Android display overview, and direct display code/token resolution. Direct administrator maintenance and operational write paths also reject archived projects.
|
||||
- Kept administrator history joins and private terminal-ticket lookups intentionally able to resolve the retained project. These are historical records, not ordinary project directories.
|
||||
- Prevented account create/update calls from granting archived projects, including a row-lock protocol that serializes grant creation against archival.
|
||||
- Updated the administrator action to the accurate neutral wording “移除项目” and made the confirmation copy distinguish archival from permanent deletion. When a staff user's final project disappears, the web client now clears the cached selection, stops polling the archived project, and hides stale queue data.
|
||||
- Prevented the demo seed reset from matching and erasing an archived project's retained history.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red evidence:
|
||||
- The initial PostgreSQL deletion integration test did not compile because `Project.ArchivedAt` was absent.
|
||||
- Administrator deletion-flow tests failed against the former delete-only wording.
|
||||
- The staff-page regression test showed that an archived final project remained in `sessionStorage` and stale queue content remained visible.
|
||||
- PostgreSQL 17, disposable fresh database: `go test -p 1 ./internal/database ./internal/httpapi -count=1` passed, including migration shape, hard deletion, ended-project archival, history/audit retention, grant removal, visibility, and rejection cases.
|
||||
- Server: `go test ./... -count=1`, `go vet ./...`, and `go build ./...` passed.
|
||||
- Web: `vitest run` passed 18 files / 70 tests; both application and Node TypeScript configurations passed `tsc --noEmit`; `vite build` passed.
|
||||
- Repository: `git diff --check` passed; line-ending notices are informational working-copy warnings.
|
||||
- Independent Sol High read-only final review: `PASS`, with no P0/P1 findings. The initial review found the ambiguous “归档项目” action label because unused projects are hard-deleted; it was corrected to “移除项目”, retested, and passed re-review.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Deploy migration 15 before the archive-aware server/application. After the first project is archived, rolling back to an older binary or dropping `archived_at` would expose that project in ordinary lists again; such a rollback requires a compatibility filter or a deliberate data migration.
|
||||
- Pre-existing display event streams are not proactively disconnected at the instant of archival. All new display resolution and operational writes are rejected, so no new project activity is produced; add project-level stream invalidation later if instantaneous connection termination becomes a requirement.
|
||||
- Add a dedicated PostgreSQL concurrency regression for the grant-write versus archive interleaving if this area receives further authorization changes. The current sorted `FOR KEY SHARE` grant locks and project `FOR UPDATE` archive lock were independently reviewed as correct.
|
||||
- Restoration, code reuse, permanent history purge, and a separate archive-management screen remain intentionally out of scope and require explicit retention/product decisions.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/40-domain/business-rules.md`. Record the confirmed removal rule: unused projects may be hard-deleted; projects with retained queue history must be ended and are then archived with history preserved.
|
||||
- Target: `.project-docs/20-architecture/data-flow.md`. Record the visibility split between ordinary active-project queries, which must filter `archived_at`, and historical joins, which intentionally retain archived project identity.
|
||||
- Target: `.project-docs/20-architecture/decisions/`. Consider an accepted decision for explicit archival state instead of implicit ORM soft-delete behavior, including the global code/token uniqueness and rollback constraints.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Reflection: Resolve destructive semantics before implementing deletion
|
||||
|
||||
- Task: `20260813-ended-project-delete-6a714b5b`
|
||||
- Date: 2026-08-13
|
||||
- Status: Confirmed workflow lesson
|
||||
|
||||
## What happened
|
||||
|
||||
The first project-removal implementation treated any queue history as a permanent hard-delete blocker and told the administrator to end the project. That message was internally contradictory: ending the project did not change the blocker, so the user could never complete the requested removal workflow.
|
||||
|
||||
The database constraints correctly signaled that operational and audit history must not be casually cascaded, but they did not by themselves define the product meaning of “delete.” The missing decision was whether the user intended destructive purge, operational removal with retained history, or simple status closure.
|
||||
|
||||
## Why it matters
|
||||
|
||||
Deletion labels often conceal several distinct domain actions. Implementing the safest database behavior without first resolving the user-visible lifecycle can create a technically guarded endpoint that has no usable success path. It can also force a later schema and query audit because archival affects every list, authorization path, cache, direct lookup, and background tool—not just the delete handler.
|
||||
|
||||
## Better workflow
|
||||
|
||||
Before implementing a destructive command, establish and test a small lifecycle table: entity state, presence of retained history, requested action, resulting visibility, retained data, and whether restoration or identifier reuse is allowed. Treat ordinary listings and historical attribution as separate query contracts. Then inventory all readers and writers against that contract before editing the endpoint.
|
||||
|
||||
## Promotion candidate
|
||||
|
||||
- Target: repository planning/review guidance for destructive lifecycle changes.
|
||||
- Proposal: require an explicit delete/archive/purge semantics check and an ordinary-list versus history-query inventory before implementation.
|
||||
- Evidence: the original guarded hard-delete path could never succeed for the exact ended project it instructed the user to create.
|
||||
- Future impact: reduces contradictory workflows and prevents archived data from leaking through secondary lists, stale grants, caches, seeds, or direct identifiers.
|
||||
- Human confirmation: required before promoting this lesson into shared canonical guidance.
|
||||
@@ -172,6 +172,8 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project,
|
||||
project.ID, project.CreatedAt = uuid.NewString(), now
|
||||
} else if err != nil {
|
||||
return project, err
|
||||
} else if project.ArchivedAt != nil {
|
||||
return project, fmt.Errorf("refusing to reset archived project %q", spec.Code)
|
||||
}
|
||||
// Only demo projects are reset; unrelated project data is never touched.
|
||||
for _, statement := range []string{
|
||||
|
||||
92
server/cmd/seed/main_test.go
Normal file
92
server/cmd/seed/main_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type inertConnPool struct{}
|
||||
|
||||
func (inertConnPool) PrepareContext(context.Context, string) (*sql.Stmt, error) {
|
||||
return nil, errors.New("unexpected prepare")
|
||||
}
|
||||
|
||||
func (inertConnPool) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
||||
return nil, errors.New("unexpected exec")
|
||||
}
|
||||
|
||||
func (inertConnPool) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
||||
return nil, errors.New("unexpected query")
|
||||
}
|
||||
|
||||
func (inertConnPool) QueryRowContext(context.Context, string, ...any) *sql.Row {
|
||||
return &sql.Row{}
|
||||
}
|
||||
|
||||
func TestResetProjectRejectsArchivedProjectBeforeAnyMutation(t *testing.T) {
|
||||
db, err := gorm.Open(postgres.New(postgres.Config{Conn: inertConnPool{}}), &gorm.Config{
|
||||
DisableAutomaticPing: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
archivedAt := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
existing := model.Project{
|
||||
ID: "11111111-1111-4111-8111-111111111111",
|
||||
Code: "DEMO",
|
||||
Name: "archived project",
|
||||
Status: model.ProjectEnded,
|
||||
ArchivedAt: &archivedAt,
|
||||
}
|
||||
if err := db.Callback().Query().Replace("gorm:query", func(tx *gorm.DB) {
|
||||
project, ok := tx.Statement.Dest.(*model.Project)
|
||||
if !ok {
|
||||
t.Fatalf("query destination = %T, want *model.Project", tx.Statement.Dest)
|
||||
}
|
||||
*project = existing
|
||||
tx.RowsAffected = 1
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mutationCount := 0
|
||||
recordMutation := func(tx *gorm.DB) {
|
||||
mutationCount++
|
||||
tx.AddError(errors.New("unexpected mutation"))
|
||||
}
|
||||
if err := db.Callback().Raw().Replace("gorm:raw", recordMutation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Update().Replace("gorm:update", recordMutation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Create().Replace("gorm:create", recordMutation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Delete().Replace("gorm:delete", recordMutation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
project, err := resetProject(db, projectSeed{
|
||||
Code: "demo", Name: "replacement", Status: model.ProjectRunning,
|
||||
}, time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC))
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "archived") {
|
||||
t.Fatalf("resetProject error = %v, want explicit archived-project error", err)
|
||||
}
|
||||
if mutationCount != 0 {
|
||||
t.Fatalf("resetProject performed %d mutations after loading an archived project, want 0", mutationCount)
|
||||
}
|
||||
if project.ID != existing.ID || project.Name != existing.Name || project.Status != existing.Status || project.ArchivedAt != existing.ArchivedAt {
|
||||
t.Fatalf("returned project was modified: %#v", project)
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,29 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) {
|
||||
if err := sqlDB.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if migrationCount < 14 {
|
||||
t.Fatalf("migration count = %d, want at least 14", migrationCount)
|
||||
if migrationCount < 15 {
|
||||
t.Fatalf("migration count = %d, want at least 15", migrationCount)
|
||||
}
|
||||
var archivedAtType, archivedAtNullable string
|
||||
var archivedAtDefault *string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'projects' AND column_name = 'archived_at'`).
|
||||
Scan(&archivedAtType, &archivedAtNullable, &archivedAtDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivedAtType != "timestamp with time zone" || archivedAtNullable != "YES" || archivedAtDefault != nil {
|
||||
t.Fatalf("archived_at schema = %q/%q/%v, want timestamptz/nullable/no default", archivedAtType, archivedAtNullable, archivedAtDefault)
|
||||
}
|
||||
var archivedStatusConstraint int
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM pg_constraint
|
||||
WHERE conrelid = 'projects'::regclass AND conname = 'projects_archived_status_check'`).Scan(&archivedStatusConstraint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivedStatusConstraint != 1 {
|
||||
t.Fatalf("projects_archived_status_check count = %d, want 1", archivedStatusConstraint)
|
||||
}
|
||||
var experiencedPeopleStartColumn string
|
||||
if err := sqlDB.QueryRowContext(ctx, `
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
@@ -111,7 +112,7 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ? AND archived_at IS NULL", projectID).Error; err != nil {
|
||||
return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
||||
}
|
||||
before := projectView(project)
|
||||
@@ -141,7 +142,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.Project
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ? AND archived_at IS NULL", projectID).Error; err != nil {
|
||||
return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
||||
}
|
||||
|
||||
@@ -150,11 +151,19 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
return err
|
||||
}
|
||||
if historyCount > 0 {
|
||||
return &apiError{
|
||||
Status: http.StatusConflict,
|
||||
Code: "PROJECT_HAS_HISTORY",
|
||||
Message: "该项目已有排队运营历史,不能删除;如需停止使用,请结束该项目。",
|
||||
if project.Status != model.ProjectEnded {
|
||||
return &apiError{Status: http.StatusConflict, Code: "PROJECT_MUST_BE_ENDED", Message: "请先结束项目,再归档并保留历史记录。"}
|
||||
}
|
||||
now := s.now()
|
||||
if err := tx.Model(&project).Updates(map[string]any{"archived_at": now, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("project_id = ?", projectID).Delete(&model.UserProject{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
actor := currentPrincipal(r.Context()).User
|
||||
return s.addAudit(tx, r, &projectID, &actor.ID, "PROJECT_ARCHIVED", "PROJECT", &projectID,
|
||||
map[string]any{"project": projectView(project), "archived_at": now})
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.AuditEntry{}).
|
||||
@@ -191,7 +200,10 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var grants []model.UserProject
|
||||
if err := s.db.WithContext(r.Context()).Find(&grants).Error; err != nil {
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.UserProject{}).
|
||||
Select("user_projects.*").
|
||||
Joins("JOIN projects AS granted_project ON granted_project.id = user_projects.project_id AND granted_project.archived_at IS NULL").
|
||||
Find(&grants).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -237,9 +249,27 @@ func validateAdminUserRequest(input adminUserRequest, creating bool) (adminUserR
|
||||
return input, &apiError{Status: 422, Code: "INVALID_PROJECT", Message: "所属项目无效"}
|
||||
}
|
||||
}
|
||||
slices.Sort(input.ProjectIDs)
|
||||
input.ProjectIDs = slices.Compact(input.ProjectIDs)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func lockActiveProjects(tx *gorm.DB, projectIDs []string) error {
|
||||
if len(projectIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var projects []model.Project
|
||||
if err := tx.Clauses(clause.Locking{Strength: "KEY SHARE"}).
|
||||
Where("id IN ? AND archived_at IS NULL", projectIDs).
|
||||
Order("id ASC").Find(&projects).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) != len(projectIDs) {
|
||||
return &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PROJECT", Message: "所属项目无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input adminUserRequest
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
@@ -265,6 +295,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 := lockActiveProjects(tx, input.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return &apiError{Status: 409, Code: "USERNAME_EXISTS", Message: "该账号已存在"}
|
||||
}
|
||||
@@ -301,6 +334,9 @@ func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var user model.User
|
||||
err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockActiveProjects(tx, input.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", id).Error; err != nil {
|
||||
return mapNotFound(err, "USER_NOT_FOUND", "账号不存在")
|
||||
}
|
||||
@@ -392,11 +428,12 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT session.id
|
||||
FROM queue_sessions AS session
|
||||
JOIN projects AS project ON project.id = session.project_id
|
||||
WHERE session.status IN ('RUNNING', 'PAUSED')
|
||||
WHERE project.archived_at IS NULL
|
||||
AND 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 projects WHERE archived_at IS NULL) 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,
|
||||
@@ -407,12 +444,15 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var projects []model.Project
|
||||
if err := s.db.WithContext(r.Context()).Order("name ASC").Find(&projects).Error; err != nil {
|
||||
if err := s.db.WithContext(r.Context()).Where("archived_at IS NULL").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 {
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.DeviceSimulation{}).
|
||||
Select("device_simulations.*").
|
||||
Joins("JOIN projects AS simulation_project ON simulation_project.id = device_simulations.project_id AND simulation_project.archived_at IS NULL").
|
||||
Order("device_simulations.created_at DESC").Limit(20).Find(&simulations).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -423,6 +463,7 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
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_project.archived_at IS NULL").
|
||||
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)
|
||||
@@ -636,7 +677,7 @@ func (s *Server) updateProjectSettings(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ? AND archived_at IS NULL", projectID).Error; err != nil {
|
||||
return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
||||
}
|
||||
before := projectView(project)
|
||||
|
||||
@@ -86,7 +86,9 @@ func (s *Server) authorizeProject(ctx context.Context, projectID string) error {
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.UserProject{}).
|
||||
Where("user_id = ? AND project_id = ?", user.ID, projectID).Count(&count).Error; err != nil {
|
||||
Joins("JOIN projects ON projects.id = user_projects.project_id").
|
||||
Where("user_projects.user_id = ? AND user_projects.project_id = ? AND projects.archived_at IS NULL", user.ID, projectID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -219,7 +221,8 @@ func userView(user model.User) map[string]any {
|
||||
|
||||
func (s *Server) projectsForUser(ctx context.Context, user model.User) ([]model.Project, error) {
|
||||
var projects []model.Project
|
||||
query := s.db.WithContext(ctx).Model(&model.Project{}).Order("name ASC")
|
||||
query := s.db.WithContext(ctx).Model(&model.Project{}).
|
||||
Where("projects.archived_at IS NULL").Order("projects.name ASC")
|
||||
if user.Role != model.RoleAdmin {
|
||||
query = query.Joins("JOIN user_projects ON user_projects.project_id = projects.id").
|
||||
Where("user_projects.user_id = ?", user.ID)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/config"
|
||||
"calllinesystem/server/internal/database"
|
||||
"calllinesystem/server/internal/model"
|
||||
"calllinesystem/server/internal/security"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestArchivedProjectIsHiddenFromOrdinaryEntryPointsButRetainedInHistoryPostgresIntegration(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
db, err := database.Open(ctx, dsn, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close(db)
|
||||
sqlDB, err := database.SQLDB(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := New(db, config.Config{
|
||||
Environment: "development",
|
||||
EncryptionKey: bytes.Repeat([]byte{0x61}, 32),
|
||||
PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32),
|
||||
SessionCookieName: "queue_session",
|
||||
SessionTTL: time.Hour,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||
server.now = func() time.Time { return now }
|
||||
|
||||
passwordHash, err := security.HashPassword("unused-integration-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10]
|
||||
admin := model.User{ID: uuid.NewString(), Username: "archive_admin_" + suffix, PasswordHash: passwordHash, Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now}
|
||||
staff := model.User{ID: uuid.NewString(), Username: "archive_staff_" + suffix, PasswordHash: passwordHash, Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&staff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
displayToken := "archive-display-token-" + uuid.NewString()
|
||||
displayTokenHash := security.HashToken(displayToken)
|
||||
project := model.Project{
|
||||
ID: uuid.NewString(), Code: "ARC" + strings.ToUpper(suffix), Name: "archived visibility project", Status: model.ProjectEnded,
|
||||
Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 1, CallMode: model.CallModeBoth,
|
||||
MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10,
|
||||
GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300,
|
||||
ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice,
|
||||
DisplayTokenHash: &displayTokenHash, DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("UPDATE projects SET archived_at = ? WHERE id = ?", now, project.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.UserProject{UserID: staff.ID, ProjectID: project.ID, CreatedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := model.QueueSession{
|
||||
ID: uuid.NewString(), ProjectID: project.ID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 2, Revision: 1,
|
||||
OpenedAt: now.Add(-time.Hour), ClosedAt: &now, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicTicketToken := "archive-ticket-token-" + uuid.NewString()
|
||||
phone := "+8613800138000"
|
||||
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+project.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phoneHMAC := server.cipher.Digest(phone)
|
||||
ticket := model.QueueTicket{
|
||||
ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 1,
|
||||
PublicTokenHash: security.HashToken(publicTicketToken), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
|
||||
Honorific: "游客", Status: model.TicketCompleted, JoinedAt: now.Add(-time.Hour), PersonalDataPurgeAt: now.AddDate(0, 1, 0),
|
||||
CreatedBy: admin.ID, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&ticket).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
staffToken := "archive-staff-session-" + uuid.NewString()
|
||||
adminToken := "archive-admin-session-" + uuid.NewString()
|
||||
for _, authSession := range []model.AuthSession{
|
||||
{ID: uuid.NewString(), UserID: staff.ID, TokenHash: security.HashToken(staffToken), ExpiresAt: now.Add(time.Hour), LastSeenAt: now, UserAgent: "integration-test", CreatedAt: now},
|
||||
{ID: uuid.NewString(), UserID: admin.ID, TokenHash: security.HashToken(adminToken), ExpiresAt: now.Add(time.Hour), LastSeenAt: now, UserAgent: "integration-test", CreatedAt: now},
|
||||
} {
|
||||
if err := db.Create(&authSession).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
staffCookie := &http.Cookie{Name: server.authCookieName(model.RoleStaff), Value: staffToken}
|
||||
adminCookie := &http.Cookie{Name: server.authCookieName(model.RoleAdmin), Value: adminToken}
|
||||
|
||||
request := func(method, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
server.Handler().ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
assertProjectAbsent := func(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), project.ID) || strings.Contains(response.Body.String(), project.Name) {
|
||||
t.Fatalf("archived project leaked in response: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("staff me and project list hide archived project even if a stale grant exists", func(t *testing.T) {
|
||||
assertProjectAbsent(t, request(http.MethodGet, "/api/staff/auth/me", staffCookie))
|
||||
assertProjectAbsent(t, request(http.MethodGet, "/api/staff/projects", staffCookie))
|
||||
})
|
||||
|
||||
t.Run("administrator me project list hides archived project", func(t *testing.T) {
|
||||
assertProjectAbsent(t, request(http.MethodGet, "/api/admin/auth/me", adminCookie))
|
||||
})
|
||||
|
||||
t.Run("public and display overview lists hide archived project", func(t *testing.T) {
|
||||
assertProjectAbsent(t, request(http.MethodGet, "/api/public/projects", nil))
|
||||
assertProjectAbsent(t, request(http.MethodGet, "/api/display/overview", nil))
|
||||
})
|
||||
|
||||
t.Run("display project code and token no longer resolve", func(t *testing.T) {
|
||||
for _, identifier := range []string{strings.ToLower(project.Code), displayToken} {
|
||||
response := request(http.MethodGet, "/api/display/"+identifier+"/snapshot", nil)
|
||||
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), `"code":"DISPLAY_NOT_FOUND"`) {
|
||||
t.Fatalf("display snapshot %q status = %d, body = %s; want DISPLAY_NOT_FOUND", identifier, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staff operational entry is rejected despite a stale grant", func(t *testing.T) {
|
||||
response := request(http.MethodGet, "/api/staff/projects/"+project.ID+"/queue", staffCookie)
|
||||
if response.Code != http.StatusForbidden && response.Code != http.StatusNotFound {
|
||||
t.Fatalf("staff queue status = %d, body = %s; want 403 or 404", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("administrator history still includes archived project", func(t *testing.T) {
|
||||
response := request(http.MethodGet, "/api/admin/history/tickets?from=2026-08-13&to=2026-08-13&project_id="+project.ID, adminCookie)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("history status = %d, want 200; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Items) != 1 || body.Items[0]["project_id"] != project.ID {
|
||||
t.Fatalf("history items = %#v, want archived project ticket", body.Items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("private terminal ticket status remains available", func(t *testing.T) {
|
||||
response := request(http.MethodGet, "/api/public/status/"+publicTicketToken, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("private ticket status = %d, want 200; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -96,16 +96,25 @@ func TestDeleteProjectPostgresIntegration(t *testing.T) {
|
||||
}
|
||||
unused := newProject("DEL"+strings.ToUpper(suffix), "unused deletion project")
|
||||
history := newProject("HIS"+strings.ToUpper(suffix), "history deletion project")
|
||||
history.Status = model.ProjectEnded
|
||||
notEnded := newProject("RUN"+strings.ToUpper(suffix), "running history project")
|
||||
notEnded.Status = model.ProjectRunning
|
||||
if err := db.Create(&unused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&history).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(¬Ended).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := model.UserProject{UserID: admin.ID, ProjectID: unused.ID, CreatedAt: now}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.UserProject{UserID: admin.ID, ProjectID: history.ID, CreatedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdAudit := func(projectID string) model.AuditEntry {
|
||||
return model.AuditEntry{
|
||||
ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &admin.ID,
|
||||
@@ -130,6 +139,12 @@ func TestDeleteProjectPostgresIntegration(t *testing.T) {
|
||||
if err := db.Create(&queueSession).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notEndedSession := queueSession
|
||||
notEndedSession.ID = uuid.NewString()
|
||||
notEndedSession.ProjectID = notEnded.ID
|
||||
if err := db.Create(¬EndedSession).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login",
|
||||
@@ -201,20 +216,25 @@ func TestDeleteProjectPostgresIntegration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project with queue history is rejected intact", func(t *testing.T) {
|
||||
t.Run("ended project with queue history is archived intact", func(t *testing.T) {
|
||||
response := deleteRequest(history.ID)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("delete history project status = %d, want 409; body = %s", response.Code, response.Body.String())
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("archive history project status = %d, want 204; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":"PROJECT_HAS_HISTORY"`) {
|
||||
t.Fatalf("delete history project body = %s, want PROJECT_HAS_HISTORY", response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "不能删除") || !strings.Contains(response.Body.String(), "结束") {
|
||||
t.Fatalf("delete history project message is not actionable Chinese: %s", response.Body.String())
|
||||
}
|
||||
if err := db.First(&model.Project{}, "id = ?", history.ID).Error; err != nil {
|
||||
var archived model.Project
|
||||
if err := db.First(&archived, "id = ?", history.ID).Error; err != nil {
|
||||
t.Fatalf("history project was not retained: %v", err)
|
||||
}
|
||||
if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(now) {
|
||||
t.Fatalf("archived_at = %v, want %v", archived.ArchivedAt, now)
|
||||
}
|
||||
var archivedGrantCount int64
|
||||
if err := db.Model(&model.UserProject{}).Where("project_id = ?", history.ID).Count(&archivedGrantCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivedGrantCount != 0 {
|
||||
t.Fatalf("archived project grants = %d, want 0", archivedGrantCount)
|
||||
}
|
||||
if err := db.First(&model.QueueSession{}, "id = ?", queueSession.ID).Error; err != nil {
|
||||
t.Fatalf("queue session was not retained: %v", err)
|
||||
}
|
||||
@@ -225,14 +245,59 @@ func TestDeleteProjectPostgresIntegration(t *testing.T) {
|
||||
if retained.ProjectID == nil || *retained.ProjectID != history.ID {
|
||||
t.Fatalf("history audit project_id = %v, want %s", retained.ProjectID, history.ID)
|
||||
}
|
||||
var deletedAuditCount int64
|
||||
if err := db.Model(&model.AuditEntry{}).
|
||||
Where("action = ? AND entity_id = ?", "PROJECT_DELETED", history.ID).
|
||||
Count(&deletedAuditCount).Error; err != nil {
|
||||
var archivedAudit model.AuditEntry
|
||||
if err := db.Where("action = ? AND entity_id = ?", "PROJECT_ARCHIVED", history.ID).First(&archivedAudit).Error; err != nil {
|
||||
t.Fatalf("load PROJECT_ARCHIVED audit: %v", err)
|
||||
}
|
||||
if archivedAudit.ProjectID == nil || *archivedAudit.ProjectID != history.ID {
|
||||
t.Fatalf("PROJECT_ARCHIVED project_id = %v, want %s", archivedAudit.ProjectID, history.ID)
|
||||
}
|
||||
second := deleteRequest(history.ID)
|
||||
if second.Code != http.StatusNotFound {
|
||||
t.Fatalf("second archive status = %d, want 404; body = %s", second.Code, second.Body.String())
|
||||
}
|
||||
|
||||
requestAdmin := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
request.AddCookie(adminCookie)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
profile := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID,
|
||||
`{"name":"archived","code":"`+history.Code+`","timezone":"Asia/Shanghai","ticket_prefix":"A"}`)
|
||||
if profile.Code != http.StatusNotFound {
|
||||
t.Fatalf("update archived project status = %d, want 404; body = %s", profile.Code, profile.Body.String())
|
||||
}
|
||||
settings := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID+"/settings", `{"status":"ENDED"}`)
|
||||
if settings.Code != http.StatusNotFound {
|
||||
t.Fatalf("update archived settings status = %d, want 404; body = %s", settings.Code, settings.Body.String())
|
||||
}
|
||||
createUser := requestAdmin(http.MethodPost, "/api/admin/users",
|
||||
`{"username":"archived_grant_`+suffix+`","password":"Password123!","role":"STAFF","project_ids":["`+history.ID+`"]}`)
|
||||
if createUser.Code != http.StatusUnprocessableEntity || !strings.Contains(createUser.Body.String(), `"code":"INVALID_PROJECT"`) {
|
||||
t.Fatalf("grant archived project status = %d, body = %s; want 422 INVALID_PROJECT", createUser.Code, createUser.Body.String())
|
||||
}
|
||||
adminUsers := requestAdmin(http.MethodGet, "/api/admin/users", "")
|
||||
if adminUsers.Code != http.StatusOK {
|
||||
t.Fatalf("admin users status = %d, want 200; body = %s", adminUsers.Code, adminUsers.Body.String())
|
||||
}
|
||||
if strings.Contains(adminUsers.Body.String(), history.ID) {
|
||||
t.Fatalf("admin users leaked archived project id: %s", adminUsers.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project with history must be ended before archival", func(t *testing.T) {
|
||||
response := deleteRequest(notEnded.ID)
|
||||
if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), `"code":"PROJECT_MUST_BE_ENDED"`) {
|
||||
t.Fatalf("archive running project status = %d, body = %s; want 409 PROJECT_MUST_BE_ENDED", response.Code, response.Body.String())
|
||||
}
|
||||
var retained model.Project
|
||||
if err := db.First(&retained, "id = ?", notEnded.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedAuditCount != 0 {
|
||||
t.Fatalf("PROJECT_DELETED audit count for retained project = %d, want 0", deletedAuditCount)
|
||||
if retained.ArchivedAt != nil {
|
||||
t.Fatalf("running project archived_at = %v, want nil", retained.ArchivedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) publicProjects(w http.ResponseWriter, r *http.Request) {
|
||||
var projects []model.Project
|
||||
if err := s.db.WithContext(r.Context()).
|
||||
Where("status = ?", model.ProjectRunning).
|
||||
Where("status = ? AND archived_at IS NULL", model.ProjectRunning).
|
||||
Order("name ASC").Find(&projects).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
@@ -128,7 +128,7 @@ func (s *Server) statusByPhone(w http.ResponseWriter, r *http.Request, limitByCl
|
||||
if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}).
|
||||
Joins("JOIN projects ON projects.id = queue_tickets.project_id").
|
||||
Joins("JOIN queue_sessions ON queue_sessions.id = queue_tickets.queue_session_id AND queue_sessions.project_id = queue_tickets.project_id").
|
||||
Where("queue_tickets.phone_hmac = ? AND queue_tickets.status IN ? AND queue_sessions.status IN ? AND projects.status IN ?",
|
||||
Where("queue_tickets.phone_hmac = ? AND queue_tickets.status IN ? AND queue_sessions.status IN ? AND projects.status IN ? AND projects.archived_at IS NULL",
|
||||
phoneDigest,
|
||||
[]string{model.TicketWaiting, model.TicketCalled, model.TicketArrived},
|
||||
activeSessionStatuses,
|
||||
@@ -312,13 +312,13 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
var project model.Project
|
||||
var err error
|
||||
if code, ok := normalizeDisplayProjectCode(identifier); ok {
|
||||
err = s.db.WithContext(r.Context()).Where("code = ?", code).First(&project).Error
|
||||
err = s.db.WithContext(r.Context()).Where("code = ? AND archived_at IS NULL", code).First(&project).Error
|
||||
} else {
|
||||
if len(identifier) < 40 || len(identifier) > 128 {
|
||||
writeError(w, &apiError{Status: http.StatusNotFound, Code: "DISPLAY_NOT_FOUND", Message: "公示屏绑定不存在"})
|
||||
return
|
||||
}
|
||||
err = s.db.WithContext(r.Context()).Where("display_token_hash = ?", security.HashToken(identifier)).First(&project).Error
|
||||
err = s.db.WithContext(r.Context()).Where("display_token_hash = ? AND archived_at IS NULL", security.HashToken(identifier)).First(&project).Error
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, mapNotFound(err, "DISPLAY_NOT_FOUND", "公示屏绑定不存在"))
|
||||
@@ -499,7 +499,7 @@ func publicDisplayProjectView(project map[string]any) map[string]any {
|
||||
|
||||
func (s *Server) displayOverview(w http.ResponseWriter, r *http.Request) {
|
||||
var projects []model.Project
|
||||
if err := s.db.WithContext(r.Context()).Order("name ASC").Find(&projects).Error; err != nil {
|
||||
if err := s.db.WithContext(r.Context()).Where("archived_at IS NULL").Order("name ASC").Find(&projects).Error; err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var project model.Project
|
||||
if err := s.db.WithContext(r.Context()).First(&project, "id = ?", projectID).Error; err != nil {
|
||||
if err := s.db.WithContext(r.Context()).First(&project, "id = ? AND archived_at IS NULL", projectID).Error; err != nil {
|
||||
writeError(w, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在"))
|
||||
return
|
||||
}
|
||||
@@ -661,7 +661,7 @@ func (s *Server) transitionTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var revision int64
|
||||
err := s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.Project
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", initial.ProjectID).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ? AND archived_at IS NULL", initial.ProjectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var session model.QueueSession
|
||||
@@ -735,7 +735,7 @@ func (s *Server) lockCallableProjectAndSession(tx *gorm.DB, projectID string) (m
|
||||
|
||||
func (s *Server) lockProjectAndSession(tx *gorm.DB, projectID string, allowPaused bool) (model.Project, model.QueueSession, error) {
|
||||
var project model.Project
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ? AND archived_at IS NULL", projectID).Error; err != nil {
|
||||
return project, model.QueueSession{}, mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
||||
}
|
||||
if project.Status != model.ProjectRunning && (!allowPaused || project.Status != model.ProjectPaused) {
|
||||
|
||||
@@ -71,6 +71,7 @@ type Project struct {
|
||||
ExperiencedPeopleStart int `gorm:"column:experienced_people_start;not null;default:0"`
|
||||
DisplayTokenHash *string `gorm:"type:char(64)"`
|
||||
DeviceSimulationMode string `gorm:"size:16;not null"`
|
||||
ArchivedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
5
server/migrations/000015_project_archival.down.sql
Normal file
5
server/migrations/000015_project_archival.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS projects_active_name_idx;
|
||||
|
||||
ALTER TABLE projects
|
||||
DROP CONSTRAINT IF EXISTS projects_archived_status_check,
|
||||
DROP COLUMN IF EXISTS archived_at;
|
||||
10
server/migrations/000015_project_archival.up.sql
Normal file
10
server/migrations/000015_project_archival.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN archived_at timestamptz;
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_archived_status_check
|
||||
CHECK (archived_at IS NULL OR status = 'ENDED');
|
||||
|
||||
CREATE INDEX projects_active_name_idx
|
||||
ON projects (name, id)
|
||||
WHERE archived_at IS NULL;
|
||||
@@ -151,22 +151,23 @@ describe("AdminPage active tickets", () => {
|
||||
expect(screen.queryByRole("combobox", { name: "预计等待时间方式" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请在入口附近等候。");
|
||||
expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "删除项目" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "移除项目" })).toBeVisible();
|
||||
expect(screen.getByText("有运营历史的已结束项目将从所有项目列表隐藏,历史记录仍保留;无运营历史项目会永久删除。")).toBeVisible();
|
||||
});
|
||||
|
||||
it("确认后删除项目,刷新数据并返回列表", async () => {
|
||||
it("确认后归档或移除项目,刷新数据并返回列表", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockImplementation(() => new Promise<void>((resolve) => { resolveDelete = resolve; }));
|
||||
refreshMock.mockResolvedValueOnce(undefined);
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "移除项目" }));
|
||||
expect(deleteProject).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认移除" }));
|
||||
|
||||
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith("project-1"));
|
||||
const deletingButton = screen.getByRole("button", { name: "正在删除" });
|
||||
const deletingButton = screen.getByRole("button", { name: "正在移除" });
|
||||
expect(deletingButton).toBeDisabled();
|
||||
fireEvent.click(deletingButton);
|
||||
expect(deleteProject).toHaveBeenCalledTimes(1);
|
||||
@@ -178,15 +179,16 @@ describe("AdminPage active tickets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("删除项目失败时留在维护页并显示服务端消息", async () => {
|
||||
it("归档或移除项目失败时留在维护页并显示服务端消息", async () => {
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockRejectedValue(new ApiError("项目存在活动排队,无法删除", 409));
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "移除项目" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认移除" }));
|
||||
|
||||
expect(await screen.findByText("项目存在活动排队,无法删除")).toBeVisible();
|
||||
expect(screen.getByText("移除失败")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -353,7 +353,7 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
|
||||
await onRefresh();
|
||||
navigate("/admin/projects", { replace: true });
|
||||
} catch (caught) {
|
||||
setDeleteError(caught instanceof ApiError ? caught.message : "项目删除失败,请稍后重试。");
|
||||
setDeleteError(caught instanceof ApiError ? caught.message : "项目移除失败,请稍后重试。");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
@@ -363,15 +363,15 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
|
||||
<ProjectForm project={project} onSaved={onRefresh} />
|
||||
<section className="panel" aria-label="危险操作">
|
||||
<div className="panel__header">
|
||||
<div><h3>危险操作</h3><p>删除后无法撤销,请确认不再需要该项目。</p></div>
|
||||
<div><h3>危险操作</h3><p>有运营历史的已结束项目将从所有项目列表隐藏,历史记录仍保留;无运营历史项目会永久删除。</p></div>
|
||||
{!confirmingDelete
|
||||
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}>删除项目</button>
|
||||
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}>移除项目</button>
|
||||
: <div>
|
||||
<button className="button button--secondary" type="button" onClick={() => setConfirmingDelete(false)} disabled={deleting}>取消</button>{" "}
|
||||
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在删除" : "确认删除"}</button>
|
||||
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在移除" : "确认移除"}</button>
|
||||
</div>}
|
||||
</div>
|
||||
{deleteError ? <FeedbackBanner tone="danger" title="删除失败">{deleteError}</FeedbackBanner> : null}
|
||||
{deleteError ? <FeedbackBanner tone="danger" title="移除失败">{deleteError}</FeedbackBanner> : null}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" }));
|
||||
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING", projectsVisible: true, exerciseQueueLoad: false }));
|
||||
|
||||
vi.mock("../components/AppShell", () => ({
|
||||
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
|
||||
@@ -10,12 +10,12 @@ vi.mock("../components/AppShell", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000
|
||||
usePollingResource: (_load: unknown, options: { intervalMs: number; enabled?: boolean }) => options.intervalMs === 60_000
|
||||
? {
|
||||
data: { projects: [
|
||||
data: { projects: staffPageTestState.projectsVisible ? [
|
||||
{ id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
|
||||
{ id: "project-2", name: "西门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
|
||||
] },
|
||||
] : [] },
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
error: null,
|
||||
@@ -23,7 +23,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
lastClientSuccessAt: new Date().toISOString(),
|
||||
refresh: vi.fn(),
|
||||
}
|
||||
: {
|
||||
: (staffPageTestState.exerciseQueueLoad && options.enabled !== false && (_load as (signal: AbortSignal) => unknown)(new AbortController().signal), {
|
||||
data: {
|
||||
project: { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
|
||||
revision: 8,
|
||||
@@ -70,7 +70,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
offline: false,
|
||||
lastClientSuccessAt: new Date().toISOString(),
|
||||
refresh: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../auth/AuthContext", () => ({
|
||||
useAuth: () => ({ user: { id: "staff-1", username: "staff01", display_name: "小李", role: "STAFF" } }),
|
||||
@@ -86,9 +86,28 @@ function renderScene(path: string) {
|
||||
describe("StaffPage scene-focused H5", () => {
|
||||
beforeEach(() => {
|
||||
staffPageTestState.projectStatus = "RUNNING";
|
||||
staffPageTestState.projectsVisible = true;
|
||||
staffPageTestState.exerciseQueueLoad = false;
|
||||
sessionStorage.setItem("scenic-current-project", "project-1");
|
||||
});
|
||||
|
||||
it("clears a removed project's cached selection and queue after a successful empty project response", async () => {
|
||||
staffPageTestState.exerciseQueueLoad = true;
|
||||
const queue = vi.spyOn(api, "queue").mockResolvedValue({} as never);
|
||||
const view = renderScene("/staff");
|
||||
expect(queue).toHaveBeenCalledWith("project-1", expect.any(AbortSignal));
|
||||
expect(screen.getByText("00012")).toBeVisible();
|
||||
|
||||
queue.mockClear();
|
||||
staffPageTestState.projectsVisible = false;
|
||||
view.rerender(<MemoryRouter initialEntries={["/staff"]}><StaffPage /></MemoryRouter>);
|
||||
|
||||
await waitFor(() => expect(sessionStorage.getItem("scenic-current-project")).toBeNull());
|
||||
expect(queue).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("东门观光车")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("00012")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows calling but blocks ticket creation while the project is paused", () => {
|
||||
staffPageTestState.projectStatus = "PAUSED";
|
||||
renderScene("/staff");
|
||||
|
||||
@@ -106,14 +106,23 @@ export function StaffPage() {
|
||||
const callIntentRef = useRef<WriteIntent | null>(null);
|
||||
|
||||
const projects = projectResource.data?.projects ?? [];
|
||||
const hasSuccessfulEmptyProjectList = Boolean(projectResource.data && !projectResource.loading && !projectResource.error && projects.length === 0);
|
||||
const activeProjectId = hasSuccessfulEmptyProjectList ? "" : selectedProjectId;
|
||||
useEffect(() => {
|
||||
if (hasSuccessfulEmptyProjectList) {
|
||||
setSelectedProjectId("");
|
||||
setPendingProjectId("");
|
||||
setShowProjectSwitcher(false);
|
||||
sessionStorage.removeItem("scenic-current-project");
|
||||
return;
|
||||
}
|
||||
if (!projects.length) return;
|
||||
const available = projects.some((project) => project.id === selectedProjectId);
|
||||
if (!available) {
|
||||
setSelectedProjectId(projects[0].id);
|
||||
setPendingProjectId(projects[0].id);
|
||||
}
|
||||
}, [projects, selectedProjectId]);
|
||||
}, [hasSuccessfulEmptyProjectList, projects, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) sessionStorage.setItem("scenic-current-project", selectedProjectId);
|
||||
@@ -130,10 +139,10 @@ export function StaffPage() {
|
||||
}, [notice]);
|
||||
|
||||
const queueResource = usePollingResource(
|
||||
(signal) => api.queue(selectedProjectId, signal),
|
||||
{ enabled: Boolean(selectedProjectId), intervalMs: 5_000, resourceKey: selectedProjectId },
|
||||
(signal) => api.queue(activeProjectId, signal),
|
||||
{ enabled: Boolean(activeProjectId), intervalMs: 5_000, resourceKey: activeProjectId },
|
||||
);
|
||||
const queue = queueResource.data;
|
||||
const queue = activeProjectId ? queueResource.data : undefined;
|
||||
const selectedProject = queue?.project ?? projects.find((project) => project.id === selectedProjectId);
|
||||
const callMode = selectedProject?.call_mode ?? "TICKET";
|
||||
const supportsTicketCall = callMode === "TICKET" || callMode === "BOTH";
|
||||
|
||||
Reference in New Issue
Block a user