Initial AI manga platform
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user