189 lines
5.8 KiB
TypeScript
189 lines
5.8 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
maskWecomPluginApiKey,
|
|
parseWecomPluginPermissions,
|
|
type CreateWecomApiPluginRequest,
|
|
type UpdateWecomApiPluginRequest,
|
|
type WecomApiPluginDto,
|
|
type WecomApiPluginSecretDto,
|
|
} from '@dukang/shared-types';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
|
|
const MIN_KEY_LEN = 16;
|
|
|
|
@Injectable()
|
|
export class AdminWecomApiPluginsService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async list(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 } = {};
|
|
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.wecomApiPlugin.findMany({
|
|
where,
|
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.wecomApiPlugin.count({ where }),
|
|
]);
|
|
|
|
return serializeBigInt({
|
|
items: items.map((row) => this.toDto(row)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
});
|
|
}
|
|
|
|
async detail(id: bigint): Promise<WecomApiPluginDto> {
|
|
return this.toDto(await this.mustGet(id));
|
|
}
|
|
|
|
async create(dto: CreateWecomApiPluginRequest): Promise<WecomApiPluginSecretDto> {
|
|
const name = dto.name?.trim();
|
|
if (!name) throw new BadRequestException('请填写名称');
|
|
const permissions = parseWecomPluginPermissions(dto.permissions);
|
|
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
|
const apiKey = this.normalizeApiKey(dto.apiKey, true);
|
|
|
|
try {
|
|
const row = await this.prisma.wecomApiPlugin.create({
|
|
data: {
|
|
name,
|
|
apiKey,
|
|
permissions: JSON.stringify(permissions),
|
|
remark: dto.remark?.trim() || null,
|
|
enabled: dto.enabled !== false,
|
|
sortOrder: dto.sortOrder ?? 0,
|
|
},
|
|
});
|
|
return { ...this.toDto(row), apiKey };
|
|
} catch (e) {
|
|
this.rethrowUniqueKey(e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async update(id: bigint, dto: UpdateWecomApiPluginRequest): Promise<WecomApiPluginDto> {
|
|
const existing = await this.mustGet(id);
|
|
const data: Prisma.WecomApiPluginUpdateInput = {};
|
|
|
|
if (dto.name !== undefined) {
|
|
const name = dto.name.trim();
|
|
if (!name) throw new BadRequestException('请填写名称');
|
|
data.name = name;
|
|
}
|
|
if (dto.permissions !== undefined) {
|
|
const permissions = parseWecomPluginPermissions(dto.permissions);
|
|
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
|
data.permissions = JSON.stringify(permissions);
|
|
}
|
|
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
|
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
|
if (dto.apiKey !== undefined && String(dto.apiKey).trim()) {
|
|
data.apiKey = this.normalizeApiKey(dto.apiKey, false);
|
|
}
|
|
|
|
try {
|
|
const row = await this.prisma.wecomApiPlugin.update({
|
|
where: { id: existing.id },
|
|
data,
|
|
});
|
|
return this.toDto(row);
|
|
} catch (e) {
|
|
this.rethrowUniqueKey(e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async remove(id: bigint) {
|
|
await this.mustGet(id);
|
|
await this.prisma.wecomApiPlugin.delete({ where: { id } });
|
|
return { ok: true };
|
|
}
|
|
|
|
async rotateKey(id: bigint): Promise<WecomApiPluginSecretDto> {
|
|
await this.mustGet(id);
|
|
const apiKey = generateApiKey();
|
|
try {
|
|
const row = await this.prisma.wecomApiPlugin.update({
|
|
where: { id },
|
|
data: { apiKey },
|
|
});
|
|
return { ...this.toDto(row), apiKey };
|
|
} catch (e) {
|
|
this.rethrowUniqueKey(e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private async mustGet(id: bigint) {
|
|
const row = await this.prisma.wecomApiPlugin.findUnique({ where: { id } });
|
|
if (!row) throw new NotFoundException('API 插件不存在');
|
|
return row;
|
|
}
|
|
|
|
private normalizeApiKey(raw: string | undefined, generateIfEmpty: boolean): string {
|
|
const v = String(raw ?? '').trim();
|
|
if (!v) {
|
|
if (generateIfEmpty) return generateApiKey();
|
|
throw new BadRequestException('请填写 API Key');
|
|
}
|
|
if (v.length < MIN_KEY_LEN) {
|
|
throw new BadRequestException(`API Key 至少 ${MIN_KEY_LEN} 位`);
|
|
}
|
|
if (v.length > 128) throw new BadRequestException('API Key 过长');
|
|
return v;
|
|
}
|
|
|
|
private rethrowUniqueKey(e: unknown): void {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
|
throw new BadRequestException('API Key 已存在,请更换');
|
|
}
|
|
}
|
|
|
|
private toDto(row: {
|
|
id: bigint;
|
|
name: string;
|
|
apiKey: string;
|
|
permissions: string;
|
|
remark: string | null;
|
|
enabled: boolean;
|
|
sortOrder: number;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}): WecomApiPluginDto {
|
|
return {
|
|
id: row.id.toString(),
|
|
name: row.name,
|
|
apiKeyConfigured: Boolean(row.apiKey),
|
|
apiKeyMasked: maskWecomPluginApiKey(row.apiKey),
|
|
permissions: parseWecomPluginPermissions(row.permissions),
|
|
remark: row.remark,
|
|
enabled: row.enabled,
|
|
sortOrder: row.sortOrder,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
function generateApiKey(): string {
|
|
return `dkp_${randomBytes(24).toString('hex')}`;
|
|
}
|