feat(auth): 实现平台超管与多租户角色权限深度隔离、前端动态路由白名单及DeepSeek配额守卫
这个提交包含在:
@@ -105,7 +105,11 @@ function PageLayout() {
|
|||||||
(state: GlobalState) => state
|
(state: GlobalState) => state
|
||||||
);
|
);
|
||||||
|
|
||||||
const [routes, defaultRoute] = useRoute(userInfo?.permissions);
|
const [routes, defaultRoute] = useRoute(userInfo?.permissions, {
|
||||||
|
isSuper: userInfo?.isSuper,
|
||||||
|
role: userInfo?.role,
|
||||||
|
tenantId: userInfo?.tenantId,
|
||||||
|
});
|
||||||
const defaultSelectedKeys = [currentComponent || defaultRoute];
|
const defaultSelectedKeys = [currentComponent || defaultRoute];
|
||||||
const paths = (currentComponent || defaultRoute).split('/');
|
const paths = (currentComponent || defaultRoute).split('/');
|
||||||
const defaultOpenKeys = paths.slice(0, paths.length - 1);
|
const defaultOpenKeys = paths.slice(0, paths.length - 1);
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface CoolPerson {
|
|||||||
username?: string;
|
username?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
headImg?: string;
|
headImg?: string;
|
||||||
|
isSuper?: boolean;
|
||||||
|
role?: string;
|
||||||
|
tenantId?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoolResponse<T> {
|
interface CoolResponse<T> {
|
||||||
@@ -59,6 +62,8 @@ function Index() {
|
|||||||
.get<CoolResponse<CoolPerson>>('/api/admin/base/comm/person')
|
.get<CoolResponse<CoolPerson>>('/api/admin/base/comm/person')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const person = res.data?.code === COOL_OK ? res.data.data : undefined;
|
const person = res.data?.code === COOL_OK ? res.data.data : undefined;
|
||||||
|
const isSuper = Boolean(person?.isSuper);
|
||||||
|
const role = person?.role || (isSuper ? 'super_admin' : 'tenant_owner');
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'update-userInfo',
|
type: 'update-userInfo',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -67,7 +72,10 @@ function Index() {
|
|||||||
name: person?.name || person?.nickName || person?.username || '',
|
name: person?.name || person?.nickName || person?.username || '',
|
||||||
email: person?.email || '',
|
email: person?.email || '',
|
||||||
avatar: person?.headImg || '',
|
avatar: person?.headImg || '',
|
||||||
permissions: generatePermission('admin'),
|
isSuper,
|
||||||
|
role,
|
||||||
|
tenantId: person?.tenantId,
|
||||||
|
permissions: generatePermission(role),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -78,7 +86,9 @@ function Index() {
|
|||||||
payload: {
|
payload: {
|
||||||
userLoading: false,
|
userLoading: false,
|
||||||
userInfo: {
|
userInfo: {
|
||||||
permissions: generatePermission('admin'),
|
isSuper: false,
|
||||||
|
role: 'sales_rep',
|
||||||
|
permissions: generatePermission('sales_rep'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,20 @@ export type IRoute = AuthParams & {
|
|||||||
children?: IRoute[];
|
children?: IRoute[];
|
||||||
// 当前路由是否渲染菜单项,为 true 的话不会在菜单中显示,但可通过路由地址访问。
|
// 当前路由是否渲染菜单项,为 true 的话不会在菜单中显示,但可通过路由地址访问。
|
||||||
ignore?: boolean;
|
ignore?: boolean;
|
||||||
|
// 是否仅平台超级管理员可见
|
||||||
|
superAdminOnly?: boolean;
|
||||||
|
// 是否仅租户可见
|
||||||
|
tenantOnly?: boolean;
|
||||||
|
// 角色白名单访问限制
|
||||||
|
roles?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface RoleContext {
|
||||||
|
isSuper?: boolean;
|
||||||
|
role?: string;
|
||||||
|
tenantId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
{
|
{
|
||||||
name: 'menu.dashboard',
|
name: 'menu.dashboard',
|
||||||
@@ -27,6 +39,7 @@ export const routes: IRoute[] = [
|
|||||||
{
|
{
|
||||||
name: 'menu.dashboard.financial',
|
name: 'menu.dashboard.financial',
|
||||||
key: 'dashboard/financial',
|
key: 'dashboard/financial',
|
||||||
|
superAdminOnly: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -73,22 +86,39 @@ export const routes: IRoute[] = [
|
|||||||
key: 'team',
|
key: 'team',
|
||||||
children: [
|
children: [
|
||||||
{ name: 'menu.team.members', key: 'team/members' },
|
{ name: 'menu.team.members', key: 'team/members' },
|
||||||
{ name: 'menu.team.brandKit', key: 'team/brand-kit' },
|
{
|
||||||
{ name: 'menu.team.handover', key: 'team/handover' },
|
name: 'menu.team.brandKit',
|
||||||
|
key: 'team/brand-kit',
|
||||||
|
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'menu.team.handover',
|
||||||
|
key: 'team/handover',
|
||||||
|
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'menu.settingsBiz',
|
name: 'menu.settingsBiz',
|
||||||
key: 'settings-biz',
|
key: 'settings-biz',
|
||||||
children: [
|
children: [
|
||||||
{ name: 'menu.settings.tenant', key: 'settings/tenant' },
|
{
|
||||||
{ name: 'menu.settings.billing', key: 'settings/billing' },
|
name: 'menu.settings.tenant',
|
||||||
|
key: 'settings/tenant',
|
||||||
|
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'menu.settings.billing',
|
||||||
|
key: 'settings/billing',
|
||||||
|
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||||
|
},
|
||||||
{ name: 'menu.settings.auditLogs', key: 'settings/audit-logs' },
|
{ name: 'menu.settings.auditLogs', key: 'settings/audit-logs' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'menu.platform',
|
name: 'menu.platform',
|
||||||
key: 'platform',
|
key: 'platform',
|
||||||
|
superAdminOnly: true,
|
||||||
children: [
|
children: [
|
||||||
{ name: 'menu.platform.subscribers', key: 'platform/subscribers' },
|
{ name: 'menu.platform.subscribers', key: 'platform/subscribers' },
|
||||||
{ name: 'menu.platform.renewals', key: 'platform/renewals' },
|
{ name: 'menu.platform.renewals', key: 'platform/renewals' },
|
||||||
@@ -127,8 +157,57 @@ export const getName = (path: string, routes) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterRoutesByRole = (
|
||||||
|
routesList: IRoute[],
|
||||||
|
roleCtx?: RoleContext
|
||||||
|
): IRoute[] => {
|
||||||
|
if (!routesList || !routesList.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const isSuper = Boolean(roleCtx?.isSuper || roleCtx?.role === 'super_admin');
|
||||||
|
const role = roleCtx?.role || (isSuper ? 'super_admin' : 'sales_rep');
|
||||||
|
|
||||||
|
const result: IRoute[] = [];
|
||||||
|
|
||||||
|
for (const route of routesList) {
|
||||||
|
// 1. 平台超级管理员专属路由隔离
|
||||||
|
if (route.superAdminOnly && !isSuper) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 租户专属路由隔离
|
||||||
|
if (route.tenantOnly && isSuper) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 角色白名单访问限制
|
||||||
|
if (route.roles && route.roles.length > 0) {
|
||||||
|
const isAllowed = isSuper || route.roles.includes(role);
|
||||||
|
if (!isAllowed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 递归处理子路由
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
const filteredChildren = filterRoutesByRole(route.children, roleCtx);
|
||||||
|
if (filteredChildren.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
...route,
|
||||||
|
children: filteredChildren,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({ ...route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export const generatePermission = (role: string) => {
|
export const generatePermission = (role: string) => {
|
||||||
const actions = role === 'admin' ? ['*'] : ['read'];
|
const actions = role === 'admin' || role === 'super_admin' ? ['*'] : ['read'];
|
||||||
const result = {};
|
const result = {};
|
||||||
routes.forEach((item) => {
|
routes.forEach((item) => {
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
@@ -140,7 +219,10 @@ export const generatePermission = (role: string) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useRoute = (userPermission): [IRoute[], string] => {
|
const useRoute = (
|
||||||
|
userPermission?: Record<string, string[]>,
|
||||||
|
roleCtx?: RoleContext
|
||||||
|
): [IRoute[], string] => {
|
||||||
const filterRoute = (routes: IRoute[], arr = []): IRoute[] => {
|
const filterRoute = (routes: IRoute[], arr = []): IRoute[] => {
|
||||||
if (!routes.length) {
|
if (!routes.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -148,7 +230,7 @@ const useRoute = (userPermission): [IRoute[], string] => {
|
|||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
const { requiredPermissions, oneOfPerm } = route;
|
const { requiredPermissions, oneOfPerm } = route;
|
||||||
let visible = true;
|
let visible = true;
|
||||||
if (requiredPermissions) {
|
if (requiredPermissions && userPermission) {
|
||||||
visible = auth({ requiredPermissions, oneOfPerm }, userPermission);
|
visible = auth({ requiredPermissions, oneOfPerm }, userPermission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +254,10 @@ const useRoute = (userPermission): [IRoute[], string] => {
|
|||||||
const [permissionRoute, setPermissionRoute] = useState(routes);
|
const [permissionRoute, setPermissionRoute] = useState(routes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newRoutes = filterRoute(routes);
|
const roleFiltered = filterRoutesByRole(routes, roleCtx);
|
||||||
|
const newRoutes = filterRoute(roleFiltered);
|
||||||
setPermissionRoute(newRoutes);
|
setPermissionRoute(newRoutes);
|
||||||
}, [JSON.stringify(userPermission)]);
|
}, [JSON.stringify(userPermission), JSON.stringify(roleCtx)]);
|
||||||
|
|
||||||
const defaultRoute = useMemo(() => {
|
const defaultRoute = useMemo(() => {
|
||||||
const first = permissionRoute[0];
|
const first = permissionRoute[0];
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export interface GlobalState {
|
|||||||
organization?: string;
|
organization?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
isSuper?: boolean;
|
||||||
|
role?: string;
|
||||||
|
tenantId?: string | number;
|
||||||
permissions: Record<string, string[]>;
|
permissions: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
userLoading?: boolean;
|
userLoading?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { routes, filterRoutesByRole } from '../src/routes';
|
||||||
|
|
||||||
|
test('1. 平台超级管理员 (Super Admin) 应拥有包含平台总控与全局财务大屏的完整菜单', () => {
|
||||||
|
const adminRoutes = filterRoutesByRole(routes, {
|
||||||
|
isSuper: true,
|
||||||
|
role: 'super_admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = adminRoutes.map((r) => r.key);
|
||||||
|
assert.ok(keys.includes('platform'), '超管必须拥有 platform 平台总控菜单');
|
||||||
|
assert.ok(keys.includes('dashboard'), '超管必须拥有 dashboard 菜单');
|
||||||
|
|
||||||
|
const dashboardRoute = adminRoutes.find((r) => r.key === 'dashboard');
|
||||||
|
const dashChildrenKeys = dashboardRoute?.children?.map((c) => c.key) || [];
|
||||||
|
assert.ok(
|
||||||
|
dashChildrenKeys.includes('dashboard/financial'),
|
||||||
|
'超管必须拥有 dashboard/financial 全局财务大屏'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. 租户企业管理员 (Tenant Owner) 必须完全物理剔除【平台总控】与【全局财务大屏】', () => {
|
||||||
|
const tenantRoutes = filterRoutesByRole(routes, {
|
||||||
|
isSuper: false,
|
||||||
|
role: 'tenant_owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = tenantRoutes.map((r) => r.key);
|
||||||
|
assert.ok(!keys.includes('platform'), '普通租户侧边栏绝对不得出现 platform 平台总控');
|
||||||
|
|
||||||
|
const dashboardRoute = tenantRoutes.find((r) => r.key === 'dashboard');
|
||||||
|
const dashChildrenKeys = dashboardRoute?.children?.map((c) => c.key) || [];
|
||||||
|
assert.ok(
|
||||||
|
!dashChildrenKeys.includes('dashboard/financial'),
|
||||||
|
'普通租户绝不得访问或看到 dashboard/financial 全局财务大屏'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 但租户必须能看到自己的名片、线索、自身订阅账单
|
||||||
|
assert.ok(keys.includes('card'), '租户必须有名片微官网菜单');
|
||||||
|
assert.ok(keys.includes('crm'), '租户必须有 CRM 菜单');
|
||||||
|
assert.ok(keys.includes('settings-biz'), '租户必须有 企业设置 菜单');
|
||||||
|
|
||||||
|
const settingsRoute = tenantRoutes.find((r) => r.key === 'settings-biz');
|
||||||
|
const settingsKeys = settingsRoute?.children?.map((c) => c.key) || [];
|
||||||
|
assert.ok(
|
||||||
|
settingsKeys.includes('settings/billing'),
|
||||||
|
'租户必须能访问自身的订阅账单 settings/billing'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. 租户一线销售员 (Sales Rep) 必须进一步限制企业高层设置与敏感人事交接', () => {
|
||||||
|
const repRoutes = filterRoutesByRole(routes, {
|
||||||
|
isSuper: false,
|
||||||
|
role: 'sales_rep',
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = repRoutes.map((r) => r.key);
|
||||||
|
assert.ok(!keys.includes('platform'), '销售员不得看到 platform');
|
||||||
|
|
||||||
|
const teamRoute = repRoutes.find((r) => r.key === 'team');
|
||||||
|
const teamKeys = teamRoute?.children?.map((c) => c.key) || [];
|
||||||
|
assert.ok(!teamKeys.includes('team/handover'), '销售员不得看到离职交接');
|
||||||
|
assert.ok(!teamKeys.includes('team/brand-kit'), '销售员不得篡改企业统一品牌规范');
|
||||||
|
|
||||||
|
const settingsRoute = repRoutes.find((r) => r.key === 'settings-biz');
|
||||||
|
const settingsKeys = settingsRoute?.children?.map((c) => c.key) || [];
|
||||||
|
assert.ok(!settingsKeys.includes('settings/tenant'), '销售员不得修改企业主域名与证书');
|
||||||
|
assert.ok(!settingsKeys.includes('settings/billing'), '销售员不得查看企业订阅账单');
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Body, Inject, Post } from '@midwayjs/core';
|
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 { DeepSeekService } from '../../service/deepseek';
|
||||||
import {
|
import {
|
||||||
DeepSeekChatCompletionRequest,
|
DeepSeekChatCompletionRequest,
|
||||||
DeepSeekConfig,
|
DeepSeekConfig,
|
||||||
} from '../../../../comm/types/omni';
|
} from '../../../../comm/types/omni';
|
||||||
|
import { assertSuperAdmin } from '../../../../comm/guard/super-admin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeepSeek 大模型后台管理与调试控制器
|
* DeepSeek 大模型后台管理与调试控制器 (支持多租户 BYOK 与 Token 计量)
|
||||||
*/
|
*/
|
||||||
@CoolController({
|
@CoolController({
|
||||||
api: [],
|
api: [],
|
||||||
@@ -20,18 +22,32 @@ import {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminDeepSeekController extends BaseController {
|
export class AdminDeepSeekController extends BaseController {
|
||||||
|
@Inject()
|
||||||
|
ctx: Context;
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
deepSeekService: DeepSeekService;
|
deepSeekService: DeepSeekService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前有效配置(脱敏 API Key)
|
* 获取当前有效配置(脱敏 API Key,支持租户 BYOK 隔离)
|
||||||
*/
|
*/
|
||||||
@Post('/config', { summary: '获取或保存 DeepSeek 配置' })
|
@Post('/config', { summary: '获取或保存 DeepSeek 配置' })
|
||||||
async config(@Body() body?: Partial<DeepSeekConfig>) {
|
async config(@Body() body?: Partial<DeepSeekConfig>) {
|
||||||
|
const tenantId = this.ctx?.admin?.tenantId;
|
||||||
|
|
||||||
if (body && Object.keys(body).length > 0) {
|
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
|
const maskedKey = current.apiKey
|
||||||
? current.apiKey.length > 8
|
? current.apiKey.length > 8
|
||||||
? `${current.apiKey.slice(0, 4)}****${current.apiKey.slice(-4)}`
|
? `${current.apiKey.slice(0, 4)}****${current.apiKey.slice(-4)}`
|
||||||
@@ -41,16 +57,51 @@ export class AdminDeepSeekController extends BaseController {
|
|||||||
return this.ok({
|
return this.ok({
|
||||||
...current,
|
...current,
|
||||||
apiKey: maskedKey,
|
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 官方端点连通性
|
* 测试与 DeepSeek 官方端点连通性
|
||||||
*/
|
*/
|
||||||
@Post('/test-connection', { summary: '测试连通性' })
|
@Post('/test-connection', { summary: '测试连通性' })
|
||||||
async testConnection(@Body('apiKey') apiKey?: string) {
|
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);
|
return this.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +110,11 @@ export class AdminDeepSeekController extends BaseController {
|
|||||||
*/
|
*/
|
||||||
@Post('/chat', { summary: '调试对话补全' })
|
@Post('/chat', { summary: '调试对话补全' })
|
||||||
async chat(@Body() body: DeepSeekChatCompletionRequest) {
|
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 });
|
return this.ok({ reply });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ export class DeepSeekService extends BaseService {
|
|||||||
this.tenantQuotas.set(tenantId, Math.max(0, Number(maxTotalTokens) || 0));
|
this.tenantQuotas.set(tenantId, Math.max(0, Number(maxTotalTokens) || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定租户的 Token 最大额度上限
|
||||||
|
*/
|
||||||
|
getTenantTokenQuota(tenantId: string | number): number {
|
||||||
|
return this.tenantQuotas.get(tenantId) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断言当前租户 Token 配额充足,超限则抛出 429 阻断
|
* 断言当前租户 Token 配额充足,超限则抛出 429 阻断
|
||||||
*/
|
*/
|
||||||
@@ -339,8 +346,12 @@ export class DeepSeekService extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* 测试与 DeepSeek 官方端点连通性
|
* 测试与 DeepSeek 官方端点连通性
|
||||||
*/
|
*/
|
||||||
async testConnection(testApiKey?: string): Promise<DeepSeekTestConnectionResult> {
|
async testConnection(
|
||||||
const key = (testApiKey || this.config.apiKey || '').trim();
|
testApiKey?: string,
|
||||||
|
tenantId?: number | string
|
||||||
|
): Promise<DeepSeekTestConnectionResult> {
|
||||||
|
const cfg = this.getTenantConfig(tenantId);
|
||||||
|
const key = (testApiKey || cfg.apiKey || '').trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -349,8 +360,8 @@ export class DeepSeekService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
const endpoint = `${cfg.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||||||
const model = this.config.model || 'deepseek-chat';
|
const model = cfg.model || 'deepseek-chat';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.httpClient.post(
|
await this.httpClient.post(
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export class BaseAuthorityMiddleware
|
|||||||
let statusCode = 200;
|
let statusCode = 200;
|
||||||
let { url } = ctx;
|
let { url } = ctx;
|
||||||
url = url.replace(this.prefix, '').split('?')[0];
|
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/';
|
const adminUrl = '/admin/';
|
||||||
// 路由地址为 admin前缀的 需要权限校验
|
// 路由地址为 admin前缀的 需要权限校验
|
||||||
if (_.startsWith(url, adminUrl)) {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,10 +150,10 @@ export function request<T>(config: RequestConfig): Promise<T> {
|
|||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 自动挂载 Authorization 与 x-tenant-id
|
// 自动挂载 Authorization 与 x-tenant-id (对齐 Cool-Admin 原生 JWT 格式)
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
headers['Authorization'] = token.startsWith('Bearer ') ? token.substring(7) : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = getTenantId();
|
const tenantId = getTenantId();
|
||||||
|
|||||||
@@ -44,10 +44,10 @@
|
|||||||
<button
|
<button
|
||||||
class="primary-btn"
|
class="primary-btn"
|
||||||
:class="{ 'primary-btn-loading': state.submitting }"
|
:class="{ 'primary-btn-loading': state.submitting }"
|
||||||
:disabled="state.submitting"
|
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="登录"
|
aria-label="登录"
|
||||||
@click="handlePrimarySubmit"
|
@click="handlePrimarySubmit"
|
||||||
|
@tap="handlePrimarySubmit"
|
||||||
>
|
>
|
||||||
<text class="primary-btn-text">{{ state.submitting ? '正在登录...' : '登录' }}</text>
|
<text class="primary-btn-text">{{ state.submitting ? '正在登录...' : '登录' }}</text>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户