package httpapi import ( "errors" "net/http" "time" "calllinesystem/server/internal/domain" "calllinesystem/server/internal/model" "calllinesystem/server/internal/security" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type reissueTicketRequest struct { ExpectedRevision *int64 `json:"expected_revision"` } func (s *Server) reissueTicket(w http.ResponseWriter, r *http.Request) { originalTicketID := r.PathValue("id") if err := validateUUID(originalTicketID); err != nil { writeError(w, err) return } var initial model.QueueTicket if err := s.db.WithContext(r.Context()).Select("id", "project_id", "queue_session_id"). First(&initial, "id = ?", originalTicketID).Error; err != nil { writeError(w, mapNotFound(err, "TICKET_NOT_FOUND", "号码不存在")) return } if err := s.authorizeProject(r.Context(), initial.ProjectID); err != nil { writeError(w, err) return } var input reissueTicketRequest if err := decodeJSON(r, &input); err != nil { writeError(w, err) return } if input.ExpectedRevision == nil || *input.ExpectedRevision < 0 { writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPECTED_REVISION_REQUIRED", Message: "必须提供当前队列 revision"}) return } idempotencyKey, err := readIdempotencyKey(r) if err != nil { writeError(w, err) return } hash, err := requestHash(map[string]any{ "operation": "REISSUE_TICKET", "ticket_id": originalTicketID, "expected_revision": *input.ExpectedRevision, }) if err != nil { writeError(w, err) return } user := currentPrincipal(r.Context()).User responseCode := http.StatusCreated var responseBody []byte 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 { return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") } if stored, code, found, err := loadIdempotent(tx, initial.ProjectID, user.ID, "REISSUE_TICKET", idempotencyKey, hash, s.now()); err != nil { return err } else if found { responseBody, responseCode = stored, code return nil } if project.Status != model.ProjectRunning { return &apiError{Status: http.StatusConflict, Code: "PROJECT_NOT_RUNNING", Message: "项目当前未运行,不能重新取号"} } var session model.QueueSession if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&session, "id = ? AND project_id = ?", initial.QueueSessionID, initial.ProjectID).Error; err != nil { return mapNotFound(err, "QUEUE_NOT_FOUND", "队列场次不存在") } if session.Status != "RUNNING" { return &apiError{Status: http.StatusConflict, Code: "QUEUE_NOT_RUNNING", Message: "队列当前未运行,不能重新取号"} } if session.Revision != *input.ExpectedRevision { return &apiError{ Status: http.StatusConflict, Code: "REVISION_CONFLICT", Message: "队列已更新,请刷新后重试", Details: map[string]any{"expected_revision": *input.ExpectedRevision, "current_revision": session.Revision}, } } var original model.QueueTicket if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&original, "id = ? AND project_id = ? AND queue_session_id = ?", originalTicketID, initial.ProjectID, session.ID).Error; err != nil { return mapNotFound(err, "TICKET_NOT_FOUND", "号码不存在") } if original.Status != model.TicketMissed { return &apiError{ Status: http.StatusConflict, Code: "TICKET_NOT_MISSED", Message: "只有已过号的号码可以重新取号", Details: map[string]any{"status": original.Status}, } } if original.PersonalDataPurgedAt != nil || original.PhoneHMAC == nil { return &apiError{Status: http.StatusConflict, Code: "PERSONAL_DATA_PURGED", Message: "该号码的个人信息已清理,不能重新取号"} } var existing model.QueueTicket findExisting := tx.Select("id", "display_number"). Where("project_id = ? AND reissued_from_ticket_id = ?", initial.ProjectID, original.ID).First(&existing).Error if findExisting == nil { return &apiError{ Status: http.StatusConflict, Code: "TICKET_ALREADY_REISSUED", Message: "该过号号码已经重新取号", Details: map[string]any{"ticket_id": existing.ID, "display_number": existing.DisplayNumber}, } } if !errors.Is(findExisting, gorm.ErrRecordNotFound) { return findExisting } publicToken, err := security.GenerateToken() if err != nil { return err } displayNumber, err := domain.DisplayNumber("", session.NextTicketNumber) if err != nil { return err } now := s.now() newTicket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: initial.ProjectID, QueueSessionID: session.ID, ReissuedFromTicketID: &original.ID, TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, PublicTokenHash: security.HashToken(publicToken), PhoneCiphertext: append([]byte(nil), original.PhoneCiphertext...), PhoneNonce: append([]byte(nil), original.PhoneNonce...), PhoneHMAC: cloneStringPointer(original.PhoneHMAC), LastNameCiphertext: append([]byte(nil), original.LastNameCiphertext...), LastNameNonce: append([]byte(nil), original.LastNameNonce...), Honorific: original.Honorific, Status: model.TicketWaiting, JoinedAt: now, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: user.ID, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&newTicket).Error; err != nil { return err } revision = session.Revision + 1 if err := tx.Model(&model.QueueSession{}).Where("id = ? AND project_id = ?", session.ID, initial.ProjectID). Updates(map[string]any{"next_ticket_number": session.NextTicketNumber + 1, "revision": revision, "updated_at": now}).Error; err != nil { return err } if err := s.addAudit(tx, r, &initial.ProjectID, &user.ID, "TICKET_REISSUED", "QUEUE_TICKET", &newTicket.ID, map[string]any{ "reissued_from_ticket_id": original.ID, "old_display_number": original.DisplayNumber, "new_display_number": newTicket.DisplayNumber, "revision": revision, }); err != nil { return err } ticketResponse, err := s.staffTicketView(newTicket) if err != nil { return err } ticketResponse["public_token"] = publicToken ticketResponse["public_url"] = "/visitor/" + publicToken response := map[string]any{ "ticket": ticketResponse, "public_token": publicToken, "status_path": "/api/public/status/" + publicToken, "revision": revision, } responseBody, err = marshalResponse(response) if err != nil { return err } return saveIdempotent(tx, initial.ProjectID, user.ID, "REISSUE_TICKET", idempotencyKey, hash, responseCode, responseBody, &newTicket.ID, now) }) if err != nil { writeError(w, err) return } if revision > 0 { s.hub.publish(queueEvent{ProjectID: initial.ProjectID, Revision: revision, Type: "ticket.reissued", At: s.now()}) } writeRawJSON(w, responseCode, responseBody) }