feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled

Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-26 09:31:22 +08:00
parent 745c192693
commit 2b7ef65cce
30 changed files with 2507 additions and 56 deletions
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
async function ensureColumn(table, column, ddl) {
const cols = await p.$queryRawUnsafe(`SHOW COLUMNS FROM \`${table}\` LIKE '${column}'`);
if (!cols.length) {
await p.$executeRawUnsafe(ddl);
console.log(`added ${table}.${column}`);
} else {
console.log(`${table}.${column} exists`);
}
}
(async () => {
await ensureColumn(
'wecom_bot',
'ai_enabled',
'ALTER TABLE `wecom_bot` ADD COLUMN `ai_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `permissions`',
);
await ensureColumn(
'wecom_bot',
'llm_config_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `llm_config_id` BIGINT UNSIGNED NULL AFTER `ai_enabled`',
);
await ensureColumn(
'wecom_bot',
'knowledge_base_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `knowledge_base_id` BIGINT UNSIGNED NULL AFTER `llm_config_id`',
);
await p.$disconnect();
})().catch(async (e) => {
console.error(e);
await p.$disconnect();
process.exit(1);
});
@@ -0,0 +1,50 @@
-- LLM / 知识库 / 企微 AI 字段
CREATE TABLE IF NOT EXISTS `llm_api_config` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`provider` VARCHAR(32) NOT NULL,
`base_url` VARCHAR(512) NOT NULL,
`api_key` VARCHAR(512) NOT NULL,
`model_name` VARCHAR(128) NOT NULL,
`temperature` DECIMAL(3, 2) NULL,
`max_tokens` INT NULL,
`system_prompt` TEXT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `llm_api_config_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `knowledge_base` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL,
`description` VARCHAR(512) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `knowledge_base_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `knowledge_document` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`knowledge_base_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(256) NOT NULL,
`file_name` VARCHAR(256) NULL,
`file_url` VARCHAR(1024) NULL,
`mime_type` VARCHAR(128) NULL,
`size_bytes` INT NULL,
`content_text` LONGTEXT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'READY',
`error_message` VARCHAR(512) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `knowledge_document_knowledge_base_id_idx` (`knowledge_base_id`),
CONSTRAINT `knowledge_document_knowledge_base_id_fkey`
FOREIGN KEY (`knowledge_base_id`) REFERENCES `knowledge_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+80 -12
View File
@@ -352,24 +352,92 @@ model SystemConfig {
/// 企业微信智能机器人(HQ 可创建多实例,长连接)
model WecomBot {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(64)
role String @db.VarChar(32)
botId String @unique @map("bot_id") @db.VarChar(128)
secret String @db.VarChar(256)
avatarUrl String? @map("avatar_url") @db.VarChar(512)
welcome String? @db.VarChar(1024)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(64)
role String @db.VarChar(32)
botId String @unique @map("bot_id") @db.VarChar(128)
secret String @db.VarChar(256)
avatarUrl String? @map("avatar_url") @db.VarChar(512)
welcome String? @db.VarChar(1024)
/// JSON 字符串数组,如 ["ticket.create","user.view_sms"]
permissions String @db.Text
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
permissions String @db.Text
/// 未匹配指令时是否调用语言模型
aiEnabled Boolean @default(false) @map("ai_enabled")
llmConfigId BigInt? @map("llm_config_id") @db.UnsignedBigInt
knowledgeBaseId BigInt? @map("knowledge_base_id") @db.UnsignedBigInt
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
llmConfig LlmApiConfig? @relation(fields: [llmConfigId], references: [id], onDelete: SetNull)
knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id], onDelete: SetNull)
@@index([enabled, sortOrder])
@@index([llmConfigId])
@@index([knowledgeBaseId])
@@map("wecom_bot")
}
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
model LlmApiConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(64)
provider String @db.VarChar(32)
baseUrl String @map("base_url") @db.VarChar(512)
apiKey String @map("api_key") @db.VarChar(512)
modelName String @map("model_name") @db.VarChar(128)
temperature Decimal? @db.Decimal(3, 2)
maxTokens Int? @map("max_tokens")
systemPrompt String? @map("system_prompt") @db.Text
enabled Boolean @default(true)
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
wecomBots WecomBot[]
@@index([createdByHqAccountId, enabled])
@@map("llm_api_config")
}
/// HQ 知识库
model KnowledgeBase {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(128)
description String? @db.VarChar(512)
enabled Boolean @default(true)
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
documents KnowledgeDocument[]
wecomBots WecomBot[]
@@index([createdByHqAccountId, enabled])
@@map("knowledge_base")
}
model KnowledgeDocument {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
knowledgeBaseId BigInt @map("knowledge_base_id") @db.UnsignedBigInt
title String @db.VarChar(256)
fileName String? @map("file_name") @db.VarChar(256)
fileUrl String? @map("file_url") @db.VarChar(1024)
mimeType String? @map("mime_type") @db.VarChar(128)
sizeBytes Int? @map("size_bytes")
contentText String? @map("content_text") @db.LongText
status String @default("READY") @db.VarChar(16)
errorMessage String? @map("error_message") @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id], onDelete: Cascade)
@@index([knowledgeBaseId])
@@map("knowledge_document")
}
model MockSmsCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @db.VarChar(20)
@@ -76,6 +76,15 @@ export const HqOperationAction = {
WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
@@ -163,6 +172,15 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
[HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
[HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
const MAX_CONTEXT_CHARS = 6000;
@Injectable()
export class KnowledgeRetrievalService {
constructor(private readonly prisma: PrismaService) {}
/** 简易关键词命中:按文档正文包含查询词打分,拼进上下文 */
async buildContext(knowledgeBaseId: bigint, query: string): Promise<string> {
const kb = await this.prisma.knowledgeBase.findUnique({
where: { id: knowledgeBaseId },
select: { id: true, enabled: true, name: true },
});
if (!kb?.enabled) return '';
const docs = await this.prisma.knowledgeDocument.findMany({
where: {
knowledgeBaseId,
status: 'READY',
contentText: { not: null },
},
select: { title: true, contentText: true },
take: 50,
});
if (!docs.length) return '';
const tokens = tokenize(query);
const scored = docs
.map((d) => {
const body = d.contentText || '';
let score = 0;
for (const t of tokens) {
if (body.includes(t) || d.title.includes(t)) score += 1;
}
if (!tokens.length) score = 1;
return { title: d.title, body, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score);
const picked = scored.length ? scored.slice(0, 5) : docs.slice(0, 3).map((d) => ({
title: d.title,
body: d.contentText || '',
score: 0,
}));
let out = `【知识库:${kb.name}\n`;
for (const p of picked) {
const chunk = `### ${p.title}\n${p.body}\n\n`;
if (out.length + chunk.length > MAX_CONTEXT_CHARS) {
out += chunk.slice(0, Math.max(0, MAX_CONTEXT_CHARS - out.length));
break;
}
out += chunk;
}
return out.trim();
}
}
function tokenize(q: string): string[] {
return q
.split(/[\s,,。;;、!?!?\-_/\\]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length >= 2)
.slice(0, 12);
}
@@ -0,0 +1,75 @@
import { Injectable, Logger } from '@nestjs/common';
export type LlmChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
export type LlmChatParams = {
baseUrl: string;
apiKey: string;
model: string;
messages: LlmChatMessage[];
temperature?: number | null;
maxTokens?: number | null;
};
/** 规范化 OpenAI 兼容根地址:去掉末尾 / 与重复的 /v1 */
export function normalizeLlmBaseUrl(raw: string): string {
let base = String(raw || '').trim().replace(/\/+$/, '');
// 用户常填 https://api.deepseek.com/v1 ,避免拼成 /v1/v1/chat/completions
if (/\/v1$/i.test(base)) {
base = base.replace(/\/v1$/i, '');
}
return base;
}
export function buildLlmChatCompletionsUrl(baseUrl: string): string {
return `${normalizeLlmBaseUrl(baseUrl)}/v1/chat/completions`;
}
@Injectable()
export class LlmChatClient {
private readonly logger = new Logger(LlmChatClient.name);
async chat(params: LlmChatParams): Promise<string> {
const url = buildLlmChatCompletionsUrl(params.baseUrl);
const body: Record<string, unknown> = {
model: params.model,
messages: params.messages,
stream: false,
};
if (params.temperature != null && !Number.isNaN(params.temperature)) {
body.temperature = params.temperature;
}
if (params.maxTokens != null && params.maxTokens > 0) {
body.max_tokens = params.maxTokens;
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
this.logger.warn(`llm chat failed ${res.status} ${url}: ${text.slice(0, 400)}`);
throw new Error(`语言模型调用失败(HTTP ${res.status}`);
}
let json: {
choices?: Array<{ message?: { content?: string } }>;
error?: { message?: string };
};
try {
json = JSON.parse(text) as typeof json;
} catch {
throw new Error('语言模型返回非 JSON');
}
if (json.error?.message) throw new Error(json.error.message);
const content = json.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('语言模型未返回内容');
return content;
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LlmChatClient } from './llm-chat.client';
import { KnowledgeRetrievalService } from './knowledge-retrieval.service';
@Module({
providers: [LlmChatClient, KnowledgeRetrievalService],
exports: [LlmChatClient, KnowledgeRetrievalService],
})
export class LlmModule {}
@@ -12,6 +12,7 @@ import {
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { WecomBotActionsService } from './wecom-bot-actions.service';
import { WecomBotAiService } from './wecom-bot-ai.service';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
const DEFAULT_WELCOMES: Record<WecomBotRole, string> = {
@@ -62,6 +63,7 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly prisma: PrismaService,
private readonly actions: WecomBotActionsService,
private readonly ai: WecomBotAiService,
) {}
async onModuleInit() {
@@ -150,6 +152,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
avatarUrl: string | null;
welcome: string | null;
permissions: string;
aiEnabled: boolean;
llmConfigId: bigint | null;
knowledgeBaseId: bigint | null;
enabled: boolean;
}): WecomBotRuntimeConfig {
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
@@ -164,6 +169,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
avatarUrl: row.avatarUrl,
welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role],
permissions: resolveWecomBotPermissions(role, row.permissions),
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId?.toString() ?? null,
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
};
}
@@ -258,8 +266,24 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
await this.replyText(client, frame, this.formatStatusMarkdown());
return;
}
const reply = await this.actions.handleCommand(cfg, wecomUserId, content);
await this.replyText(client, frame, reply);
const useAi = cfg.aiEnabled && !!cfg.llmConfigId;
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
skipNaturalFallback: useAi,
});
if (reply != null) {
await this.replyText(client, frame, reply);
return;
}
if (useAi) {
const aiReply = await this.ai.replyIfConfigured(cfg, content);
await this.replyText(
client,
frame,
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
);
return;
}
await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(`[${cfg.key}] handle text failed: ${msg}`);
@@ -95,6 +95,13 @@ export class WecomBotActionsService {
'',
);
}
if (bot.aiEnabled && bot.llmConfigId) {
lines.push(
'**智能问答**',
'未匹配指令的自然语言将交由绑定的语言模型回答(可挂知识库)',
'',
);
}
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
return lines.join('\n');
}
@@ -103,7 +110,8 @@ export class WecomBotActionsService {
bot: WecomBotRuntimeConfig,
wecomUserId: string,
text: string,
): Promise<string> {
opts?: { skipNaturalFallback?: boolean },
): Promise<string | null> {
const content = text.trim();
if (!content) return this.buildHelp(bot);
@@ -152,14 +160,19 @@ export class WecomBotActionsService {
return this.queryHandbook(q);
}
// 自然语言手册(仅团队助手有 handbook 权限时)
if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
// 自然语言手册(仅团队助手有 handbook 权限时;开启 AI 时改由模型+知识库回答
if (
!opts?.skipNaturalFallback &&
wecomBotHasPermission(bot, 'handbook.query') &&
content.length >= 2
) {
const hit = searchHandbook(content, 1);
if (hit.length) {
return formatHandbook(hit);
}
}
if (opts?.skipNaturalFallback) return null;
return `未识别指令。\n\n${this.buildHelp(bot)}`;
}
@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { LlmChatClient } from '../llm/llm-chat.client';
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
const DEFAULT_SYSTEM = [
'你是杜康好客企业内部助手。',
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
'回答简洁,使用中文。',
].join('');
@Injectable()
export class WecomBotAiService {
private readonly logger = new Logger(WecomBotAiService.name);
constructor(
private readonly prisma: PrismaService,
private readonly llm: LlmChatClient,
private readonly kb: KnowledgeRetrievalService,
) {}
async replyIfConfigured(bot: WecomBotRuntimeConfig, userText: string): Promise<string | null> {
if (!bot.aiEnabled || !bot.llmConfigId) return null;
const cfg = await this.prisma.llmApiConfig.findUnique({
where: { id: BigInt(bot.llmConfigId) },
});
if (!cfg?.enabled) {
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
}
let kbBlock = '';
if (bot.knowledgeBaseId) {
try {
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
} catch (e) {
this.logger.warn(`kb retrieve failed: ${String(e)}`);
}
}
const systemParts = [
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
];
try {
return await this.llm.chat({
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.modelName,
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
maxTokens: cfg.maxTokens ?? 1024,
messages: [
{ role: 'system', content: systemParts.join('') },
{ role: 'user', content: userText },
],
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(`wecom ai reply failed: ${msg}`);
return `AI 回复失败:${msg}`;
}
}
}
@@ -11,6 +11,9 @@ export type WecomBotRuntimeConfig = {
welcome: string;
avatarUrl: string | null;
permissions: WecomBotPermission[];
aiEnabled: boolean;
llmConfigId: string | null;
knowledgeBaseId: string | null;
};
export function wecomBotHasPermission(
@@ -1,14 +1,25 @@
import { Module, forwardRef } from '@nestjs/common';
import { CommonModule } from '../../modules/common/common.module';
import { IntegrationsModule } from '../integrations.module';
import { LlmModule } from '../llm/llm.module';
import { WecomAibotService } from './wecom-aibot.service';
import { WecomBotActionsService } from './wecom-bot-actions.service';
import { WecomBotAiService } from './wecom-bot-ai.service';
import { WecomBotSessionService } from './wecom-bot-session.service';
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信),不反向被 Integrations 引用 */
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信)+ Llm,不反向被 Integrations 引用 */
@Module({
imports: [forwardRef(() => CommonModule), forwardRef(() => IntegrationsModule)],
providers: [WecomBotSessionService, WecomBotActionsService, WecomAibotService],
imports: [
forwardRef(() => CommonModule),
forwardRef(() => IntegrationsModule),
LlmModule,
],
providers: [
WecomBotSessionService,
WecomBotActionsService,
WecomBotAiService,
WecomAibotService,
],
exports: [WecomAibotService],
})
export class WecomModule {}
@@ -0,0 +1,136 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import type {
CreateKnowledgeBaseRequest,
CreateKnowledgeDocumentRequest,
UpdateKnowledgeBaseRequest,
} from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
@Controller('admin/knowledge-bases')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('knowledge_bases')
export class AdminKnowledgeBasesController {
constructor(private readonly service: AdminKnowledgeBasesService) {}
@Get()
async list(
@CurrentUser() user: AuthUser,
@Query('name') name?: string,
@Query('enabled') enabled?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.list(actor, {
name,
enabled,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Get('options')
async options(@CurrentUser() user: AuthUser) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.options(actor);
}
@Get(':id')
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.detail(actor, BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_CREATE,
refType: 'KNOWLEDGE_BASE',
includeBody: true,
})
async create(@CurrentUser() user: AuthUser, @Body() body: CreateKnowledgeBaseRequest) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.create(actor, body);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_UPDATE,
refType: 'KNOWLEDGE_BASE',
refIdField: 'id',
includeBody: true,
})
async update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpdateKnowledgeBaseRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.update(actor, BigInt(id), body);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_DELETE,
refType: 'KNOWLEDGE_BASE',
refIdField: 'id',
})
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.remove(actor, BigInt(id));
}
@Get(':id/documents')
async listDocuments(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.listDocuments(actor, BigInt(id));
}
@Post(':id/documents')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_CREATE,
refType: 'KNOWLEDGE_DOCUMENT',
includeBody: true,
})
async addDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: CreateKnowledgeDocumentRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.addDocument(actor, BigInt(id), body);
}
@Delete(':id/documents/:docId')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_DELETE,
refType: 'KNOWLEDGE_DOCUMENT',
refIdField: 'docId',
})
async removeDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('docId') docId: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.removeDocument(actor, BigInt(id), BigInt(docId));
}
}
@@ -0,0 +1,341 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateKnowledgeBaseRequest,
CreateKnowledgeDocumentRequest,
KnowledgeBaseDto,
KnowledgeBaseOptionDto,
KnowledgeDocumentDto,
UpdateKnowledgeBaseRequest,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i;
@Injectable()
export class AdminKnowledgeBasesService {
constructor(private readonly prisma: PrismaService) {}
async resolveActor(actorId: bigint): Promise<ActorCtx> {
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.knowledgeBase.findMany({
where,
orderBy: [{ id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: { _count: { select: { documents: true } } },
}),
this.prisma.knowledgeBase.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.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents),
),
total,
page,
pageSize,
});
}
async options(actor: ActorCtx): Promise<KnowledgeBaseOptionDto[]> {
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
const rows = await this.prisma.knowledgeBase.findMany({
where,
orderBy: [{ id: 'desc' }],
include: { _count: { select: { documents: true } } },
});
return rows.map((r) => ({
id: r.id.toString(),
name: r.name,
enabled: r.enabled,
documentCount: r._count.documents,
}));
}
async detail(actor: ActorCtx, id: bigint) {
const row = await this.requireKb(actor, id);
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
const owner = await this.prisma.hqAccount.findUnique({
where: { id: row.createdByHqAccountId },
select: { name: true },
});
return this.toKbDto(row, actor, owner?.name ?? null, count);
}
async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('请填写名称');
const row = await this.prisma.knowledgeBase.create({
data: {
name,
description: dto.description?.trim() || null,
enabled: dto.enabled !== false,
createdByHqAccountId: actor.actorId,
},
});
return this.toKbDto(row, actor, null, 0);
}
async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) {
const row = await this.requireKb(actor, id);
this.requireWrite(actor, row);
if (!actor.isSuperAdmin) {
// 创建人可改名称/描述/启用
const data: {
name?: string;
description?: string | null;
enabled?: boolean;
} = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('名称不能为空');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.enabled !== undefined) data.enabled = dto.enabled;
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
return this.toKbDto(updated, actor, null, count);
}
const data: {
name?: string;
description?: string | null;
enabled?: boolean;
} = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('名称不能为空');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.enabled !== undefined) data.enabled = dto.enabled;
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
return this.toKbDto(updated, actor, null, count);
}
async remove(actor: ActorCtx, id: bigint) {
const row = await this.requireKb(actor, id);
this.requireWrite(actor, row);
await this.prisma.knowledgeBase.delete({ where: { id } });
return { ok: true };
}
async listDocuments(actor: ActorCtx, kbId: bigint) {
await this.requireKb(actor, kbId);
const rows = await this.prisma.knowledgeDocument.findMany({
where: { knowledgeBaseId: kbId },
orderBy: [{ id: 'desc' }],
});
return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) });
}
async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) {
const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb);
const title = dto.title?.trim();
if (!title) throw new BadRequestException('请填写标题');
let contentText = dto.contentText?.trim() || '';
let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY';
let errorMessage: string | null = null;
if (contentText) {
status = 'READY';
} else if (dto.fileUrl?.trim()) {
const fileName = dto.fileName?.trim() || '';
if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) {
try {
contentText = await fetchText(dto.fileUrl.trim());
status = contentText.trim() ? 'READY' : 'EMPTY';
if (!contentText.trim()) errorMessage = '文件内容为空';
} catch (e) {
status = 'FAILED';
errorMessage = e instanceof Error ? e.message : String(e);
}
} else {
status = 'EMPTY';
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
}
} else {
throw new BadRequestException('请粘贴正文或上传文件');
}
const row = await this.prisma.knowledgeDocument.create({
data: {
knowledgeBaseId: kbId,
title,
fileName: dto.fileName?.trim() || null,
fileUrl: dto.fileUrl?.trim() || null,
mimeType: dto.mimeType?.trim() || null,
sizeBytes: dto.sizeBytes ?? null,
contentText: contentText || null,
status,
errorMessage,
},
});
return this.toDocDto(row);
}
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb);
const doc = await this.prisma.knowledgeDocument.findFirst({
where: { id: docId, knowledgeBaseId: kbId },
});
if (!doc) throw new NotFoundException('文档不存在');
await this.prisma.knowledgeDocument.delete({ where: { id: docId } });
return { ok: true };
}
private async requireKb(actor: ActorCtx, id: bigint) {
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
if (!row) throw new NotFoundException('知识库不存在');
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
throw new ForbiddenException('无权查看该知识库');
}
return row;
}
private requireWrite(
actor: ActorCtx,
row: { createdByHqAccountId: bigint },
) {
if (actor.isSuperAdmin) return;
if (row.createdByHqAccountId !== actor.actorId) {
throw new ForbiddenException('只能操作自己创建的知识库');
}
}
private toKbDto(
row: {
id: bigint;
name: string;
description: string | null;
enabled: boolean;
createdByHqAccountId: bigint;
createdAt: Date;
updatedAt: Date;
},
actor: ActorCtx,
createdByName: string | null,
documentCount: number,
): KnowledgeBaseDto {
const isOwner = row.createdByHqAccountId === actor.actorId;
return {
id: row.id.toString(),
name: row.name,
description: row.description,
enabled: row.enabled,
documentCount,
createdByHqAccountId: row.createdByHqAccountId.toString(),
createdByName,
isOwner,
canEditFull: actor.isSuperAdmin || isOwner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toDocDto(row: {
id: bigint;
knowledgeBaseId: bigint;
title: string;
fileName: string | null;
fileUrl: string | null;
mimeType: string | null;
sizeBytes: number | null;
contentText: string | null;
status: string;
errorMessage: string | null;
createdAt: Date;
updatedAt: Date;
}): KnowledgeDocumentDto {
const status =
row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY'
? row.status
: 'EMPTY';
return {
id: row.id.toString(),
knowledgeBaseId: row.knowledgeBaseId.toString(),
title: row.title,
fileName: row.fileName,
fileUrl: row.fileUrl,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
hasContent: !!(row.contentText && row.contentText.trim()),
status,
errorMessage: row.errorMessage,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function isLikelyTextMime(mime?: string | null) {
if (!mime) return false;
return (
mime.startsWith('text/') ||
mime === 'application/json' ||
mime === 'application/markdown'
);
}
async function fetchText(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`);
const buf = await res.arrayBuffer();
if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB');
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
@@ -0,0 +1,110 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import type {
CreateLlmApiConfigRequest,
UpdateLlmApiConfigRequest,
} from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminLlmConfigsService } from './admin-llm-configs.service';
@Controller('admin/llm-configs')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('llm_configs')
export class AdminLlmConfigsController {
constructor(private readonly service: AdminLlmConfigsService) {}
@Get()
async list(
@CurrentUser() user: AuthUser,
@Query('name') name?: string,
@Query('enabled') enabled?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.list(actor, {
name,
enabled,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Get('options')
async options(@CurrentUser() user: AuthUser) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.options(actor);
}
@Get(':id')
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.detail(actor, BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.LLM_CONFIG_CREATE,
refType: 'LLM_CONFIG',
includeBody: true,
})
async create(@CurrentUser() user: AuthUser, @Body() body: CreateLlmApiConfigRequest) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.create(actor, body);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_UPDATE,
refType: 'LLM_CONFIG',
refIdField: 'id',
includeBody: true,
})
async update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpdateLlmApiConfigRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.update(actor, BigInt(id), body);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_DELETE,
refType: 'LLM_CONFIG',
refIdField: 'id',
})
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.remove(actor, BigInt(id));
}
@Post(':id/test')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_TEST,
refType: 'LLM_CONFIG',
refIdField: 'id',
})
async test(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.test(actor, BigInt(id));
}
}
@@ -0,0 +1,290 @@
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<ActorCtx> {
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<LlmApiConfigOptionDto[]> {
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<string, unknown>)[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(),
};
}
}
@@ -17,13 +17,21 @@ import {
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminLlmConfigsService } from './admin-llm-configs.service';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
@Controller('admin/wecom-bots')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('wecom_bots')
export class AdminWecomBotsController {
constructor(private readonly service: AdminWecomBotsService) {}
constructor(
private readonly service: AdminWecomBotsService,
private readonly llmConfigs: AdminLlmConfigsService,
private readonly knowledgeBases: AdminKnowledgeBasesService,
) {}
@Get()
list(
@@ -52,6 +60,16 @@ export class AdminWecomBotsController {
return this.service.reloadRuntime();
}
@Get('ai-options')
async aiOptions(@CurrentUser() user: AuthUser) {
const actor = await this.llmConfigs.resolveActor(user.actorId);
const [llmConfigs, knowledgeBases] = await Promise.all([
this.llmConfigs.options(actor),
this.knowledgeBases.options(actor),
]);
return { llmConfigs, knowledgeBases };
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
@@ -21,6 +21,26 @@ function isWecomRole(v: string): v is WecomBotRole {
return (WECOM_BOT_ROLES as readonly string[]).includes(v);
}
type WecomRow = {
id: bigint;
name: string;
role: string;
botId: string;
secret: string;
avatarUrl: string | null;
welcome: string | null;
permissions: string;
aiEnabled: boolean;
llmConfigId: bigint | null;
knowledgeBaseId: bigint | null;
enabled: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
llmConfig?: { id: bigint; name: string } | null;
knowledgeBase?: { id: bigint; name: string } | null;
};
@Injectable()
export class AdminWecomBotsService {
constructor(
@@ -48,6 +68,10 @@ export class AdminWecomBotsService {
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
llmConfig: { select: { id: true, name: true } },
knowledgeBase: { select: { id: true, name: true } },
},
}),
this.prisma.wecomBot.count({ where }),
]);
@@ -62,7 +86,13 @@ export class AdminWecomBotsService {
}
async detail(id: bigint) {
const row = await this.prisma.wecomBot.findUnique({ where: { id } });
const row = await this.prisma.wecomBot.findUnique({
where: { id },
include: {
llmConfig: { select: { id: true, name: true } },
knowledgeBase: { select: { id: true, name: true } },
},
});
if (!row) throw new NotFoundException('机器人不存在');
return this.toDto(row);
}
@@ -79,6 +109,8 @@ export class AdminWecomBotsService {
const exists = await this.prisma.wecomBot.findUnique({ where: { botId } });
if (exists) throw new BadRequestException('BotID 已存在');
const llmConfigId = await this.resolveLlmId(dto.llmConfigId);
const knowledgeBaseId = await this.resolveKbId(dto.knowledgeBaseId);
const permissions = resolveWecomBotPermissions(dto.role, dto.permissions);
const row = await this.prisma.wecomBot.create({
data: {
@@ -89,9 +121,16 @@ export class AdminWecomBotsService {
avatarUrl: dto.avatarUrl?.trim() || null,
welcome: dto.welcome?.trim() || null,
permissions: JSON.stringify(permissions),
aiEnabled: dto.aiEnabled === true,
llmConfigId,
knowledgeBaseId,
enabled: dto.enabled !== false,
sortOrder: dto.sortOrder ?? 0,
},
include: {
llmConfig: { select: { id: true, name: true } },
knowledgeBase: { select: { id: true, name: true } },
},
});
await this.wecomAibot.reload('bot-create');
return this.toDto(row);
@@ -132,6 +171,11 @@ export class AdminWecomBotsService {
const secret =
dto.secret !== undefined && dto.secret.trim() ? dto.secret.trim() : existing.secret;
const llmConfigId =
dto.llmConfigId !== undefined ? await this.resolveLlmId(dto.llmConfigId) : undefined;
const knowledgeBaseId =
dto.knowledgeBaseId !== undefined ? await this.resolveKbId(dto.knowledgeBaseId) : undefined;
const row = await this.prisma.wecomBot.update({
where: { id },
data: {
@@ -143,9 +187,16 @@ export class AdminWecomBotsService {
dto.avatarUrl === undefined ? undefined : dto.avatarUrl?.trim() || null,
welcome: dto.welcome === undefined ? undefined : dto.welcome?.trim() || null,
permissions: permissionsJson,
aiEnabled: dto.aiEnabled,
llmConfigId,
knowledgeBaseId,
enabled: dto.enabled,
sortOrder: dto.sortOrder,
},
include: {
llmConfig: { select: { id: true, name: true } },
knowledgeBase: { select: { id: true, name: true } },
},
});
await this.wecomAibot.reload('bot-update');
return this.toDto(row);
@@ -163,20 +214,25 @@ export class AdminWecomBotsService {
return this.wecomAibot.reload('manual');
}
private toDto(row: {
id: bigint;
name: string;
role: string;
botId: string;
secret: string;
avatarUrl: string | null;
welcome: string | null;
permissions: string;
enabled: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}): WecomBotDto {
private async resolveLlmId(raw?: string | null): Promise<bigint | null> {
if (raw === undefined) return null;
if (raw === null || raw === '') return null;
const id = BigInt(raw);
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
if (!row) throw new BadRequestException('语言模型配置不存在');
return id;
}
private async resolveKbId(raw?: string | null): Promise<bigint | null> {
if (raw === undefined) return null;
if (raw === null || raw === '') return null;
const id = BigInt(raw);
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
if (!row) throw new BadRequestException('知识库不存在');
return id;
}
private toDto(row: WecomRow): WecomBotDto {
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
const permissions = resolveWecomBotPermissions(role, row.permissions) as WecomBotPermission[];
return {
@@ -188,6 +244,11 @@ export class AdminWecomBotsService {
avatarUrl: row.avatarUrl,
welcome: row.welcome,
permissions,
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId?.toString() ?? null,
llmConfigName: row.llmConfig?.name ?? null,
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
knowledgeBaseName: row.knowledgeBase?.name ?? null,
enabled: row.enabled,
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
@@ -45,6 +45,7 @@ import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { WecomModule } from '../../integrations/wecom/wecom.module';
import { LlmModule } from '../../integrations/llm/llm.module';
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
@@ -62,10 +63,14 @@ import { AdminDeployService } from './admin-deploy.service';
import { AdminSystemConfigController } from './admin-system-config.controller';
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminLlmConfigsController } from './admin-llm-configs.controller';
import { AdminLlmConfigsService } from './admin-llm-configs.service';
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
@Module({
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, RedeemModule, StoreModule],
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
controllers: [
AdminDashboardController,
AdminDeployController,
@@ -102,6 +107,8 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminHqPermissionsController,
AdminSystemConfigController,
AdminWecomBotsController,
AdminLlmConfigsController,
AdminKnowledgeBasesController,
AdminFulfillmentProvidersController,
],
providers: [
@@ -129,6 +136,8 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminHqPermissionsService,
AdminDeployService,
AdminWecomBotsService,
AdminLlmConfigsService,
AdminKnowledgeBasesService,
SuperAdminGuard,
],
exports: [CityScopeModule],