Перейти к основному содержимому

GET /api/public/support/tickets/{id} — Моё обращение и переписка

Назначение

Возвращает заявителю карточку его обращения в Службу заботы ПИН и публичную переписку по нему (экран «Мои обращения» → карточка тикета): тема, номер SUP-NNN, статус/приоритет/категория, счётчики и тред сообщений «от старых к новым». В тред попадают только сообщения с Visibility = Public; внутренние заметки операторов (Internal) заявителю не отдаются на уровне запроса (INV-MSG-2, ticket-message.md). Вложения сообщений хранятся во внешнем storage/CDN — в ответе отдаются короткоживущие presigned-ссылки на скачивание. Метод только читает: агрегат TicketEntity не изменяется, события не публикуются.

Metadata

ПолеЗначение
Сервис/контекстsupport
API areapublic
Feature grouptickets
Статусdraft
Документdocs/06-services/support/api/public/get-my-ticket.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (заявитель — любой авторизованный пользователь, Bearer JWT)
Auth policy / Permissionsauthenticated; специальный permission не требуется (публичная область, доступ к собственному тикету) — не путать с support.tickets.view оператора (../../README.md)
Scope (RBAC)self (роль Службы заботы не требуется)
Record-levelтолько собственное обращение: предикат Ticket.RequesterUserId = actor.UserId; чужой тикет неотличим от несуществующего — единый SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration)
Tenant isolationне tenanted по tenant актора: заявитель вне PIN-tenant. support-сущности несут TenantId = PIN CompanyId (константа платформы, ADR-0052); фактический охват — record-level по RequesterUserId
Auditread собственных данных; audit-запись не создаётся. PII заявителя (его же ФИО/контакты) в карточку не выносится; Body/FileName в логи целиком не пишутся

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/public/support/tickets/{id}
Success status200 (в т.ч. пустой тред — Messages.Items = [])
Idempotency headerне применяется (GET — safe/idempotent по природе)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
RequesterUserIdidentity claims (sub)GuidДавалидный JWT — 401 до handler-аrecord-level ключ
TicketIdroute {id}GuidДа!= Guid.Empty; существование + принадлежность — шаг 3record-level по Ticket.RequesterUserId
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 50)1..100 (тред переписки — десятки строк)защита от unbounded

Сортировка треда — фиксированная серверная: по CreatedAt по возрастанию («старые → новые», tie-break по Id). Клиентские сортировки/курсоры не принимаются.

Модель ответа TicketDetailModel

Карточка обращения + постраничный тред сообщений (Items, TotalCount, Page, PageSize). Дома полей — ticket.md / ticket-message.md; ниже — только состав проекции (дельта), без переописания сущностей.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаTicketEntity.IdИдентификатор обращения.
NumberlongДа.NumberЧеловекочитаемый номер (SUP-NNN).
SubjectstringДа.SubjectТема обращения.
Categorystring (enum TicketCategory)Да.CategoryТематика. Enum строкой.
Prioritystring (enum TicketPriority)Да.PriorityПриоритет. Enum строкой.
Statusstring (enum TicketStatus)Да.StatusСтатус жизненного цикла (New/InWork/Resolved/Closed).
Channelstring (enum TicketChannel)Да.ChannelКанал поступления. Enum строкой.
RelatedDealIdGuid?Нет.RelatedDealIdСвязанная сделка (crm), если обращение по сделке.
RelatedPropertyObjectIdGuid?Нет.RelatedPropertyObjectIdСвязанный объект недвижимости (chessboard).
MessagesCountintДа.MessagesCountПолный счётчик тикета (включает Internal; тред ниже — только Public).
LastMessageAtDateTimeOffset?Нет.LastMessageAtМомент последнего сообщения (любой видимости).
CreatedAtDateTimeOffsetДа.CreatedAtСоздание обращения.
ResolvedAtDateTimeOffset?Нет.ResolvedAtМомент перехода в Resolved.
ClosedAtDateTimeOffset?Нет.ClosedAtМомент перехода в Closed.
Messagesпостраничный список TicketMessageModelДапубличный тред (шаг 3)Страница публичного треда (Items = [] — сообщений ещё нет).

