feat(admin): Complete tenant management and module access control system

Major Features:
- Tenant management CRUD (list, create, edit, delete, module configuration)
- Dynamic module management system (modules table with 8 modules)
- Multi-tenant module permission merging (ModuleService)
- Module access control middleware (requireModule)
- User module permission API (GET /api/v1/auth/me/modules)
- Frontend module permission filtering (HomePage + TopNavigation)

Module Integration:
- RVW module integrated with PromptService (editorial + methodology)
- All modules (RVW/PKB/ASL/DC) added authenticate + requireModule middleware
- Fixed ReviewTask foreign key constraint (cross-schema issue)
- Removed all MOCK_USER_ID, unified to request.user?.userId

Prompt Management Enhancements:
- Module names displayed in Chinese (RVW -> 智能审稿)
- Enhanced version history with view content and rollback features
- List page shows both activeVersion and draftVersion columns

Database Changes:
- Added platform_schema.modules table
- Modified tenant_modules table (added index and UUID)
- Removed ReviewTask foreign key to public.users (cross-schema fix)
- Seeded 8 modules: RVW, PKB, ASL, DC, IIT, AIA, SSA, ST

Documentation Updates:
- Updated ADMIN module development status
- Updated TODO checklist (89% progress)
- Updated Prompt management plan (Phase 3.5.5 completed)
- Added module authentication specification

Files Changed: 80+
Status: All features tested and verified locally
Next: User management module development
This commit is contained in:
2026-01-13 07:34:30 +08:00
parent 5523ef36ea
commit d595037316
51 changed files with 3550 additions and 287 deletions

View File

@@ -233,3 +233,50 @@ export async function logout(
});
}
/**
* 获取当前用户可访问的模块
*
* GET /api/v1/auth/me/modules
*/
export async function getUserModules(
request: FastifyRequest,
reply: FastifyReply
) {
try {
if (!request.user) {
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message: '未认证',
});
}
// SUPER_ADMIN 和 PROMPT_ENGINEER 可以访问所有模块
if (request.user.role === 'SUPER_ADMIN' || request.user.role === 'PROMPT_ENGINEER') {
const { moduleService } = await import('./module.service.js');
const allModules = await moduleService.getAllModules();
return reply.status(200).send({
success: true,
data: allModules.map(m => m.code),
});
}
const { moduleService } = await import('./module.service.js');
const result = await moduleService.getUserModules(request.user.userId);
return reply.status(200).send({
success: true,
data: result.modules,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取用户模块失败';
logger.error('获取用户模块失败', { error: message, userId: request.user?.userId });
return reply.status(500).send({
success: false,
error: 'InternalServerError',
message,
});
}
}

View File

@@ -13,6 +13,7 @@ import { FastifyRequest, FastifyReply, FastifyInstance, preHandlerHookHandler }
import { jwtService } from './jwt.service.js';
import type { DecodedToken } from './jwt.service.js';
import { logger } from '../logging/index.js';
import { moduleService } from './module.service.js';
/**
* 扩展 Fastify Request 类型
@@ -224,6 +225,64 @@ export const requireSameTenant: preHandlerHookHandler = async (
}
};
/**
* 模块访问检查中间件工厂
*
* 检查用户是否有权访问指定模块
* 支持多租户模块权限合并
*
* @param moduleCode 模块代码 (RVW, PKB, ASL, DC, IIT, AIA)
*
* @example
* fastify.post('/tasks', {
* preHandler: [authenticate, requireModule('RVW')]
* }, controller.createTask);
*/
export function requireModule(moduleCode: string): preHandlerHookHandler {
return async (request: FastifyRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: '未认证',
});
}
// SUPER_ADMIN 可以访问所有模块
if (request.user.role === 'SUPER_ADMIN') {
return;
}
// PROMPT_ENGINEER 可以访问所有模块(用于调试)
if (request.user.role === 'PROMPT_ENGINEER') {
return;
}
// 检查用户是否有权访问该模块
const canAccess = await moduleService.canAccessModule(
request.user.userId,
moduleCode
);
if (!canAccess) {
logger.warn('[Auth] 模块访问被拒绝', {
userId: request.user.userId,
role: request.user.role,
moduleCode,
});
return reply.status(403).send({
error: 'Forbidden',
message: `您没有访问 ${moduleCode} 模块的权限,请联系管理员开通`,
});
}
logger.debug('[Auth] 模块访问已授权', {
userId: request.user.userId,
moduleCode,
});
};
}
/**
* 注册认证插件到 Fastify
*/

