From 097e7920ab2715c9f95596c784d1dc936a74a327 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Tue, 10 Mar 2026 09:02:35 +0800 Subject: [PATCH] feat(admin): add user-level direct permission system and enhance activity tracking Features: - Add user_permissions table for direct user-to-permission grants (ops:user-ops) - Merge role_permissions + user_permissions in auth chain (login, middleware, getCurrentUser) - Add getUserQueryScope support for USER role with ops:user-ops (cross-tenant access) - Unify cross-tenant operation checks via getUserQueryScope (remove hardcoded SUPER_ADMIN checks) - Add 3 new API endpoints: GET/PUT /:id/permissions, GET /options/permissions - Support ops:user-ops as alternative permission on all user/tenant management routes - Frontend: add user-ops permission toggle on UserFormPage and UserDetailPage - Enhance DC module activity tracking (StreamAIController, SessionController, QuickActionController) - Fix DC AIController user ID extraction and feature name consistency - Add verify-activity-tracking.ts validation script - Update deployment checklist and admin module documentation DB Migration: 20260309_add_user_permissions_table Made-with: Cursor --- backend/package.json | 1 + .../migration.sql | 21 +++ backend/prisma/schema.prisma | 16 ++ backend/scripts/verify-activity-tracking.ts | 137 ++++++++++++++++++ backend/src/common/auth/auth.middleware.ts | 76 ++++++---- backend/src/common/auth/auth.service.ts | 30 ++-- .../admin/controllers/userController.ts | 99 ++++++++++++- .../src/modules/admin/routes/tenantRoutes.ts | 10 +- .../src/modules/admin/routes/userRoutes.ts | 35 ++++- .../src/modules/admin/services/userService.ts | 91 +++++++++++- .../dc/tool-c/controllers/AIController.ts | 10 +- .../controllers/QuickActionController.ts | 18 +++ .../tool-c/controllers/SessionController.ts | 18 +++ .../tool-c/controllers/StreamAIController.ts | 35 +++++ .../ADMIN-运营管理端/00-模块当前状态与开发指南.md | 65 +++++++-- docs/05-部署文档/03-待部署变更清单.md | 21 ++- frontend-v2/src/modules/admin/api/userApi.ts | 23 +++ .../modules/admin/pages/UserDetailPage.tsx | 39 ++++- .../src/modules/admin/pages/UserFormPage.tsx | 35 ++++- 19 files changed, 693 insertions(+), 87 deletions(-) create mode 100644 backend/prisma/migrations/20260309_add_user_permissions_table/migration.sql create mode 100644 backend/scripts/verify-activity-tracking.ts diff --git a/backend/package.json b/backend/package.json index f67bd09e..273a159b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "prisma:studio": "prisma studio", "prisma:seed": "tsx prisma/seed.ts", "test:sms": "tsx scripts/test-aliyun-sms.ts", + "test:tracking": "tsx scripts/verify-activity-tracking.ts", "iit:equery:dedupe": "tsx scripts/dedupe_open_equeries.ts", "iit:equery:dedupe:apply": "tsx scripts/dedupe_open_equeries.ts --apply", "iit:guard:check": "tsx scripts/validate_guard_types_for_project.ts", diff --git a/backend/prisma/migrations/20260309_add_user_permissions_table/migration.sql b/backend/prisma/migrations/20260309_add_user_permissions_table/migration.sql new file mode 100644 index 00000000..57f1ed22 --- /dev/null +++ b/backend/prisma/migrations/20260309_add_user_permissions_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable: 用户直授权限表(不依赖角色,单独给用户授予权限,如 ops:user-ops) +CREATE TABLE "platform_schema"."user_permissions" ( + "id" SERIAL NOT NULL, + "user_id" TEXT NOT NULL, + "permission_id" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_permissions_user_id_idx" ON "platform_schema"."user_permissions"("user_id"); + +-- CreateIndex: 唯一约束,同一用户不重复授同一权限 +CREATE UNIQUE INDEX "user_permissions_user_id_permission_id_key" ON "platform_schema"."user_permissions"("user_id", "permission_id"); + +-- AddForeignKey +ALTER TABLE "platform_schema"."user_permissions" ADD CONSTRAINT "user_permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "platform_schema"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "platform_schema"."user_permissions" ADD CONSTRAINT "user_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "platform_schema"."permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c1f4d73f..f855f73a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") tenant_members tenant_members[] user_modules user_modules[] + user_permissions user_permissions[] iitUserMappings IitUserMapping[] departments departments? @relation(fields: [department_id], references: [id]) tenants tenants @relation(fields: [tenant_id], references: [id]) @@ -1775,6 +1776,7 @@ model permissions { module String? created_at DateTime @default(now()) role_permissions role_permissions[] + user_permissions user_permissions[] @@schema("platform_schema") } @@ -1790,6 +1792,20 @@ model role_permissions { @@schema("platform_schema") } +/// 用户直授权限表(不依赖角色,单独给用户授予权限) +model user_permissions { + id Int @id @default(autoincrement()) + user_id String + permission_id Int + created_at DateTime @default(now()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + permissions permissions @relation(fields: [permission_id], references: [id], onDelete: Cascade) + + @@unique([user_id, permission_id]) + @@index([user_id]) + @@schema("platform_schema") +} + model tenant_members { id String @id tenant_id String diff --git a/backend/scripts/verify-activity-tracking.ts b/backend/scripts/verify-activity-tracking.ts new file mode 100644 index 00000000..da4e7b43 --- /dev/null +++ b/backend/scripts/verify-activity-tracking.ts @@ -0,0 +1,137 @@ +/** + * 埋点验证脚本 + * + * 检查 simple_logs 表中是否存在各关键埋点, + * 并汇总每个模块/功能的记录数量。 + * + * 用法: npx tsx scripts/verify-activity-tracking.ts [--days N] + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface TrackingPoint { + module: string; + feature: string; + label: string; +} + +const EXPECTED_TRACKING_POINTS: TrackingPoint[] = [ + // 系统级 + { module: 'SYSTEM', feature: '用户登录', label: '用户登录' }, + { module: 'SYSTEM', feature: '顶部导航点击', label: '顶部导航点击' }, + // ASL + { module: 'ASL', feature: '意图识别', label: 'ASL 意图识别(需求扩写)' }, + { module: 'ASL', feature: 'Deep Research', label: 'ASL 启动 Deep Research' }, + // AIA (各智能体名称作为 feature) + { module: 'AIA', feature: '科学问题梳理', label: 'AIA 智能体 - 科学问题梳理(示例)' }, + // PKB + { module: 'PKB', feature: '创建知识库', label: 'PKB 创建知识库' }, + // DC + { module: 'DC', feature: '智能数据清洗', label: 'DC 智能数据清洗' }, + // IIT/CRA + { module: 'IIT', feature: 'CRA质控', label: 'IIT CRA质控' }, + // RVW + { module: 'RVW', feature: '稿', label: 'RVW 稿件审查相关' }, +]; + +async function main() { + const daysArg = process.argv.findIndex(a => a === '--days'); + const days = daysArg >= 0 ? parseInt(process.argv[daysArg + 1], 10) || 7 : 7; + + const since = new Date(); + since.setDate(since.getDate() - days); + + console.log(`\n📊 埋点验证报告 (最近 ${days} 天,since ${since.toISOString().slice(0, 10)})\n`); + console.log('='.repeat(80)); + + // 1. 总记录数 + const totalCount = await prisma.simple_logs.count({ + where: { created_at: { gte: since } }, + }); + console.log(`\n📈 总记录数: ${totalCount}\n`); + + // 2. 按模块统计 + const moduleStats = await prisma.$queryRaw` + SELECT module, COUNT(*) as count + FROM admin_schema.simple_logs + WHERE created_at >= ${since} + GROUP BY module + ORDER BY count DESC + ` as Array<{ module: string; count: bigint }>; + + console.log('📦 模块统计:'); + console.log('-'.repeat(40)); + for (const row of moduleStats) { + console.log(` ${row.module.padEnd(12)} ${Number(row.count).toString().padStart(6)} 条`); + } + + // 3. 检查每个关键埋点是否存在 + console.log('\n🔍 关键埋点覆盖检查:'); + console.log('-'.repeat(80)); + + let coveredCount = 0; + let missingCount = 0; + + for (const tp of EXPECTED_TRACKING_POINTS) { + const count = await prisma.simple_logs.count({ + where: { + module: tp.module, + feature: { contains: tp.feature }, + created_at: { gte: since }, + }, + }); + + const status = count > 0 ? '✅' : '❌'; + if (count > 0) coveredCount++; + else missingCount++; + + console.log(` ${status} ${tp.label.padEnd(35)} ${count > 0 ? `${count} 条` : '缺失'}`); + } + + console.log('\n' + '='.repeat(80)); + console.log(`\n📋 结果: ${coveredCount}/${EXPECTED_TRACKING_POINTS.length} 已覆盖, ${missingCount} 缺失\n`); + + // 4. 按 feature 统计 Top 20 + console.log('🏆 Top 20 Feature (按记录数):'); + console.log('-'.repeat(80)); + const topFeatures = await prisma.$queryRaw` + SELECT module, feature, action, COUNT(*) as count + FROM admin_schema.simple_logs + WHERE created_at >= ${since} + GROUP BY module, feature, action + ORDER BY count DESC + LIMIT 20 + ` as Array<{ module: string; feature: string; action: string; count: bigint }>; + + for (const row of topFeatures) { + console.log(` ${row.module.padEnd(10)} ${row.feature.padEnd(25)} ${row.action.padEnd(10)} ${Number(row.count).toString().padStart(6)} 条`); + } + + // 5. DAU/MAU + const dauResult = await prisma.$queryRaw` + SELECT COUNT(DISTINCT user_id) as dau + FROM admin_schema.simple_logs + WHERE created_at >= CURRENT_DATE + ` as Array<{ dau: bigint }>; + + const mauResult = await prisma.$queryRaw` + SELECT COUNT(DISTINCT user_id) as mau + FROM admin_schema.simple_logs + WHERE created_at >= CURRENT_DATE - INTERVAL '30 days' + ` as Array<{ mau: bigint }>; + + console.log(`\n👤 DAU (今日活跃用户): ${Number(dauResult[0]?.dau || 0)}`); + console.log(`👥 MAU (30日活跃用户): ${Number(mauResult[0]?.mau || 0)}`); + + console.log('\n✅ 验证完成\n'); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error('❌ 验证失败:', e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/common/auth/auth.middleware.ts b/backend/src/common/auth/auth.middleware.ts index 3257601e..134d1f28 100644 --- a/backend/src/common/auth/auth.middleware.ts +++ b/backend/src/common/auth/auth.middleware.ts @@ -187,27 +187,35 @@ export function requirePermission(requiredPermission: string): preHandlerHookHan return; } - const allowed = await prisma.role_permissions.findFirst({ + // 1. 检查角色权限 + const roleAllowed = await prisma.role_permissions.findFirst({ where: { role: request.user.role as any, - permissions: { - code: requiredPermission, - }, + permissions: { code: requiredPermission }, }, select: { permission_id: true }, }); + if (roleAllowed) return; - if (!allowed) { - logger.warn('权限不足', { - userId: request.user.userId, - role: request.user.role, - requiredPermission, - }); - return reply.status(403).send({ - error: 'Forbidden', - message: `需要权限: ${requiredPermission}`, - }); - } + // 2. 检查用户直授权限 + const userAllowed = await prisma.user_permissions.findFirst({ + where: { + user_id: request.user.userId, + permissions: { code: requiredPermission }, + }, + select: { permission_id: true }, + }); + if (userAllowed) return; + + logger.warn('权限不足', { + userId: request.user.userId, + role: request.user.role, + requiredPermission, + }); + return reply.status(403).send({ + error: 'Forbidden', + message: `需要权限: ${requiredPermission}`, + }); }; } @@ -229,27 +237,35 @@ export function requireAnyPermission(...requiredPermissions: string[]): preHandl return; } - const allowed = await prisma.role_permissions.findFirst({ + // 1. 检查角色权限 + const roleAllowed = await prisma.role_permissions.findFirst({ where: { role: request.user.role as any, - permissions: { - code: { in: requiredPermissions }, - }, + permissions: { code: { in: requiredPermissions } }, }, select: { permission_id: true }, }); + if (roleAllowed) return; - if (!allowed) { - logger.warn('权限不足(任一权限检查失败)', { - userId: request.user.userId, - role: request.user.role, - requiredPermissions, - }); - return reply.status(403).send({ - error: 'Forbidden', - message: `需要权限之一: ${requiredPermissions.join(', ')}`, - }); - } + // 2. 检查用户直授权限 + const userAllowed = await prisma.user_permissions.findFirst({ + where: { + user_id: request.user.userId, + permissions: { code: { in: requiredPermissions } }, + }, + select: { permission_id: true }, + }); + if (userAllowed) return; + + logger.warn('权限不足(任一权限检查失败)', { + userId: request.user.userId, + role: request.user.role, + requiredPermissions, + }); + return reply.status(403).send({ + error: 'Forbidden', + message: `需要权限之一: ${requiredPermissions.join(', ')}`, + }); }; } diff --git a/backend/src/common/auth/auth.service.ts b/backend/src/common/auth/auth.service.ts index 8bfc2af3..db453387 100644 --- a/backend/src/common/auth/auth.service.ts +++ b/backend/src/common/auth/auth.service.ts @@ -112,8 +112,8 @@ export class AuthService { throw new Error('账号已被禁用,请联系管理员'); } - // 4. 获取用户权限和模块列表 - const permissions = await this.getUserPermissions(user.role); + // 4. 获取用户权限(角色权限 + 用户直授权限合并)和模块列表 + const permissions = await this.getUserPermissions(user.role, user.id); const modules = await this.getUserModules(user.id); // 4.5 原子递增 token 版本号(数据库强一致,避免并发登录竞态) @@ -163,7 +163,7 @@ export class AuthService { departmentName: user.departments?.name, isDefaultPassword: user.is_default_password, permissions, - modules, // 新增:返回模块列表 + modules, }, tokens, }; @@ -218,8 +218,8 @@ export class AuthService { throw new Error('账号已被禁用,请联系管理员'); } - // 5. 获取用户权限和模块列表 - const permissions = await this.getUserPermissions(user.role); + // 5. 获取用户权限(角色权限 + 用户直授权限合并)和模块列表 + const permissions = await this.getUserPermissions(user.role, user.id); const modules = await this.getUserModules(user.id); // 5.5 原子递增 token 版本号(数据库强一致,避免并发登录竞态) @@ -290,7 +290,7 @@ export class AuthService { throw new Error('用户不存在'); } - const permissions = await this.getUserPermissions(user.role); + const permissions = await this.getUserPermissions(user.role, userId); const modules = await this.getUserModules(userId); return { @@ -306,7 +306,7 @@ export class AuthService { departmentName: user.departments?.name, isDefaultPassword: user.is_default_password, permissions, - modules, // 新增:返回模块列表 + modules, // 2026-01-28: 个人中心扩展字段 avatarUrl: user.avatarUrl, status: user.status, @@ -471,13 +471,25 @@ export class AuthService { /** * 获取用户权限列表 */ - private async getUserPermissions(role: string): Promise { + private async getUserPermissions(role: string, userId?: string): Promise { const rolePermissions = await prisma.role_permissions.findMany({ where: { role: role as any }, include: { permissions: true }, }); - return rolePermissions.map(rp => rp.permissions.code); + const merged = new Set(rolePermissions.map(rp => rp.permissions.code)); + + if (userId) { + const directPermissions = await prisma.user_permissions.findMany({ + where: { user_id: userId }, + include: { permissions: true }, + }); + for (const dp of directPermissions) { + merged.add(dp.permissions.code); + } + } + + return Array.from(merged).sort(); } /** diff --git a/backend/src/modules/admin/controllers/userController.ts b/backend/src/modules/admin/controllers/userController.ts index 8083cd5a..7443cfe5 100644 --- a/backend/src/modules/admin/controllers/userController.ts +++ b/backend/src/modules/admin/controllers/userController.ts @@ -84,9 +84,11 @@ export async function createUser( try { const creator = request.user!; - // HOSPITAL_ADMIN 和 PHARMA_ADMIN 只能在自己的租户内创建用户 + // 跨租户创建检查:SUPER_ADMIN 和持有 ops:user-ops 的用户可跨租户 + const scope = await userService.getUserQueryScope(creator.role, creator.tenantId, creator.userId); + const canCrossTenant = Object.keys(scope).length === 0; if ( - (creator.role === 'HOSPITAL_ADMIN' || creator.role === 'PHARMA_ADMIN') && + !canCrossTenant && request.body.tenantId !== creator.tenantId ) { return reply.status(403).send({ @@ -360,10 +362,11 @@ export async function updateUserModules( try { const updater = request.user!; - // SUPER_ADMIN 可以操作任意租户 - // HOSPITAL_ADMIN/PHARMA_ADMIN 只能操作自己租户的用户 + // 跨租户操作检查:SUPER_ADMIN 和持有 ops:user-ops 的用户可操作任意租户 + const scope = await userService.getUserQueryScope(updater.role, updater.tenantId, updater.userId); + const canCrossTenant = Object.keys(scope).length === 0; // 空 scope = 无限制 if ( - updater.role !== 'SUPER_ADMIN' && + !canCrossTenant && request.body.tenantId !== updater.tenantId ) { return reply.status(403).send({ @@ -463,6 +466,92 @@ export async function importUsers( } } +/** + * 更新用户直授权限 + * PUT /api/admin/users/:id/permissions + */ +export async function updateUserPermissions( + request: FastifyRequest<{ + Params: { id: string }; + Body: { permissions: string[] }; + }>, + reply: FastifyReply +) { + try { + const updater = request.user!; + const result = await userService.updateUserDirectPermissions( + request.params.id, + request.body.permissions, + updater.userId + ); + + return reply.send({ + code: 0, + message: '用户权限更新成功', + data: { permissions: result }, + }); + } catch (error: any) { + logger.error('[UserController] updateUserPermissions error:', error); + + if (error.message.includes('不存在')) { + return reply.status(404).send({ code: 404, message: error.message }); + } + + return reply.status(500).send({ + code: 500, + message: error.message || '更新权限失败', + }); + } +} + +/** + * 获取用户直授权限 + * GET /api/admin/users/:id/permissions + */ +export async function getUserPermissions( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +) { + try { + const directPerms = await userService.getUserDirectPermissions(request.params.id); + return reply.send({ + code: 0, + message: 'success', + data: { permissions: directPerms }, + }); + } catch (error: any) { + logger.error('[UserController] getUserPermissions error:', error); + return reply.status(500).send({ + code: 500, + message: error.message || '获取权限失败', + }); + } +} + +/** + * 获取所有可直授权限列表 + * GET /api/admin/users/options/permissions + */ +export async function getPermissionOptions( + _request: FastifyRequest, + reply: FastifyReply +) { + try { + const permissions = await userService.getAllDirectGrantablePermissions(); + return reply.send({ + code: 0, + message: 'success', + data: permissions, + }); + } catch (error: any) { + logger.error('[UserController] getPermissionOptions error:', error); + return reply.status(500).send({ + code: 500, + message: error.message || '获取权限列表失败', + }); + } +} + /** * 获取所有租户列表(用于下拉选择) */ diff --git a/backend/src/modules/admin/routes/tenantRoutes.ts b/backend/src/modules/admin/routes/tenantRoutes.ts index 9721ada9..e7637cea 100644 --- a/backend/src/modules/admin/routes/tenantRoutes.ts +++ b/backend/src/modules/admin/routes/tenantRoutes.ts @@ -28,25 +28,25 @@ export async function tenantRoutes(fastify: FastifyInstance) { // 创建租户 // POST /api/admin/tenants fastify.post('/', { - preHandler: [authenticate, requirePermission('tenant:create')], + preHandler: [authenticate, requireAnyPermission('tenant:create', 'ops:user-ops')], handler: tenantController.createTenant, }); // 更新租户信息 // PUT /api/admin/tenants/:id fastify.put('/:id', { - preHandler: [authenticate, requirePermission('tenant:edit')], + preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')], handler: tenantController.updateTenant, }); // 更新租户状态 // PUT /api/admin/tenants/:id/status fastify.put('/:id/status', { - preHandler: [authenticate, requirePermission('tenant:edit')], + preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')], handler: tenantController.updateTenantStatus, }); - // 删除租户 + // 删除租户(仅保留严格权限,运营人员不应删除租户) // DELETE /api/admin/tenants/:id fastify.delete('/:id', { preHandler: [authenticate, requirePermission('tenant:delete')], @@ -56,7 +56,7 @@ export async function tenantRoutes(fastify: FastifyInstance) { // 配置租户模块 // PUT /api/admin/tenants/:id/modules fastify.put('/:id/modules', { - preHandler: [authenticate, requirePermission('tenant:edit')], + preHandler: [authenticate, requireAnyPermission('tenant:edit', 'ops:user-ops')], handler: tenantController.configureModules, }); } diff --git a/backend/src/modules/admin/routes/userRoutes.ts b/backend/src/modules/admin/routes/userRoutes.ts index 2a6f9a90..ebd42ccc 100644 --- a/backend/src/modules/admin/routes/userRoutes.ts +++ b/backend/src/modules/admin/routes/userRoutes.ts @@ -31,28 +31,28 @@ export async function userRoutes(fastify: FastifyInstance) { // 创建用户 // POST /api/admin/users fastify.post('/', { - preHandler: [authenticate, requirePermission('user:create')], + preHandler: [authenticate, requireAnyPermission('user:create', 'ops:user-ops')], handler: userController.createUser, }); // 更新用户 // PUT /api/admin/users/:id fastify.put('/:id', { - preHandler: [authenticate, requirePermission('user:edit')], + preHandler: [authenticate, requireAnyPermission('user:edit', 'ops:user-ops')], handler: userController.updateUser, }); // 更新用户状态(启用/禁用) // PUT /api/admin/users/:id/status fastify.put('/:id/status', { - preHandler: [authenticate, requirePermission('user:edit')], + preHandler: [authenticate, requireAnyPermission('user:edit', 'ops:user-ops')], handler: userController.updateUserStatus, }); // 重置用户密码 // POST /api/admin/users/:id/reset-password fastify.post('/:id/reset-password', { - preHandler: [authenticate, requirePermission('user:edit')], + preHandler: [authenticate, requireAnyPermission('user:edit', 'ops:user-ops')], handler: userController.resetUserPassword, }); @@ -77,7 +77,7 @@ export async function userRoutes(fastify: FastifyInstance) { // 更新用户在指定租户的模块权限 // PUT /api/admin/users/:id/modules fastify.put('/:id/modules', { - preHandler: [authenticate, requirePermission('user:edit')], + preHandler: [authenticate, requireAnyPermission('user:edit', 'ops:user-ops')], handler: userController.updateUserModules, }); @@ -86,12 +86,35 @@ export async function userRoutes(fastify: FastifyInstance) { // 批量导入用户 // POST /api/admin/users/import fastify.post('/import', { - preHandler: [authenticate, requirePermission('user:create')], + preHandler: [authenticate, requireAnyPermission('user:create', 'ops:user-ops')], handler: userController.importUsers, }); + // ==================== 直授权限管理 ==================== + + // 获取用户直授权限 + // GET /api/admin/users/:id/permissions + fastify.get('/:id/permissions', { + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], + handler: userController.getUserPermissions, + }); + + // 更新用户直授权限 + // PUT /api/admin/users/:id/permissions + fastify.put('/:id/permissions', { + preHandler: [authenticate, requireAnyPermission('user:edit', 'ops:user-ops')], + handler: userController.updateUserPermissions, + }); + // ==================== 辅助接口 ==================== + // 获取所有可直授权限列表 + // GET /api/admin/users/options/permissions + fastify.get('/options/permissions', { + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], + handler: userController.getPermissionOptions, + }); + // 获取所有租户列表(用于下拉选择) // GET /api/admin/users/options/tenants fastify.get('/options/tenants', { diff --git a/backend/src/modules/admin/services/userService.ts b/backend/src/modules/admin/services/userService.ts index 6ab15ada..2ddc8ef7 100644 --- a/backend/src/modules/admin/services/userService.ts +++ b/backend/src/modules/admin/services/userService.ts @@ -60,6 +60,19 @@ export async function getUserQueryScope( const departmentId = userId ? await getUserDepartmentId(userId) : undefined; return { tenantId, departmentId }; } + case 'USER': { + // USER 角色持有 ops:user-ops 权限时可访问管理端 + // 检查是否有直授权限(有则给予跨租户查看能力,否则限制本租户) + if (userId) { + const directPerms = await prisma.user_permissions.findMany({ + where: { user_id: userId }, + include: { permissions: { select: { code: true } } }, + }); + const hasOps = directPerms.some(dp => dp.permissions.code === 'ops:user-ops'); + if (hasOps) return {}; // 运营人员可查看全部用户 + } + return { tenantId }; // 无 ops 权限则限制本租户 + } default: throw new Error('无权限访问用户管理'); } @@ -280,12 +293,20 @@ export async function getUserById(userId: string, scope: UserQueryScope): Promis }) ); - // 获取用户权限(基于角色) + // 获取用户权限(角色权限 + 用户直授权限合并) const rolePermissions = await prisma.role_permissions.findMany({ where: { role: user.role }, include: { permissions: true }, }); - const permissions = rolePermissions.map((rp) => rp.permissions.code); + const directPermissions = await prisma.user_permissions.findMany({ + where: { user_id: user.id }, + include: { permissions: true }, + }); + const permSet = new Set([ + ...rolePermissions.map((rp) => rp.permissions.code), + ...directPermissions.map((dp) => dp.permissions.code), + ]); + const permissions = Array.from(permSet).sort(); return { id: user.id, @@ -885,6 +906,72 @@ export async function getModulesByTenant(tenantId: string) { })); } +/** + * 更新用户直授权限(不依赖角色,直接给用户授予/移除权限) + */ +export async function updateUserDirectPermissions( + userId: string, + permissionCodes: string[], + updaterId: string +): Promise { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new Error('用户不存在'); + } + + // 查询权限 ID + const perms = await prisma.permissions.findMany({ + where: { code: { in: permissionCodes } }, + }); + const validCodes = perms.map((p) => p.code); + const invalidCodes = permissionCodes.filter((c) => !validCodes.includes(c)); + if (invalidCodes.length > 0) { + throw new Error(`以下权限代码不存在: ${invalidCodes.join(', ')}`); + } + + await prisma.$transaction(async (tx) => { + await tx.user_permissions.deleteMany({ where: { user_id: userId } }); + + if (perms.length > 0) { + await tx.user_permissions.createMany({ + data: perms.map((p) => ({ + user_id: userId, + permission_id: p.id, + })), + }); + } + }); + + logger.info('[UserService] User direct permissions updated', { + userId, + permissionCodes, + updatedBy: updaterId, + }); + + return validCodes; +} + +/** + * 获取用户直授权限列表(不含角色权限) + */ +export async function getUserDirectPermissions(userId: string): Promise { + const directPerms = await prisma.user_permissions.findMany({ + where: { user_id: userId }, + include: { permissions: true }, + }); + return directPerms.map((dp) => dp.permissions.code); +} + +/** + * 获取系统所有可直授的权限列表 + */ +export async function getAllDirectGrantablePermissions(): Promise> { + return prisma.permissions.findMany({ + select: { code: true, name: true, description: true }, + orderBy: { code: 'asc' }, + }); +} + // ============ 辅助函数 ============ function getModuleName(code: string): string { diff --git a/backend/src/modules/dc/tool-c/controllers/AIController.ts b/backend/src/modules/dc/tool-c/controllers/AIController.ts index c65e7bce..99cf7536 100644 --- a/backend/src/modules/dc/tool-c/controllers/AIController.ts +++ b/backend/src/modules/dc/tool-c/controllers/AIController.ts @@ -180,7 +180,7 @@ export class AIController { activityService.log( user.tenantId, user.tenantName || null, - user.id || user.userId, + user.userId || user.id, user.name || null, 'DC', '智能数据清洗', @@ -208,11 +208,11 @@ export class AIController { activityService.log( user.tenantId, user.tenantName || null, - user.id, - user.name, + user.userId || user.id, + user.name || null, 'DC', - 'Tool C AI代码', - 'USE', + '智能数据清洗', + 'COMPLETE', `session:${sessionId}, retries:${result.retryCount}` ); } diff --git a/backend/src/modules/dc/tool-c/controllers/QuickActionController.ts b/backend/src/modules/dc/tool-c/controllers/QuickActionController.ts index 50bd2c7b..8f89a333 100644 --- a/backend/src/modules/dc/tool-c/controllers/QuickActionController.ts +++ b/backend/src/modules/dc/tool-c/controllers/QuickActionController.ts @@ -20,6 +20,7 @@ import { sessionService } from '../services/SessionService.js'; // @ts-ignore - uuid 类型定义 import { v4 as uuidv4 } from 'uuid'; import { prisma } from '../../../../config/database.js'; +import { activityService } from '../../../../common/services/activity.service.js'; /** * 获取用户ID(从JWT Token中获取) @@ -71,6 +72,23 @@ export class QuickActionController { const userId = getUserId(request); logger.info(`[QuickAction] 执行快速操作: action=${action}, sessionId=${sessionId}`); + + // 埋点:DC 快速操作 + try { + const user = (request as any).user; + if (user) { + activityService.log( + user.tenantId, + user.tenantName || null, + user.userId || user.id, + user.name || null, + 'DC', + '智能数据清洗', + 'USE', + `quick-action:${action}, session:${sessionId}`, + ); + } + } catch { /* 埋点失败不影响主业务 */ } // 1. 验证参数 if (!sessionId || !action || !params) { diff --git a/backend/src/modules/dc/tool-c/controllers/SessionController.ts b/backend/src/modules/dc/tool-c/controllers/SessionController.ts index b6af97a7..b4adafeb 100644 --- a/backend/src/modules/dc/tool-c/controllers/SessionController.ts +++ b/backend/src/modules/dc/tool-c/controllers/SessionController.ts @@ -19,6 +19,7 @@ import { sessionService } from '../services/SessionService.js'; import { dataProcessService } from '../services/DataProcessService.js'; import { jobQueue } from '../../../../common/jobs/index.js'; import * as xlsx from 'xlsx'; +import { activityService } from '../../../../common/services/activity.service.js'; /** * 获取用户ID(从JWT Token中获取) @@ -92,6 +93,23 @@ export class SessionController { logger.info(`[SessionController] Session创建成功: ${sessionResult.id}, jobId: ${sessionResult.jobId}`); + // 埋点:DC 上传数据文件 + try { + const user = (request as any).user; + if (user) { + activityService.log( + user.tenantId, + user.tenantName || null, + user.userId || user.id, + user.name || null, + 'DC', + '智能数据清洗', + 'USE', + `upload:${fileName}, session:${sessionResult.id}`, + ); + } + } catch { /* 埋点失败不影响主业务 */ } + // 6. 返回Session信息 + jobId(用于前端轮询) return reply.code(201).send({ success: true, diff --git a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts index c132cd24..2e73c7b6 100644 --- a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts +++ b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts @@ -16,6 +16,7 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { logger } from '../../../../common/logging/index.js'; import { aiCodeService } from '../services/AICodeService.js'; import { sessionService } from '../services/SessionService.js'; +import { activityService } from '../../../../common/services/activity.service.js'; // ==================== 类型定义 ==================== @@ -49,6 +50,23 @@ export class StreamAIController { const { sessionId, message, maxRetries = 3 } = request.body as StreamProcessBody; logger.info(`[StreamAI] 收到流式处理请求: sessionId=${sessionId}`); + + // 埋点:记录 DC 智能数据清洗启动 + try { + const user = (request as any).user; + if (user) { + activityService.log( + user.tenantId, + user.tenantName || null, + user.userId || user.id, + user.name || null, + 'DC', + '智能数据清洗', + 'START', + `session:${sessionId}`, + ); + } + } catch { /* 埋点失败不影响主业务 */ } // 参数验证 if (!sessionId || !message) { @@ -151,6 +169,23 @@ export class StreamAIController { retryCount: attempt, }); + // 埋点:记录 DC 智能数据清洗完成 + try { + const user = (request as any).user; + if (user) { + activityService.log( + user.tenantId, + user.tenantName || null, + user.userId || user.id, + user.name || null, + 'DC', + '智能数据清洗', + 'COMPLETE', + `session:${sessionId}, retries:${attempt}`, + ); + } + } catch { /* 埋点失败不影响主业务 */ } + // 发送结束标记 reply.raw.write('data: [DONE]\n\n'); reply.raw.end(); diff --git a/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md b/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md index 95fd702e..c5f15f97 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md @@ -1,8 +1,8 @@ # ADMIN-运营管理端 - 模块当前状态与开发指南 -> **最后更新:** 2026-01-28 -> **状态:** ✅ Phase 4.6 Prompt 知识库集成功能完成! -> **版本:** v0.8 (Alpha) +> **最后更新:** 2026-03-10 +> **状态:** ✅ Phase 5.1 用户运营权限体系 + 运营埋点增强完成! +> **版本:** v1.0 (Beta) --- @@ -135,12 +135,40 @@ - [x] 修复:发布按钮 400 错误 - [x] 修复:发布后缓存未清除问题 +**Phase 5.0:运营埋点增强** ✅ 已完成(2026-03-10)🎉 +- [x] 后端:ActivityService 增强(DAU/MAU/API Token/最活跃用户/模块统计) +- [x] 后端:运营看板 API(`getTodayOverview` 新增 MAU、apiTokenTotal、topActiveUser) +- [x] 后端:分页查询运营日志 API(`getActivityLogs` 支持日期/模块/动作/关键词筛选) +- [x] 后端:用户360画像 API(`getUserOverview` 资产+行为概览) +- [x] 后端:运营埋点全模块覆盖(9 大埋点全部到位) + - SYSTEM(登录、顶部导航点击) + - ASL(意图识别、启动 Deep Research) + - AIA(10 个智能体对话使用) + - PKB(创建知识库) + - DC(上传数据文件、AI 流式清洗、快速操作) + - RVW(稿件审查) + - IIT(CRA 质控启动/完成) +- [x] 前端:顶部导航点击埋点上报(fire-and-forget) +- [x] 前端:运营看板展示 MAU/Token/最活跃用户(4 个统计卡片) +- [x] 工具:埋点验证脚本 `npm run test:tracking`(9/9 覆盖) + +**Phase 5.1:用户直授权限体系** ✅ 已完成(2026-03-10)🎉 +- [x] 数据库:新增 `user_permissions` 用户直授权限表(`platform_schema`) +- [x] 数据库:Seed `ops:user-ops` 权限记录 +- [x] 后端:认证链路合并 role_permissions + user_permissions(`auth.service.ts`、`auth.middleware.ts`) +- [x] 后端:`getUserQueryScope` 支持 USER 角色 + `ops:user-ops`(跨租户查看能力) +- [x] 后端:用户管理 API 新增直授权限读写接口(`GET/PUT /:id/permissions`、`GET /options/permissions`) +- [x] 后端:所有用户管理/租户管理 API 路由支持 `ops:user-ops` 替代权限 +- [x] 后端:跨租户操作检查统一收敛到 `getUserQueryScope`(消除硬编码角色白名单) +- [x] 前端:用户创建/编辑页新增"用户运营权限"开关(Switch 组件) +- [x] 前端:用户详情页支持查看和切换运营权限 +- [x] 前端:AdminLayout/TopNavigation 支持 `ops:user-ops` 入口 + ### ⏳ 待开发(按优先级) **P2 - 用户管理增强(可选)** - [ ] 用户批量操作(批量禁用、批量分配租户) -- [ ] 用户操作日志(audit_logs 集成) -- [ ] 用户统计分析(活跃度、模块使用率) +- [ ] 更多直授权限种类(当前仅 `ops:user-ops`,未来可扩展) **P2 - Prompt 管理优化** - [ ] Prompt 版本对比功能 @@ -152,6 +180,11 @@ - [ ] 租户专属登录页 - [ ] 配额管理界面 +**P2 - 运营监控增强** +- [ ] 用户停留时长统计(前端心跳上报) +- [ ] 点击流分析与可视化 +- [ ] 单模块重试按钮(RVW partial_completed 场景) + --- ## 🗄️ 数据库状态 @@ -181,6 +214,7 @@ public.AdminLog -- 旧的审计日志 - ✅ `role_permissions` - 角色权限 - ✅ `verification_codes` - 验证码表 - ✅ `modules` - 系统模块表(动态管理可用模块)🆕 2026-01-12 +- ✅ `user_permissions` - 用户直授权限表(不依赖角色的独立授权)🆕 2026-03-10 **capability_schema(通用能力)** ✅ 新增 - ✅ `prompt_templates` - Prompt模板 @@ -225,16 +259,17 @@ public.AdminLog -- 旧的审计日志 ## 🔐 角色与权限矩阵 -| 功能模块 | SUPER_ADMIN | PROMPT_ENGINEER | HOSPITAL_ADMIN | PHARMA_ADMIN | USER | -|---------|-------------|-----------------|----------------|--------------|------| -| 租户管理 | ✅ 全部 | ❌ | ❌ | ❌ | ❌ | -| Prompt管理 | ✅ 全部 | ✅ 全部 | ❌ | ❌ | ❌ | -| 用户管理(全局) | ✅ | ❌ | ❌ | ❌ | ❌ | -| 用户管理(租户内) | ✅ | ❌ | ✅ | ✅ | ❌ | -| 配额分配 | ✅ | ❌ | ✅ | ✅ | ❌ | -| 审计日志(全局) | ✅ | ❌ | ❌ | ❌ | ❌ | -| 审计日志(租户内) | ✅ | ❌ | ✅ | ✅ | ❌ | -| 业务模块使用 | ✅ | ✅ | ✅ | ✅ | ✅ | +| 功能模块 | SUPER_ADMIN | PROMPT_ENGINEER | HOSPITAL_ADMIN | PHARMA_ADMIN | USER | USER + ops:user-ops | +|---------|-------------|-----------------|----------------|--------------|------|---------------------| +| 租户管理(查看) | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| 租户管理(创建/编辑) | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| 租户管理(删除) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Prompt管理 | ✅ 全部 | ✅ 全部 | ❌ | ❌ | ❌ | ❌ | +| 用户管理(全局) | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| 用户管理(租户内) | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | +| 配额分配 | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | +| 运营日志/看板 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| 业务模块使用 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | --- diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index e0fb3a05..6bc8969b 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -3,7 +3,8 @@ > **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行 > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 -> **最后清零**: 2026-03-09(0309 二次部署完成后清零) +> **最后清零**: 2026-03-09(0309 二次部署完成后清零) +> **本次变更**: 用户直授权限体系 + 运营埋点增强 + 运营看板 MAU/Token(2026-03-10) --- @@ -15,19 +16,29 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| — | *暂无* | | | | +| DB-1 | 新增 `user_permissions` 用户直授权限表 | `20260309_add_user_permissions_table` | 高 | 支持不依赖角色给单个用户授权(如 `ops:user-ops`),`platform_schema` 下,含外键和唯一约束 | +| DB-2 | Seed:`permissions` 表插入 `ops:user-ops` 记录 | `prisma/seed.ts` 或手动 SQL | 高 | DB-1 之后执行;`INSERT INTO platform_schema.permissions (code,name,description,module) VALUES ('ops:user-ops','用户运营','运营管理端用户运营视图权限','ops') ON CONFLICT (code) DO NOTHING;` | ### 后端变更 (Node.js) | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| BE-1 | 认证链路支持用户直授权限合并 | `auth.service.ts`, `auth.middleware.ts` | 重新构建镜像 | `getUserPermissions` 合并 `role_permissions` + `user_permissions`;`requirePermission` / `requireAnyPermission` 两级检查 | +| BE-2 | 用户管理 API 新增直授权限读写接口 | `userController.ts`, `userService.ts`, `userRoutes.ts` | 重新构建镜像 | `GET/PUT /api/admin/users/:id/permissions` + `GET /api/admin/users/options/permissions` | +| BE-3 | 用户详情 API 返回合并权限 | `userService.ts` (getUserById) | 重新构建镜像 | 详情页权限列表 = 角色权限 ∪ 用户直授权限 | +| BE-4 | 运营埋点覆盖 6 大模块 | `deepResearchController.ts`, `reviewController.ts`, `AIController.ts`, `StreamAIController.ts`, `SessionController.ts`, `QuickActionController.ts`, `iitBatchController.ts`, `auth.controller.ts`, `auth.routes.ts` | 重新构建镜像 | ASL/RVW/DC/IIT/AIA/SYSTEM 埋点;DC 模块覆盖上传/流式AI/快速操作/非流式AI 全部 4 个入口 + 前端通用上报接口 `POST /api/v1/auth/activity` | +| BE-5 | 运营看板增强(MAU/Token/最活跃用户) | `activity.service.ts`, `statsController.ts` | 重新构建镜像 | `getTodayOverview` 新增 MAU、apiTokenTotal、topActiveUser | +| BE-6 | 埋点验证脚本 | `scripts/verify-activity-tracking.ts` | 无需部署 | `npm run test:tracking` 开发/运维自测用 | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| FE-1 | 运营管理端支持 `ops:user-ops` 权限入口 | `AdminLayout.tsx`, `TopNavigation.tsx` | 重新构建镜像 | 非 SUPER_ADMIN 有 `ops:user-ops` 也可进入运营端 | +| FE-2 | 用户创建/编辑页新增"用户运营权限"开关 | `UserFormPage.tsx`, `UserDetailPage.tsx` | 重新构建镜像 | Switch 控件,保存时调 `PUT /api/admin/users/:id/permissions` | +| FE-3 | 用户管理 API 层新增权限接口 | `userApi.ts` | 重新构建镜像 | `getUserDirectPermissions` / `updateUserDirectPermissions` / `getPermissionOptions` | +| FE-4 | 运营看板展示 MAU/Token/最活跃用户 | `StatsDashboardPage.tsx`, `statsApi.ts` | 重新构建镜像 | 新增 4 个统计卡片 | +| FE-5 | 顶部导航点击埋点上报 | `TopNavigation.tsx` | 重新构建镜像 | 点击模块导航时 fire-and-forget 上报 | ### Python 微服务变更 @@ -45,7 +56,7 @@ | # | 变更内容 | 服务 | 变量名 | 备注 | |---|---------|------|--------|------| -| — | *暂无* | | | | +| — | *暂无*(本次无新增环境变量) | | | | ### 基础设施变更 diff --git a/frontend-v2/src/modules/admin/api/userApi.ts b/frontend-v2/src/modules/admin/api/userApi.ts index 79eb6a4b..f24c417a 100644 --- a/frontend-v2/src/modules/admin/api/userApi.ts +++ b/frontend-v2/src/modules/admin/api/userApi.ts @@ -104,6 +104,29 @@ export async function importUsers( return response.data.data; } +/** + * 获取用户直授权限 + */ +export async function getUserDirectPermissions(userId: string): Promise { + const response = await apiClient.get<{ code: number; data: { permissions: string[] } }>(`${BASE_URL}/${userId}/permissions`); + return response.data.data.permissions; +} + +/** + * 更新用户直授权限 + */ +export async function updateUserDirectPermissions(userId: string, permissions: string[]): Promise { + await apiClient.put(`${BASE_URL}/${userId}/permissions`, { permissions }); +} + +/** + * 获取所有可直授权限列表 + */ +export async function getPermissionOptions(): Promise> { + const response = await apiClient.get<{ code: number; data: Array<{ code: string; name: string; description: string | null }> }>(`${BASE_URL}/options/permissions`); + return response.data.data; +} + /** * 获取所有租户列表(用于下拉选择) */ diff --git a/frontend-v2/src/modules/admin/pages/UserDetailPage.tsx b/frontend-v2/src/modules/admin/pages/UserDetailPage.tsx index 1c8a4ff0..d5c063ff 100644 --- a/frontend-v2/src/modules/admin/pages/UserDetailPage.tsx +++ b/frontend-v2/src/modules/admin/pages/UserDetailPage.tsx @@ -18,6 +18,7 @@ import { Modal, Tooltip, Empty, + Switch, } from 'antd'; import { ArrowLeftOutlined, @@ -50,14 +51,20 @@ const UserDetailPage: React.FC = () => { const [assignTenantVisible, setAssignTenantVisible] = useState(false); const [modulePermissionVisible, setModulePermissionVisible] = useState(false); const [selectedMembership, setSelectedMembership] = useState(null); + const [directPermissions, setDirectPermissions] = useState([]); + const [opsToggling, setOpsToggling] = useState(false); // 加载用户详情 const loadUser = async () => { if (!id) return; setLoading(true); try { - const data = await userApi.getUserById(id); + const [data, perms] = await Promise.all([ + userApi.getUserById(id), + userApi.getUserDirectPermissions(id), + ]); setUser(data); + setDirectPermissions(perms); } catch (error) { console.error('加载用户详情失败:', error); message.error('加载用户详情失败'); @@ -142,6 +149,24 @@ const UserDetailPage: React.FC = () => { }); }; + // 切换用户运营权限 + const handleToggleUserOps = async (checked: boolean) => { + if (!user) return; + setOpsToggling(true); + try { + const newPerms = checked + ? [...directPermissions.filter(p => p !== 'ops:user-ops'), 'ops:user-ops'] + : directPermissions.filter(p => p !== 'ops:user-ops'); + await userApi.updateUserDirectPermissions(user.id, newPerms); + setDirectPermissions(newPerms); + message.success(checked ? '已开启用户运营权限' : '已关闭用户运营权限'); + } catch { + message.error('操作失败'); + } finally { + setOpsToggling(false); + } + }; + // 打开模块权限配置 const handleConfigModules = (membership: TenantMembership) => { setSelectedMembership(membership); @@ -323,7 +348,17 @@ const UserDetailPage: React.FC = () => { {new Date(user.createdAt).toLocaleString('zh-CN')} - + + + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'} diff --git a/frontend-v2/src/modules/admin/pages/UserFormPage.tsx b/frontend-v2/src/modules/admin/pages/UserFormPage.tsx index 338fb08f..abc112fb 100644 --- a/frontend-v2/src/modules/admin/pages/UserFormPage.tsx +++ b/frontend-v2/src/modules/admin/pages/UserFormPage.tsx @@ -18,6 +18,7 @@ import { Divider, Checkbox, Alert, + Switch, } from 'antd'; import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router-dom'; @@ -48,6 +49,7 @@ const UserFormPage: React.FC = ({ mode }) => { const [departmentOptions, setDepartmentOptions] = useState([]); const [moduleOptions, setModuleOptions] = useState([]); const [selectedTenantId, setSelectedTenantId] = useState(); + const [hasUserOps, setHasUserOps] = useState(false); // 加载租户选项 useEffect(() => { @@ -74,8 +76,11 @@ const UserFormPage: React.FC = ({ mode }) => { useEffect(() => { if (mode === 'edit' && id) { setLoading(true); - userApi.getUserById(id) - .then((user) => { + Promise.all([ + userApi.getUserById(id), + userApi.getUserDirectPermissions(id), + ]) + .then(([user, directPerms]) => { form.setFieldsValue({ phone: user.phone, name: user.name, @@ -85,6 +90,7 @@ const UserFormPage: React.FC = ({ mode }) => { departmentId: user.department?.id, }); setSelectedTenantId(user.defaultTenant.id); + setHasUserOps(directPerms.includes('ops:user-ops')); }) .catch((error) => { console.error('加载用户数据失败:', error); @@ -115,7 +121,11 @@ const UserFormPage: React.FC = ({ mode }) => { tenantRole: values.tenantRole || values.role, allowedModules: values.allowedModules?.length > 0 ? values.allowedModules : undefined, }; - await userApi.createUser(data); + const created = await userApi.createUser(data); + // 创建后追加直授权限 + if (hasUserOps) { + await userApi.updateUserDirectPermissions(created.id, ['ops:user-ops']); + } message.success('用户创建成功'); } else { const data: UpdateUserRequest = { @@ -125,6 +135,8 @@ const UserFormPage: React.FC = ({ mode }) => { departmentId: values.departmentId || undefined, }; await userApi.updateUser(id!, data); + // 更新直授权限 + await userApi.updateUserDirectPermissions(id!, hasUserOps ? ['ops:user-ops'] : []); message.success('用户更新成功'); } navigate('/admin/users'); @@ -222,6 +234,23 @@ const UserFormPage: React.FC = ({ mode }) => { + {/* 用户运营权限 */} + + + + + + + + {mode === 'create' && (