Внутренние поля наружу не проецируются: TenantId, AssigneeUserId, QueueId, RequesterUserId/ RequesterName/RequesterPhone/RequesterEmail (собственные данные заявителя дублировать не нужно), SLA-поля (FirstResponseDueAt/ResolutionDueAt/SlaBreached), эскалация и audit-поля.

Модель TicketMessageModel (строка треда)

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаTicketMessageEntity.IdИдентификатор сообщения.
AuthorKindstring (enum TicketMessageAuthorKind)Да.AuthorKindRequester / Operator / System. AuthorUserId наружу не проецируется.
BodystringДа.Bodyне логируется целиком (PII)Текст сообщения.
CreatedAtDateTimeOffsetДа.CreatedAtМомент отправки (ключ сортировки asc).
AttachmentsTicketMessageAttachmentsModel?Нетowned jsonb .Attachments + presign (шаг 4)Вложения с presigned-ссылками; null — вложений нет.

Visibility в проекцию не выводится: тред по определению содержит только Public (фильтр на уровне запроса).

Модель TicketMessageAttachmentsModel (container)

ПолеТипОбязательностьИсточникОписание
ItemsIReadOnlyList<TicketMessageAttachmentModel>ДаTicketMessageAttachments.Items (ticket-message.md)Список вложений (пустой = нет вложений).

Модель TicketMessageAttachmentModel (элемент Items)

ПолеТипОбязательностьИсточникMaskingОписание
FileIdGuidДаTicketAttachment.FileIdИдентификатор бинаря в storage/CDN.
FileNamestringДа.FileNameне логируется целиком (PII)Имя файла для сохранения.
ContentTypestringДа.ContentTypeMIME-тип.
SizeByteslongДа.SizeBytesРазмер файла (> 0, ≤ 25 МБ).
Urlstring?Нетstorage presign (TTL 5 мин)не логируется (содержит подпись)Presigned-ссылка; null — storage временно недоступен (best-effort деградация, метаданные остаются).
ExpiresAtDateTimeOffset?НетstorageИстечение ссылки; null при деградации.

Алгоритм

  1. Контекст и доступ. Операцию выполняет авторизованный заявитель (actor User, RequesterUserId из claim sub; без валидного JWT → 401). TicketId — из маршрута {id}, Page/PageSize — из query. Специальный permission не требуется; доступ — record-level (только своё обращение).
  2. Проверка входа.
    • синтаксис/диапазоны: TicketId не пустой, Page >= 1, PageSize ∈ 1..100; нарушение → VALIDATION_FAILED;
    • существование + принадлежность (совмещены с чтением карточки, шаг 3): обращение должно существовать и принадлежать заявителю (RequesterUserId == actor); отсутствующий, чужой или удалённый тикет неотличимы → SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration);
    • допустимость состояния не проверяется: карточка читается в любом TicketStatus, включая терминальный Closed (история обращения доступна всегда).
  3. Что читаем.
    • (а) карточка обращения — по Id с record-level-предикатом (RequesterUserId == actor, не удалённое); отсутствие → 404;
    • (б) публичный тред страницей: сообщения обращения с Visibility == Public и не удалённые, отсортированные по CreatedAt по возрастанию, страницей Page/PageSize; TotalCount — по тому же предикату. Партиальный индекс ix_ticket_messages_public (ticket_id, created_at) покрывает и предикат, и порядок. Internal-заметки исключаются на уровне запроса (defense-in-depth), а не после выборки. Порядок: сначала карточка (record-level guard решает, отдавать ли тред вообще).
  4. Presign вложений. FileId всех вложений страницы собираются в один батч-запрос presigned-ссылок к storage/CDN (TTL 5 минут; timeout 5 с, без retry); в ответ проставляются Url/ExpiresAt, а FileName/ContentType/SizeBytes берутся из вложений сообщения (TicketMessageAttachments, ContractVersion = 1). Сбой storage → Url/ExpiresAt = null для затронутых вложений (best-effort, тред всё равно отдаётся 200).
  5. Результат. Возвращается TicketDetailModel с постраничным тредом Messages → HTTP 200. Пустой тред → 200 c Messages.Items = [], Messages.TotalCount = 0 (не 404). Отсутствие/чужой/удалённый тикет → SUPPORT_TICKET_NOT_FOUND (404). Свежесть: карточка и тред — read-your-writes (основная БД); presigned-ссылки — eventual/деградируемы.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (read-only query)
