import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract" import { redis } from "./redis" export const userEventChannel = "user:events" /** * 站点配置变更广播。旧后端的 `utils/websocket.push_config_update`: * 超管改了配置,所有开着页面的人立刻生效,不必刷新。 * 这是全站广播,不分用户,所以是一个固定 topic 而不是 per-user。 */ export const configUpdateChannel = "config:updates" export const configTopic = "events:config" export async function publishConfigUpdate(key: string, value: unknown) { await redis.publish(configUpdateChannel, JSON.stringify({ type: "config_update", key, value })) } /** * 会话吊销广播。让还挂着的 WebSocket 立刻知道自己该下线了。 * * 两种作用域,**不能混**: * - `{ token }` 用户登出。只该断这一张会话 —— 同一个人在别的设备上是另一张会话, * 不该被牵连。 * - `{ userId }` 账号被禁用。所有设备都得断。禁用只改数据库的 isDisabled 列、 * 不动 Redis 里的会话,WebSocket 那边的会话巡检永远发现不了,只能靠这条主动通知。 */ export const sessionRevokedChannel = "session:revoked" export type SessionRevokedReason = "session-ended" | "account-disabled" export interface SessionRevoked { token?: string userId?: number reason: SessionRevokedReason } export async function publishSessionRevoked( target: { token: string } | { userId: number }, reason: SessionRevokedReason, ) { await redis.publish(sessionRevokedChannel, JSON.stringify({ ...target, reason })) } export function parseSessionRevoked(raw: string): SessionRevoked | null { try { const value = JSON.parse(raw) as SessionRevoked if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null return value } catch { return null } } interface UserEvent { userId: number data: FlowchartUpdate | Record } interface AchievementNotification { id: number name: string description: string icon: string rarity: string kind: "achievement" | "badge" } export function userEventTopic(userId: number) { return `events:user:${userId}` } export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) { await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) })) } export async function publishAchievementNotification( userId: number, achievements: AchievementNotification[], ) { if (!achievements.length) return await redis.publish(userEventChannel, JSON.stringify({ userId, data: { type: "achievement_unlocked", achievements }, })) } export function parseUserEvent(raw: string): UserEvent | null { try { const value = JSON.parse(raw) as UserEvent if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null return value } catch { return null } }