1
This commit is contained in:
97
server-node/src/services/captchaChallengeStore.ts
Normal file
97
server-node/src/services/captchaChallengeStore.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { AuthCaptchaChallenge } from '../../../packages/shared/src/contracts/auth.js';
|
||||
|
||||
type CaptchaChallengeRecord = {
|
||||
challengeId: string;
|
||||
scopeKey: string;
|
||||
answer: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
imageDataUrl: string;
|
||||
};
|
||||
|
||||
function buildCaptchaSvgDataUrl(text: string) {
|
||||
const lines = Array.from({ length: 4 }, (_, index) => {
|
||||
const x1 = 8 + index * 18;
|
||||
const x2 = 150 - index * 16;
|
||||
const y1 = 12 + index * 8;
|
||||
const y2 = 46 - index * 6;
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="rgba(245,158,11,0.38)" stroke-width="1.4" />`;
|
||||
}).join('');
|
||||
|
||||
const noise = Array.from(text).map((char, index) => {
|
||||
const x = 24 + index * 24;
|
||||
const y = 30 + ((index % 2) * 6 - 3);
|
||||
const rotate = index % 2 === 0 ? -8 : 7;
|
||||
return `<text x="${x}" y="${y}" fill="#f8fafc" font-size="22" font-family="monospace" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
|
||||
}).join('');
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="56" viewBox="0 0 160 56">
|
||||
<rect width="160" height="56" rx="14" fill="#11131a"/>
|
||||
<rect x="1" y="1" width="158" height="54" rx="13" fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
${lines}
|
||||
${noise}
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function normalizeCaptchaAnswer(answer: string) {
|
||||
return answer.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildCaptchaText() {
|
||||
return crypto.randomBytes(3).toString('hex').slice(0, 5).toUpperCase();
|
||||
}
|
||||
|
||||
export class CaptchaChallengeStore {
|
||||
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
|
||||
|
||||
create(scopeKey: string, expiresInSeconds: number): AuthCaptchaChallenge {
|
||||
const text = buildCaptchaText();
|
||||
const challengeId = `captcha_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + expiresInSeconds * 1000);
|
||||
|
||||
this.challenges.set(challengeId, {
|
||||
challengeId,
|
||||
scopeKey,
|
||||
answer: normalizeCaptchaAnswer(text),
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
imageDataUrl: buildCaptchaSvgDataUrl(text),
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId,
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: buildCaptchaSvgDataUrl(text),
|
||||
expiresInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
verify(params: {
|
||||
challengeId: string;
|
||||
scopeKey: string;
|
||||
answer: string;
|
||||
}) {
|
||||
const record = this.challenges.get(params.challengeId);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
if (record.scopeKey !== params.scopeKey) {
|
||||
this.challenges.delete(params.challengeId);
|
||||
return false;
|
||||
}
|
||||
if (new Date(record.expiresAt).getTime() <= Date.now()) {
|
||||
this.challenges.delete(params.challengeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isValid =
|
||||
record.answer === normalizeCaptchaAnswer(params.answer);
|
||||
this.challenges.delete(params.challengeId);
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const plainTextRequestSchema = z.object({
|
||||
systemPrompt: z.string().trim().min(1),
|
||||
userPrompt: z.string().trim().min(1),
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const baseCharacterChatSchema = z.object({
|
||||
worldType: z.string().trim().min(1),
|
||||
playerCharacter: jsonObjectSchema,
|
||||
targetCharacter: jsonObjectSchema,
|
||||
storyHistory: z.array(jsonObjectSchema).default([]),
|
||||
context: jsonObjectSchema,
|
||||
conversationHistory: z.array(jsonObjectSchema).default([]),
|
||||
targetStatus: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const baseNpcChatSchema = z.object({
|
||||
worldType: z.string().trim().min(1),
|
||||
character: jsonObjectSchema,
|
||||
encounter: jsonObjectSchema,
|
||||
monsters: z.array(jsonObjectSchema).default([]),
|
||||
history: z.array(jsonObjectSchema).default([]),
|
||||
context: jsonObjectSchema,
|
||||
});
|
||||
|
||||
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
|
||||
conversationSummary: z.string().optional().default(''),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
}) satisfies z.ZodType<CharacterChatReplyRequest>;
|
||||
|
||||
export const characterChatSuggestionsRequestSchema =
|
||||
baseCharacterChatSchema.extend({
|
||||
conversationSummary: z.string().optional().default(''),
|
||||
}) satisfies z.ZodType<CharacterChatSuggestionsRequest>;
|
||||
|
||||
export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend(
|
||||
{
|
||||
previousSummary: z.string().optional().default(''),
|
||||
},
|
||||
) satisfies z.ZodType<CharacterChatSummaryRequest>;
|
||||
|
||||
export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||
topic: z.string().trim().min(1),
|
||||
resultSummary: z.string().optional().default(''),
|
||||
}) satisfies z.ZodType<NpcChatDialogueRequest>;
|
||||
|
||||
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||
invitationText: z.string().trim().min(1),
|
||||
recruitSummary: z.string().optional().default(''),
|
||||
}) satisfies z.ZodType<NpcRecruitDialogueRequest>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../../../src/services/ai.js';
|
||||
generateCustomWorldProfileFromOrchestrator,
|
||||
} from '../modules/ai/customWorldOrchestrator.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function generateCustomWorldProfile(
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromAi(input, {
|
||||
const profile = await generateCustomWorldProfileFromOrchestrator(input, {
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export type CustomWorldSessionStatus =
|
||||
| 'clarifying'
|
||||
| 'ready_to_generate'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'generation_error';
|
||||
|
||||
export type CustomWorldQuestion = {
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
};
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
|
||||
import type {
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldQuestion,
|
||||
CustomWorldSessionStatus,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
creatorIntent: JsonObject | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: Record<string, unknown>;
|
||||
result?: JsonObject;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -38,7 +31,7 @@ function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
creatorIntent: JsonObject | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
@@ -91,8 +84,8 @@ export class CustomWorldSessionStore {
|
||||
create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
generationMode: 'fast' | 'full',
|
||||
creatorIntent: JsonObject | null,
|
||||
generationMode: CustomWorldGenerationMode,
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
@@ -159,7 +152,7 @@ export class CustomWorldSessionStore {
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
|
||||
setResult(userId: string, sessionId: string, result: JsonObject) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
@@ -167,7 +160,7 @@ export class CustomWorldSessionStore {
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { Response as ExpressResponse } from 'express';
|
||||
import type {
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { upstreamError } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
import { HttpError, upstreamError } from '../errors.js';
|
||||
import {
|
||||
extractApiErrorMessage,
|
||||
prepareApiResponse,
|
||||
prepareEventStreamResponse,
|
||||
} from '../http.js';
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -18,6 +25,14 @@ type CompletionRequest = {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
type RequestExecutionOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.replace(/\/+$/u, '');
|
||||
}
|
||||
@@ -26,11 +41,69 @@ function buildCompletionUrl(baseUrl: string) {
|
||||
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
|
||||
}
|
||||
|
||||
function isAbortLikeError(error: unknown) {
|
||||
return (
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError') ||
|
||||
(error instanceof Error && error.name === 'AbortError')
|
||||
);
|
||||
}
|
||||
|
||||
function readTimeoutMs(config: AppConfig) {
|
||||
const parsed = Number(config.rawEnv.LLM_REQUEST_TIMEOUT_MS);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? Math.round(parsed)
|
||||
: DEFAULT_LLM_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export class UpstreamLlmTimeoutError extends HttpError {
|
||||
constructor(message = 'LLM 上游请求超时') {
|
||||
super(502, message, {
|
||||
code: 'UPSTREAM_TIMEOUT',
|
||||
});
|
||||
this.name = 'UpstreamLlmTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UpstreamLlmConnectivityError extends HttpError {
|
||||
constructor(message = '无法连接 LLM 上游服务') {
|
||||
super(502, message, {
|
||||
code: 'UPSTREAM_CONNECTIVITY',
|
||||
});
|
||||
this.name = 'UpstreamLlmConnectivityError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isUpstreamLlmTimeoutError(
|
||||
error: unknown,
|
||||
): error is UpstreamLlmTimeoutError {
|
||||
return (
|
||||
error instanceof UpstreamLlmTimeoutError ||
|
||||
(error instanceof HttpError && error.code === 'UPSTREAM_TIMEOUT')
|
||||
);
|
||||
}
|
||||
|
||||
export function isUpstreamLlmConnectivityError(
|
||||
error: unknown,
|
||||
): error is UpstreamLlmConnectivityError {
|
||||
return (
|
||||
error instanceof UpstreamLlmConnectivityError ||
|
||||
(error instanceof HttpError && error.code === 'UPSTREAM_CONNECTIVITY')
|
||||
);
|
||||
}
|
||||
|
||||
export class UpstreamLlmClient {
|
||||
readonly logger: Logger;
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.requestTimeoutMs = readTimeoutMs(config);
|
||||
}
|
||||
|
||||
private resolveModel(model?: string) {
|
||||
return model?.trim() || this.config.llm.model;
|
||||
@@ -47,24 +120,128 @@ export class UpstreamLlmClient {
|
||||
};
|
||||
}
|
||||
|
||||
async requestCompletion(body: CompletionRequest, signal?: AbortSignal) {
|
||||
const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model: this.resolveModel(body.model),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
private createRequestSignal(
|
||||
externalSignal?: AbortSignal,
|
||||
timeoutMs = this.requestTimeoutMs,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const handleAbort = () => controller.abort(externalSignal?.reason);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
handleAbort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', handleAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
didTimeout() {
|
||||
return timedOut;
|
||||
},
|
||||
cleanup() {
|
||||
clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener('abort', handleAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private attachRequestAbort(request: ExpressRequest) {
|
||||
const controller = new AbortController();
|
||||
const handleClose = () => controller.abort();
|
||||
request.on('close', handleClose);
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup() {
|
||||
request.removeListener('close', handleClose);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async requestCompletion(
|
||||
body: CompletionRequest,
|
||||
options: RequestExecutionOptions = {},
|
||||
) {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
||||
? Math.round(options.timeoutMs)
|
||||
: this.requestTimeoutMs;
|
||||
const requestSignal = this.createRequestSignal(options.signal, timeoutMs);
|
||||
const model = this.resolveModel(body.model);
|
||||
const debugLabel =
|
||||
typeof options.debugLabel === 'string' && options.debugLabel.trim()
|
||||
? options.debugLabel.trim()
|
||||
: undefined;
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
llm_model: model,
|
||||
llm_stream: body.stream === true,
|
||||
llm_timeout_ms: timeoutMs,
|
||||
llm_debug_label: debugLabel,
|
||||
},
|
||||
'llm upstream request started',
|
||||
);
|
||||
|
||||
let response: globalThis.Response;
|
||||
try {
|
||||
response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model,
|
||||
}),
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
requestSignal.cleanup();
|
||||
if (requestSignal.didTimeout() && isAbortLikeError(error)) {
|
||||
throw new UpstreamLlmTimeoutError();
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
throw new UpstreamLlmConnectivityError();
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
llm_model: model,
|
||||
llm_stream: body.stream === true,
|
||||
llm_debug_label: debugLabel,
|
||||
},
|
||||
'llm upstream request failed',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
requestSignal.cleanup();
|
||||
|
||||
if (!response.ok) {
|
||||
const rawText = await response.text();
|
||||
throw upstreamError(
|
||||
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
|
||||
);
|
||||
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
llm_model: model,
|
||||
llm_stream: body.stream === true,
|
||||
llm_status: response.status,
|
||||
llm_debug_label: debugLabel,
|
||||
},
|
||||
'llm upstream request succeeded',
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -73,6 +250,8 @@ export class UpstreamLlmClient {
|
||||
userPrompt: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
}) {
|
||||
const response = await this.requestCompletion(
|
||||
{
|
||||
@@ -82,7 +261,11 @@ export class UpstreamLlmClient {
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
},
|
||||
params.signal,
|
||||
{
|
||||
signal: params.signal,
|
||||
timeoutMs: params.timeoutMs,
|
||||
debugLabel: params.debugLabel,
|
||||
},
|
||||
);
|
||||
const rawText = await response.text();
|
||||
const parsed = JSON.parse(rawText) as {
|
||||
@@ -101,69 +284,116 @@ export class UpstreamLlmClient {
|
||||
return content;
|
||||
}
|
||||
|
||||
async forwardCompletion(body: Record<string, unknown>, response: ExpressResponse) {
|
||||
const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model:
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model
|
||||
: this.config.llm.model,
|
||||
}),
|
||||
});
|
||||
async forwardCompletion(
|
||||
request: ExpressRequest,
|
||||
body: Record<string, unknown>,
|
||||
response: ExpressResponse,
|
||||
) {
|
||||
const requestAbort = this.attachRequestAbort(request);
|
||||
let upstreamResponse: globalThis.Response;
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
const rawText = await upstreamResponse.text();
|
||||
throw upstreamError(
|
||||
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
|
||||
);
|
||||
try {
|
||||
upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model:
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model
|
||||
: this.config.llm.model,
|
||||
}),
|
||||
signal: requestAbort.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
requestAbort.cleanup();
|
||||
if (requestAbort.signal.aborted && response.writableEnded) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
response.status(upstreamResponse.status);
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
|
||||
);
|
||||
if (!upstreamResponse.ok) {
|
||||
requestAbort.cleanup();
|
||||
const rawText = await upstreamResponse.text();
|
||||
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
|
||||
}
|
||||
|
||||
prepareApiResponse(request, response, {
|
||||
statusCode: upstreamResponse.status,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
upstreamResponse.headers.get('content-type') ||
|
||||
'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
requestAbort.cleanup();
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
|
||||
try {
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
|
||||
} finally {
|
||||
requestAbort.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async forwardSseText(params: {
|
||||
request: ExpressRequest;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
response: ExpressResponse;
|
||||
model?: string;
|
||||
}) {
|
||||
const upstreamResponse = await this.requestCompletion({
|
||||
model: params.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
const requestAbort = this.attachRequestAbort(params.request);
|
||||
let upstreamResponse: globalThis.Response;
|
||||
|
||||
try {
|
||||
upstreamResponse = await this.requestCompletion(
|
||||
{
|
||||
model: params.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
},
|
||||
{
|
||||
signal: requestAbort.signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
requestAbort.cleanup();
|
||||
if (requestAbort.signal.aborted && params.response.writableEnded) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
prepareEventStreamResponse(params.request, params.response, {
|
||||
statusCode: upstreamResponse.status,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
upstreamResponse.headers.get('content-type') ||
|
||||
'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
params.response.status(upstreamResponse.status);
|
||||
params.response.setHeader(
|
||||
'Content-Type',
|
||||
upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8',
|
||||
);
|
||||
params.response.setHeader('Cache-Control', 'no-cache');
|
||||
params.response.setHeader('Connection', 'keep-alive');
|
||||
params.response.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
requestAbort.cleanup();
|
||||
params.response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
|
||||
try {
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(
|
||||
params.response,
|
||||
);
|
||||
} finally {
|
||||
requestAbort.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
QUEST_INTIMACY_LEVELS,
|
||||
QUEST_NARRATIVE_TYPES,
|
||||
QUEST_OBJECTIVE_KINDS,
|
||||
QUEST_REWARD_THEMES,
|
||||
QUEST_URGENCY_LEVELS,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
compileQuestIntentToQuest,
|
||||
evaluateQuestOpportunity,
|
||||
} from '../../../src/data/questFlow.js';
|
||||
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
|
||||
import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js';
|
||||
import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js';
|
||||
import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js';
|
||||
import type { GameState } from '../../../src/types/game.js';
|
||||
import type { Encounter } from '../../../src/types/scene.js';
|
||||
import type { QuestLogEntry } from '../../../src/types/story.js';
|
||||
buildQuestGenerationContextFromState,
|
||||
buildQuestIntentPrompt,
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
} from '../bridges/legacyQuestRuntimeBridge.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type QuestPreviewRequest = Parameters<typeof evaluateQuestOpportunity>[0];
|
||||
type QuestIntent = ReturnType<typeof buildFallbackQuestIntent>;
|
||||
type QuestGenerationInput = Parameters<typeof buildQuestGenerationContextFromState>[0];
|
||||
type QuestGenerationState = QuestGenerationInput['state'];
|
||||
type QuestGenerationEncounter = QuestGenerationInput['encounter'];
|
||||
type QuestLogEntry = ReturnType<typeof compileQuestIntentToQuest>;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
@@ -41,7 +53,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
|
||||
summary: coerceString(intent.summary, fallback.summary),
|
||||
narrativeType:
|
||||
typeof intent.narrativeType === 'string' &&
|
||||
['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
|
||||
QUEST_NARRATIVE_TYPES.includes(intent.narrativeType)
|
||||
? (intent.narrativeType as QuestIntent['narrativeType'])
|
||||
: fallback.narrativeType,
|
||||
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
|
||||
@@ -51,29 +63,20 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
|
||||
recommendedObjectiveKinds: coerceStringArray(
|
||||
intent.recommendedObjectiveKinds,
|
||||
fallback.recommendedObjectiveKinds,
|
||||
).filter((kind) =>
|
||||
[
|
||||
'defeat_hostile_npc',
|
||||
'inspect_treasure',
|
||||
'spar_with_npc',
|
||||
'talk_to_npc',
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
].includes(kind),
|
||||
) as QuestIntent['recommendedObjectiveKinds'],
|
||||
).filter((kind) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
|
||||
urgency:
|
||||
typeof intent.urgency === 'string' &&
|
||||
['low', 'medium', 'high'].includes(intent.urgency)
|
||||
QUEST_URGENCY_LEVELS.includes(intent.urgency)
|
||||
? (intent.urgency as QuestIntent['urgency'])
|
||||
: fallback.urgency,
|
||||
intimacy:
|
||||
typeof intent.intimacy === 'string' &&
|
||||
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
|
||||
QUEST_INTIMACY_LEVELS.includes(intent.intimacy)
|
||||
? (intent.intimacy as QuestIntent['intimacy'])
|
||||
: fallback.intimacy,
|
||||
rewardTheme:
|
||||
typeof intent.rewardTheme === 'string' &&
|
||||
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
|
||||
QUEST_REWARD_THEMES.includes(intent.rewardTheme)
|
||||
? (intent.rewardTheme as QuestIntent['rewardTheme'])
|
||||
: fallback.rewardTheme,
|
||||
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
|
||||
@@ -82,10 +85,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
|
||||
|
||||
export async function generateQuestForNpcEncounter(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
},
|
||||
params: QuestGenerationRequest<QuestGenerationState, QuestGenerationEncounter>,
|
||||
): Promise<QuestLogEntry | null> {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
@@ -95,7 +95,7 @@ export async function generateQuestForNpcEncounter(
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map((quest: QuestLogEntry) => ({
|
||||
currentQuests: state.quests.map((quest) => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js';
|
||||
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
|
||||
import {
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
|
||||
RUNTIME_ITEM_TONE_VALUES,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
import type {
|
||||
RuntimeItemIntentRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemIntentPrompt,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from '../../../src/services/runtimeItemAiPrompt.js';
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../../../src/types/runtimeItem.js';
|
||||
} from '../bridges/legacyRuntimeItemBridge.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type RuntimeItemGenerationContext = Parameters<typeof buildRuntimeItemAiIntent>[0];
|
||||
type RuntimeItemPlan = Parameters<typeof buildRuntimeItemAiIntent>[1];
|
||||
type RuntimeItemAiIntent = ReturnType<typeof buildRuntimeItemAiIntent>;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
@@ -45,7 +51,7 @@ function sanitizeRuntimeItemAiIntent(
|
||||
(
|
||||
item,
|
||||
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
|
||||
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES.includes(item),
|
||||
);
|
||||
const tone = coerceString(intent.tone, fallback.tone);
|
||||
|
||||
@@ -59,7 +65,7 @@ function sanitizeRuntimeItemAiIntent(
|
||||
desiredFunctionalBias.length > 0
|
||||
? desiredFunctionalBias
|
||||
: fallback.desiredFunctionalBias,
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
tone: RUNTIME_ITEM_TONE_VALUES.includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
|
||||
@@ -80,10 +86,7 @@ function sanitizeRuntimeItemAiIntent(
|
||||
|
||||
export async function generateRuntimeItemIntents(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
},
|
||||
params: RuntimeItemIntentRequest<RuntimeItemGenerationContext, RuntimeItemPlan>,
|
||||
) {
|
||||
const fallbackIntents = params.plans.map((plan) =>
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
|
||||
239
server-node/src/services/smsVerificationService.ts
Normal file
239
server-node/src/services/smsVerificationService.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import DypnsClient, {
|
||||
CheckSmsVerifyCodeRequest,
|
||||
SendSmsVerifyCodeRequest,
|
||||
} from '@alicloud/dypnsapi20170525';
|
||||
import OpenApiClient from '@alicloud/openapi-client';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import {
|
||||
badRequest,
|
||||
unauthorized,
|
||||
upstreamError,
|
||||
} from '../errors.js';
|
||||
|
||||
export type SendLoginCodeResult = {
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
providerRequestId: string | null;
|
||||
};
|
||||
|
||||
export type SmsVerificationService = {
|
||||
sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise<SendLoginCodeResult>;
|
||||
verifyLoginCode(
|
||||
phoneNumber: NormalizedPhoneNumber,
|
||||
verifyCode: string,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
|
||||
return !config.accessKeyId || !config.accessKeySecret;
|
||||
}
|
||||
|
||||
function buildProviderErrorMessage(prefix: string, message: string) {
|
||||
const normalizedMessage = message.trim();
|
||||
return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix;
|
||||
}
|
||||
|
||||
class AliyunSmsVerificationService implements SmsVerificationService {
|
||||
private readonly client: DypnsClient;
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig['smsAuth'],
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
if (isAliyunConfigMissing(config)) {
|
||||
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
|
||||
}
|
||||
|
||||
const clientConfig = new OpenApiClient.Config({
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
endpoint: config.endpoint,
|
||||
protocol: 'HTTPS',
|
||||
});
|
||||
this.client = new DypnsClient(clientConfig);
|
||||
}
|
||||
|
||||
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
|
||||
const templateParam = JSON.stringify({
|
||||
[this.config.templateParamKey]: '##code##',
|
||||
});
|
||||
const request = new SendSmsVerifyCodeRequest({
|
||||
phoneNumber: phoneNumber.nationalNumber,
|
||||
countryCode: this.config.countryCode,
|
||||
signName: this.config.signName,
|
||||
templateCode: this.config.templateCode,
|
||||
templateParam,
|
||||
codeLength: this.config.codeLength,
|
||||
codeType: this.config.codeType,
|
||||
validTime: this.config.validTimeSeconds,
|
||||
interval: this.config.intervalSeconds,
|
||||
duplicatePolicy: this.config.duplicatePolicy,
|
||||
returnVerifyCode: this.config.returnVerifyCode,
|
||||
schemeName: this.config.schemeName || undefined,
|
||||
outId: `login_${crypto.randomBytes(12).toString('hex')}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.client.sendSmsVerifyCode(request);
|
||||
const body = response.body;
|
||||
if (!body?.success || body.code !== 'OK') {
|
||||
throw this.resolveAliyunRequestError(
|
||||
'短信验证码发送失败',
|
||||
body?.message ?? '',
|
||||
body?.code ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cooldownSeconds: this.config.intervalSeconds,
|
||||
expiresInSeconds: this.config.validTimeSeconds,
|
||||
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
|
||||
} satisfies SendLoginCodeResult;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'HttpError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
{
|
||||
err: error,
|
||||
phone_suffix: phoneNumber.nationalNumber.slice(-4),
|
||||
},
|
||||
'aliyun sms send failed',
|
||||
);
|
||||
throw upstreamError(
|
||||
buildProviderErrorMessage(
|
||||
'短信验证码发送失败',
|
||||
error instanceof Error ? error.message : 'unknown error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLoginCode(
|
||||
phoneNumber: NormalizedPhoneNumber,
|
||||
verifyCode: string,
|
||||
) {
|
||||
const request = new CheckSmsVerifyCodeRequest({
|
||||
phoneNumber: phoneNumber.nationalNumber,
|
||||
countryCode: this.config.countryCode,
|
||||
verifyCode,
|
||||
caseAuthPolicy: this.config.caseAuthPolicy,
|
||||
schemeName: this.config.schemeName || undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.client.checkSmsVerifyCode(request);
|
||||
const body = response.body;
|
||||
if (!body?.success || body.code !== 'OK') {
|
||||
throw this.resolveAliyunRequestError(
|
||||
'验证码校验失败',
|
||||
body?.message ?? '',
|
||||
body?.code ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (body.model?.verifyResult !== 'PASS') {
|
||||
throw unauthorized('验证码错误或已失效');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'HttpError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
{
|
||||
err: error,
|
||||
phone_suffix: phoneNumber.nationalNumber.slice(-4),
|
||||
},
|
||||
'aliyun sms verify failed',
|
||||
);
|
||||
throw upstreamError(
|
||||
buildProviderErrorMessage(
|
||||
'验证码校验失败',
|
||||
error instanceof Error ? error.message : 'unknown error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAliyunRequestError(
|
||||
fallbackMessage: string,
|
||||
providerMessage: string,
|
||||
providerCode: string,
|
||||
) {
|
||||
const normalizedCode = providerCode.trim().toUpperCase();
|
||||
if (
|
||||
normalizedCode.includes('MOBILE') ||
|
||||
normalizedCode.includes('PHONE') ||
|
||||
normalizedCode.includes('TEMPLATE') ||
|
||||
normalizedCode.includes('SIGN')
|
||||
) {
|
||||
return badRequest(
|
||||
buildProviderErrorMessage(fallbackMessage, providerMessage),
|
||||
{
|
||||
providerCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return upstreamError(
|
||||
buildProviderErrorMessage(fallbackMessage, providerMessage),
|
||||
{
|
||||
providerCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MockSmsVerificationService implements SmsVerificationService {
|
||||
private readonly sentCodes = new Map<string, string>();
|
||||
|
||||
constructor(private readonly config: AppConfig['smsAuth']) {}
|
||||
|
||||
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
|
||||
this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode);
|
||||
return {
|
||||
cooldownSeconds: this.config.intervalSeconds,
|
||||
expiresInSeconds: this.config.validTimeSeconds,
|
||||
providerRequestId: 'mock-request-id',
|
||||
} satisfies SendLoginCodeResult;
|
||||
}
|
||||
|
||||
async verifyLoginCode(
|
||||
phoneNumber: NormalizedPhoneNumber,
|
||||
verifyCode: string,
|
||||
) {
|
||||
const expectedCode = this.sentCodes.get(phoneNumber.e164);
|
||||
if (!expectedCode || expectedCode !== verifyCode) {
|
||||
throw unauthorized('验证码错误或已失效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSmsVerificationService(
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
): SmsVerificationService {
|
||||
if (!config.smsAuth.enabled) {
|
||||
return {
|
||||
async sendLoginCode() {
|
||||
throw badRequest('短信验证码登录未启用');
|
||||
},
|
||||
async verifyLoginCode() {
|
||||
throw badRequest('短信验证码登录未启用');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config.smsAuth.provider === 'mock') {
|
||||
return new MockSmsVerificationService(config.smsAuth);
|
||||
}
|
||||
|
||||
return new AliyunSmsVerificationService(config.smsAuth, logger);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
generateInitialStoryStrict as generateInitialStoryFromAi,
|
||||
generateNextStepStrict as generateNextStepFromAi,
|
||||
type StoryGenerationContext,
|
||||
type StoryRequestOptions,
|
||||
} from '../../../src/services/ai.js';
|
||||
import type { Character } from '../../../src/types/characters.js';
|
||||
import type { WorldType } from '../../../src/types/core.js';
|
||||
import type { SceneHostileNpc } from '../../../src/types/scene.js';
|
||||
import type { StoryMoment } from '../../../src/types/story.js';
|
||||
generateInitialStoryFromOrchestrator,
|
||||
generateNextStoryFromOrchestrator,
|
||||
} from '../modules/ai/storyOrchestrator.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const storyRequestSchema = z.object({
|
||||
worldType: z.string().trim().min(1),
|
||||
character: z.record(z.string(), z.unknown()),
|
||||
monsters: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
history: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
character: jsonObjectSchema,
|
||||
monsters: z.array(jsonObjectSchema).default([]),
|
||||
history: z.array(jsonObjectSchema).default([]),
|
||||
choice: z.string().optional().default(''),
|
||||
context: z.record(z.string(), z.unknown()),
|
||||
context: jsonObjectSchema,
|
||||
requestOptions: z.object({
|
||||
availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]),
|
||||
optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]),
|
||||
availableOptions: z.array(jsonObjectSchema).optional().default([]),
|
||||
optionCatalog: z.array(jsonObjectSchema).optional().default([]),
|
||||
}).optional().default({
|
||||
availableOptions: [],
|
||||
optionCatalog: [],
|
||||
@@ -28,28 +26,30 @@ const storyRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export function parseStoryRequest(body: unknown) {
|
||||
return storyRequestSchema.parse(body);
|
||||
return storyRequestSchema.parse(body) as StoryRequestPayload;
|
||||
}
|
||||
|
||||
function toTypedStoryParams(
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
return {
|
||||
worldType: request.worldType as WorldType,
|
||||
character: request.character as unknown as Character,
|
||||
monsters: request.monsters as unknown as SceneHostileNpc[],
|
||||
history: request.history as unknown as StoryMoment[],
|
||||
worldType: request.worldType,
|
||||
character: request.character,
|
||||
monsters: request.monsters,
|
||||
history: request.history,
|
||||
choice: request.choice.trim(),
|
||||
context: request.context as unknown as StoryGenerationContext,
|
||||
requestOptions: request.requestOptions as unknown as StoryRequestOptions,
|
||||
context: request.context,
|
||||
requestOptions: request.requestOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateHighQualityInitialStory(
|
||||
llmClient: UpstreamLlmClient,
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateInitialStoryFromAi(
|
||||
return generateInitialStoryFromOrchestrator(
|
||||
llmClient,
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
@@ -59,10 +59,12 @@ export async function generateHighQualityInitialStory(
|
||||
}
|
||||
|
||||
export async function generateHighQualityNextStory(
|
||||
llmClient: UpstreamLlmClient,
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateNextStepFromAi(
|
||||
return generateNextStoryFromOrchestrator(
|
||||
llmClient,
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
|
||||
182
server-node/src/services/wechatAuthService.ts
Normal file
182
server-node/src/services/wechatAuthService.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { badRequest, upstreamError } from '../errors.js';
|
||||
|
||||
export type WechatIdentityProfile = {
|
||||
providerUid: string;
|
||||
providerUnionId: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
metaJson: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type WechatAuthService = {
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}): string;
|
||||
resolveCallbackProfile(params: {
|
||||
code?: string | null;
|
||||
mockCode?: string | null;
|
||||
}): Promise<WechatIdentityProfile>;
|
||||
};
|
||||
|
||||
class MockWechatAuthService implements WechatAuthService {
|
||||
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
||||
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}) {
|
||||
const callbackUrl = new URL(params.callbackUrl);
|
||||
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
||||
callbackUrl.searchParams.set('state', params.state);
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
|
||||
async resolveCallbackProfile(params: {
|
||||
mockCode?: string | null;
|
||||
}) {
|
||||
const mockCode = params.mockCode?.trim() || this.config.mockUserId;
|
||||
return {
|
||||
providerUid: mockCode,
|
||||
providerUnionId: this.config.mockUnionId || null,
|
||||
displayName: this.config.mockDisplayName || '微信旅人',
|
||||
avatarUrl: this.config.mockAvatarUrl || null,
|
||||
metaJson: {
|
||||
mockCode,
|
||||
},
|
||||
} satisfies WechatIdentityProfile;
|
||||
}
|
||||
}
|
||||
|
||||
class RealWechatAuthService implements WechatAuthService {
|
||||
constructor(
|
||||
private readonly config: AppConfig['wechatAuth'],
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
if (!config.appId || !config.appSecret) {
|
||||
throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置');
|
||||
}
|
||||
}
|
||||
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}) {
|
||||
const url = new URL(this.config.authorizeEndpoint);
|
||||
url.searchParams.set('appid', this.config.appId);
|
||||
url.searchParams.set('redirect_uri', params.callbackUrl);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'snsapi_login');
|
||||
url.searchParams.set('state', params.state);
|
||||
return `${url.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async resolveCallbackProfile(params: {
|
||||
code?: string | null;
|
||||
}) {
|
||||
const code = params.code?.trim();
|
||||
if (!code) {
|
||||
throw badRequest('缺少微信授权 code');
|
||||
}
|
||||
|
||||
try {
|
||||
const accessTokenUrl = new URL(this.config.accessTokenEndpoint);
|
||||
accessTokenUrl.searchParams.set('appid', this.config.appId);
|
||||
accessTokenUrl.searchParams.set('secret', this.config.appSecret);
|
||||
accessTokenUrl.searchParams.set('code', code);
|
||||
accessTokenUrl.searchParams.set('grant_type', 'authorization_code');
|
||||
|
||||
const accessTokenResponse = await fetch(accessTokenUrl.toString());
|
||||
const accessTokenPayload =
|
||||
(await accessTokenResponse.json()) as Record<string, unknown>;
|
||||
|
||||
if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') {
|
||||
throw new Error(
|
||||
typeof accessTokenPayload.errmsg === 'string'
|
||||
? accessTokenPayload.errmsg
|
||||
: 'failed to exchange code',
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken =
|
||||
typeof accessTokenPayload.access_token === 'string'
|
||||
? accessTokenPayload.access_token
|
||||
: '';
|
||||
const openId = accessTokenPayload.openid;
|
||||
const fallbackUnionId =
|
||||
typeof accessTokenPayload.unionid === 'string'
|
||||
? accessTokenPayload.unionid
|
||||
: null;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('missing access_token');
|
||||
}
|
||||
|
||||
const userInfoUrl = new URL(this.config.userInfoEndpoint);
|
||||
userInfoUrl.searchParams.set('access_token', accessToken);
|
||||
userInfoUrl.searchParams.set('openid', openId);
|
||||
userInfoUrl.searchParams.set('lang', 'zh_CN');
|
||||
|
||||
const userInfoResponse = await fetch(userInfoUrl.toString());
|
||||
const userInfoPayload =
|
||||
(await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') {
|
||||
throw new Error(
|
||||
typeof userInfoPayload.errmsg === 'string'
|
||||
? userInfoPayload.errmsg
|
||||
: 'failed to fetch user info',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerUid: userInfoPayload.openid,
|
||||
providerUnionId:
|
||||
typeof userInfoPayload.unionid === 'string'
|
||||
? userInfoPayload.unionid
|
||||
: fallbackUnionId,
|
||||
displayName:
|
||||
typeof userInfoPayload.nickname === 'string'
|
||||
? userInfoPayload.nickname
|
||||
: null,
|
||||
avatarUrl:
|
||||
typeof userInfoPayload.headimgurl === 'string'
|
||||
? userInfoPayload.headimgurl
|
||||
: null,
|
||||
metaJson: userInfoPayload,
|
||||
} satisfies WechatIdentityProfile;
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, 'wechat auth callback failed');
|
||||
throw upstreamError(
|
||||
error instanceof Error
|
||||
? `微信登录失败:${error.message}`
|
||||
: '微信登录失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWechatAuthService(
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
): WechatAuthService {
|
||||
if (!config.wechatAuth.enabled) {
|
||||
return {
|
||||
buildAuthorizationUrl() {
|
||||
throw badRequest('微信登录暂未启用');
|
||||
},
|
||||
async resolveCallbackProfile() {
|
||||
throw badRequest('微信登录暂未启用');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config.wechatAuth.provider === 'mock') {
|
||||
return new MockWechatAuthService(config.wechatAuth);
|
||||
}
|
||||
|
||||
return new RealWechatAuthService(config.wechatAuth, logger);
|
||||
}
|
||||
32
server-node/src/services/wechatAuthStateStore.ts
Normal file
32
server-node/src/services/wechatAuthStateStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export type WechatAuthStateRecord = {
|
||||
state: string;
|
||||
redirectPath: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export class WechatAuthStateStore {
|
||||
private readonly states = new Map<string, WechatAuthStateRecord>();
|
||||
|
||||
create(redirectPath: string) {
|
||||
const state = crypto.randomBytes(18).toString('hex');
|
||||
const record: WechatAuthStateRecord = {
|
||||
state,
|
||||
redirectPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.states.set(state, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
consume(state: string) {
|
||||
const record = this.states.get(state) ?? null;
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.states.delete(state);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user