View File

@@ -5,11 +5,12 @@
*/
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import {
import {
loginWithPassword,
loginWithVerificationCode,
sendVerificationCode,
getCurrentUser,
getUserModules,
changePassword,
refreshToken,
logout,
@@ -120,6 +121,13 @@ export async function authRoutes(
preHandler: [authenticate],
}, getCurrentUser);
/**
* 获取当前用户可访问的模块
*/
fastify.get('/me/modules', {
preHandler: [authenticate],
}, getUserModules);
/**
* 修改密码
*/

View File

@@ -25,11 +25,16 @@ export {
requireRoles,
requirePermission,
requireSameTenant,
requireModule,
registerAuthPlugin,
AuthenticationError,
AuthorizationError,
} from './auth.middleware.js';
// Module Service
export { moduleService } from './module.service.js';
export type { ModuleInfo, UserModulesResult } from './module.service.js';
// Auth Routes
export { authRoutes } from './auth.routes.js';

View File

@@ -0,0 +1,273 @@
/**
* 模块权限服务
*
* 管理用户可访问的模块,支持多租户模块权限合并
*/
import { prisma } from '../../config/database.js';
import { logger } from '../logging/index.js';
/**
* 模块信息
*/
export interface ModuleInfo {
code: string;
name: string;
description?: string | null;
icon?: string | null;
}
/**
* 用户模块访问结果
*/
export interface UserModulesResult {
modules: string[]; // 可访问的模块代码列表
moduleDetails: ModuleInfo[]; // 模块详细信息
tenantModules: { // 按租户分组的模块
tenantId: string;
tenantName: string;
isPrimary: boolean;
modules: string[];
}[];
}
class ModuleService {
/**
* 获取所有可用模块
*/
async getAllModules(): Promise<ModuleInfo[]> {
const modules = await prisma.modules.findMany({
where: { is_active: true },
orderBy: { sort_order: 'asc' },
select: {
code: true,
name: true,
description: true,
icon: true,
},
});
return modules;
}
/**
* 获取用户可访问的所有模块
* 合并用户所属的所有租户的模块权限
*/
async getUserModules(userId: string): Promise<UserModulesResult> {
try {
// 1. 获取用户的主租户
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
tenant_id: true,
tenants: {
select: {
id: true,
name: true,
},
},
},
});
if (!user) {
logger.warn('[ModuleService] 用户不存在', { userId });
return { modules: [], moduleDetails: [], tenantModules: [] };
}
// 2. 获取用户加入的其他租户
const memberships = await prisma.tenant_members.findMany({
where: { user_id: userId },
select: {
tenant_id: true,
tenants: {
select: {
id: true,
name: true,
},
},
},
});
// 3. 构建租户列表(主租户 + 额外加入的租户)
const tenantMap = new Map<string, { name: string; isPrimary: boolean }>();
// 主租户
if (user.tenant_id && user.tenants) {
tenantMap.set(user.tenant_id, {
name: user.tenants.name,
isPrimary: true,
});
}
// 额外加入的租户
for (const m of memberships) {
if (!tenantMap.has(m.tenant_id)) {
tenantMap.set(m.tenant_id, {
name: m.tenants.name,
isPrimary: false,
});
}
}
const tenantIds = Array.from(tenantMap.keys());
if (tenantIds.length === 0) {
logger.warn('[ModuleService] 用户没有关联任何租户', { userId });
return { modules: [], moduleDetails: [], tenantModules: [] };
}
// 4. 查询所有租户的已开通模块
const tenantModulesData = await prisma.tenant_modules.findMany({
where: {
tenant_id: { in: tenantIds },
is_enabled: true,
OR: [
{ expires_at: null },
{ expires_at: { gt: new Date() } },
],
},
select: {
tenant_id: true,
module_code: true,
},
});
// 5. 按租户分组模块
const tenantModulesGrouped: UserModulesResult['tenantModules'] = [];
const modulesByTenant = new Map<string, string[]>();
for (const tm of tenantModulesData) {
if (!modulesByTenant.has(tm.tenant_id)) {
modulesByTenant.set(tm.tenant_id, []);
}
modulesByTenant.get(tm.tenant_id)!.push(tm.module_code);
}
Array.from(tenantMap.entries()).forEach(([tenantId, tenantInfo]) => {
tenantModulesGrouped.push({
tenantId,
tenantName: tenantInfo.name,
isPrimary: tenantInfo.isPrimary,
modules: modulesByTenant.get(tenantId) || [],
});
});
// 6. 合并所有模块(去重)
const moduleSet = new Set(tenantModulesData.map(tm => tm.module_code));
const allModuleCodes = Array.from(moduleSet);
// 7. 获取模块详细信息
const moduleDetails = await prisma.modules.findMany({
where: {
code: { in: allModuleCodes },
is_active: true,
},
orderBy: { sort_order: 'asc' },
select: {
code: true,
name: true,
description: true,
icon: true,
},
});
logger.debug('[ModuleService] 获取用户模块成功', {
userId,
tenantCount: tenantIds.length,
moduleCount: allModuleCodes.length,
});
return {
modules: allModuleCodes,
moduleDetails,
tenantModules: tenantModulesGrouped,
};
} catch (error) {
logger.error('[ModuleService] 获取用户模块失败', { userId, error });
return { modules: [], moduleDetails: [], tenantModules: [] };
}
}
/**
* 检查用户是否有权访问指定模块
*/
async canAccessModule(userId: string, moduleCode: string): Promise<boolean> {
try {
const { modules } = await this.getUserModules(userId);
return modules.includes(moduleCode);
} catch (error) {
logger.error('[ModuleService] 检查模块权限失败', { userId, moduleCode, error });
return false;
}
}
/**
* 获取租户的已开通模块
*/
async getTenantModules(tenantId: string): Promise<string[]> {
const modules = await prisma.tenant_modules.findMany({
where: {
tenant_id: tenantId,
is_enabled: true,
OR: [
{ expires_at: null },
{ expires_at: { gt: new Date() } },
],
},
select: { module_code: true },
});
return modules.map(m => m.module_code);
}
/**
* 设置租户的模块配置
*/
async setTenantModules(
tenantId: string,
moduleConfigs: { code: string; enabled: boolean; expiresAt?: Date | null }[]
): Promise<void> {
for (const config of moduleConfigs) {
// 先查询是否存在
const existing = await prisma.tenant_modules.findUnique({
where: {
tenant_id_module_code: {
tenant_id: tenantId,
module_code: config.code,
},
},
});
if (existing) {
// 更新
await prisma.tenant_modules.update({
where: { id: existing.id },
data: {
is_enabled: config.enabled,
expires_at: config.expiresAt,
},
});
} else {
// 创建(使用 crypto.randomUUID 生成 id
const { randomUUID } = await import('crypto');
await prisma.tenant_modules.create({
data: {
id: randomUUID(),
tenant_id: tenantId,
module_code: config.code,
is_enabled: config.enabled,
expires_at: config.expiresAt,
},
});
}
}
logger.info('[ModuleService] 更新租户模块配置', {
tenantId,
moduleCount: moduleConfigs.length,
});
}
}
// 导出单例
export const moduleService = new ModuleService();

