Files
ai/backend/src/characters/characters.service.ts
T
2026-06-15 17:45:28 +08:00

709 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}