180 lines
6.9 KiB
Go
180 lines
6.9 KiB
Go
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, PartySize: original.PartySize,
|
|
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, "party_size": newTicket.PartySize, "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)
|
|
}
|