135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { HostApiContext } from '../context';
|
|
import { parseJsonBody, sendJson } from '../route-utils';
|
|
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
|
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
|
|
|
const LOCAL_ROOT = '/api/works/learning';
|
|
const UPSTREAM_ROOT = '/api/learning';
|
|
|
|
type Dependencies = {
|
|
fetchImpl?: typeof fetch;
|
|
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
|
apiBaseUrl?: string;
|
|
};
|
|
|
|
function isLearningPath(pathname: string): boolean {
|
|
return pathname === `${LOCAL_ROOT}/courses`
|
|
|| pathname === `${LOCAL_ROOT}/courses/mine`
|
|
|| pathname === `${LOCAL_ROOT}/progress`
|
|
|| /^\/api\/works\/learning\/courses\/[^/]+$/.test(pathname)
|
|
|| /^\/api\/works\/learning\/courses\/[^/]+\/progress$/.test(pathname)
|
|
|| pathname === `${LOCAL_ROOT}/generations`
|
|
|| /^\/api\/works\/learning\/generations\/[^/]+$/.test(pathname)
|
|
|| /^\/api\/works\/learning\/generations\/[^/]+\/(?:cancel|resume|finalize)$/.test(pathname);
|
|
}
|
|
|
|
function upstreamPath(pathname: string): string {
|
|
return `${UPSTREAM_ROOT}${pathname.slice(LOCAL_ROOT.length)}`;
|
|
}
|
|
|
|
function allowedQuery(url: URL): string {
|
|
const query = new URLSearchParams();
|
|
for (const key of ['limit', 'offset']) {
|
|
const value = url.searchParams.get(key);
|
|
if (value && /^\d{1,3}$/.test(value)) query.set(key, value);
|
|
}
|
|
const encoded = query.toString();
|
|
return encoded ? `?${encoded}` : '';
|
|
}
|
|
|
|
function errorFields(payload: unknown, status: number): { code: string; error: string } {
|
|
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
? payload as Record<string, unknown>
|
|
: {};
|
|
const detail = record.detail && typeof record.detail === 'object' && !Array.isArray(record.detail)
|
|
? record.detail as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
code: typeof detail.code === 'string' ? detail.code : `LEARNING_HTTP_${status}`,
|
|
error: typeof detail.message === 'string'
|
|
? detail.message
|
|
: typeof record.detail === 'string'
|
|
? record.detail
|
|
: typeof record.error === 'string'
|
|
? record.error
|
|
: '学习服务暂时不可用',
|
|
};
|
|
}
|
|
|
|
export function createLearningRouteHandler(dependencies: Dependencies = {}) {
|
|
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
|
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
|
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
|
|
|
return async function handleLearningRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
_ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (!isLearningPath(url.pathname)) return false;
|
|
const isProgressWrite = req.method === 'PUT'
|
|
&& /^\/api\/works\/learning\/courses\/[^/]+\/progress$/.test(url.pathname);
|
|
const isGenerationStart = req.method === 'POST' && url.pathname === `${LOCAL_ROOT}/generations`;
|
|
const isGenerationFinalize = req.method === 'POST'
|
|
&& /^\/api\/works\/learning\/generations\/[^/]+\/finalize$/.test(url.pathname);
|
|
const isGenerationControl = req.method === 'POST'
|
|
&& /^\/api\/works\/learning\/generations\/[^/]+\/(?:cancel|resume)$/.test(url.pathname);
|
|
if (req.method !== 'GET' && !isProgressWrite && !isGenerationStart && !isGenerationFinalize && !isGenerationControl) {
|
|
sendJson(res, 405, { success: false, code: 'LEARNING_METHOD_NOT_ALLOWED', error: '不支持的学习请求' });
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const token = await getAccessToken({ fetchImpl });
|
|
if (!token) {
|
|
sendJson(res, 401, { success: false, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' });
|
|
return true;
|
|
}
|
|
const body = isProgressWrite || isGenerationStart
|
|
? await parseJsonBody<unknown>(req)
|
|
: undefined;
|
|
const request = (accessToken: string) => fetchImpl(
|
|
`${apiBaseUrl}${upstreamPath(url.pathname)}${allowedQuery(url)}`,
|
|
{
|
|
method: req.method,
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: `Bearer ${accessToken}`,
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
redirect: 'manual',
|
|
},
|
|
);
|
|
|
|
let response = await request(token);
|
|
if (response.status === 401) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
|
if (refreshed) response = await request(refreshed);
|
|
}
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok || payload === null) {
|
|
const fields = errorFields(payload, response.status || 502);
|
|
sendJson(res, response.status || 502, { success: false, status: response.status, ...fields });
|
|
return true;
|
|
}
|
|
sendJson(res, response.status, { success: true, data: payload });
|
|
return true;
|
|
} catch (error) {
|
|
sendJson(res, 502, {
|
|
success: false,
|
|
status: 502,
|
|
code: 'LEARNING_UNAVAILABLE',
|
|
error: error instanceof Error ? error.message : '学习服务暂时不可用',
|
|
});
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
export const handleLearningRoutes = createLearningRouteHandler();
|