This commit is contained in:
2026-10-11 13:26:52 +07:00
parent a01fd5c452
commit bdeddf679c
5 changed files with 285 additions and 52 deletions
+35
View File
@@ -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;
}
+32 -1
View File
@@ -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<DownloadFileResult> {
return download(`api/games/${id}/results-package/download`, `game-results-${id}.zip`)
}
async function applyGameResultsPackage(id: number, file: File): Promise<GameResultsPackageRsp> {
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<GameImportRsp> {
const response = await request('api/games/import', {
method: 'POST',
@@ -106,6 +135,8 @@ function buildBinaryClient(
uploadScenarioArchive,
downloadGamePackage,
uploadGamePackage,
downloadGameResultsPackage,
applyGameResultsPackage,
}
}
@@ -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<GetTasksRsp>;
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
@@ -1280,6 +1345,9 @@ export interface EveningDetectiveServer {
GetGame(request: GetGameReq): Promise<GetGameRsp>;
// HTTP uploads are streamed by a dedicated handler, bypassing gRPC message limits.
ImportGame(request: googleapi_HttpBody): Promise<GameImportRsp>;
// Dedicated HTTP handlers stream ZIPs; gRPC retains its message size limit.
DownloadGameResultsPackage(request: GameResultsPackageReq): Promise<googleapi_HttpBody>;
ApplyGameResultsPackage(request: ApplyGameResultsPackageReq): Promise<GameResultsPackageRsp>;
StartGameExport(request: StartGameExportReq): Promise<GameExportRsp>;
GetGameExport(request: GetGameExportReq): Promise<GameExportRsp>;
IssueGameTickets(request: IssueGameTicketsReq): Promise<IssueGameTicketsRsp>;
@@ -2443,6 +2511,46 @@ export function createEveningDetectiveServerClient(
method: "ImportGame",
}) as Promise<GameImportRsp>;
},
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<googleapi_HttpBody>;
},
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<GameResultsPackageRsp>;
},
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)
+105
View File
@@ -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<UploadFileInfo[]>([])
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(() => {
<n-button @click="updateGame()">Сохранить изменения</n-button>
</n-flex>
</UiBlock>
<UiBlock v-if="canDownloadResults">
<n-button
:loading="isDownloadingResults"
:disabled="isDownloadingResults"
@click="downloadResults"
>
Скачать результаты
</n-button>
</UiBlock>
<UiBlock v-if="canExport">
<p class="screens-title">Возврат результатов с площадки</p>
<n-space vertical>
<n-alert type="warning">
Команды, билеты, результаты и игровой сценарий будут заменены данными пакета.
Онлайн-правки этих данных потеряются. Оформление и музыка сохраняются.
</n-alert>
<n-upload
:file-list="resultsFiles"
:default-upload="false"
:max="1"
accept=".zip"
:disabled="isApplyingResults"
@change="changeResultsFile"
>
<n-button :disabled="isApplyingResults">Выбрать ZIP результатов</n-button>
</n-upload>
<n-alert v-if="resultsError" type="error" role="alert">{{ resultsError }}</n-alert>
<n-button
:loading="isApplyingResults"
:disabled="isApplyingResults || !resultsFiles.length"
@click="applyResults"
>
Применить ZIP и перезаписать результаты
</n-button>
</n-space>
</UiBlock>
<UiBlock v-if="canExport">
<p class="screens-title">Передача игры на площадку</p>
<n-space vertical>
+5 -1
View File
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import GamePage from '@/components/GamePage.vue'
const pageVersion = ref(0)
</script>
<template>
<GamePage />
<GamePage :key="pageVersion" @results-applied="pageVersion++" />
</template>