feat(auth): 实现平台超管与多租户角色权限深度隔离、前端动态路由白名单及DeepSeek配额守卫
这个提交包含在:
@@ -1,13 +1,15 @@
|
||||
import { Body, Inject, Post } from '@midwayjs/core';
|
||||
import { BaseController, CoolController } from '@cool-midway/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { BaseController, CoolController, CoolCommException } from '@cool-midway/core';
|
||||
import { DeepSeekService } from '../../service/deepseek';
|
||||
import {
|
||||
DeepSeekChatCompletionRequest,
|
||||
DeepSeekConfig,
|
||||
} from '../../../../comm/types/omni';
|
||||
import { assertSuperAdmin } from '../../../../comm/guard/super-admin';
|
||||
|
||||
/**
|
||||
* DeepSeek 大模型后台管理与调试控制器
|
||||
* DeepSeek 大模型后台管理与调试控制器 (支持多租户 BYOK 与 Token 计量)
|
||||
*/
|
||||
@CoolController({
|
||||
api: [],
|
||||
@@ -20,18 +22,32 @@ import {
|
||||
],
|
||||
})
|
||||
export class AdminDeepSeekController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
deepSeekService: DeepSeekService;
|
||||
|
||||
/**
|
||||
* 获取当前有效配置(脱敏 API Key)
|
||||
* 获取当前有效配置(脱敏 API Key,支持租户 BYOK 隔离)
|
||||
*/
|
||||
@Post('/config', { summary: '获取或保存 DeepSeek 配置' })
|
||||
async config(@Body() body?: Partial<DeepSeekConfig>) {
|
||||
const tenantId = this.ctx?.admin?.tenantId;
|
||||
|
||||
if (body && Object.keys(body).length > 0) {
|
||||
this.deepSeekService.updateConfig(body);
|
||||
if (tenantId !== undefined && tenantId !== null) {
|
||||
this.deepSeekService.updateTenantConfig(tenantId, body);
|
||||
} else {
|
||||
this.deepSeekService.updateConfig(body);
|
||||
}
|
||||
}
|
||||
const current = this.deepSeekService.getConfig();
|
||||
|
||||
const current =
|
||||
tenantId !== undefined && tenantId !== null
|
||||
? this.deepSeekService.getTenantConfig(tenantId)
|
||||
: this.deepSeekService.getConfig();
|
||||
|
||||
const maskedKey = current.apiKey
|
||||
? current.apiKey.length > 8
|
||||
? `${current.apiKey.slice(0, 4)}****${current.apiKey.slice(-4)}`
|
||||
@@ -41,16 +57,51 @@ export class AdminDeepSeekController extends BaseController {
|
||||
return this.ok({
|
||||
...current,
|
||||
apiKey: maskedKey,
|
||||
isConfigured: this.deepSeekService.isConfigured(),
|
||||
isConfigured: this.deepSeekService.isConfigured(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前租户的 Token 消耗统计与额度进度
|
||||
*/
|
||||
@Post('/usage', { summary: '租户 Token 消耗与额度统计' })
|
||||
async usage() {
|
||||
const tenantId = this.ctx?.admin?.tenantId || 0;
|
||||
const usage = this.deepSeekService.getTenantTokenUsage(tenantId);
|
||||
const quota = this.deepSeekService.getTenantTokenQuota(tenantId);
|
||||
const percent =
|
||||
quota > 0 ? Math.min(100, Math.round((usage.totalTokens / quota) * 100)) : 0;
|
||||
|
||||
return this.ok({
|
||||
...usage,
|
||||
quota,
|
||||
percent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员设置租户的 Token 配额上限
|
||||
*/
|
||||
@Post('/quota', { summary: '设置租户 Token 配额上限' })
|
||||
async quota(@Body() body: { tenantId: string | number; maxTokens: number }) {
|
||||
assertSuperAdmin(this.ctx);
|
||||
if (!body?.tenantId) {
|
||||
throw new CoolCommException('缺少租户标识 tenantId');
|
||||
}
|
||||
this.deepSeekService.setTenantTokenQuota(body.tenantId, Number(body.maxTokens) || 0);
|
||||
return this.ok({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试与 DeepSeek 官方端点连通性
|
||||
*/
|
||||
@Post('/test-connection', { summary: '测试连通性' })
|
||||
async testConnection(@Body('apiKey') apiKey?: string) {
|
||||
const res = await this.deepSeekService.testConnection(apiKey);
|
||||
const tenantId = this.ctx?.admin?.tenantId;
|
||||
const res =
|
||||
tenantId !== undefined && tenantId !== null
|
||||
? await this.deepSeekService.testConnection(apiKey, tenantId)
|
||||
: await this.deepSeekService.testConnection(apiKey);
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@@ -59,7 +110,11 @@ export class AdminDeepSeekController extends BaseController {
|
||||
*/
|
||||
@Post('/chat', { summary: '调试对话补全' })
|
||||
async chat(@Body() body: DeepSeekChatCompletionRequest) {
|
||||
const reply = await this.deepSeekService.chatCompletion(body);
|
||||
const tenantId = this.ctx?.admin?.tenantId;
|
||||
const reply =
|
||||
tenantId !== undefined && tenantId !== null
|
||||
? await this.deepSeekService.chatCompletion(body, undefined, tenantId)
|
||||
: await this.deepSeekService.chatCompletion(body);
|
||||
return this.ok({ reply });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,13 @@ export class DeepSeekService extends BaseService {
|
||||
this.tenantQuotas.set(tenantId, Math.max(0, Number(maxTotalTokens) || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定租户的 Token 最大额度上限
|
||||
*/
|
||||
getTenantTokenQuota(tenantId: string | number): number {
|
||||
return this.tenantQuotas.get(tenantId) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前租户 Token 配额充足,超限则抛出 429 阻断
|
||||
*/
|
||||
@@ -339,8 +346,12 @@ export class DeepSeekService extends BaseService {
|
||||
/**
|
||||
* 测试与 DeepSeek 官方端点连通性
|
||||
*/
|
||||
async testConnection(testApiKey?: string): Promise<DeepSeekTestConnectionResult> {
|
||||
const key = (testApiKey || this.config.apiKey || '').trim();
|
||||
async testConnection(
|
||||
testApiKey?: string,
|
||||
tenantId?: number | string
|
||||
): Promise<DeepSeekTestConnectionResult> {
|
||||
const cfg = this.getTenantConfig(tenantId);
|
||||
const key = (testApiKey || cfg.apiKey || '').trim();
|
||||
if (!key) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -349,8 +360,8 @@ export class DeepSeekService extends BaseService {
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||||
const model = this.config.model || 'deepseek-chat';
|
||||
const endpoint = `${cfg.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||||
const model = cfg.model || 'deepseek-chat';
|
||||
|
||||
try {
|
||||
await this.httpClient.post(
|
||||
|
||||
@@ -49,7 +49,10 @@ export class BaseAuthorityMiddleware
|
||||
let statusCode = 200;
|
||||
let { url } = ctx;
|
||||
url = url.replace(this.prefix, '').split('?')[0];
|
||||
const token = ctx.get('Authorization');
|
||||
let token = ctx.get('Authorization') || '';
|
||||
if (token.startsWith('Bearer ')) {
|
||||
token = token.substring(7);
|
||||
}
|
||||
const adminUrl = '/admin/';
|
||||
// 路由地址为 admin前缀的 需要权限校验
|
||||
if (_.startsWith(url, adminUrl)) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AdminDeepSeekController } from '../../../src/modules/ai/controller/admin/deepseek';
|
||||
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
|
||||
describe('AdminDeepSeekController 多租户 BYOK 与 Token 用量控制台 (TDD)', () => {
|
||||
let controller: AdminDeepSeekController;
|
||||
let service: DeepSeekService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DeepSeekService();
|
||||
controller = new AdminDeepSeekController();
|
||||
controller.deepSeekService = service;
|
||||
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
||||
});
|
||||
|
||||
describe('1. 租户管理员私有 BYOK 配置隔离', () => {
|
||||
it('租户管理员保存私有 API Key,仅生效于该租户,不污染全局配置', async () => {
|
||||
// 模拟租户 888 的登录上下文
|
||||
(controller as any).ctx = {
|
||||
admin: { username: 'tenant_admin_888', tenantId: 888 },
|
||||
};
|
||||
|
||||
// 租户保存私有 Key
|
||||
const saveRes: any = await controller.config({
|
||||
apiKey: 'sk-tenant-888-secret-key-123456',
|
||||
model: 'deepseek-reasoner',
|
||||
});
|
||||
|
||||
expect(saveRes.code).toBe(1000);
|
||||
expect(saveRes.data.isConfigured).toBe(true);
|
||||
expect(saveRes.data.model).toBe('deepseek-reasoner');
|
||||
// 验证脱敏显示
|
||||
expect(saveRes.data.apiKey).toBe('sk-t****3456');
|
||||
|
||||
// 验证底层 service 中该租户已配置独立 Key
|
||||
expect(service.isConfigured(888)).toBe(true);
|
||||
expect(service.getTenantConfig(888).apiKey).toBe('sk-tenant-888-secret-key-123456');
|
||||
|
||||
// 验证全局公共池并未被污染
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
expect(service.getConfig().apiKey).toBeFalsy();
|
||||
});
|
||||
|
||||
it('超管访问 config 操作平台公共资金池配置', async () => {
|
||||
// 模拟平台超管上下文
|
||||
(controller as any).ctx = {
|
||||
admin: { username: 'admin', isSuper: true },
|
||||
};
|
||||
|
||||
await controller.config({
|
||||
apiKey: 'sk-platform-master-key-999999',
|
||||
model: 'deepseek-chat',
|
||||
});
|
||||
|
||||
// 平台全局已配置
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
expect(service.getConfig().apiKey).toBe('sk-platform-master-key-999999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 租户 Token 消耗统计与用量查询 (POST /usage)', () => {
|
||||
it('租户可准确查询自身的 Token 消耗与配额进度', async () => {
|
||||
// 模拟记录租户 666 的 Token 消耗
|
||||
service.recordTokenUsage(666, {
|
||||
promptTokens: 120,
|
||||
completionTokens: 80,
|
||||
totalTokens: 200,
|
||||
});
|
||||
service.setTenantTokenQuota(666, 10000);
|
||||
|
||||
(controller as any).ctx = {
|
||||
admin: { username: 'tenant_user_666', tenantId: 666 },
|
||||
};
|
||||
|
||||
const usageRes = await (controller as any).usage();
|
||||
expect(usageRes.code).toBe(1000);
|
||||
expect(usageRes.data.totalTokens).toBe(200);
|
||||
expect(usageRes.data.promptTokens).toBe(120);
|
||||
expect(usageRes.data.completionTokens).toBe(80);
|
||||
expect(usageRes.data.callCount).toBe(1);
|
||||
expect(usageRes.data.quota).toBe(10000);
|
||||
expect(usageRes.data.percent).toBe(2); // 200 / 10000 = 2%
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 超管设置租户配额 (POST /quota)', () => {
|
||||
it('超级管理员可成功为指定租户设置额度上限', async () => {
|
||||
(controller as any).ctx = {
|
||||
admin: { username: 'admin', isSuper: true },
|
||||
};
|
||||
|
||||
const quotaRes = await (controller as any).quota({
|
||||
tenantId: 777,
|
||||
maxTokens: 50000,
|
||||
});
|
||||
|
||||
expect(quotaRes.code).toBe(1000);
|
||||
// 验证生效
|
||||
expect(() => service.assertTenantTokenQuota(777, 40000)).not.toThrow();
|
||||
expect(() => service.assertTenantTokenQuota(777, 60000)).toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('非超级管理员调用 quota 必须抛出 403 阻断越权', async () => {
|
||||
(controller as any).ctx = {
|
||||
admin: { username: 'tenant_rep', tenantId: 10 },
|
||||
};
|
||||
|
||||
await expect(
|
||||
(controller as any).quota({ tenantId: 10, maxTokens: 999999 })
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户