import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LLM_PROVIDERS, LLM_PROVIDER_PRESETS, type CreateLlmApiConfigRequest, type LlmApiConfigDto, type LlmApiConfigOptionDto, type LlmProvider, type UpdateLlmApiConfigRequest, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { LlmChatClient, normalizeLlmBaseUrl } from '../../integrations/llm/llm-chat.client'; type ActorCtx = { actorId: bigint; isSuperAdmin: boolean }; function isProvider(v: string): v is LlmProvider { return (LLM_PROVIDERS as readonly string[]).includes(v); } @Injectable() export class AdminLlmConfigsService { constructor( private readonly prisma: PrismaService, private readonly llm: LlmChatClient, ) {} async resolveActor(actorId: bigint): Promise { const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId }, select: { adminRole: true, status: true }, }); if (!account || account.status !== 'ACTIVE') { throw new ForbiddenException('账号不可用'); } return { actorId, isSuperAdmin: account.adminRole === 'SUPER_ADMIN', }; } async list( actor: ActorCtx, query: { name?: string; enabled?: string; page?: number; pageSize?: number }, ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: { name?: { contains: string }; enabled?: boolean; createdByHqAccountId?: bigint; } = {}; if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId; if (query.name?.trim()) where.name = { contains: query.name.trim() }; if (query.enabled === 'true' || query.enabled === 'false') { where.enabled = query.enabled === 'true'; } const [items, total] = await Promise.all([ this.prisma.llmApiConfig.findMany({ where, orderBy: [{ id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.llmApiConfig.count({ where }), ]); const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))]; const owners = ownerIds.length ? await this.prisma.hqAccount.findMany({ where: { id: { in: ownerIds } }, select: { id: true, name: true }, }) : []; const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name])); return serializeBigInt({ items: items.map((row) => this.toDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null)), total, page, pageSize, }); } /** 企微绑定下拉:已启用;非超管仅自己的 */ async options(actor: ActorCtx): Promise { const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true }; if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId; const rows = await this.prisma.llmApiConfig.findMany({ where, orderBy: [{ id: 'desc' }], select: { id: true, name: true, provider: true, modelName: true, enabled: true }, }); return rows.map((r) => ({ id: r.id.toString(), name: r.name, provider: (isProvider(r.provider) ? r.provider : 'CUSTOM') as LlmProvider, modelName: r.modelName, enabled: r.enabled, })); } async detail(actor: ActorCtx, id: bigint) { const row = await this.requireReadable(actor, id); const owner = await this.prisma.hqAccount.findUnique({ where: { id: row.createdByHqAccountId }, select: { name: true }, }); return this.toDto(row, actor, owner?.name ?? null); } async create(actor: ActorCtx, dto: CreateLlmApiConfigRequest) { const name = dto.name?.trim(); if (!name) throw new BadRequestException('请填写名称'); if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商'); const apiKey = dto.apiKey?.trim(); if (!apiKey) throw new BadRequestException('请填写 API Key'); const preset = LLM_PROVIDER_PRESETS[dto.provider]; const baseUrl = normalizeLlmBaseUrl(dto.baseUrl?.trim() || preset.defaultBaseUrl); const modelName = dto.modelName?.trim() || preset.defaultModel; if (!baseUrl) throw new BadRequestException('请填写 Base URL'); if (!modelName) throw new BadRequestException('请填写模型名'); const row = await this.prisma.llmApiConfig.create({ data: { name, provider: dto.provider, baseUrl, apiKey, modelName, temperature: dto.temperature ?? null, maxTokens: dto.maxTokens ?? null, systemPrompt: dto.systemPrompt?.trim() || null, enabled: dto.enabled !== false, createdByHqAccountId: actor.actorId, }, }); return this.toDto(row, actor, null); } async update(actor: ActorCtx, id: bigint, dto: UpdateLlmApiConfigRequest) { const row = await this.requireReadable(actor, id); const isOwner = row.createdByHqAccountId === actor.actorId; if (!actor.isSuperAdmin) { if (!isOwner) throw new ForbiddenException('只能操作自己创建的配置'); // 非超管仅可改 enabled const keys = Object.keys(dto).filter((k) => (dto as Record)[k] !== undefined); if (keys.some((k) => k !== 'enabled')) { throw new ForbiddenException('非超级管理员只能修改配置是否生效'); } if (dto.enabled === undefined) throw new BadRequestException('请指定 enabled'); const updated = await this.prisma.llmApiConfig.update({ where: { id }, data: { enabled: dto.enabled }, }); return this.toDto(updated, actor, null); } // 超管全量 let provider = row.provider as LlmProvider; if (dto.provider !== undefined) { if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商'); provider = dto.provider; } const preset = LLM_PROVIDER_PRESETS[provider]; const data: { name?: string; provider?: string; baseUrl?: string; apiKey?: string; modelName?: string; temperature?: number | null; maxTokens?: number | null; systemPrompt?: string | null; enabled?: boolean; } = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (!name) throw new BadRequestException('名称不能为空'); data.name = name; } if (dto.provider !== undefined) data.provider = provider; if (dto.baseUrl !== undefined) { data.baseUrl = normalizeLlmBaseUrl(dto.baseUrl.trim() || preset.defaultBaseUrl); if (!data.baseUrl) throw new BadRequestException('Base URL 不能为空'); } if (dto.apiKey !== undefined && dto.apiKey.trim()) data.apiKey = dto.apiKey.trim(); if (dto.modelName !== undefined) { data.modelName = dto.modelName.trim() || preset.defaultModel; if (!data.modelName) throw new BadRequestException('模型名不能为空'); } if (dto.temperature !== undefined) data.temperature = dto.temperature; if (dto.maxTokens !== undefined) data.maxTokens = dto.maxTokens; if (dto.systemPrompt !== undefined) data.systemPrompt = dto.systemPrompt?.trim() || null; if (dto.enabled !== undefined) data.enabled = dto.enabled; const updated = await this.prisma.llmApiConfig.update({ where: { id }, data }); return this.toDto(updated, actor, null); } async remove(actor: ActorCtx, id: bigint) { if (!actor.isSuperAdmin) { throw new ForbiddenException('仅超级管理员可删除语言模型配置'); } const row = await this.prisma.llmApiConfig.findUnique({ where: { id } }); if (!row) throw new NotFoundException('配置不存在'); await this.prisma.llmApiConfig.delete({ where: { id } }); return { ok: true }; } async test(actor: ActorCtx, id: bigint) { const row = await this.requireReadable(actor, id); if (!row.enabled) throw new BadRequestException('配置未启用'); const reply = await this.llm.chat({ baseUrl: row.baseUrl, apiKey: row.apiKey, model: row.modelName, temperature: row.temperature != null ? Number(row.temperature) : 0.2, maxTokens: row.maxTokens ?? 64, messages: [ { role: 'system', content: '用一句话回复:连接成功。' }, { role: 'user', content: 'ping' }, ], }); return { ok: true, reply }; } private async requireReadable(actor: ActorCtx, id: bigint) { const row = await this.prisma.llmApiConfig.findUnique({ where: { id } }); if (!row) throw new NotFoundException('配置不存在'); if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) { throw new ForbiddenException('无权查看该配置'); } return row; } private toDto( row: { id: bigint; name: string; provider: string; baseUrl: string; apiKey: string; modelName: string; temperature: { toNumber?: () => number } | number | null; maxTokens: number | null; systemPrompt: string | null; enabled: boolean; createdByHqAccountId: bigint; createdAt: Date; updatedAt: Date; }, actor: ActorCtx, createdByName: string | null, ): LlmApiConfigDto { const isOwner = row.createdByHqAccountId === actor.actorId; const temp = row.temperature == null ? null : typeof row.temperature === 'number' ? row.temperature : Number(row.temperature); return { id: row.id.toString(), name: row.name, provider: (isProvider(row.provider) ? row.provider : 'CUSTOM') as LlmProvider, baseUrl: row.baseUrl, modelName: row.modelName, apiKeyConfigured: !!row.apiKey, temperature: temp, maxTokens: row.maxTokens, systemPrompt: row.systemPrompt, enabled: row.enabled, createdByHqAccountId: row.createdByHqAccountId.toString(), createdByName, isOwner, canEditFull: actor.isSuperAdmin, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } }