1
This commit is contained in:
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal file
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user