From bdeddf679c31bf14832310859b67ae6ee09c43e8 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 13:26:52 +0700 Subject: [PATCH] up --- proto/main.proto | 35 ++++ src/api/binary_client.ts | 33 +++- .../crabs/evening_detective_server/index.ts | 158 ++++++++++++------ src/components/GamePage.vue | 105 ++++++++++++ src/views/GameView.vue | 6 +- 5 files changed, 285 insertions(+), 52 deletions(-) diff --git a/proto/main.proto b/proto/main.proto index f573f36..c227cd9 100644 --- a/proto/main.proto +++ b/proto/main.proto @@ -634,6 +634,30 @@ service EveningDetectiveServer { }; } + // Dedicated HTTP handlers stream ZIPs; gRPC retains its message size limit. + rpc DownloadGameResultsPackage(GameResultsPackageReq) returns (google.api.HttpBody) { + option (google.api.http) = {get: "/api/games/{ID}/results-package/download"}; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Игры"; + summary: "Скачать свежий ZIP результатов завершённой venue-игры"; + produces: "application/zip"; + }; + } + + rpc ApplyGameResultsPackage(ApplyGameResultsPackageReq) returns (GameResultsPackageRsp) { + option (google.api.http) = { + post: "/api/games/{ID}/results-package/apply" + body: "package" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Игры"; + summary: "Применить ZIP результатов на исходной prod-игре"; + description: "HTTP принимает сырой ZIP до 1 ГиБ, включая поток без Content-Length. Ошибки: 400 неверный пакет/игра, 413 размер, 409 конфликт, 503 временный отказ."; + consumes: "application/zip"; + produces: "application/json"; + }; + } + rpc StartGameExport(StartGameExportReq) returns (GameExportRsp) { option (google.api.http) = { post: "/api/games/{ID}/export" @@ -2345,3 +2369,14 @@ message DeleteTaskReq { message TaskRsp { DeveloperTask task = 1; } message DeleteTaskRsp {} + +message GameResultsPackageReq { int32 ID = 1; } +message ApplyGameResultsPackageReq { + int32 ID = 1; + google.api.HttpBody package = 2; +} +message GameResultsPackageRsp { + int32 gameID = 1; + string packageID = 2; + string appliedAt = 3; +} diff --git a/src/api/binary_client.ts b/src/api/binary_client.ts index 64f60d1..a3a4f4d 100644 --- a/src/api/binary_client.ts +++ b/src/api/binary_client.ts @@ -1,4 +1,7 @@ -import type { GameImportRsp } from '@/api/generated/crabs/evening_detective_server' +import type { + GameImportRsp, + GameResultsPackageRsp, +} from '@/api/generated/crabs/evening_detective_server' import { useAuthStore } from '@/stores/auth' import { retryWithAuth } from './authRetry' @@ -62,6 +65,32 @@ function buildBinaryClient( return download(`api/games/${id}/export/download`, `game-${id}.zip`) } + function downloadGameResultsPackage(id: number): Promise { + return download(`api/games/${id}/results-package/download`, `game-results-${id}.zip`) + } + + async function applyGameResultsPackage(id: number, file: File): Promise { + const response = await request(`api/games/${id}/results-package/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/zip' }, + body: file, + }) + if (!response.ok) throw new Error(await gatewayErrorMessage(response)) + const data: unknown = await response.json() + if ( + !data || + typeof data !== 'object' || + !('gameID' in data) || + data.gameID !== id || + !('packageID' in data) || + typeof data.packageID !== 'string' || + !('appliedAt' in data) || + typeof data.appliedAt !== 'string' + ) + throw new Error('Некорректный ответ применения результатов') + return { gameID: data.gameID, packageID: data.packageID, appliedAt: data.appliedAt } + } + async function uploadGamePackage(file: File): Promise { const response = await request('api/games/import', { method: 'POST', @@ -106,6 +135,8 @@ function buildBinaryClient( uploadScenarioArchive, downloadGamePackage, uploadGamePackage, + downloadGameResultsPackage, + applyGameResultsPackage, } } diff --git a/src/api/generated/crabs/evening_detective_server/index.ts b/src/api/generated/crabs/evening_detective_server/index.ts index 740ef47..3cbd8cd 100644 --- a/src/api/generated/crabs/evening_detective_server/index.ts +++ b/src/api/generated/crabs/evening_detective_server/index.ts @@ -1216,6 +1216,71 @@ export type TaskRsp = { export type DeleteTaskRsp = { }; +export type GameResultsPackageReq = { + ID: number | undefined; +}; + +export type ApplyGameResultsPackageReq = { + ID: number | undefined; + package: googleapi_HttpBody | undefined; +}; + +// Message that represents an arbitrary HTTP body. It should only be used for +// payload formats that can't be represented as JSON, such as raw binary or +// an HTML page. +// This message can be used both in streaming and non-streaming API methods in +// the request as well as the response. +// It can be used as a top-level request field, which is convenient if one +// wants to extract parameters from either the URL or HTTP template into the +// request fields and also want access to the raw HTTP body. +// Example: +// message GetResourceRequest { +// // A unique request id. +// string request_id = 1; +// // The raw HTTP body is bound to this field. +// google.api.HttpBody http_body = 2; +// } +// service ResourceService { +// rpc GetResource(GetResourceRequest) +// returns (google.api.HttpBody); +// rpc UpdateResource(google.api.HttpBody) +// returns (google.protobuf.Empty); +// } +// Example with streaming methods: +// service CaldavService { +// rpc GetCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// rpc UpdateCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// } +// Use of this type only changes how the request and response bodies are +// handled, all other features will continue to work unchanged. +export type googleapi_HttpBody = { + // The HTTP Content-Type header value specifying the content type of the body. + contentType: string | undefined; + // The HTTP request/response body as raw binary. + data: string | undefined; + // Application specific response metadata. Must be set in the first response + // for streaming APIs. + extensions: wellKnownAny[] | undefined; +}; + +// If the Any contains a value that has a special JSON mapping, +// it will be converted as follows: +// {"@type": xxx, "value": yyy}. +// Otherwise, the value will be converted into a JSON object, +// and the "@type" field will be inserted to indicate the actual data type. +interface wellKnownAny { + "@type": string; + [key: string]: unknown; +} + +export type GameResultsPackageRsp = { + gameID: number | undefined; + packageID: string | undefined; + appliedAt: string | undefined; +}; + export interface EveningDetectiveServer { GetTasks(request: GetTasksReq): Promise; GetTaskAssignees(request: GetTaskAssigneesReq): Promise; @@ -1280,6 +1345,9 @@ export interface EveningDetectiveServer { GetGame(request: GetGameReq): Promise; // HTTP uploads are streamed by a dedicated handler, bypassing gRPC message limits. ImportGame(request: googleapi_HttpBody): Promise; + // Dedicated HTTP handlers stream ZIPs; gRPC retains its message size limit. + DownloadGameResultsPackage(request: GameResultsPackageReq): Promise; + ApplyGameResultsPackage(request: ApplyGameResultsPackageReq): Promise; StartGameExport(request: StartGameExportReq): Promise; GetGameExport(request: GetGameExportReq): Promise; IssueGameTickets(request: IssueGameTicketsReq): Promise; @@ -2443,6 +2511,46 @@ export function createEveningDetectiveServerClient( method: "ImportGame", }) as Promise; }, + DownloadGameResultsPackage(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.ID) { + throw new Error("missing required field request.ID"); + } + const path = `api/games/${request.ID}/results-package/download`; // eslint-disable-line quotes + const body = null; + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "GET", + body, + }, { + service: "EveningDetectiveServer", + method: "DownloadGameResultsPackage", + }) as Promise; + }, + ApplyGameResultsPackage(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.ID) { + throw new Error("missing required field request.ID"); + } + const path = `api/games/${request.ID}/results-package/apply`; // eslint-disable-line quotes + const body = JSON.stringify(request?.package ?? {}); + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "POST", + body, + }, { + service: "EveningDetectiveServer", + method: "ApplyGameResultsPackage", + }) as Promise; + }, StartGameExport(request) { // eslint-disable-line @typescript-eslint/no-unused-vars if (!request.ID) { throw new Error("missing required field request.ID"); @@ -3422,55 +3530,5 @@ export function createEveningDetectiveServerClient( }, }; } -// Message that represents an arbitrary HTTP body. It should only be used for -// payload formats that can't be represented as JSON, such as raw binary or -// an HTML page. -// This message can be used both in streaming and non-streaming API methods in -// the request as well as the response. -// It can be used as a top-level request field, which is convenient if one -// wants to extract parameters from either the URL or HTTP template into the -// request fields and also want access to the raw HTTP body. -// Example: -// message GetResourceRequest { -// // A unique request id. -// string request_id = 1; -// // The raw HTTP body is bound to this field. -// google.api.HttpBody http_body = 2; -// } -// service ResourceService { -// rpc GetResource(GetResourceRequest) -// returns (google.api.HttpBody); -// rpc UpdateResource(google.api.HttpBody) -// returns (google.protobuf.Empty); -// } -// Example with streaming methods: -// service CaldavService { -// rpc GetCalendar(stream google.api.HttpBody) -// returns (stream google.api.HttpBody); -// rpc UpdateCalendar(stream google.api.HttpBody) -// returns (stream google.api.HttpBody); -// } -// Use of this type only changes how the request and response bodies are -// handled, all other features will continue to work unchanged. -export type googleapi_HttpBody = { - // The HTTP Content-Type header value specifying the content type of the body. - contentType: string | undefined; - // The HTTP request/response body as raw binary. - data: string | undefined; - // Application specific response metadata. Must be set in the first response - // for streaming APIs. - extensions: wellKnownAny[] | undefined; -}; - -// If the Any contains a value that has a special JSON mapping, -// it will be converted as follows: -// {"@type": xxx, "value": yyy}. -// Otherwise, the value will be converted into a JSON object, -// and the "@type" field will be inserted to indicate the actual data type. -interface wellKnownAny { - "@type": string; - [key: string]: unknown; -} - // @@protoc_insertion_point(typescript-http-eof) diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 3234b7f..67e3ccc 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -15,6 +15,8 @@ import { NTabPane, NTabs, NText, + NUpload, + type UploadFileInfo, } from 'naive-ui' import { useMessage } from 'naive-ui' import { computed, ref } from 'vue' @@ -54,6 +56,8 @@ import { useUIStore } from '@/stores/ui' import { applicationFileType, applicationFileURL } from '@/utils/application' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' +const emit = defineEmits<{ resultsApplied: [] }>() + const client = getAuthClient() const binaryClient = getBinaryClient() const isDownloadingQR = ref(false) @@ -118,6 +122,16 @@ const canExport = computed( ) const isStartingExport = ref(false) const isDownloadingPackage = ref(false) +const canDownloadResults = computed( + () => + uiStore.environment === 'venue' && + game.value.status === 'finish' && + authStore.hasAnyRole(['admin', 'organizer', 'developer']), +) +const isDownloadingResults = ref(false) +const isApplyingResults = ref(false) +const resultsFiles = ref([]) +const resultsError = ref('') const participationOptions = [ { label: 'Ожидает ответа', value: 'pending' }, @@ -516,6 +530,60 @@ async function downloadPackage() { } } +function changeResultsFile({ fileList }: { fileList: UploadFileInfo[] }) { + if (isApplyingResults.value) return + resultsFiles.value = fileList.slice(-1) + resultsError.value = '' +} + +async function downloadResults() { + if (!canDownloadResults.value || isDownloadingResults.value) return + isDownloadingResults.value = true + try { + const { blob, filename } = await binaryClient.downloadGameResultsPackage(game.value.ID!) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось скачать результаты') + } finally { + isDownloadingResults.value = false + } +} + +async function applyResults() { + if (!canExport.value || isApplyingResults.value) return + const file = resultsFiles.value[0]?.file + if (!file || file.size === 0) { + resultsError.value = 'Выберите непустой ZIP-файл' + return + } + if (!file.name.toLowerCase().endsWith('.zip')) { + resultsError.value = 'Выберите файл с расширением .zip' + return + } + if (file.size > 1024 ** 3) { + resultsError.value = 'Размер пакета не должен превышать 1 ГиБ' + return + } + isApplyingResults.value = true + resultsError.value = '' + try { + await binaryClient.applyGameResultsPackage(game.value.ID!, file) + message.success('Результаты приняты') + emit('resultsApplied') + } catch (err) { + resultsError.value = err instanceof Error ? err.message : 'Не удалось применить результаты' + } finally { + isApplyingResults.value = false + } +} + async function downloadTeamQR() { if (isDownloadingQR.value || !game.value.teams?.length) return isDownloadingQR.value = true @@ -679,6 +747,7 @@ async function issueTickets() { } useSimplePolling(() => { + if (isApplyingResults.value) return getGame(Number(gameId)) getGameScreens() getGameMusic() @@ -905,6 +974,42 @@ useSimplePolling(() => { Сохранить изменения + + + Скачать результаты + + + +

Возврат результатов с площадки

+ + + Команды, билеты, результаты и игровой сценарий будут заменены данными пакета. + Онлайн-правки этих данных потеряются. Оформление и музыка сохраняются. + + + Выбрать ZIP результатов + + {{ resultsError }} + + Применить ZIP и перезаписать результаты + + +

Передача игры на площадку

diff --git a/src/views/GameView.vue b/src/views/GameView.vue index fc41143..497a727 100644 --- a/src/views/GameView.vue +++ b/src/views/GameView.vue @@ -1,7 +1,11 @@