feat: add live camera multi-device viewer mode
这个提交包含在:
@@ -6,23 +6,29 @@ export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
res: CreateExpressContextOptions["res"];
|
||||
user: User | null;
|
||||
sessionSid: string | null;
|
||||
};
|
||||
|
||||
export async function createContext(
|
||||
opts: CreateExpressContextOptions
|
||||
): Promise<TrpcContext> {
|
||||
let user: User | null = null;
|
||||
let sessionSid: string | null = null;
|
||||
|
||||
try {
|
||||
user = await sdk.authenticateRequest(opts.req);
|
||||
const authenticated = await sdk.authenticateRequestWithSession(opts.req);
|
||||
user = authenticated.user;
|
||||
sessionSid = authenticated.sid;
|
||||
} catch (error) {
|
||||
// Authentication is optional for public procedures.
|
||||
user = null;
|
||||
sessionSid = null;
|
||||
}
|
||||
|
||||
return {
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
user,
|
||||
sessionSid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,7 +260,11 @@ class SDKServer {
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<User> {
|
||||
// Regular authentication flow
|
||||
const authenticated = await this.authenticateRequestWithSession(req);
|
||||
return authenticated.user;
|
||||
}
|
||||
|
||||
async authenticateRequestWithSession(req: Request): Promise<{ user: User; sid: string | null }> {
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
const session = await this.verifySession(sessionCookie);
|
||||
@@ -273,7 +277,6 @@ class SDKServer {
|
||||
const signedInAt = new Date();
|
||||
let user = await db.getUserByOpenId(sessionUserId);
|
||||
|
||||
// If user not in DB, sync from OAuth server automatically
|
||||
if (!user) {
|
||||
try {
|
||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||
@@ -300,7 +303,10 @@ class SDKServer {
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
return {
|
||||
user,
|
||||
sid: session.sid ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
136
server/db.ts
136
server/db.ts
@@ -8,6 +8,7 @@ import {
|
||||
poseAnalyses, InsertPoseAnalysis,
|
||||
trainingRecords, InsertTrainingRecord,
|
||||
liveAnalysisSessions, InsertLiveAnalysisSession,
|
||||
liveAnalysisRuntime, InsertLiveAnalysisRuntime,
|
||||
liveActionSegments, InsertLiveActionSegment,
|
||||
dailyTrainingAggregates, InsertDailyTrainingAggregate,
|
||||
ratingHistory, InsertRatingHistory,
|
||||
@@ -32,6 +33,7 @@ import { fetchTutorialMetrics, shouldRefreshTutorialMetrics } from "./tutorialMe
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
const APP_TIMEZONE = process.env.TZ || "Asia/Shanghai";
|
||||
export const LIVE_ANALYSIS_RUNTIME_TIMEOUT_MS = 15_000;
|
||||
|
||||
function getDateFormatter() {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
@@ -888,6 +890,140 @@ export async function createLiveAnalysisSession(session: InsertLiveAnalysisSessi
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function getUserLiveAnalysisRuntime(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(liveAnalysisRuntime)
|
||||
.where(eq(liveAnalysisRuntime.userId, userId))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function upsertUserLiveAnalysisRuntime(
|
||||
userId: number,
|
||||
patch: Omit<InsertLiveAnalysisRuntime, "id" | "createdAt" | "updatedAt" | "userId">,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const existing = await getUserLiveAnalysisRuntime(userId);
|
||||
if (existing) {
|
||||
await db.update(liveAnalysisRuntime)
|
||||
.set({
|
||||
ownerSid: patch.ownerSid ?? existing.ownerSid,
|
||||
status: patch.status ?? existing.status,
|
||||
title: patch.title ?? existing.title,
|
||||
sessionMode: patch.sessionMode ?? existing.sessionMode,
|
||||
mediaSessionId: patch.mediaSessionId === undefined ? existing.mediaSessionId : patch.mediaSessionId,
|
||||
startedAt: patch.startedAt === undefined ? existing.startedAt : patch.startedAt,
|
||||
endedAt: patch.endedAt === undefined ? existing.endedAt : patch.endedAt,
|
||||
lastHeartbeatAt: patch.lastHeartbeatAt === undefined ? existing.lastHeartbeatAt : patch.lastHeartbeatAt,
|
||||
snapshot: patch.snapshot === undefined ? existing.snapshot : patch.snapshot,
|
||||
})
|
||||
.where(eq(liveAnalysisRuntime.userId, userId));
|
||||
return getUserLiveAnalysisRuntime(userId);
|
||||
}
|
||||
|
||||
const result = await db.insert(liveAnalysisRuntime).values({
|
||||
userId,
|
||||
ownerSid: patch.ownerSid ?? null,
|
||||
status: patch.status ?? "idle",
|
||||
title: patch.title ?? null,
|
||||
sessionMode: patch.sessionMode ?? "practice",
|
||||
mediaSessionId: patch.mediaSessionId ?? null,
|
||||
startedAt: patch.startedAt ?? null,
|
||||
endedAt: patch.endedAt ?? null,
|
||||
lastHeartbeatAt: patch.lastHeartbeatAt ?? null,
|
||||
snapshot: patch.snapshot ?? null,
|
||||
});
|
||||
|
||||
const runtimeId = result[0].insertId;
|
||||
const rows = await db.select().from(liveAnalysisRuntime).where(eq(liveAnalysisRuntime.id, runtimeId)).limit(1);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateUserLiveAnalysisRuntime(
|
||||
userId: number,
|
||||
patch: Partial<Omit<InsertLiveAnalysisRuntime, "id" | "createdAt" | "updatedAt" | "userId">>,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const existing = await getUserLiveAnalysisRuntime(userId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
await db.update(liveAnalysisRuntime)
|
||||
.set({
|
||||
ownerSid: patch.ownerSid === undefined ? existing.ownerSid : patch.ownerSid,
|
||||
status: patch.status ?? existing.status,
|
||||
title: patch.title === undefined ? existing.title : patch.title,
|
||||
sessionMode: patch.sessionMode ?? existing.sessionMode,
|
||||
mediaSessionId: patch.mediaSessionId === undefined ? existing.mediaSessionId : patch.mediaSessionId,
|
||||
startedAt: patch.startedAt === undefined ? existing.startedAt : patch.startedAt,
|
||||
endedAt: patch.endedAt === undefined ? existing.endedAt : patch.endedAt,
|
||||
lastHeartbeatAt: patch.lastHeartbeatAt === undefined ? existing.lastHeartbeatAt : patch.lastHeartbeatAt,
|
||||
snapshot: patch.snapshot === undefined ? existing.snapshot : patch.snapshot,
|
||||
})
|
||||
.where(eq(liveAnalysisRuntime.userId, userId));
|
||||
|
||||
return getUserLiveAnalysisRuntime(userId);
|
||||
}
|
||||
|
||||
export async function updateLiveAnalysisRuntimeHeartbeat(input: {
|
||||
userId: number;
|
||||
ownerSid: string;
|
||||
runtimeId: number;
|
||||
mediaSessionId?: string | null;
|
||||
snapshot?: unknown;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const existing = await getUserLiveAnalysisRuntime(input.userId);
|
||||
if (!existing || existing.id !== input.runtimeId || existing.ownerSid !== input.ownerSid || existing.status !== "active") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await db.update(liveAnalysisRuntime)
|
||||
.set({
|
||||
mediaSessionId: input.mediaSessionId === undefined ? existing.mediaSessionId : input.mediaSessionId,
|
||||
snapshot: input.snapshot === undefined ? existing.snapshot : input.snapshot,
|
||||
lastHeartbeatAt: new Date(),
|
||||
endedAt: null,
|
||||
})
|
||||
.where(and(
|
||||
eq(liveAnalysisRuntime.userId, input.userId),
|
||||
eq(liveAnalysisRuntime.id, input.runtimeId),
|
||||
));
|
||||
|
||||
return getUserLiveAnalysisRuntime(input.userId);
|
||||
}
|
||||
|
||||
export async function endUserLiveAnalysisRuntime(input: {
|
||||
userId: number;
|
||||
ownerSid?: string | null;
|
||||
runtimeId?: number;
|
||||
snapshot?: unknown;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const existing = await getUserLiveAnalysisRuntime(input.userId);
|
||||
if (!existing) return undefined;
|
||||
if (input.runtimeId != null && existing.id !== input.runtimeId) return undefined;
|
||||
if (input.ownerSid != null && existing.ownerSid !== input.ownerSid) return undefined;
|
||||
|
||||
await db.update(liveAnalysisRuntime)
|
||||
.set({
|
||||
status: "ended",
|
||||
mediaSessionId: null,
|
||||
endedAt: new Date(),
|
||||
snapshot: input.snapshot === undefined ? existing.snapshot : input.snapshot,
|
||||
})
|
||||
.where(eq(liveAnalysisRuntime.userId, input.userId));
|
||||
|
||||
return getUserLiveAnalysisRuntime(input.userId);
|
||||
}
|
||||
|
||||
export async function createLiveActionSegments(segments: InsertLiveActionSegment[]) {
|
||||
const db = await getDb();
|
||||
if (!db || segments.length === 0) return;
|
||||
|
||||
@@ -45,7 +45,7 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
function createMockContext(user: AuthenticatedUser | null = null, sessionSid = "test-session-sid"): {
|
||||
ctx: TrpcContext;
|
||||
clearedCookies: { name: string; options: Record<string, unknown> }[];
|
||||
setCookies: { name: string; value: string; options: Record<string, unknown> }[];
|
||||
@@ -56,6 +56,7 @@ function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
return {
|
||||
ctx: {
|
||||
user,
|
||||
sessionSid: user ? sessionSid : null,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
@@ -1296,6 +1297,161 @@ describe("analysis.liveSessionSave", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis.runtime", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("acquires owner mode when runtime is idle", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-owner");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(undefined);
|
||||
const upsertSpy = vi.spyOn(db, "upsertUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
id: 11,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: null,
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
});
|
||||
|
||||
expect(upsertSpy).toHaveBeenCalledWith(7, expect.objectContaining({
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
}));
|
||||
expect(result.role).toBe("owner");
|
||||
expect((result.runtimeSession as any)?.ownerSid).toBe("sid-owner");
|
||||
});
|
||||
|
||||
it("returns viewer mode when another session sid already holds the runtime", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const activeRuntime = {
|
||||
id: 15,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 练习",
|
||||
sessionMode: "pk",
|
||||
mediaSessionId: "media-sync-1",
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: { phase: "analyzing" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(activeRuntime as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "实时分析 练习",
|
||||
sessionMode: "pk",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("viewer");
|
||||
expect((result.runtimeSession as any)?.mediaSessionId).toBe("media-sync-1");
|
||||
});
|
||||
|
||||
it("keeps owner mode when the same sid reacquires the runtime", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-owner");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const activeRuntime = {
|
||||
id: 19,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "旧标题",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: "media-sync-2",
|
||||
startedAt: new Date("2026-03-16T00:00:00.000Z"),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: { phase: "analyzing" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(activeRuntime as any);
|
||||
const updateSpy = vi.spyOn(db, "updateUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
...activeRuntime,
|
||||
title: "新标题",
|
||||
} as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "新标题",
|
||||
sessionMode: "practice",
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(7, expect.objectContaining({
|
||||
ownerSid: "sid-owner",
|
||||
title: "新标题",
|
||||
status: "active",
|
||||
}));
|
||||
expect(result.role).toBe("owner");
|
||||
});
|
||||
|
||||
it("rejects heartbeat from a non-owner sid", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "updateLiveAnalysisRuntimeHeartbeat").mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(caller.analysis.runtimeHeartbeat({
|
||||
runtimeId: 20,
|
||||
mediaSessionId: "media-sync-3",
|
||||
snapshot: { phase: "analyzing" },
|
||||
})).rejects.toThrow("当前设备不是实时分析持有端");
|
||||
});
|
||||
|
||||
it("rejects release from a non-owner sid", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "endUserLiveAnalysisRuntime").mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
id: 23,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: "media-sync-4",
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await expect(caller.analysis.runtimeRelease({
|
||||
runtimeId: 23,
|
||||
snapshot: { phase: "failed" },
|
||||
})).rejects.toThrow("当前设备不是实时分析持有端");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rating.refreshMine", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -73,6 +73,67 @@ const trainingProfileUpdateSchema = z.object({
|
||||
assessmentNotes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
const liveRuntimeSnapshotSchema = z.object({
|
||||
phase: z.enum(["idle", "analyzing", "saving", "safe", "failed"]).optional(),
|
||||
startedAt: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
currentAction: z.string().optional(),
|
||||
rawAction: z.string().optional(),
|
||||
feedback: z.array(z.string()).optional(),
|
||||
liveScore: z.record(z.string(), z.number()).nullable().optional(),
|
||||
stabilityMeta: z.record(z.string(), z.any()).optional(),
|
||||
visibleSegments: z.number().optional(),
|
||||
unknownSegments: z.number().optional(),
|
||||
archivedVideoCount: z.number().optional(),
|
||||
recentSegments: z.array(z.object({
|
||||
actionType: z.string(),
|
||||
isUnknown: z.boolean().optional(),
|
||||
startMs: z.number(),
|
||||
endMs: z.number(),
|
||||
durationMs: z.number(),
|
||||
confidenceAvg: z.number().optional(),
|
||||
score: z.number().optional(),
|
||||
clipLabel: z.string().optional(),
|
||||
})).optional(),
|
||||
}).passthrough();
|
||||
|
||||
function getRuntimeOwnerSid(ctx: { sessionSid: string | null; user: { openId: string } }) {
|
||||
return ctx.sessionSid || `legacy:${ctx.user.openId}`;
|
||||
}
|
||||
|
||||
async function resolveLiveRuntimeRole(params: {
|
||||
userId: number;
|
||||
sessionSid: string;
|
||||
}) {
|
||||
let runtime = await db.getUserLiveAnalysisRuntime(params.userId);
|
||||
if (!runtime) {
|
||||
return { role: "idle" as const, runtimeSession: null };
|
||||
}
|
||||
|
||||
const heartbeatAt = runtime.lastHeartbeatAt ?? runtime.updatedAt ?? runtime.startedAt;
|
||||
const isStale =
|
||||
runtime.status === "active" &&
|
||||
(!heartbeatAt || (Date.now() - heartbeatAt.getTime()) > db.LIVE_ANALYSIS_RUNTIME_TIMEOUT_MS);
|
||||
|
||||
if (isStale) {
|
||||
runtime = await db.endUserLiveAnalysisRuntime({
|
||||
userId: params.userId,
|
||||
runtimeId: runtime.id,
|
||||
snapshot: runtime.snapshot,
|
||||
}) ?? null as any;
|
||||
return { role: "idle" as const, runtimeSession: null };
|
||||
}
|
||||
|
||||
if (runtime.status !== "active") {
|
||||
return { role: "idle" as const, runtimeSession: runtime };
|
||||
}
|
||||
|
||||
return {
|
||||
role: runtime.ownerSid === params.sessionSid ? "owner" as const : "viewer" as const,
|
||||
runtimeSession: runtime,
|
||||
};
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -455,6 +516,122 @@ export const appRouter = router({
|
||||
return { session, segments };
|
||||
}),
|
||||
|
||||
runtimeGet: protectedProcedure.query(async ({ ctx }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
return resolveLiveRuntimeRole({
|
||||
userId: ctx.user.id,
|
||||
sessionSid,
|
||||
});
|
||||
}),
|
||||
|
||||
runtimeAcquire: protectedProcedure
|
||||
.input(z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
sessionMode: z.enum(["practice", "pk"]).default("practice"),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const current = await resolveLiveRuntimeRole({
|
||||
userId: ctx.user.id,
|
||||
sessionSid,
|
||||
});
|
||||
|
||||
if (current.role === "viewer" && current.runtimeSession?.status === "active") {
|
||||
return current;
|
||||
}
|
||||
|
||||
const runtime = current.runtimeSession?.status === "active" && current.role === "owner"
|
||||
? await db.updateUserLiveAnalysisRuntime(ctx.user.id, {
|
||||
ownerSid: sessionSid,
|
||||
status: "active",
|
||||
title: input.title,
|
||||
sessionMode: input.sessionMode,
|
||||
startedAt: current.runtimeSession.startedAt ?? new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
})
|
||||
: await db.upsertUserLiveAnalysisRuntime(ctx.user.id, {
|
||||
ownerSid: sessionSid,
|
||||
status: "active",
|
||||
title: input.title,
|
||||
sessionMode: input.sessionMode,
|
||||
mediaSessionId: null,
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: {
|
||||
phase: "idle",
|
||||
startedAt: Date.now(),
|
||||
durationMs: 0,
|
||||
currentAction: "unknown",
|
||||
rawAction: "unknown",
|
||||
feedback: [],
|
||||
visibleSegments: 0,
|
||||
unknownSegments: 0,
|
||||
archivedVideoCount: 0,
|
||||
recentSegments: [],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
role: "owner" as const,
|
||||
runtimeSession: runtime ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
runtimeHeartbeat: protectedProcedure
|
||||
.input(z.object({
|
||||
runtimeId: z.number(),
|
||||
mediaSessionId: z.string().max(96).nullable().optional(),
|
||||
snapshot: liveRuntimeSnapshotSchema.optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const runtime = await db.updateLiveAnalysisRuntimeHeartbeat({
|
||||
userId: ctx.user.id,
|
||||
ownerSid: sessionSid,
|
||||
runtimeId: input.runtimeId,
|
||||
mediaSessionId: input.mediaSessionId,
|
||||
snapshot: input.snapshot,
|
||||
});
|
||||
|
||||
if (!runtime) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
|
||||
}
|
||||
|
||||
return {
|
||||
role: "owner" as const,
|
||||
runtimeSession: runtime,
|
||||
};
|
||||
}),
|
||||
|
||||
runtimeRelease: protectedProcedure
|
||||
.input(z.object({
|
||||
runtimeId: z.number().optional(),
|
||||
snapshot: liveRuntimeSnapshotSchema.optional(),
|
||||
}).optional())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const runtime = await db.endUserLiveAnalysisRuntime({
|
||||
userId: ctx.user.id,
|
||||
ownerSid: sessionSid,
|
||||
runtimeId: input?.runtimeId,
|
||||
snapshot: input?.snapshot,
|
||||
});
|
||||
|
||||
if (!runtime) {
|
||||
const current = await db.getUserLiveAnalysisRuntime(ctx.user.id);
|
||||
if (current?.status === "active" && current.ownerSid !== sessionSid) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
runtimeSession: runtime ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
// Generate AI correction suggestions
|
||||
getCorrections: protectedProcedure
|
||||
.input(z.object({
|
||||
|
||||
在新工单中引用
屏蔽一个用户