feat: integrate learning module
This commit is contained in:
143
electron/services/learning-runtime-bridge.ts
Normal file
143
electron/services/learning-runtime-bridge.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user