This commit is contained in:
2026-10-11 13:47:36 +07:00
parent bdeddf679c
commit e12ef3bde0
15 changed files with 486 additions and 10 deletions
+65 -8
View File
@@ -41,6 +41,23 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
};
service EveningDetectiveServer {
rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) {
option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Устройства";
summary: "Обновить присутствие подключения браузера";
description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.";
security: {};
};
}
rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) {
option (google.api.http) = { get: "/api/devices" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Устройства";
summary: "Получить устройства площадки";
description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.";
};
}
rpc GetTasks(GetTasksReq) returns (GetTasksRsp) {
option (google.api.http) = { get: "/api/tasks" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
@@ -638,8 +655,8 @@ service EveningDetectiveServer {
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-игры";
tags : "Игры";
summary : "Скачать свежий ZIP результатов завершённой venue-игры";
produces: "application/zip";
};
}
@@ -650,11 +667,11 @@ service EveningDetectiveServer {
body: "package"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Игры";
summary: "Применить ZIP результатов на исходной prod-игре";
tags : "Игры";
summary : "Применить ZIP результатов на исходной prod-игре";
description: "HTTP принимает сырой ZIP до 1 ГиБ, включая поток без Content-Length. Ошибки: 400 неверный пакет/игра, 413 размер, 409 конфликт, 503 временный отказ.";
consumes: "application/zip";
produces: "application/json";
consumes : "application/zip";
produces : "application/json";
};
}
@@ -2371,12 +2388,52 @@ message TaskRsp { DeveloperTask task = 1; }
message DeleteTaskRsp {}
message GameResultsPackageReq { int32 ID = 1; }
message ApplyGameResultsPackageReq {
int32 ID = 1;
int32 ID = 1;
google.api.HttpBody package = 2;
}
message GameResultsPackageRsp {
int32 gameID = 1;
int32 gameID = 1;
string packageID = 2;
string appliedAt = 3;
}
message DeviceHeartbeatReq {
string deviceID = 1;
string connectionID = 2;
optional int32 organizerGameID = 3;
optional int32 teamID = 4;
string password = 5;
string screenID = 6;
string accessToken = 7;
}
message DeviceHeartbeatRsp {
optional bool enabled = 1;
google.protobuf.Timestamp serverTime = 2;
}
message GetDevicesReq { optional int32 gameID = 1; }
message GetDevicesRsp {
repeated VenueDevice devices = 1;
google.protobuf.Timestamp serverTime = 2;
}
message VenueDevice {
string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3;
bool connected = 4;
repeated DeviceConnection connections = 5;
}
message DeviceConnection {
string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3;
string IP = 4;
string userAgent = 5;
bool connected = 6;
optional int32 gameID = 7;
optional int32 teamID = 8;
optional int32 screenID = 9;
string kind = 10; // browser, organizer, team, screen
}
+4
View File
@@ -2,6 +2,10 @@
import { NConfigProvider, NMessageProvider } from 'naive-ui'
import { darkTheme } from 'naive-ui'
import { RouterView } from 'vue-router'
import { useDevicePresence } from '@/composables/useDevicePresence'
useDevicePresence()
</script>
<template>
@@ -1281,7 +1281,54 @@ export type GameResultsPackageRsp = {
appliedAt: string | undefined;
};
export type DeviceHeartbeatReq = {
deviceID: string | undefined;
connectionID: string | undefined;
organizerGameID?: number;
teamID?: number;
password: string | undefined;
screenID: string | undefined;
accessToken: string | undefined;
};
export type DeviceHeartbeatRsp = {
enabled?: boolean;
serverTime: wellKnownTimestamp | undefined;
};
export type GetDevicesReq = {
gameID?: number;
};
export type GetDevicesRsp = {
devices: VenueDevice[] | undefined;
serverTime: wellKnownTimestamp | undefined;
};
export type VenueDevice = {
ID: string | undefined;
firstSeenAt: wellKnownTimestamp | undefined;
lastSeenAt: wellKnownTimestamp | undefined;
connected: boolean | undefined;
connections: DeviceConnection[] | undefined;
};
export type DeviceConnection = {
ID: string | undefined;
firstSeenAt: wellKnownTimestamp | undefined;
lastSeenAt: wellKnownTimestamp | undefined;
IP: string | undefined;
userAgent: string | undefined;
connected: boolean | undefined;
gameID?: number;
teamID?: number;
screenID?: number;
kind: string | undefined;
};
export interface EveningDetectiveServer {
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
GetTasks(request: GetTasksReq): Promise<GetTasksRsp>;
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
CreateTask(request: CreateTaskReq): Promise<TaskRsp>;
@@ -1417,6 +1464,43 @@ export function createEveningDetectiveServerClient(
handler: RequestHandler
): EveningDetectiveServer {
return {
DeviceHeartbeat(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/devices/heartbeat`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "DeviceHeartbeat",
}) as Promise<DeviceHeartbeatRsp>;
},
GetDevices(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/devices`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
if (request.gameID) {
queryParams.push(`gameID=${encodeURIComponent(request.gameID.toString())}`)
}
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetDevices",
}) as Promise<GetDevicesRsp>;
},
GetTasks(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/tasks`; // eslint-disable-line quotes
const body = null;
+2
View File
@@ -50,6 +50,7 @@ import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue'
import { useDeviceContext } from '@/composables/useDeviceContext'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
@@ -64,6 +65,7 @@ const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
useDeviceContext(() => ({ organizerGameID: Number(gameId) }))
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers'
function isGameTab(value: string): value is GameTab {
+4
View File
@@ -7,6 +7,7 @@ import { getAuthClient } from '@/api/auth_client'
import type { Game, ScreenState, TeamResult } from '@/api/generated/crabs/evening_detective_server'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { useDeviceContext } from '@/composables/useDeviceContext'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { applicationFileURL } from '@/utils/application'
import { formatGameDateTime } from '@/utils/datetime'
@@ -44,6 +45,9 @@ let appliedCommandVersion = 0
let timerTick: number | null = null
const bindingFlow = computed(() => Boolean(route.query.screenID && route.query.accessToken))
useDeviceContext(() => (screenId.value && accessToken.value && !bindingFlow.value
? { screenID: screenId.value, accessToken: accessToken.value }
: {}))
const qrValue = computed(() => {
if (!screenId.value || !accessToken.value) return ''
const url = new URL('/screen', window.location.origin)
+2
View File
@@ -15,6 +15,7 @@ import type {
import GameInputForm from '@/components/GameInputForm.vue'
import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue'
import { useDeviceContext } from '@/composables/useDeviceContext'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
@@ -26,6 +27,7 @@ const message = useMessage()
const route = useRoute()
const teamId = route.params.id
const password = route.query.password
useDeviceContext(() => ({ teamID: Number(teamId), password: typeof password === 'string' ? password : undefined }))
const newPlacesCount = ref(0)
const actions = ref<TeamAction[]>([])
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { createPinia } from 'pinia'
import { createServer } from 'vite'
import { createRenderer, nextTick } from 'vue'
const server = await createServer({
configFile: false,
root: fileURLToPath(new URL('../../..', import.meta.url)),
resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } },
server: { middlewareMode: true, hmr: false, ws: false },
})
const { useDevicePresence } = await server.ssrLoadModule('/src/composables/useDevicePresence.ts')
const { useDevicePresenceStore } = await server.ssrLoadModule('/src/stores/devicePresence.ts')
const originalFetch = globalThis.fetch
const originalWarn = console.warn
const originalClearInterval = globalThis.clearInterval
const descriptors = ['window', 'document', 'localStorage'].map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
] as const)
after(async () => {
globalThis.fetch = originalFetch
globalThis.clearInterval = originalClearInterval
console.warn = originalWarn
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor)
else Reflect.deleteProperty(globalThis, key)
}
await server.close()
})
const renderer = createRenderer({
patchProp() {}, insert() {}, remove() {},
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
setText() {}, setElementText() {}, parentNode: () => null, nextSibling: () => null,
})
const settle = async () => { await nextTick(); await new Promise(setImmediate) }
function mount() {
const callbacks = new Map<number, () => void>()
const windowEvents = Object.assign(new EventTarget(), {
setInterval(callback: () => void) { callbacks.set(1, callback); return 1 },
})
const documentEvents = Object.assign(new EventTarget(), { visibilityState: 'visible' })
const storage = new Map<string, string>([['accessToken', 'expired'], ['refreshToken', 'expired-refresh']])
Object.defineProperty(globalThis, 'window', { configurable: true, value: windowEvents })
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentEvents })
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
} })
globalThis.clearInterval = ((id: number) => callbacks.delete(id)) as typeof clearInterval
console.warn = () => {}
const pinia = createPinia()
const app = renderer.createApp({ setup() { useDevicePresence(); return () => null } })
app.use(pinia)
app.mount({})
return { app, callbacks, windowEvents, documentEvents, storage, store: useDevicePresenceStore(pinia) }
}
function deferredResponse() {
let resolve!: (response: Response) => void
const promise = new Promise<Response>((done) => { resolve = done })
return { promise, resolve }
}
const json = (enabled: boolean) => new Response(JSON.stringify({ enabled }), {
headers: { 'Content-Type': 'application/json' },
})
test('requests never overlap; latest context follows in-flight request; disabled stops polling', async () => {
const first = deferredResponse()
const requests: Array<Record<string, unknown>> = []
globalThis.fetch = async (_url, options) => {
requests.push(JSON.parse(String(options?.body)))
return requests.length === 1 ? first.promise : json(true)
}
const fixture = mount()
try {
assert.equal(requests.length, 1)
fixture.windowEvents.dispatchEvent(new Event('online'))
fixture.documentEvents.dispatchEvent(new Event('visibilitychange'))
fixture.callbacks.get(1)!()
const lease = fixture.store.claim()
lease.set({ teamID: 2, password: 'secret' })
await settle()
assert.equal(requests.length, 1)
first.resolve(json(true))
await settle()
assert.equal(requests.length, 2)
assert.equal(requests[1].teamID, 2)
assert.equal(requests[0].deviceID, requests[1].deviceID)
assert.equal(requests[0].connectionID, requests[1].connectionID)
globalThis.fetch = async () => json(false)
fixture.windowEvents.dispatchEvent(new Event('online'))
await settle()
assert.equal(fixture.callbacks.size, 0)
const stoppedAttempts = requests.length
globalThis.fetch = async () => { requests.push({}); return json(true) }
lease.clear()
fixture.windowEvents.dispatchEvent(new Event('online'))
await settle()
assert.equal(requests.length, stoppedAttempts)
} finally { fixture.app.unmount() }
})
test('public credential failure does not refresh or clear JWT and waits for next interval', async () => {
let attempts = 0
globalThis.fetch = async (url) => {
assert.match(String(url), /devices\/heartbeat$/)
attempts++
return new Response('{}', { status: 401 })
}
const fixture = mount()
try {
await settle()
fixture.store.claim().set({ screenID: '3', accessToken: 'bad' })
await settle()
assert.equal(attempts, 2)
assert.equal(fixture.storage.get('accessToken'), 'expired')
assert.equal(fixture.storage.get('refreshToken'), 'expired-refresh')
fixture.callbacks.get(1)!()
await settle()
assert.equal(attempts, 3)
} finally { fixture.app.unmount() }
})
+9
View File
@@ -0,0 +1,9 @@
import { onScopeDispose, watch } from 'vue'
import { type DeviceContext, useDevicePresenceStore } from '@/stores/devicePresence'
export function useDeviceContext(source: () => Partial<DeviceContext>) {
const lease = useDevicePresenceStore().claim()
watch(source, lease.set, { immediate: true })
onScopeDispose(lease.clear)
}
+67
View File
@@ -0,0 +1,67 @@
import { onMounted, onUnmounted, watch } from 'vue'
import { getAuthClient } from '@/api/auth_client'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useDevicePresenceStore } from '@/stores/devicePresence'
import { browserDeviceID, newConnectionID } from '@/utils/deviceID'
export function useDevicePresence() {
const authClient = getAuthClient()
const publicClient = buildClient()
const store = useDevicePresenceStore()
const deviceID = browserDeviceID()
const connectionID = newConnectionID()
let isEnabled = true
let isSending = false
let isDisposed = false
let shouldSendNext = false
async function heartbeat() {
if (!isEnabled || isDisposed) return
if (isSending) {
shouldSendNext = true
return
}
isSending = true
shouldSendNext = false
try {
const client = store.context.organizerGameID ? authClient : publicClient
const response = await client.DeviceHeartbeat({
deviceID,
connectionID,
organizerGameID: store.context.organizerGameID,
teamID: store.context.teamID,
password: store.context.password,
screenID: store.context.screenID,
accessToken: store.context.accessToken,
})
if (response.enabled === false) {
isEnabled = false
polling.stop()
}
} catch {
console.warn('Не удалось обновить присутствие устройства; повтор по интервалу')
shouldSendNext = false
} finally {
isSending = false
if (shouldSendNext) void heartbeat()
}
}
function visible() {
if (document.visibilityState === 'visible') void heartbeat()
}
const polling = useSimplePolling(() => void heartbeat(), 20_000)
watch(() => store.context, () => void heartbeat())
onMounted(() => {
document.addEventListener('visibilitychange', visible)
window.addEventListener('online', heartbeat)
})
onUnmounted(() => {
isDisposed = true
document.removeEventListener('visibilitychange', visible)
window.removeEventListener('online', heartbeat)
})
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createPinia, setActivePinia } from 'pinia'
import { useDevicePresenceStore } from '../devicePresence.ts'
test('page leases clear context and reject late updates after navigation', () => {
setActivePinia(createPinia())
const store = useDevicePresenceStore()
const player = store.claim()
player.set({ teamID: 2, password: 'secret' })
assert.equal(store.context.teamID, 2)
const screen = store.claim()
screen.set({ screenID: '3', accessToken: 'token' })
player.clear()
player.set({ teamID: 2, password: 'stale' })
assert.deepEqual(store.context, { screenID: '3', accessToken: 'token' })
screen.clear()
screen.set({ screenID: '3', accessToken: 'stale' })
assert.deepEqual(store.context, {})
})
+32
View File
@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { DeviceHeartbeatReq } from '@/api/generated/crabs/evening_detective_server'
export type DeviceContext = Pick<
DeviceHeartbeatReq,
'organizerGameID' | 'teamID' | 'password' | 'screenID' | 'accessToken'
>
export const useDevicePresenceStore = defineStore('devicePresence', () => {
const context = shallowRef<Partial<DeviceContext>>({})
let owner: symbol | undefined
function claim() {
const id = Symbol()
owner = id
context.value = {}
return {
set(value: Partial<DeviceContext>) {
if (owner === id) context.value = value
},
clear() {
if (owner !== id) return
owner = undefined
context.value = {}
},
}
}
return { context, claim }
})
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import { browserDeviceID, newConnectionID } from '../deviceID.ts'
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
after(() => {
if (original) Object.defineProperty(globalThis, 'localStorage', original)
else Reflect.deleteProperty(globalThis, 'localStorage')
})
test('device survives reload, connections differ and malformed storage is replaced', () => {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
const device = browserDeviceID()
assert.match(device, /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/)
assert.equal(browserDeviceID(), device)
assert.notEqual(newConnectionID(), newConnectionID())
values.set('evening-detective-device-ID', 'broken')
assert.notEqual(browserDeviceID(), 'broken')
})
test('unavailable storage falls back to a temporary UUID', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() { throw new Error('blocked') },
})
assert.match(browserDeviceID(), /^[\da-f-]{36}$/)
})
+23
View File
@@ -0,0 +1,23 @@
const deviceKey = 'evening-detective-device-ID'
const uuidPattern = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i
// getRandomValues also works on a venue served over local HTTP.
export function newConnectionID(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function browserDeviceID(): string {
const temporaryID = newConnectionID()
try {
const saved = localStorage.getItem(deviceKey)
if (saved && uuidPattern.test(saved)) return saved
localStorage.setItem(deviceKey, temporaryID)
} catch {
// Presence still works when browser storage is unavailable.
}
return temporaryID
}
+3 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import GamePage from '@/components/GamePage.vue'
const pageVersion = ref(0)
const route = useRoute()
</script>
<template>
<GamePage :key="pageVersion" @results-applied="pageVersion++" />
<GamePage :key="`${route.params.id}-${pageVersion}`" @results-applied="pageVersion++" />
</template>
+5 -1
View File
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import TeamStoryPage from '@/components/TeamStoryPage.vue'
const route = useRoute()
</script>
<template>
<TeamStoryPage />
<TeamStoryPage :key="route.fullPath" />
</template>