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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
137
backend/scripts/verify-activity-tracking.ts
Normal file
137
backend/scripts/verify-activity-tracking.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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(', ')}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string[]> {
|
||||
private async getUserPermissions(role: string, userId?: string): Promise<string[]> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 || '获取权限列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户列表(用于下拉选择)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string[]> {
|
||||
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<Array<{ code: string; name: string; description: string | null }>> {
|
||||
return prisma.permissions.findMany({
|
||||
select: { code: true, name: true, description: true },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
function getModuleName(code: string): string {
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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管理 | ✅ 全部 | ✅ 全部 | ❌ | ❌ | ❌ | ❌ |
|
||||
| 用户管理(全局) | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 用户管理(租户内) | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 配额分配 | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
| 运营日志/看板 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 业务模块使用 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
| # | 变更内容 | 服务 | 变量名 | 备注 |
|
||||
|---|---------|------|--------|------|
|
||||
| — | *暂无* | | | |
|
||||
| — | *暂无*(本次无新增环境变量) | | | |
|
||||
|
||||
### 基础设施变更
|
||||
|
||||
|
||||
@@ -104,6 +104,29 @@ export async function importUsers(
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户直授权限
|
||||
*/
|
||||
export async function getUserDirectPermissions(userId: string): Promise<string[]> {
|
||||
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<void> {
|
||||
await apiClient.put(`${BASE_URL}/${userId}/permissions`, { permissions });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可直授权限列表
|
||||
*/
|
||||
export async function getPermissionOptions(): Promise<Array<{ code: string; name: string; description: string | null }>> {
|
||||
const response = await apiClient.get<{ code: number; data: Array<{ code: string; name: string; description: string | null }> }>(`${BASE_URL}/options/permissions`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户列表(用于下拉选择)
|
||||
*/
|
||||
|
||||
@@ -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<TenantMembership | null>(null);
|
||||
const [directPermissions, setDirectPermissions] = useState<string[]>([]);
|
||||
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 = () => {
|
||||
<Descriptions.Item label="创建时间">
|
||||
{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后登录" span={3}>
|
||||
<Descriptions.Item label="用户运营权限">
|
||||
<Switch
|
||||
checked={directPermissions.includes('ops:user-ops')}
|
||||
onChange={handleToggleUserOps}
|
||||
loading={opsToggling}
|
||||
checkedChildren="已开启"
|
||||
unCheckedChildren="未开启"
|
||||
size="small"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后登录" span={2}>
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
@@ -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<UserFormPageProps> = ({ mode }) => {
|
||||
const [departmentOptions, setDepartmentOptions] = useState<DepartmentOption[]>([]);
|
||||
const [moduleOptions, setModuleOptions] = useState<ModuleOption[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>();
|
||||
const [hasUserOps, setHasUserOps] = useState(false);
|
||||
|
||||
// 加载租户选项
|
||||
useEffect(() => {
|
||||
@@ -74,8 +76,11 @@ const UserFormPage: React.FC<UserFormPageProps> = ({ 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<UserFormPageProps> = ({ 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<UserFormPageProps> = ({ 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<UserFormPageProps> = ({ 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<UserFormPageProps> = ({ mode }) => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 用户运营权限 */}
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="用户运营权限"
|
||||
tooltip="开启后该用户可访问运营管理端的租户管理、用户管理、运营日志模块"
|
||||
>
|
||||
<Switch
|
||||
checked={hasUserOps}
|
||||
onChange={setHasUserOps}
|
||||
checkedChildren="已开启"
|
||||
unCheckedChildren="未开启"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{mode === 'create' && (
|
||||
<Alert
|
||||
message="默认密码"
|
||||
|
||||
Reference in New Issue
Block a user