External callsstorage/CDN GetDownloadFileLinks(fileIds, ttl) — timeout 5 с, без retry, best-effort (сбой → Url = null)
Cache / projections / searchnone (карточка и тред читаются query-time; source of truth — support.tickets / support.ticket_messages)
Notificationsnone (уведомление заявителя о новом ответе доставляет notifications по pin.support.ticket-status-changed.v1../../events/ticket-status-changed.md, не этот метод)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400TicketId = Guid.Empty, Page/PageSize вне границ«Проверьте параметры запроса»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»re-login
SUPPORT_TICKET_NOT_FOUND404тикета нет / принадлежит другому заявителю (RequesterUserId != actor) / soft-deleted«Обращение не найдено»no retry
— (деградация, не ошибка)200storage недоступен/timeout при presignвложения с Url = null (метаданные сохранены)клиент может перезапросить

Acceptance criteria

  • AC-1 (happy path). Given своё обращение с публичным тредом; When GET .../tickets/{id}; Then 200: карточка обращения (тема/номер/статус/приоритет/категория/счётчики) + страница публичных сообщений, отсортированных по CreatedAt по возрастанию, с presigned-ссылками вложений (TTL 5 мин).
  • AC-2 (пустой тред). Given обращение без публичных сообщений; When запрос; Then 200 с Messages.Items = [], Messages.TotalCount = 0 (не 404).
  • AC-3 (internal-заметки скрыты). Given обращение с внутренними заметками оператора (Visibility = Internal); When запрос; Then в тред попадают только Public-сообщения; при этом MessagesCount в карточке — полный счётчик тикета (включает Internal), а тред — только публичный срез.
  • AC-4 (чужое/несуществующее/удалённое — privacy). Given TicketId чужого заявителя, несуществующий или soft-deleted; When запрос; Then SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration).
  • AC-5 (история доступна в любом статусе). Given своё обращение в Closed; When запрос; Then 200 — карточка и тред читаются в терминальном статусе (история всегда доступна).
  • AC-6 (деградация storage). Given storage/CDN недоступен/timeout при presign; When запрос; Then 200: тред отдаётся, у затронутых вложений Url = null/ExpiresAt = null, метаданные (FileName/ContentType/SizeBytes) сохранены — не ошибка.
  • AC-7 (валидация/доступ). Given Page < 1/PageSize ∉ 1..100VALIDATION_FAILED (400); Given отсутствует/просрочен JWT → UNAUTHORIZED (401).

Совместимость и наблюдаемость

  • Новые optional поля карточки/сообщения и additive-поля вложений (ContractVersion = 1, дом — ticket-message.md) — без breaking change; клиент обязан игнорировать неизвестные значения enum TicketStatus/TicketPriority/TicketCategory/TicketChannel/ TicketMessageAuthorKind (forward-compatible, enum строкой). Удаление/переименование/смена типа поля — breaking (migration + rollout).
  • Logs: my-ticket-viewed (ticketId, requesterUserId, messagesReturned — без Body/FileName/PII); metrics: support_public_operations_total{operation="get-my-ticket",result}, latency p95 < 1 c (SLO списков, ../../README.md), support_storage_presign_total{result}; traces: HTTP-span → запрос к хранилищу → запрос к storage; correlation через OTel и traceparent; dashboard pin-support.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы