比较提交
3 次代码提交
13e59b8e8a
...
31bead3452
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
31bead3452 | ||
|
|
a5103685fb | ||
|
|
f9db6ef590 |
@@ -8,6 +8,27 @@ export type ChangeLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "2026.03.16-live-analysis-lock-hardening",
|
||||||
|
releaseDate: "2026-03-16",
|
||||||
|
repoVersion: "f9db6ef",
|
||||||
|
summary: "修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
|
||||||
|
features: [
|
||||||
|
"旧用户名登录 token 即使缺少 `sid`,现在也会按 token 本身派生唯一会话标识,不再把不同设备错误识别成同一持有端",
|
||||||
|
"同步观看模式新增 viewer 自动重试:当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会自动重连而不是一直黑屏",
|
||||||
|
"在线录制页接入实时分析占用锁;当其他设备正在 `/live-camera` 分析时,本页会禁止再次启动摄像头和录制",
|
||||||
|
"应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 502 的问题",
|
||||||
|
"线上 smoke 已确认 `https://te.hao.work/live-camera` 与 `/recorder` 都已切换到本次新构建,公开站点不再返回 502",
|
||||||
|
],
|
||||||
|
tests: [
|
||||||
|
"curl -I https://te.hao.work/",
|
||||||
|
"pnpm check",
|
||||||
|
"pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts",
|
||||||
|
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"viewer mode|viewer stream|recorder blocks\"",
|
||||||
|
"pnpm build",
|
||||||
|
"线上 smoke: H1 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "2026.03.16-live-analysis-runtime-migration",
|
version: "2026.03.16-live-analysis-runtime-migration",
|
||||||
releaseDate: "2026-03-16",
|
releaseDate: "2026-03-16",
|
||||||
|
|||||||
@@ -549,6 +549,7 @@ export default function LiveCamera() {
|
|||||||
const broadcastSessionIdRef = useRef<string | null>(null);
|
const broadcastSessionIdRef = useRef<string | null>(null);
|
||||||
const viewerPeerRef = useRef<RTCPeerConnection | null>(null);
|
const viewerPeerRef = useRef<RTCPeerConnection | null>(null);
|
||||||
const viewerSessionIdRef = useRef<string | null>(null);
|
const viewerSessionIdRef = useRef<string | null>(null);
|
||||||
|
const viewerRetryTimerRef = useRef<number>(0);
|
||||||
const runtimeIdRef = useRef<number | null>(null);
|
const runtimeIdRef = useRef<number | null>(null);
|
||||||
const heartbeatTimerRef = useRef<number>(0);
|
const heartbeatTimerRef = useRef<number>(0);
|
||||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
@@ -883,6 +884,10 @@ export default function LiveCamera() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeViewerPeer = useCallback(() => {
|
const closeViewerPeer = useCallback(() => {
|
||||||
|
if (viewerRetryTimerRef.current) {
|
||||||
|
window.clearTimeout(viewerRetryTimerRef.current);
|
||||||
|
viewerRetryTimerRef.current = 0;
|
||||||
|
}
|
||||||
viewerSessionIdRef.current = null;
|
viewerSessionIdRef.current = null;
|
||||||
if (viewerPeerRef.current) {
|
if (viewerPeerRef.current) {
|
||||||
viewerPeerRef.current.ontrack = null;
|
viewerPeerRef.current.ontrack = null;
|
||||||
@@ -1026,15 +1031,22 @@ export default function LiveCamera() {
|
|||||||
await peer.setLocalDescription(offer);
|
await peer.setLocalDescription(offer);
|
||||||
await waitForIceGathering(peer);
|
await waitForIceGathering(peer);
|
||||||
|
|
||||||
const answer = await signalMediaViewerSession(mediaSessionId, {
|
try {
|
||||||
sdp: peer.localDescription?.sdp || "",
|
const answer = await signalMediaViewerSession(mediaSessionId, {
|
||||||
type: peer.localDescription?.type || "offer",
|
sdp: peer.localDescription?.sdp || "",
|
||||||
});
|
type: peer.localDescription?.type || "offer",
|
||||||
|
});
|
||||||
|
|
||||||
await peer.setRemoteDescription({
|
await peer.setRemoteDescription({
|
||||||
type: answer.type as RTCSdpType,
|
type: answer.type as RTCSdpType,
|
||||||
sdp: answer.sdp,
|
sdp: answer.sdp,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (viewerPeerRef.current === peer) {
|
||||||
|
closeViewerPeer();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}, [closeViewerPeer]);
|
}, [closeViewerPeer]);
|
||||||
|
|
||||||
const stopCamera = useCallback(() => {
|
const stopCamera = useCallback(() => {
|
||||||
@@ -1087,11 +1099,27 @@ export default function LiveCamera() {
|
|||||||
|
|
||||||
void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => {
|
void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => {
|
||||||
const message = error?.message || "同步画面连接失败";
|
const message = error?.message || "同步画面连接失败";
|
||||||
if (!/409/.test(message)) {
|
if (/409|viewer stream not ready/i.test(message)) {
|
||||||
setViewerError(message);
|
setViewerError("持有端正在准备同步画面,正在自动重试...");
|
||||||
|
if (!viewerRetryTimerRef.current) {
|
||||||
|
viewerRetryTimerRef.current = window.setTimeout(() => {
|
||||||
|
viewerRetryTimerRef.current = 0;
|
||||||
|
void runtimeQuery.refetch();
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setViewerError(message);
|
||||||
});
|
});
|
||||||
}, [cameraActive, closeViewerPeer, runtimeRole, runtimeSession?.mediaSessionId, startViewerStream]);
|
}, [
|
||||||
|
cameraActive,
|
||||||
|
closeViewerPeer,
|
||||||
|
runtimeQuery.refetch,
|
||||||
|
runtimeQuery.dataUpdatedAt,
|
||||||
|
runtimeRole,
|
||||||
|
runtimeSession?.mediaSessionId,
|
||||||
|
startViewerStream,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ function summarizeActions(actionSummary: Record<ActionType, number>) {
|
|||||||
export default function Recorder() {
|
export default function Recorder() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const runtimeQuery = trpc.analysis.runtimeGet.useQuery(undefined, {
|
||||||
|
refetchInterval: 1000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
|
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setArchiveTaskId(data.taskId);
|
setArchiveTaskId(data.taskId);
|
||||||
@@ -262,6 +266,9 @@ export default function Recorder() {
|
|||||||
|
|
||||||
const mobile = useMemo(() => isMobileDevice(), []);
|
const mobile = useMemo(() => isMobileDevice(), []);
|
||||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||||
|
const runtimeRole = runtimeQuery.data?.role ?? "idle";
|
||||||
|
const liveAnalysisRuntime = runtimeQuery.data?.runtimeSession;
|
||||||
|
const liveAnalysisOccupied = runtimeRole === "viewer" && liveAnalysisRuntime?.status === "active";
|
||||||
const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
|
const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
|
||||||
const archiveTaskQuery = useBackgroundTask(archiveTaskId);
|
const archiveTaskQuery = useBackgroundTask(archiveTaskId);
|
||||||
const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession);
|
const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession);
|
||||||
@@ -402,6 +409,11 @@ export default function Recorder() {
|
|||||||
preferredZoom = zoomTargetRef.current,
|
preferredZoom = zoomTargetRef.current,
|
||||||
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
|
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
|
||||||
) => {
|
) => {
|
||||||
|
if (liveAnalysisOccupied) {
|
||||||
|
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
|
||||||
|
toast.error(`${title},当前设备不能再开启录制摄像头`);
|
||||||
|
throw new Error("当前账号已有其他设备正在实时分析");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (streamRef.current) {
|
if (streamRef.current) {
|
||||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
@@ -440,7 +452,7 @@ export default function Recorder() {
|
|||||||
toast.error(`摄像头启动失败: ${message}`);
|
toast.error(`摄像头启动失败: ${message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}), [facingMode, mobile, qualityPreset, syncZoomState]);
|
}), [facingMode, liveAnalysisOccupied, liveAnalysisRuntime?.title, mobile, qualityPreset, syncZoomState]);
|
||||||
|
|
||||||
const ensurePreviewStream = useCallback(async () => {
|
const ensurePreviewStream = useCallback(async () => {
|
||||||
if (streamRef.current) {
|
if (streamRef.current) {
|
||||||
@@ -849,6 +861,11 @@ export default function Recorder() {
|
|||||||
toast.error("请先登录后再开始录制");
|
toast.error("请先登录后再开始录制");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (liveAnalysisOccupied) {
|
||||||
|
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
|
||||||
|
toast.error(`${title},当前设备不能同时开始录制`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setMode("preparing");
|
setMode("preparing");
|
||||||
@@ -898,7 +915,21 @@ export default function Recorder() {
|
|||||||
setMode("idle");
|
setMode("idle");
|
||||||
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
|
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
|
||||||
}
|
}
|
||||||
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
|
}, [
|
||||||
|
ensurePreviewStream,
|
||||||
|
facingMode,
|
||||||
|
liveAnalysisOccupied,
|
||||||
|
liveAnalysisRuntime?.title,
|
||||||
|
mimeType,
|
||||||
|
mobile,
|
||||||
|
qualityPreset,
|
||||||
|
startActionSampling,
|
||||||
|
startRealtimePush,
|
||||||
|
startRecorderLoop,
|
||||||
|
syncSessionState,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
]);
|
||||||
|
|
||||||
const finishRecording = useCallback(async () => {
|
const finishRecording = useCallback(async () => {
|
||||||
const session = currentSessionRef.current;
|
const session = currentSessionRef.current;
|
||||||
@@ -1140,9 +1171,10 @@ export default function Recorder() {
|
|||||||
data-testid="recorder-start-camera-button"
|
data-testid="recorder-start-camera-button"
|
||||||
onClick={() => void startCamera()}
|
onClick={() => void startCamera()}
|
||||||
className={buttonClass()}
|
className={buttonClass()}
|
||||||
|
disabled={liveAnalysisOccupied}
|
||||||
>
|
>
|
||||||
<Camera className={iconClass} />
|
<Camera className={iconClass} />
|
||||||
{labelFor("启动摄像头", "启动")}
|
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "启动摄像头", liveAnalysisOccupied ? "占用" : "启动")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1150,9 +1182,10 @@ export default function Recorder() {
|
|||||||
data-testid="recorder-start-recording-button"
|
data-testid="recorder-start-recording-button"
|
||||||
onClick={() => void beginRecording()}
|
onClick={() => void beginRecording()}
|
||||||
className={buttonClass("record")}
|
className={buttonClass("record")}
|
||||||
|
disabled={liveAnalysisOccupied}
|
||||||
>
|
>
|
||||||
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
|
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
|
||||||
{labelFor("开始录制", "录制")}
|
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "开始录制", liveAnalysisOccupied ? "占用" : "录制")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
|
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
|
||||||
<VideoOff className={iconClass} />
|
<VideoOff className={iconClass} />
|
||||||
@@ -1362,6 +1395,23 @@ export default function Recorder() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{liveAnalysisOccupied ? (
|
||||||
|
<Alert className="border-amber-300/70 bg-amber-50 text-amber-950">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
<AlertTitle>当前账号已有其他设备正在实时分析</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{liveAnalysisRuntime?.title || "其他设备正在实时分析"},本页已禁止再次启动摄像头和录制,避免同账号多端同时占用镜头。
|
||||||
|
你可以前往
|
||||||
|
{" "}
|
||||||
|
<a href="/live-camera" className="font-medium underline underline-offset-4">
|
||||||
|
实时分析页
|
||||||
|
</a>
|
||||||
|
{" "}
|
||||||
|
查看同步画面与动作识别结果。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<Card className="overflow-hidden border-0 shadow-lg">
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Tennis Training Hub - 变更日志
|
# Tennis Training Hub - 变更日志
|
||||||
|
|
||||||
|
## 2026.03.16-live-analysis-lock-hardening (2026-03-16)
|
||||||
|
|
||||||
|
### 功能更新
|
||||||
|
|
||||||
|
- 修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题;缺少 `sid` 的旧 token 现在会按 token 本身派生唯一会话标识
|
||||||
|
- `/live-camera` 的同步观看模式新增自动重试;当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会继续重连,不再长时间停留在无画面状态
|
||||||
|
- `/recorder` 接入实时分析占用锁;其他设备正在实时分析时,本页会禁止再次启动摄像头和开始录制,并提示前往 `/live-camera` 查看同步画面
|
||||||
|
- 应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 `502`
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `curl -I https://te.hao.work/`
|
||||||
|
- `pnpm check`
|
||||||
|
- `pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts`
|
||||||
|
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"`
|
||||||
|
- `playwright-skill` 线上校验:登录 `H1` 后访问 `/changelog`,确认 `2026.03.16-live-analysis-lock-hardening` 与仓库版本 `f9db6ef` 已展示
|
||||||
|
- `pnpm build`
|
||||||
|
- Playwright 线上 smoke:`H1` 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放
|
||||||
|
|
||||||
|
### 线上 smoke
|
||||||
|
|
||||||
|
- `https://te.hao.work/` 已切换到本次新构建,不再返回 `502`
|
||||||
|
- 当前公开站点前端资源 revision:`assets/index-mi8CPCFI.js` 与 `assets/index-Cp_VJ8sf.css`
|
||||||
|
- 真实双端验证已通过:同账号 `H1` 手机端开始实时分析后,PC 端 `/live-camera` 进入同步观看模式且可拉起同步流,`/recorder` 页面会阻止再次占用摄像头
|
||||||
|
|
||||||
|
### 仓库版本
|
||||||
|
|
||||||
|
- `f9db6ef`
|
||||||
|
|
||||||
## 2026.03.16-live-analysis-runtime-migration (2026-03-16)
|
## 2026.03.16-live-analysis-runtime-migration (2026-03-16)
|
||||||
|
|
||||||
### 功能更新
|
### 功能更新
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, se
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { syncTutorialImages } from "../tutorialImages";
|
import { syncTutorialImages } from "../tutorialImages";
|
||||||
|
|
||||||
|
async function warmupApplicationData() {
|
||||||
|
const tasks: Array<{ label: string; run: () => Promise<unknown> }> = [
|
||||||
|
{ label: "seedTutorials", run: () => seedTutorials() },
|
||||||
|
{ label: "syncTutorialImages", run: () => syncTutorialImages() },
|
||||||
|
{ label: "seedVisionReferenceImages", run: () => seedVisionReferenceImages() },
|
||||||
|
{ label: "seedAchievementDefinitions", run: () => seedAchievementDefinitions() },
|
||||||
|
{ label: "seedAppSettings", run: () => seedAppSettings() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await task.run();
|
||||||
|
console.log(`[startup] ${task.label} finished in ${Date.now() - startedAt}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[startup] ${task.label} failed`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function scheduleDailyNtrpRefresh() {
|
async function scheduleDailyNtrpRefresh() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() !== 0 || now.getMinutes() > 5) {
|
if (now.getHours() !== 0 || now.getMinutes() > 5) {
|
||||||
@@ -64,12 +84,6 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
await seedTutorials();
|
|
||||||
await syncTutorialImages();
|
|
||||||
await seedVisionReferenceImages();
|
|
||||||
await seedAchievementDefinitions();
|
|
||||||
await seedAppSettings();
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
registerMediaProxy(app);
|
registerMediaProxy(app);
|
||||||
@@ -108,6 +122,7 @@ async function startServer() {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`Server running on http://localhost:${port}/`);
|
console.log(`Server running on http://localhost:${port}/`);
|
||||||
|
void warmupApplicationData();
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
57
server/_core/sdk.test.ts
普通文件
57
server/_core/sdk.test.ts
普通文件
@@ -0,0 +1,57 @@
|
|||||||
|
import { SignJWT } from "jose";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
async function loadSdkForTest() {
|
||||||
|
process.env.JWT_SECRET = "test-cookie-secret";
|
||||||
|
process.env.VITE_APP_ID = "test-app";
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const [{ sdk }, { ENV }] = await Promise.all([
|
||||||
|
import("./sdk"),
|
||||||
|
import("./env"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { sdk, ENV };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signLegacyToken(openId: string, appId: string, name: string) {
|
||||||
|
const secret = new TextEncoder().encode(process.env.JWT_SECRET || "");
|
||||||
|
return new SignJWT({
|
||||||
|
openId,
|
||||||
|
appId,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
|
.setExpirationTime(Math.floor((Date.now() + 60_000) / 1000))
|
||||||
|
.sign(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sdk.verifySession", () => {
|
||||||
|
it("derives a stable legacy sid when the token payload does not include sid", async () => {
|
||||||
|
const { sdk, ENV } = await loadSdkForTest();
|
||||||
|
const legacyToken = await signLegacyToken("username_H1_legacy", ENV.appId, "H1");
|
||||||
|
|
||||||
|
const session = await sdk.verifySession(legacyToken);
|
||||||
|
|
||||||
|
expect(session).not.toBeNull();
|
||||||
|
expect(session?.sid).toMatch(/^legacy-token:/);
|
||||||
|
expect(session?.sid).toHaveLength("legacy-token:".length + 32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives different legacy sid values for different legacy login tokens", async () => {
|
||||||
|
const firstLoad = await loadSdkForTest();
|
||||||
|
const tokenA = await signLegacyToken("username_H1_legacy", firstLoad.ENV.appId, "H1");
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const secondLoad = await loadSdkForTest();
|
||||||
|
const tokenB = await signLegacyToken("username_H1_legacy", secondLoad.ENV.appId, "H1-second");
|
||||||
|
|
||||||
|
const sessionA = await firstLoad.sdk.verifySession(tokenA);
|
||||||
|
const sessionB = await secondLoad.sdk.verifySession(tokenB);
|
||||||
|
|
||||||
|
expect(sessionA?.sid).toMatch(/^legacy-token:/);
|
||||||
|
expect(sessionB?.sid).toMatch(/^legacy-token:/);
|
||||||
|
expect(sessionA?.sid).not.toBe(sessionB?.sid);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from "axios";
|
|||||||
import { parse as parseCookieHeader } from "cookie";
|
import { parse as parseCookieHeader } from "cookie";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { User } from "../../drizzle/schema";
|
import type { User } from "../../drizzle/schema";
|
||||||
import * as db from "../db";
|
import * as db from "../db";
|
||||||
import { ENV } from "./env";
|
import { ENV } from "./env";
|
||||||
@@ -223,11 +224,15 @@ class SDKServer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const derivedSid = typeof sid === "string" && sid.length > 0
|
||||||
|
? sid
|
||||||
|
: `legacy-token:${createHash("sha256").update(cookieValue).digest("hex").slice(0, 32)}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openId,
|
openId,
|
||||||
appId,
|
appId,
|
||||||
name: typeof name === "string" ? name : undefined,
|
name: typeof name === "string" ? name : undefined,
|
||||||
sid: typeof sid === "string" ? sid : undefined,
|
sid: derivedSid,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Auth] Session verification failed", String(error));
|
console.warn("[Auth] Session verification failed", String(error));
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ test("live camera switches into viewer mode when another device already owns ana
|
|||||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("live camera retries viewer stream when owner track is not ready on first attempt", async ({ page }) => {
|
||||||
|
const state = await installAppMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
liveViewerMode: true,
|
||||||
|
viewerSignalConflictOnce: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/live-camera");
|
||||||
|
await expect(page.getByText("同步观看模式")).toBeVisible();
|
||||||
|
await expect.poll(() => state.viewerSignalConflictRemaining).toBe(0);
|
||||||
|
await expect.poll(() => state.mediaSession?.viewerCount ?? 0).toBe(1);
|
||||||
|
await expect(page.getByText(/同步观看中|重新同步/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
|
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
|
||||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||||
|
|
||||||
@@ -126,3 +140,11 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
|
|||||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||||
await expect(page.getByText("E2E 录制")).toBeVisible();
|
await expect(page.getByText("E2E 录制")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recorder blocks local camera when another device owns live analysis", async ({ page }) => {
|
||||||
|
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
|
||||||
|
|
||||||
|
await page.goto("/recorder");
|
||||||
|
await expect(page.getByText("当前账号已有其他设备正在实时分析")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("recorder-start-camera-button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ type MockAppState = {
|
|||||||
nextVideoId: number;
|
nextVideoId: number;
|
||||||
nextTaskId: number;
|
nextTaskId: number;
|
||||||
authMeNullResponsesAfterLogin: number;
|
authMeNullResponsesAfterLogin: number;
|
||||||
|
viewerSignalConflictRemaining: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trpcResult(json: unknown) {
|
function trpcResult(json: unknown) {
|
||||||
@@ -637,15 +638,24 @@ async function handleMedia(route: Route, state: MockAppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.endsWith("/signal")) {
|
if (path.endsWith("/viewer-signal")) {
|
||||||
state.mediaSession.status = "recording";
|
if (state.viewerSignalConflictRemaining > 0) {
|
||||||
await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
|
state.viewerSignalConflictRemaining -= 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "viewer stream not ready" }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
|
||||||
|
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.endsWith("/viewer-signal")) {
|
if (path.endsWith("/signal")) {
|
||||||
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
|
state.mediaSession.status = "recording";
|
||||||
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
|
await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +724,7 @@ export async function installAppMocks(
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
authMeNullResponsesAfterLogin?: number;
|
authMeNullResponsesAfterLogin?: number;
|
||||||
liveViewerMode?: boolean;
|
liveViewerMode?: boolean;
|
||||||
|
viewerSignalConflictOnce?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
|
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
|
||||||
@@ -817,6 +828,7 @@ export async function installAppMocks(
|
|||||||
nextVideoId: 100,
|
nextVideoId: 100,
|
||||||
nextTaskId: 1,
|
nextTaskId: 1,
|
||||||
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
||||||
|
viewerSignalConflictRemaining: options?.viewerSignalConflictOnce ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -921,9 +933,12 @@ export async function installAppMocks(
|
|||||||
localDescription: { type: string; sdp: string } | null = null;
|
localDescription: { type: string; sdp: string } | null = null;
|
||||||
remoteDescription: { type: string; sdp: string } | null = null;
|
remoteDescription: { type: string; sdp: string } | null = null;
|
||||||
onconnectionstatechange: (() => void) | null = null;
|
onconnectionstatechange: (() => void) | null = null;
|
||||||
|
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
|
||||||
|
|
||||||
addTrack() {}
|
addTrack() {}
|
||||||
|
|
||||||
|
addTransceiver() {}
|
||||||
|
|
||||||
async createOffer() {
|
async createOffer() {
|
||||||
return { type: "offer", sdp: "mock-offer" };
|
return { type: "offer", sdp: "mock-offer" };
|
||||||
}
|
}
|
||||||
@@ -937,6 +952,7 @@ export async function installAppMocks(
|
|||||||
async setRemoteDescription(description: { type: string; sdp: string }) {
|
async setRemoteDescription(description: { type: string; sdp: string }) {
|
||||||
this.remoteDescription = description;
|
this.remoteDescription = description;
|
||||||
this.connectionState = "connected";
|
this.connectionState = "connected";
|
||||||
|
this.ontrack?.({ streams: [new MediaStream()] });
|
||||||
this.onconnectionstatechange?.();
|
this.onconnectionstatechange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户