Files
makelore/electron/services/learning-runtime-bridge.ts
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

144 lines
5.4 KiB
TypeScript

import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getValidWorksSquareAccessToken } from './works-square-session';
import type {
LearningRuntimeBridgeEvent,
LearningRuntimeBridgeRequest,
LearningRuntimeCapability,
} from '../../shared/learning';
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
const CAPABILITIES = new Set<LearningRuntimeCapability>([
'quiz-grade',
'pbl/v2/task/update',
'pbl/v2/open-task',
'pbl/v2/simulator',
'pbl/v2/instructor',
'pbl/v2/evaluate',
]);
type Dependencies = {
fetchImpl?: typeof fetch;
getAccessToken?: typeof getValidWorksSquareAccessToken;
apiBaseUrl?: string;
};
function capabilityPath(capability: LearningRuntimeCapability): string {
return capability.split('/').map(encodeURIComponent).join('/');
}
function sanitizeAnchor(value: unknown): { sceneId?: string; sceneOrder?: number } | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const candidate = value as Record<string, unknown>;
const anchor = {
...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256
? { sceneId: candidate.sceneId }
: {}),
...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0
? { sceneOrder: Number(candidate.sceneOrder) }
: {}),
};
return Object.keys(anchor).length > 0 ? anchor : undefined;
}
function validateRequest(request: LearningRuntimeBridgeRequest): string {
const anchor = sanitizeAnchor(request.context.anchor);
if (!REQUEST_ID_PATTERN.test(request.requestId)
|| !COURSE_ID_PATTERN.test(request.courseId)
|| !SHA256_PATTERN.test(request.contentHash)
|| (request.context.moduleId !== null && !MODULE_ID_PATTERN.test(request.context.moduleId))
|| !SHA256_PATTERN.test(request.context.moduleContentHash)
|| !anchor?.sceneId
|| request.method !== 'POST'
|| !CAPABILITIES.has(request.capability)) {
throw new Error('课堂联网能力请求无效');
}
const body = JSON.stringify({
context: {
moduleId: request.context.moduleId,
moduleContentHash: request.context.moduleContentHash,
anchor: { sceneId: anchor.sceneId, ...(anchor.sceneOrder !== undefined ? { sceneOrder: anchor.sceneOrder } : {}) },
},
body: request.body ?? {},
});
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('课堂联网请求过大');
return body;
}
export function createLearningRuntimeBridge(dependencies: Dependencies = {}) {
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
async function request(
input: LearningRuntimeBridgeRequest,
emit: (event: LearningRuntimeBridgeEvent) => void,
): Promise<void> {
try {
const body = validateRequest(input);
const token = await getAccessToken({ fetchImpl });
if (!token) {
emit({ requestId: input.requestId, type: 'error', code: 'LEARNING_AUTH_REQUIRED', message: '请先登录后使用联网课堂能力' });
return;
}
const fetchRequest = (accessToken: string) => fetchImpl(
`${apiBaseUrl}/api/learning/courses/${encodeURIComponent(input.courseId)}/runtime/${capabilityPath(input.capability)}`,
{
method: 'POST',
headers: {
Accept: '*/*',
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Course-Content-Hash': input.contentHash,
'X-Content-Hash': input.contentHash,
},
body,
redirect: 'manual',
},
);
let response = await fetchRequest(token);
if (response.status === 401) {
await response.body?.cancel().catch(() => undefined);
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
if (refreshed) response = await fetchRequest(refreshed);
}
emit({
requestId: input.requestId,
type: 'start',
status: response.status,
contentType: response.headers.get('content-type') || 'application/octet-stream',
});
if (response.body) {
const reader = response.body.getReader();
let bytesRead = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytesRead += chunk.value.byteLength;
if (bytesRead > MAX_RESPONSE_BYTES) throw new Error('课堂联网响应过大');
emit({ requestId: input.requestId, type: 'chunk', chunk: new Uint8Array(chunk.value) });
}
} finally {
reader.releaseLock();
}
}
emit({ requestId: input.requestId, type: 'end' });
} catch (error) {
emit({
requestId: input.requestId,
type: 'error',
code: 'LEARNING_RUNTIME_UNAVAILABLE',
message: error instanceof Error ? error.message : '课堂联网能力暂时不可用',
});
}
}
return { request };
}