Initial commit
This commit is contained in:
179
server/internal/httpapi/reissue.go
Normal file
179
server/internal/httpapi/reissue.go
Normal file
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user