Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

View 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)
}