Initial AI manga platform
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>)
|
||||
: {};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetType } from './asset.types';
|
||||
|
||||
export class UploadAssetDto {
|
||||
asset_type?: AssetType;
|
||||
project_id?: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class RegisterDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user