feat: add live camera multi-device viewer mode

这个提交包含在:
cryptocommuniums-afk
2026-03-16 16:39:14 +08:00
父节点 f0bbe4c82f
当前提交 4e4122d758
修改 15 个文件,包含 1523 行新增110 行删除

查看文件

@@ -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,
};
}
}

查看文件

@@ -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({