This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -0,0 +1,338 @@
import { Router } from 'express';
import { z } from 'zod';
import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { AppContext } from '../../context.js';
import { badRequest, conflict, notFound } from '../../errors.js';
import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js';
import { requireJwtAuth } from '../../middleware/auth.js';
import { routeMeta } from '../../middleware/routeMeta.js';
import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const customWorldProfileSchema = z.object({
profile: jsonObjectSchema,
});
export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH =
'/api/runtime/custom-world-library';
export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH =
'/api/runtime/custom-world-gallery';
export const RPG_WORLD_WORKS_ROUTE_BASE_PATH =
'/api/runtime/custom-world/works';
const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-';
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveAgentSessionIdFromProfileId(profileId: string) {
if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) {
return null;
}
const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim();
return sessionId || null;
}
function resolvePublishedWorldName(profile: unknown) {
const profileRecord =
profile && typeof profile === 'object' && !Array.isArray(profile)
? (profile as Record<string, unknown>)
: null;
return toText(profileRecord?.name) || '当前世界';
}
async function syncAgentSessionPublishedState(params: {
context: AppContext;
userId: string;
sessionId: string;
worldName: string;
qualityFindings: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
}) {
const publishedQualityFindings = params.qualityFindings.filter(
(entry) => entry.severity !== 'blocker',
);
const publishedState = {
stage: 'published' as const,
qualityFindings: publishedQualityFindings,
};
await params.context.customWorldAgentSessions.replaceDerivedState(
params.userId,
params.sessionId,
publishedState,
);
await params.context.customWorldAgentSessions.appendCheckpoint(
params.userId,
params.sessionId,
{
label: `发布世界 ${params.worldName}`,
snapshot: publishedState,
},
);
await params.context.customWorldAgentSessions.appendMessage(
params.userId,
params.sessionId,
{
id: `message-${Date.now().toString(36)}-library-publish`,
role: 'assistant',
kind: 'action_result',
text:
publishedQualityFindings.length > 0
? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。`
: `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`,
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
);
}
async function resolveAuthDisplayName(context: AppContext, userId: string) {
const user = await context.userRepository.findById(userId);
if (!user) {
throw notFound('user not found');
}
return user.displayName?.trim() || '玩家';
}
export function createRpgWorldLibraryRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
const publishingService = new CustomWorldAgentPublishingService(
context.rpgWorldProfileRepository,
);
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, {
entries:
await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(response, {
entry,
} satisfies CustomWorldGalleryDetailResponse);
}),
);
router.get(
'/runtime/custom-world/works',
requireAuth,
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
asyncHandler(async (request, response) => {
sendApiResponse<ListCustomWorldWorksResponse>(response, {
items: await context.rpgWorldWorkSummaryService.list(request.userId!),
});
}),
);
router.get(
'/runtime/custom-world-library',
requireAuth,
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
sendApiResponse(response, {
entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles(
request.userId!,
),
} satisfies CustomWorldLibraryResponse);
}),
);
router.put(
'/runtime/custom-world-library/:profileId',
requireAuth,
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
sendApiResponse(
response,
await context.rpgWorldLibraryRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
authorDisplayName,
),
);
}),
);
router.delete(
'/runtime/custom-world-library/:profileId',
requireAuth,
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
sendApiResponse(response, {
entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
} satisfies CustomWorldLibraryResponse);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
requireAuth,
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const agentSessionId = resolveAgentSessionIdFromProfileId(profileId);
if (agentSessionId) {
const agentSession = await context.customWorldAgentSessions.get(
request.userId!,
agentSessionId,
);
if (agentSession) {
try {
publishingService.buildPublishReadiness({
sessionId: agentSessionId,
draftProfile: agentSession.draftProfile,
qualityFindings: agentSession.qualityFindings,
});
} catch (error) {
throw conflict(
error instanceof Error
? error.message
: '当前世界还没有通过发布校验。',
);
}
const publishResult = await publishingService.publishSessionDraft({
userId: request.userId!,
authorDisplayName,
sessionId: agentSessionId,
draftProfile:
(agentSession.draftProfile ?? {}) as Record<string, unknown>,
qualityFindings: agentSession.qualityFindings,
});
await syncAgentSessionPublishedState({
context,
userId: request.userId!,
sessionId: agentSessionId,
worldName: resolvePublishedWorldName(publishResult.publishedProfile),
qualityFindings: agentSession.qualityFindings,
});
sendApiResponse(
response,
publishResult.mutation satisfies CustomWorldLibraryMutationResponse,
);
return;
}
}
const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/unpublish',
requireAuth,
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
return router;
}