Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
@@ -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);
}
}
}