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([ '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); } } }