View File

@@ -56,21 +56,43 @@ export async function listPrompts(
const templates = await promptService.listTemplates(module);
// 转换为 API 响应格式
const result = templates.map(t => ({
id: t.id,
code: t.code,
name: t.name,
module: t.module,
description: t.description,
variables: t.variables,
latestVersion: t.versions[0] ? {
version: t.versions[0].version,
status: t.versions[0].status,
createdAt: t.versions[0].created_at,
} : null,
updatedAt: t.updated_at,
}));
// 转换为 API 响应格式,分别返回 ACTIVE 和 DRAFT 版本
const result = templates.map(t => {
const activeVersion = t.versions.find(v => v.status === 'ACTIVE');
const draftVersion = t.versions.find(v => v.status === 'DRAFT');
// 调试日志
console.log(`[PromptController] ${t.code} 版本数量: ${t.versions.length}`);
console.log(` - ACTIVE: ${activeVersion ? 'v' + activeVersion.version : '无'}`);
console.log(` - DRAFT: ${draftVersion ? 'v' + draftVersion.version : '无'}`);
return {
id: t.id,
code: t.code,
name: t.name,
module: t.module,
description: t.description,
variables: t.variables,
activeVersion: activeVersion ? {
version: activeVersion.version,
status: activeVersion.status,
createdAt: activeVersion.created_at,
} : null,
draftVersion: draftVersion ? {
version: draftVersion.version,
status: draftVersion.status,
createdAt: draftVersion.created_at,
} : null,
latestVersion: t.versions[0] ? {
version: t.versions[0].version,
status: t.versions[0].status,
createdAt: t.versions[0].created_at,
} : null,
updatedAt: t.updated_at,
};
});
console.log('[PromptController] 返回数据示例:', JSON.stringify(result[0], null, 2));
return reply.send({
success: true,
@@ -155,7 +177,7 @@ export async function saveDraft(
try {
const { code } = request.params;
const { content, modelConfig, changelog } = request.body;
const userId = (request as any).user?.id;
const userId = (request as any).user?.userId;
const promptService = getPromptService(prisma);
@@ -202,7 +224,7 @@ export async function publishPrompt(
) {
try {
const { code } = request.params;
const userId = (request as any).user?.id;
const userId = (request as any).user?.userId;
const promptService = getPromptService(prisma);
@@ -238,7 +260,7 @@ export async function rollbackPrompt(
try {
const { code } = request.params;
const { version } = request.body;
const userId = (request as any).user?.id;
const userId = (request as any).user?.userId;
const promptService = getPromptService(prisma);
@@ -273,7 +295,7 @@ export async function setDebugMode(
) {
try {
const { modules, enabled } = request.body;
const userId = (request as any).user?.id;
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({
@@ -317,7 +339,7 @@ export async function getDebugStatus(
reply: FastifyReply
) {
try {
const userId = (request as any).user?.id;
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({
@@ -416,3 +438,4 @@ export async function invalidateCache(
}
}

View File

@@ -98,3 +98,5 @@ export function getAllFallbackCodes(): string[] {
return Object.keys(FALLBACK_PROMPTS);
}

View File

@@ -16,6 +16,7 @@ import {
testRender,
invalidateCache,
} from './prompt.controller.js';
import { authenticate, requirePermission } from '../auth/auth.middleware.js';
// Schema 定义
const listPromptsSchema = {
@@ -156,68 +157,70 @@ const testRenderSchema = {
* 注册 Prompt 管理路由
*/
export async function promptRoutes(fastify: FastifyInstance) {
// 列表
// 列表(需要认证 + prompt:view
fastify.get('/', {
schema: listPromptsSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:view')],
preHandler: [authenticate, requirePermission('prompt:view')],
handler: listPrompts,
});
// 详情
// 详情(需要认证 + prompt:view
fastify.get('/:code', {
schema: getPromptDetailSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:view')],
preHandler: [authenticate, requirePermission('prompt:view')],
handler: getPromptDetail,
});
// 调试模式 - 获取状态(需要认证)
// 注意:这个路由必须在 /:code 之前,否则会被 /:code 匹配
fastify.get('/debug', {
preHandler: [authenticate],
handler: getDebugStatus,
});
// 保存草稿(需要 prompt:edit
fastify.post('/:code/draft', {
schema: saveDraftSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
preHandler: [authenticate, requirePermission('prompt:edit')],
handler: saveDraft,
});
// 发布(需要 prompt:publish
fastify.post('/:code/publish', {
schema: publishSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:publish')],
preHandler: [authenticate, requirePermission('prompt:publish')],
handler: publishPrompt,
});
// 回滚(需要 prompt:publish
fastify.post('/:code/rollback', {
schema: rollbackSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:publish')],
preHandler: [authenticate, requirePermission('prompt:publish')],
handler: rollbackPrompt,
});
// 调试模式 - 获取状态
fastify.get('/debug', {
// preHandler: [fastify.authenticate],
handler: getDebugStatus,
});
// 调试模式 - 设置(需要 prompt:debug
fastify.post('/debug', {
schema: setDebugModeSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:debug')],
preHandler: [authenticate, requirePermission('prompt:debug')],
handler: setDebugMode,
});
// 测试渲染
// 测试渲染(需要 prompt:edit
fastify.post('/test-render', {
schema: testRenderSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
preHandler: [authenticate, requirePermission('prompt:edit')],
handler: testRender,
});
// 清除缓存
// 清除缓存(需要 prompt:edit
fastify.post('/:code/invalidate-cache', {
schema: getPromptDetailSchema,
// preHandler: [fastify.authenticate, fastify.requirePermission('prompt:edit')],
preHandler: [authenticate, requirePermission('prompt:edit')],
handler: invalidateCache,
});
}
export default promptRoutes;

View File

@@ -376,7 +376,7 @@ export class PromptService {
include: {
versions: {
orderBy: { version: 'desc' },
take: 1,
// 返回所有版本,让 controller 自己过滤 ACTIVE 和 DRAFT
},
},
orderBy: { code: 'asc' },

View File

@@ -67,3 +67,5 @@ export interface VariableValidation {
extraVariables: string[];
}