Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
UseGuards
} from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminListAssetsQueryDto,
AdminListCharactersQueryDto,
AdminListCopyrightRecordsQueryDto,
AdminListGlobalCharactersQueryDto,
AdminListHitAnalysesQueryDto,
AdminListNovelChaptersQueryDto,
AdminListNovelSourcesQueryDto,
AdminListOperationLogsQueryDto,
AdminBindCharacterGlobalDto,
AdminAnalyzeHitCaseDto,
AdminCreateHitAnalysisCaseDto,
AdminListProjectsQueryDto,
AdminListRouterAuditsQueryDto,
AdminListStoryboardShotsQueryDto,
AdminListUsersQueryDto,
AdminListWorksQueryDto,
AdminListCreativePatternsQueryDto,
AdminPromoteHitCasePatternsDto,
AdminResetUserPasswordDto,
AdminSaveGlobalCharacterDto,
AdminUpdateRouterAuditQualityDto,
AdminUpdateCreativePatternDto,
AdminUpdateCreativePatternStatusDto,
AdminUpdateProjectStatusDto,
AdminUpdateUserRoleDto,
AdminUpdateUserStatusDto,
AdminUpdateSystemConfigDto
} from './admin.dto';
import { AdminService } from './admin.service';
@Controller('admin')
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(@Inject(AdminService) private readonly adminService: AdminService) {}
@Get('dashboard')
getDashboard(@CurrentUser() user: AuthRequestUser) {
return this.adminService.getDashboard(user);
}
@Get('rbac/me')
getRbacProfile(@CurrentUser() user: AuthRequestUser) {
return this.adminService.getRbacProfile(user);
}
@Get('projects')
listProjects(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListProjectsQueryDto) {
return this.adminService.listProjects(user, query);
}
@Get('projects/:projectId')
getProjectDetail(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) {
return this.adminService.getProjectDetail(user, projectId);
}
@Patch('projects/:projectId/status')
updateProjectStatus(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: AdminUpdateProjectStatusDto
) {
return this.adminService.updateProjectStatus(user, projectId, dto);
}
@Get('users')
listUsers(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListUsersQueryDto) {
return this.adminService.listUsers(user, query);
}
@Get('users/:userId/detail')
getUserDetail(@CurrentUser() user: AuthRequestUser, @Param('userId') userId: string) {
return this.adminService.getUserDetail(user, userId);
}
@Patch('users/:userId/status')
updateUserStatus(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminUpdateUserStatusDto
) {
return this.adminService.updateUserStatus(user, userId, dto);
}
@Patch('users/:userId/role')
updateUserRole(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminUpdateUserRoleDto
) {
return this.adminService.updateUserRole(user, userId, dto);
}
@Post('users/:userId/reset-password')
resetUserPassword(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminResetUserPasswordDto
) {
return this.adminService.resetUserPassword(user, userId, dto);
}
@Get('assets')
listAssets(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListAssetsQueryDto) {
return this.adminService.listAssets(user, query);
}
@Get('novel-sources')
listNovelSources(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListNovelSourcesQueryDto
) {
return this.adminService.listNovelSources(user, query);
}
@Get('novel-chapters')
listNovelChapters(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListNovelChaptersQueryDto
) {
return this.adminService.listNovelChapters(user, query);
}
@Get('characters')
listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) {
return this.adminService.listCharacters(user, query);
}
@Get('global-characters')
listGlobalCharacters(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListGlobalCharactersQueryDto
) {
return this.adminService.listGlobalCharacters(user, query);
}
@Post('global-characters')
createGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Body() dto: AdminSaveGlobalCharacterDto
) {
return this.adminService.createGlobalCharacter(user, dto);
}
@Patch('global-characters/:globalCharacterId')
updateGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: AdminSaveGlobalCharacterDto
) {
return this.adminService.updateGlobalCharacter(user, globalCharacterId, dto);
}
@Post('characters/:characterId/bind-global')
bindCharacterGlobal(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: AdminBindCharacterGlobalDto
) {
return this.adminService.bindCharacterGlobal(user, characterId, dto);
}
@Get('storyboard-shots')
listStoryboardShots(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListStoryboardShotsQueryDto
) {
return this.adminService.listStoryboardShots(user, query);
}
@Get('router-audits')
listRouterAudits(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListRouterAuditsQueryDto
) {
return this.adminService.listRouterAudits(user, query);
}
@Get('hit-analyses')
listHitAnalyses(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListHitAnalysesQueryDto
) {
return this.adminService.listHitAnalyses(user, query);
}
@Post('hit-analyses')
createHitAnalysisCase(
@CurrentUser() user: AuthRequestUser,
@Body() dto: AdminCreateHitAnalysisCaseDto
) {
return this.adminService.createHitAnalysisCase(user, dto);
}
@Post('hit-analyses/:caseId/analyze')
analyzeHitCase(
@CurrentUser() user: AuthRequestUser,
@Param('caseId') caseId: string,
@Body() dto: AdminAnalyzeHitCaseDto
) {
return this.adminService.analyzeHitCase(user, caseId, dto);
}
@Post('hit-analyses/:caseId/patterns')
promoteHitCasePatterns(
@CurrentUser() user: AuthRequestUser,
@Param('caseId') caseId: string,
@Body() dto: AdminPromoteHitCasePatternsDto
) {
return this.adminService.promoteHitCasePatterns(user, caseId, dto);
}
@Get('creative-patterns')
listCreativePatterns(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListCreativePatternsQueryDto
) {
return this.adminService.listCreativePatterns(user, query);
}
@Patch('creative-patterns/:patternId')
updateCreativePattern(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string,
@Body() dto: AdminUpdateCreativePatternDto
) {
return this.adminService.updateCreativePattern(user, patternId, dto);
}
@Patch('creative-patterns/:patternId/status')
updateCreativePatternStatus(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string,
@Body() dto: AdminUpdateCreativePatternStatusDto
) {
return this.adminService.updateCreativePatternStatus(user, patternId, dto);
}
@Post('creative-patterns/:patternId/refresh-metrics')
refreshCreativePatternMetrics(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string
) {
return this.adminService.refreshCreativePatternMetrics(user, patternId);
}
@Get('router-audits/video-clips/:clipId/timeline')
getRouterAuditClipTimeline(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string
) {
return this.adminService.getRouterAuditClipTimeline(user, clipId);
}
@Patch('router-audits/video-clips/:clipId/quality')
updateRouterAuditClipQuality(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string,
@Body() dto: AdminUpdateRouterAuditQualityDto
) {
return this.adminService.updateRouterAuditClipQuality(user, clipId, dto);
}
@Get('works')
listWorks(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListWorksQueryDto) {
return this.adminService.listWorks(user, query);
}
@Get('copyright-records')
listCopyrightRecords(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListCopyrightRecordsQueryDto
) {
return this.adminService.listCopyrightRecords(user, query);
}
@Get('operation-logs')
listOperationLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListOperationLogsQueryDto
) {
return this.adminService.listOperationLogs(user, query);
}
@Get('operation-logs/export')
exportOperationLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListOperationLogsQueryDto
) {
return this.adminService.exportOperationLogs(user, query);
}
@Get('system-configs')
listSystemConfigs(@CurrentUser() user: AuthRequestUser) {
return this.adminService.listSystemConfigs(user);
}
@Patch('system-configs/:configKey')
updateSystemConfig(
@CurrentUser() user: AuthRequestUser,
@Param('configKey') configKey: string,
@Body() dto: AdminUpdateSystemConfigDto
) {
return this.adminService.updateSystemConfig(user, configKey, dto);
}
}
+218
View File
@@ -0,0 +1,218 @@
export class AdminListProjectsQueryDto {
status?: string;
input_mode?: string;
user_id?: string;
limit?: string;
}
export class AdminListUsersQueryDto {
role?: string;
status?: string;
limit?: string;
}
export class AdminListAssetsQueryDto {
asset_type?: string;
status?: string;
project_id?: string;
user_id?: string;
limit?: string;
}
export class AdminListNovelSourcesQueryDto {
project_id?: string;
user_id?: string;
source_type?: string;
parse_status?: string;
limit?: string;
}
export class AdminListNovelChaptersQueryDto {
project_id?: string;
novel_source_id?: string;
status?: string;
limit?: string;
}
export class AdminListCharactersQueryDto {
project_id?: string;
global_character_id?: string;
status?: string;
role_type?: string;
limit?: string;
}
export class AdminListGlobalCharactersQueryDto {
status?: string;
role_archetype?: string;
commercial_status?: string;
limit?: string;
}
export class AdminSaveGlobalCharacterDto {
name?: string;
display_name?: string;
role_archetype?: string;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
default_costume_rules?: string;
wardrobe_json?: unknown;
special_props?: string;
personality_desc?: string;
speech_style?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
negative_rules?: string;
anchor_asset_id?: string;
voice_sample_asset_id?: string;
commercial_status?: string;
usage_scope?: string;
status?: string;
}
export class AdminBindCharacterGlobalDto {
global_character_id?: string;
reason?: string;
}
export class AdminListStoryboardShotsQueryDto {
project_id?: string;
episode_id?: string;
status?: string;
limit?: string;
}
export class AdminListRouterAuditsQueryDto {
project_id?: string;
episode_id?: string;
provider_code?: string;
quality_status?: string;
route_tier?: string;
limit?: string;
}
export class AdminListHitAnalysesQueryDto {
source_platform?: string;
genre?: string;
status?: string;
limit?: string;
}
export class AdminCreateHitAnalysisCaseDto {
title?: string;
source_platform?: string;
source_url?: string;
content_type?: string;
genre?: string;
language?: string;
target_audience?: string;
duration_seconds?: number | string | null;
episode_count?: number | string | null;
tags?: string[] | string;
metrics_json?: unknown;
transcript_text?: string;
summary_text?: string;
auto_analyze?: boolean;
}
export class AdminAnalyzeHitCaseDto {
min_segment_seconds?: number | string | null;
segment_count?: number | string | null;
}
export class AdminPromoteHitCasePatternsDto {
pattern_types?: string[] | string;
}
export class AdminListCreativePatternsQueryDto {
pattern_type?: string;
genre?: string;
status?: string;
limit?: string;
}
export class AdminUpdateCreativePatternDto {
pattern_type?: string;
title?: string;
genre?: string | null;
language?: string;
description?: string | null;
structure_json?: unknown;
prompt_template?: string | null;
negative_prompt?: string | null;
tags?: string[] | string | null;
effectiveness_score?: number | string | null;
status?: string;
reason?: string;
}
export class AdminUpdateCreativePatternStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateRouterAuditQualityDto {
result_status?: string;
reason?: string;
quality_score?: number | string | null;
}
export class AdminListWorksQueryDto {
project_id?: string;
user_id?: string;
status?: string;
limit?: string;
}
export class AdminListCopyrightRecordsQueryDto {
project_id?: string;
user_id?: string;
authorization_type?: string;
limit?: string;
}
export class AdminListOperationLogsQueryDto {
user_id?: string;
operator_role?: string;
action?: string;
target_type?: string;
target_id?: string;
date_from?: string;
date_to?: string;
limit?: string;
}
export class AdminUpdateProjectStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateUserStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateUserRoleDto {
role?: string;
reason?: string;
}
export class AdminResetUserPasswordDto {
new_password?: string;
reason?: string;
}
export class AdminUpdateSystemConfigDto {
config_value?: unknown;
description?: string;
is_public?: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService]
})
export class AdminModule {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
import type {
CopyrightRecord,
CreativePattern,
HitAnalysisCase,
HitAnalysisSegment,
NovelChapter,
NovelSource,
OperationLog,
SystemConfig
} from '@prisma/client';
export function toSafeNovelSource(source: NovelSource) {
return {
id: source.id.toString(),
project_id: source.project_id.toString(),
source_type: source.source_type,
title: source.title,
author_name: source.author_name,
raw_asset_id: source.raw_asset_id?.toString() ?? null,
word_count: source.word_count,
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
text_preview: createTextPreview(source.clean_text || source.raw_text),
created_at: source.created_at.toISOString()
};
}
export function toSafeNovelChapter(chapter: NovelChapter) {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
chapter_no: chapter.chapter_no,
title: chapter.title,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
content_preview: createTextPreview(chapter.content, 3000),
word_count: chapter.word_count,
status: chapter.status,
created_at: chapter.created_at.toISOString()
};
}
function createTextPreview(value: string | null, maxLength = 2000) {
if (!value) {
return null;
}
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
}
export function toSafeCopyrightRecord(record: CopyrightRecord) {
return {
id: record.id.toString(),
project_id: record.project_id.toString(),
user_id: record.user_id.toString(),
authorization_type: record.authorization_type,
statement_text: record.statement_text,
ip: record.ip,
confirmed_at: record.confirmed_at.toISOString()
};
}
export function toSafeOperationLog(log: OperationLog) {
return {
id: log.id.toString(),
user_id: log.user_id?.toString() ?? null,
operator_role: log.operator_role,
action: log.action,
target_type: log.target_type,
target_id: log.target_id?.toString() ?? null,
metadata_json: log.metadata_json,
created_at: log.created_at.toISOString()
};
}
export function toSafeSystemConfig(config: SystemConfig) {
return {
id: config.id.toString(),
config_key: config.config_key,
config_value: config.config_value,
description: config.description,
is_public: config.is_public,
created_at: config.created_at.toISOString(),
updated_at: config.updated_at.toISOString()
};
}
export function toSafeHitAnalysisCase(item: HitAnalysisCase) {
return {
id: item.id.toString(),
title: item.title,
source_platform: item.source_platform,
source_url: item.source_url,
content_type: item.content_type,
genre: item.genre,
language: item.language,
target_audience: item.target_audience,
duration_seconds: item.duration_seconds,
episode_count: item.episode_count,
tags_json: item.tags_json,
metrics_json: item.metrics_json,
summary_text: item.summary_text,
transcript_preview: createTextPreview(item.transcript_text, 3000),
analysis_json: item.analysis_json,
diagnosis_score: item.diagnosis_score ? Number(item.diagnosis_score.toString()) : null,
status: item.status,
created_by_user_id: item.created_by_user_id?.toString() ?? null,
created_at: item.created_at.toISOString(),
updated_at: item.updated_at.toISOString()
};
}
export function toSafeHitAnalysisSegment(item: HitAnalysisSegment) {
return {
id: item.id.toString(),
case_id: item.case_id.toString(),
segment_no: item.segment_no,
start_second: item.start_second,
end_second: item.end_second,
scene_type: item.scene_type,
hook_type: item.hook_type,
emotion: item.emotion,
conflict_type: item.conflict_type,
plot_function: item.plot_function,
visual_strategy: item.visual_strategy,
dialogue_pattern: item.dialogue_pattern,
camera_notes: item.camera_notes,
importance_score: item.importance_score,
emotion_score: item.emotion_score,
action_score: item.action_score,
tags_json: item.tags_json,
summary_text: item.summary_text,
prompt_seed: item.prompt_seed,
created_at: item.created_at.toISOString()
};
}
export function toSafeCreativePattern(item: CreativePattern) {
return {
id: item.id.toString(),
source_case_id: item.source_case_id?.toString() ?? null,
pattern_type: item.pattern_type,
title: item.title,
genre: item.genre,
language: item.language,
description: item.description,
structure_json: item.structure_json,
prompt_template: item.prompt_template,
negative_prompt: item.negative_prompt,
tags_json: item.tags_json,
usage_count: item.usage_count,
effectiveness_score: item.effectiveness_score ? Number(item.effectiveness_score.toString()) : null,
status: item.status,
created_by_user_id: item.created_by_user_id?.toString() ?? null,
created_at: item.created_at.toISOString(),
updated_at: item.updated_at.toISOString()
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AiRouterService } from './ai-router.service';
@Module({
imports: [PrismaModule],
providers: [AiRouterService],
exports: [AiRouterService]
})
export class AiRouterModule {}
@@ -0,0 +1,226 @@
import { Prisma, type Project, type ProviderConfig, type StoryboardShot } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { DEFAULT_AI_ROUTER_CONFIG } from './ai-router.types';
import { AiRouterService } from './ai-router.service';
const now = new Date('2026-06-09T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: 'AI Router 测试项目',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'live_action',
output_type: 'short_video',
output_mode: 'live_action_ai',
visual_mode: 'live_action',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'live_action_shots_prepared',
copyright_status: 'ai_original',
payment_status: 'paid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
return {
id: 20n,
project_id: 10n,
episode_id: 30n,
shot_no: 1,
scene_name: '会议室反击',
location_desc: '高层会议室',
characters_json: [{ id: '1', name: '林晚' }],
visual_desc: '林晚站在会议桌前。',
action_desc: '林晚播放录音证据。',
dialogue_text: '这一回,我不会再退。',
narration_text: '局势开始反转。',
camera_motion: 'zoom_in',
effect_type: 'flash',
duration: new Prisma.Decimal(4),
scene_type: null,
importance_score: null,
emotion_score: null,
action_score: null,
route_tier: null,
prompt_text: '真人短剧会议室反击',
negative_prompt: '低清晰度',
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
keyframe_asset_id: 40n,
video_clip_asset_id: null,
video_status: 'keyframe_generated',
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
id: 100n,
provider_type: 'VideoProvider',
provider_code: 'mock-video',
display_name: 'Mock Video',
mode: 'mock',
model_name: 'mock-video-v1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: {},
cost_rule_json: { flat_cost: 0, unit: 'mock' },
created_at: now,
updated_at: now,
...overrides
};
}
describe('AiRouterService', () => {
let prisma: any;
let service: AiRouterService;
beforeEach(() => {
prisma = {
systemConfig: {
upsert: vi.fn().mockResolvedValue({
config_key: 'ai.router.v1',
config_value: DEFAULT_AI_ROUTER_CONFIG
})
},
providerConfig: {
findMany: vi.fn()
},
providerLog: {
aggregate: vi.fn().mockResolvedValue({
_sum: { cost_actual: new Prisma.Decimal(0) }
})
}
};
service = new AiRouterService(prisma as PrismaService);
});
it('routes normal Chinese live-action shots to Hailuo when enabled', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 101n,
provider_code: 'minimax_hailuo_23_fast',
display_name: 'Hailuo Fast',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.03, currency: 'USD' }
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }),
duration: 5
});
expect(decision.provider_code).toBe('minimax_hailuo_23_fast');
expect(decision.route_tier).toBe('normal');
expect(decision.estimated_cost).toBe(0.15);
expect(decision.decision_reason).toBe('auto_normal_route');
});
it('routes high-value or complex shots to Kling when enabled', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 102n,
provider_code: 'kling-image-to-video',
display_name: 'Kling',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.12, currency: 'USD' }
}),
createProvider({
id: 101n,
provider_code: 'minimax_hailuo_23_fast',
mode: 'real',
is_enabled: true
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({
action_desc: '女主在雨夜追车,真相曝光,高潮打脸。',
importance_score: 9,
action_score: 7
}),
duration: 5
});
expect(decision.provider_code).toBe('kling-image-to-video');
expect(decision.route_tier).toBe('premium');
expect(decision.fallback_chain).toContain('minimax_hailuo_23_fast');
});
it('falls back through disabled providers to mock video', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
provider_code: 'minimax_hailuo_23_fast',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
is_enabled: false
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }),
duration: 5
});
expect(decision.provider_code).toBe('mock-video');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'provider_disabled',
'auto_normal_route'
]);
});
it('keeps admin manual override as an explicit router decision', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
is_enabled: true
})
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot(),
duration: 5,
manual_provider_code: 'jimeng_seedance',
allow_manual_override: true
});
expect(decision.provider_code).toBe('jimeng_seedance');
expect(decision.manual_override).toBe(true);
expect(decision.decision_reason).toBe('manual_provider_override');
});
});
+374
View File
@@ -0,0 +1,374 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import type { Prisma, Project, ProviderConfig, StoryboardShot } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
AI_ROUTER_CONFIG_KEY,
AI_ROUTER_DEFAULT_LANGUAGE,
DEFAULT_AI_ROUTER_CONFIG,
type AiRouteDecision,
type AiRouteTier,
type AiRouterShotScores
} from './ai-router.types';
const ROUTER_MAX_PROVIDER_CLIP_SECONDS = 10;
@Injectable()
export class AiRouterService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
scoreLiveActionShot(shot: StoryboardShot): AiRouterShotScores {
const sceneType = this.normalizeSceneType(shot.scene_type) ?? this.inferSceneType(shot);
const importanceScore = this.clampScore(shot.importance_score ?? this.inferImportanceScore(shot, sceneType));
const emotionScore = this.clampScore(shot.emotion_score ?? this.inferEmotionScore(shot));
const actionScore = this.clampScore(shot.action_score ?? this.inferActionScore(shot));
const routeTier = this.normalizeRouteTier(shot.route_tier) ?? this.routeTierForScores(importanceScore, actionScore);
return {
scene_type: sceneType,
importance_score: importanceScore,
emotion_score: emotionScore,
action_score: actionScore,
route_tier: routeTier
};
}
async resolveLiveActionVideoRoute(input: {
project: Project;
shot: StoryboardShot;
duration: number;
language?: string | null;
manual_provider_code?: string | null;
allow_manual_override?: boolean;
max_cost_per_clip?: number | null;
}): Promise<AiRouteDecision> {
const scores = this.scoreLiveActionShot(input.shot);
const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage());
const manualProviderCode = this.normalizeText(input.manual_provider_code);
if (manualProviderCode && input.allow_manual_override) {
return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
}
if (manualProviderCode && !input.allow_manual_override) {
throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN');
}
const config = await this.loadRouterConfig();
const languageConfig = this.resolveLiveActionLanguageConfig(config, language);
const tierConfig = this.jsonObject(languageConfig[scores.route_tier]);
const primaryProviderCode =
this.normalizeText(tierConfig.provider_code) ??
(scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast');
const fallbackChain = this.uniqueStrings([
primaryProviderCode,
...this.stringArray(tierConfig.fallback_chain),
'mock-video'
]);
return this.selectVideoProviderFromCandidates({
language,
duration: input.duration,
scores,
fallbackChain,
maxCostPerClip: input.max_cost_per_clip ?? null,
dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget),
manualOverride: false,
defaultReason: `auto_${scores.route_tier}_route`
});
}
private async resolveManualVideoProvider(
providerCode: string,
language: string,
duration: number,
scores: AiRouterShotScores
): Promise<AiRouteDecision> {
return this.selectVideoProviderFromCandidates({
language,
duration,
scores,
fallbackChain: [providerCode],
maxCostPerClip: null,
dailyBudget: 0,
manualOverride: true,
defaultReason: 'manual_provider_override'
});
}
private async selectVideoProviderFromCandidates(input: {
language: string;
duration: number;
scores: AiRouterShotScores;
fallbackChain: string[];
maxCostPerClip: number | null;
dailyBudget: number;
manualOverride: boolean;
defaultReason: string;
}): Promise<AiRouteDecision> {
const providers = await this.prisma.providerConfig.findMany({
where: {
provider_type: 'VideoProvider',
provider_code: { in: input.fallbackChain }
}
});
const providerByCode = new Map(providers.map((provider) => [provider.provider_code, provider]));
const usedToday = input.dailyBudget > 0 ? await this.getTodayProviderCost() : 0;
const candidates: AiRouteDecision['candidates'] = [];
for (const providerCode of input.fallbackChain) {
const provider = providerByCode.get(providerCode);
const estimatedCost = provider
? this.estimateVideoCost(provider.cost_rule_json, input.duration)
: 0;
if (!provider) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'provider_not_found',
estimated_cost: estimatedCost
});
continue;
}
if (!provider.is_enabled) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'provider_disabled',
estimated_cost: estimatedCost
});
continue;
}
if (input.maxCostPerClip && input.maxCostPerClip > 0 && estimatedCost > input.maxCostPerClip) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'max_cost_per_clip_exceeded',
estimated_cost: estimatedCost
});
continue;
}
if (input.dailyBudget > 0 && usedToday + estimatedCost > input.dailyBudget) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'router_daily_budget_exceeded',
estimated_cost: estimatedCost
});
continue;
}
candidates.push({
provider_code: providerCode,
status: 'selected',
reason: input.defaultReason,
estimated_cost: estimatedCost
});
return {
config_key: AI_ROUTER_CONFIG_KEY,
task_type: 'live_action_video_clip_generate',
language: input.language,
provider_code: provider.provider_code,
provider_id: provider.id.toString(),
provider_mode: provider.mode,
route_tier: input.scores.route_tier,
fallback_chain: input.fallbackChain,
candidates,
decision_reason: input.defaultReason,
estimated_cost: estimatedCost,
manual_override: input.manualOverride,
scores: input.scores
};
}
throw new BadRequestException({
message: 'AI_ROUTER_NO_VIDEO_PROVIDER_AVAILABLE',
candidates
});
}
private async loadRouterConfig() {
const config = await this.prisma.systemConfig.upsert({
where: { config_key: AI_ROUTER_CONFIG_KEY },
update: {},
create: {
config_key: AI_ROUTER_CONFIG_KEY,
config_value: DEFAULT_AI_ROUTER_CONFIG,
description: 'AI Router V1 route config for automatic provider selection by language, shot score and budget.',
is_public: false
}
});
return this.jsonObject(config.config_value ?? DEFAULT_AI_ROUTER_CONFIG);
}
private async resolveDefaultLanguage() {
const config = await this.loadRouterConfig();
return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE;
}
private resolveLiveActionLanguageConfig(config: Record<string, unknown>, language: string) {
const liveAction = this.jsonObject(config.live_action_video);
const current = this.jsonObject(liveAction[language]);
if (Object.keys(current).length > 0) return current;
return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]);
}
private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) {
const costRule = this.jsonObject(rule);
const flatCost = this.numberFromJson(costRule.flat_cost);
const pricePerSecond = this.numberFromJson(costRule.price_per_second);
const pricePerClip = this.numberFromJson(costRule.price_per_clip);
const durations = this.splitProviderClipDurations(duration);
const cost = durations.reduce(
(sum, clipDuration) => sum + flatCost + pricePerClip + clipDuration * pricePerSecond,
0
);
return Number(cost.toFixed(4));
}
private splitProviderClipDurations(duration: number) {
const normalized = Number(Math.max(1, duration).toFixed(2));
if (normalized <= ROUTER_MAX_PROVIDER_CLIP_SECONDS) {
return [normalized];
}
const count = Math.ceil(normalized / ROUTER_MAX_PROVIDER_CLIP_SECONDS);
const base = Number((normalized / count).toFixed(2));
const durations = Array.from({ length: count }, () => base);
const total = Number(durations.reduce((sum, item) => sum + item, 0).toFixed(2));
const diff = Number((normalized - total).toFixed(2));
durations[durations.length - 1] = Number((durations[durations.length - 1] + diff).toFixed(2));
return durations;
}
private async getTodayProviderCost() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await this.prisma.providerLog.aggregate({
where: {
status: 'success',
created_at: { gte: today }
},
_sum: { cost_actual: true }
});
return result._sum.cost_actual ? Number(result._sum.cost_actual.toString()) : 0;
}
private inferSceneType(shot: StoryboardShot) {
const text = this.shotText(shot);
if (/(打|追|跑|撞|爆|战|枪|刀|车祸|逃|搏斗|扇|摔)/.test(text)) return 'action';
if (/(哭|崩溃|表白|分手|争吵|怒|吻|求婚|告白)/.test(text)) return 'emotion';
if (shot.dialogue_text && shot.dialogue_text.length >= (shot.narration_text?.length ?? 0)) return 'dialog';
if (/(远景|空镜|转场|环境|街道|夜景|大楼)/.test(text)) return 'establishing';
return 'dialog';
}
private inferImportanceScore(shot: StoryboardShot, sceneType: string) {
const text = this.shotText(shot);
let score = sceneType === 'establishing' ? 2 : 3;
if (shot.shot_no === 1) score += 1;
if (/(主角|男主|女主|第一次|登场|相遇|重逢)/.test(text)) score += 2;
if (/(打脸|反转|真相|高潮|大结局|求婚|婚礼|分手|车祸|死亡|曝光|证据)/.test(text)) score += 3;
if (/(吻|接吻|表白|崩溃|哭|下跪|复仇|救人)/.test(text)) score += 2;
if (shot.effect_type && shot.effect_type !== 'none') score += 1;
return score;
}
private inferEmotionScore(shot: StoryboardShot) {
const text = this.shotText(shot);
let score = 2;
if (/(争吵|愤怒|怒|质问|冷笑|羞辱)/.test(text)) score += 3;
if (/(哭|崩溃|绝望|心碎|分手)/.test(text)) score += 5;
if (/(表白|告白|求婚|拥抱|吻|接吻)/.test(text)) score += 5;
if (/[!]{1,}/.test(text)) score += 1;
return score;
}
private inferActionScore(shot: StoryboardShot) {
const text = this.shotText(shot);
let score = 1;
if (/(走|转身|推门|靠近)/.test(text)) score += 1;
if (/(跑|追|开车|车|摔|扇|打|抢|逃)/.test(text)) score += 4;
if (/(打架|搏斗|爆炸|枪|刀|车祸|坠落|火灾)/.test(text)) score += 6;
if (/(多人|群像|人群)/.test(text)) score += 2;
return score;
}
private shotText(shot: StoryboardShot) {
return [
shot.scene_name,
shot.location_desc,
shot.visual_desc,
shot.action_desc,
shot.dialogue_text,
shot.narration_text,
shot.actor_action,
shot.performance_instruction
]
.filter(Boolean)
.join(' ');
}
private routeTierForScores(importanceScore: number, actionScore: number): AiRouteTier {
return importanceScore > 7 || actionScore > 5 ? 'premium' : 'normal';
}
private normalizeRouteTier(value: string | null): AiRouteTier | null {
return value === 'premium' || value === 'normal' ? value : null;
}
private normalizeSceneType(value: string | null) {
const normalized = this.normalizeText(value);
return normalized ? normalized.slice(0, 50) : null;
}
private clampScore(value: number) {
if (!Number.isFinite(value)) return 1;
return Math.max(1, Math.min(10, Math.round(value)));
}
private uniqueStrings(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
private stringArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => this.normalizeText(item)).filter((item): item is string => Boolean(item))
: [];
}
private normalizeText(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private numberFromJson(value: unknown) {
const numberValue = Number(value ?? 0);
return Number.isFinite(numberValue) ? numberValue : 0;
}
private jsonObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Prisma } from '@prisma/client';
export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1';
export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN';
export const DEFAULT_AI_ROUTER_CONFIG = {
version: 1,
enabled: true,
default_language: AI_ROUTER_DEFAULT_LANGUAGE,
daily_budget: 500,
live_action_video: {
'zh-CN': {
thresholds: {
premium_importance_gt: 7,
premium_action_gt: 5
},
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
},
premium: {
provider_code: 'kling-image-to-video',
fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
}
}
}
} satisfies Prisma.InputJsonObject;
export type AiRouteTier = 'normal' | 'premium';
export interface AiRouterShotScores {
scene_type: string;
importance_score: number;
emotion_score: number;
action_score: number;
route_tier: AiRouteTier;
}
export interface AiRouteDecision {
config_key: string;
task_type: 'live_action_video_clip_generate';
language: string;
provider_code: string;
provider_id: string | null;
provider_mode: string | null;
route_tier: AiRouteTier;
fallback_chain: string[];
candidates: Array<{
provider_code: string;
status: 'selected' | 'skipped';
reason: string;
estimated_cost: number;
}>;
decision_reason: string;
estimated_cost: number;
manual_override: boolean;
scores: AiRouterShotScores;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { AppController } from './app.controller';
describe('AppController', () => {
it('returns health status', () => {
const controller = new AppController();
expect(controller.getHealth()).toEqual({
status: 'ok',
service: 'backend-api'
});
});
});
+20
View File
@@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get()
getRoot() {
return {
service: 'ai-manga-backend',
stage: 'stage-03-auth'
};
}
@Get('health')
getHealth() {
return {
status: 'ok',
service: 'backend-api'
};
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetsModule } from './assets/assets.module';
import { AuthModule } from './auth/auth.module';
import { BillingModule } from './billing/billing.module';
import { CharactersModule } from './characters/characters.module';
import { ApiCryptoController, ClientConfigController } from './common/api-crypto.controller';
import { ApiCryptoService } from './common/api-crypto.service';
import { AllExceptionsFilter } from './common/all-exceptions.filter';
import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { EncryptedRequestMiddleware } from './common/encrypted-request.middleware';
import { RequestIdMiddleware } from './common/request-id.middleware';
import { SecureTransportMiddleware } from './common/secure-transport.middleware';
import { EpisodesModule } from './episodes/episodes.module';
import { ImagesModule } from './images/images.module';
import { LiveActionModule } from './live-action/live-action.module';
import { MediaModule } from './media/media.module';
import { MemoriesModule } from './memories/memories.module';
import { NovelsModule } from './novels/novels.module';
import { ProjectsModule } from './projects/projects.module';
import { ProvidersModule } from './providers/providers.module';
import { PrismaModule } from './prisma/prisma.module';
import { QueuesModule } from './queues/queues.module';
import { ReviewsModule } from './reviews/reviews.module';
import { ScriptsModule } from './scripts/scripts.module';
import { StoryBiblesModule } from './story-bibles/story-bibles.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
PrismaModule,
UsersModule,
AuthModule,
BillingModule,
ProjectsModule,
AssetsModule,
NovelsModule,
StoryBiblesModule,
CharactersModule,
MemoriesModule,
EpisodesModule,
ScriptsModule,
QueuesModule,
ProvidersModule,
ReviewsModule,
ImagesModule,
LiveActionModule,
MediaModule,
AdminModule
],
controllers: [AppController, ApiCryptoController, ClientConfigController],
providers: [
ApiCryptoService,
{
provide: APP_INTERCEPTOR,
useClass: ApiResponseInterceptor
},
{
provide: APP_FILTER,
useClass: AllExceptionsFilter
}
]
})
export class AppModule {
configure(consumer: import('@nestjs/common').MiddlewareConsumer) {
consumer
.apply(RequestIdMiddleware, SecureTransportMiddleware, EncryptedRequestMiddleware)
.forRoutes('*');
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Asset } from '@prisma/client';
export type AssetType = 'novel_text' | 'image' | 'audio' | 'video' | 'document';
export interface StoredObject {
file_path: string;
size: bigint;
hash: string;
backend: 'local' | 'minio';
}
export interface SafeAsset {
id: string;
user_id: string | null;
project_id: string | null;
asset_type: string;
file_path: string;
mime_type: string | null;
width: number | null;
height: number | null;
duration: string | null;
size: string | null;
hash: string | null;
visibility: string;
status: string;
created_at: string;
}
export function toSafeAsset(asset: Asset): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
project_id: asset.project_id?.toString() ?? null,
asset_type: asset.asset_type,
file_path: asset.file_path,
mime_type: asset.mime_type,
width: asset.width,
height: asset.height,
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
};
}
+163
View File
@@ -0,0 +1,163 @@
import {
BadRequestException,
Body,
Controller,
Get,
Inject,
Param,
Post,
Req,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
function parseUploadLimitBytes(value: string | undefined, fallback: number) {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/.exec(normalized);
if (!match) return fallback;
const numberValue = Number(match[1]);
const unit = match[2] || 'b';
const multiplier =
unit === 'gb' ? 1024 * 1024 * 1024 :
unit === 'mb' ? 1024 * 1024 :
unit === 'kb' ? 1024 :
1;
return Number.isFinite(numberValue) && numberValue > 0
? Math.floor(numberValue * multiplier)
: fallback;
}
@Controller()
@UseGuards(JwtAuthGuard)
export class AssetsController {
constructor(@Inject(AssetsService) private readonly assetsService: AssetsService) {}
@Post('assets/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadAsset(
@CurrentUser() user: AuthRequestUser,
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadAssetDto & Record<string, unknown>
) {
const uploadFile = file ?? this.fileFromEncryptedBody(dto);
return this.assetsService.uploadAsset(
user,
uploadFile,
dto.asset_type || 'document',
dto.project_id
);
}
@Post('projects/:projectId/novel/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadNovel(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@UploadedFile() file: Express.Multer.File,
@Body() body: Record<string, unknown>
) {
return this.assetsService.uploadNovelFile(user, projectId, file ?? this.fileFromEncryptedBody(body));
}
@Get('assets/:assetId')
getAsset(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
mime_type: result.asset.mime_type || 'application/octet-stream',
size: result.buffer.length,
content_base64: result.buffer.toString('base64')
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return new StreamableFile(result.buffer);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
const filePayload = body?.file;
if (typeof filePayload !== 'object' || filePayload === null) {
throw new BadRequestException('Uploaded file is required');
}
const fileRecord = filePayload as Record<string, unknown>;
const originalName = String(fileRecord.original_name || fileRecord.name || 'upload.bin');
const mimeType = String(fileRecord.mime_type || 'application/octet-stream');
const contentBase64 = fileRecord.content_base64;
if (typeof contentBase64 !== 'string') {
throw new BadRequestException('Encrypted uploaded file content is required');
}
const buffer = Buffer.from(contentBase64, 'base64');
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new BadRequestException('Uploaded file is too large');
}
return {
fieldname: 'file',
originalname: originalName,
encoding: '7bit',
mimetype: mimeType,
size: buffer.length,
buffer
} as Express.Multer.File;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectsModule } from '../projects/projects.module';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import { PublicTempAssetsController } from './public-temp-assets.controller';
import { StorageService } from './storage.service';
@Module({
imports: [AuthModule, ProjectsModule],
controllers: [AssetsController, PublicTempAssetsController],
providers: [AssetsService, StorageService],
exports: [AssetsService, StorageService]
})
export class AssetsModule {}
+151
View File
@@ -0,0 +1,151 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AssetsService } from './assets.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProjectsService } from '../projects/projects.service';
import type { StorageService } from './storage.service';
import type { AuthRequestUser } from '../auth/auth.types';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
return {
fieldname: 'file',
originalname: 'novel.txt',
encoding: '7bit',
mimetype: 'text/plain',
size: 12,
buffer: Buffer.from('hello novel'),
destination: '',
filename: '',
path: '',
stream: undefined as never,
...overrides
};
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
beforeEach(() => {
prisma = {
project: {
findUnique: vi.fn()
},
asset: {
create: vi.fn(),
findUnique: vi.fn()
}
};
storage = {
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://novels/test.txt',
size: 12n,
hash: 'hash',
backend: 'local'
}),
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('video bytes'))
};
projectsService = {
assertProjectOwner: vi.fn().mockResolvedValue(100n)
};
service = new AssetsService(
prisma as unknown as PrismaService,
storage as StorageService,
projectsService as ProjectsService
);
});
it('stores novel uploads as private assets', async () => {
prisma.asset.create.mockResolvedValue({
id: 200n,
user_id: 1n,
project_id: 100n,
asset_type: 'novel_text',
file_path: 'local://novels/test.txt',
file_url: null,
mime_type: 'text/plain',
width: null,
height: null,
duration: null,
size: 12n,
hash: 'hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.uploadNovelFile(user, '100', createFile());
expect(storage.storePrivateFile).toHaveBeenCalledWith(
expect.objectContaining({ originalname: 'novel.txt' }),
'novels'
);
expect(prisma.asset.create).toHaveBeenCalledWith({
data: expect.objectContaining({
asset_type: 'novel_text',
visibility: 'private',
file_url: null
})
});
expect(result.asset.visibility).toBe('private');
expect(result.next_step).toBe('copyright_confirm');
});
it('rejects unsupported novel file types', async () => {
await expect(
service.uploadNovelFile(
user,
'100',
createFile({ originalname: 'novel.exe', mimetype: 'application/octet-stream' })
)
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects uploads to projects owned by others', async () => {
vi.mocked(projectsService.assertProjectOwner).mockRejectedValue(
new ForbiddenException('Project is private')
);
await expect(service.uploadNovelFile(user, '100', createFile())).rejects.toBeInstanceOf(
ForbiddenException
);
});
it('returns a private file buffer for owned assets', async () => {
prisma.asset.findUnique.mockResolvedValue({
id: 300n,
user_id: 1n,
project_id: 100n,
asset_type: 'video',
file_path: 'local://videos/final.mp4',
file_url: null,
mime_type: 'video/mp4',
width: 1080,
height: 1920,
duration: 4,
size: 11n,
hash: 'video-hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.downloadAssetForUser(user, '300');
expect(storage.readPrivateFile).toHaveBeenCalledWith('local://videos/final.mp4');
expect(result.asset.id).toBe('300');
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
});
+192
View File
@@ -0,0 +1,192 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types';
import { StorageService } from './storage.service';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
'text/markdown',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream'
]);
@Injectable()
export class AssetsService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(StorageService)
private readonly storage: StorageService,
@Inject(ProjectsService)
private readonly projectsService: ProjectsService
) {}
async uploadAsset(
user: AuthRequestUser,
file: Express.Multer.File,
assetType: AssetType = 'document',
projectId?: string
) {
const projectBigInt = projectId
? await this.projectsService.assertProjectOwner(projectId, user)
: null;
const stored = await this.storage.storePrivateFile(file, assetType);
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: assetType,
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || null,
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend
};
}
async uploadNovelFile(
user: AuthRequestUser,
projectId: string,
file: Express.Multer.File
) {
this.validateNovelFile(file);
const projectBigInt = await this.projectsService.assertProjectOwner(projectId, user);
const stored = await this.storage.storePrivateFile(file, 'novels');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: 'novel_text',
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || 'text/plain',
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend,
next_step: 'copyright_confirm'
};
}
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
buffer,
filename: this.buildDownloadFilename(asset)
};
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new NotFoundException('Asset not found');
}
return asset;
}
private buildDownloadFilename(asset: Asset) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
}
private validateNovelFile(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('Novel file is required');
}
const lowerName = file.originalname.toLowerCase();
const hasAllowedExtension =
lowerName.endsWith('.txt') ||
lowerName.endsWith('.md') ||
lowerName.endsWith('.docx') ||
lowerName.endsWith('.pdf');
if (!hasAllowedExtension || !ALLOWED_NOVEL_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException('Only txt, md, docx, and text pdf novel files are supported now');
}
}
private parseId(id: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException('Invalid id');
}
}
private extensionFromMime(mimeType: string | null | undefined) {
switch (mimeType) {
case 'video/mp4':
return '.mp4';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/mpeg':
return '.mp3';
case 'application/x-subrip':
return '.srt';
case 'image/svg+xml':
return '.svg';
case 'image/png':
return '.png';
case 'image/jpeg':
return '.jpg';
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@Controller('public-temp-assets')
export class PublicTempAssetsController {
constructor(@Inject(StorageService) private readonly storage: StorageService) {}
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
}
}
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StorageService } from './storage.service';
describe('StorageService temporary public URLs', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.PUBLIC_ASSET_BASE_URL;
delete process.env.PUBLIC_ASSET_SIGNING_SECRET;
});
it('creates a signed temporary URL and reads the private object through the token', async () => {
process.env.PUBLIC_ASSET_BASE_URL = 'https://api.example.com';
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
const readSpy = vi.spyOn(service, 'readPrivateFile').mockResolvedValue(Buffer.from('video-bytes'));
const url = service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4',
expiresInSeconds: 600
});
const token = new URL(url).pathname.split('/').pop() || '';
const result = await service.readTemporaryPublicFile(decodeURIComponent(token));
expect(url).toMatch(/^https:\/\/api\.example\.com\/api\/public-temp-assets\//);
expect(readSpy).toHaveBeenCalledWith('local://live-action-video-clips/source.mp4');
expect(result.mimeType).toBe('video/mp4');
expect(result.buffer.toString()).toBe('video-bytes');
});
it('requires a public base URL before minting temporary links', () => {
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
expect(() =>
service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4'
})
).toThrow(BadRequestException);
});
});
+284
View File
@@ -0,0 +1,284 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { Client } from 'minio';
import type { StoredObject } from './asset.types';
type TemporaryPublicFilePayload = {
file_path: string;
mime_type: string;
expires_at: number;
nonce: string;
};
@Injectable()
export class StorageService {
private readonly root = this.resolveLocalRoot(process.env.LOCAL_STORAGE_ROOT || '../storage');
private readonly privateBucket =
process.env.MINIO_BUCKET_PRIVATE || 'ai-manga-private';
async storePrivateFile(file: Express.Multer.File, prefix: string): Promise<StoredObject> {
if (!file?.buffer?.length) {
throw new BadRequestException('Uploaded file is empty');
}
if (this.shouldUseMinio()) {
return this.storeWithMinio(file, prefix);
}
return this.storeLocally(file, prefix);
}
async readPrivateFile(filePath: string): Promise<Buffer> {
if (filePath.startsWith('local://')) {
return this.readLocalObject(filePath);
}
if (filePath.startsWith('minio://')) {
return this.readMinioObject(filePath);
}
throw new BadRequestException('Unsupported storage path');
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
expiresInSeconds?: number | null;
}) {
const baseUrl = this.resolvePublicAssetBaseUrl();
const expiresInSeconds = this.normalizeTemporaryUrlExpires(input.expiresInSeconds);
const payload: TemporaryPublicFilePayload = {
file_path: input.filePath,
mime_type: input.mimeType || 'application/octet-stream',
expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds,
nonce: randomUUID()
};
const payloadPart = this.base64UrlEncode(Buffer.from(JSON.stringify(payload), 'utf8'));
const signature = this.signTemporaryPublicPayload(payloadPart);
const token = `${payloadPart}.${signature}`;
return `${baseUrl}/public-temp-assets/${encodeURIComponent(token)}`;
}
async readTemporaryPublicFile(token: string) {
const payload = this.verifyTemporaryPublicToken(token);
return {
buffer: await this.readPrivateFile(payload.file_path),
mimeType: payload.mime_type || 'application/octet-stream',
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const safePrefix = prefix.replace(/[^a-z0-9/_-]/gi, '_');
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${safePrefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
const fullPath = join(this.root, 'private', objectName);
await mkdir(join(this.root, 'private', safePrefix), { recursive: true });
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, file.buffer);
return {
file_path: `local://${objectName}`,
size: BigInt(file.size),
hash,
backend: 'local'
};
}
private async storeWithMinio(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const exists = await client.bucketExists(this.privateBucket).catch(() => false);
if (!exists) {
await client.makeBucket(this.privateBucket);
}
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${prefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
await client.putObject(this.privateBucket, objectName, file.buffer, file.size, {
'Content-Type': file.mimetype
});
return {
file_path: `minio://${this.privateBucket}/${objectName}`,
size: BigInt(file.size),
hash,
backend: 'minio'
};
}
private async readLocalObject(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path');
}
return readFile(join(this.root, 'private', objectName));
}
private async readMinioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
}
const [, bucket, objectName] = match;
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private async streamToBuffer(stream: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
private shouldUseMinio() {
return process.env.STORAGE_DRIVER === 'minio';
}
private resolvePublicAssetBaseUrl() {
const configured =
process.env.PUBLIC_ASSET_BASE_URL ||
process.env.PUBLIC_API_BASE_URL ||
process.env.API_PUBLIC_BASE_URL ||
process.env.APP_PUBLIC_URL ||
process.env.PUBLIC_BASE_URL ||
'';
const normalized = configured.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(normalized)) {
throw new BadRequestException('PUBLIC_ASSET_BASE_URL_REQUIRED');
}
return normalized.endsWith('/api') ? normalized : `${normalized}/api`;
}
private normalizeTemporaryUrlExpires(value: number | null | undefined) {
const numeric = Number(value ?? process.env.PUBLIC_ASSET_URL_EXPIRES_SECONDS ?? 3600);
if (!Number.isFinite(numeric)) return 3600;
return Math.min(Math.max(Math.round(numeric), 60), 24 * 60 * 60);
}
private verifyTemporaryPublicToken(token: string): TemporaryPublicFilePayload {
const [payloadPart, signature] = String(token || '').split('.');
if (!payloadPart || !signature) {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_TOKEN');
}
const expected = this.signTemporaryPublicPayload(payloadPart);
if (!this.safeEqualBase64Url(signature, expected)) {
throw new ForbiddenException('INVALID_TEMP_PUBLIC_ASSET_SIGNATURE');
}
let payload: TemporaryPublicFilePayload;
try {
payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as TemporaryPublicFilePayload;
} catch {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PAYLOAD');
}
if (!payload.file_path || typeof payload.file_path !== 'string') {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PATH');
}
if (!Number.isFinite(payload.expires_at) || payload.expires_at < Math.floor(Date.now() / 1000)) {
throw new ForbiddenException('TEMP_PUBLIC_ASSET_EXPIRED');
}
return payload;
}
private signTemporaryPublicPayload(payloadPart: string) {
return this.base64UrlEncode(
createHmac('sha256', this.resolveTemporaryPublicAssetSecret())
.update(payloadPart)
.digest()
);
}
private resolveTemporaryPublicAssetSecret() {
const secret =
process.env.PUBLIC_ASSET_SIGNING_SECRET ||
process.env.TEMP_PUBLIC_ASSET_SECRET ||
process.env.JWT_SECRET ||
'';
if (!secret || secret.length < 16) {
throw new BadRequestException('PUBLIC_ASSET_SIGNING_SECRET_REQUIRED');
}
return secret;
}
private safeEqualBase64Url(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
private base64UrlEncode(buffer: Buffer) {
return buffer.toString('base64url');
}
private resolveLocalRoot(root: string) {
if (isAbsolute(root)) return root;
// Resolve relative storage roots from the backend package directory so
// starting the server from repo root or backend/ cannot split local files.
return resolve(__dirname, '../..', root);
}
private extensionFromMime(mimeType: string | undefined) {
switch (mimeType) {
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { AssetType } from './asset.types';
export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}
+44
View File
@@ -0,0 +1,44 @@
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator';
import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import type { AuthRequestUser } from './auth.types';
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout() {
return { logged_out: true };
}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
@Controller()
export class ProfileController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
+10
View File
@@ -0,0 +1,10 @@
export class RegisterDto {
email?: string;
password?: string;
nickname?: string;
}
export class LoginDto {
email?: string;
password?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module';
import { AuthController, ProfileController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
const jwtExpiresIn = (process.env.JWT_EXPIRES_IN ?? '7d') as JwtSignOptions['expiresIn'];
@Module({
imports: [
UsersModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'dev_only_change_me',
signOptions: {
expiresIn: jwtExpiresIn
}
})
],
controllers: [AuthController, ProfileController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, JwtModule]
})
export class AuthModule {}
+129
View File
@@ -0,0 +1,129 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
import type { SafeUser } from '../users/user.types';
import type { UsersService } from '../users/users.service';
const safeUser: SafeUser = {
id: '1',
email: 'user@example.com',
phone: null,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
created_at: '2026-05-31T00:00:00.000Z'
};
function createPrismaUser(passwordHash: string) {
return {
id: 1n,
email: 'user@example.com',
phone: null,
password_hash: passwordHash,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
last_login_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
};
}
describe('AuthService', () => {
let usersService: Pick<
UsersService,
'findByEmail' | 'findById' | 'createUser' | 'toSafeUser'
>;
let jwtService: Pick<JwtService, 'sign'>;
let service: AuthService;
beforeEach(() => {
usersService = {
findByEmail: vi.fn(),
findById: vi.fn(),
createUser: vi.fn(),
toSafeUser: vi.fn()
};
jwtService = {
sign: vi.fn(() => 'signed.jwt.token')
};
service = new AuthService(usersService as UsersService, jwtService as JwtService);
});
it('registers an active user and returns a token', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const result = await service.register({
email: ' USER@example.com ',
password: 'password123',
nickname: 'User'
});
expect(usersService.findByEmail).toHaveBeenCalledWith('user@example.com');
expect(usersService.createUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
nickname: 'User'
})
);
expect(result).toMatchObject({
access_token: 'signed.jwt.token',
token_type: 'Bearer',
user: safeUser
});
});
it('rejects duplicate email registration', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(createPrismaUser('hash'));
await expect(
service.register({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(ConflictException);
});
it('logs in with a valid password', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const registered = await service.register({
email: 'user@example.com',
password: 'password123'
});
const passwordHash = vi.mocked(usersService.createUser).mock.calls[0]?.[0]
.password_hash;
expect(registered.access_token).toBe('signed.jwt.token');
vi.mocked(usersService.findByEmail).mockResolvedValue(
createPrismaUser(passwordHash)
);
vi.mocked(usersService.toSafeUser).mockReturnValue(safeUser);
const result = await service.login({
email: 'user@example.com',
password: 'password123'
});
expect(result.user).toEqual(safeUser);
});
it('rejects invalid login credentials', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
await expect(
service.login({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+110
View File
@@ -0,0 +1,110 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { UsersService } from '../users/users.service';
import type { LoginDto, RegisterDto } from './auth.dto';
import type { AuthResult, AuthRequestUser, JwtPayload } from './auth.types';
const PASSWORD_MIN_LENGTH = 8;
@Injectable()
export class AuthService {
constructor(
@Inject(UsersService)
private readonly usersService: UsersService,
@Inject(JwtService)
private readonly jwtService: JwtService
) {}
async register(dto: RegisterDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await hash(password, 12);
const user = await this.usersService.createUser({
email,
password_hash: passwordHash,
nickname: this.normalizeOptionalText(dto.nickname)
});
return this.createAuthResult(user);
}
async login(dto: LoginDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const user = await this.usersService.findByEmail(email);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('Invalid email or password');
}
const passwordMatches = await compare(password, user.password_hash);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid email or password');
}
return this.createAuthResult(this.usersService.toSafeUser(user));
}
async getProfile(currentUser: AuthRequestUser) {
const user = await this.usersService.findById(currentUser.id);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('User is unavailable');
}
return this.usersService.toSafeUser(user);
}
private createAuthResult(user: AuthResult['user']): AuthResult {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role
};
return {
access_token: this.jwtService.sign(payload),
token_type: 'Bearer',
expires_in: process.env.JWT_EXPIRES_IN ?? '7d',
user
};
}
private normalizeEmail(email: string | undefined) {
const value = email?.trim().toLowerCase();
if (!value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new BadRequestException('Valid email is required');
}
return value;
}
private validatePassword(password: string | undefined) {
if (!password || password.length < PASSWORD_MIN_LENGTH) {
throw new BadRequestException(
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`
);
}
return password;
}
private normalizeOptionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { SafeUser } from '../users/user.types';
export interface JwtPayload {
sub: string;
email: string | null;
role: string;
}
export interface AuthRequestUser {
id: string;
email: string | null;
role: string;
}
export interface AuthResult {
access_token: string;
token_type: 'Bearer';
expires_in: string;
user: SafeUser;
}
@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
interface RequestWithUser {
user?: AuthRequestUser;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<RequestWithUser>();
return request.user;
}
);
+51
View File
@@ -0,0 +1,51 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, vi } from 'vitest';
import { JwtAuthGuard } from './jwt-auth.guard';
function createContext(headers: Record<string, string | undefined>) {
const request = { headers };
return {
request,
context: {
switchToHttp: () => ({
getRequest: () => request
})
}
};
}
describe('JwtAuthGuard', () => {
it('attaches user payload for valid bearer tokens', async () => {
const jwtService = {
verifyAsync: vi.fn().mockResolvedValue({
sub: '1',
email: 'user@example.com',
role: 'user'
})
};
const guard = new JwtAuthGuard(jwtService as unknown as JwtService);
const { context, request } = createContext({
authorization: 'Bearer valid-token'
});
await expect(guard.canActivate(context as never)).resolves.toBe(true);
expect(request).toMatchObject({
user: {
id: '1',
email: 'user@example.com',
role: 'user'
}
});
});
it('rejects missing bearer tokens', async () => {
const guard = new JwtAuthGuard({ verifyAsync: vi.fn() } as unknown as JwtService);
const { context } = createContext({});
await expect(guard.canActivate(context as never)).rejects.toBeInstanceOf(
UnauthorizedException
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AuthRequestUser, JwtPayload } from './auth.types';
interface AuthenticatedRequest {
headers: Record<string, string | string[] | undefined>;
user?: AuthRequestUser;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@Inject(JwtService) private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException('Missing bearer token');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
request.user = {
id: payload.sub,
email: payload.email,
role: payload.role
};
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(authorization: string | string[] | undefined) {
const header = Array.isArray(authorization) ? authorization[0] : authorization;
if (!header) {
return null;
}
const [type, token] = header.split(' ');
return type?.toLowerCase() === 'bearer' && token ? token : null;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ForbiddenException } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
export const ADMIN_PERMISSIONS = [
'admin:read',
'projects:write',
'users:read',
'users:write',
'billing:read',
'billing:write',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'providers:write',
'costs:read',
'settings:read',
'settings:write',
'audit:read',
'audit:export'
] as const;
export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
const ROLE_PERMISSIONS: Record<string, readonly AdminPermission[] | '*'> = {
admin: '*',
operator: [
'admin:read',
'projects:write',
'users:read',
'billing:read',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'costs:read',
'audit:read'
],
finance: [
'admin:read',
'users:read',
'billing:read',
'billing:write',
'tasks:read',
'costs:read',
'audit:read',
'audit:export'
],
auditor: [
'admin:read',
'users:read',
'billing:read',
'reviews:read',
'tasks:read',
'providers:read',
'costs:read',
'settings:read',
'audit:read',
'audit:export'
]
};
export function permissionsForRole(role: string) {
const permissions = ROLE_PERMISSIONS[role];
return permissions === '*' ? [...ADMIN_PERMISSIONS] : [...(permissions ?? [])];
}
export function hasPermission(user: AuthRequestUser, permission: AdminPermission) {
return permissionsForRole(user.role).includes(permission);
}
export function assertPermission(user: AuthRequestUser, permission: AdminPermission) {
if (!hasPermission(user, permission)) {
throw new ForbiddenException(`Permission required: ${permission}`);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminAdjustQuotaDto,
AdminGrantQuotaDto,
AdminListQuotaAccountsQueryDto,
CreateOrderDto,
FreezeProjectQuotaDto,
ListOrdersQueryDto,
ListQuotaLogsQueryDto,
ReleaseProjectQuotaDto
} from './billing.dto';
import { BillingService } from './billing.service';
@Controller()
export class BillingController {
constructor(@Inject(BillingService) private readonly billingService: BillingService) {}
@Get('billing/packages')
listPackages() {
return this.billingService.listPackages();
}
@Get('billing/quota')
@UseGuards(JwtAuthGuard)
getQuota(@CurrentUser() user: AuthRequestUser) {
return this.billingService.getQuotaAccount(user);
}
@Get('billing/quota/logs')
@UseGuards(JwtAuthGuard)
listQuotaLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: ListQuotaLogsQueryDto
) {
return this.billingService.listQuotaLogs(user, query);
}
@Get('billing/orders')
@UseGuards(JwtAuthGuard)
listOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) {
return this.billingService.listMyOrders(user, query);
}
@Post('billing/orders')
@UseGuards(JwtAuthGuard)
createOrder(@CurrentUser() user: AuthRequestUser, @Body() dto: CreateOrderDto) {
return this.billingService.createOrder(user, dto);
}
@Post('billing/orders/:orderId/mock-pay')
@UseGuards(JwtAuthGuard)
mockPayOrder(@CurrentUser() user: AuthRequestUser, @Param('orderId') orderId: string) {
return this.billingService.mockPayOrder(user, orderId);
}
@Get('projects/:projectId/quota/estimate')
@UseGuards(JwtAuthGuard)
estimateProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.billingService.estimateProjectQuota(user, projectId);
}
@Post('projects/:projectId/quota/freeze')
@UseGuards(JwtAuthGuard)
freezeProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: FreezeProjectQuotaDto
) {
return this.billingService.freezeProjectQuota(user, projectId, dto);
}
@Post('projects/:projectId/quota/release')
@UseGuards(JwtAuthGuard)
releaseProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ReleaseProjectQuotaDto
) {
return this.billingService.releaseProjectQuota(user, projectId, dto);
}
@Get('admin/orders')
@UseGuards(JwtAuthGuard)
listAdminOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) {
return this.billingService.listAdminOrders(user, query);
}
@Get('admin/quota-accounts')
@UseGuards(JwtAuthGuard)
listAdminQuotaAccounts(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListQuotaAccountsQueryDto
) {
return this.billingService.listAdminQuotaAccounts(user, query);
}
@Post('admin/users/:userId/quota/grant')
@UseGuards(JwtAuthGuard)
adminGrantQuota(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminGrantQuotaDto
) {
return this.billingService.adminGrantQuota(user, userId, dto);
}
@Post('admin/users/:userId/quota/adjust')
@UseGuards(JwtAuthGuard)
adminAdjustQuota(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminAdjustQuotaDto
) {
return this.billingService.adminAdjustQuota(user, userId, dto);
}
}
+41
View File
@@ -0,0 +1,41 @@
export class CreateOrderDto {
package_code?: string;
project_id?: string;
payment_method?: string;
}
export class ListOrdersQueryDto {
payment_status?: string;
limit?: string;
}
export class ListQuotaLogsQueryDto {
project_id?: string;
change_type?: string;
limit?: string;
}
export class FreezeProjectQuotaDto {
amount?: number;
reason?: string;
}
export class ReleaseProjectQuotaDto {
reason?: string;
}
export class AdminGrantQuotaDto {
amount?: number;
reason?: string;
}
export class AdminAdjustQuotaDto {
delta?: number;
reason?: string;
}
export class AdminListQuotaAccountsQueryDto {
user_id?: string;
status?: string;
limit?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService]
})
export class BillingModule {}
+278
View File
@@ -0,0 +1,278 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { BillingService } from './billing.service';
const now = new Date('2026-05-31T00:00:00.000Z');
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const admin: AuthRequestUser = {
id: '9',
email: 'admin@example.com',
role: 'admin'
};
function createProject(overrides: Record<string, unknown> = {}) {
return {
id: 10n,
user_id: 1n,
title: '额度项目',
input_mode: 'ai_original',
genre: 'urban',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 1,
episode_duration: 60,
status: 'storyboard_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createAccount(overrides: Record<string, unknown> = {}) {
return {
id: 20n,
user_id: 1n,
total_quota: new Prisma.Decimal(120),
available_quota: new Prisma.Decimal(120),
frozen_quota: new Prisma.Decimal(0),
used_quota: new Prisma.Decimal(0),
status: 'active',
created_at: now,
updated_at: now,
...overrides
};
}
function createOrder(overrides: Record<string, unknown> = {}) {
return {
id: 30n,
user_id: 1n,
project_id: null,
order_no: 'ORDTEST',
package_code: 'standard_3ep',
amount: new Prisma.Decimal(199),
currency: 'CNY',
payment_method: 'mock_pay',
payment_status: 'pending',
paid_at: null,
created_at: now,
updated_at: now,
...overrides
};
}
function createLog(overrides: Record<string, unknown> = {}) {
return {
id: 40n,
user_id: 1n,
project_id: 10n,
task_id: null,
change_type: 'freeze',
amount: new Prisma.Decimal(71),
balance_after: new Prisma.Decimal(49),
reason: 'project_generation_freeze',
metadata_json: {},
created_at: now,
...overrides
};
}
describe('BillingService', () => {
let prisma: any;
let service: BillingService;
beforeEach(() => {
prisma = {
$transaction: vi.fn((handler) => handler(prisma)),
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createProject(data)
)
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: 1n })
},
order: {
create: vi.fn().mockResolvedValue(createOrder()),
findUnique: vi.fn().mockResolvedValue(createOrder()),
findMany: vi.fn().mockResolvedValue([createOrder()]),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createOrder(data)
)
},
quotaAccount: {
upsert: vi.fn().mockResolvedValue(createAccount()),
findMany: vi.fn().mockResolvedValue([createAccount()]),
update: vi.fn(async ({ data }: { data: Record<string, Prisma.Decimal> }) =>
createAccount(data)
)
},
quotaLog: {
create: vi.fn().mockResolvedValue(createLog()),
findMany: vi.fn().mockResolvedValue([createLog()])
},
operationLog: {
create: vi.fn().mockResolvedValue({
id: 50n,
user_id: 9n,
operator_role: 'admin',
action: 'admin_adjust_quota',
target_type: 'user',
target_id: 1n,
ip: null,
user_agent: null,
metadata_json: {},
created_at: now
})
}
};
service = new BillingService(prisma as PrismaService);
});
it('lists available billing packages', () => {
const result = service.listPackages();
expect(result.packages.some((pkg) => pkg.code === 'standard_3ep')).toBe(true);
});
it('creates a pending order for a package', async () => {
const result = await service.createOrder(user, { package_code: 'standard_3ep' });
expect(prisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
user_id: 1n,
package_code: 'standard_3ep',
payment_status: 'pending'
})
});
expect(result.order.payment_status).toBe('pending');
});
it('mock pays an order and recharges quota', async () => {
const result = await service.mockPayOrder(user, '30');
expect(prisma.quotaAccount.update).toHaveBeenCalledWith({
where: { user_id: 1n },
data: expect.objectContaining({
total_quota: expect.any(Prisma.Decimal),
available_quota: expect.any(Prisma.Decimal)
})
});
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'recharge'
})
});
expect(result.order.payment_status).toBe('paid');
});
it('freezes project quota and marks project payment as frozen', async () => {
const result = await service.freezeProjectQuota(user, '10');
expect(prisma.quotaAccount.update).toHaveBeenCalledWith({
where: { user_id: 1n },
data: expect.objectContaining({
available_quota: expect.any(Prisma.Decimal),
frozen_quota: expect.any(Prisma.Decimal)
})
});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { payment_status: 'quota_frozen' }
});
expect(result.project_payment_status).toBe('quota_frozen');
});
it('rejects freezing when available quota is insufficient', async () => {
prisma.quotaAccount.upsert.mockResolvedValue(
createAccount({ available_quota: new Prisma.Decimal(1) })
);
await expect(service.freezeProjectQuota(user, '10')).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects formal render when project quota is not frozen', async () => {
await expect(service.ensureProjectQuotaReserved(createProject())).rejects.toBeInstanceOf(
BadRequestException
);
});
it('admin grants quota and writes an operation log', async () => {
const result = await service.adminGrantQuota(admin, '1', {
amount: 20,
reason: 'internal test'
});
expect(result.account.available_quota).toBe(140);
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'admin_grant',
amount: expect.any(Prisma.Decimal),
reason: 'internal test'
})
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_grant_quota',
target_type: 'user',
target_id: 1n
})
});
});
it('admin deducts quota through correction and preserves audit logs', async () => {
const result = await service.adminAdjustQuota(admin, '1', {
delta: -10,
reason: 'wrong manual grant'
});
const updateData = prisma.quotaAccount.update.mock.calls[0][0].data;
expect(updateData.total_quota.toString()).toBe('110');
expect(updateData.available_quota.toString()).toBe('110');
expect(result.account.available_quota).toBe(110);
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'admin_correction_deduct',
amount: expect.any(Prisma.Decimal),
reason: 'wrong manual grant'
})
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_adjust_quota',
metadata_json: expect.objectContaining({
delta: -10,
reason: 'wrong manual grant'
})
})
});
});
it('rejects admin quota deduction when available quota is insufficient', async () => {
prisma.quotaAccount.upsert.mockResolvedValue(
createAccount({ available_quota: new Prisma.Decimal(1) })
);
await expect(
service.adminAdjustQuota(admin, '1', { delta: -10, reason: 'correction' })
).rejects.toBeInstanceOf(BadRequestException);
});
});
+749
View File
@@ -0,0 +1,749 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import { Prisma, type Project } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { assertPermission } from '../auth/rbac';
import { PrismaService } from '../prisma/prisma.service';
import {
AdminAdjustQuotaDto,
AdminGrantQuotaDto,
AdminListQuotaAccountsQueryDto,
CreateOrderDto,
FreezeProjectQuotaDto,
ListOrdersQueryDto,
ListQuotaLogsQueryDto,
ReleaseProjectQuotaDto
} from './billing.dto';
import {
BILLING_PACKAGES,
toSafeOrder,
toSafeQuotaAccount,
toSafeQuotaLog
} from './billing.types';
const DEFAULT_SHOTS_PER_EPISODE = 6;
const QUOTA_COSTS = {
source: 8,
story_bible: 6,
characters: 8,
character_images: 8,
memory: 4,
episode_plan_per_episode: 3,
script_per_episode: 3,
storyboard_per_episode: 4,
shot_image: 2,
audio_per_episode: 2,
subtitle_per_episode: 1,
video_per_episode: 6
} as const;
@Injectable()
export class BillingService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
listPackages() {
return { packages: BILLING_PACKAGES };
}
async getQuotaAccount(user: AuthRequestUser) {
const account = await this.ensureQuotaAccount(BigInt(user.id));
return toSafeQuotaAccount(account);
}
async listMyOrders(user: AuthRequestUser, query: ListOrdersQueryDto) {
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const orders = await this.prisma.order.findMany({
where: {
user_id: BigInt(user.id),
...(query.payment_status ? { payment_status: this.normalizeText(query.payment_status, 50) } : {})
},
orderBy: { created_at: 'desc' },
take: limit
});
return { orders: orders.map(toSafeOrder), total: orders.length, limit };
}
async listQuotaLogs(user: AuthRequestUser, query: ListQuotaLogsQueryDto) {
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const where: Prisma.QuotaLogWhereInput = { user_id: BigInt(user.id) };
if (query.project_id) {
const project = await this.findProjectForUser(query.project_id, user);
where.project_id = project.id;
}
if (query.change_type) {
where.change_type = this.normalizeText(query.change_type, 50);
}
const logs = await this.prisma.quotaLog.findMany({
where,
orderBy: { created_at: 'desc' },
take: limit
});
return { logs: logs.map(toSafeQuotaLog), total: logs.length, limit };
}
async createOrder(user: AuthRequestUser, dto: CreateOrderDto) {
const pkg = this.findPackage(dto.package_code);
const projectId = dto.project_id ? (await this.findProjectForUser(dto.project_id, user)).id : null;
const order = await this.prisma.order.create({
data: {
user_id: BigInt(user.id),
project_id: projectId,
order_no: this.createOrderNo(),
package_code: pkg.code,
amount: pkg.amount,
currency: pkg.currency,
payment_method: this.normalizeText(dto.payment_method ?? 'mock_pay', 50),
payment_status: 'pending'
}
});
return {
order: toSafeOrder(order),
package: pkg,
next_step: 'mock_pay'
};
}
async mockPayOrder(user: AuthRequestUser, orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: this.parseId(orderId, 'Invalid order id') }
});
if (!order || order.user_id.toString() !== user.id) {
throw new NotFoundException('Order not found');
}
const pkg = this.findPackage(order.package_code ?? undefined);
if (order.payment_status === 'paid') {
const account = await this.ensureQuotaAccount(order.user_id);
return {
order: toSafeOrder(order),
account: toSafeQuotaAccount(account),
package: pkg,
reused: true
};
}
if (order.payment_status !== 'pending') {
throw new BadRequestException('Only pending orders can be paid in mock mode');
}
const paidAt = new Date();
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, order.user_id);
const quota = new Prisma.Decimal(pkg.quota_amount);
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: order.user_id },
data: {
total_quota: account.total_quota.plus(quota),
available_quota: account.available_quota.plus(quota)
}
});
const updatedOrder = await tx.order.update({
where: { id: order.id },
data: {
payment_status: 'paid',
paid_at: paidAt
}
});
const log = await tx.quotaLog.create({
data: {
user_id: order.user_id,
project_id: order.project_id,
change_type: 'recharge',
amount: quota,
balance_after: updatedAccount.available_quota,
reason: `mock_pay:${pkg.code}`,
metadata_json: {
order_id: order.id.toString(),
order_no: order.order_no,
package_code: pkg.code
}
}
});
return { account: updatedAccount, order: updatedOrder, log };
});
return {
order: toSafeOrder(result.order),
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
package: pkg
};
}
async estimateProjectQuota(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
return this.createProjectEstimate(project);
}
async freezeProjectQuota(
user: AuthRequestUser,
projectId: string,
dto: FreezeProjectQuotaDto = {}
) {
const project = await this.findProjectForUser(projectId, user);
const estimate = this.createProjectEstimate(project);
const requested = dto.amount ? this.normalizeQuotaAmount(dto.amount, 'amount') : estimate.total_quota;
if (project.payment_status === 'paid') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
estimate,
amount: 0,
project_payment_status: 'paid',
reused: true
};
}
if (project.payment_status === 'quota_frozen') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
estimate,
amount: requested,
project_payment_status: 'quota_frozen',
reused: true
};
}
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const amount = new Prisma.Decimal(requested);
if (account.available_quota.lessThan(amount)) {
throw new BadRequestException('Insufficient quota');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
available_quota: account.available_quota.minus(amount),
frozen_quota: account.frozen_quota.plus(amount)
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'quota_frozen' }
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
change_type: 'freeze',
amount,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'project_generation_freeze', 255),
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
return { account: updatedAccount, project: updatedProject, log, amount };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
estimate,
amount: Number(result.amount.toString()),
project_payment_status: result.project.payment_status
};
}
async releaseProjectQuota(
user: AuthRequestUser,
projectId: string,
dto: ReleaseProjectQuotaDto = {}
) {
const project = await this.findProjectForUser(projectId, user);
if (project.payment_status !== 'quota_frozen') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
amount: 0,
project_payment_status: project.payment_status,
reused: true
};
}
const estimate = this.createProjectEstimate(project);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const estimateAmount = new Prisma.Decimal(estimate.total_quota);
const amount = account.frozen_quota.lessThan(estimateAmount)
? account.frozen_quota
: estimateAmount;
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
available_quota: account.available_quota.plus(amount),
frozen_quota: account.frozen_quota.minus(amount)
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'unpaid' }
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
change_type: 'release',
amount,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'project_generation_release', 255),
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
return { account: updatedAccount, project: updatedProject, log, amount };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
amount: Number(result.amount.toString()),
project_payment_status: result.project.payment_status
};
}
async ensureProjectQuotaReserved(project: Project) {
if (project.payment_status === 'paid') {
return;
}
if (project.payment_status !== 'quota_frozen') {
throw new BadRequestException('Project quota must be frozen before formal video render');
}
const account = await this.ensureQuotaAccount(project.user_id);
const estimate = this.createProjectEstimate(project);
if (account.frozen_quota.lessThan(new Prisma.Decimal(estimate.total_quota))) {
throw new BadRequestException('Frozen quota is insufficient for this project');
}
}
async deductReservedProjectQuota(project: Project, taskId: bigint, reason = 'video_render_success') {
if (project.payment_status === 'paid') {
return null;
}
if (project.payment_status !== 'quota_frozen') {
throw new BadRequestException('Project quota is not frozen');
}
const estimate = this.createProjectEstimate(project);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const amount = new Prisma.Decimal(estimate.total_quota);
if (account.frozen_quota.lessThan(amount)) {
throw new BadRequestException('Frozen quota is insufficient for deduction');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
frozen_quota: account.frozen_quota.minus(amount),
used_quota: account.used_quota.plus(amount)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
task_id: taskId,
change_type: 'deduct',
amount,
balance_after: updatedAccount.available_quota,
reason,
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'paid' }
});
return { account: updatedAccount, log, project: updatedProject };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
project_payment_status: result.project.payment_status
};
}
async listAdminOrders(user: AuthRequestUser, query: ListOrdersQueryDto) {
assertPermission(user, 'billing:read');
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const orders = await this.prisma.order.findMany({
where: query.payment_status
? { payment_status: this.normalizeText(query.payment_status, 50) }
: {},
orderBy: { created_at: 'desc' },
take: limit
});
return { orders: orders.map(toSafeOrder), total: orders.length, limit };
}
async listAdminQuotaAccounts(user: AuthRequestUser, query: AdminListQuotaAccountsQueryDto) {
assertPermission(user, 'billing:read');
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const where: Prisma.QuotaAccountWhereInput = {};
if (query.user_id) {
where.user_id = this.parseId(query.user_id, 'Invalid user_id');
}
if (query.status) {
where.status = this.normalizeText(query.status, 50);
}
const accounts = await this.prisma.quotaAccount.findMany({
where,
orderBy: { updated_at: 'desc' },
take: limit
});
return { accounts: accounts.map(toSafeQuotaAccount), total: accounts.length, limit };
}
async adminGrantQuota(user: AuthRequestUser, targetUserId: string, dto: AdminGrantQuotaDto) {
assertPermission(user, 'billing:write');
const userId = this.parseId(targetUserId, 'Invalid user id');
const target = await this.prisma.user.findUnique({ where: { id: userId } });
if (!target) {
throw new NotFoundException('User not found');
}
const amount = this.normalizeQuotaAmount(dto.amount, 'amount');
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, userId);
const quota = new Prisma.Decimal(amount);
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: userId },
data: {
total_quota: account.total_quota.plus(quota),
available_quota: account.available_quota.plus(quota)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: userId,
change_type: 'admin_grant',
amount: quota,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255),
metadata_json: {
operator_id: user.id
}
}
});
await tx.operationLog.create({
data: {
user_id: this.parseId(user.id, 'Invalid user id'),
operator_role: user.role,
action: 'admin_grant_quota',
target_type: 'user',
target_id: userId,
metadata_json: {
amount,
reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255)
}
}
});
return { account: updatedAccount, log };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log)
};
}
async adminAdjustQuota(user: AuthRequestUser, targetUserId: string, dto: AdminAdjustQuotaDto) {
assertPermission(user, 'billing:write');
const userId = this.parseId(targetUserId, 'Invalid user id');
const target = await this.prisma.user.findUnique({ where: { id: userId } });
if (!target) {
throw new NotFoundException('User not found');
}
const delta = this.normalizeQuotaDelta(dto.delta, 'delta');
const reason = this.normalizeText(dto.reason, 255);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, userId);
const amount = new Prisma.Decimal(Math.abs(delta));
const isAddition = delta > 0;
if (!isAddition && account.available_quota.lessThan(amount)) {
throw new BadRequestException('Available quota is insufficient for adjustment');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: userId },
data: isAddition
? {
total_quota: account.total_quota.plus(amount),
available_quota: account.available_quota.plus(amount)
}
: {
total_quota: account.total_quota.minus(amount),
available_quota: account.available_quota.minus(amount)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: userId,
change_type: isAddition ? 'admin_correction_add' : 'admin_correction_deduct',
amount,
balance_after: updatedAccount.available_quota,
reason,
metadata_json: {
operator_id: user.id,
delta
}
}
});
await tx.operationLog.create({
data: {
user_id: this.parseId(user.id, 'Invalid user id'),
operator_role: user.role,
action: 'admin_adjust_quota',
target_type: 'user',
target_id: userId,
metadata_json: {
delta,
reason
}
}
});
return { account: updatedAccount, log };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log)
};
}
private createProjectEstimate(project: Project) {
const episodeCount = this.normalizeEpisodeCount(project.target_episode_count ?? 1);
const shotCount = episodeCount * DEFAULT_SHOTS_PER_EPISODE;
const inputModeCost = project.input_mode === 'upload' ? QUOTA_COSTS.source : QUOTA_COSTS.source + 4;
const breakdown = [
{ key: 'source', label: project.input_mode === 'upload' ? '上传小说解析' : 'AI 原创小说', quota: inputModeCost },
{ key: 'story_bible', label: '故事圣经', quota: QUOTA_COSTS.story_bible },
{ key: 'characters', label: '角色圣经', quota: QUOTA_COSTS.characters },
{ key: 'character_images', label: '角色锚点图', quota: QUOTA_COSTS.character_images },
{ key: 'memory', label: '长篇记忆', quota: QUOTA_COSTS.memory },
{
key: 'episodes',
label: `分集计划 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.episode_plan_per_episode
},
{
key: 'scripts',
label: `单集脚本 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.script_per_episode
},
{
key: 'storyboards',
label: `分镜 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.storyboard_per_episode
},
{
key: 'shot_images',
label: `正式分镜图约 ${shotCount}`,
quota: shotCount * QUOTA_COSTS.shot_image
},
{
key: 'audio',
label: `TTS ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.audio_per_episode
},
{
key: 'subtitle',
label: `字幕 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.subtitle_per_episode
},
{
key: 'video',
label: `视频合成 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.video_per_episode
}
];
const total = breakdown.reduce((sum, item) => sum + item.quota, 0);
return {
project_id: project.id.toString(),
input_mode: project.input_mode,
target_episode_count: episodeCount,
estimated_shot_count: shotCount,
total_quota: total,
breakdown
};
}
private findPackage(packageCode?: string) {
const pkg = BILLING_PACKAGES.find((item) => item.code === packageCode);
if (!pkg) {
throw new BadRequestException('Invalid package_code');
}
return pkg;
}
private async ensureQuotaAccount(userId: bigint) {
return this.prisma.quotaAccount.upsert({
where: { user_id: userId },
update: {},
create: {
user_id: userId,
total_quota: 0,
available_quota: 0,
frozen_quota: 0,
used_quota: 0,
status: 'active'
}
});
}
private async ensureQuotaAccountTx(tx: Prisma.TransactionClient, userId: bigint) {
return tx.quotaAccount.upsert({
where: { user_id: userId },
update: {},
create: {
user_id: userId,
total_quota: 0,
available_quota: 0,
frozen_quota: 0,
used_quota: 0,
status: 'active'
}
});
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private createOrderNo() {
const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
return `ORD${timestamp}${randomUUID().replace(/-/g, '').slice(0, 10).toUpperCase()}`;
}
private normalizeEpisodeCount(value: number) {
if (!Number.isInteger(value) || value < 1) return 1;
return Math.min(value, 100);
}
private normalizePositiveInt(
value: unknown,
field: string,
min: number,
max: number,
fallback: number
) {
if (value === undefined || value === null || value === '') return fallback;
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private normalizeQuotaAmount(value: unknown, field: string) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) {
throw new BadRequestException(`${field} must be a positive number`);
}
return Number(numberValue.toFixed(2));
}
private normalizeQuotaDelta(value: unknown, field: string) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue === 0) {
throw new BadRequestException(`${field} must be a non-zero number`);
}
if (Math.abs(numberValue) > 1000000) {
throw new BadRequestException(`${field} must not exceed 1000000`);
}
return Number(numberValue.toFixed(2));
}
private normalizeText(value: string | undefined, maxLength: number) {
const normalized = value?.trim();
if (!normalized) {
throw new BadRequestException('Text value is required');
}
if (normalized.length > maxLength) {
throw new BadRequestException(`Text value must be at most ${maxLength} characters`);
}
return normalized;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
private assertAdmin(user: AuthRequestUser) {
if (user.role !== 'admin') {
throw new ForbiddenException('Admin role required');
}
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { Order, QuotaAccount, QuotaLog } from '@prisma/client';
export interface BillingPackage {
code: string;
name: string;
description: string;
amount: number;
currency: 'CNY';
quota_amount: number;
included_episodes: number;
features: string[];
recommended?: boolean;
}
export const BILLING_PACKAGES: BillingPackage[] = [
{
code: 'trial_1ep',
name: '试用版',
description: '适合验证 1 集基础漫剧流程。',
amount: 0,
currency: 'CNY',
quota_amount: 35,
included_episodes: 1,
features: ['1 集', '低清预览', '内部测试授权']
},
{
code: 'standard_3ep',
name: '标准短剧版',
description: '适合 3 集 MVP 短剧闭环。',
amount: 199,
currency: 'CNY',
quota_amount: 120,
included_episodes: 3,
recommended: true,
features: ['3 集', '正式 MP4', '1 次小改额度']
},
{
code: 'serial_10ep',
name: '连载测试版',
description: '适合 10 集以内连载测试。',
amount: 599,
currency: 'CNY',
quota_amount: 420,
included_episodes: 10,
features: ['10 集', '批量生成', '人工审核入口']
},
{
code: 'custom_20ep',
name: '高端定制版',
description: '适合 20 集以上定制项目。',
amount: 1999,
currency: 'CNY',
quota_amount: 1200,
included_episodes: 20,
features: ['20 集以上', '角色精修', '关键镜头动态预留']
}
];
export interface SafeQuotaAccount {
id: string;
user_id: string;
total_quota: number;
available_quota: number;
frozen_quota: number;
used_quota: number;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeQuotaLog {
id: string;
user_id: string;
project_id: string | null;
task_id: string | null;
change_type: string;
amount: number;
balance_after: number | null;
reason: string | null;
metadata_json: unknown;
created_at: string;
}
export interface SafeOrder {
id: string;
user_id: string;
project_id: string | null;
order_no: string;
package_code: string | null;
amount: number;
currency: string;
payment_method: string | null;
payment_status: string;
paid_at: string | null;
created_at: string;
updated_at: string;
}
export function toSafeQuotaAccount(account: QuotaAccount): SafeQuotaAccount {
return {
id: account.id.toString(),
user_id: account.user_id.toString(),
total_quota: Number(account.total_quota.toString()),
available_quota: Number(account.available_quota.toString()),
frozen_quota: Number(account.frozen_quota.toString()),
used_quota: Number(account.used_quota.toString()),
status: account.status,
created_at: account.created_at.toISOString(),
updated_at: account.updated_at.toISOString()
};
}
export function toSafeQuotaLog(log: QuotaLog): SafeQuotaLog {
return {
id: log.id.toString(),
user_id: log.user_id.toString(),
project_id: log.project_id?.toString() ?? null,
task_id: log.task_id?.toString() ?? null,
change_type: log.change_type,
amount: Number(log.amount.toString()),
balance_after: log.balance_after ? Number(log.balance_after.toString()) : null,
reason: log.reason,
metadata_json: log.metadata_json,
created_at: log.created_at.toISOString()
};
}
export function toSafeOrder(order: Order): SafeOrder {
return {
id: order.id.toString(),
user_id: order.user_id.toString(),
project_id: order.project_id?.toString() ?? null,
order_no: order.order_no,
package_code: order.package_code,
amount: Number(order.amount.toString()),
currency: order.currency,
payment_method: order.payment_method,
payment_status: order.payment_status,
paid_at: order.paid_at?.toISOString() ?? null,
created_at: order.created_at.toISOString(),
updated_at: order.updated_at.toISOString()
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { CharacterRoleType, CharacterStatus } from './character.types';
export class ExtractCharactersDto {
story_bible_id?: string;
}
export class CreateCharacterDto {
global_character_id?: string;
name?: string;
alias_names?: string[];
role_type?: CharacterRoleType;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
relationship_desc?: string;
character_arc?: string;
negative_rules?: string;
wardrobe_variant?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
importance_level?: number;
}
export class UpdateCharacterDto extends CreateCharacterDto {
status?: CharacterStatus;
}
+162
View File
@@ -0,0 +1,162 @@
import type { Character, GlobalCharacter, Prisma } from '@prisma/client';
export const CHARACTER_ROLE_TYPES = [
'protagonist',
'lead',
'supporting',
'antagonist',
'minor'
] as const;
export const CHARACTER_STATUSES = [
'draft',
'generated',
'edited',
'locked',
'deleted'
] as const;
export type CharacterRoleType = (typeof CHARACTER_ROLE_TYPES)[number];
export type CharacterStatus = (typeof CHARACTER_STATUSES)[number];
export interface SafeCharacter {
id: string;
project_id: string;
global_character_id: string | null;
name: string;
alias_names: Prisma.JsonValue | null;
role_type: string;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
relationship_desc: string | null;
character_arc: string | null;
negative_rules: string | null;
anchor_asset_id: string | null;
wardrobe_variant: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
importance_level: number;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeGlobalCharacter {
id: string;
name: string;
display_name: string | null;
role_archetype: string;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
default_costume_rules: string | null;
wardrobe_json: Prisma.JsonValue | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
negative_rules: string | null;
anchor_asset_id: string | null;
voice_sample_asset_id: string | null;
commercial_status: string;
usage_scope: string;
status: string;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
}
export function toSafeCharacter(character: Character): SafeCharacter {
return {
id: character.id.toString(),
project_id: character.project_id.toString(),
global_character_id: character.global_character_id?.toString() ?? null,
name: character.name,
alias_names: character.alias_names,
role_type: character.role_type,
gender_label: character.gender_label,
age_group: character.age_group,
identity_desc: character.identity_desc,
appearance_desc: character.appearance_desc,
face_desc: character.face_desc,
hair_desc: character.hair_desc,
eye_desc: character.eye_desc,
body_desc: character.body_desc,
costume_rules: character.costume_rules,
special_props: character.special_props,
personality_desc: character.personality_desc,
speech_style: character.speech_style,
relationship_desc: character.relationship_desc,
character_arc: character.character_arc,
negative_rules: character.negative_rules,
anchor_asset_id: character.anchor_asset_id?.toString() ?? null,
wardrobe_variant: character.wardrobe_variant,
voice_provider_code: character.voice_provider_code,
voice_model: character.voice_model,
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
importance_level: character.importance_level,
status: character.status,
created_at: character.created_at.toISOString(),
updated_at: character.updated_at.toISOString()
};
}
export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter {
return {
id: character.id.toString(),
name: character.name,
display_name: character.display_name,
role_archetype: character.role_archetype,
gender_label: character.gender_label,
age_group: character.age_group,
identity_desc: character.identity_desc,
appearance_desc: character.appearance_desc,
face_desc: character.face_desc,
hair_desc: character.hair_desc,
eye_desc: character.eye_desc,
body_desc: character.body_desc,
default_costume_rules: character.default_costume_rules,
wardrobe_json: character.wardrobe_json,
special_props: character.special_props,
personality_desc: character.personality_desc,
speech_style: character.speech_style,
voice_provider_code: character.voice_provider_code,
voice_model: character.voice_model,
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
negative_rules: character.negative_rules,
anchor_asset_id: character.anchor_asset_id?.toString() ?? null,
voice_sample_asset_id: character.voice_sample_asset_id?.toString() ?? null,
commercial_status: character.commercial_status,
usage_scope: character.usage_scope,
status: character.status,
created_by_user_id: character.created_by_user_id?.toString() ?? null,
created_at: character.created_at.toISOString(),
updated_at: character.updated_at.toISOString()
};
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
Patch,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import { CharactersService } from './characters.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class CharactersController {
constructor(@Inject(CharactersService) private readonly charactersService: CharactersService) {}
@Post('projects/:projectId/characters/extract')
extractCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ExtractCharactersDto
) {
return this.charactersService.extractCharacters(user, projectId, dto);
}
@Get('projects/:projectId/characters')
listCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('include_deleted') includeDeleted?: string
) {
return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true');
}
@Post('projects/:projectId/characters')
createCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateCharacterDto
) {
return this.charactersService.createCharacter(user, projectId, dto);
}
@Post('projects/:projectId/characters/confirm')
confirmCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.confirmCharacters(user, projectId);
}
@Patch('characters/:characterId')
updateCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: UpdateCharacterDto
) {
return this.charactersService.updateCharacter(user, characterId, dto);
}
@Delete('characters/:characterId')
deleteCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.deleteCharacter(user, characterId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
@Module({
imports: [AuthModule],
controllers: [CharactersController],
providers: [CharactersService],
exports: [CharactersService]
})
export class CharactersModule {}
@@ -0,0 +1,294 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Character, NovelChapter, Project, StoryBible } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { CharactersService } from './characters.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'story_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
completed_at: null,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '主要人物:林晚;其对手、旧友、合作者将在后续角色圣经中细化。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
summary: '林晚确认重生并整理证据。',
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
word_count: 22,
status: 'generated',
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致脸型',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、合同',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与对手冲突',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'generated',
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
describe('CharactersService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
storyBible: {
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
novelChapter: { findMany: ReturnType<typeof vi.fn> };
character: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
characterMemory: { create: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
let tx: {
project: { update: ReturnType<typeof vi.fn> };
character: {
createMany: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
};
let service: CharactersService;
beforeEach(() => {
tx = {
project: {
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' }))
},
character: {
createMany: vi.fn().mockResolvedValue({ count: 3 }),
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({ id: 51n, name: '周启', role_type: 'antagonist', importance_level: 80 }),
createCharacter({ id: 52n, name: '沈知夏', role_type: 'supporting', importance_level: 60 })
]),
updateMany: vi.fn().mockResolvedValue({ count: 0 })
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' }))
},
storyBible: {
findUnique: vi.fn().mockResolvedValue(createStoryBible()),
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([createChapter()])
},
character: {
create: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })),
findMany: vi.fn().mockResolvedValue([createCharacter()]),
findUnique: vi.fn().mockResolvedValue(createCharacter()),
update: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })),
updateMany: vi.fn(),
createMany: vi.fn()
},
characterMemory: {
create: vi.fn().mockResolvedValue({})
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new CharactersService(prisma as unknown as PrismaService);
});
it('extracts characters from a confirmed story bible', async () => {
const result = await service.extractCharacters(user, '10', { story_bible_id: '40' });
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'character_extracting' }
});
expect(tx.character.createMany).toHaveBeenCalled();
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'waiting_character_confirm' }
});
expect(result.characters).toHaveLength(3);
expect(result.next_step).toBe('character_confirm');
});
it('requires a confirmed story bible before extraction', async () => {
prisma.storyBible.findFirst.mockResolvedValue(null);
await expect(service.extractCharacters(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('creates a manual character', async () => {
const result = await service.createCharacter(user, '10', {
name: '顾南',
role_type: 'supporting',
importance_level: 50
});
expect(prisma.character.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
name: '顾南',
role_type: 'supporting',
status: 'edited'
})
});
expect(result.status).toBe('edited');
});
it('blocks core field changes after a character is locked', async () => {
prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' }));
await expect(
service.updateCharacter(user, '50', { name: '新的名字' })
).rejects.toBeInstanceOf(BadRequestException);
});
it('allows non-core patching after a character is locked', async () => {
prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' }));
prisma.character.update.mockResolvedValue(
createCharacter({ status: 'locked', costume_rules: '新增雨夜外套变体。' })
);
const result = await service.updateCharacter(user, '50', {
costume_rules: '新增雨夜外套变体。'
});
expect(prisma.character.update).toHaveBeenCalledWith({
where: { id: 50n },
data: expect.objectContaining({
costume_rules: '新增雨夜外套变体。'
})
});
expect(prisma.character.update.mock.calls[0][0].data.status).toBeUndefined();
expect(prisma.characterMemory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 50n,
memory_type: 'profile_adjustment'
})
});
expect(result.costume_rules).toBe('新增雨夜外套变体。');
});
it('confirms characters and locks the library', async () => {
prisma.character.findMany.mockResolvedValue([
createCharacter(),
createCharacter({ id: 51n, name: '周启', role_type: 'antagonist' })
]);
const result = await service.confirmCharacters(user, '10');
expect(tx.character.updateMany).toHaveBeenCalledWith({
where: {
project_id: 10n,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'locked' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'character_confirmed' }
});
expect(result.next_step).toBe('episode_plan_generate');
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listCharacters(user, '10')).rejects.toBeInstanceOf(
ForbiddenException
);
});
});
@@ -0,0 +1,708 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Character, GlobalCharacter, NovelChapter, Prisma, Project, StoryBible } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import {
CHARACTER_ROLE_TYPES,
CHARACTER_STATUSES,
toSafeCharacter,
type CharacterRoleType
} from './character.types';
interface CharacterDraft {
global_character_id: bigint | null;
name: string;
alias_names: Prisma.InputJsonValue;
role_type: CharacterRoleType;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
relationship_desc: string | null;
character_arc: string | null;
negative_rules: string | null;
anchor_asset_id: bigint | null;
importance_level: number;
wardrobe_variant: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
}
const LOCKED_CORE_FIELDS = new Set<keyof UpdateCharacterDto>([
'name',
'role_type',
'gender_label',
'age_group',
'identity_desc',
'appearance_desc',
'face_desc',
'hair_desc',
'eye_desc',
'body_desc'
]);
@Injectable()
export class CharactersService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async extractCharacters(user: AuthRequestUser, projectId: string, dto: ExtractCharactersDto) {
const project = await this.findProjectForUser(projectId, user);
const storyBible = dto.story_bible_id
? await this.findStoryBibleById(project.id, dto.story_bible_id)
: await this.findConfirmedStoryBible(project.id);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before character extraction');
}
const chapters = await this.prisma.novelChapter.findMany({
where: { project_id: project.id },
orderBy: { chapter_no: 'asc' }
});
const drafts = this.buildCharacterDrafts(storyBible, chapters);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'character_extracting' }
});
const characters = await this.prisma.$transaction(async (tx) => {
await tx.character.updateMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
data: { status: 'deleted' }
});
await tx.character.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
...draft,
status: 'generated'
}))
});
const saved = await tx.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_character_confirm' }
});
return saved;
});
return {
characters: characters.map(toSafeCharacter),
story_bible: {
id: storyBible.id.toString(),
version: storyBible.version,
status: storyBible.status
},
next_step: 'character_confirm'
};
}
async listCharacters(user: AuthRequestUser, projectId: string, includeDeleted = false) {
const project = await this.findProjectForUser(projectId, user);
const characters = await this.prisma.character.findMany({
where: {
project_id: project.id,
...(includeDeleted ? {} : { status: { not: 'deleted' } })
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
return characters.map(toSafeCharacter);
}
async createCharacter(user: AuthRequestUser, projectId: string, dto: CreateCharacterDto) {
const project = await this.findProjectForUser(projectId, user);
const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id);
const draft = this.createDraftFromDto(dto, globalCharacter);
const character = await this.prisma.character.create({
data: {
project_id: project.id,
...draft,
status: 'edited'
}
});
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'waiting_character_confirm' }
});
return toSafeCharacter(character);
}
async updateCharacter(user: AuthRequestUser, characterId: string, dto: UpdateCharacterDto) {
const character = await this.findCharacterForUser(characterId, user);
this.assertLockedPatchAllowed(character, dto);
const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id);
const data = this.createUpdateData(dto, character.status !== 'locked', globalCharacter, character);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No character fields to update');
}
const updated = await this.prisma.character.update({
where: { id: character.id },
data
});
if (character.status === 'locked') {
await this.prisma.characterMemory.create({
data: {
project_id: character.project_id,
character_id: character.id,
episode_id: null,
memory_type: 'profile_adjustment',
content: this.describeLockedCharacterPatch(dto)
}
});
}
if (updated.status !== 'locked') {
await this.prisma.project.update({
where: { id: updated.project_id },
data: { status: 'waiting_character_confirm' }
});
}
return toSafeCharacter(updated);
}
async deleteCharacter(user: AuthRequestUser, characterId: string) {
const character = await this.findCharacterForUser(characterId, user);
if (character.status === 'locked') {
throw new BadRequestException('Locked characters cannot be deleted');
}
const deleted = await this.prisma.character.update({
where: { id: character.id },
data: { status: 'deleted' }
});
await this.prisma.project.update({
where: { id: character.project_id },
data: { status: 'waiting_character_confirm' }
});
return toSafeCharacter(deleted);
}
async confirmCharacters(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const characters = await this.prisma.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
if (characters.length === 0) {
throw new BadRequestException('At least one character is required before confirmation');
}
if (!characters.some((character) => ['protagonist', 'lead'].includes(character.role_type))) {
throw new BadRequestException('A protagonist or lead character is required before confirmation');
}
const locked = await this.prisma.$transaction(async (tx) => {
await tx.character.updateMany({
where: {
project_id: project.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'locked' }
});
const saved = await tx.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
await tx.project.update({
where: { id: project.id },
data: { status: 'character_confirmed' }
});
return saved;
});
return {
characters: locked.map(toSafeCharacter),
next_step: 'episode_plan_generate'
};
}
private buildCharacterDrafts(storyBible: StoryBible, chapters: NovelChapter[]): CharacterDraft[] {
const protagonist = this.guessProtagonist(storyBible, chapters);
const antagonist = this.guessAntagonist(storyBible, chapters, protagonist);
const supporter = this.guessSupporter(storyBible, chapters, protagonist, antagonist);
return [
this.buildProtagonist(protagonist, storyBible),
this.buildAntagonist(antagonist, storyBible),
this.buildSupporter(supporter, protagonist, storyBible)
].filter((draft, index, list) =>
list.findIndex((item) => item.name === draft.name) === index
);
}
private buildProtagonist(name: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'protagonist',
gender_label: this.inferGender(name),
age_group: '青年',
identity_desc: this.extractAfter(storyBible.main_plot, '主要人物') ?? '故事主角,核心目标推动者。',
appearance_desc: `${name}五官清晰,眼神坚定,整体气质克制锋利,适合韩漫短剧主角。`,
face_desc: '精致鹅蛋脸或小方脸,轮廓干净,表情有压迫感。',
hair_desc: '深色中长发或利落短发,发型稳定,不随剧情随意改变。',
eye_desc: '深色眼睛,眼神坚定,关键反击场景有锐利高光。',
body_desc: '身形修长,站姿稳定,动作干练。',
costume_rules: '默认现代都市通勤装,深色外套、干净衬衫,重要场合可换正式套装。',
special_props: '手机、合同、录音或关键证据文件。',
personality_desc: '冷静、克制、目标感强,遇到压力先观察再反击。',
speech_style: '短句明确,不解释过多,关键台词有压迫感。',
relationship_desc: storyBible.main_plot ?? '与对手存在利益冲突,与潜在合作者存在信任考验。',
character_arc: storyBible.ending_direction ?? '从被动防守转向主动掌控局面。',
negative_rules: '不得改名,不得年龄漂移,不得突然软弱或无因放弃核心目标。',
anchor_asset_id: null,
importance_level: 100,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '冷静克制的年轻女性声线,语速中等,反击台词更有压迫感。',
performance_style: '微表情克制,关键反击时眼神压迫增强。'
};
}
private buildAntagonist(name: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'antagonist',
gender_label: this.inferGender(name),
age_group: '青年到中年',
identity_desc: '与主角核心目标冲突的主要阻碍者。',
appearance_desc: `${name}外表精致但带距离感,表情常带审视或压迫。`,
face_desc: '脸部线条偏锋利,笑容克制,眼神有算计感。',
hair_desc: '发型整齐,商务感强。',
eye_desc: '眼神冷静,常避开正面情绪。',
body_desc: '姿态控制感强,动作少但压迫明显。',
costume_rules: '商务深色系,避免与主角服装完全相同。',
special_props: '平板、合同、会议资料或控制权文件。',
personality_desc: '擅长隐藏真实动机,习惯利用规则和舆论施压。',
speech_style: '语气礼貌但带威胁,常用反问和条件交换。',
relationship_desc: storyBible.core_conflict ?? '与主角围绕核心目标持续对抗。',
character_arc: '前期占据优势,中期逐步暴露破绽,后期成为主线真相入口。',
negative_rules: '不得与主角混脸,不得突然洗白,不得无因放弃利益目标。',
anchor_asset_id: null,
importance_level: 80,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '低沉或冷硬声线,语速偏慢,礼貌但带压迫。',
performance_style: '动作少但控制感强,表情审视、笑容克制。'
};
}
private buildSupporter(name: string, protagonist: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'supporting',
gender_label: this.inferGender(name),
age_group: '青年',
identity_desc: '主角阶段性合作者或见证者。',
appearance_desc: `${name}亲和但有专业感,视觉上与${protagonist}形成区分。`,
face_desc: '脸部线条柔和,表情更外放。',
hair_desc: '自然深色发型,轮廓清楚。',
eye_desc: '眼神明亮,情绪反应明显。',
body_desc: '行动灵活,适合辅助调查和转场。',
costume_rules: '浅色或中性色日常装,避免抢主角视觉中心。',
special_props: '笔记本、工作证或资料袋。',
personality_desc: '敏锐、讲义气,但在压力下会犹豫。',
speech_style: '语速较快,常提醒风险,也会补充信息。',
relationship_desc: `${name}${protagonist}存在信任考验,后续可发展为稳定协作关系。`,
character_arc: storyBible.main_plot?.slice(0, 120) ?? '从旁观者成长为主角的重要支撑。',
negative_rules: '不得替代主角决策,不得在未铺垫时掌握关键真相。',
anchor_asset_id: null,
importance_level: 60,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '亲和、反应快的年轻声线,信息补充时语速略快。',
performance_style: '情绪外放,适合惊讶、提醒和辅助调查。'
};
}
private createDraftFromDto(dto: CreateCharacterDto, globalCharacter: GlobalCharacter | null): CharacterDraft {
const name = this.optionalText(dto.name) ?? globalCharacter?.display_name ?? globalCharacter?.name;
if (!name) {
throw new BadRequestException('name is required');
}
const roleType = this.validateRoleType(dto.role_type ?? globalCharacter?.role_archetype ?? 'supporting');
return {
global_character_id: globalCharacter?.id ?? null,
name,
alias_names: this.normalizeAliases(dto.alias_names),
role_type: roleType,
gender_label: this.optionalText(dto.gender_label) ?? globalCharacter?.gender_label ?? null,
age_group: this.optionalText(dto.age_group) ?? globalCharacter?.age_group ?? null,
identity_desc: this.optionalText(dto.identity_desc) ?? globalCharacter?.identity_desc ?? null,
appearance_desc: this.optionalText(dto.appearance_desc) ?? globalCharacter?.appearance_desc ?? null,
face_desc: this.optionalText(dto.face_desc) ?? globalCharacter?.face_desc ?? null,
hair_desc: this.optionalText(dto.hair_desc) ?? globalCharacter?.hair_desc ?? null,
eye_desc: this.optionalText(dto.eye_desc) ?? globalCharacter?.eye_desc ?? null,
body_desc: this.optionalText(dto.body_desc) ?? globalCharacter?.body_desc ?? null,
costume_rules: this.optionalText(dto.costume_rules) ?? globalCharacter?.default_costume_rules ?? null,
special_props: this.optionalText(dto.special_props) ?? globalCharacter?.special_props ?? null,
personality_desc: this.optionalText(dto.personality_desc) ?? globalCharacter?.personality_desc ?? null,
speech_style: this.optionalText(dto.speech_style) ?? globalCharacter?.speech_style ?? null,
relationship_desc: this.optionalText(dto.relationship_desc),
character_arc: this.optionalText(dto.character_arc),
negative_rules: this.optionalText(dto.negative_rules) ?? globalCharacter?.negative_rules ?? null,
anchor_asset_id: globalCharacter?.anchor_asset_id ?? null,
importance_level: this.validateImportance(dto.importance_level ?? 10),
wardrobe_variant: this.optionalText(dto.wardrobe_variant),
voice_provider_code: this.optionalText(dto.voice_provider_code) ?? globalCharacter?.voice_provider_code ?? null,
voice_model: this.optionalText(dto.voice_model) ?? globalCharacter?.voice_model ?? null,
voice_id: this.optionalText(dto.voice_id) ?? globalCharacter?.voice_id ?? null,
voice_style: this.optionalText(dto.voice_style) ?? globalCharacter?.voice_style ?? null,
performance_style: this.optionalText(dto.performance_style) ?? globalCharacter?.performance_style ?? null
};
}
private createUpdateData(
dto: UpdateCharacterDto,
markEdited = true,
globalCharacter: GlobalCharacter | null,
currentCharacter: Character
): Prisma.CharacterUncheckedUpdateInput {
const data: Prisma.CharacterUncheckedUpdateInput = {};
if ('global_character_id' in dto) {
data.global_character_id = globalCharacter?.id ?? null;
if (globalCharacter) {
if (!currentCharacter.anchor_asset_id && globalCharacter.anchor_asset_id) {
data.anchor_asset_id = globalCharacter.anchor_asset_id;
}
if (!currentCharacter.voice_provider_code && globalCharacter.voice_provider_code) {
data.voice_provider_code = globalCharacter.voice_provider_code;
}
if (!currentCharacter.voice_model && globalCharacter.voice_model) {
data.voice_model = globalCharacter.voice_model;
}
if (!currentCharacter.voice_id && globalCharacter.voice_id) {
data.voice_id = globalCharacter.voice_id;
}
if (!currentCharacter.voice_style && globalCharacter.voice_style) {
data.voice_style = globalCharacter.voice_style;
}
if (!currentCharacter.performance_style && globalCharacter.performance_style) {
data.performance_style = globalCharacter.performance_style;
}
if (!currentCharacter.costume_rules && globalCharacter.default_costume_rules) {
data.costume_rules = globalCharacter.default_costume_rules;
}
}
}
if ('name' in dto) data.name = this.requiredText(dto.name, 'name is required');
if ('alias_names' in dto) data.alias_names = this.normalizeAliases(dto.alias_names);
if ('role_type' in dto) data.role_type = this.validateRoleType(dto.role_type);
if ('gender_label' in dto) data.gender_label = this.optionalText(dto.gender_label);
if ('age_group' in dto) data.age_group = this.optionalText(dto.age_group);
if ('identity_desc' in dto) data.identity_desc = this.optionalText(dto.identity_desc);
if ('appearance_desc' in dto) data.appearance_desc = this.optionalText(dto.appearance_desc);
if ('face_desc' in dto) data.face_desc = this.optionalText(dto.face_desc);
if ('hair_desc' in dto) data.hair_desc = this.optionalText(dto.hair_desc);
if ('eye_desc' in dto) data.eye_desc = this.optionalText(dto.eye_desc);
if ('body_desc' in dto) data.body_desc = this.optionalText(dto.body_desc);
if ('costume_rules' in dto) data.costume_rules = this.optionalText(dto.costume_rules);
if ('special_props' in dto) data.special_props = this.optionalText(dto.special_props);
if ('personality_desc' in dto) data.personality_desc = this.optionalText(dto.personality_desc);
if ('speech_style' in dto) data.speech_style = this.optionalText(dto.speech_style);
if ('relationship_desc' in dto) data.relationship_desc = this.optionalText(dto.relationship_desc);
if ('character_arc' in dto) data.character_arc = this.optionalText(dto.character_arc);
if ('negative_rules' in dto) data.negative_rules = this.optionalText(dto.negative_rules);
if ('wardrobe_variant' in dto) data.wardrobe_variant = this.optionalText(dto.wardrobe_variant);
if ('voice_provider_code' in dto) data.voice_provider_code = this.optionalText(dto.voice_provider_code);
if ('voice_model' in dto) data.voice_model = this.optionalText(dto.voice_model);
if ('voice_id' in dto) data.voice_id = this.optionalText(dto.voice_id);
if ('voice_style' in dto) data.voice_style = this.optionalText(dto.voice_style);
if ('performance_style' in dto) data.performance_style = this.optionalText(dto.performance_style);
if ('importance_level' in dto) {
data.importance_level = this.validateImportance(dto.importance_level);
}
if ('status' in dto) data.status = this.validateStatus(dto.status);
if (markEdited && Object.keys(data).length > 0 && data.status !== 'locked') {
data.status = data.status ?? 'edited';
}
return data;
}
private assertLockedPatchAllowed(character: Character, dto: UpdateCharacterDto) {
if (character.status !== 'locked') {
return;
}
for (const field of LOCKED_CORE_FIELDS) {
if (field in dto) {
throw new BadRequestException('Locked character core fields cannot be changed');
}
}
if (dto.status && dto.status !== 'locked') {
throw new BadRequestException('Locked character status cannot be changed here');
}
}
private describeLockedCharacterPatch(dto: UpdateCharacterDto) {
const labels: string[] = [];
if ('global_character_id' in dto) labels.push('全局角色绑定');
if ('alias_names' in dto) labels.push('别名');
if ('costume_rules' in dto) labels.push('服装规则');
if ('special_props' in dto) labels.push('特殊道具');
if ('personality_desc' in dto) labels.push('性格补充');
if ('speech_style' in dto) labels.push('说话方式');
if ('wardrobe_variant' in dto) labels.push('服装变体');
if ('voice_provider_code' in dto || 'voice_model' in dto || 'voice_id' in dto || 'voice_style' in dto) {
labels.push('角色声音');
}
if ('performance_style' in dto) labels.push('表演风格');
if ('relationship_desc' in dto) labels.push('人物关系');
if ('character_arc' in dto) labels.push('成长线');
if ('negative_rules' in dto) labels.push('禁用规则');
if ('importance_level' in dto) labels.push('重要级别');
return `锁定角色资料补充:${labels.join('、') || '非核心描述'}`;
}
private async findActiveGlobalCharacter(globalCharacterId: string | undefined) {
const normalized = globalCharacterId?.trim();
if (!normalized) {
return null;
}
const globalCharacter = await this.prisma.globalCharacter.findUnique({
where: { id: this.parseId(normalized, 'Invalid global_character_id') }
});
if (!globalCharacter || globalCharacter.status !== 'active') {
throw new NotFoundException('Active global character not found');
}
return globalCharacter;
}
private async findCharacterForUser(characterId: string, user: AuthRequestUser) {
const character = await this.prisma.character.findUnique({
where: { id: this.parseId(characterId, 'Invalid character id') }
});
if (!character || character.status === 'deleted') {
throw new NotFoundException('Character not found');
}
await this.findProjectForUser(character.project_id.toString(), user);
return character;
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findConfirmedStoryBible(projectId: bigint) {
return this.prisma.storyBible.findFirst({
where: {
project_id: projectId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
});
}
private async findStoryBibleById(projectId: bigint, storyBibleId: string) {
const storyBible = await this.prisma.storyBible.findUnique({
where: { id: this.parseId(storyBibleId, 'Invalid story bible id') }
});
if (!storyBible || storyBible.project_id !== projectId || storyBible.status !== 'confirmed') {
throw new NotFoundException('Confirmed story bible not found');
}
return storyBible;
}
private guessProtagonist(storyBible: StoryBible, chapters: NovelChapter[]) {
const text = [storyBible.logline, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
return this.matchName(text, ['林晚', '沈知夏', '顾南', '陆沉']) ?? '林晚';
}
private guessAntagonist(storyBible: StoryBible, chapters: NovelChapter[], protagonist: string) {
const text = [storyBible.core_conflict, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
const matched = this.matchName(text, ['旧团队', '对手', '投资人', '周启', '苏曼', '陆沉']);
if (!matched || matched === protagonist || matched.length > 4) {
return '周启';
}
return matched;
}
private guessSupporter(
storyBible: StoryBible,
chapters: NovelChapter[],
protagonist: string,
antagonist: string
) {
const text = [storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
const matched = this.matchName(text, ['合作者', '旧友', '助理', '沈知夏', '顾南']);
if (!matched || matched === protagonist || matched === antagonist || matched.length > 4) {
return '沈知夏';
}
return matched;
}
private matchName(text: string, candidates: string[]) {
const known = candidates.find((name) => text.includes(name) && name.length <= 4);
if (known) {
return known;
}
return /[\u4e00-\u9fa5]{2,4}(?=站在|醒来|必须|决定|知道|拿出|重回)/.exec(text)?.[0];
}
private extractAfter(value: string | null, label: string) {
if (!value) return null;
const line = value.split('\n').find((item) => item.includes(label));
return line?.replace(`${label}`, '').trim() || null;
}
private inferGender(name: string) {
if (/[晚夏曼雪月柔]/.test(name)) {
return '女';
}
if (/[沉南启川宇]/.test(name)) {
return '男';
}
return '未指定';
}
private validateRoleType(value: string | undefined): CharacterRoleType {
if (!value || !CHARACTER_ROLE_TYPES.includes(value as never)) {
throw new BadRequestException('role_type is invalid');
}
return value as CharacterRoleType;
}
private validateStatus(value: string | undefined) {
if (!value || !CHARACTER_STATUSES.includes(value as never)) {
throw new BadRequestException('status is invalid');
}
return value;
}
private validateImportance(value: unknown) {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < 0 || numberValue > 100) {
throw new BadRequestException('importance_level must be an integer between 0 and 100');
}
return numberValue;
}
private normalizeAliases(value: string[] | undefined): Prisma.InputJsonValue {
return Array.isArray(value)
? value.map((item) => item.trim()).filter(Boolean)
: [];
}
private requiredText(value: string | undefined, message: string) {
const normalized = value?.trim();
if (!normalized) {
throw new BadRequestException(message);
}
return normalized;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}
@@ -0,0 +1,59 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
import type { RequestWithRequestId } from './request-id.middleware';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly apiCrypto: ApiCryptoService) {}
catch(exception: unknown, host: ArgumentsHost) {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
const request = context.getRequest<RequestWithRequestId & RequestWithApiCrypto>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload = {
code: status,
message: this.getMessage(exception),
data: null,
request_id: request.requestId || 'req_unknown'
};
if (request.apiCrypto) {
response.setHeader('x-api-encrypted', 'v1');
}
response.status(status).json(this.apiCrypto.encryptForRequest(request, payload));
}
private getMessage(exception: unknown) {
if (exception instanceof HttpException) {
const body = exception.getResponse();
if (typeof body === 'string') {
return body;
}
if (typeof body === 'object' && body !== null && 'message' in body) {
const message = body.message;
return Array.isArray(message) ? message.join('; ') : String(message);
}
}
if (exception instanceof Error) {
return exception.message || 'Internal server error';
}
return 'Internal server error';
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Header, Inject } from '@nestjs/common';
import { ApiCryptoService } from './api-crypto.service';
@Controller('crypto')
export class ApiCryptoController {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
@Get('handshake')
@Header('Cache-Control', 'no-store')
handshake() {
return this.apiCrypto.createHandshake();
}
}
@Controller('client-config')
export class ClientConfigController {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
@Get()
@Header('Cache-Control', 'no-store')
getClientConfig() {
return this.apiCrypto.getClientConfig();
}
}
@@ -0,0 +1,123 @@
import {
createCipheriv,
createDecipheriv,
createECDH,
hkdfSync,
randomBytes
} from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
ApiCryptoService,
type ApiCryptoEnvelope,
type ApiCryptoPublicJwk,
type RequestWithApiCrypto
} from './api-crypto.service';
function base64UrlEncode(input: Buffer) {
return input
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64');
}
function publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk {
return {
kty: 'EC',
crv: 'P-256',
x: base64UrlEncode(publicKey.subarray(1, 33)),
y: base64UrlEncode(publicKey.subarray(33, 65)),
ext: true
};
}
function jwkToPublicKey(jwk: ApiCryptoPublicJwk) {
return Buffer.concat([
Buffer.from([4]),
base64UrlDecode(jwk.x),
base64UrlDecode(jwk.y)
]);
}
function encryptPayload(payload: unknown, key: Buffer, sessionId: string, clientPublicKey: ApiCryptoPublicJwk): ApiCryptoEnvelope {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')),
cipher.final()
]);
return {
version: 1,
session_id: sessionId,
client_public_key: clientPublicKey,
iv: base64UrlEncode(iv),
ciphertext: base64UrlEncode(Buffer.concat([encrypted, cipher.getAuthTag()]))
};
}
function decryptPayload(envelope: ApiCryptoEnvelope, key: Buffer) {
const iv = base64UrlDecode(envelope.iv);
const encryptedWithTag = base64UrlDecode(envelope.ciphertext);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(encryptedWithTag.subarray(-16));
const plaintext = Buffer.concat([
decipher.update(encryptedWithTag.subarray(0, -16)),
decipher.final()
]);
return JSON.parse(plaintext.toString('utf8')) as unknown;
}
describe('ApiCryptoService', () => {
it('decrypts client envelopes and encrypts API responses with the derived session key', async () => {
const service = new ApiCryptoService({
systemConfig: {
findUnique: async () => null
}
} as never);
const handshake = service.createHandshake();
const client = createECDH('prime256v1');
client.generateKeys();
const clientPublicKey = publicKeyToJwk(client.getPublicKey());
const sharedSecret = client.computeSecret(jwkToPublicKey(handshake.server_public_key));
const key = Buffer.from(
hkdfSync(
'sha256',
sharedSecret,
base64UrlDecode(handshake.salt),
Buffer.from(`ai-manga-api-v1:${handshake.session_id}`, 'utf8'),
32
)
);
const requestBody = { title: '加密测试', count: 3 };
const envelope = encryptPayload(requestBody, key, handshake.session_id, clientPublicKey);
const req = {
headers: {},
body: envelope
} as RequestWithApiCrypto;
const attachedEnvelope = await service.attachRequestContext(req);
const decryptedBody = service.decryptRequestBody(attachedEnvelope!, req.apiCrypto!);
expect(decryptedBody).toEqual(requestBody);
const encryptedResponse = service.encryptForRequest(req, {
code: 0,
message: 'success',
data: { ok: true },
request_id: 'req_test'
}) as ApiCryptoEnvelope;
expect(decryptPayload(encryptedResponse, key)).toMatchObject({
code: 0,
data: { ok: true }
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import {
BadRequestException,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createECDH,
hkdfSync,
randomBytes,
randomUUID
} from 'node:crypto';
import type { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
const API_CRYPTO_VERSION = 1;
const AES_KEY_BYTES = 32;
const AES_GCM_AUTH_TAG_BYTES = 16;
const AES_GCM_IV_BYTES = 12;
const DEFAULT_SESSION_TTL_SECONDS = 15 * 60;
const HKDF_INFO_PREFIX = 'ai-manga-api-v1';
const API_CRYPTO_CONFIG_KEY = 'security.api_crypto_enabled';
const CONFIG_CACHE_MS = 5000;
export interface ApiCryptoPublicJwk {
kty: 'EC';
crv: 'P-256';
x: string;
y: string;
ext?: boolean;
key_ops?: string[];
}
export interface ApiCryptoEnvelope {
version: number;
session_id: string;
client_public_key?: ApiCryptoPublicJwk;
iv: string;
ciphertext: string;
}
export interface ApiCryptoContext {
sessionId: string;
clientPublicKey: ApiCryptoPublicJwk;
key: Buffer;
}
export interface RequestWithApiCrypto extends Request {
apiCrypto?: ApiCryptoContext;
requestId?: string;
}
interface ApiCryptoSession {
privateKey: Buffer;
salt: Buffer;
expiresAt: number;
}
@Injectable()
export class ApiCryptoService {
private readonly sessions = new Map<string, ApiCryptoSession>();
private cachedEnabled: { value: boolean; expiresAt: number } | null = null;
constructor(private readonly prisma: PrismaService) {}
async isEnabled() {
const envOverride = this.readBooleanEnv(process.env.API_CRYPTO_ENABLED);
if (envOverride !== null) {
return envOverride;
}
const now = Date.now();
if (this.cachedEnabled && this.cachedEnabled.expiresAt > now) {
return this.cachedEnabled.value;
}
let value = false;
try {
const config = await this.prisma.systemConfig.findUnique({
where: { config_key: API_CRYPTO_CONFIG_KEY }
});
value = this.readEnabledFromConfig(config?.config_value);
} catch {
value = false;
}
this.cachedEnabled = {
value,
expiresAt: now + CONFIG_CACHE_MS
};
return value;
}
clearConfigCache() {
this.cachedEnabled = null;
}
async getClientConfig() {
return {
api_crypto_enabled: await this.isEnabled(),
api_crypto_mode: process.env.API_CRYPTO_ENABLED?.trim() || 'auto',
api_crypto_session_ttl_seconds: this.sessionTtlSeconds()
};
}
createHandshake() {
this.pruneExpiredSessions();
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
const sessionId = randomUUID();
const expiresAt = Date.now() + this.sessionTtlSeconds() * 1000;
const salt = randomBytes(16);
this.sessions.set(sessionId, {
privateKey: ecdh.getPrivateKey(),
salt,
expiresAt
});
return {
version: API_CRYPTO_VERSION,
algorithm: 'ECDH-P256-HKDF-SHA256-AES-256-GCM',
session_id: sessionId,
server_public_key: this.publicKeyToJwk(ecdh.getPublicKey()),
salt: this.base64UrlEncode(salt),
expires_at: new Date(expiresAt).toISOString()
};
}
shouldUseEncryptedApi(req: Request) {
return this.isEncryptedHeader(req.headers['x-api-encrypted']) || this.isEnvelope(req.body);
}
async attachRequestContext(req: RequestWithApiCrypto) {
const bodyEnvelope = this.isEnvelope(req.body) ? req.body : null;
const sessionId = this.readHeader(req.headers['x-api-session-id']) || bodyEnvelope?.session_id;
const clientPublicKey =
bodyEnvelope?.client_public_key ||
this.decodePublicKeyHeader(req.headers['x-api-client-public-key']);
if (!sessionId || !clientPublicKey) {
throw new BadRequestException('Encrypted API session headers are required');
}
req.apiCrypto = {
sessionId,
clientPublicKey,
key: this.deriveKey(sessionId, clientPublicKey)
};
return bodyEnvelope;
}
decryptRequestBody(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) {
const plaintext = this.decryptEnvelope(envelope, context);
return plaintext === null ? {} : plaintext;
}
encryptForRequest(req: RequestWithApiCrypto, payload: unknown) {
if (!req.apiCrypto) {
return payload;
}
return this.encryptPayload(payload, req.apiCrypto);
}
encryptPayload(payload: unknown, context: ApiCryptoContext) {
const iv = randomBytes(AES_GCM_IV_BYTES);
const cipher = createCipheriv('aes-256-gcm', context.key, iv);
const plaintext = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
encrypted: true,
version: API_CRYPTO_VERSION,
session_id: context.sessionId,
iv: this.base64UrlEncode(iv),
ciphertext: this.base64UrlEncode(Buffer.concat([encrypted, tag]))
};
}
isEnvelope(value: unknown): value is ApiCryptoEnvelope {
if (typeof value !== 'object' || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return (
record.version === API_CRYPTO_VERSION &&
typeof record.session_id === 'string' &&
typeof record.iv === 'string' &&
typeof record.ciphertext === 'string'
);
}
private decryptEnvelope(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) {
if (envelope.session_id !== context.sessionId) {
throw new BadRequestException('Encrypted API session mismatch');
}
const iv = this.base64UrlDecode(envelope.iv);
const encryptedWithTag = this.base64UrlDecode(envelope.ciphertext);
if (iv.length !== AES_GCM_IV_BYTES || encryptedWithTag.length <= AES_GCM_AUTH_TAG_BYTES) {
throw new BadRequestException('Invalid encrypted API payload');
}
const ciphertext = encryptedWithTag.subarray(0, -AES_GCM_AUTH_TAG_BYTES);
const tag = encryptedWithTag.subarray(-AES_GCM_AUTH_TAG_BYTES);
const decipher = createDecipheriv('aes-256-gcm', context.key, iv);
decipher.setAuthTag(tag);
try {
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(plaintext.toString('utf8')) as unknown;
} catch {
throw new BadRequestException('Cannot decrypt API payload');
}
}
private deriveKey(sessionId: string, clientPublicKey: ApiCryptoPublicJwk) {
const session = this.sessions.get(sessionId);
if (!session || session.expiresAt <= Date.now()) {
this.sessions.delete(sessionId);
throw new UnauthorizedException('Encrypted API session expired');
}
const ecdh = createECDH('prime256v1');
ecdh.setPrivateKey(session.privateKey);
const sharedSecret = ecdh.computeSecret(this.jwkToPublicKey(clientPublicKey));
const key = hkdfSync(
'sha256',
sharedSecret,
session.salt,
Buffer.from(`${HKDF_INFO_PREFIX}:${sessionId}`, 'utf8'),
AES_KEY_BYTES
);
return Buffer.from(key);
}
private publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk {
if (publicKey.length !== 65 || publicKey[0] !== 4) {
throw new Error('Invalid P-256 public key');
}
return {
kty: 'EC',
crv: 'P-256',
x: this.base64UrlEncode(publicKey.subarray(1, 33)),
y: this.base64UrlEncode(publicKey.subarray(33, 65)),
ext: true
};
}
private jwkToPublicKey(jwk: ApiCryptoPublicJwk) {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
throw new BadRequestException('Invalid encrypted API public key');
}
const x = this.base64UrlDecode(jwk.x);
const y = this.base64UrlDecode(jwk.y);
if (x.length !== 32 || y.length !== 32) {
throw new BadRequestException('Invalid encrypted API public key');
}
return Buffer.concat([Buffer.from([4]), x, y]);
}
private decodePublicKeyHeader(value: string | string[] | undefined) {
const encoded = this.readHeader(value);
if (!encoded) return null;
try {
return JSON.parse(this.base64UrlDecode(encoded).toString('utf8')) as ApiCryptoPublicJwk;
} catch {
throw new BadRequestException('Invalid encrypted API public key header');
}
}
private readHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
private isEncryptedHeader(value: string | string[] | undefined) {
const header = this.readHeader(value)?.trim().toLowerCase();
return header === 'v1' || header === '1' || header === 'true';
}
private sessionTtlSeconds() {
const configured = Number(process.env.API_CRYPTO_SESSION_TTL_SECONDS);
return Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_SESSION_TTL_SECONDS;
}
private readBooleanEnv(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === 'auto') return null;
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return null;
}
private readEnabledFromConfig(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'object' && value !== null && 'enabled' in value) {
return Boolean((value as { enabled?: unknown }).enabled);
}
return false;
}
private pruneExpiredSessions() {
const now = Date.now();
for (const [sessionId, session] of this.sessions.entries()) {
if (session.expiresAt <= now) {
this.sessions.delete(sessionId);
}
}
}
private base64UrlEncode(input: Buffer) {
return input
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
private base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64');
}
}
@@ -0,0 +1,70 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile
} from '@nestjs/common';
import type { Response } from 'express';
import { Observable, from, mergeMap } from 'rxjs';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
import type { RequestWithRequestId } from './request-id.middleware';
interface ApiEnvelope {
code: number;
message: string;
data: unknown;
request_id: string;
}
function isApiEnvelope(value: unknown): value is ApiEnvelope {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
'message' in value &&
'data' in value &&
'request_id' in value
);
}
@Injectable()
export class ApiResponseInterceptor implements NestInterceptor {
constructor(private readonly apiCrypto: ApiCryptoService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<RequestWithRequestId & RequestWithApiCrypto>();
const response = http.getResponse<Response>();
const requestId = request.requestId || 'req_unknown';
return next.handle().pipe(
mergeMap((data) => {
if (isApiEnvelope(data)) {
this.markEncryptedResponse(request, response);
return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, data)));
}
if (data instanceof StreamableFile) {
return from(Promise.resolve(data));
}
const envelope = {
code: 0,
message: 'success',
data: data ?? null,
request_id: requestId
};
this.markEncryptedResponse(request, response);
return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, envelope)));
})
);
}
private markEncryptedResponse(request: RequestWithApiCrypto, response: Response) {
if (request.apiCrypto) {
response.setHeader('x-api-encrypted', 'v1');
}
}
}
@@ -0,0 +1,83 @@
import { HttpException, HttpStatus, Inject, Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Response } from 'express';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
@Injectable()
export class EncryptedRequestMiddleware implements NestMiddleware {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
use(req: RequestWithApiCrypto, res: Response, next: NextFunction) {
void this.handle(req, res, next);
}
private async handle(req: RequestWithApiCrypto, res: Response, next: NextFunction) {
if (this.isConfigRoute(req)) {
next();
return;
}
const isEncryptedRequest = this.apiCrypto.shouldUseEncryptedApi(req);
const isCryptoEnabled = await this.apiCrypto.isEnabled();
if (!isEncryptedRequest && !isCryptoEnabled) {
next();
return;
}
try {
if (!isEncryptedRequest) {
throw new HttpException('Encrypted API is enabled, please encrypt this request', HttpStatus.BAD_REQUEST);
}
const envelope = await this.apiCrypto.attachRequestContext(req);
if (envelope) {
req.body = this.apiCrypto.decryptRequestBody(envelope, req.apiCrypto!);
} else if (this.requiresEncryptedBody(req)) {
throw new HttpException('Encrypted API request body is required', HttpStatus.BAD_REQUEST);
}
next();
} catch (error) {
const status = error instanceof HttpException ? error.getStatus() : HttpStatus.BAD_REQUEST;
const message = error instanceof Error ? error.message : 'Cannot decrypt API payload';
const payload = {
code: status,
message,
data: null,
request_id: req.requestId || 'req_unknown'
};
const body = this.apiCrypto.encryptForRequest(req, payload);
if (req.apiCrypto) {
res.setHeader('x-api-encrypted', 'v1');
}
res.status(status).json(body);
}
}
private isConfigRoute(req: RequestWithApiCrypto) {
const requestWithUrl = req as RequestWithApiCrypto & { originalUrl?: string };
const path = requestWithUrl.originalUrl || req.path || req.url || '';
return (
path === '/api/crypto/handshake' ||
path === '/api/client-config' ||
path === '/crypto/handshake' ||
path === '/client-config'
);
}
private requiresEncryptedBody(req: RequestWithApiCrypto) {
const method = req.method.toUpperCase();
const contentType = req.headers['content-type'];
const normalizedContentType = Array.isArray(contentType) ? contentType[0] : contentType;
return (
method !== 'GET' &&
method !== 'HEAD' &&
Boolean(normalizedContentType?.includes('application/json'))
);
}
}
@@ -0,0 +1,19 @@
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
export interface RequestWithRequestId extends Request {
requestId?: string;
}
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: RequestWithRequestId, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id'];
const requestId = Array.isArray(incoming) ? incoming[0] : incoming;
req.requestId = requestId || `req_${randomUUID()}`;
res.setHeader('x-request-id', req.requestId);
next();
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface RequestWithId {
requestId?: string;
headers?: Record<string, string | string[] | undefined>;
user?: unknown;
}
@@ -0,0 +1,90 @@
import type { NextFunction, Request, Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SecureTransportMiddleware } from './secure-transport.middleware';
const originalEnv = { ...process.env };
function createRequest(overrides: Partial<Request> = {}) {
return {
headers: {},
hostname: 'api.example.test',
ip: '203.0.113.10',
protocol: 'http',
secure: false,
requestId: 'req_test',
...overrides
} as Request & { requestId: string };
}
function createResponse() {
const headers: Record<string, string> = {};
const response = {
setHeader: vi.fn((name: string, value: string) => {
headers[name.toLowerCase()] = value;
}),
status: vi.fn().mockReturnThis(),
json: vi.fn()
};
return { response: response as unknown as Response, headers, raw: response };
}
describe('SecureTransportMiddleware', () => {
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
it('allows local HTTP when local development fallback is enabled', () => {
process.env.HTTPS_REQUIRED = 'true';
const middleware = new SecureTransportMiddleware();
const req = createRequest({ hostname: '127.0.0.1', ip: '127.0.0.1' });
const { response, headers, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).toHaveBeenCalledOnce();
expect(raw.status).not.toHaveBeenCalled();
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['strict-transport-security']).toContain('max-age=31536000');
});
it('rejects non-local HTTP requests when HTTPS is required', () => {
process.env.HTTPS_REQUIRED = 'true';
const middleware = new SecureTransportMiddleware();
const req = createRequest();
const { response, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).not.toHaveBeenCalled();
expect(raw.status).toHaveBeenCalledWith(426);
expect(raw.json).toHaveBeenCalledWith({
code: 426,
message: 'HTTPS is required for API requests',
data: null,
request_id: 'req_test'
});
});
it('accepts HTTPS forwarded by the reverse proxy', () => {
process.env.HTTPS_REQUIRED = 'true';
process.env.HTTPS_ALLOW_LOCAL_HTTP = 'false';
const middleware = new SecureTransportMiddleware();
const req = createRequest({
headers: { 'x-forwarded-proto': 'https' },
hostname: 'api.example.test',
ip: '203.0.113.10'
});
const { response, headers, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).toHaveBeenCalledOnce();
expect(raw.status).not.toHaveBeenCalled();
expect(headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains');
});
});
@@ -0,0 +1,66 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import type { RequestWithRequestId } from './request-id.middleware';
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
@Injectable()
export class SecureTransportMiddleware implements NestMiddleware {
use(req: RequestWithRequestId, res: Response, next: NextFunction) {
this.setSecurityHeaders(req, res);
if (this.isHttpsRequired() && !this.isSecureRequest(req) && !this.isLocalRequest(req)) {
res.status(426).json({
code: 426,
message: 'HTTPS is required for API requests',
data: null,
request_id: req.requestId || 'req_unknown'
});
return;
}
next();
}
private setSecurityHeaders(req: Request, res: Response) {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('x-frame-options', 'DENY');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('permissions-policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('cross-origin-resource-policy', 'same-origin');
if (this.isHttpsRequired() || this.isSecureRequest(req)) {
res.setHeader(
'strict-transport-security',
'max-age=31536000; includeSubDomains'
);
}
}
private isHttpsRequired() {
const configured = process.env.HTTPS_REQUIRED?.trim().toLowerCase();
if (configured === 'true') return true;
if (configured === 'false') return false;
return process.env.NODE_ENV === 'production';
}
private isSecureRequest(req: Request) {
const forwardedProto = req.headers['x-forwarded-proto'];
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const firstProto = proto?.split(',')[0]?.trim().toLowerCase();
return req.secure || firstProto === 'https' || req.protocol === 'https';
}
private isLocalRequest(req: Request) {
if (process.env.HTTPS_ALLOW_LOCAL_HTTP === 'false') {
return false;
}
const host = req.hostname || req.ip || '';
return LOCAL_HOSTS.has(host) || req.ip === '::ffff:127.0.0.1';
}
}
+56
View File
@@ -0,0 +1,56 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function parseEnvValue(rawValue: string) {
let value = rawValue.trim();
if (!value) return '';
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return value
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t');
}
function parseEnvFile(content: string) {
const result: Record<string, string> = {};
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
if (!match) continue;
result[match[1]] = parseEnvValue(match[2]);
}
return result;
}
export function loadEnvFiles() {
const initialKeys = new Set(Object.keys(process.env));
const backendRoot = resolve(__dirname, '..', '..');
const repoRoot = resolve(backendRoot, '..');
const files = [...new Set([resolve(repoRoot, '.env'), resolve(backendRoot, '.env')])];
for (const file of files) {
if (!existsSync(file)) continue;
const parsed = parseEnvFile(readFileSync(file, 'utf8'));
for (const [key, value] of Object.entries(parsed)) {
if (initialKeys.has(key)) continue;
process.env[key] = value;
}
}
}
loadEnvFiles();
+17
View File
@@ -0,0 +1,17 @@
import type { EpisodeStatus } from './episode.types';
export class GenerateEpisodePlanDto {
target_episode_count?: number;
}
export class UpdateEpisodeDto {
episode_no?: number;
source_chapter_ids?: string[];
title?: string;
summary?: string;
opening_hook?: string;
middle_conflict?: string;
ending_hook?: string;
target_duration?: number;
status?: EpisodeStatus;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Episode, Prisma } from '@prisma/client';
export const EPISODE_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const;
export type EpisodeStatus = (typeof EPISODE_STATUSES)[number];
export interface SafeEpisode {
id: string;
project_id: string;
episode_no: number;
source_chapter_ids: Prisma.JsonValue | null;
title: string | null;
summary: string | null;
opening_hook: string | null;
middle_conflict: string | null;
ending_hook: string | null;
target_duration: number | null;
status: string;
created_at: string;
updated_at: string;
}
export function toSafeEpisode(episode: Episode): SafeEpisode {
return {
id: episode.id.toString(),
project_id: episode.project_id.toString(),
episode_no: episode.episode_no,
source_chapter_ids: episode.source_chapter_ids,
title: episode.title,
summary: episode.summary,
opening_hook: episode.opening_hook,
middle_conflict: episode.middle_conflict,
ending_hook: episode.ending_hook,
target_duration: episode.target_duration,
status: episode.status,
created_at: episode.created_at.toISOString(),
updated_at: episode.updated_at.toISOString()
};
}
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto';
import { EpisodesService } from './episodes.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class EpisodesController {
constructor(@Inject(EpisodesService) private readonly episodesService: EpisodesService) {}
@Post('projects/:projectId/episodes/generate-plan')
generatePlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GenerateEpisodePlanDto
) {
return this.episodesService.generatePlan(user, projectId, dto);
}
@Get('projects/:projectId/episodes')
listEpisodes(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.episodesService.listEpisodes(user, projectId);
}
@Patch('episodes/:episodeId')
updateEpisode(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: UpdateEpisodeDto
) {
return this.episodesService.updateEpisode(user, episodeId, dto);
}
@Post('projects/:projectId/episodes/confirm')
confirmEpisodes(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.episodesService.confirmEpisodes(user, projectId);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { EpisodesController } from './episodes.controller';
import { EpisodesService } from './episodes.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [EpisodesController],
providers: [EpisodesService],
exports: [EpisodesService]
})
export class EpisodesModule {}
@@ -0,0 +1,353 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
Character,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { EpisodesService } from './episodes.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'character_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致脸型',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、录音证据',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与周启围绕项目控制权对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
summary: '林晚确认重生并整理证据。',
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
word_count: 22,
status: 'generated',
created_at: now,
...overrides
};
}
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
return {
id: 60n,
project_id: 10n,
episode_id: null,
chapter_id: 30n,
memory_type: 'foreshadowing',
content: '录音证据会在后续揭开幕后真相。',
importance_level: 90,
status: 'active',
created_at: now,
...overrides
};
}
function createPlotThread(overrides: Partial<PlotThread> = {}): PlotThread {
return {
id: 70n,
project_id: 10n,
thread_name: '主线目标',
thread_type: 'main_plot',
description: '林晚夺回原创项目控制权。',
start_episode_no: 1,
expected_resolve_episode_no: 3,
resolved_episode_no: null,
status: 'open',
created_at: now,
updated_at: now,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 80n,
project_id: 10n,
episode_no: 1,
source_chapter_ids: ['30'],
title: '第1集 暴雨重启',
summary: '林晚确认重生并整理证据。',
opening_hook: '林晚在暴雨夜发现关键转机。',
middle_conflict: '周启试图转移责任。',
ending_hook: '录音证据指向幕后真相。',
target_duration: 60,
status: 'generated',
created_at: now,
updated_at: now,
...overrides
};
}
describe('EpisodesService', () => {
let prisma: any;
let tx: any;
let service: EpisodesService;
beforeEach(() => {
tx = {
episode: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 3 }),
findMany: vi.fn().mockResolvedValue([
createEpisode(),
createEpisode({ id: 81n, episode_no: 2, title: '第2集 会议反击' }),
createEpisode({ id: 82n, episode_no: 3, title: '第3集 真相逼近' })
]),
updateMany: vi.fn().mockResolvedValue({ count: 3 })
},
project: {
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_episode_confirm' }))
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject({ status: 'episode_planning' }))
},
storyBible: {
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
character: {
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({
id: 51n,
name: '周启',
role_type: 'antagonist',
importance_level: 80
})
])
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([
createChapter(),
createChapter({
id: 31n,
chapter_no: 2,
title: '第2章 会议反击',
summary: '林晚在会议上用证据反击周启。'
}),
createChapter({
id: 32n,
chapter_no: 3,
title: '第3章 真相逼近',
summary: '幕后投资人的名字第一次出现。'
})
]),
count: vi.fn().mockResolvedValue(1)
},
plotMemory: {
findMany: vi.fn().mockResolvedValue([
createPlotMemory(),
createPlotMemory({
id: 61n,
memory_type: 'unresolved_conflict',
content: '林晚必须在资本压力中守住原创项目。'
})
])
},
plotThread: {
findMany: vi.fn().mockResolvedValue([createPlotThread()])
},
episode: {
count: vi.fn().mockResolvedValue(0),
findMany: vi.fn().mockResolvedValue([createEpisode()]),
findUnique: vi.fn().mockResolvedValue(createEpisode()),
findFirst: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(createEpisode({ status: 'edited', title: '第1集 新标题' }))
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new EpisodesService(prisma as PrismaService);
});
it('generates an episode plan from story, character, and memory context', async () => {
const result = await service.generatePlan(user, '10', {});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'episode_planning' }
});
expect(tx.episode.deleteMany).toHaveBeenCalledWith({ where: { project_id: 10n } });
expect(tx.episode.createMany.mock.calls[0][0].data).toHaveLength(3);
expect(tx.episode.createMany.mock.calls[0][0].data[0]).toEqual(
expect.objectContaining({
project_id: 10n,
episode_no: 1,
status: 'generated'
})
);
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'waiting_episode_confirm' }
});
expect(result.episodes).toHaveLength(3);
expect(result.next_step).toBe('episode_confirm');
});
it('requires long-form memories before planning episodes', async () => {
prisma.plotMemory.findMany.mockResolvedValue([]);
await expect(service.generatePlan(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('updates an editable episode and marks it edited', async () => {
const result = await service.updateEpisode(user, '80', {
title: '第1集 新标题',
opening_hook: '新开头钩子'
});
expect(prisma.episode.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
title: '第1集 新标题',
opening_hook: '新开头钩子',
status: 'edited'
})
});
expect(result.status).toBe('edited');
});
it('blocks editing confirmed episodes', async () => {
prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'confirmed' }));
await expect(service.updateEpisode(user, '80', { title: '不可编辑' })).rejects.toBeInstanceOf(
BadRequestException
);
});
it('confirms a complete episode plan', async () => {
prisma.episode.findMany.mockResolvedValue([
createEpisode(),
createEpisode({ id: 81n, episode_no: 2 }),
createEpisode({ id: 82n, episode_no: 3 })
]);
const result = await service.confirmEpisodes(user, '10');
expect(tx.episode.updateMany).toHaveBeenCalledWith({
where: {
project_id: 10n,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'episode_confirmed' }
});
expect(result.next_step).toBe('script_generate');
});
it('rejects incomplete episode confirmation', async () => {
prisma.episode.findMany.mockResolvedValue([createEpisode({ ending_hook: null })]);
await expect(service.confirmEpisodes(user, '10')).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listEpisodes(user, '10')).rejects.toBeInstanceOf(ForbiddenException);
});
});
+501
View File
@@ -0,0 +1,501 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type {
Character,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Prisma,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto';
import { EPISODE_STATUSES, toSafeEpisode, type EpisodeStatus } from './episode.types';
const MIN_EPISODES = 1;
const MAX_EPISODES = 100;
const MIN_DURATION = 15;
const MAX_DURATION = 600;
interface EpisodeDraft {
episode_no: number;
source_chapter_ids: Prisma.InputJsonValue;
title: string;
summary: string;
opening_hook: string;
middle_conflict: string;
ending_hook: string;
target_duration: number;
status: EpisodeStatus;
}
interface EpisodePlanContext {
storyBible: StoryBible;
characters: Character[];
chapters: NovelChapter[];
plotMemories: PlotMemory[];
plotThreads: PlotThread[];
}
@Injectable()
export class EpisodesService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async generatePlan(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) {
const project = await this.findProjectForUser(projectId, user);
const count = this.resolveEpisodeCount(project, dto.target_episode_count);
const context = await this.loadPlanContext(project.id);
const existingConfirmed = await this.prisma.episode.count({
where: {
project_id: project.id,
status: 'confirmed'
}
});
if (existingConfirmed > 0) {
throw new BadRequestException('Confirmed episodes cannot be regenerated');
}
const drafts = this.buildEpisodeDrafts(project, count, context);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'episode_planning' }
});
const episodes = await this.prisma.$transaction(async (tx) => {
await tx.episode.deleteMany({
where: { project_id: project.id }
});
await tx.episode.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
...draft
}))
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_episode_confirm' }
});
return saved;
});
return {
episodes: episodes.map(toSafeEpisode),
memory_context: {
story_bible_id: context.storyBible.id.toString(),
locked_character_count: context.characters.length,
active_plot_memory_count: context.plotMemories.length,
open_thread_count: context.plotThreads.length
},
next_step: 'episode_confirm'
};
}
async listEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
return episodes.map(toSafeEpisode);
}
async updateEpisode(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeDto) {
const episode = await this.findEpisodeForUser(episodeId, user);
if (episode.status === 'confirmed') {
throw new BadRequestException('Confirmed episodes cannot be edited');
}
const data = await this.createUpdateData(episode, dto);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No episode fields to update');
}
const updated = await this.prisma.episode.update({
where: { id: episode.id },
data
});
await this.prisma.project.update({
where: { id: episode.project_id },
data: { status: 'waiting_episode_confirm' }
});
return toSafeEpisode(updated);
}
async confirmEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
this.assertEpisodesReadyForConfirmation(episodes);
const confirmed = await this.prisma.$transaction(async (tx) => {
await tx.episode.updateMany({
where: {
project_id: project.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'episode_confirmed' }
});
return saved;
});
return {
episodes: confirmed.map(toSafeEpisode),
next_step: 'script_generate'
};
}
private async loadPlanContext(projectId: bigint): Promise<EpisodePlanContext> {
const [storyBible, characters, chapters, plotMemories, plotThreads] = await Promise.all([
this.prisma.storyBible.findFirst({
where: {
project_id: projectId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
}),
this.prisma.character.findMany({
where: {
project_id: projectId,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.novelChapter.findMany({
where: { project_id: projectId },
orderBy: { chapter_no: 'asc' }
}),
this.prisma.plotMemory.findMany({
where: {
project_id: projectId,
status: 'active'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.plotThread.findMany({
where: {
project_id: projectId,
status: { in: ['open', 'progressing', 'paused'] }
},
orderBy: [{ status: 'asc' }, { id: 'asc' }]
})
]);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before episode planning');
}
if (characters.length === 0) {
throw new BadRequestException('Locked characters are required before episode planning');
}
if (chapters.length === 0) {
throw new BadRequestException('Novel chapters are required before episode planning');
}
if (plotMemories.length === 0) {
throw new BadRequestException('Long-form plot memories are required before episode planning');
}
return {
storyBible,
characters,
chapters,
plotMemories,
plotThreads
};
}
private buildEpisodeDrafts(
project: Project,
count: number,
context: EpisodePlanContext
): EpisodeDraft[] {
const protagonist =
context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
context.characters[0];
const antagonist = context.characters.find((character) => character.role_type === 'antagonist');
const importantForeshadowing = context.plotMemories.find(
(memory) => memory.memory_type === 'foreshadowing'
);
const unresolvedConflict = context.plotMemories.find(
(memory) => memory.memory_type === 'unresolved_conflict'
);
const mainThread =
context.plotThreads.find((thread) => thread.thread_type === 'main_plot') ??
context.plotThreads[0];
const duration = this.validateDuration(project.episode_duration ?? 60);
return Array.from({ length: count }, (_, index) => {
const episodeNo = index + 1;
const chapterGroup = this.pickChapterGroup(context.chapters, index, count);
const firstChapter = chapterGroup[0] ?? context.chapters[0];
const lastChapter = chapterGroup.at(-1) ?? firstChapter;
const chapterSummary = chapterGroup
.map((chapter) => chapter.summary || this.compact(chapter.content).slice(0, 70))
.join('');
const sourceChapterIds = chapterGroup.map((chapter) => chapter.id.toString());
const threadText = mainThread?.description || context.storyBible.main_plot || '主线目标持续推进';
return {
episode_no: episodeNo,
source_chapter_ids: sourceChapterIds,
title: this.buildEpisodeTitle(episodeNo, firstChapter, count),
summary: [
`${protagonist.name}围绕${this.compact(threadText).slice(0, 80)}推进第${episodeNo}集。`,
chapterSummary,
episodeNo === count
? context.storyBible.ending_direction || '阶段性回收关键伏笔,并保留下一阶段入口。'
: '本集保留短视频节奏,结尾留下可承接悬念。'
].filter(Boolean).join(' '),
opening_hook:
episodeNo === 1
? `${protagonist.name}在高压场景中发现关键转机,观众第一秒进入冲突。`
: `承接上一集悬念,${protagonist.name}立刻面对新的选择和压力。`,
middle_conflict:
unresolvedConflict?.content ||
`${antagonist?.name ?? '主要对手'}围绕核心利益继续施压,${protagonist.name}必须用证据或行动反击。`,
ending_hook: this.buildEndingHook(
episodeNo,
count,
protagonist.name,
lastChapter,
importantForeshadowing,
context.storyBible
),
target_duration: duration,
status: 'generated'
};
});
}
private buildEpisodeTitle(episodeNo: number, chapter: NovelChapter, count: number) {
const cleaned = chapter.title
?.replace(/^?[0-9]+[.\s-]*/u, '')
.trim();
const fallback = episodeNo === count ? '真相逼近' : episodeNo === 1 ? '开局反击' : '冲突升级';
return `${episodeNo}${cleaned || fallback}`;
}
private buildEndingHook(
episodeNo: number,
count: number,
protagonistName: string,
chapter: NovelChapter,
foreshadowing: PlotMemory | undefined,
storyBible: StoryBible
) {
if (episodeNo === count) {
return storyBible.ending_direction || `${protagonistName}阶段性赢下对抗,但幕后真相仍未完全揭开。`;
}
const source = foreshadowing?.content || chapter.summary || chapter.title || '关键线索';
return `${protagonistName}发现${this.compact(source).slice(0, 42)},下一集必须继续追查。`;
}
private pickChapterGroup(chapters: NovelChapter[], index: number, count: number) {
const start = Math.floor((index * chapters.length) / count);
const end = Math.max(start + 1, Math.floor(((index + 1) * chapters.length) / count));
return chapters.slice(start, Math.min(end, chapters.length));
}
private async createUpdateData(
episode: Episode,
dto: UpdateEpisodeDto
): Promise<Prisma.EpisodeUncheckedUpdateInput> {
const data: Prisma.EpisodeUncheckedUpdateInput = {};
if ('episode_no' in dto) {
data.episode_no = await this.validateEpisodeNoForUpdate(episode, dto.episode_no);
}
if ('source_chapter_ids' in dto) {
data.source_chapter_ids = await this.validateSourceChapterIds(
episode.project_id,
dto.source_chapter_ids
);
}
if ('title' in dto) data.title = this.optionalText(dto.title);
if ('summary' in dto) data.summary = this.optionalText(dto.summary);
if ('opening_hook' in dto) data.opening_hook = this.optionalText(dto.opening_hook);
if ('middle_conflict' in dto) data.middle_conflict = this.optionalText(dto.middle_conflict);
if ('ending_hook' in dto) data.ending_hook = this.optionalText(dto.ending_hook);
if ('target_duration' in dto) {
data.target_duration = this.validateDuration(dto.target_duration);
}
if ('status' in dto) data.status = this.validateStatus(dto.status);
if (Object.keys(data).length > 0 && data.status !== 'confirmed') {
data.status = data.status ?? 'edited';
}
return data;
}
private async validateEpisodeNoForUpdate(episode: Episode, value: number | undefined) {
const episodeNo = this.validatePositiveInt(value, 'episode_no', MIN_EPISODES, MAX_EPISODES);
if (episodeNo === episode.episode_no) {
return episodeNo;
}
const existing = await this.prisma.episode.findFirst({
where: {
project_id: episode.project_id,
episode_no: episodeNo,
id: { not: episode.id }
}
});
if (existing) {
throw new BadRequestException('episode_no already exists in this project');
}
return episodeNo;
}
private async validateSourceChapterIds(projectId: bigint, value: string[] | undefined) {
if (!Array.isArray(value) || value.length === 0) {
throw new BadRequestException('source_chapter_ids must be a non-empty array');
}
const ids = value.map((item) => this.parseId(String(item), 'Invalid source chapter id'));
const count = await this.prisma.novelChapter.count({
where: {
project_id: projectId,
id: { in: ids }
}
});
if (count !== ids.length) {
throw new BadRequestException('source_chapter_ids contain chapters outside this project');
}
return ids.map((id) => id.toString());
}
private assertEpisodesReadyForConfirmation(episodes: Episode[]) {
if (episodes.length === 0) {
throw new BadRequestException('Episode plan is required before confirmation');
}
for (const [index, episode] of episodes.entries()) {
if (episode.episode_no !== index + 1) {
throw new BadRequestException('Episode numbers must be continuous from 1');
}
if (
!episode.title ||
!episode.summary ||
!episode.opening_hook ||
!episode.middle_conflict ||
!episode.ending_hook ||
!episode.target_duration
) {
throw new BadRequestException('All episodes must include title, hooks, conflict, summary, and duration');
}
}
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) {
const episode = await this.prisma.episode.findUnique({
where: { id: this.parseId(episodeId, 'Invalid episode id') }
});
if (!episode) {
throw new NotFoundException('Episode not found');
}
await this.findProjectForUser(episode.project_id.toString(), user);
return episode;
}
private resolveEpisodeCount(project: Project, value: number | undefined) {
return this.validatePositiveInt(
value ?? project.target_episode_count ?? (project.input_mode === 'ai_original' ? 3 : 1),
'target_episode_count',
MIN_EPISODES,
MAX_EPISODES
);
}
private validateDuration(value: number | undefined) {
return this.validatePositiveInt(value, 'target_duration', MIN_DURATION, MAX_DURATION);
}
private validateStatus(value: string | undefined): EpisodeStatus {
if (!value || !EPISODE_STATUSES.includes(value as never)) {
throw new BadRequestException('episode status is invalid');
}
return value as EpisodeStatus;
}
private validatePositiveInt(value: unknown, field: string, min: number, max: number) {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private compact(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
export class GenerateCharacterImagesDto {
image_types?: string[];
count_per_type?: number;
force?: boolean;
set_first_as_anchor?: boolean;
}
export class SetCharacterAnchorDto {
character_image_id?: string;
asset_id?: string;
}
export class GenerateShotImageDto {
image_type?: string;
force?: boolean;
}
export class GenerateEpisodeShotImagesDto {
image_type?: string;
only_missing?: boolean;
limit?: number;
}
+83
View File
@@ -0,0 +1,83 @@
import type { Asset, CharacterImage, ShotImage } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
export const CHARACTER_IMAGE_TYPES = [
'front_reference',
'side_reference',
'expression_pack',
'costume_default',
'costume_special',
'anchor',
'scene_variant'
] as const;
export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const;
export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number];
export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number];
export interface SafeCharacterImage {
id: string;
project_id: string;
character_id: string;
asset_id: string | null;
image_type: string;
prompt_text: string | null;
negative_prompt: string | null;
is_anchor: boolean;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export interface SafeShotImage {
id: string;
project_id: string;
episode_id: string | null;
shot_id: string;
asset_id: string | null;
image_type: string;
prompt_text: string | null;
negative_prompt: string | null;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export function toSafeCharacterImage(
image: CharacterImage & { asset?: Asset | null }
): SafeCharacterImage {
return {
id: image.id.toString(),
project_id: image.project_id.toString(),
character_id: image.character_id.toString(),
asset_id: image.asset_id?.toString() ?? null,
image_type: image.image_type,
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
is_anchor: image.is_anchor,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
asset: image.asset ? toSafeAsset(image.asset) : undefined
};
}
export function toSafeShotImage(image: ShotImage & { asset?: Asset | null }): SafeShotImage {
return {
id: image.id.toString(),
project_id: image.project_id.toString(),
episode_id: image.episode_id?.toString() ?? null,
shot_id: image.shot_id.toString(),
asset_id: image.asset_id?.toString() ?? null,
image_type: image.image_type,
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
asset: image.asset ? toSafeAsset(image.asset) : undefined
};
}
+74
View File
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
GenerateCharacterImagesDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
} from './image.dto';
import { ImagesService } from './images.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ImagesController {
constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {}
@Post('characters/:characterId/generate-images')
generateCharacterImages(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: GenerateCharacterImagesDto
) {
return this.imagesService.generateCharacterImages(user, characterId, dto);
}
@Get('characters/:characterId/images')
listCharacterImages(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.imagesService.listCharacterImages(user, characterId);
}
@Post('characters/:characterId/set-anchor')
setCharacterAnchor(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SetCharacterAnchorDto
) {
return this.imagesService.setCharacterAnchor(user, characterId, dto);
}
@Post('storyboard-shots/:shotId/images/generate')
generateShotImage(
@CurrentUser() user: AuthRequestUser,
@Param('shotId') shotId: string,
@Body() dto: GenerateShotImageDto
) {
return this.imagesService.generateShotImage(user, shotId, dto);
}
@Get('storyboard-shots/:shotId/images')
listShotImages(@CurrentUser() user: AuthRequestUser, @Param('shotId') shotId: string) {
return this.imagesService.listShotImages(user, shotId);
}
@Post('episodes/:episodeId/shot-images/generate')
generateEpisodeShotImages(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: GenerateEpisodeShotImagesDto
) {
return this.imagesService.generateEpisodeShotImages(user, episodeId, dto);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { ImagesController } from './images.controller';
import { ImagesService } from './images.service';
@Module({
imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule],
controllers: [ImagesController],
providers: [ImagesService],
exports: [ImagesService]
})
export class ImagesModule {}
+485
View File
@@ -0,0 +1,485 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
Asset,
Character,
CharacterImage,
Episode,
Project,
RenderTask,
ShotImage,
StoryboardShot
} from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import type { StorageService } from '../assets/storage.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service';
import { ImagesService } from './images.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '阶段15 图片项目',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'storyboard_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 20n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '短剧主角',
appearance_desc: '眼神坚定,气质冷静',
face_desc: '精致鹅蛋脸',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、录音证据',
personality_desc: '克制果断',
speech_style: '短句明确',
relationship_desc: '与周启对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名,不得改发色',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 30n,
project_id: 10n,
episode_no: 1,
source_chapter_ids: ['1'],
title: '第1集',
summary: '林晚反击。',
opening_hook: '会议室大屏播放录音。',
middle_conflict: '周启试图压制。',
ending_hook: '幕后车辆出现。',
target_duration: 60,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
return {
id: 40n,
project_id: 10n,
episode_id: 30n,
shot_no: 1,
scene_name: '雨夜反击',
location_desc: '会议室',
characters_json: [{ id: '20', name: '林晚' }],
visual_desc: '林晚站在会议桌前,冷静抬眼。',
action_desc: '林晚播放录音证据。',
dialogue_text: '这一回,我不会再退。',
narration_text: '局势开始反转。',
camera_motion: 'zoom_in',
effect_type: 'flash',
duration: new Prisma.Decimal(4),
scene_type: null,
importance_score: null,
emotion_score: null,
action_score: null,
route_tier: null,
prompt_text: '高质量韩漫风,会议室反击。',
negative_prompt: '低清晰度,多余人物。',
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
keyframe_asset_id: null,
video_clip_asset_id: null,
video_status: null,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 50n,
user_id: 1n,
project_id: 10n,
asset_type: 'image',
file_path: 'local://generated-images/mock.svg',
file_url: 'mock://image/mock.png',
mime_type: 'image/svg+xml',
width: 1080,
height: 1920,
duration: null,
size: 1024n,
hash: 'hash-a',
visibility: 'private',
status: 'active',
created_at: now,
...overrides
};
}
function createCharacterImage(overrides: Partial<CharacterImage> = {}): CharacterImage {
return {
id: 60n,
project_id: 10n,
character_id: 20n,
asset_id: 50n,
image_type: 'front_reference',
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
...overrides
};
}
function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage {
return {
id: 70n,
project_id: 10n,
episode_id: 30n,
shot_id: 40n,
asset_id: 50n,
image_type: 'preview',
prompt_text: 'prompt',
negative_prompt: 'negative',
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
...overrides
};
}
function createTask(overrides: Partial<RenderTask> = {}): RenderTask {
return {
id: 80n,
project_id: 10n,
episode_id: null,
shot_id: null,
task_type: 'character_image_generate',
provider_id: null,
status: 'pending',
input_json: {},
input_hash: 'hash-task',
idempotency_key: 'idem-task',
output_asset_id: null,
provider_request_id: null,
retry_count: 0,
max_retry: 3,
cost_estimate: null,
cost_actual: null,
error_code: null,
error_message: null,
created_at: now,
started_at: null,
finished_at: null,
...overrides
};
}
describe('ImagesService', () => {
let prisma: any;
let storage: any;
let providers: any;
let tx: any;
let service: ImagesService;
beforeEach(() => {
tx = {
characterImage: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' }))
},
character: {
update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n }))
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject())
},
character: {
findUnique: vi.fn().mockResolvedValue(createCharacter()),
findMany: vi.fn().mockResolvedValue([createCharacter({ anchor_asset_id: 50n })])
},
characterImage: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createCharacterImage()),
findMany: vi.fn().mockResolvedValue([createCharacterImage()]),
create: vi.fn().mockResolvedValue(createCharacterImage())
},
storyboardShot: {
findUnique: vi.fn().mockResolvedValue(createShot()),
findMany: vi.fn().mockResolvedValue([createShot()])
},
episode: {
findUnique: vi.fn().mockResolvedValue(createEpisode())
},
shotImage: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createShotImage()),
findMany: vi.fn().mockResolvedValue([createShotImage()]),
create: vi.fn().mockResolvedValue(createShotImage())
},
renderTask: {
create: vi.fn().mockResolvedValue(createTask()),
update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n }))
},
asset: {
create: vi.fn().mockResolvedValue(createAsset()),
findUnique: vi.fn().mockResolvedValue(createAsset())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
storage = {
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://generated-images/mock.svg',
size: 1024n,
hash: 'hash-a',
backend: 'local'
})
};
providers = {
executeProvider: vi.fn().mockResolvedValue({
provider: {
mode: 'mock'
},
result: {
provider_request_id: 'mock-mock-image-a',
asset_url: 'mock://image/a.png'
},
provider_log: {
cost_estimate: 0,
cost_actual: 0
}
})
};
service = new ImagesService(
prisma as PrismaService,
storage as StorageService,
providers as ProvidersService
);
});
it('generates locked character reference images through ImageProvider', async () => {
const result = await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
set_first_as_anchor: false
});
expect(providers.executeProvider).toHaveBeenCalledWith(
expect.objectContaining({
provider_type: 'ImageProvider',
task_id: '80',
allow_fallback: false,
input_json: expect.objectContaining({
width: 1080,
height: 1920
})
})
);
expect(prisma.characterImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 20n,
asset_id: 50n,
image_type: 'front_reference',
status: 'generated'
})
});
expect(prisma.renderTask.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
status: 'success',
output_asset_id: 50n
})
});
expect(result.images).toHaveLength(1);
expect(result.next_step).toBe('shot_image_generate');
});
it('sets a character anchor image and updates the character anchor asset', async () => {
const result = await service.setCharacterAnchor(user, '20', {
character_image_id: '60'
});
expect(tx.characterImage.updateMany).toHaveBeenCalledWith({
where: {
character_id: 20n,
is_anchor: true,
id: { not: 60n }
},
data: {
is_anchor: false,
status: 'generated'
}
});
expect(tx.character.update).toHaveBeenCalledWith({
where: { id: 20n },
data: { anchor_asset_id: 50n }
});
expect(result.anchor_asset_id).toBe('50');
});
it('generates a preview image for a confirmed storyboard shot', async () => {
const result = await service.generateShotImage(user, '40', {
image_type: 'preview'
});
expect(prisma.shotImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
episode_id: 30n,
shot_id: 40n,
asset_id: 50n,
image_type: 'preview',
status: 'generated'
})
});
expect(providers.executeProvider.mock.calls[0][0].input_json.prompt).toContain(
'anchor_asset_id=50'
);
expect(providers.executeProvider.mock.calls[0][0].allow_fallback).toBe(false);
expect(result.reused).toBe(false);
expect(result.next_step).toBe('final_image_generate');
});
it('stores real provider image bytes instead of the SVG fallback', async () => {
const png = Buffer.from('real-image-bytes');
providers.executeProvider.mockResolvedValueOnce({
provider: {
mode: 'real'
},
result: {
provider_request_id: 'real-image-1',
asset_url: 'openai://image/real-image-1.png',
content_base64: png.toString('base64'),
mime_type: 'image/png'
},
provider_log: {
cost_estimate: 0.02,
cost_actual: 0.02
}
});
storage.storePrivateFile.mockResolvedValueOnce({
file_path: 'local://generated-images/real.png',
size: BigInt(png.length),
hash: 'real-hash',
backend: 'local'
});
await service.generateShotImage(user, '40', { image_type: 'preview' });
expect(storage.storePrivateFile).toHaveBeenCalledWith(
expect.objectContaining({
originalname: 'shot-40-preview.png',
mimetype: 'image/png',
size: png.length,
buffer: png
}),
'generated-images'
);
expect(prisma.asset.create).toHaveBeenCalledWith({
data: expect.objectContaining({
file_path: 'local://generated-images/real.png',
file_url: 'openai://image/real-image-1.png',
mime_type: 'image/png',
status: 'active'
})
});
});
it('does not create mock images when a real provider returns no image content', async () => {
providers.executeProvider.mockResolvedValueOnce({
provider: {
mode: 'real'
},
result: {
provider_request_id: 'real-image-empty',
asset_url: 'openai://image/empty.png'
},
provider_log: {
cost_estimate: 0,
cost_actual: 0
}
});
await expect(service.generateShotImage(user, '40', { image_type: 'preview' })).rejects.toBeInstanceOf(
BadRequestException
);
expect(prisma.asset.create).not.toHaveBeenCalled();
expect(prisma.renderTask.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
status: 'failed',
error_code: 'IMAGE_ASSET_STORE_FAILED'
})
});
});
it('rejects image generation for unconfirmed storyboard shots', async () => {
prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'generated' }));
await expect(service.generateShotImage(user, '40', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listShotImages(user, '40')).rejects.toBeInstanceOf(ForbiddenException);
});
});
+985
View File
@@ -0,0 +1,985 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset, Character, Prisma, Project, StoryboardShot } from '@prisma/client';
import { Prisma as PrismaNamespace } from '@prisma/client';
import { createHash } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
import {
GenerateCharacterImagesDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
} from './image.dto';
import {
CHARACTER_IMAGE_TYPES,
SHOT_IMAGE_TYPES,
toSafeCharacterImage,
toSafeShotImage,
type CharacterImageType,
type ShotImageType
} from './image.types';
const DEFAULT_CHARACTER_IMAGE_TYPES: CharacterImageType[] = [
'front_reference',
'anchor',
'expression_pack'
];
const DEFAULT_QUALITY_SCORE = new PrismaNamespace.Decimal(92);
interface StoredGeneratedImage {
asset: Asset;
provider_request_id: string | null;
cost_estimate: number | null;
cost_actual: number | null;
}
@Injectable()
export class ImagesService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(StorageService) private readonly storage: StorageService,
@Inject(ProvidersService) private readonly providersService: ProvidersService
) {}
async generateCharacterImages(
user: AuthRequestUser,
characterId: string,
dto: GenerateCharacterImagesDto
) {
const { character, project } = await this.loadCharacterForUser(characterId, user);
if (character.status !== 'locked') {
throw new BadRequestException('Locked character is required before image generation');
}
const imageTypes = this.resolveCharacterImageTypes(dto.image_types);
const countPerType = this.normalizePositiveInt(dto.count_per_type, 'count_per_type', 1, 3, 1);
const images = [];
for (const imageType of imageTypes) {
for (let index = 0; index < countPerType; index += 1) {
const existing = dto.force
? null
: await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
image_type: imageType,
status: { in: ['generated', 'selected'] }
},
orderBy: { created_at: 'asc' }
});
if (existing) {
images.push(await this.loadCharacterImage(existing.id));
continue;
}
images.push(await this.generateSingleCharacterImage(project, character, imageType, index));
}
}
let anchor = null;
if (dto.set_first_as_anchor !== false) {
const anchorCandidate =
images.find((image) => image.image_type === 'anchor') ?? images[0] ?? null;
if (anchorCandidate?.id) {
anchor = await this.setCharacterAnchor(user, character.id.toString(), {
character_image_id: anchorCandidate.id
});
}
}
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'character_image_generated' }
});
return {
images,
anchor,
next_step: 'shot_image_generate'
};
}
async listCharacterImages(user: AuthRequestUser, characterId: string) {
const { character } = await this.loadCharacterForUser(characterId, user);
const images = await this.prisma.characterImage.findMany({
where: { character_id: character.id },
orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }]
});
return Promise.all(images.map((image) => this.withCharacterAsset(image)));
}
async setCharacterAnchor(
user: AuthRequestUser,
characterId: string,
dto: SetCharacterAnchorDto
) {
const { character } = await this.loadCharacterForUser(characterId, user);
const image = await this.resolveCharacterAnchorImage(character, dto);
if (!image.asset_id) {
throw new BadRequestException('Character image has no asset');
}
const [updated] = await this.prisma.$transaction(async (tx) => {
await tx.characterImage.updateMany({
where: {
character_id: character.id,
is_anchor: true,
id: { not: image.id }
},
data: {
is_anchor: false,
status: 'generated'
}
});
const selected = await tx.characterImage.update({
where: { id: image.id },
data: {
is_anchor: true,
status: 'selected'
}
});
await tx.character.update({
where: { id: character.id },
data: { anchor_asset_id: image.asset_id }
});
return [selected];
});
return {
character_id: character.id.toString(),
anchor_asset_id: image.asset_id.toString(),
image: await this.withCharacterAsset(updated),
next_step: 'storyboard_image_generate'
};
}
async generateShotImage(user: AuthRequestUser, shotId: string, dto: GenerateShotImageDto) {
const { shot, project } = await this.loadShotForUser(shotId, user);
const imageType = this.validateShotImageType(dto.image_type ?? 'preview');
if (shot.status !== 'confirmed') {
throw new BadRequestException('Confirmed storyboard shot is required before image generation');
}
const existing = dto.force
? null
: await this.prisma.shotImage.findFirst({
where: {
shot_id: shot.id,
image_type: imageType,
status: 'generated'
},
orderBy: { created_at: 'asc' }
});
if (existing) {
return {
image: await this.loadShotImage(existing.id),
reused: true
};
}
const image = await this.generateSingleShotImage(project, shot, imageType);
await this.prisma.project.update({
where: { id: project.id },
data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' }
});
return {
image,
reused: false,
next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate'
};
}
async listShotImages(user: AuthRequestUser, shotId: string) {
const { shot } = await this.loadShotForUser(shotId, user);
const images = await this.prisma.shotImage.findMany({
where: { shot_id: shot.id },
orderBy: [{ image_type: 'asc' }, { created_at: 'asc' }]
});
return Promise.all(images.map((image) => this.withShotAsset(image)));
}
async generateEpisodeShotImages(
user: AuthRequestUser,
episodeId: string,
dto: GenerateEpisodeShotImagesDto
) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const imageType = this.validateShotImageType(dto.image_type ?? 'preview');
const onlyMissing = dto.only_missing !== false;
const limit = this.normalizePositiveInt(dto.limit, 'limit', 1, 50, 20);
const shots = await this.prisma.storyboardShot.findMany({
where: {
episode_id: episode.id,
status: 'confirmed'
},
orderBy: { shot_no: 'asc' },
take: limit
});
if (shots.length === 0) {
throw new BadRequestException('Confirmed storyboard shots are required before image generation');
}
const images = [];
for (const shot of shots) {
const existing = onlyMissing
? await this.prisma.shotImage.findFirst({
where: {
shot_id: shot.id,
image_type: imageType,
status: 'generated'
},
orderBy: { created_at: 'asc' }
})
: null;
if (existing) {
images.push(await this.loadShotImage(existing.id));
} else {
images.push(await this.generateSingleShotImage(project, shot, imageType));
}
}
await this.prisma.project.update({
where: { id: project.id },
data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' }
});
return {
episode_id: episode.id.toString(),
image_type: imageType,
images,
generated_count: images.length,
next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate'
};
}
private async generateSingleCharacterImage(
project: Project,
character: Character,
imageType: CharacterImageType,
index: number
) {
const prompt = this.buildCharacterPrompt(project, character, imageType, index);
const negativePrompt = this.buildCharacterNegativePrompt(character);
const task = await this.createRenderTask(project.id, null, null, 'character_image_generate', {
target_type: 'character',
character_id: character.id.toString(),
image_type: imageType,
index,
prompt,
negative_prompt: negativePrompt,
width: 1080,
height: 1920
});
const stored = await this.executeAndStoreGeneratedImage({
project,
taskId: task.id,
prompt,
negativePrompt,
width: 1080,
height: 1920,
imageKind: `character-${character.id.toString()}-${imageType}-${index}`
});
const created = await this.prisma.characterImage.create({
data: {
project_id: project.id,
character_id: character.id,
asset_id: stored.asset.id,
image_type: imageType,
prompt_text: prompt,
negative_prompt: negativePrompt,
is_anchor: imageType === 'anchor',
quality_score: DEFAULT_QUALITY_SCORE,
status: 'generated'
}
});
return this.withCharacterAsset(created);
}
private async generateSingleShotImage(
project: Project,
shot: StoryboardShot,
imageType: ShotImageType
) {
const characterRefs = await this.loadShotCharacterRefs(shot);
const prompt = this.buildShotPrompt(project, shot, characterRefs, imageType);
const negativePrompt = this.buildShotNegativePrompt(shot, characterRefs);
const task = await this.createRenderTask(project.id, shot.episode_id, shot.id, 'shot_image_generate', {
target_type: 'storyboard_shot',
shot_id: shot.id.toString(),
image_type: imageType,
prompt,
negative_prompt: negativePrompt,
width: 1080,
height: 1920,
anchor_asset_ids: characterRefs
.map((character) => character.anchor_asset_id?.toString())
.filter((assetId): assetId is string => Boolean(assetId))
});
const stored = await this.executeAndStoreGeneratedImage({
project,
taskId: task.id,
prompt,
negativePrompt,
width: 1080,
height: 1920,
imageKind: `shot-${shot.id.toString()}-${imageType}`
});
const created = await this.prisma.shotImage.create({
data: {
project_id: project.id,
episode_id: shot.episode_id,
shot_id: shot.id,
asset_id: stored.asset.id,
image_type: imageType,
prompt_text: prompt,
negative_prompt: negativePrompt,
quality_score: DEFAULT_QUALITY_SCORE,
status: 'generated'
}
});
return this.withShotAsset(created);
}
private async executeAndStoreGeneratedImage(input: {
project: Project;
taskId: bigint;
prompt: string;
negativePrompt: string;
width: number;
height: number;
imageKind: string;
}): Promise<StoredGeneratedImage> {
const providerResult = await this.providersService.executeProvider({
provider_type: 'ImageProvider',
purpose: input.imageKind,
project_id: input.project.id.toString(),
task_id: input.taskId.toString(),
allow_fallback: false,
return_binary: true,
input_json: {
prompt: input.prompt,
negative_prompt: input.negativePrompt,
width: input.width,
height: input.height
}
});
try {
const result = this.jsonObject(providerResult.result);
const providerRequestId = this.stringifyText(result.provider_request_id);
const generatedFile = await this.createGeneratedImageFile(
result,
input,
providerResult.provider.mode === 'mock'
);
const stored = await this.storage.storePrivateFile(generatedFile as unknown as Express.Multer.File, 'generated-images');
const asset = await this.prisma.asset.create({
data: {
user_id: input.project.user_id,
project_id: input.project.id,
asset_type: 'image',
file_path: stored.file_path,
file_url: this.stringifyText(result.asset_url) || null,
mime_type: generatedFile.mimetype,
width: input.width,
height: input.height,
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: generatedFile.isMock ? 'mock' : 'active'
}
});
await this.prisma.renderTask.update({
where: { id: input.taskId },
data: {
status: 'success',
output_asset_id: asset.id,
finished_at: new Date()
}
});
return {
asset,
provider_request_id: providerRequestId || null,
cost_estimate: providerResult.provider_log.cost_estimate,
cost_actual: providerResult.provider_log.cost_actual
};
} catch (error) {
const normalized = this.toError(error);
await this.prisma.renderTask.update({
where: { id: input.taskId },
data: {
status: 'failed',
error_code: 'IMAGE_ASSET_STORE_FAILED',
error_message: normalized.message,
finished_at: new Date()
}
});
throw error;
}
}
private async createGeneratedImageFile(
result: Record<string, unknown>,
input: {
width: number;
height: number;
imageKind: string;
prompt: string;
},
allowMockOutput: boolean
) {
const contentBase64 = this.stringifyText(result.content_base64);
const mimeType = this.normalizeImageMimeType(this.stringifyText(result.mime_type));
if (contentBase64) {
const buffer = this.decodeBase64(contentBase64, 'ImageProvider content_base64');
return {
originalname: `${input.imageKind}${this.extensionFromMime(mimeType)}`,
mimetype: mimeType,
size: buffer.length,
buffer,
isMock: false
};
}
const assetUrl = this.stringifyText(result.asset_url);
if (/^https?:\/\//i.test(assetUrl)) {
const downloaded = await this.downloadProviderAsset(assetUrl, 'image');
const downloadedMime = this.normalizeImageMimeType(downloaded.mimeType);
return {
originalname: `${input.imageKind}${this.extensionFromMime(downloadedMime)}`,
mimetype: downloadedMime,
size: downloaded.buffer.length,
buffer: downloaded.buffer,
isMock: false
};
}
if (!allowMockOutput) {
throw new BadRequestException('ImageProvider did not return image content or downloadable URL');
}
const svg = this.createMockSvg(input.width, input.height, input.imageKind, input.prompt);
const buffer = Buffer.from(svg);
return {
originalname: `${input.imageKind}.svg`,
mimetype: 'image/svg+xml',
size: buffer.length,
buffer,
isMock: true
};
}
private async downloadProviderAsset(url: string, expectedType: 'image') {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new BadRequestException(`Provider ${expectedType} download failed: HTTP ${response.status}`);
}
const mimeType = response.headers.get('content-type') || '';
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) {
throw new BadRequestException(`Provider ${expectedType} download returned empty content`);
}
return { buffer, mimeType };
} catch (error) {
const normalized = this.toError(error);
throw new BadRequestException(`Provider ${expectedType} download failed: ${normalized.message}`);
} finally {
clearTimeout(timeout);
}
}
private decodeBase64(value: string, label: string) {
const buffer = Buffer.from(value, 'base64');
if (!buffer.length) {
throw new BadRequestException(`${label} is empty`);
}
return buffer;
}
private normalizeImageMimeType(value: string | undefined) {
const normalized = value?.split(';')[0]?.trim().toLowerCase();
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg';
if (normalized === 'image/webp') return 'image/webp';
if (normalized === 'image/svg+xml') return 'image/svg+xml';
return 'image/png';
}
private extensionFromMime(mimeType: string) {
switch (mimeType) {
case 'image/jpeg':
return '.jpg';
case 'image/webp':
return '.webp';
case 'image/svg+xml':
return '.svg';
case 'image/png':
default:
return '.png';
}
}
private async createRenderTask(
projectId: bigint,
episodeId: bigint | null,
shotId: bigint | null,
taskType: 'character_image_generate' | 'shot_image_generate',
inputJson: Prisma.InputJsonObject
) {
const inputHash = this.hashJson(inputJson);
return this.prisma.renderTask.create({
data: {
project_id: projectId,
episode_id: episodeId,
shot_id: shotId,
task_type: taskType,
status: 'pending',
input_json: inputJson,
input_hash: inputHash,
idempotency_key: `${taskType}:${projectId.toString()}:${episodeId?.toString() ?? 'none'}:${shotId?.toString() ?? 'none'}:${inputHash}:${Date.now()}`,
retry_count: 0,
max_retry: 3
}
});
}
private buildCharacterPrompt(
project: Project,
character: Character,
imageType: CharacterImageType,
index: number
) {
return [
'high quality Korean webtoon style, vertical 9:16 character reference',
'clean illustration only, no visible text, no labels, no speech bubbles',
`project=${project.title ?? 'untitled'}`,
`image_type=${imageType}`,
`variant=${index + 1}`,
`name=${character.name}`,
character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null,
`role=${character.role_type}`,
`gender=${character.gender_label ?? 'unspecified'}`,
`age=${character.age_group ?? 'adult'}`,
`identity=${character.identity_desc ?? 'main cast'}`,
`appearance=${character.appearance_desc ?? ''}`,
`face=${character.face_desc ?? ''}`,
`hair=${character.hair_desc ?? ''}`,
`eyes=${character.eye_desc ?? ''}`,
`body=${character.body_desc ?? ''}`,
`costume=${character.costume_rules ?? 'clean modern outfit'}`,
character.wardrobe_variant ? `wardrobe_variant=${character.wardrobe_variant}` : null,
character.performance_style ? `performance=${character.performance_style}` : null,
`props=${character.special_props ?? 'none'}`
].filter(Boolean).join('\n');
}
private buildCharacterNegativePrompt(character: Character) {
return [
'low quality, blurry, extra fingers, bad hands, text artifacts, watermark',
'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, text boxes, unreadable glyphs, square glyph artifacts',
'face drift, age drift, hair color drift, duplicate person, mixed identity',
character.negative_rules
].filter(Boolean).join(', ');
}
private buildShotPrompt(
project: Project,
shot: StoryboardShot,
characters: Character[],
imageType: ShotImageType
) {
const characterLines = characters.map((character) =>
[
character.name,
character.age_group,
character.face_desc,
character.hair_desc,
character.costume_rules,
character.wardrobe_variant,
character.performance_style,
character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null,
character.anchor_asset_id ? `anchor_asset_id=${character.anchor_asset_id.toString()}` : null
].filter(Boolean).join(' | ')
);
return [
'high quality Korean webtoon style, vertical 9:16 storyboard image',
'clean cinematic frame only, no visible text, no captions, no speech bubbles, no dialogue balloons, no comic text boxes',
'express dialogue through facial expression, pose, camera and lighting only',
`project=${project.title ?? 'untitled'}`,
`image_type=${imageType}`,
`shot_no=${shot.shot_no}`,
`scene=${shot.scene_name ?? ''}`,
`location=${shot.location_desc ?? ''}`,
`visual=${shot.visual_desc ?? ''}`,
`action=${shot.action_desc ?? ''}`,
`camera=${shot.camera_motion ?? 'subtle zoom'}`,
`effect=${shot.effect_type ?? 'none'}`,
`dialogue=${shot.dialogue_text ?? ''}`,
`narration=${shot.narration_text ?? ''}`,
`characters=${characterLines.join(' || ') || 'no named character'}`
].filter(Boolean).join('\n');
}
private buildShotNegativePrompt(shot: StoryboardShot, characters: Character[]) {
return [
shot.negative_prompt,
'low quality, blurry, extra fingers, bad hands, text artifacts, watermark',
'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, comic panels with text, text boxes, unreadable glyphs, square glyph artifacts',
'wrong face, age drift, hair color drift, extra people, missing character',
characters.length > 2 ? 'avoid crowded composition, separate character faces clearly' : null
].filter(Boolean).join(', ');
}
private createMockSvg(width: number, height: number, label: string, prompt: string) {
const color = `#${this.hashJson({ label }).slice(0, 6)}`;
const promptHash = this.hashJson({ prompt }).slice(0, 12);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
`<rect width="100%" height="100%" fill="${color}"/>`,
'<rect x="72" y="72" width="936" height="1776" rx="36" fill="rgba(255,255,255,0.18)" stroke="rgba(255,255,255,0.55)" stroke-width="4"/>',
'<circle cx="540" cy="520" r="210" fill="rgba(255,255,255,0.26)"/>',
'<rect x="250" y="820" width="580" height="620" rx="120" fill="rgba(255,255,255,0.22)"/>',
`<text x="540" y="1540" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" fill="white">MOCK IMAGE</text>`,
`<text x="540" y="1600" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="white">${this.escapeXml(label.slice(0, 48))}</text>`,
`<text x="540" y="1650" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" fill="white">prompt:${promptHash}</text>`,
'</svg>'
].join('');
}
private async loadShotCharacterRefs(shot: StoryboardShot) {
const ids = this.extractCharacterIds(shot.characters_json);
if (ids.length === 0) {
return [];
}
return this.prisma.character.findMany({
where: {
id: { in: ids },
project_id: shot.project_id,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
}
private extractCharacterIds(value: Prisma.JsonValue | null) {
if (!Array.isArray(value)) {
return [];
}
const ids = value
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
const raw = (item as Record<string, Prisma.JsonValue>).id;
return typeof raw === 'string' || typeof raw === 'number' ? this.tryParseId(raw) : null;
})
.filter((id): id is bigint => id !== null);
return [...new Set(ids)];
}
private async resolveCharacterAnchorImage(character: Character, dto: SetCharacterAnchorDto) {
if (dto.character_image_id) {
const image = await this.prisma.characterImage.findUnique({
where: { id: this.parseId(dto.character_image_id, 'Invalid character_image_id') }
});
if (!image || image.character_id !== character.id) {
throw new NotFoundException('Character image not found');
}
return image;
}
if (dto.asset_id) {
const assetId = this.parseId(dto.asset_id, 'Invalid asset_id');
const image = await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
asset_id: assetId
},
orderBy: { created_at: 'asc' }
});
if (!image) {
throw new NotFoundException('Character image not found for asset');
}
return image;
}
const image = await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
status: { in: ['generated', 'selected'] }
},
orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }]
});
if (!image) {
throw new NotFoundException('Character image not found');
}
return image;
}
private async loadCharacterImage(id: bigint) {
const image = await this.prisma.characterImage.findUnique({
where: { id }
});
if (!image) {
throw new NotFoundException('Character image not found');
}
return this.withCharacterAsset(image);
}
private async loadShotImage(id: bigint) {
const image = await this.prisma.shotImage.findUnique({
where: { id }
});
if (!image) {
throw new NotFoundException('Shot image not found');
}
return this.withShotAsset(image);
}
private async withCharacterAsset(image: Awaited<ReturnType<typeof this.prisma.characterImage.findFirst>>) {
if (!image) {
throw new NotFoundException('Character image not found');
}
const asset = image.asset_id
? await this.prisma.asset.findUnique({ where: { id: image.asset_id } })
: null;
return toSafeCharacterImage({ ...image, asset });
}
private async withShotAsset(image: Awaited<ReturnType<typeof this.prisma.shotImage.findFirst>>) {
if (!image) {
throw new NotFoundException('Shot image not found');
}
const asset = image.asset_id
? await this.prisma.asset.findUnique({ where: { id: image.asset_id } })
: null;
return toSafeShotImage({ ...image, asset });
}
private async loadCharacterForUser(characterId: string, user: AuthRequestUser) {
const character = await this.prisma.character.findUnique({
where: { id: this.parseId(characterId, 'Invalid character id') }
});
if (!character || character.status === 'deleted') {
throw new NotFoundException('Character not found');
}
const project = await this.findProjectForUser(character.project_id, user);
return { character, project };
}
private async loadShotForUser(shotId: string, user: AuthRequestUser) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: this.parseId(shotId, 'Invalid shot id') }
});
if (!shot) {
throw new NotFoundException('Storyboard shot not found');
}
const project = await this.findProjectForUser(shot.project_id, user);
return { shot, project };
}
private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) {
const episode = await this.prisma.episode.findUnique({
where: { id: this.parseId(episodeId, 'Invalid episode id') }
});
if (!episode) {
throw new NotFoundException('Episode not found');
}
const project = await this.findProjectForUser(episode.project_id, user);
return { episode, project };
}
private async findProjectForUser(projectId: bigint, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: projectId }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private resolveCharacterImageTypes(value: string[] | undefined) {
if (!value?.length) {
return DEFAULT_CHARACTER_IMAGE_TYPES;
}
return value.map((item) => this.validateCharacterImageType(item));
}
private validateCharacterImageType(value: unknown): CharacterImageType {
if (typeof value !== 'string' || !(CHARACTER_IMAGE_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException('image_type is not supported');
}
return value as CharacterImageType;
}
private validateShotImageType(value: unknown): ShotImageType {
if (typeof value !== 'string' || !(SHOT_IMAGE_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException('image_type must be preview or final');
}
return value as ShotImageType;
}
private normalizePositiveInt(
value: unknown,
field: string,
min: number,
max: number,
fallback: number
) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private parseId(value: string | bigint | number, message: string) {
try {
const id = BigInt(value);
if (id <= 0n) {
throw new Error('ID must be positive');
}
return id;
} catch {
throw new BadRequestException(message);
}
}
private tryParseId(value: string | number) {
try {
const id = BigInt(value);
return id > 0n ? id : null;
} catch {
return null;
}
}
private hashJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null) {
return createHash('sha256').update(this.stableStringify(value)).digest('hex');
}
private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
}
const entries = Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`);
return `{${entries.join(',')}}`;
}
private jsonObject(value: unknown) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private stringifyText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
private toError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
private escapeXml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
@@ -0,0 +1,556 @@
import 'reflect-metadata';
import '../config/load-env';
import { Prisma, PrismaClient } from '@prisma/client';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
type TestcaseCharacter = {
character_key: string;
name: string;
role_type: string;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
relationship_desc?: string;
character_arc?: string;
negative_rules?: string;
wardrobe_variant?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
importance_level?: number;
};
type TestcaseShotCharacter = {
character_key?: string;
name?: string;
};
type TestcaseShot = {
shot_no: number;
scene_name: string;
location_desc: string;
characters_json: TestcaseShotCharacter[];
visual_desc: string;
action_desc: string;
dialogue_text?: string | null;
narration_text?: string | null;
camera_motion?: string | null;
effect_type?: string | null;
duration: number;
scene_type?: string | null;
importance_score?: number | null;
emotion_score?: number | null;
action_score?: number | null;
route_tier?: string | null;
status?: string;
};
type LiveActionTestcase = {
testcase_id: string;
project: {
title?: string;
input_mode?: string;
genre?: string;
style_code?: string;
output_type?: string;
output_mode?: string;
visual_mode?: string;
video_generation_level?: string;
target_episode_count?: number;
episode_duration?: number;
quality_level?: string;
is_long_series?: boolean;
};
story_bible: {
premise?: string;
world_setting?: string;
tone?: string;
forbidden_setting?: string;
continuity_rules?: string[];
};
characters: TestcaseCharacter[];
scenes: Array<{
scene_key: string;
name: string;
location_desc: string;
visual_rules?: string;
}>;
episode: {
episode_no: number;
title?: string;
summary?: string;
opening_hook?: string;
middle_conflict?: string;
ending_hook?: string;
target_duration?: number;
script_text?: string;
narration_text?: string;
dialogue_json?: unknown;
};
storyboard_shots: TestcaseShot[];
output_requirements?: unknown;
acceptance_criteria?: unknown;
};
type RuntimeConfig = {
filePath: string;
ownerEmail: string | null;
replace: boolean;
};
const DEFAULT_TESTCASE_PATH = resolve(
__dirname,
'..',
'..',
'..',
'storage',
'private',
'live-action-testcases',
'takeaway-heir-episode-001.json'
);
async function main() {
const config = parseArgs(process.argv.slice(2));
const prisma = new PrismaClient();
try {
const testcase = await loadTestcase(config.filePath);
assertTestcase(testcase);
const owner = await resolveOwner(prisma, config.ownerEmail);
if (config.replace) {
await deleteExistingImportedProjects(prisma, testcase);
}
const result = await prisma.$transaction(async (tx) => {
const project = await tx.project.create({
data: {
user_id: owner.id,
title: testcase.project.title ?? '真人短剧压测项目',
input_mode: testcase.project.input_mode ?? 'ai_original',
genre: testcase.project.genre ?? 'urban_counterattack',
style_code: testcase.project.style_code ?? 'live_action',
output_type: testcase.project.output_type ?? 'short_video',
output_mode: testcase.project.output_mode ?? 'live_action_ai',
visual_mode: testcase.project.visual_mode ?? 'live_action',
video_generation_level: testcase.project.video_generation_level ?? 'standard',
target_episode_count: testcase.project.target_episode_count ?? 1,
episode_duration: testcase.project.episode_duration ?? testcase.episode.target_duration ?? 60,
status: 'storyboard_confirmed',
copyright_status: 'confirmed',
payment_status: 'paid',
quality_level: testcase.project.quality_level ?? 'provider_acceptance',
is_long_series: testcase.project.is_long_series ?? false
}
});
await tx.copyrightRecord.create({
data: {
project_id: project.id,
user_id: owner.id,
authorization_type: 'ai_original_testcase',
statement_text: `测试用例 ${testcase.testcase_id}:AI 原创短剧压测素材,仅用于内部流水线验收。`
}
});
await tx.storyBible.create({
data: {
project_id: project.id,
title: testcase.project.title ?? '真人短剧压测故事圣经',
logline: testcase.story_bible.premise ?? testcase.episode.summary ?? null,
main_plot: testcase.episode.script_text ?? testcase.story_bible.premise ?? null,
core_conflict: testcase.episode.middle_conflict ?? null,
selling_points: [
testcase.episode.opening_hook,
testcase.episode.ending_hook
].filter(Boolean).join('\n') || null,
tone: testcase.story_bible.tone ?? '都市逆袭',
world_summary: testcase.story_bible.world_setting ?? null,
ending_direction: testcase.episode.ending_hook ?? null,
taboo_rules: [
testcase.story_bible.forbidden_setting,
...(testcase.story_bible.continuity_rules ?? [])
].filter(Boolean).join('\n'),
version: 1,
status: 'confirmed'
}
});
await tx.worldBible.create({
data: {
project_id: project.id,
world_type: 'modern_urban',
setting_text: testcase.story_bible.world_setting ?? null,
rules_text: testcase.story_bible.premise ?? null,
social_structure: '现代都市,林氏集团为隐秘顶级财团。',
time_period: '现代',
visual_rules: testcase.scenes.map((scene) => `${scene.name}${scene.location_desc}${scene.visual_rules ? `${scene.visual_rules}` : ''}`).join('\n'),
forbidden_rules: testcase.story_bible.forbidden_setting ?? null,
status: 'confirmed'
}
});
const characterByKey = new Map<string, { id: bigint; name: string }>();
for (const character of testcase.characters) {
const saved = await tx.character.create({
data: {
project_id: project.id,
name: character.name,
alias_names: [],
role_type: character.role_type,
gender_label: character.gender_label ?? null,
age_group: character.age_group ?? null,
identity_desc: character.identity_desc ?? null,
appearance_desc: character.appearance_desc ?? null,
face_desc: character.face_desc ?? null,
hair_desc: character.hair_desc ?? null,
eye_desc: character.eye_desc ?? null,
body_desc: character.body_desc ?? null,
costume_rules: character.costume_rules ?? null,
special_props: character.special_props ?? null,
personality_desc: character.personality_desc ?? null,
speech_style: character.speech_style ?? null,
relationship_desc: character.relationship_desc ?? null,
character_arc: character.character_arc ?? null,
negative_rules: character.negative_rules ?? null,
wardrobe_variant: character.wardrobe_variant ?? null,
voice_provider_code: character.voice_provider_code ?? null,
voice_model: character.voice_model ?? null,
voice_id: character.voice_id ?? null,
voice_style: character.voice_style ?? null,
performance_style: character.performance_style ?? null,
importance_level: character.importance_level ?? 0,
status: 'locked'
}
});
characterByKey.set(character.character_key, {
id: saved.id,
name: saved.name
});
await tx.actorProfile.create({
data: {
project_id: project.id,
character_id: saved.id,
actor_desc: [
character.name,
character.age_group,
character.gender_label,
character.identity_desc,
character.appearance_desc
].filter(Boolean).join(''),
appearance_rules: [
character.face_desc,
character.hair_desc,
character.body_desc,
character.negative_rules
].filter(Boolean).join(''),
wardrobe_rules: [
character.costume_rules,
character.special_props,
character.wardrobe_variant
].filter(Boolean).join(''),
performance_style: character.performance_style ?? character.personality_desc ?? null,
voice_style: character.voice_style ?? character.speech_style ?? null,
reference_asset_ids: [],
status: 'generated'
}
});
}
const episode = await tx.episode.create({
data: {
project_id: project.id,
episode_no: testcase.episode.episode_no,
source_chapter_ids: [],
title: testcase.episode.title ?? null,
summary: testcase.episode.summary ?? null,
opening_hook: testcase.episode.opening_hook ?? null,
middle_conflict: testcase.episode.middle_conflict ?? null,
ending_hook: testcase.episode.ending_hook ?? null,
target_duration: testcase.episode.target_duration ?? null,
status: 'confirmed'
}
});
await tx.episodeScript.create({
data: {
project_id: project.id,
episode_id: episode.id,
script_text: testcase.episode.script_text ?? null,
narration_text: testcase.episode.narration_text ?? null,
dialogue_json: toPrismaJson(testcase.episode.dialogue_json ?? []),
version: 1,
status: 'confirmed'
}
});
const shotIds: Array<{ id: string; shot_no: number; route_tier: string | null }> = [];
for (const shot of testcase.storyboard_shots) {
const saved = await tx.storyboardShot.create({
data: {
project_id: project.id,
episode_id: episode.id,
shot_no: shot.shot_no,
scene_name: shot.scene_name,
location_desc: shot.location_desc,
characters_json: toPrismaJson(resolveShotCharacters(shot.characters_json, characterByKey)),
visual_desc: shot.visual_desc,
action_desc: shot.action_desc,
dialogue_text: shot.dialogue_text ?? null,
narration_text: shot.narration_text ?? null,
camera_motion: shot.camera_motion ?? null,
effect_type: shot.effect_type ?? null,
duration: new Prisma.Decimal(shot.duration),
scene_type: shot.scene_type ?? null,
importance_score: shot.importance_score ?? null,
emotion_score: shot.emotion_score ?? null,
action_score: shot.action_score ?? null,
route_tier: shot.route_tier ?? null,
prompt_text: buildShotPromptText(shot),
negative_prompt: buildShotNegativePrompt(),
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
video_status: null,
status: 'confirmed'
}
});
shotIds.push({
id: saved.id.toString(),
shot_no: saved.shot_no,
route_tier: saved.route_tier
});
}
await tx.operationLog.create({
data: {
user_id: owner.id,
operator_role: owner.role,
action: 'live_action_testcase_import',
target_type: 'project',
target_id: project.id,
metadata_json: toPrismaJson({
testcase_id: testcase.testcase_id,
file_path: config.filePath,
episode_id: episode.id.toString(),
shot_count: testcase.storyboard_shots.length,
total_duration: totalDuration(testcase),
output_requirements: testcase.output_requirements ?? {},
acceptance_criteria: testcase.acceptance_criteria ?? {}
})
}
});
return {
project_id: project.id.toString(),
episode_id: episode.id.toString(),
owner_user_id: owner.id.toString(),
shot_ids: shotIds,
total_duration: totalDuration(testcase)
};
});
console.log(JSON.stringify({
status: 'imported',
testcase_id: testcase.testcase_id,
...result,
next_step: 'prepare_live_action_shots'
}, null, 2));
} finally {
await prisma.$disconnect();
}
}
function parseArgs(args: string[]): RuntimeConfig {
const map = new Map<string, string>();
for (const arg of args) {
if (!arg.startsWith('--')) continue;
const [key, ...rest] = arg.slice(2).split('=');
map.set(key, rest.length > 0 ? rest.join('=') : 'true');
}
return {
filePath: resolve(map.get('file') ?? process.env.LIVE_ACTION_TESTCASE_FILE ?? DEFAULT_TESTCASE_PATH),
ownerEmail: map.get('owner-email') ?? process.env.LIVE_ACTION_TESTCASE_OWNER_EMAIL ?? null,
replace: booleanArg(map.get('replace') ?? process.env.LIVE_ACTION_TESTCASE_REPLACE)
};
}
function booleanArg(value: string | undefined) {
if (!value) return false;
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
async function loadTestcase(filePath: string): Promise<LiveActionTestcase> {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw) as LiveActionTestcase;
}
function assertTestcase(testcase: LiveActionTestcase) {
if (!testcase.testcase_id) throw new Error('testcase_id is required');
if (!testcase.project?.title) throw new Error('project.title is required');
if (!Array.isArray(testcase.characters) || testcase.characters.length === 0) {
throw new Error('characters are required');
}
if (!testcase.episode?.episode_no) throw new Error('episode.episode_no is required');
if (!Array.isArray(testcase.storyboard_shots) || testcase.storyboard_shots.length === 0) {
throw new Error('storyboard_shots are required');
}
}
async function resolveOwner(prisma: PrismaClient, ownerEmail: string | null) {
const explicit = ownerEmail
? await prisma.user.findFirst({ where: { email: ownerEmail, status: 'active' } })
: null;
const owner = explicit ??
await prisma.user.findFirst({ where: { role: 'admin', status: 'active' }, orderBy: { id: 'asc' } }) ??
await prisma.user.findFirst({ where: { status: 'active' }, orderBy: { id: 'asc' } });
if (!owner) {
throw new Error('No active user found. Seed an admin user before importing the testcase.');
}
return owner;
}
async function deleteExistingImportedProjects(prisma: PrismaClient, testcase: LiveActionTestcase) {
const logs = await prisma.operationLog.findMany({
where: {
action: 'live_action_testcase_import',
target_type: 'project',
metadata_json: {
path: '$.testcase_id',
equals: testcase.testcase_id
}
},
select: {
target_id: true
}
});
const ids = logs
.map((log) => log.target_id)
.filter((id): id is bigint => Boolean(id));
if (ids.length === 0) return;
await prisma.$transaction(async (tx) => {
await tx.videoClip.deleteMany({ where: { project_id: { in: ids } } });
await tx.renderTask.deleteMany({ where: { project_id: { in: ids } } });
await tx.shotImage.deleteMany({ where: { project_id: { in: ids } } });
await tx.storyboardShot.deleteMany({ where: { project_id: { in: ids } } });
await tx.episodeScript.deleteMany({ where: { project_id: { in: ids } } });
await tx.episode.deleteMany({ where: { project_id: { in: ids } } });
await tx.actorProfile.deleteMany({ where: { project_id: { in: ids } } });
await tx.characterMemory.deleteMany({ where: { project_id: { in: ids } } });
await tx.characterImage.deleteMany({ where: { project_id: { in: ids } } });
await tx.character.deleteMany({ where: { project_id: { in: ids } } });
await tx.worldBible.deleteMany({ where: { project_id: { in: ids } } });
await tx.storyBible.deleteMany({ where: { project_id: { in: ids } } });
await tx.plotMemory.deleteMany({ where: { project_id: { in: ids } } });
await tx.plotThread.deleteMany({ where: { project_id: { in: ids } } });
await tx.continuityCheck.deleteMany({ where: { project_id: { in: ids } } });
await tx.contentReview.deleteMany({ where: { project_id: { in: ids } } });
await tx.copyrightRecord.deleteMany({ where: { project_id: { in: ids } } });
await tx.operationLog.deleteMany({
where: {
OR: [
{ target_type: 'project', target_id: { in: ids } },
{ metadata_json: { path: '$.testcase_id', equals: testcase.testcase_id } }
]
}
});
await tx.project.deleteMany({ where: { id: { in: ids } } });
});
}
function resolveShotCharacters(
characters: TestcaseShotCharacter[],
characterByKey: Map<string, { id: bigint; name: string }>
) {
return characters.map((character) => {
const saved = character.character_key ? characterByKey.get(character.character_key) : null;
return {
id: saved?.id.toString() ?? null,
character_key: character.character_key ?? null,
name: saved?.name ?? character.name ?? ''
};
});
}
function buildShotPromptText(shot: TestcaseShot) {
return [
'真人短剧分镜图参考',
`场景:${shot.location_desc}`,
`人物:${shot.characters_json.map((character) => character.name).filter(Boolean).join('、') || '主要角色'}`,
`画面:${shot.visual_desc}`,
`动作:${shot.action_desc}`,
shot.camera_motion ? `镜头:${shot.camera_motion}` : null,
shot.effect_type ? `效果:${shot.effect_type}` : null,
`时长:${shot.duration}`
].filter(Boolean).join('\n');
}
function buildShotNegativePrompt() {
return [
'低清晰度',
'人物变脸',
'服装突变',
'多余手指',
'字幕乱码',
'水印',
'动漫风',
'夸张玄幻特效'
].join('');
}
function totalDuration(testcase: LiveActionTestcase) {
return Number(testcase.storyboard_shots.reduce((sum, shot) => sum + Number(shot.duration || 0), 0).toFixed(2));
}
function toPrismaJson(value: unknown): Prisma.InputJsonValue {
if (value === null || value === undefined) return {};
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
if (Array.isArray(value)) return value.map((item) => toPrismaJson(item));
if (typeof value === 'object') {
const output: Record<string, Prisma.InputJsonValue> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (child !== undefined) {
output[key] = toPrismaJson(child);
}
}
return output;
}
return String(value);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -0,0 +1,690 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import type { Prisma, ProviderConfig, VideoClip } from '@prisma/client';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import { AppModule } from '../app.module';
import type { AuthRequestUser } from '../auth/auth.types';
import { AssetsService } from '../assets/assets.service';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
import { LiveActionService } from './live-action.service';
type AcceptanceStatus = 'passed' | 'failed' | 'skipped';
type AcceptanceRow = {
provider_code: string;
provider_label: string;
started_at: string;
finished_at: string;
status: AcceptanceStatus;
provider_enabled: boolean | null;
provider_mode: string | null;
api_key_env: string | null;
api_key_configured: boolean | null;
preflight_ready: boolean;
preflight_next_step: string | null;
blockers: string[];
warnings: string[];
clip_id: string | null;
output_asset_id: string | null;
cost_actual: number | null;
quality_status: string | null;
quality_score: number | null;
repair_action: string | null;
error_message: string | null;
preview_url: string | null;
};
type AcceptanceReport = {
generated_at: string;
project_id: string;
episode_id: string;
shot_id: string;
keyframe_asset_id: string | null;
providers: string[];
min_quality_score: number;
max_cost_per_clip: number | null;
confirm_real_video: boolean;
force_enable_providers: boolean;
force_regenerate: boolean;
operator_user_id: string;
rows: AcceptanceRow[];
summary: {
passed: number;
failed: number;
skipped: number;
};
};
type RuntimeConfig = {
projectId: string;
episodeId: string;
shotId: string;
providerCodes: string[];
keyframePath: string | null;
keyframeAssetId: string | null;
confirmRealVideo: boolean;
forceEnableProviders: boolean;
forceRegenerate: boolean;
runQualityCheck: boolean;
failOnReject: boolean;
maxCostPerClip: number | null;
minQualityScore: number;
outputDir: string;
operatorUserId: string | null;
};
type ProviderReadiness = {
provider_enabled: boolean | null;
provider_mode: string | null;
api_key_env: string | null;
api_key_configured: boolean | null;
blockers: string[];
warnings: string[];
};
type ImmediateQualityRunner = {
executeVideoClipQualityCheckNow: (
user: AuthRequestUser,
clipId: string,
dto?: {
auto_repair?: boolean;
min_quality_score?: number | string | null;
confirm_real_video?: boolean;
max_cost_per_clip?: number | string | null;
}
) => Promise<{
video_clip: {
id: string;
output_asset_id: string | null;
cost_actual: number | null;
quality_status: string | null;
quality_score: number | null;
};
repair_action: string;
}>;
};
const PROVIDER_ALIASES: Record<string, string> = {
hailuo: 'minimax_hailuo_23_fast',
minimax: 'minimax_hailuo_23_fast',
kling: 'kling-image-to-video',
mock: 'mock-video'
};
async function main() {
const config = readConfig();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
const prisma = app.get(PrismaService);
const liveAction = app.get(LiveActionService);
const assets = app.get(AssetsService);
const providers = app.get(ProvidersService);
const operator = await resolveOperator(prisma, config);
const assetOwner = await resolveProjectOwner(prisma, config);
await providers.bootstrapVideoProviders(operator);
if (config.forceEnableProviders) {
await prisma.providerConfig.updateMany({
where: {
provider_type: 'VideoProvider',
provider_code: { in: config.providerCodes.filter((code) => code !== 'mock-video') }
},
data: { is_enabled: true }
});
}
const keyframeAssetId = await ensureAcceptanceKeyframe({
config,
operator,
assetOwner,
assets,
liveAction
});
const rows: AcceptanceRow[] = [];
for (const providerCode of config.providerCodes) {
rows.push(await runProviderAcceptance({
config,
operator,
liveAction,
prisma,
providerCode
}));
}
const report: AcceptanceReport = {
generated_at: new Date().toISOString(),
project_id: config.projectId,
episode_id: config.episodeId,
shot_id: config.shotId,
keyframe_asset_id: keyframeAssetId,
providers: config.providerCodes,
min_quality_score: config.minQualityScore,
max_cost_per_clip: config.maxCostPerClip,
confirm_real_video: config.confirmRealVideo,
force_enable_providers: config.forceEnableProviders,
force_regenerate: config.forceRegenerate,
operator_user_id: operator.id,
rows,
summary: {
passed: rows.filter((row) => row.status === 'passed').length,
failed: rows.filter((row) => row.status === 'failed').length,
skipped: rows.filter((row) => row.status === 'skipped').length
}
};
const output = await writeReport(config, report);
console.log(`Live-action provider acceptance report written:`);
console.log(`- ${output.jsonPath}`);
console.log(`- ${output.markdownPath}`);
console.table(rows.map((row) => ({
provider: row.provider_code,
status: row.status,
enabled: row.provider_enabled === null ? '-' : row.provider_enabled ? 'yes' : 'no',
key: row.api_key_env ? `${row.api_key_env}:${row.api_key_configured ? 'yes' : 'no'}` : '-',
preflight: row.preflight_ready,
clip: row.clip_id ?? '-',
cost: row.cost_actual ?? '-',
quality: row.quality_score ?? row.quality_status ?? '-',
error: row.error_message ? row.error_message.slice(0, 80) : '-'
})));
if (config.failOnReject && rows.some((row) => row.status !== 'passed')) {
process.exitCode = 1;
}
} finally {
await app.close();
}
}
async function runProviderAcceptance(input: {
config: RuntimeConfig;
operator: AuthRequestUser;
liveAction: LiveActionService;
prisma: PrismaService;
providerCode: string;
}): Promise<AcceptanceRow> {
const startedAt = new Date().toISOString();
const baseRow: AcceptanceRow = {
provider_code: input.providerCode,
provider_label: providerLabel(input.providerCode),
started_at: startedAt,
finished_at: startedAt,
status: 'failed',
provider_enabled: null,
provider_mode: null,
api_key_env: null,
api_key_configured: null,
preflight_ready: false,
preflight_next_step: null,
blockers: [],
warnings: [],
clip_id: null,
output_asset_id: null,
cost_actual: null,
quality_status: null,
quality_score: null,
repair_action: null,
error_message: null,
preview_url: null
};
try {
const readiness = await inspectProviderReadiness(
input.prisma,
input.providerCode,
input.config.confirmRealVideo
);
baseRow.provider_enabled = readiness.provider_enabled;
baseRow.provider_mode = readiness.provider_mode;
baseRow.api_key_env = readiness.api_key_env;
baseRow.api_key_configured = readiness.api_key_configured;
baseRow.blockers.push(...readiness.blockers);
baseRow.warnings.push(...readiness.warnings);
if (readiness.blockers.length > 0) {
return finishRow(baseRow, 'skipped', readiness.blockers[0]);
}
const preflight = await input.liveAction.preflightVideoClips(input.operator, input.config.episodeId, {
provider_code: input.providerCode,
confirm_real_video: input.config.confirmRealVideo,
max_cost_per_clip: input.config.maxCostPerClip,
shot_id: input.config.shotId
});
baseRow.preflight_ready = preflight.ready;
baseRow.preflight_next_step = preflight.next_step;
baseRow.blockers.push(...preflight.blockers.map((issue) => `${issue.code}: ${issue.message}`));
baseRow.warnings.push(...preflight.warnings.map((issue) => `${issue.code}: ${issue.message}`));
if (!preflight.ready) {
return finishRow(baseRow, 'skipped', baseRow.blockers[0] ?? 'Preflight did not pass');
}
if (input.providerCode !== 'mock-video' && !input.config.confirmRealVideo) {
return finishRow(
baseRow,
'skipped',
'LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO=true is required before running a real video provider'
);
}
const generated = await input.liveAction.generateShotVideoClip(input.operator, input.config.episodeId, input.config.shotId, {
provider_code: input.providerCode,
confirm_real_video: input.config.confirmRealVideo,
force: input.config.forceRegenerate,
max_cost_per_clip: input.config.maxCostPerClip
});
baseRow.clip_id = generated.video_clip.id;
baseRow.output_asset_id = generated.video_clip.output_asset_id;
baseRow.cost_actual = generated.video_clip.cost_actual;
baseRow.preview_url = generated.video_clip.output_asset_id
? `/api/assets/${generated.video_clip.output_asset_id}/download`
: null;
if (input.config.runQualityCheck) {
const qualityRunner = input.liveAction as unknown as ImmediateQualityRunner;
const quality = await qualityRunner.executeVideoClipQualityCheckNow(input.operator, generated.video_clip.id, {
auto_repair: false,
min_quality_score: input.config.minQualityScore,
confirm_real_video: input.config.confirmRealVideo,
max_cost_per_clip: input.config.maxCostPerClip
});
baseRow.output_asset_id = quality.video_clip.output_asset_id ?? baseRow.output_asset_id;
baseRow.cost_actual = quality.video_clip.cost_actual ?? baseRow.cost_actual;
baseRow.quality_status = quality.video_clip.quality_status;
baseRow.quality_score = quality.video_clip.quality_score;
baseRow.repair_action = quality.repair_action;
} else {
const latestClip = await findLatestClip(input.prisma, generated.video_clip.id);
baseRow.quality_status = latestClip?.quality_status ?? null;
baseRow.quality_score = latestClip?.quality_score ? Number(latestClip.quality_score.toString()) : null;
}
const passed =
baseRow.output_asset_id !== null &&
(baseRow.quality_score === null
? baseRow.quality_status === null || baseRow.quality_status === 'passed'
: baseRow.quality_status === 'passed' && baseRow.quality_score >= input.config.minQualityScore);
return finishRow(baseRow, passed ? 'passed' : 'failed', passed ? null : 'Quality check did not meet acceptance threshold');
} catch (error) {
const message = toErrorMessage(error);
const status = isReadinessError(message) ? 'skipped' : 'failed';
return finishRow(baseRow, status, message);
}
}
async function inspectProviderReadiness(
prisma: PrismaService,
providerCode: string,
confirmRealVideo: boolean
): Promise<ProviderReadiness> {
const provider = await prisma.providerConfig.findUnique({
where: {
provider_type_provider_code: {
provider_type: 'VideoProvider',
provider_code: providerCode
}
}
});
const config = jsonObject(provider?.config_json);
const apiKeyEnv = stringifyText(config.api_key_env);
const isRealProvider = Boolean(provider && provider.mode !== 'mock');
const blockers: string[] = [];
const warnings: string[] = [];
const hasManagedKey = hasManagedProviderSecret(config.api_key_secure);
const apiKeyConfigured = isRealProvider
? hasManagedKey || (apiKeyEnv ? hasConfiguredEnvValue(apiKeyEnv) : false)
: null;
if (!provider) {
blockers.push('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND: 视频 Provider 不存在。');
} else if (!provider.is_enabled) {
blockers.push('LIVE_ACTION_VIDEO_PROVIDER_DISABLED: 视频 Provider 未启用。');
}
if (provider && isRealProvider && !confirmRealVideo) {
blockers.push('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED: 真实视频生成需要显式确认费用。');
}
if (provider && isRealProvider && !apiKeyConfigured) {
blockers.push(
apiKeyEnv
? `${apiKeyEnv}_MISSING: 未配置 ${apiKeyEnv},不能调用真实 Provider。`
: 'VIDEO_PROVIDER_API_KEY_NOT_CONFIGURED: 未配置后台密钥或 api_key_env,不能调用真实 Provider。'
);
}
if (provider && isRealProvider && hasManagedKey && !apiKeyEnv) {
warnings.push('VIDEO_PROVIDER_API_KEY_ENV_NOT_SET: 已保存后台密钥,但 Provider 配置未声明 api_key_env。');
}
return {
provider_enabled: provider?.is_enabled ?? null,
provider_mode: provider?.mode ?? null,
api_key_env: apiKeyEnv || null,
api_key_configured: apiKeyConfigured,
blockers,
warnings
};
}
async function ensureAcceptanceKeyframe(input: {
config: RuntimeConfig;
operator: AuthRequestUser;
assetOwner: AuthRequestUser;
assets: AssetsService;
liveAction: LiveActionService;
}) {
if (input.config.keyframePath) {
const absolutePath = resolve(input.config.keyframePath);
const buffer = await readFile(absolutePath);
const mimeType = imageMimeFromPath(absolutePath);
if (!mimeType) {
throw new Error('LIVE_ACTION_ACCEPTANCE_KEYFRAME_PATH must be a PNG, JPG, JPEG, or WEBP file');
}
const uploaded = await input.assets.uploadAsset(
input.assetOwner,
{
fieldname: 'file',
originalname: basename(absolutePath),
encoding: '7bit',
mimetype: mimeType,
size: buffer.length,
buffer
} as Express.Multer.File,
'image',
input.config.projectId
);
await input.liveAction.attachShotKeyframe(input.operator, input.config.episodeId, input.config.shotId, {
asset_id: uploaded.asset.id
});
return uploaded.asset.id;
}
if (input.config.keyframeAssetId) {
await input.liveAction.attachShotKeyframe(input.operator, input.config.episodeId, input.config.shotId, {
asset_id: input.config.keyframeAssetId
});
return input.config.keyframeAssetId;
}
return null;
}
async function resolveOperator(prisma: PrismaService, config: RuntimeConfig): Promise<AuthRequestUser> {
const explicit = config.operatorUserId
? await prisma.user.findUnique({ where: { id: parseBigInt(config.operatorUserId, 'LIVE_ACTION_ACCEPTANCE_OPERATOR_USER_ID') } })
: null;
const admin = explicit ?? await prisma.user.findFirst({ where: { role: 'admin', status: 'active' }, orderBy: { id: 'asc' } });
if (!admin || admin.role !== 'admin') {
throw new Error('An active admin user is required for provider acceptance because provider override is admin-only');
}
const project = await prisma.project.findUnique({
where: { id: parseBigInt(config.projectId, 'LIVE_ACTION_ACCEPTANCE_PROJECT_ID') }
});
if (!project) {
throw new Error(`Project not found: ${config.projectId}`);
}
const episode = await prisma.episode.findUnique({
where: { id: parseBigInt(config.episodeId, 'LIVE_ACTION_ACCEPTANCE_EPISODE_ID') }
});
if (!episode || episode.project_id !== project.id) {
throw new Error('Episode does not belong to the configured project');
}
const shot = await prisma.storyboardShot.findUnique({
where: { id: parseBigInt(config.shotId, 'LIVE_ACTION_ACCEPTANCE_SHOT_ID') }
});
if (!shot || shot.episode_id !== episode.id) {
throw new Error('Shot does not belong to the configured episode');
}
return {
id: admin.id.toString(),
email: admin.email,
role: admin.role
};
}
async function resolveProjectOwner(prisma: PrismaService, config: RuntimeConfig): Promise<AuthRequestUser> {
const project = await prisma.project.findUnique({
where: { id: parseBigInt(config.projectId, 'LIVE_ACTION_ACCEPTANCE_PROJECT_ID') }
});
const owner = project
? await prisma.user.findUnique({ where: { id: project.user_id } })
: null;
if (!owner) {
throw new Error(`Project owner not found: ${config.projectId}`);
}
return {
id: owner.id.toString(),
email: owner.email,
role: owner.role
};
}
async function findLatestClip(prisma: PrismaService, clipId: string): Promise<VideoClip | null> {
return prisma.videoClip.findUnique({
where: { id: parseBigInt(clipId, 'clip id') }
});
}
async function writeReport(config: RuntimeConfig, report: AcceptanceReport) {
const day = report.generated_at.slice(0, 10);
const timestamp = report.generated_at.replace(/[^0-9]+/g, '').slice(0, 14);
const dir = join(resolve(config.outputDir), day);
const baseName = `live-action-acceptance-project-${config.projectId}-episode-${config.episodeId}-shot-${config.shotId}-${timestamp}`;
const jsonPath = join(dir, `${baseName}.json`);
const markdownPath = join(dir, `${baseName}.md`);
await mkdir(dir, { recursive: true });
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(markdownPath, markdownReport(report));
return { jsonPath, markdownPath };
}
function markdownReport(report: AcceptanceReport) {
const rows = report.rows.map((row) => [
row.provider_code,
row.status,
row.provider_enabled === null ? '-' : row.provider_enabled ? 'yes' : 'no',
row.api_key_env ? `${row.api_key_env}:${row.api_key_configured ? 'yes' : 'no'}` : '-',
row.preflight_ready ? 'yes' : 'no',
row.clip_id ?? '-',
row.output_asset_id ?? '-',
row.cost_actual ?? '-',
row.quality_score ?? row.quality_status ?? '-',
row.error_message?.replace(/\|/g, '/') ?? '-'
]);
return [
'# Live Action Provider Acceptance',
'',
`Generated at: ${report.generated_at}`,
`Project: ${report.project_id}`,
`Episode: ${report.episode_id}`,
`Shot: ${report.shot_id}`,
`Keyframe asset: ${report.keyframe_asset_id ?? '-'}`,
`Confirm real video: ${report.confirm_real_video ? 'yes' : 'no'}`,
`Min quality score: ${report.min_quality_score}`,
`Max cost per clip: ${report.max_cost_per_clip ?? '-'}`,
'',
'| Provider | Status | Enabled | Key | Preflight | Clip | Asset | Cost | Quality | Error |',
'| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
...rows.map((row) => `| ${row.join(' | ')} |`),
'',
`Summary: passed=${report.summary.passed}, failed=${report.summary.failed}, skipped=${report.summary.skipped}`,
''
].join('\n');
}
function readConfig(): RuntimeConfig {
const projectId = requiredEnv('LIVE_ACTION_ACCEPTANCE_PROJECT_ID');
const episodeId = requiredEnv('LIVE_ACTION_ACCEPTANCE_EPISODE_ID');
const shotId = requiredEnv('LIVE_ACTION_ACCEPTANCE_SHOT_ID');
return {
projectId,
episodeId,
shotId,
providerCodes: uniqueStrings(
optionalEnv('LIVE_ACTION_ACCEPTANCE_PROVIDERS', 'hailuo,kling,mock')
.split(',')
.map((value) => providerAlias(value.trim()))
.filter(Boolean)
),
keyframePath: optionalEnv('LIVE_ACTION_ACCEPTANCE_KEYFRAME_PATH', '').trim() || null,
keyframeAssetId: optionalEnv('LIVE_ACTION_ACCEPTANCE_KEYFRAME_ASSET_ID', '').trim() || null,
confirmRealVideo: envBoolean('LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO', false),
forceEnableProviders: envBoolean('LIVE_ACTION_ACCEPTANCE_FORCE_ENABLE_PROVIDERS', false),
forceRegenerate: envBoolean('LIVE_ACTION_ACCEPTANCE_FORCE_REGENERATE', true),
runQualityCheck: envBoolean('LIVE_ACTION_ACCEPTANCE_RUN_QUALITY', true),
failOnReject: envBoolean('LIVE_ACTION_ACCEPTANCE_FAIL_ON_REJECT', false),
maxCostPerClip: optionalNumber('LIVE_ACTION_ACCEPTANCE_MAX_COST_PER_CLIP'),
minQualityScore: optionalNumber('LIVE_ACTION_ACCEPTANCE_MIN_QUALITY_SCORE') ?? 80,
outputDir: optionalEnv('LIVE_ACTION_ACCEPTANCE_OUTPUT_DIR', defaultAcceptanceOutputDir()),
operatorUserId: optionalEnv('LIVE_ACTION_ACCEPTANCE_OPERATOR_USER_ID', '').trim() || null
};
}
function providerAlias(value: string) {
return PROVIDER_ALIASES[value] ?? value;
}
function providerLabel(providerCode: string) {
if (providerCode === 'minimax_hailuo_23_fast') return 'Hailuo Fast';
if (providerCode === 'kling-image-to-video') return 'Kling';
if (providerCode === 'mock-video') return 'Mock';
return providerCode;
}
function imageMimeFromPath(filePath: string) {
const extension = extname(filePath).toLowerCase();
if (extension === '.png') return 'image/png';
if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg';
if (extension === '.webp') return 'image/webp';
return '';
}
function defaultAcceptanceOutputDir() {
const cwd = process.cwd();
return basename(cwd) === 'backend'
? resolve(cwd, '..', 'storage/private/live-action-acceptance')
: resolve(cwd, 'storage/private/live-action-acceptance');
}
function jsonObject(value: Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, Prisma.JsonValue>
: {};
}
function stringifyText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function hasConfiguredEnvValue(name: string) {
const value = process.env[name]?.trim();
return Boolean(value && !/^your[_-]/i.test(value) && !/replace/i.test(value));
}
function hasManagedProviderSecret(value: unknown) {
const payload = jsonObject(value as Prisma.JsonValue | null | undefined);
return payload.kind === 'provider_secret_v1' && typeof payload.value === 'string' && payload.value.length > 0;
}
function isReadinessError(message: string) {
return [
'AI_ROUTER_NO_VIDEO_PROVIDER_AVAILABLE',
'LIVE_ACTION_VIDEO_PROVIDER_DISABLED',
'LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND',
'REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED',
'API key',
'api_key'
].some((pattern) => message.includes(pattern));
}
function finishRow(row: AcceptanceRow, status: AcceptanceStatus, errorMessage: string | null) {
return {
...row,
status,
finished_at: new Date().toISOString(),
error_message: errorMessage
};
}
function uniqueStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function optionalEnv(name: string, fallback: string) {
return process.env[name]?.trim() || fallback;
}
function optionalNumber(name: string) {
const value = process.env[name]?.trim();
if (!value) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${name} must be a number`);
}
return parsed;
}
function envBoolean(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase();
if (!value) return fallback;
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
if (['0', 'false', 'no', 'off'].includes(value)) return false;
throw new Error(`${name} must be true or false`);
}
function parseBigInt(value: string, label: string) {
try {
return BigInt(value);
} catch {
throw new Error(`${label} must be a valid integer id`);
}
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
main().catch((error) => {
console.error(toErrorMessage(error));
process.exit(1);
});
@@ -0,0 +1,154 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
LiveActionAttachKeyframeDto,
LiveActionCostEstimateQueryDto,
LiveActionGenerateDto,
LiveActionManualReviewDto,
LiveActionPreflightQueryDto,
LiveActionQualityCheckDto
} from './live-action.dto';
import { LiveActionService } from './live-action.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class LiveActionController {
constructor(@Inject(LiveActionService) private readonly liveActionService: LiveActionService) {}
@Get('projects/:projectId/live-action/actor-profiles')
listActorProfiles(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) {
return this.liveActionService.listActorProfiles(user, projectId);
}
@Post('projects/:projectId/live-action/actor-profiles/generate')
generateActorProfiles(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateActorProfiles(user, projectId, dto);
}
@Get('episodes/:episodeId/live-action/shots')
listLiveActionShots(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) {
return this.liveActionService.listLiveActionShots(user, episodeId);
}
@Post('episodes/:episodeId/live-action/shots/prepare')
prepareLiveActionShots(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.prepareLiveActionShots(user, episodeId, dto);
}
@Post('episodes/:episodeId/live-action/keyframes/generate')
generateKeyframes(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateKeyframes(user, episodeId, dto);
}
@Get('episodes/:episodeId/live-action/video-clips')
listVideoClips(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) {
return this.liveActionService.listVideoClips(user, episodeId);
}
@Get('live-action/video-providers')
listVideoProviders(@CurrentUser() user: AuthRequestUser) {
return this.liveActionService.listVideoProviders(user);
}
@Get('episodes/:episodeId/live-action/video-clips/cost-estimate')
estimateVideoClipCost(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query() query: LiveActionCostEstimateQueryDto
) {
return this.liveActionService.estimateVideoClipCost(user, episodeId, query.provider_code);
}
@Get('episodes/:episodeId/live-action/video-clips/preflight')
preflightVideoClips(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query() query: LiveActionPreflightQueryDto
) {
return this.liveActionService.preflightVideoClips(user, episodeId, query);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe')
attachShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionAttachKeyframeDto
) {
return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate')
generateShotVideoClip(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateShotVideoClip(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/video-clips/generate')
generateVideoClips(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateVideoClips(user, episodeId, dto);
}
@Post('live-action/video-clips/:clipId/retry')
retryVideoClip(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.retryVideoClip(user, clipId, dto);
}
@Post('live-action/video-clips/:clipId/quality-check')
checkVideoClipQuality(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string,
@Body() dto: LiveActionQualityCheckDto
) {
return this.liveActionService.checkVideoClipQuality(user, clipId, dto);
}
@Post('live-action/video-clips/:clipId/manual-review')
manualReviewVideoClip(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string,
@Body() dto: LiveActionManualReviewDto
) {
return this.liveActionService.manualReviewVideoClip(user, clipId, dto);
}
@Post('live-action/video-clips/:clipId/select-candidate')
selectVideoClipCandidate(@CurrentUser() user: AuthRequestUser, @Param('clipId') clipId: string) {
return this.liveActionService.selectVideoClipCandidate(user, clipId);
}
@Post('episodes/:episodeId/live-action/render')
renderLiveActionEpisode(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto);
}
}
@@ -0,0 +1,55 @@
export class LiveActionGenerateDto {
force?: boolean;
only_missing?: boolean;
provider_code?: string;
confirm_real_video?: boolean;
max_cost_per_clip?: number | string | null;
candidate_count?: number | string | null;
shot_id?: string;
include_audio?: boolean;
include_subtitle?: boolean;
include_bgm?: boolean;
include_sfx?: boolean;
include_lip_sync?: boolean;
lip_sync_max_seconds?: number | string | null;
voice?: string;
voice_provider_code?: string;
lip_sync_provider_code?: string;
subtitle_mode?: 'dialogue' | 'shot';
max_chars_per_line?: number | string | null;
bgm_asset_id?: string;
bgm_volume?: number | string | null;
sfx_volume?: number | string | null;
action_beat_mode?: boolean | string;
action_beat_count?: number | string | null;
}
export class LiveActionQualityCheckDto {
auto_repair?: boolean;
min_quality_score?: number | string | null;
confirm_real_video?: boolean;
max_cost_per_clip?: number | string | null;
}
export class LiveActionCostEstimateQueryDto {
provider_code?: string;
}
export class LiveActionPreflightQueryDto {
provider_code?: string;
confirm_real_video?: boolean | string;
max_cost_per_clip?: number | string | null;
shot_id?: string;
action_beat_mode?: boolean | string;
action_beat_count?: number | string | null;
}
export class LiveActionAttachKeyframeDto {
asset_id?: string;
}
export class LiveActionManualReviewDto {
result_status?: string;
reason?: string;
quality_score?: number | string | null;
}
@@ -0,0 +1,18 @@
import { forwardRef, Module } from '@nestjs/common';
import { AiRouterModule } from '../ai-router/ai-router.module';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { QueuesModule } from '../queues/queues.module';
import { LiveActionController } from './live-action.controller';
import { LiveActionService } from './live-action.service';
import { LiveActionPromptBuilderService } from './prompt-builder.service';
@Module({
imports: [AiRouterModule, AuthModule, AssetsModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
controllers: [LiveActionController],
providers: [LiveActionService, LiveActionPromptBuilderService],
exports: [LiveActionService, LiveActionPromptBuilderService]
})
export class LiveActionModule {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
import type { ActorProfile, Prisma, StoryboardShot, VideoClip } from '@prisma/client';
export interface SafeActorProfile {
id: string;
project_id: string;
character_id: string;
actor_desc: string | null;
appearance_rules: string | null;
wardrobe_rules: string | null;
performance_style: string | null;
voice_style: string | null;
reference_asset_ids: Prisma.JsonValue | null;
anchor_asset_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeLiveActionShot {
id: string;
project_id: string;
episode_id: string;
shot_no: number;
scene_name: string | null;
live_action_desc: string | null;
actor_action: string | null;
camera_instruction: string | null;
performance_instruction: string | null;
scene_type: string | null;
importance_score: number | null;
emotion_score: number | null;
action_score: number | null;
route_tier: string | null;
video_prompt: string | null;
keyframe_asset_id: string | null;
video_clip_asset_id: string | null;
video_status: string | null;
duration: string | null;
status: string;
updated_at: string;
}
export interface SafeVideoClip {
id: string;
project_id: string;
episode_id: string;
shot_id: string;
provider_id: string | null;
input_asset_id: string | null;
output_asset_id: string | null;
duration: string | null;
prompt_text: string | null;
status: string;
cost_actual: number | null;
retry_count: number;
quality_status: string | null;
quality_score: number | null;
quality_issues: Prisma.JsonValue | null;
created_at: string;
updated_at: string;
}
export function toSafeActorProfile(profile: ActorProfile): SafeActorProfile {
return {
id: profile.id.toString(),
project_id: profile.project_id.toString(),
character_id: profile.character_id.toString(),
actor_desc: profile.actor_desc,
appearance_rules: profile.appearance_rules,
wardrobe_rules: profile.wardrobe_rules,
performance_style: profile.performance_style,
voice_style: profile.voice_style,
reference_asset_ids: profile.reference_asset_ids,
anchor_asset_id: profile.anchor_asset_id?.toString() ?? null,
status: profile.status,
created_at: profile.created_at.toISOString(),
updated_at: profile.updated_at.toISOString()
};
}
export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
return {
id: shot.id.toString(),
project_id: shot.project_id.toString(),
episode_id: shot.episode_id.toString(),
shot_no: shot.shot_no,
scene_name: shot.scene_name,
live_action_desc: shot.live_action_desc,
actor_action: shot.actor_action,
camera_instruction: shot.camera_instruction,
performance_instruction: shot.performance_instruction,
scene_type: shot.scene_type,
importance_score: shot.importance_score,
emotion_score: shot.emotion_score,
action_score: shot.action_score,
route_tier: shot.route_tier,
video_prompt: shot.video_prompt,
keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null,
video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null,
video_status: shot.video_status,
duration: shot.duration?.toString() ?? null,
status: shot.status,
updated_at: shot.updated_at.toISOString()
};
}
export function toSafeVideoClip(clip: VideoClip): SafeVideoClip {
return {
id: clip.id.toString(),
project_id: clip.project_id.toString(),
episode_id: clip.episode_id.toString(),
shot_id: clip.shot_id.toString(),
provider_id: clip.provider_id?.toString() ?? null,
input_asset_id: clip.input_asset_id?.toString() ?? null,
output_asset_id: clip.output_asset_id?.toString() ?? null,
duration: clip.duration?.toString() ?? null,
prompt_text: clip.prompt_text,
status: clip.status,
cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null,
retry_count: clip.retry_count,
quality_status: clip.quality_status,
quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null,
quality_issues: clip.quality_issues,
created_at: clip.created_at.toISOString(),
updated_at: clip.updated_at.toISOString()
};
}
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import { LiveActionPromptBuilderService } from './prompt-builder.service';
describe('LiveActionPromptBuilderService', () => {
const builder = new LiveActionPromptBuilderService();
it('builds a Hailuo profile prompt with camera syntax and compact audit components', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '屏幕恋人',
episodeNo: 1,
episodeTitle: '她从屏幕里出现',
shotNo: 3,
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'dimensional_break',
routeTier: 'premium',
durationSeconds: 6,
characters: '白裙少女、程序员',
actorConsistencyRules: '白裙少女保持同一张脸、白色连衣裙、黑色长发',
location: '深夜程序员桌面,笔记本电脑发出冷蓝色光',
action: '白裙少女从笔记本屏幕边缘伸出手,慢慢跨入现实房间',
cameraMotion: 'zoom_in',
performanceInstruction: '好奇、温柔,但动作克制真实',
effectType: 'digital portal',
directorPlan: {
plan_version: 'live-action-director-plan-v1',
scene_group_id: 'scene-1',
scene_beat: 'screen emergence',
shot_role: 'reveal',
shot_size: 'medium reveal shot',
blocking: 'the character slowly reaches through the laptop edge while the programmer holds still',
continuity_in: 'cut from the programmer eyeline to the laptop screen',
continuity_out: 'end with the hand crossing the frame edge',
edit_intent: 'sell the impossible action with a delayed reveal',
sound_bridge: 'digital shimmer carries across the edit'
},
lipSyncPolicy: {
lip_sync_required: false,
strategy: 'not_required',
reason: 'no_dialogue',
visual_fallback: false
},
scores: {
importance_score: 10,
emotion_score: 8,
action_score: 8,
route_tier: 'premium'
}
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('[推进]');
expect(result.prompt).toContain('导演分镜');
expect(result.prompt).toContain('剪辑目的');
expect(result.prompt).toContain('后期音效提示');
expect(result.prompt.length).toBeLessThanOrEqual(1800);
expect(result.components).toEqual(
expect.objectContaining({
prompt_version: 'live-action-prompt-engine-v1',
provider_profile: 'hailuo',
scene_type: 'dimensional_break',
route_tier: 'premium',
camera_tag: '[推进]'
})
);
expect(result.negative_prompt).toContain('anime style');
});
it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'kling_21',
sceneType: 'dialog',
routeTier: 'normal',
durationSeconds: 5,
characters: '林晚',
location: '会议室',
action: '林晚抬头看向对方,准备说出关键台词',
cameraInstruction: 'front close-up dialogue',
dialogueText: '这一回,我不会再退。',
lipSyncPolicy: {
lip_sync_required: true,
strategy: 'post_tts_subtitle_light_mouth',
reason: 'high_risk_dialogue_without_lipsync_provider',
visual_fallback: true
}
});
expect(result.provider_profile).toBe('kling');
expect(result.prompt).toContain('medium shot, three-quarter angle');
expect(result.prompt).toContain('post_tts_subtitle_light_mouth');
expect(result.negative_prompt).toContain('frontal mouth close-up');
});
it('adds motion-director choreography for xianxia hand-seal power-up shots', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '法相天地',
episodeNo: 1,
episodeTitle: '千臂法身',
shotNo: 2,
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'xianxia_transformation',
routeTier: 'premium',
durationSeconds: 3,
characters: '浴血白裙女仙,银质头饰,保持同一张脸',
location: '崩塌废墟,乌云压低,碎石悬浮',
action: '镜头紧贴她翻飞的皓腕,十指如古典舞般柔美却迅捷地交错结印。',
visualDescription: '胸前骤然聚起高速旋转的紫色光球,强大气流撕扯半透仙裙。',
cameraMotion: '手部特写接环绕跟拍',
effectType: '繁花结印,紫色光球,灵力电流'
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('动作导演');
expect(result.prompt).toContain('时间节奏');
expect(result.prompt).toContain('结印手法必须清楚');
expect(result.prompt).toContain('食指中指并拢');
expect(result.prompt).toContain('lotus seal');
expect(result.prompt).toContain('紫色光球');
expect(result.negative_prompt).toContain('random hand waving');
expect(result.components.motion_director).toEqual(
expect.objectContaining({
motion_version: 'live-action-motion-director-v1',
beat_style: expect.stringContaining('hand-seal')
})
);
});
it('adds motion-director scale beats for thousand-arm dharma-form shots', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'kling_21',
sceneType: 'xianxia_transformation',
routeTier: 'premium',
durationSeconds: 4,
characters: '白裙女仙',
location: '废墟战场,地面裂开',
action: '女仙双臂猛然向后一展,身后透明千臂法身拔地而起。',
visualDescription: '千只巨手结出不同仙印,碎石失重悬浮并瞬间粉化。',
cameraMotion: '贴地极低视角仰拍',
effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化'
});
expect(result.prompt).toContain('Motion director');
expect(result.prompt).toContain('千臂法身');
expect(result.prompt).toContain('巨手依次结出不同仙印');
expect(result.prompt).toContain('贴地低角度仰拍');
expect(result.negative_prompt).toContain('tiny dharma body');
expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手');
});
it('builds a 10-second one-shot xianxia climax prompt for Hailuo', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '法相天地',
episodeNo: 1,
episodeTitle: '一镜到底',
shotNo: 1,
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'xianxia_transformation',
routeTier: 'premium',
durationSeconds: 10,
characters: '白裙女仙,黑色长发,银质头饰,保持同一张脸和服装',
location: '崩塌的仙侠废墟战场,残垣断壁,碎石悬浮,狂风呼啸',
action: '白裙女仙落地撑地,抬头觉醒,双手结印,紫色光球聚能,双臂展开,千臂法身升起。',
visualDescription: '同一镜头内完成落地、结印、法相天地爆发,透明千臂法身拔地而起。',
cameraMotion: 'one-shot push in, hand close-up, low-angle upward follow',
effectType: '法相天地 千臂法身 紫色光球 结印 落地 碎石粉化'
});
expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('10秒');
expect(result.prompt).toContain('一镜到底动作链');
expect(result.prompt).toContain('0.0-2.0s');
expect(result.prompt).toContain('3.0-5.0s');
expect(result.prompt).toContain('8.0-10.0s');
expect(result.prompt).toContain('双手在胸前清晰结印');
expect(result.prompt).toContain('千臂法身完全展开');
expect(result.prompt.length).toBeLessThanOrEqual(2400);
expect(result.negative_prompt).toContain('multi-shot montage');
expect(result.negative_prompt).toContain('character identity drift');
});
});
@@ -0,0 +1,711 @@
import { Injectable } from '@nestjs/common';
export type LiveActionPromptProfile = 'generic' | 'hailuo' | 'kling' | 'mock';
export interface LiveActionPromptLipSyncPolicyInput {
lip_sync_required: boolean;
strategy: string;
reason: string;
visual_fallback: boolean;
}
export interface LiveActionPromptScoresInput {
importance_score: number;
emotion_score: number;
action_score: number;
route_tier: string;
}
export interface LiveActionPromptDirectorPlanInput {
plan_version: string;
scene_group_id: string;
scene_beat: string;
shot_role: string;
shot_size: string;
blocking: string;
continuity_in: string;
continuity_out: string;
edit_intent: string;
sound_bridge: string;
}
export interface LiveActionPromptMotionDirectorInput {
motion_version: string;
beat_style: string;
action_technique: string;
time_beats: string[];
camera_rhythm: string;
vfx_timing: string;
sound_hits: string;
negative_motion: string[];
}
export interface LiveActionPromptBuildInput {
projectTitle?: string | null;
episodeNo?: number | null;
episodeTitle?: string | null;
shotNo?: number | null;
providerCode?: string | null;
sceneType?: string | null;
routeTier?: string | null;
durationSeconds: number;
characters: string;
actorConsistencyRules?: string | null;
location: string;
action: string;
visualDescription?: string | null;
cameraMotion?: string | null;
cameraInstruction?: string | null;
performanceInstruction?: string | null;
dialogueText?: string | null;
narrationText?: string | null;
effectType?: string | null;
scores?: LiveActionPromptScoresInput | null;
lipSyncPolicy?: LiveActionPromptLipSyncPolicyInput | null;
directorPlan?: LiveActionPromptDirectorPlanInput | null;
}
export interface LiveActionPromptComponents {
prompt_version: string;
provider_profile: LiveActionPromptProfile;
scene_type: string;
route_tier: string;
aspect_ratio: string;
visual_style: string;
characters: string;
actor_consistency_rules: string | null;
location: string;
main_action: string;
camera_shot: string;
camera_move: string;
camera_tag: string | null;
performance: string;
lighting: string;
vfx_cue: string | null;
sound_cue: string | null;
duration_seconds: number;
continuity_rules: string[];
motion_director: LiveActionPromptMotionDirectorInput | null;
director_plan: LiveActionPromptDirectorPlanInput | null;
lip_sync: LiveActionPromptLipSyncPolicyInput | null;
negative_prompt: string;
}
export interface LiveActionPromptBuildResult {
prompt: string;
negative_prompt: string;
components: LiveActionPromptComponents;
prompt_version: string;
provider_profile: LiveActionPromptProfile;
}
type SceneTemplate = {
sceneType: string;
cameraShot: string;
cameraMove: string;
lighting: string;
performance: string;
vfxCue: string | null;
soundCue: string | null;
negative: string[];
};
const PROMPT_VERSION = 'live-action-prompt-engine-v1';
const DEFAULT_NEGATIVE = [
'anime style',
'comic style',
'cartoon face',
'text overlays',
'burned subtitles',
'watermark',
'logo',
'distorted hands',
'extra fingers',
'face drift',
'identity change',
'overexposed skin',
'low resolution',
'random scene cuts'
];
@Injectable()
export class LiveActionPromptBuilderService {
buildLiveActionVideoPrompt(input: LiveActionPromptBuildInput): LiveActionPromptBuildResult {
const providerProfile = this.resolveProviderProfile(input.providerCode);
const sceneType = this.normalizeSceneType(input.sceneType, input);
const template = this.sceneTemplate(sceneType);
const routeTier = this.clean(input.routeTier) || input.scores?.route_tier || 'normal';
const cameraShot = this.resolveCameraShot(input, template);
const cameraMove = this.resolveCameraMove(input, template, providerProfile);
const cameraTag = providerProfile === 'hailuo' ? this.hailuoCameraTag(cameraMove, input.cameraMotion) : null;
const lipSync = input.lipSyncPolicy ?? null;
const motionDirector = this.buildMotionDirector(input, sceneType);
const negativePrompt = this.joinUnique([
...DEFAULT_NEGATIVE,
...template.negative,
...(motionDirector?.negative_motion ?? []),
...(input.directorPlan ? ['montage slideshow look', 'unmotivated time jump', 'new location jump cut'] : []),
...(lipSync?.visual_fallback ? ['frontal mouth close-up', 'clear Chinese mouth articulation'] : []),
...(providerProfile === 'hailuo' ? ['long multi-action sequence in one clip'] : []),
...(providerProfile === 'kling' ? ['inconsistent motion physics'] : [])
], ', ');
const components: LiveActionPromptComponents = {
prompt_version: PROMPT_VERSION,
provider_profile: providerProfile,
scene_type: sceneType,
route_tier: routeTier,
aspect_ratio: '9:16 vertical video',
visual_style: 'photorealistic Chinese live-action short drama',
characters: this.clean(input.characters) || 'main characters',
actor_consistency_rules: this.clean(input.actorConsistencyRules) || null,
location: this.clean(input.location) || 'modern Chinese short-drama location',
main_action: this.resolveMainAction(input, template),
camera_shot: cameraShot,
camera_move: cameraMove,
camera_tag: cameraTag,
performance: this.resolvePerformance(input, template),
lighting: this.resolveLighting(input, template, routeTier),
vfx_cue: this.resolveVfxCue(input.effectType, sceneType, template),
sound_cue: this.resolveSoundCue(input.effectType, sceneType, template),
duration_seconds: this.clampDuration(input.durationSeconds),
continuity_rules: this.continuityRules(providerProfile, lipSync, input.directorPlan ?? null),
motion_director: motionDirector,
director_plan: input.directorPlan ?? null,
lip_sync: lipSync,
negative_prompt: negativePrompt
};
return {
prompt: this.composePrompt(input, components),
negative_prompt: negativePrompt,
components,
prompt_version: PROMPT_VERSION,
provider_profile: providerProfile
};
}
private composePrompt(input: LiveActionPromptBuildInput, components: LiveActionPromptComponents) {
const profile = components.provider_profile;
if (profile === 'hailuo') {
const maxPromptLength = components.duration_seconds >= 9 ? 2400 : 1800;
return this.limitPrompt([
'真人短剧竖屏9:16,写实电影感,适合抖音短剧。',
components.camera_tag,
`项目:${this.clean(input.projectTitle) || '真人短剧'};第${input.episodeNo ?? '-'}集:${this.clean(input.episodeTitle) || '短剧片段'};镜头${input.shotNo ?? '-'}`,
`场景:${components.location}`,
`人物:${components.characters}`,
components.actor_consistency_rules ? `演员一致性:${components.actor_consistency_rules}` : null,
`主动作:${components.main_action}`,
...this.motionDirectorLinesZh(components),
...this.directorPlanLinesZh(components),
`镜头:${components.camera_shot}${components.camera_move}`,
`表演:${components.performance}`,
`光线:${components.lighting}`,
components.vfx_cue ? `视觉特效:${components.vfx_cue}` : null,
components.sound_cue ? `后期音效提示:${components.sound_cue}` : null,
...this.lipSyncLines(components),
`时长:${components.duration_seconds}秒,只完成一个主要动作,动作连续,不要突然切场景。`,
`避免:${components.negative_prompt}`
], maxPromptLength);
}
if (profile === 'mock') {
return [
`prompt_version: ${components.prompt_version}`,
`provider_profile: ${profile}`,
`scene_type: ${components.scene_type}`,
`route_tier: ${components.route_tier}`,
`characters: ${components.characters}`,
components.actor_consistency_rules ? `演员一致性 / actor_consistency: ${components.actor_consistency_rules}` : null,
`scene: ${components.location}`,
`action: ${components.main_action}`,
`camera: ${components.camera_shot}; ${components.camera_move}`,
`performance: ${components.performance}`,
components.vfx_cue ? `vfx: ${components.vfx_cue}` : null,
components.sound_cue ? `sound_cue: ${components.sound_cue}` : null,
...this.motionDirectorLinesMock(components),
...this.directorPlanLinesMock(components),
...this.lipSyncLines(components),
`duration: ${components.duration_seconds}s`,
`negative_prompt: ${components.negative_prompt}`
].filter(Boolean).join('\n');
}
return this.limitPrompt([
'Photorealistic Chinese vertical short drama, 9:16 vertical video.',
`Project: ${this.clean(input.projectTitle) || 'live action short drama'}. Episode ${input.episodeNo ?? '-'}: ${this.clean(input.episodeTitle) || 'short drama episode'}. Shot ${input.shotNo ?? '-'}.`,
`Scene type: ${components.scene_type}. Route tier: ${components.route_tier}.`,
`Location: ${components.location}.`,
`Characters: ${components.characters}.`,
components.actor_consistency_rules ? `Actor consistency rules: ${components.actor_consistency_rules}.` : null,
`Main action: ${components.main_action}.`,
...this.motionDirectorLinesEn(components),
...this.directorPlanLinesEn(components),
`Camera shot: ${components.camera_shot}.`,
`Camera movement: ${components.camera_move}.`,
`Performance: ${components.performance}.`,
`Lighting: ${components.lighting}.`,
components.vfx_cue ? `Visual effects: ${components.vfx_cue}.` : null,
components.sound_cue ? `Sound cue for post-production: ${components.sound_cue}.` : null,
...this.lipSyncLines(components),
`Continuity: ${components.continuity_rules.join('; ')}.`,
`Duration: ${components.duration_seconds} seconds. Complete one clear action only, with continuous motion and no random scene cuts.`,
`Avoid: ${components.negative_prompt}.`
], profile === 'kling' ? 2200 : 2000);
}
private sceneTemplate(sceneType: string): SceneTemplate {
const templates: Record<string, SceneTemplate> = {
dialog: {
sceneType: 'dialog',
cameraShot: 'medium shot or over-the-shoulder composition',
cameraMove: 'slow push-in with subtle handheld realism',
lighting: 'natural indoor cinematic lighting',
performance: 'restrained short-drama acting, readable eye contact and reaction',
vfxCue: null,
soundCue: 'quiet room tone, soft dramatic underscore',
negative: ['exaggerated mouth movement', 'static CCTV angle']
},
conflict: {
sceneType: 'conflict',
cameraShot: 'medium close-up with reaction space',
cameraMove: 'controlled push-in, slight handheld tension',
lighting: 'high-contrast realistic short-drama lighting',
performance: 'tense eye contact, controlled anger, clear reaction beat',
vfxCue: null,
soundCue: 'low tension hit, subtle heartbeat ambience',
negative: ['comedy expression', 'random action jump']
},
reveal: {
sceneType: 'reveal',
cameraShot: 'close-up detail then readable character reaction',
cameraMove: 'slow dolly-in, suspenseful pause',
lighting: 'focused cinematic key light with realistic shadows',
performance: 'shock is visible but restrained, short-drama reveal beat',
vfxCue: 'brief highlight on the revealed object or face',
soundCue: 'short reveal sting, low bass swell',
negative: ['overly magical glow', 'unreadable object']
},
dimensional_break: {
sceneType: 'dimensional_break',
cameraShot: 'close-up on laptop screen then medium shot of the real room',
cameraMove: 'camera tracks from screen edge into the real space',
lighting: 'dark room with screen glow and realistic rim light',
performance: 'curious, controlled expression, surreal but believable',
vfxCue: 'digital shimmer at the screen boundary, soft portal glow',
soundCue: 'digital shimmer, tiny electric crackle, soft portal pulse',
negative: ['full cartoon body', 'warped laptop', 'mismatched scale']
},
xianxia_transformation: {
sceneType: 'xianxia_transformation',
cameraShot: 'low-angle heroic shot with large scale background',
cameraMove: 'fast push-in then upward follow movement',
lighting: 'volumetric golden light, stormy sky contrast',
performance: 'divine wrath, calm but overwhelming power',
vfxCue: 'golden runes, lightning, shockwave, huge scale',
soundCue: 'thunder roar, energy burst, deep impact hit',
negative: ['small scale', 'cheap game effect', 'chaotic camera']
},
action: {
sceneType: 'action',
cameraShot: 'medium wide shot keeping the full body readable',
cameraMove: 'tracking movement with stable handheld energy',
lighting: 'realistic cinematic light with clear subject separation',
performance: 'decisive movement, believable body mechanics',
vfxCue: null,
soundCue: 'movement whoosh, short impact accent',
negative: ['motion smear', 'broken limbs', 'unreadable action']
}
};
return templates[sceneType] ?? templates.dialog;
}
private resolveProviderProfile(providerCode?: string | null): LiveActionPromptProfile {
const code = (providerCode || '').toLowerCase();
if (!code || code.includes('mock')) return 'mock';
if (/hailuo|minimax|video-0|seaweed/.test(code)) return 'hailuo';
if (/kling|kuaishou|可灵/.test(code)) return 'kling';
return 'generic';
}
private normalizeSceneType(value: string | null | undefined, input: LiveActionPromptBuildInput) {
const explicit = this.clean(value);
const text = `${value || ''} ${input.effectType || ''} ${input.action || ''} ${input.visualDescription || ''}`.toLowerCase();
if (/dimensional|次元|屏幕|laptop|portal|screen/.test(text)) return 'dimensional_break';
if (/法相|xianxia|仙侠|dharma|giant|rune|lightning|transform/.test(text)) return 'xianxia_transformation';
if (explicit) return explicit;
if (/conflict|争吵|反击|打脸|confront/.test(text)) return 'conflict';
if (/reveal|曝光|发现|证据|反转/.test(text)) return 'reveal';
if (/run|fight|追|打|爆炸|action/.test(text)) return 'action';
return 'dialog';
}
private resolveMainAction(input: LiveActionPromptBuildInput, template: SceneTemplate) {
return this.clean(input.action) || this.clean(input.visualDescription) || template.performance;
}
private resolveCameraShot(input: LiveActionPromptBuildInput, template: SceneTemplate) {
const raw = this.clean(input.cameraInstruction);
if (input.lipSyncPolicy?.visual_fallback) {
return 'medium shot, three-quarter angle or slight profile, lips small in frame';
}
if (!raw) return template.cameraShot;
if (/close.?up|特写|近景|medium close/i.test(raw)) return raw;
return `${template.cameraShot}, ${raw}`;
}
private resolveCameraMove(input: LiveActionPromptBuildInput, template: SceneTemplate, profile: LiveActionPromptProfile) {
const raw = this.clean(input.cameraMotion) || this.clean(input.cameraInstruction);
const text = raw.toLowerCase();
if (/zoom_in|push|推进|dolly.?in|靠近/.test(text)) return profile === 'hailuo' ? '镜头缓慢推进,主体逐渐变大' : 'slow cinematic push-in';
if (/zoom_out|pull|拉远|dolly.?out|远离/.test(text)) return profile === 'hailuo' ? '镜头缓慢拉远,展示环境关系' : 'slow pull-back revealing the environment';
if (/orbit|环绕|circle|旋转/.test(text)) return profile === 'hailuo' ? '镜头小幅环绕主体,保持人物稳定' : 'subtle orbit camera around the subject';
if (/track|follow|跟拍/.test(text)) return profile === 'hailuo' ? '镜头平稳跟随人物动作' : 'smooth tracking shot following the action';
if (/fixed|static|固定/.test(text)) return profile === 'hailuo' ? '固定镜头,人物表演推动情绪' : 'locked-off shot, acting carries the emotion';
return template.cameraMove;
}
private hailuoCameraTag(cameraMove: string, rawMotion?: string | null) {
const text = `${cameraMove} ${rawMotion || ''}`.toLowerCase();
if (/pull|拉远|zoom_out|远离/.test(text)) return '[拉远]';
if (/orbit|环绕|circle|旋转/.test(text)) return '[环绕]';
if (/track|follow|跟拍/.test(text)) return '[跟拍]';
if (/fixed|static|固定/.test(text)) return '[固定]';
if (/push|推进|zoom_in|dolly.?in|靠近/.test(text)) return '[推进]';
return '[推进]';
}
private resolvePerformance(input: LiveActionPromptBuildInput, template: SceneTemplate) {
const raw = this.clean(input.performanceInstruction);
const dialogue = this.clean(input.dialogueText);
const narration = this.clean(input.narrationText);
if (raw) return raw;
if (dialogue) return `${template.performance}; dialogue beat: ${dialogue}`;
if (narration) return `${template.performance}; narration beat: ${narration}`;
return template.performance;
}
private resolveLighting(input: LiveActionPromptBuildInput, template: SceneTemplate, routeTier: string) {
if (routeTier === 'premium') {
return `${template.lighting}, stronger depth of field and cinematic subject separation`;
}
return template.lighting;
}
private resolveVfxCue(effectType: string | null | undefined, sceneType: string, template: SceneTemplate) {
const effect = this.clean(effectType);
if (/flash|闪|证据/.test(effect)) return 'brief realistic highlight or camera flash accent, no cartoon effect';
if (/portal|digital|screen|次元/.test(effect) || sceneType === 'dimensional_break') return template.vfxCue;
if (/法相|lightning|rune|仙/.test(effect) || sceneType === 'xianxia_transformation') return template.vfxCue;
return template.vfxCue;
}
private resolveSoundCue(effectType: string | null | undefined, sceneType: string, template: SceneTemplate) {
const effect = this.clean(effectType);
if (/flash|闪|证据/.test(effect)) return 'short camera flash tick, low reveal sting';
if (/portal|digital|screen|次元/.test(effect) || sceneType === 'dimensional_break') return template.soundCue;
if (/法相|lightning|rune|仙/.test(effect) || sceneType === 'xianxia_transformation') return template.soundCue;
return template.soundCue;
}
private continuityRules(
profile: LiveActionPromptProfile,
lipSync: LiveActionPromptLipSyncPolicyInput | null,
directorPlan: LiveActionPromptDirectorPlanInput | null
) {
return [
'keep the same actor face, hairstyle, costume and body scale',
'one main action only in this short clip',
'no random cuts, no new characters unless specified',
directorPlan ? `editorial purpose: ${directorPlan.edit_intent}` : '',
directorPlan ? `continuity in: ${directorPlan.continuity_in}` : '',
directorPlan ? `continuity out: ${directorPlan.continuity_out}` : '',
directorPlan ? 'preserve screen direction, eyeline and lighting continuity across adjacent shots' : '',
profile === 'hailuo' ? 'camera instruction should be simple and visible' : 'motion must remain physically believable',
lipSync?.visual_fallback ? 'avoid visible Chinese mouth articulation because audio/subtitles are added later' : ''
].filter(Boolean);
}
private buildMotionDirector(input: LiveActionPromptBuildInput, sceneType: string): LiveActionPromptMotionDirectorInput | null {
if (sceneType === 'xianxia_transformation') {
return this.buildXianxiaMotionDirector(input);
}
return null;
}
private buildXianxiaMotionDirector(input: LiveActionPromptBuildInput): LiveActionPromptMotionDirectorInput {
const text = `${input.action || ''} ${input.visualDescription || ''} ${input.effectType || ''} ${input.cameraMotion || ''} ${input.cameraInstruction || ''}`.toLowerCase();
const duration = this.clampDuration(input.durationSeconds);
if (
duration >= 9 &&
/落地|翻身|翻滚|抬头|撑地|landing|roll|kneel/i.test(text) &&
/结印|印法|光球|紫色|灵力|seal|mudra|orb|energy/i.test(text) &&
/法相|法身|千臂|巨手|dharma|giant|thousand/i.test(text)
) {
return {
motion_version: 'live-action-motion-director-v1',
beat_style: '10-second one-shot Douyin xianxia climax, one continuous action chain from injury landing to dharma-form reveal',
action_technique: '一镜到底动作链:受伤落地、抬头觉醒、双手结印、紫色光球聚能、双臂展开、千臂法身升起、爆光定格;全程保持同一人物、同一废墟空间、同一镜头动机',
time_beats: [
'0.0-2.0s:白裙女仙从废墟残垣中落地,手掌先撑地,膝盖滑过碎石,尘土和碎石被冲击震开,银饰剧烈晃动',
'2.0-3.0s:她缓慢抬头,眼神极度凌厉,瞳孔金光亮起,镜头快速推进到眼部特写',
'3.0-5.0s:镜头回到中近景,双手在胸前清晰结印,食指中指并拢交错,手腕翻转,拇指扣成莲花印',
'5.0-6.5s:紫色光球在胸前高速旋转变大,灵力电流闪烁,气流撕扯衣袂,周围碎石失重悬浮',
'6.5-8.0s:女仙双臂像凤凰展翅一样猛然后扫,胸口抬起,肩线打开,身后透明千臂法身从地面拔地而起',
'8.0-10.0s:千臂法身完全展开,巨手一层层结出不同仙印,地面塌陷,碎石粉化,金色神光爆发,低角度仰拍定格'
],
camera_rhythm: '连续一镜到底:中景接住落地,快速推进眼部,中近景跟随双手结印,最后贴地低角度仰拍拉升到法身;不要突然切换场景',
vfx_timing: '尘土在落地时爆开,金光在抬头时出现,紫色光球在结印后出现,法身必须由双臂展开触发,最后 2 秒爆光定格',
sound_hits: 'landing rubble hit, eye flash sting, hand-seal electric rise, orb bass swell, arms-spread whoosh, final dharma LFE impact',
negative_motion: [
'multi-shot montage',
'random scene cuts',
'character identity drift',
'standing still power pose',
'random hand waving',
'static dharma statue',
'cheap game aura'
]
};
}
if (/法相|法身|千臂|巨手|威压|降临|dharma|giant|colossal|thousand/.test(text)) {
return {
motion_version: 'live-action-motion-director-v1',
beat_style: 'high-value divine arrival beat, body action triggers the giant dharma form',
action_technique: '女仙双臂像凤凰展翅一样猛然后扫,胸口抬起,肩线打开;身后千臂法身随动作拔地而起,每一层巨手依次结出不同仙印',
time_beats: this.xianxiaTimeBeats(duration, [
'0.0-0.7s:贴地极低机位,女仙双臂向后展开,裙摆被风压掀起',
'0.7-1.8s:她身后透明法身从地面升起,先出现头部和肩部轮廓',
'1.8-3.0s:千只巨手一层层展开并结印,地面裂开,碎石失重上浮',
'3.0s-end:镜头从脚下仰拍拉到法身脸部,低频冲击,碎石瞬间粉化'
]),
camera_rhythm: '贴地低角度仰拍,跟随法身向上拉升,不要横向乱晃',
vfx_timing: '法身必须由女仙展开双臂触发,巨手展开、地裂、碎石粉化要分三层递进',
sound_hits: 'arms spread whoosh, dharma rise sub-bass, ground crack, final LFE impact',
negative_motion: ['tiny dharma body', 'static statue behind actor', 'arms not forming seals', 'cheap game aura', 'chaotic camera shake']
};
}
if (/结印|印法|手|皓腕|光球|紫色|orb|seal|mudra|finger/.test(text)) {
return {
motion_version: 'live-action-motion-director-v1',
beat_style: 'Douyin xianxia power-up beat, readable hand-seal choreography before the energy burst',
action_technique: '结印手法必须清楚:食指中指并拢交错,手腕快速翻转,拇指扣成莲花印,双手向外一震;finger mudra / lotus seal, not random hand waving',
time_beats: this.xianxiaTimeBeats(duration, [
'0.0-0.5s:手部特写,双腕从胸前交叉进入画面,银饰轻响',
'0.5-1.4s:十指连续完成三次清晰结印,食指中指并拢、交错、翻腕、扣印',
'1.4-2.2s:紫色光球在胸前从小点高速旋转变大,气流撕扯衣袂',
'2.2s-end:双掌猛然向外一震,光球爆亮,镜头小幅环绕但手势保持可读'
]),
camera_rhythm: '手部特写先稳住 0.5 秒,再小幅环绕跟拍,最后跟着双掌震出产生短促冲击',
vfx_timing: '紫色光球必须在第二次手印后出现,在最后双掌震出时爆亮',
sound_hits: '0.5s silver ornament tick, 1.4s electric rise, final palm snap with bass hit',
negative_motion: ['random hand waving', 'blurred fingers', 'hands leaving frame', 'static magical orb only', 'no visible seal gesture']
};
}
return {
motion_version: 'live-action-motion-director-v1',
beat_style: 'Douyin opening hook, injury landing then eye-power reveal',
action_technique: '白裙女仙从残垣借力翻滚落地,手掌先撑地,膝盖滑过碎石,银饰剧烈摆动;抬头一瞬双眼爆出金光',
time_beats: this.xianxiaTimeBeats(duration, [
'0.0-0.6s:废墟崩塌中凌空翻身,身体从画面侧上方落入中景',
'0.6-1.4s:手掌和膝盖触地滑停,碎石被冲击震开,腿部血迹清楚',
'1.4-2.2s:她猛然抬头,白发和银饰被狂风甩动',
'2.2s-end:镜头极速推进到眼部特写,瞳孔金光爆亮,停住半拍'
]),
camera_rhythm: '中景接住落地动作,然后快速推进到眼睛;只做一次强推,不要连续乱切',
vfx_timing: '落地时尘土爆开,抬头时风压增强,眼神定格时金光出现',
sound_hits: 'rubble hit on landing, silver ornaments rattle, sharp wind rise, eye flash sting',
negative_motion: ['standing still power pose', 'floating without landing impact', 'slide-show image movement', 'weak eye reveal']
};
}
private xianxiaTimeBeats(duration: number, beats: string[]) {
if (duration <= 3) return beats.slice(0, 4);
if (duration <= 4) return beats;
return [
...beats,
`${Math.max(0, duration - 1).toFixed(1)}s-end:保留最后一秒给爆点余震,不新增地点和新动作。`
];
}
private motionDirectorLinesZh(components: LiveActionPromptComponents) {
const motion = components.motion_director;
if (!motion) return [];
return [
`动作导演:${motion.beat_style}${motion.action_technique}`,
`时间节奏:${motion.time_beats.join(' / ')}`,
`运镜节奏:${motion.camera_rhythm}`,
`特效时机:${motion.vfx_timing}`,
`声音卡点:${motion.sound_hits}`,
`动作禁忌:${motion.negative_motion.join('')}`
];
}
private motionDirectorLinesEn(components: LiveActionPromptComponents) {
const motion = components.motion_director;
if (!motion) return [];
return [
`Motion director: ${motion.beat_style}; ${motion.action_technique}.`,
`Time beats: ${motion.time_beats.join(' / ')}.`,
`Camera rhythm: ${motion.camera_rhythm}.`,
`VFX timing: ${motion.vfx_timing}.`,
`Sound hits: ${motion.sound_hits}.`,
`Avoid motion: ${motion.negative_motion.join(', ')}.`
];
}
private motionDirectorLinesMock(components: LiveActionPromptComponents) {
const motion = components.motion_director;
if (!motion) return [];
return [
`motion_director_version: ${motion.motion_version}`,
`motion_beat_style: ${motion.beat_style}`,
`motion_action_technique: ${motion.action_technique}`,
`motion_time_beats: ${motion.time_beats.join(' | ')}`,
`motion_camera_rhythm: ${motion.camera_rhythm}`,
`motion_vfx_timing: ${motion.vfx_timing}`,
`motion_sound_hits: ${motion.sound_hits}`,
`motion_negative: ${motion.negative_motion.join(', ')}`
];
}
private directorPlanLinesZh(components: LiveActionPromptComponents) {
const plan = components.director_plan;
if (!plan) return [];
return [
`导演分镜:${plan.shot_role}${plan.shot_size}${plan.blocking}`,
`剪辑目的:${plan.edit_intent}`,
`连续性:承接上一镜=${plan.continuity_in};出画衔接=${plan.continuity_out}`,
`声音桥:${plan.sound_bridge}`,
`场景组:${plan.scene_group_id},保持同一空间轴线和光线方向。`
];
}
private directorPlanLinesEn(components: LiveActionPromptComponents) {
const plan = components.director_plan;
if (!plan) return [];
return [
`Director beat: ${plan.shot_role}, ${plan.shot_size}, ${plan.blocking}.`,
`Editing intent: ${plan.edit_intent}.`,
`Continuity in: ${plan.continuity_in}. Continuity out: ${plan.continuity_out}.`,
`Sound bridge: ${plan.sound_bridge}.`,
`Scene group: ${plan.scene_group_id}; keep the same spatial axis and lighting direction.`
];
}
private directorPlanLinesMock(components: LiveActionPromptComponents) {
const plan = components.director_plan;
if (!plan) return [];
return [
`director_plan_version: ${plan.plan_version}`,
`scene_group_id: ${plan.scene_group_id}`,
`shot_role: ${plan.shot_role}`,
`shot_size: ${plan.shot_size}`,
`blocking: ${plan.blocking}`,
`continuity_in: ${plan.continuity_in}`,
`continuity_out: ${plan.continuity_out}`,
`edit_intent: ${plan.edit_intent}`,
`sound_bridge: ${plan.sound_bridge}`
];
}
private lipSyncLines(components: LiveActionPromptComponents) {
const policy = components.lip_sync;
if (!policy || policy.strategy === 'not_required') return [];
if (policy.visual_fallback) {
return [
`lip_sync_required: ${policy.lip_sync_required}`,
`lip_sync_strategy: ${policy.strategy}`,
`lip_sync_reason: ${policy.reason}`,
'Dialogue will be handled by post-production TTS and subtitles; keep the mouth mostly closed or tiny in frame.',
'Do not animate clear mouth articulation for Chinese speech.',
'Avoid direct frontal mouth close-up; prefer medium shot, three-quarter profile, or over-the-shoulder composition.'
];
}
return [
`lip_sync_required: ${policy.lip_sync_required}`,
`lip_sync_strategy: ${policy.strategy}`,
`lip_sync_reason: ${policy.reason}`,
'Keep face identity stable for downstream lip-sync; avoid exaggerated mouth shapes.'
];
}
private joinUnique(values: Array<string | null | undefined>, separator: string) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const cleaned = this.clean(value);
const key = cleaned.toLowerCase();
if (cleaned && !seen.has(key)) {
seen.add(key);
result.push(cleaned);
}
}
return result.join(separator);
}
private limitPrompt(lines: Array<string | null>, maxLength: number) {
const kept = lines.filter((line): line is string => Boolean(this.clean(line)));
while (kept.join('\n').length > maxLength && kept.length > 8) {
kept.splice(kept.length - 3, 1);
}
const prompt = kept.join('\n');
return prompt.length <= maxLength ? prompt : prompt.slice(0, maxLength - 20).trimEnd();
}
private clampDuration(value: number) {
if (!Number.isFinite(value)) return 5;
return Math.max(1, Math.min(60, Number(value.toFixed(2))));
}
private clean(value: unknown) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'reflect-metadata';
import './config/load-env';
import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
import type { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
type ExpressLikeApp = {
set?: (key: string, value: boolean | number | string) => void;
};
function isProduction() {
return process.env.NODE_ENV === 'production';
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parseCommaList(value: string | undefined) {
return (value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function createCorsOptions(): CorsOptions {
const allowedOrigins = parseCommaList(process.env.CORS_ORIGINS);
return {
origin:
allowedOrigins.length > 0
? (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error('Origin is not allowed by CORS'), false);
}
: !isProduction(),
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'authorization',
'content-type',
'x-request-id',
'x-api-encrypted',
'x-api-session-id',
'x-api-client-public-key'
],
exposedHeaders: ['content-disposition', 'x-request-id', 'x-api-encrypted'],
maxAge: 86400
};
}
function configureTrustProxy(app: INestApplication) {
const express = app.getHttpAdapter().getInstance() as ExpressLikeApp;
const shouldTrustProxy = parseBoolean(process.env.TRUST_PROXY, isProduction());
express.set?.('trust proxy', shouldTrustProxy);
}
function requestBodyLimit() {
return process.env.REQUEST_BODY_LIMIT || process.env.MAX_REQUEST_BODY_SIZE || '160mb';
}
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
const bodyLimit = requestBodyLimit();
app.use(json({ limit: bodyLimit }));
app.use(urlencoded({ limit: bodyLimit, extended: true }));
configureTrustProxy(app);
app.enableCors(createCorsOptions());
app.setGlobalPrefix('api');
const port = Number(process.env.PORT ?? 3000);
await app.listen(port, '0.0.0.0');
// eslint-disable-next-line no-console
console.log(`Backend API listening on http://127.0.0.1:${port}/api`);
}
void bootstrap();
+59
View File
@@ -0,0 +1,59 @@
import { Body, Controller, Get, Inject, Param, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
GenerateEpisodeAudioDto,
GenerateEpisodeSubtitleDto,
RetryEpisodeAudioSegmentDto,
RenderEpisodeVideoDto
} from './media.dto';
import { MediaService } from './media.service';
@Controller('episodes/:episodeId')
@UseGuards(JwtAuthGuard)
export class MediaController {
constructor(@Inject(MediaService) private readonly mediaService: MediaService) {}
@Post('audio/generate')
generateAudio(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: GenerateEpisodeAudioDto
) {
return this.mediaService.generateEpisodeAudio(user, episodeId, dto);
}
@Post('audio/segments/:segmentIndex/retry')
retryAudioSegment(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('segmentIndex') segmentIndex: string,
@Body() dto: RetryEpisodeAudioSegmentDto
) {
return this.mediaService.retryEpisodeAudioSegment(user, episodeId, segmentIndex, dto);
}
@Post('subtitle/generate')
generateSubtitle(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: GenerateEpisodeSubtitleDto
) {
return this.mediaService.generateEpisodeSubtitle(user, episodeId, dto);
}
@Post('video/render')
renderVideo(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: RenderEpisodeVideoDto
) {
return this.mediaService.renderEpisodeVideo(user, episodeId, dto);
}
@Get('media-assets')
listMediaAssets(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) {
return this.mediaService.listEpisodeMediaAssets(user, episodeId);
}
}
+27
View File
@@ -0,0 +1,27 @@
export class GenerateEpisodeAudioDto {
voice?: string;
narration_voice?: string;
dialogue_mode?: 'mixed' | 'narration';
max_segments?: number;
force?: boolean;
}
export class RetryEpisodeAudioSegmentDto {
voice?: string;
voice_style?: string;
speech_speed?: number | string;
}
export class GenerateEpisodeSubtitleDto {
max_chars_per_line?: number;
subtitle_mode?: 'dialogue' | 'shot';
include_speaker?: boolean;
force?: boolean;
}
export class RenderEpisodeVideoDto {
force?: boolean;
include_audio?: boolean;
include_subtitle?: boolean;
prefer_ffmpeg?: boolean;
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { BillingModule } from '../billing/billing.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
@Module({
imports: [AuthModule, AssetsModule, BillingModule, PrismaModule, ProvidersModule],
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService]
})
export class MediaModule {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import type { Asset } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
import { toSafeRenderTask, type SafeRenderTask } from '../queues/task.types';
import type { RenderTask } from '@prisma/client';
export interface SrtCue {
index: number;
start: string;
end: string;
text: string;
start_seconds?: number;
end_seconds?: number;
shot_id?: string | null;
shot_no?: number | null;
segment_type?: 'narration' | 'dialogue' | 'shot';
speaker_name?: string | null;
}
export interface SafeMediaTaskResult {
asset: SafeAsset;
task: SafeRenderTask;
reused: boolean;
}
export function toSafeMediaTaskResult(
asset: Asset,
task: RenderTask,
reused = false
): SafeMediaTaskResult {
return {
asset: toSafeAsset(asset),
task: toSafeRenderTask(task),
reused
};
}
+124
View File
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ContinuityCheckDto,
CreatePlotMemoryDto,
CreatePlotThreadDto,
GeneratePlotMemoriesDto,
UpdatePlotMemoryDto,
UpdatePlotThreadDto
} from './memory.dto';
import { MemoriesService } from './memories.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class MemoriesController {
constructor(@Inject(MemoriesService) private readonly memoriesService: MemoriesService) {}
@Get('projects/:projectId/plot-memories')
listPlotMemories(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('memory_type') memoryType?: string,
@Query('status') status?: string,
@Query('episode_id') episodeId?: string
) {
return this.memoriesService.listPlotMemories(user, projectId, {
memory_type: memoryType,
status,
episode_id: episodeId
});
}
@Post('projects/:projectId/plot-memories/generate')
generatePlotMemories(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GeneratePlotMemoriesDto
) {
return this.memoriesService.generatePlotMemories(user, projectId, dto);
}
@Post('projects/:projectId/plot-memories')
createPlotMemory(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreatePlotMemoryDto
) {
return this.memoriesService.createPlotMemory(user, projectId, dto);
}
@Patch('plot-memories/:memoryId')
updatePlotMemory(
@CurrentUser() user: AuthRequestUser,
@Param('memoryId') memoryId: string,
@Body() dto: UpdatePlotMemoryDto
) {
return this.memoriesService.updatePlotMemory(user, memoryId, dto);
}
@Get('projects/:projectId/memory-context')
getMemoryContext(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('episode_no') episodeNo?: string
) {
return this.memoriesService.getMemoryContext(user, projectId, episodeNo);
}
@Get('characters/:characterId/memories')
listCharacterMemories(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.memoriesService.listCharacterMemories(user, characterId);
}
@Get('projects/:projectId/plot-threads')
listPlotThreads(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('status') status?: string
) {
return this.memoriesService.listPlotThreads(user, projectId, status);
}
@Post('projects/:projectId/plot-threads')
createPlotThread(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreatePlotThreadDto
) {
return this.memoriesService.createPlotThread(user, projectId, dto);
}
@Patch('plot-threads/:threadId')
updatePlotThread(
@CurrentUser() user: AuthRequestUser,
@Param('threadId') threadId: string,
@Body() dto: UpdatePlotThreadDto
) {
return this.memoriesService.updatePlotThread(user, threadId, dto);
}
@Post('episodes/:episodeId/continuity-check')
runContinuityCheck(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: ContinuityCheckDto
) {
return this.memoriesService.runContinuityCheck(user, episodeId, dto);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { MemoriesController } from './memories.controller';
import { MemoriesService } from './memories.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [MemoriesController],
providers: [MemoriesService],
exports: [MemoriesService]
})
export class MemoriesModule {}
@@ -0,0 +1,399 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
Character,
CharacterMemory,
ContinuityCheck,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { MemoriesService } from './memories.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 5,
episode_duration: 60,
status: 'character_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司,不得突然加入超能力。',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
summary: '林晚确认重生并整理证据。',
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
word_count: 22,
status: 'generated',
created_at: now,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致脸型',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、合同、录音证据',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与周启围绕项目控制权对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
return {
id: 60n,
project_id: 10n,
episode_id: null,
chapter_id: 30n,
memory_type: 'foreshadowing',
content: '录音证据会在后续揭开幕后真相。',
importance_level: 90,
status: 'active',
created_at: now,
...overrides
};
}
function createCharacterMemory(overrides: Partial<CharacterMemory> = {}): CharacterMemory {
return {
id: 70n,
project_id: 10n,
character_id: 50n,
episode_id: null,
memory_type: 'current_state',
content: '林晚当前持有录音证据。',
created_at: now,
...overrides
};
}
function createPlotThread(overrides: Partial<PlotThread> = {}): PlotThread {
return {
id: 80n,
project_id: 10n,
thread_name: '主线目标',
thread_type: 'main_plot',
description: '林晚夺回原创项目控制权。',
start_episode_no: 1,
expected_resolve_episode_no: 5,
resolved_episode_no: null,
status: 'open',
created_at: now,
updated_at: now,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 90n,
project_id: 10n,
episode_no: 2,
source_chapter_ids: [],
title: '第2集 会议反击',
summary: '林晚带着录音证据进入会议室。',
opening_hook: '录音证据被投到大屏。',
middle_conflict: '周启试图转移责任。',
ending_hook: '幕后投资人的名字第一次出现。',
target_duration: 60,
status: 'draft',
created_at: now,
updated_at: now,
...overrides
};
}
function createContinuityCheck(overrides: Partial<ContinuityCheck> = {}): ContinuityCheck {
return {
id: 100n,
project_id: 10n,
episode_id: 90n,
check_type: 'character_name',
result_status: 'pass',
issue_text: null,
suggestion_text: null,
created_at: now,
...overrides
};
}
describe('MemoriesService', () => {
let prisma: any;
let tx: any;
let service: MemoriesService;
beforeEach(() => {
tx = {
plotMemory: {
createMany: vi.fn().mockResolvedValue({ count: 6 })
},
characterMemory: {
createMany: vi.fn().mockResolvedValue({ count: 8 })
},
plotThread: {
createMany: vi.fn().mockResolvedValue({ count: 3 })
}
};
let continuityId = 100n;
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject())
},
storyBible: {
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([
createChapter(),
createChapter({
id: 31n,
chapter_no: 2,
title: '第2章 会议反击',
summary: '林晚在会议上用证据反击周启。'
})
]),
findUnique: vi.fn().mockResolvedValue(createChapter())
},
character: {
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({
id: 51n,
name: '周启',
role_type: 'antagonist',
importance_level: 80
})
]),
findUnique: vi.fn().mockResolvedValue(createCharacter())
},
plotMemory: {
findMany: vi.fn().mockResolvedValue([createPlotMemory()]),
findUnique: vi.fn().mockResolvedValue(createPlotMemory()),
create: vi.fn().mockResolvedValue(createPlotMemory({ memory_type: 'event' })),
update: vi.fn().mockResolvedValue(createPlotMemory({ status: 'resolved' }))
},
characterMemory: {
findMany: vi.fn().mockResolvedValue([createCharacterMemory()])
},
plotThread: {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(createPlotThread()),
create: vi.fn().mockResolvedValue(createPlotThread({ thread_name: '新伏笔线' })),
update: vi.fn().mockResolvedValue(createPlotThread({ status: 'resolved' }))
},
episode: {
findUnique: vi.fn().mockResolvedValue(createEpisode()),
findMany: vi.fn().mockResolvedValue([
createEpisode({
id: 87n,
episode_no: 2,
ending_hook: '录音证据被周启抢走。'
}),
createEpisode({
id: 88n,
episode_no: 3,
ending_hook: '林晚发现幕后投资人。'
}),
createEpisode({
id: 89n,
episode_no: 4,
ending_hook: '顾南带来新线索。'
})
])
},
continuityCheck: {
create: vi.fn(async ({ data }: { data: Partial<ContinuityCheck> }) =>
createContinuityCheck({
id: continuityId++,
...data
})
)
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new MemoriesService(prisma as PrismaService);
});
it('generates plot, character, and thread memories from confirmed bibles', async () => {
const result = await service.generatePlotMemories(user, '10', {});
expect(tx.plotMemory.createMany).toHaveBeenCalled();
expect(tx.characterMemory.createMany).toHaveBeenCalled();
expect(tx.plotThread.createMany).toHaveBeenCalled();
expect(tx.plotMemory.createMany.mock.calls[0][0].data).toEqual(
expect.arrayContaining([
expect.objectContaining({ memory_type: 'unresolved_conflict' }),
expect.objectContaining({ memory_type: 'foreshadowing' }),
expect.objectContaining({ memory_type: 'world_rule' })
])
);
expect(result.created_count.plot_threads).toBe(3);
expect(result.next_step).toBe('episode_plan_generate');
});
it('requires locked characters before memory generation', async () => {
prisma.character.findMany.mockResolvedValue([createCharacter({ status: 'generated' })]);
await expect(service.generatePlotMemories(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('builds an episode 5 memory context with the previous three summaries', async () => {
prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]);
const result = await service.getMemoryContext(user, '10', '5');
expect(result.episode_no).toBe(5);
expect(result.previous_episodes).toHaveLength(3);
expect(result.previous_episode_ending_hook).toBe('顾南带来新线索。');
expect(result.generation_inputs).toContain('前 3 集摘要');
});
it('creates and resolves a manual plot memory', async () => {
const created = await service.createPlotMemory(user, '10', {
memory_type: 'foreshadowing',
content: '第2集出现的旧照片需要在第5集回收。',
importance_level: 80
});
const updated = await service.updatePlotMemory(user, '60', { status: 'resolved' });
expect(prisma.plotMemory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
memory_type: 'foreshadowing',
status: 'active'
})
});
expect(updated.status).toBe('resolved');
expect(created.memory_type).toBe('event');
});
it('creates and updates plot threads', async () => {
const created = await service.createPlotThread(user, '10', {
thread_name: '新伏笔线',
thread_type: 'mystery',
description: '旧照片来源需要持续推进。'
});
const updated = await service.updatePlotThread(user, '80', {
status: 'resolved',
resolved_episode_no: 5
});
expect(created.thread_name).toBe('新伏笔线');
expect(prisma.plotThread.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
status: 'resolved',
resolved_episode_no: 5
})
});
expect(updated.status).toBe('resolved');
});
it('detects continuity failures', async () => {
prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]);
const result = await service.runContinuityCheck(user, '90', {
script_text: '林晚拿出录音证据,却突然觉醒超能力,直接让周启认输。下一集真相出现。'
});
expect(result.result_status).toBe('fail');
expect(prisma.continuityCheck.create).toHaveBeenCalled();
expect(result.checks.some((check) => check.result_status === 'fail')).toBe(true);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listPlotMemories(user, '10', {})).rejects.toBeInstanceOf(
ForbiddenException
);
});
});
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import type {
PlotMemoryStatus,
PlotMemoryType,
PlotThreadStatus,
PlotThreadType
} from './memory.types';
export class GeneratePlotMemoriesDto {
episode_id?: string;
chapter_id?: string;
}
export class CreatePlotMemoryDto {
episode_id?: string;
chapter_id?: string;
memory_type?: PlotMemoryType;
content?: string;
importance_level?: number;
status?: PlotMemoryStatus;
}
export class UpdatePlotMemoryDto {
memory_type?: PlotMemoryType;
content?: string;
importance_level?: number;
status?: PlotMemoryStatus;
}
export class CreatePlotThreadDto {
thread_name?: string;
thread_type?: PlotThreadType;
description?: string;
start_episode_no?: number;
expected_resolve_episode_no?: number;
resolved_episode_no?: number;
status?: PlotThreadStatus;
}
export class UpdatePlotThreadDto {
thread_name?: string;
thread_type?: PlotThreadType;
description?: string;
start_episode_no?: number;
expected_resolve_episode_no?: number;
resolved_episode_no?: number;
status?: PlotThreadStatus;
}
export class ContinuityCheckDto {
check_type?: string;
script_text?: string;
}
+153
View File
@@ -0,0 +1,153 @@
import type { CharacterMemory, ContinuityCheck, PlotMemory, PlotThread } from '@prisma/client';
export const PLOT_MEMORY_TYPES = [
'episode_summary',
'event',
'foreshadowing',
'unresolved_conflict',
'resolved_conflict',
'relationship_change',
'prop_state',
'scene_state',
'world_rule',
'next_hook'
] as const;
export const PLOT_MEMORY_STATUSES = ['active', 'resolved', 'archived'] as const;
export const CHARACTER_MEMORY_TYPES = [
'current_state',
'relationship',
'visual_rule',
'growth',
'profile_adjustment'
] as const;
export const PLOT_THREAD_TYPES = [
'main_plot',
'romance',
'revenge',
'mystery',
'villain_plan',
'character_growth',
'world_secret'
] as const;
export const PLOT_THREAD_STATUSES = [
'open',
'progressing',
'paused',
'resolved',
'abandoned'
] as const;
export const CONTINUITY_RESULTS = ['pass', 'warning', 'fail'] as const;
export type PlotMemoryType = (typeof PLOT_MEMORY_TYPES)[number];
export type PlotMemoryStatus = (typeof PLOT_MEMORY_STATUSES)[number];
export type CharacterMemoryType = (typeof CHARACTER_MEMORY_TYPES)[number];
export type PlotThreadType = (typeof PLOT_THREAD_TYPES)[number];
export type PlotThreadStatus = (typeof PLOT_THREAD_STATUSES)[number];
export type ContinuityResult = (typeof CONTINUITY_RESULTS)[number];
export interface SafePlotMemory {
id: string;
project_id: string;
episode_id: string | null;
chapter_id: string | null;
memory_type: string;
content: string;
importance_level: number;
status: string;
created_at: string;
}
export interface SafeCharacterMemory {
id: string;
project_id: string;
character_id: string;
episode_id: string | null;
memory_type: string;
content: string;
created_at: string;
}
export interface SafePlotThread {
id: string;
project_id: string;
thread_name: string;
thread_type: string;
description: string | null;
start_episode_no: number | null;
expected_resolve_episode_no: number | null;
resolved_episode_no: number | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeContinuityCheck {
id: string;
project_id: string;
episode_id: string | null;
check_type: string;
result_status: string;
issue_text: string | null;
suggestion_text: string | null;
created_at: string;
}
export function toSafePlotMemory(memory: PlotMemory): SafePlotMemory {
return {
id: memory.id.toString(),
project_id: memory.project_id.toString(),
episode_id: memory.episode_id?.toString() ?? null,
chapter_id: memory.chapter_id?.toString() ?? null,
memory_type: memory.memory_type,
content: memory.content,
importance_level: memory.importance_level,
status: memory.status,
created_at: memory.created_at.toISOString()
};
}
export function toSafeCharacterMemory(memory: CharacterMemory): SafeCharacterMemory {
return {
id: memory.id.toString(),
project_id: memory.project_id.toString(),
character_id: memory.character_id.toString(),
episode_id: memory.episode_id?.toString() ?? null,
memory_type: memory.memory_type,
content: memory.content,
created_at: memory.created_at.toISOString()
};
}
export function toSafePlotThread(thread: PlotThread): SafePlotThread {
return {
id: thread.id.toString(),
project_id: thread.project_id.toString(),
thread_name: thread.thread_name,
thread_type: thread.thread_type,
description: thread.description,
start_episode_no: thread.start_episode_no,
expected_resolve_episode_no: thread.expected_resolve_episode_no,
resolved_episode_no: thread.resolved_episode_no,
status: thread.status,
created_at: thread.created_at.toISOString(),
updated_at: thread.updated_at.toISOString()
};
}
export function toSafeContinuityCheck(check: ContinuityCheck): SafeContinuityCheck {
return {
id: check.id.toString(),
project_id: check.project_id.toString(),
episode_id: check.episode_id?.toString() ?? null,
check_type: check.check_type,
result_status: check.result_status,
issue_text: check.issue_text,
suggestion_text: check.suggestion_text,
created_at: check.created_at.toISOString()
};
}
@@ -0,0 +1,49 @@
import { BadRequestException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { NovelParserService } from './novel-parser.service';
describe('NovelParserService', () => {
const service = new NovelParserService();
it('extracts plain text buffers', async () => {
const result = await service.extractText(
'local://novels/test.txt',
'text/plain',
Buffer.from('第1章 开始\n这是正文。')
);
expect(result.extractor).toBe('plain_text');
expect(result.text).toContain('这是正文');
});
it('cleans text and splits chapters by headings', () => {
const parsed = service.parseText(`
1
https://example.com
`);
expect(parsed.chapter_count).toBe(2);
expect(parsed.parse_report.strategy).toBe('heading');
expect(parsed.parse_report.removed_line_count).toBe(1);
expect(parsed.chapters[0].title).toBe('第1章 重生');
expect(parsed.chapters[1].content).toContain('完整方案');
});
it('falls back to chunk splitting when headings are missing', () => {
const parsed = service.parseText(
'她醒来时,窗外正在下雨。她意识到命运已经重新开始,于是把所有证据重新整理,准备迎接第一场反击。'
);
expect(parsed.chapter_count).toBe(1);
expect(parsed.parse_report.strategy).toBe('word_chunk');
expect(parsed.parse_report.warnings[0]).toContain('按字数切分');
});
it('rejects text that is too short', () => {
expect(() => service.parseText('太短')).toThrow(BadRequestException);
});
});
+298
View File
@@ -0,0 +1,298 @@
import { extname } from 'node:path';
import { BadRequestException, Injectable } from '@nestjs/common';
import mammoth from 'mammoth';
import { PDFParse } from 'pdf-parse';
const MAX_CHAPTER_CHARS = 6000;
const MIN_TEXT_CHARS = 20;
export interface ExtractedText {
text: string;
extractor: 'plain_text' | 'docx' | 'pdf_text';
warnings: string[];
}
export interface ParsedChapterDraft {
chapter_no: number;
title: string;
content: string;
summary: string;
visual_summary: string;
word_count: number;
}
export interface ParsedNovelText {
clean_text: string;
word_count: number;
chapter_count: number;
chapters: ParsedChapterDraft[];
parse_report: {
strategy: 'heading' | 'word_chunk';
removed_line_count: number;
warnings: string[];
};
}
interface CleanResult {
cleanText: string;
removedLineCount: number;
}
@Injectable()
export class NovelParserService {
async extractText(
filePath: string,
mimeType: string | null | undefined,
buffer: Buffer
): Promise<ExtractedText> {
const extension = extname(filePath.replace(/^local:\/\//, '').replace(/^minio:\/\/[^/]+\//, ''))
.toLowerCase();
if (extension === '.docx' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
const result = await mammoth.extractRawText({ buffer });
return {
text: result.value,
extractor: 'docx',
warnings: result.messages.map((message) => message.message)
};
}
if (extension === '.pdf' || mimeType === 'application/pdf') {
const parser = new PDFParse({ data: buffer });
try {
const result = await parser.getText();
return {
text: result.text,
extractor: 'pdf_text',
warnings: []
};
} finally {
await parser.destroy();
}
}
return {
text: buffer.toString('utf8'),
extractor: 'plain_text',
warnings: []
};
}
parseText(rawText: string, warnings: string[] = []): ParsedNovelText {
const cleaned = this.cleanText(rawText);
this.assertReadableText(cleaned.cleanText);
const splitResult = this.splitChapters(cleaned.cleanText);
const chapters = splitResult.chapters.map((chapter, index) => ({
chapter_no: index + 1,
title: chapter.title || `${index + 1}`,
content: chapter.content,
summary: this.buildSummary(chapter.content),
visual_summary: this.buildVisualSummary(chapter.content),
word_count: this.countWords(chapter.content)
}));
return {
clean_text: cleaned.cleanText,
word_count: this.countWords(cleaned.cleanText),
chapter_count: chapters.length,
chapters,
parse_report: {
strategy: splitResult.strategy,
removed_line_count: cleaned.removedLineCount,
warnings: [
...warnings,
...(splitResult.strategy === 'word_chunk'
? ['未识别到明确章节标题,已按字数切分。']
: [])
]
}
};
}
countWords(text: string) {
const cjkCount = text.match(/[\u3400-\u9fff]/g)?.length ?? 0;
const wordCount = text.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
return cjkCount + wordCount;
}
private cleanText(rawText: string): CleanResult {
const normalized = rawText
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/\t/g, ' ');
const lines = normalized.split('\n');
const cleanLines: string[] = [];
let previousBlank = false;
let removedLineCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (this.isNoiseLine(trimmed)) {
removedLineCount += 1;
continue;
}
if (!trimmed) {
if (!previousBlank) {
cleanLines.push('');
}
previousBlank = true;
continue;
}
cleanLines.push(trimmed);
previousBlank = false;
}
return {
cleanText: cleanLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
removedLineCount
};
}
private splitChapters(cleanText: string) {
const lines = cleanText.split('\n');
const chapters: Array<{ title: string; content: string }> = [];
let currentTitle = '';
let currentLines: string[] = [];
let foundHeading = false;
const flush = () => {
const content = currentLines.join('\n').trim();
if (content) {
chapters.push({
title: currentTitle,
content
});
}
currentLines = [];
};
for (const line of lines) {
const heading = this.matchChapterHeading(line);
if (heading) {
if (foundHeading || currentLines.join('').trim()) {
flush();
}
currentTitle = heading;
foundHeading = true;
continue;
}
currentLines.push(line);
}
flush();
if (foundHeading && chapters.length > 0) {
return {
strategy: 'heading' as const,
chapters
};
}
return {
strategy: 'word_chunk' as const,
chapters: this.splitByLength(cleanText)
};
}
private splitByLength(cleanText: string) {
const paragraphs = cleanText.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean);
const chapters: Array<{ title: string; content: string }> = [];
let chunk: string[] = [];
let chunkLength = 0;
for (const paragraph of paragraphs) {
if (chunkLength > 0 && chunkLength + paragraph.length > MAX_CHAPTER_CHARS) {
chapters.push({
title: `${chapters.length + 1}`,
content: chunk.join('\n\n')
});
chunk = [];
chunkLength = 0;
}
chunk.push(paragraph);
chunkLength += paragraph.length;
}
if (chunk.length > 0) {
chapters.push({
title: `${chapters.length + 1}`,
content: chunk.join('\n\n')
});
}
return chapters.length > 0
? chapters
: [{ title: '第1段', content: cleanText }];
}
private matchChapterHeading(line: string) {
const trimmed = line.trim();
if (!trimmed || trimmed.length > 80) {
return null;
}
const patterns = [
/^第[零一二三四五六七八九十百千万两\d]+[章节回卷集部篇][\s::、.-]*(.+)?$/,
/^chapter\s*\d+[\s::、.-]*(.+)?$/i,
/^\d{1,4}[\s、._-]+(.+)$/,
/^(序章|楔子|前言|正文|番外(?:篇|外)?(?:\s*\d+)?|尾声|后记)$/
];
return patterns.some((pattern) => pattern.test(trimmed)) ? trimmed : null;
}
private buildSummary(content: string) {
return this.compact(content).slice(0, 180);
}
private buildVisualSummary(content: string) {
return this.compact(content).slice(0, 120);
}
private compact(text: string) {
return text.replace(/\s+/g, ' ').trim();
}
private assertReadableText(text: string) {
if (text.length < MIN_TEXT_CHARS) {
throw new BadRequestException('Novel text is too short to parse');
}
const replacementCount = text.match(/\uFFFD/g)?.length ?? 0;
if (replacementCount > 0 && replacementCount / text.length > 0.01) {
throw new BadRequestException('Novel text looks garbled, please upload UTF-8 text');
}
}
private isNoiseLine(line: string) {
if (!line) {
return false;
}
const noisePatterns = [
/^本章未完.*$/i,
/^请收藏.*$/i,
/^求收藏.*$/i,
/^求推荐.*$/i,
/^--+$/,
/https?:\/\//i,
/www\./i,
/关注公众号/,
/扫码/,
/手机用户请浏览/,
/最新章节/,
/无弹窗/
];
return noisePatterns.some((pattern) => pattern.test(line));
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { AuthorizationType } from './novel.types';
export class ConfirmCopyrightDto {
authorization_type?: AuthorizationType;
statement_text?: string;
}
export class PasteNovelDto {
title?: string;
author_name?: string;
text?: string;
}
export class ParseNovelDto {
asset_id?: string;
source_id?: string;
title?: string;
author_name?: string;
}
export class UpdateNovelChapterDto {
title?: string;
content?: string;
summary?: string;
visual_summary?: string;
}
+94
View File
@@ -0,0 +1,94 @@
import type { CopyrightRecord, NovelChapter, NovelSource, Prisma } from '@prisma/client';
export const AUTHORIZATION_TYPES = [
'author_self',
'licensed',
'public_domain',
'internal_test'
] as const;
export type AuthorizationType = (typeof AUTHORIZATION_TYPES)[number];
export interface SafeNovelSource {
id: string;
project_id: string;
source_type: string;
title: string | null;
author_name: string | null;
raw_asset_id: string | null;
word_count: number | null;
chapter_count: number | null;
parse_status: string;
parse_report: Prisma.JsonValue | null;
created_at: string;
}
export interface SafeNovelChapter {
id: string;
project_id: string;
novel_source_id: string | null;
chapter_no: number;
title: string | null;
content: string;
summary: string | null;
visual_summary: string | null;
word_count: number | null;
status: string;
created_at: string;
}
export interface SafeCopyrightRecord {
id: string;
project_id: string;
user_id: string;
authorization_type: string;
statement_text: string;
ip: string | null;
user_agent: string | null;
confirmed_at: string;
}
export function toSafeNovelSource(source: NovelSource): SafeNovelSource {
return {
id: source.id.toString(),
project_id: source.project_id.toString(),
source_type: source.source_type,
title: source.title,
author_name: source.author_name,
raw_asset_id: source.raw_asset_id?.toString() ?? null,
word_count: source.word_count,
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
created_at: source.created_at.toISOString()
};
}
export function toSafeNovelChapter(chapter: NovelChapter): SafeNovelChapter {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
chapter_no: chapter.chapter_no,
title: chapter.title,
content: chapter.content,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
word_count: chapter.word_count,
status: chapter.status,
created_at: chapter.created_at.toISOString()
};
}
export function toSafeCopyrightRecord(record: CopyrightRecord): SafeCopyrightRecord {
return {
id: record.id.toString(),
project_id: record.project_id.toString(),
user_id: record.user_id.toString(),
authorization_type: record.authorization_type,
statement_text: record.statement_text,
ip: record.ip,
user_agent: record.user_agent,
confirmed_at: record.confirmed_at.toISOString()
};
}
+83
View File
@@ -0,0 +1,83 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
Req,
UseGuards
} from '@nestjs/common';
import type { Request } from 'express';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ConfirmCopyrightDto,
ParseNovelDto,
PasteNovelDto,
UpdateNovelChapterDto
} from './novel.dto';
import { NovelsService } from './novels.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class NovelsController {
constructor(@Inject(NovelsService) private readonly novelsService: NovelsService) {}
@Post('projects/:projectId/copyright/confirm')
confirmCopyright(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ConfirmCopyrightDto,
@Req() request: Request
) {
return this.novelsService.confirmCopyright(user, projectId, dto, request);
}
@Get('projects/:projectId/copyright')
listCopyrightRecords(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.novelsService.listCopyrightRecords(user, projectId);
}
@Post('projects/:projectId/novel/paste')
pasteNovel(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: PasteNovelDto
) {
return this.novelsService.pasteNovel(user, projectId, dto);
}
@Post('projects/:projectId/novel/parse')
parseNovel(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ParseNovelDto
) {
return this.novelsService.parseNovel(user, projectId, dto);
}
@Get('projects/:projectId/novel/parse-result')
getParseResult(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('source_id') sourceId?: string
) {
return this.novelsService.getParseResult(user, projectId, sourceId);
}
@Patch('novel-chapters/:chapterId')
updateChapter(
@CurrentUser() user: AuthRequestUser,
@Param('chapterId') chapterId: string,
@Body() dto: UpdateNovelChapterDto
) {
return this.novelsService.updateChapter(user, chapterId, dto);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { NovelsController } from './novels.controller';
import { NovelParserService } from './novel-parser.service';
import { NovelsService } from './novels.service';
import { OriginalNovelMockService } from './original-novel-mock.service';
import { OriginalNovelsController } from './original-novels.controller';
@Module({
imports: [AuthModule, AssetsModule],
controllers: [NovelsController, OriginalNovelsController],
providers: [NovelParserService, NovelsService, OriginalNovelMockService],
exports: [NovelParserService, NovelsService, OriginalNovelMockService]
})
export class NovelsModule {}
+317
View File
@@ -0,0 +1,317 @@
import { BadRequestException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Asset, CopyrightRecord, NovelChapter, NovelSource, Project } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import type { StorageService } from '../assets/storage.service';
import type { NovelParserService, ParsedNovelText } from './novel-parser.service';
import { NovelsService } from './novels.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '上传小说项目',
input_mode: 'upload',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 1,
episode_duration: 60,
status: 'source_selecting',
copyright_status: 'confirmed',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
completed_at: null,
...overrides
};
}
function createSource(overrides: Partial<NovelSource> = {}): NovelSource {
return {
id: 20n,
project_id: 10n,
source_type: 'paste',
title: '上传小说项目',
author_name: null,
raw_asset_id: null,
raw_text: '第1章 重生\n她醒来后开始反击。',
clean_text: null,
word_count: 12,
chapter_count: null,
parse_status: 'pending',
parse_report: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 重生',
content: '她醒来后开始反击。',
summary: '她醒来后开始反击。',
visual_summary: '她醒来后开始反击。',
word_count: 9,
status: 'parsed',
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createRecord(overrides: Partial<CopyrightRecord> = {}): CopyrightRecord {
return {
id: 40n,
project_id: 10n,
user_id: 1n,
authorization_type: 'author_self',
statement_text: '我确认拥有该小说的合法改编权。',
ip: '127.0.0.1',
user_agent: 'vitest',
confirmed_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 50n,
user_id: 1n,
project_id: 10n,
asset_type: 'novel_text',
file_path: 'local://novels/test.txt',
file_url: null,
mime_type: 'text/plain',
width: null,
height: null,
duration: null,
size: 100n,
hash: 'hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
const parsedText: ParsedNovelText = {
clean_text: '第1章 重生\n她醒来后开始反击。',
word_count: 9,
chapter_count: 1,
chapters: [
{
chapter_no: 1,
title: '第1章 重生',
content: '她醒来后开始反击。',
summary: '她醒来后开始反击。',
visual_summary: '她醒来后开始反击。',
word_count: 9
}
],
parse_report: {
strategy: 'heading',
removed_line_count: 0,
warnings: []
}
};
describe('NovelsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
copyrightRecord: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
novelSource: {
create: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
novelChapter: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
asset: { findUnique: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
let tx: {
project: { update: ReturnType<typeof vi.fn> };
novelSource: { create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
novelChapter: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};
let storage: Pick<StorageService, 'readPrivateFile'>;
let parser: Pick<NovelParserService, 'countWords' | 'extractText' | 'parseText'>;
let service: NovelsService;
beforeEach(() => {
tx = {
project: { update: vi.fn().mockResolvedValue(createProject({ status: 'novel_uploaded' })) },
novelSource: {
create: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' })),
update: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' }))
},
novelChapter: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
findMany: vi.fn().mockResolvedValue([createChapter()])
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject())
},
copyrightRecord: {
create: vi.fn().mockResolvedValue(createRecord()),
findMany: vi.fn().mockResolvedValue([createRecord()]),
count: vi.fn().mockResolvedValue(1)
},
novelSource: {
create: vi.fn().mockResolvedValue(createSource()),
findUnique: vi.fn().mockResolvedValue(createSource()),
findFirst: vi.fn().mockResolvedValue(createSource()),
update: vi.fn()
},
novelChapter: {
findUnique: vi.fn().mockResolvedValue(createChapter()),
findMany: vi.fn().mockResolvedValue([createChapter()]),
update: vi.fn().mockResolvedValue(createChapter({ status: 'edited' }))
},
asset: {
findUnique: vi.fn().mockResolvedValue(createAsset())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
storage = {
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('第1章 重生\n她醒来后开始反击。'))
};
parser = {
countWords: vi.fn((text: string) => text.length),
extractText: vi.fn().mockResolvedValue({
text: '第1章 重生\n她醒来后开始反击。',
extractor: 'plain_text',
warnings: []
}),
parseText: vi.fn().mockReturnValue(parsedText)
};
service = new NovelsService(
prisma as unknown as PrismaService,
storage as StorageService,
parser as NovelParserService
);
});
it('confirms copyright and updates the project', async () => {
const result = await service.confirmCopyright(
user,
'10',
{
authorization_type: 'author_self',
statement_text: '我确认拥有该小说的合法改编权。'
},
{ ip: '127.0.0.1', headers: { 'user-agent': 'vitest' } }
);
expect(prisma.copyrightRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
authorization_type: 'author_self'
})
});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: expect.objectContaining({
copyright_status: 'confirmed',
status: 'copyright_confirmed'
})
});
expect(result.next_step).toBe('novel_parse');
});
it('parses a pasted source into chapters', async () => {
const result = await service.parseNovel(user, '10', { source_id: '20' });
expect(parser.parseText).toHaveBeenCalledWith(
'第1章 重生\n她醒来后开始反击。',
[]
);
expect(tx.novelChapter.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
status: 'parsed'
})
]
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'novel_uploaded' }
});
expect(result.chapters).toHaveLength(1);
});
it('parses an uploaded asset into a new source', async () => {
const result = await service.parseNovel(user, '10', { asset_id: '50' });
expect(storage.readPrivateFile).toHaveBeenCalledWith('local://novels/test.txt');
expect(parser.extractText).toHaveBeenCalled();
expect(tx.novelSource.create).toHaveBeenCalledWith({
data: expect.objectContaining({
raw_asset_id: 50n,
parse_status: 'parsed'
})
});
expect(result.source.raw_asset_id).toBeNull();
});
it('rejects parsing before copyright is confirmed', async () => {
prisma.project.findUnique.mockResolvedValue(
createProject({ copyright_status: 'pending' })
);
prisma.copyrightRecord.count.mockResolvedValue(0);
await expect(service.parseNovel(user, '10', { source_id: '20' })).rejects.toBeInstanceOf(
BadRequestException
);
});
it('updates an owned chapter manually', async () => {
const result = await service.updateChapter(user, '30', {
content: '她拿出证据,完成第一场反击。'
});
expect(prisma.novelChapter.update).toHaveBeenCalledWith({
where: { id: 30n },
data: expect.objectContaining({
content: '她拿出证据,完成第一场反击。',
status: 'edited'
})
});
expect(result.status).toBe('edited');
});
});
+482
View File
@@ -0,0 +1,482 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset, NovelChapter, NovelSource, Prisma, Project } from '@prisma/client';
import type { Request } from 'express';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { StorageService } from '../assets/storage.service';
import {
ConfirmCopyrightDto,
ParseNovelDto,
PasteNovelDto,
UpdateNovelChapterDto
} from './novel.dto';
import { NovelParserService, type ParsedNovelText } from './novel-parser.service';
import {
AUTHORIZATION_TYPES,
toSafeCopyrightRecord,
toSafeNovelChapter,
toSafeNovelSource
} from './novel.types';
const MAX_PASTE_CHARS = 2_000_000;
@Injectable()
export class NovelsService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(StorageService)
private readonly storage: StorageService,
@Inject(NovelParserService)
private readonly parser: NovelParserService
) {}
async confirmCopyright(
user: AuthRequestUser,
projectId: string,
dto: ConfirmCopyrightDto,
request: Pick<Request, 'ip' | 'headers'>
) {
const project = await this.findProjectForUser(projectId, user);
this.assertUploadProject(project);
const authorizationType = this.validateAuthorizationType(dto.authorization_type);
const statementText = this.normalizeRequiredText(
dto.statement_text,
'statement_text is required'
);
const record = await this.prisma.copyrightRecord.create({
data: {
project_id: project.id,
user_id: BigInt(user.id),
authorization_type: authorizationType,
statement_text: statementText,
ip: request.ip || null,
user_agent: this.headerToString(request.headers['user-agent'])
}
});
await this.prisma.project.update({
where: { id: project.id },
data: {
copyright_status: 'confirmed',
status: project.status === 'source_selecting' ? 'copyright_confirmed' : project.status
}
});
return {
record: toSafeCopyrightRecord(record),
next_step: 'novel_parse'
};
}
async listCopyrightRecords(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const records = await this.prisma.copyrightRecord.findMany({
where: { project_id: project.id },
orderBy: { confirmed_at: 'desc' }
});
return records.map(toSafeCopyrightRecord);
}
async pasteNovel(user: AuthRequestUser, projectId: string, dto: PasteNovelDto) {
const project = await this.findProjectForUser(projectId, user);
this.assertUploadProject(project);
const text = this.normalizeRequiredText(dto.text, 'text is required');
if (text.length > MAX_PASTE_CHARS) {
throw new BadRequestException('Pasted novel text is too large');
}
const source = await this.prisma.novelSource.create({
data: {
project_id: project.id,
source_type: 'paste',
title: this.normalizeOptionalText(dto.title) ?? project.title,
author_name: this.normalizeOptionalText(dto.author_name),
raw_text: text,
word_count: this.parser.countWords(text),
parse_status: 'pending'
}
});
return {
source: toSafeNovelSource(source),
next_step: project.copyright_status === 'confirmed' ? 'novel_parse' : 'copyright_confirm'
};
}
async parseNovel(user: AuthRequestUser, projectId: string, dto: ParseNovelDto) {
const project = await this.findProjectForUser(projectId, user);
this.assertUploadProject(project);
await this.assertCopyrightConfirmed(project);
const input = await this.resolveParseInput(project, user, dto);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'text_parsing' }
});
try {
const parsed = this.parser.parseText(input.rawText, input.warnings);
return await this.saveParsedNovel(project, input, parsed, dto);
} catch (error) {
await this.markParseFailed(project.id, input.source?.id, error);
throw error;
}
}
async getParseResult(user: AuthRequestUser, projectId: string, sourceId?: string) {
const project = await this.findProjectForUser(projectId, user);
const source = sourceId
? await this.findSourceForProject(project.id, sourceId)
: await this.prisma.novelSource.findFirst({
where: { project_id: project.id },
orderBy: { created_at: 'desc' }
});
if (!source) {
return {
source: null,
chapters: []
};
}
const chapters = await this.prisma.novelChapter.findMany({
where: { novel_source_id: source.id },
orderBy: { chapter_no: 'asc' }
});
return {
source: toSafeNovelSource(source),
chapters: chapters.map(toSafeNovelChapter)
};
}
async updateChapter(
user: AuthRequestUser,
chapterId: string,
dto: UpdateNovelChapterDto
) {
const chapter = await this.prisma.novelChapter.findUnique({
where: { id: this.parseId(chapterId, 'Invalid chapter id') }
});
if (!chapter) {
throw new NotFoundException('Novel chapter not found');
}
await this.findProjectForUser(chapter.project_id.toString(), user);
const data: Partial<NovelChapter> = {};
let edited = false;
if ('title' in dto) {
data.title = this.normalizeOptionalText(dto.title) ?? null;
edited = true;
}
if ('content' in dto) {
data.content = this.normalizeRequiredText(dto.content, 'content is required');
data.word_count = this.parser.countWords(data.content);
edited = true;
}
if ('summary' in dto) {
data.summary = this.normalizeOptionalText(dto.summary) ?? null;
edited = true;
}
if ('visual_summary' in dto) {
data.visual_summary = this.normalizeOptionalText(dto.visual_summary) ?? null;
edited = true;
}
if (edited) {
data.status = 'edited';
}
const updated = await this.prisma.novelChapter.update({
where: { id: chapter.id },
data
});
return toSafeNovelChapter(updated);
}
private async resolveParseInput(
project: Project,
user: AuthRequestUser,
dto: ParseNovelDto
) {
if (dto.asset_id && dto.source_id) {
throw new BadRequestException('asset_id and source_id cannot be used together');
}
if (dto.asset_id) {
const asset = await this.findAssetForParse(project, user, dto.asset_id);
const buffer = await this.storage.readPrivateFile(asset.file_path);
const extracted = await this.parser.extractText(asset.file_path, asset.mime_type, buffer);
return {
source: null,
rawText: extracted.text,
rawAssetId: asset.id,
sourceType: 'upload',
title: this.normalizeOptionalText(dto.title) ?? project.title,
authorName: this.normalizeOptionalText(dto.author_name),
extractor: extracted.extractor,
warnings: extracted.warnings
};
}
const source = dto.source_id
? await this.findSourceForProject(project.id, dto.source_id)
: await this.prisma.novelSource.findFirst({
where: {
project_id: project.id,
raw_text: { not: null }
},
orderBy: { created_at: 'desc' }
});
if (!source?.raw_text) {
throw new BadRequestException('source_id or asset_id is required');
}
return {
source,
rawText: source.raw_text,
rawAssetId: source.raw_asset_id,
sourceType: source.source_type,
title: this.normalizeOptionalText(dto.title) ?? source.title ?? project.title,
authorName: this.normalizeOptionalText(dto.author_name) ?? source.author_name,
extractor: 'plain_text',
warnings: [] as string[]
};
}
private async saveParsedNovel(
project: Project,
input: Awaited<ReturnType<NovelsService['resolveParseInput']>>,
parsed: ParsedNovelText,
dto: ParseNovelDto
) {
return this.prisma.$transaction(async (tx) => {
const source = input.source
? await tx.novelSource.update({
where: { id: input.source.id },
data: {
title: input.title,
author_name: input.authorName,
raw_text: input.rawText,
clean_text: parsed.clean_text,
word_count: parsed.word_count,
chapter_count: parsed.chapter_count,
parse_status: 'parsed',
parse_report: this.buildParseReport(input, parsed)
}
})
: await tx.novelSource.create({
data: {
project_id: project.id,
source_type: input.sourceType,
title: input.title,
author_name: input.authorName,
raw_asset_id: input.rawAssetId,
raw_text: input.rawText,
clean_text: parsed.clean_text,
word_count: parsed.word_count,
chapter_count: parsed.chapter_count,
parse_status: 'parsed',
parse_report: this.buildParseReport(input, parsed)
}
});
await tx.novelChapter.deleteMany({
where: { novel_source_id: source.id }
});
await tx.novelChapter.createMany({
data: parsed.chapters.map((chapter) => ({
project_id: project.id,
novel_source_id: source.id,
chapter_no: chapter.chapter_no,
title: chapter.title,
content: chapter.content,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
word_count: chapter.word_count,
status: 'parsed'
}))
});
const chapters = await tx.novelChapter.findMany({
where: { novel_source_id: source.id },
orderBy: { chapter_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: {
status: 'novel_uploaded'
}
});
return {
source: toSafeNovelSource(source),
chapters: chapters.map(toSafeNovelChapter),
next_step: 'story_bible_generate',
request: {
asset_id: dto.asset_id ?? null,
source_id: dto.source_id ?? null
}
};
});
}
private buildParseReport(
input: Awaited<ReturnType<NovelsService['resolveParseInput']>>,
parsed: ParsedNovelText
): Prisma.InputJsonObject {
return {
source_type: input.sourceType,
extractor: input.extractor,
strategy: parsed.parse_report.strategy,
removed_line_count: parsed.parse_report.removed_line_count,
warnings: parsed.parse_report.warnings,
word_count: parsed.word_count,
chapter_count: parsed.chapter_count
};
}
private async markParseFailed(projectId: bigint, sourceId: bigint | undefined, error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown parse error';
await this.prisma.project.update({
where: { id: projectId },
data: { status: 'text_parse_failed' }
});
if (sourceId) {
await this.prisma.novelSource.update({
where: { id: sourceId },
data: {
parse_status: 'failed',
parse_report: {
error: message
}
}
});
}
}
private async assertCopyrightConfirmed(project: Project) {
if (project.copyright_status === 'confirmed') {
return;
}
const count = await this.prisma.copyrightRecord.count({
where: { project_id: project.id }
});
if (count === 0) {
throw new BadRequestException('Copyright must be confirmed before parsing novel');
}
}
private async findAssetForParse(project: Project, user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId, 'Invalid asset id') }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new NotFoundException('Asset not found');
}
if (asset.project_id?.toString() !== project.id.toString()) {
throw new BadRequestException('Asset does not belong to this project');
}
if (asset.asset_type !== 'novel_text') {
throw new BadRequestException('Asset is not a novel text file');
}
return asset;
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findSourceForProject(projectId: bigint, sourceId: string) {
const source = await this.prisma.novelSource.findUnique({
where: { id: this.parseId(sourceId, 'Invalid source id') }
});
if (!source || source.project_id !== projectId) {
throw new NotFoundException('Novel source not found');
}
return source;
}
private assertUploadProject(project: Project) {
if (project.input_mode !== 'upload') {
throw new BadRequestException('Novel parsing is only available for upload projects');
}
}
private validateAuthorizationType(value: string | undefined) {
if (!value || !AUTHORIZATION_TYPES.includes(value as never)) {
throw new BadRequestException(
'authorization_type must be author_self, licensed, public_domain, or internal_test'
);
}
return value;
}
private normalizeRequiredText(value: string | undefined, message: string) {
const normalized = value?.trim();
if (!normalized) {
throw new BadRequestException(message);
}
return normalized;
}
private normalizeOptionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
private headerToString(value: string | string[] | undefined) {
return Array.isArray(value) ? value.join(', ') : value ?? null;
}
}
@@ -0,0 +1,290 @@
import { BadRequestException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NovelChapter, NovelSource, Prisma, Project } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import type { NovelParserService } from './novel-parser.service';
import { OriginalNovelMockService } from './original-novel-mock.service';
import type { OriginalIdea, OriginalNovelReport, OriginalOutline } from './original-novel.types';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const idea: OriginalIdea = {
title: '重生归来,我只搞事业',
genre: 'urban_rebirth',
target_audience: '短视频用户',
protagonist_name: '林晚',
protagonist_setting: '年轻制片人',
story_mood: '高能反击',
selling_points: ['重生归来', '证据反杀'],
world_setting: '现代都市内容公司',
logline: '林晚重回命运转折点。',
core_conflict: '林晚必须夺回项目控制权。',
visual_hooks: ['暴雨夜醒来']
};
const outline: OriginalOutline = {
main_plot: '林晚夺回项目控制权。',
chapter_count: 3,
chapters: [
{
chapter_no: 1,
title: '第1章 暴雨重启',
goal: '确认重生',
conflict: '旧团队催签协议',
turning_point: '找到证据',
ending_hook: '陌生录音出现'
},
{
chapter_no: 2,
title: '第2章 会议反击',
goal: '保住提案',
conflict: '对手抢创意',
turning_point: '时间戳反杀',
ending_hook: '投资人出现'
},
{
chapter_no: 3,
title: '第3章 片场亮灯',
goal: '拿回试拍',
conflict: '旧友求情',
turning_point: '交给法务',
ending_hook: '背叛者出现'
}
]
};
const baseReport: OriginalNovelReport = {
provider: 'mock_novel_provider',
mode: 'ai_original',
stage: 'outline',
idea,
outline
};
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '原创项目',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'source_selecting',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
completed_at: null,
...overrides
};
}
function createSource(overrides: Partial<NovelSource> = {}): NovelSource {
return {
id: 20n,
project_id: 10n,
source_type: 'ai_original',
title: '重生归来,我只搞事业',
author_name: 'AI Mock',
raw_asset_id: null,
raw_text: null,
clean_text: null,
word_count: null,
chapter_count: 3,
parse_status: 'outline_ready',
parse_report: baseReport as unknown as Prisma.JsonValue,
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在现代都市内容公司的中心。陌生录音出现。',
summary: '林晚确认重生并找到证据。',
visual_summary: '暴雨夜醒来,陌生录音出现。',
word_count: 20,
status: 'generated',
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
describe('OriginalNovelMockService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
novelSource: {
create: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
novelChapter: { findMany: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
let tx: {
project: { update: ReturnType<typeof vi.fn> };
novelSource: { update: ReturnType<typeof vi.fn> };
novelChapter: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};
let parser: Pick<NovelParserService, 'countWords'>;
let service: OriginalNovelMockService;
beforeEach(() => {
tx = {
project: {
update: vi.fn().mockResolvedValue(createProject({ status: 'novel_uploaded' }))
},
novelSource: {
update: vi.fn().mockResolvedValue(createSource({ parse_status: 'generated' }))
},
novelChapter: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 3 }),
findMany: vi.fn().mockResolvedValue([createChapter()])
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject({ status: 'novel_generating' }))
},
novelSource: {
create: vi.fn().mockResolvedValue(
createSource({
parse_status: 'idea_ready',
parse_report: {
provider: 'mock_novel_provider',
mode: 'ai_original',
stage: 'idea',
idea
} as unknown as Prisma.JsonValue
})
),
findUnique: vi.fn().mockResolvedValue(createSource()),
findFirst: vi.fn().mockResolvedValue(createSource()),
update: vi.fn().mockResolvedValue(createSource())
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([createChapter()])
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
parser = {
countWords: vi.fn((text: string) => text.length)
};
service = new OriginalNovelMockService(
prisma as unknown as PrismaService,
parser as NovelParserService
);
});
it('generates an original idea and moves project into novel_generating', async () => {
const result = await service.generateIdea(user, '10', {
protagonist_name: '林晚',
selling_points: '重生归来,证据反杀'
});
expect(prisma.novelSource.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
source_type: 'ai_original',
parse_status: 'idea_ready'
})
});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'novel_generating' }
});
expect(result.next_step).toBe('original_outline');
});
it('rejects upload projects', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ input_mode: 'upload' }));
await expect(service.generateIdea(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('generates an outline after idea', async () => {
prisma.novelSource.findUnique.mockResolvedValue(
createSource({
parse_report: {
provider: 'mock_novel_provider',
mode: 'ai_original',
stage: 'idea',
idea
} as unknown as Prisma.JsonValue
})
);
const result = await service.generateOutline(user, '10', {
source_id: '20',
target_chapter_count: 3
});
expect(prisma.novelSource.update).toHaveBeenCalledWith({
where: { id: 20n },
data: expect.objectContaining({
parse_status: 'outline_ready',
chapter_count: 3
})
});
expect(result.outline.chapters).toHaveLength(3);
});
it('generates chapters and saves them to novel_chapters', async () => {
const result = await service.generateChapters(user, '10', { source_id: '20' });
expect(tx.novelChapter.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({
project_id: 10n,
novel_source_id: 20n,
status: 'generated'
})
])
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'novel_uploaded' }
});
expect(result.next_step).toBe('original_self_check');
});
it('runs self-check after chapters are generated', async () => {
const result = await service.selfCheck(user, '10', { source_id: '20' });
expect(prisma.novelSource.update).toHaveBeenCalledWith({
where: { id: 20n },
data: expect.objectContaining({
parse_status: 'checked'
})
});
expect(result.self_check.passed).toBe(true);
expect(result.next_step).toBe('story_bible_generate');
});
});
@@ -0,0 +1,512 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { NovelChapter, NovelSource, Prisma, Project } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { NovelParserService } from './novel-parser.service';
import {
GenerateOriginalChaptersDto,
GenerateOriginalIdeaDto,
GenerateOriginalOutlineDto,
OriginalSelfCheckDto
} from './original-novel.dto';
import type {
OriginalIdea,
OriginalNovelReport,
OriginalOutline,
OriginalSelfCheckResult
} from './original-novel.types';
import { toSafeNovelChapter, toSafeNovelSource } from './novel.types';
const MIN_MOCK_CHAPTERS = 1;
const MAX_MOCK_CHAPTERS = 12;
@Injectable()
export class OriginalNovelMockService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(NovelParserService)
private readonly parser: NovelParserService
) {}
async generateIdea(user: AuthRequestUser, projectId: string, dto: GenerateOriginalIdeaDto) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const idea = this.buildIdea(project, dto);
const report: OriginalNovelReport = {
provider: 'mock_novel_provider',
mode: 'ai_original',
stage: 'idea',
idea,
warnings: ['阶段 07 使用 deterministic mock,不调用真实 AI Provider。']
};
const source = await this.prisma.novelSource.create({
data: {
project_id: project.id,
source_type: 'ai_original',
title: idea.title,
author_name: 'AI Mock',
parse_status: 'idea_ready',
parse_report: report as unknown as Prisma.InputJsonObject
}
});
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'novel_generating' }
});
return {
source: toSafeNovelSource(source),
idea,
next_step: 'original_outline'
};
}
async generateOutline(
user: AuthRequestUser,
projectId: string,
dto: GenerateOriginalOutlineDto
) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const source = await this.requireOriginalSource(project.id, dto.source_id);
const report = this.readReport(source);
if (!report.idea) {
throw new BadRequestException('Original idea must be generated before outline');
}
const chapterCount = this.resolveChapterCount(dto.target_chapter_count, project);
const outline = this.buildOutline(report.idea, chapterCount);
const nextReport: OriginalNovelReport = {
...report,
stage: 'outline',
outline
};
const updated = await this.prisma.novelSource.update({
where: { id: source.id },
data: {
parse_status: 'outline_ready',
chapter_count: chapterCount,
parse_report: nextReport as unknown as Prisma.InputJsonObject
}
});
return {
source: toSafeNovelSource(updated),
idea: report.idea,
outline,
next_step: 'original_chapters'
};
}
async generateChapters(
user: AuthRequestUser,
projectId: string,
dto: GenerateOriginalChaptersDto
) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const source = await this.requireOriginalSource(project.id, dto.source_id);
const report = this.readReport(source);
if (!report.idea) {
throw new BadRequestException('Original idea must be generated before chapters');
}
const outline =
report.outline ??
this.buildOutline(report.idea, this.resolveChapterCount(dto.target_chapter_count, project));
const chapters = outline.chapters.map((chapter) =>
this.buildChapter(report.idea as OriginalIdea, chapter)
);
const rawText = chapters
.map((chapter) => `${chapter.title}\n${chapter.content}`)
.join('\n\n');
const wordCount = this.parser.countWords(rawText);
const nextReport: OriginalNovelReport = {
...report,
stage: 'chapters',
outline
};
return this.prisma.$transaction(async (tx) => {
const updatedSource = await tx.novelSource.update({
where: { id: source.id },
data: {
clean_text: rawText,
raw_text: rawText,
word_count: wordCount,
chapter_count: chapters.length,
parse_status: 'generated',
parse_report: nextReport as unknown as Prisma.InputJsonObject
}
});
await tx.novelChapter.deleteMany({
where: { novel_source_id: source.id }
});
await tx.novelChapter.createMany({
data: chapters.map((chapter) => ({
project_id: project.id,
novel_source_id: source.id,
chapter_no: chapter.chapter_no,
title: chapter.title,
content: chapter.content,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
word_count: chapter.word_count,
status: 'generated'
}))
});
const savedChapters = await tx.novelChapter.findMany({
where: { novel_source_id: source.id },
orderBy: { chapter_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'novel_uploaded' }
});
return {
source: toSafeNovelSource(updatedSource),
outline,
chapters: savedChapters.map(toSafeNovelChapter),
next_step: 'original_self_check'
};
});
}
async selfCheck(user: AuthRequestUser, projectId: string, dto: OriginalSelfCheckDto) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const source = await this.requireOriginalSource(project.id, dto.source_id);
const report = this.readReport(source);
const chapters = await this.prisma.novelChapter.findMany({
where: { novel_source_id: source.id },
orderBy: { chapter_no: 'asc' }
});
if (!report.idea || chapters.length === 0) {
throw new BadRequestException('Original chapters must be generated before self-check');
}
const selfCheck = this.buildSelfCheck(report.idea, chapters);
const nextReport: OriginalNovelReport = {
...report,
stage: 'self_check',
self_check: selfCheck
};
const updated = await this.prisma.novelSource.update({
where: { id: source.id },
data: {
parse_status: selfCheck.passed ? 'checked' : 'check_failed',
parse_report: nextReport as unknown as Prisma.InputJsonObject
}
});
return {
source: toSafeNovelSource(updated),
self_check: selfCheck,
next_step: selfCheck.passed ? 'story_bible_generate' : 'original_revision'
};
}
async getResult(user: AuthRequestUser, projectId: string, sourceId?: string) {
const project = await this.findProjectForUser(projectId, user);
this.assertOriginalProject(project);
const source = await this.findOriginalSource(project.id, sourceId);
if (!source) {
return {
source: null,
idea: null,
outline: null,
self_check: null,
chapters: []
};
}
const report = this.readReport(source);
const chapters = await this.prisma.novelChapter.findMany({
where: { novel_source_id: source.id },
orderBy: { chapter_no: 'asc' }
});
return {
source: toSafeNovelSource(source),
idea: report.idea ?? null,
outline: report.outline ?? null,
self_check: report.self_check ?? null,
chapters: chapters.map(toSafeNovelChapter)
};
}
private buildIdea(project: Project, dto: GenerateOriginalIdeaDto): OriginalIdea {
const genre = this.normalizeOptionalText(dto.genre) ?? project.genre ?? 'urban_rebirth';
const title = this.normalizeOptionalText(dto.title) ?? project.title ?? this.titleForGenre(genre);
const protagonistName = this.normalizeOptionalText(dto.protagonist_name) ?? '林晚';
const protagonistSetting =
this.normalizeOptionalText(dto.protagonist_setting) ?? '被夺走项目成果的年轻制片人';
const targetAudience =
this.normalizeOptionalText(dto.target_audience) ?? '喜欢高能反击和短视频爽点的用户';
const storyMood = this.normalizeOptionalText(dto.story_mood) ?? '克制、锋利、连续反转';
const worldSetting =
this.normalizeOptionalText(dto.world_setting) ?? '现代都市内容公司与资本局中局';
const sellingPoints = this.splitList(dto.selling_points, [
'重生归来',
'证据反杀',
'事业线逆袭',
'每章结尾强钩子'
]);
return {
title,
genre,
target_audience: targetAudience,
protagonist_name: protagonistName,
protagonist_setting: protagonistSetting,
story_mood: storyMood,
selling_points: sellingPoints,
world_setting: worldSetting,
logline: `${protagonistName}重回命运转折点,用前世记忆和手中证据夺回作品控制权。`,
core_conflict: `${protagonistName}必须在合作方、旧友和资本压力之间保护原创项目,并揭开前世失败的真相。`,
visual_hooks: [
'暴雨夜醒来的重生瞬间',
'会议室投屏反杀',
'旧合同与隐藏录音同时曝光',
'片场灯光亮起时主角完成选择'
]
};
}
private buildOutline(idea: OriginalIdea, chapterCount: number): OriginalOutline {
const templates = [
{
title: '第1章 暴雨重启',
goal: `${idea.protagonist_name}确认自己回到关键节点。`,
conflict: '旧团队催她签下不公平协议。',
turning_point: '她发现前世被篡改的附件还没有交出去。',
ending_hook: '手机里突然收到一段来自陌生号码的录音。'
},
{
title: '第2章 会议反击',
goal: `${idea.protagonist_name}保住项目提案。`,
conflict: '对手在全员会议上抢先展示她的创意。',
turning_point: '她当场调出时间戳和原始脚本,证明自己才是作者。',
ending_hook: '幕后投资人第一次注意到她。'
},
{
title: '第3章 片场亮灯',
goal: `${idea.protagonist_name}拿回试拍机会。`,
conflict: '旧友试图用情分让她撤回追责。',
turning_point: '她拒绝妥协,把证据交给法务并启动试拍。',
ending_hook: '镜头开机时,她看见前世真正的背叛者站在监视器后。'
},
{
title: '第4章 旧账翻面',
goal: `${idea.protagonist_name}逼近真相。`,
conflict: '资本方要求她用热搜换掉核心表达。',
turning_point: '她用预热视频数据反向争取话语权。',
ending_hook: '匿名人发来前世事故现场的照片。'
}
];
return {
main_plot: `${idea.logline}${idea.core_conflict}`,
chapter_count: chapterCount,
chapters: Array.from({ length: chapterCount }, (_, index) => {
const template = templates[index % templates.length];
return {
chapter_no: index + 1,
title:
index < templates.length
? template.title
: `${index + 1}章 新的筹码`,
goal: template.goal,
conflict: template.conflict,
turning_point: template.turning_point,
ending_hook: template.ending_hook
};
})
};
}
private buildChapter(
idea: OriginalIdea,
outline: OriginalOutline['chapters'][number]
) {
const content = [
`${outline.title}\n${idea.protagonist_name}站在${idea.world_setting}的中心,终于确认这不是梦。${outline.goal}她把混乱的情绪压下去,只留下一个清晰的念头:这一次不能再输。`,
`${outline.conflict}灯光、屏幕和沉默的人群把压力推到她面前。她没有急着解释,而是把前世遗漏的细节一项项放回桌面,让每个人都看见真相的边缘。`,
`${outline.turning_point}空气安静下来,对手的表情第一次失控。${idea.protagonist_name}知道自己只是赢下第一步,真正的局还藏在更深处。`,
`${outline.ending_hook}她合上电脑,抬头看向玻璃门外的倒影,那里有一个熟悉却不该出现的人。`
].join('\n\n');
return {
chapter_no: outline.chapter_no,
title: outline.title,
content,
summary: `${outline.goal}${outline.turning_point}`,
visual_summary: `${outline.title}${outline.conflict}${outline.ending_hook}`,
word_count: this.parser.countWords(content)
};
}
private buildSelfCheck(
idea: OriginalIdea,
chapters: NovelChapter[]
): OriginalSelfCheckResult {
const fullText = chapters.map((chapter) => chapter.content).join('\n');
const checks = [
{
key: 'character_consistency',
passed: fullText.includes(idea.protagonist_name),
message: '主角姓名在章节中保持一致。'
},
{
key: 'clear_main_line',
passed: chapters.length > 0 && Boolean(idea.core_conflict),
message: '主线目标和核心冲突已建立。'
},
{
key: 'strong_conflict',
passed: chapters.every((chapter) => (chapter.summary ?? '').length > 10),
message: '每章保留冲突和转折摘要。'
},
{
key: 'visual_ready',
passed: chapters.every((chapter) => (chapter.visual_summary ?? '').length > 10),
message: '每章包含可视化场景摘要。'
},
{
key: 'short_video_hook',
passed: chapters.every((chapter) => chapter.content.includes('钩子') || chapter.content.includes('出现')),
message: '章节结尾保留短视频改编钩子。'
}
];
const passedCount = checks.filter((check) => check.passed).length;
return {
passed: passedCount === checks.length,
score: Math.round((passedCount / checks.length) * 100),
checks
};
}
private readReport(source: NovelSource): OriginalNovelReport {
const report = source.parse_report;
if (!report || typeof report !== 'object' || Array.isArray(report)) {
throw new BadRequestException('Original source report is invalid');
}
return report as unknown as OriginalNovelReport;
}
private async requireOriginalSource(projectId: bigint, sourceId?: string) {
const source = await this.findOriginalSource(projectId, sourceId);
if (!source) {
throw new NotFoundException('Original novel source not found');
}
return source;
}
private async findOriginalSource(projectId: bigint, sourceId?: string) {
const source = sourceId
? await this.prisma.novelSource.findUnique({
where: { id: this.parseId(sourceId, 'Invalid source id') }
})
: await this.prisma.novelSource.findFirst({
where: { project_id: projectId, source_type: 'ai_original' },
orderBy: { created_at: 'desc' }
});
if (!source || source.project_id !== projectId || source.source_type !== 'ai_original') {
return null;
}
return source;
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private assertOriginalProject(project: Project) {
if (project.input_mode !== 'ai_original') {
throw new BadRequestException('Original novel mock is only available for ai_original projects');
}
}
private resolveChapterCount(value: number | undefined, project: Project) {
const numberValue = Number(value ?? project.target_episode_count ?? 3);
if (
!Number.isInteger(numberValue) ||
numberValue < MIN_MOCK_CHAPTERS ||
numberValue > MAX_MOCK_CHAPTERS
) {
throw new BadRequestException(
`target_chapter_count must be an integer between ${MIN_MOCK_CHAPTERS} and ${MAX_MOCK_CHAPTERS}`
);
}
return numberValue;
}
private splitList(value: string | undefined, fallback: string[]) {
const items = value
?.split(/[,\n,、]/)
.map((item) => item.trim())
.filter(Boolean);
return items?.length ? items : fallback;
}
private titleForGenre(genre: string) {
const titles: Record<string, string> = {
urban_rebirth: '重生归来,我只搞事业',
revenge: '她把旧账一笔笔讨回',
sweet_romance: '合约到期前心动了',
fantasy: '灵脉重启之后'
};
return titles[genre] ?? '原创漫剧项目';
}
private normalizeOptionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}

Some files were not shown because too many files have changed in this diff Show More