144 lines
5.4 KiB
TypeScript
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 };
|
|
}
|