修复项目无法删除问题
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user