From 971e903acf144b20cd8d660a8d9bb80a2bc88cd1 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Mon, 9 Mar 2026 22:27:11 +0800 Subject: [PATCH] chore(deploy): finalize 0309 SAE rollout updates Sync deployment documentation to the final successful SAE state and clear pending deployment checklist items. Include backend/frontend/R hardening and diagnostics improvements required for stable production behavior. Made-with: Cursor --- backend/prisma/seed.ts | 12 +- backend/src/common/auth/auth.controller.ts | 58 ++++++ backend/src/common/auth/auth.middleware.ts | 73 ++++++- backend/src/common/auth/auth.routes.ts | 29 +++ .../src/common/services/activity.service.ts | 43 +++- .../admin/iit-projects/iitBatchController.ts | 40 ++++ .../src/modules/admin/routes/statsRoutes.ts | 10 +- .../src/modules/admin/routes/tenantRoutes.ts | 8 +- .../src/modules/admin/routes/userRoutes.ts | 12 +- .../asl/controllers/deepResearchController.ts | 45 +++++ .../dc/tool-c/controllers/AIController.ts | 19 ++ .../rvw/controllers/reviewController.ts | 24 +++ .../06-用户运营权限与运营日志增强实施计划.md | 184 ++++++++++++++++++ .../00-阿里云SAE最新真实状态记录.md | 68 +++++-- docs/05-部署文档/03-待部署变更清单.md | 26 ++- .../0309部署/01-数据库部署完成总结.md | 176 +++++++---------- .../src/framework/layout/AdminLayout.tsx | 9 +- .../src/framework/layout/TopNavigation.tsx | 19 +- frontend-v2/src/modules/admin/api/statsApi.ts | 21 +- .../admin/pages/StatsDashboardPage.tsx | 46 ++++- r-statistics-service/Dockerfile | 3 + r-statistics-service/plumber.R | 29 +++ r-statistics-service/utils/error_codes.R | 36 +++- 23 files changed, 810 insertions(+), 180 deletions(-) create mode 100644 docs/03-业务模块/ADMIN-运营管理端/04-开发计划/06-用户运营权限与运营日志增强实施计划.md diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 017c602e..fc55f844 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -102,7 +102,7 @@ async function main() { // ============================================ console.log('📌 创建超级管理员...'); - const superAdmin = await prisma.User.upsert({ + const superAdmin = await prisma.user.upsert({ where: { phone: '13800000001' }, update: {}, create: { @@ -123,7 +123,7 @@ async function main() { // ============================================ console.log('📌 创建Prompt工程师账号...'); - const promptEngineer = await prisma.User.upsert({ + const promptEngineer = await prisma.user.upsert({ where: { phone: '13800000002' }, update: {}, create: { @@ -232,7 +232,7 @@ async function main() { // ============================================ console.log('📌 创建医院管理员...'); - const hospitalAdmin = await prisma.User.upsert({ + const hospitalAdmin = await prisma.user.upsert({ where: { phone: '13800138001' }, update: {}, create: { @@ -266,7 +266,7 @@ async function main() { // ============================================ console.log('📌 创建普通医生用户...'); - const doctor1 = await prisma.User.upsert({ + const doctor1 = await prisma.user.upsert({ where: { phone: '13800138002' }, update: {}, create: { @@ -282,7 +282,7 @@ async function main() { }, }); - const doctor2 = await prisma.User.upsert({ + const doctor2 = await prisma.user.upsert({ where: { phone: '13800138003' }, update: {}, create: { @@ -343,6 +343,8 @@ async function main() { // 审计日志权限 { code: 'audit:view', name: '查看审计日志', description: '查看操作审计日志', module: 'audit' }, + // 用户运营权限(可访问租户管理/用户管理/运营日志) + { code: 'ops:user-ops', name: '用户运营', description: '运营管理端用户运营视图权限', module: 'ops' }, ]; for (const perm of permissionsData) { diff --git a/backend/src/common/auth/auth.controller.ts b/backend/src/common/auth/auth.controller.ts index a9c6667b..442e5286 100644 --- a/backend/src/common/auth/auth.controller.ts +++ b/backend/src/common/auth/auth.controller.ts @@ -394,6 +394,64 @@ export async function logout( }); } +/** + * 记录前端行为埋点(如顶部导航点击) + * + * POST /api/v1/auth/activity + */ +export async function logActivity( + request: FastifyRequest<{ + Body: { + module: 'SYSTEM' | 'AIA' | 'PKB' | 'ASL' | 'DC' | 'RVW' | 'IIT' | 'SSA' | 'ST'; + feature: string; + action?: 'LOGIN' | 'USE' | 'EXPORT' | 'CREATE' | 'DELETE' | 'CLICK' | 'START' | 'COMPLETE' | 'ERROR'; + info?: string; + }; + }>, + reply: FastifyReply +) { + try { + if (!request.user) { + return reply.status(401).send({ + success: false, + error: 'Unauthorized', + message: '未认证', + }); + } + + const { module, feature, action = 'USE', info } = request.body; + if (!module || !feature?.trim()) { + return reply.status(400).send({ + success: false, + error: 'BadRequest', + message: 'module 和 feature 不能为空', + }); + } + + const user = await authService.getCurrentUser(request.user.userId); + activityService.log( + user.tenantId, + user.tenantName || null, + user.id, + user.name, + module, + feature.trim(), + action, + info, + ); + + return reply.status(200).send({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : '埋点记录失败'; + logger.warn('记录埋点失败', { error: message, userId: request.user?.userId }); + return reply.status(500).send({ + success: false, + error: 'InternalServerError', + message, + }); + } +} + /** * ��ȡ��ǰ�û��ɷ��ʵ�ģ�� diff --git a/backend/src/common/auth/auth.middleware.ts b/backend/src/common/auth/auth.middleware.ts index e5b71d64..3257601e 100644 --- a/backend/src/common/auth/auth.middleware.ts +++ b/backend/src/common/auth/auth.middleware.ts @@ -183,20 +183,73 @@ export function requirePermission(requiredPermission: string): preHandlerHookHan }); } - // TODO: 从缓存或数据库获取用户权限 - // 目前简化处理:超级管理员拥有所有权限 if (request.user.role === 'SUPER_ADMIN') { return; } - // TODO: 实现权限检查逻辑 - // const hasPermission = await checkUserPermission(request.user.userId, requiredPermission); - // if (!hasPermission) { - // return reply.status(403).send({ - // error: 'Forbidden', - // message: `需要权限: ${requiredPermission}`, - // }); - // } + const allowed = await prisma.role_permissions.findFirst({ + where: { + role: request.user.role as any, + permissions: { + code: requiredPermission, + }, + }, + select: { permission_id: true }, + }); + + if (!allowed) { + logger.warn('权限不足', { + userId: request.user.userId, + role: request.user.role, + requiredPermission, + }); + return reply.status(403).send({ + error: 'Forbidden', + message: `需要权限: ${requiredPermission}`, + }); + } + }; +} + +/** + * 任一权限检查中间件工厂 + * + * @param requiredPermissions 允许的权限code列表(命中任意一个即放行) + */ +export function requireAnyPermission(...requiredPermissions: string[]): preHandlerHookHandler { + return async (request: FastifyRequest, reply: FastifyReply) => { + if (!request.user) { + return reply.status(401).send({ + error: 'Unauthorized', + message: '未认证', + }); + } + + if (request.user.role === 'SUPER_ADMIN') { + return; + } + + const allowed = await prisma.role_permissions.findFirst({ + where: { + role: request.user.role as any, + permissions: { + code: { in: requiredPermissions }, + }, + }, + select: { permission_id: true }, + }); + + if (!allowed) { + 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.routes.ts b/backend/src/common/auth/auth.routes.ts index 379eac4c..b81293b6 100644 --- a/backend/src/common/auth/auth.routes.ts +++ b/backend/src/common/auth/auth.routes.ts @@ -16,6 +16,7 @@ import { uploadAvatar, refreshToken, logout, + logActivity, } from './auth.controller.js'; import { authenticate } from './auth.middleware.js'; @@ -87,6 +88,26 @@ const refreshTokenSchema = { }, }; +const logActivitySchema = { + body: { + type: 'object', + required: ['module', 'feature'], + properties: { + module: { + type: 'string', + enum: ['SYSTEM', 'AIA', 'PKB', 'ASL', 'DC', 'RVW', 'IIT', 'SSA', 'ST'], + }, + feature: { type: 'string', minLength: 1, maxLength: 120 }, + action: { + type: 'string', + enum: ['LOGIN', 'USE', 'EXPORT', 'CREATE', 'DELETE', 'CLICK', 'START', 'COMPLETE', 'ERROR'], + default: 'USE', + }, + info: { type: 'string', maxLength: 2000 }, + }, + }, +}; + /** * 注册认证路由 */ @@ -170,6 +191,14 @@ export async function authRoutes( fastify.post('/logout', { preHandler: [authenticate], }, logout); + + /** + * 前端行为埋点上报(如顶部导航点击) + */ + fastify.post('/activity', { + preHandler: [authenticate], + schema: logActivitySchema, + }, logActivity as any); } export default authRoutes; diff --git a/backend/src/common/services/activity.service.ts b/backend/src/common/services/activity.service.ts index 9681ec1e..37fc6978 100644 --- a/backend/src/common/services/activity.service.ts +++ b/backend/src/common/services/activity.service.ts @@ -34,6 +34,9 @@ export type ActionType = | 'EXPORT' // 导出/下载 | 'CREATE' // 创建资源 | 'DELETE' // 删除资源 + | 'CLICK' // 点击行为 + | 'START' // 启动任务 + | 'COMPLETE'// 完成任务 | 'ERROR'; // 错误记录 /** @@ -111,22 +114,35 @@ export const activityService = { */ async getTodayOverview(): Promise<{ dau: number; + mau: number; dat: number; exportCount: number; + apiTokenTotal: number; + topActiveUser: { userId: string; userName: string | null; actionCount: number } | null; moduleStats: Record; }> { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); + const monthStart = new Date(todayStart); + monthStart.setDate(monthStart.getDate() - 29); - // 查询 DAU/DAT/导出数 + // 查询 DAU/MAU/DAT/导出数/Token总量(从 info 文本中提取 tokens: N) const stats = await prisma.$queryRaw` SELECT COUNT(DISTINCT user_id) as dau, + COUNT(DISTINCT CASE WHEN created_at >= ${monthStart} THEN user_id END) as mau, COUNT(DISTINCT tenant_id) as dat, - COUNT(CASE WHEN action = 'EXPORT' THEN 1 END) as export_count + COUNT(CASE WHEN action = 'EXPORT' THEN 1 END) as export_count, + COALESCE(SUM( + CASE + WHEN info ~* 'tokens?\\D*\\d+' + THEN (regexp_match(info, '(?i)tokens?\\D*(\\d+)'))[1]::bigint + ELSE 0 + END + ), 0) as api_token_total FROM admin_schema.simple_logs WHERE created_at >= ${todayStart} - ` as Array<{ dau: bigint; dat: bigint; export_count: bigint }>; + ` as Array<{ dau: bigint; mau: bigint; dat: bigint; export_count: bigint; api_token_total: bigint }>; // 查询模块使用统计 const moduleStats = await prisma.$queryRaw` @@ -141,10 +157,31 @@ export const activityService = { moduleMap[m.module] = Number(m.count); }); + // 今日最活跃用户(按行为数) + const topUsers = await prisma.$queryRaw` + SELECT user_id, user_name, COUNT(*) as action_count + FROM admin_schema.simple_logs + WHERE created_at >= ${todayStart} + GROUP BY user_id, user_name + ORDER BY action_count DESC + LIMIT 1 + ` as Array<{ user_id: string; user_name: string | null; action_count: bigint }>; + + const topActiveUser = topUsers[0] + ? { + userId: topUsers[0].user_id, + userName: topUsers[0].user_name, + actionCount: Number(topUsers[0].action_count), + } + : null; + return { dau: Number(stats[0]?.dau || 0), + mau: Number(stats[0]?.mau || 0), dat: Number(stats[0]?.dat || 0), exportCount: Number(stats[0]?.export_count || 0), + apiTokenTotal: Number(stats[0]?.api_token_total || 0), + topActiveUser, moduleStats: moduleMap, }; }, diff --git a/backend/src/modules/admin/iit-projects/iitBatchController.ts b/backend/src/modules/admin/iit-projects/iitBatchController.ts index 33652c10..2b781b05 100644 --- a/backend/src/modules/admin/iit-projects/iitBatchController.ts +++ b/backend/src/modules/admin/iit-projects/iitBatchController.ts @@ -20,6 +20,7 @@ import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js'; import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js'; import { QcReportService } from '../../iit-manager/services/QcReportService.js'; import { dailyQcOrchestrator } from '../../iit-manager/services/DailyQcOrchestrator.js'; +import { activityService } from '../../../common/services/activity.service.js'; const prisma = new PrismaClient(); @@ -45,10 +46,30 @@ export class IitBatchController { ) { const { projectId } = request.params; const startTime = Date.now(); + const requestUserId = (request as any).user?.userId as string | undefined; try { logger.info('[V3.1] Batch QC started', { projectId }); + if (requestUserId) { + const actor = await prisma.user.findUnique({ + where: { id: requestUserId }, + include: { tenants: true }, + }); + if (actor) { + activityService.log( + actor.tenant_id, + actor.tenants?.name || null, + actor.id, + actor.name, + 'IIT', + 'CRA质控', + 'START', + `projectId:${projectId}`, + ); + } + } + const project = await prisma.iitProject.findUnique({ where: { id: projectId }, select: { id: true }, @@ -87,6 +108,25 @@ export class IitBatchController { projectId, totalRecords, totalEvents, passed, failed, warnings, durationMs, }); + if (requestUserId) { + const actor = await prisma.user.findUnique({ + where: { id: requestUserId }, + include: { tenants: true }, + }); + if (actor) { + activityService.log( + actor.tenant_id, + actor.tenants?.name || null, + actor.id, + actor.name, + 'IIT', + 'CRA质控', + 'COMPLETE', + `projectId:${projectId}, total:${totalEvents}, pass:${passed}, fail:${failed}, warn:${warnings}`, + ); + } + } + return reply.send({ success: true, message: '事件级全量质控完成(V3.1 QcExecutor)', diff --git a/backend/src/modules/admin/routes/statsRoutes.ts b/backend/src/modules/admin/routes/statsRoutes.ts index e3b0016c..81452750 100644 --- a/backend/src/modules/admin/routes/statsRoutes.ts +++ b/backend/src/modules/admin/routes/statsRoutes.ts @@ -9,7 +9,7 @@ import type { FastifyInstance } from 'fastify'; import * as statsController from '../controllers/statsController.js'; -import { authenticate, requirePermission } from '../../../common/auth/auth.middleware.js'; +import { authenticate, requireAnyPermission, requirePermission } from '../../../common/auth/auth.middleware.js'; export async function statsRoutes(fastify: FastifyInstance) { // ==================== 运营统计 ==================== @@ -21,7 +21,7 @@ export async function statsRoutes(fastify: FastifyInstance) { * 权限: SUPER_ADMIN, PROMPT_ENGINEER */ fastify.get('/overview', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: statsController.getOverview, }); @@ -32,7 +32,7 @@ export async function statsRoutes(fastify: FastifyInstance) { * 权限: SUPER_ADMIN, PROMPT_ENGINEER */ fastify.get('/live-feed', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: statsController.getLiveFeed, }); @@ -43,7 +43,7 @@ export async function statsRoutes(fastify: FastifyInstance) { * 权限: SUPER_ADMIN, PROMPT_ENGINEER */ fastify.get('/logs', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: statsController.getActivityLogs, }); @@ -72,7 +72,7 @@ export async function userOverviewRoute(fastify: FastifyInstance) { * 权限: SUPER_ADMIN */ fastify.get('/:id/overview', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: statsController.getUserOverview, }); } diff --git a/backend/src/modules/admin/routes/tenantRoutes.ts b/backend/src/modules/admin/routes/tenantRoutes.ts index 53be37a4..9721ada9 100644 --- a/backend/src/modules/admin/routes/tenantRoutes.ts +++ b/backend/src/modules/admin/routes/tenantRoutes.ts @@ -6,7 +6,7 @@ import type { FastifyInstance } from 'fastify'; import * as tenantController from '../controllers/tenantController.js'; -import { authenticate, requirePermission } from '../../../common/auth/auth.middleware.js'; +import { authenticate, requireAnyPermission, requirePermission } from '../../../common/auth/auth.middleware.js'; export async function tenantRoutes(fastify: FastifyInstance) { // ==================== 租户管理 ==================== @@ -14,14 +14,14 @@ export async function tenantRoutes(fastify: FastifyInstance) { // 获取租户列表 // GET /api/admin/tenants?type=&status=&search=&page=1&limit=20 fastify.get('/', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: tenantController.listTenants, }); // 获取租户详情 // GET /api/admin/tenants/:id fastify.get('/:id', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: tenantController.getTenant, }); @@ -70,7 +70,7 @@ export async function moduleRoutes(fastify: FastifyInstance) { // 获取所有可用模块列表 // GET /api/admin/modules fastify.get('/', { - preHandler: [authenticate, requirePermission('tenant:view')], + preHandler: [authenticate, requireAnyPermission('tenant:view', 'ops:user-ops')], handler: tenantController.listModules, }); } diff --git a/backend/src/modules/admin/routes/userRoutes.ts b/backend/src/modules/admin/routes/userRoutes.ts index 7f6bd0f1..2a6f9a90 100644 --- a/backend/src/modules/admin/routes/userRoutes.ts +++ b/backend/src/modules/admin/routes/userRoutes.ts @@ -5,7 +5,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { authenticate, requireRoles, requirePermission } from '../../../common/auth/auth.middleware.js'; +import { authenticate, requireRoles, requirePermission, requireAnyPermission } from '../../../common/auth/auth.middleware.js'; import * as userController from '../controllers/userController.js'; /** @@ -17,14 +17,14 @@ export async function userRoutes(fastify: FastifyInstance) { // 获取用户列表 // GET /api/admin/users?page=1&pageSize=20&search=&role=&tenantId=&status=&departmentId= fastify.get('/', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: userController.listUsers, }); // 获取用户详情 // GET /api/admin/users/:id fastify.get('/:id', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: userController.getUserById, }); @@ -95,21 +95,21 @@ export async function userRoutes(fastify: FastifyInstance) { // 获取所有租户列表(用于下拉选择) // GET /api/admin/users/options/tenants fastify.get('/options/tenants', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: userController.getAllTenants, }); // 获取租户的科室列表 // GET /api/admin/users/options/tenants/:tenantId/departments fastify.get('/options/tenants/:tenantId/departments', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: userController.getDepartmentsByTenant, }); // 获取租户的模块列表(用于模块配置) // GET /api/admin/users/options/tenants/:tenantId/modules fastify.get('/options/tenants/:tenantId/modules', { - preHandler: [authenticate, requirePermission('user:view')], + preHandler: [authenticate, requireAnyPermission('user:view', 'ops:user-ops')], handler: userController.getModulesByTenant, }); } diff --git a/backend/src/modules/asl/controllers/deepResearchController.ts b/backend/src/modules/asl/controllers/deepResearchController.ts index 3be9e614..4fc25a80 100644 --- a/backend/src/modules/asl/controllers/deepResearchController.ts +++ b/backend/src/modules/asl/controllers/deepResearchController.ts @@ -11,6 +11,7 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { prisma } from '../../../config/database.js'; import { jobQueue } from '../../../common/jobs/index.js'; import { logger } from '../../../common/logging/index.js'; +import { activityService } from '../../../common/services/activity.service.js'; import { requirementExpansionService } from '../services/requirementExpansionService.js'; import { wordExportService } from '../services/wordExportService.js'; import { DEEP_RESEARCH_DATA_SOURCES } from '../config/dataSources.js'; @@ -63,6 +64,28 @@ export async function generateRequirement( filters, }); + // 埋点:ASL 意图识别(需求扩写) + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { tenants: true }, + }); + if (user) { + activityService.log( + user.tenant_id, + user.tenants?.name || null, + user.id, + user.name, + 'ASL', + '意图识别', + 'USE', + `queryLen:${originalQuery.trim().length}`, + ); + } + } catch { + // 埋点失败不影响主流程 + } + return reply.send({ success: true, data: result }); } catch (error: any) { logger.error('[DeepResearchController] generateRequirement failed', { @@ -120,6 +143,28 @@ export async function executeTask( await jobQueue.push('asl_deep_research_v2', { taskId }); + // 埋点:启动 Deep Research + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { tenants: true }, + }); + if (user) { + activityService.log( + user.tenant_id, + user.tenants?.name || null, + user.id, + user.name, + 'ASL', + '启动Deep Research', + 'START', + `taskId:${taskId}`, + ); + } + } catch { + // 埋点失败不影响主流程 + } + logger.info('[DeepResearchController] Task pushed to queue', { taskId }); return reply.send({ success: true }); diff --git a/backend/src/modules/dc/tool-c/controllers/AIController.ts b/backend/src/modules/dc/tool-c/controllers/AIController.ts index ed0e8b9f..c65e7bce 100644 --- a/backend/src/modules/dc/tool-c/controllers/AIController.ts +++ b/backend/src/modules/dc/tool-c/controllers/AIController.ts @@ -172,6 +172,25 @@ export class AIController { error: '重试次数必须在1-5之间' }); } + + // 埋点:记录 Tool C AI清洗启动 + try { + const user = (request as any).user; + if (user) { + activityService.log( + user.tenantId, + user.tenantName || null, + user.id || user.userId, + user.name || null, + 'DC', + '智能数据清洗', + 'START', + `session:${sessionId}`, + ); + } + } catch { + // 埋点失败不影响主业务 + } // 生成并执行(带重试) const result = await aiCodeService.generateAndExecute( diff --git a/backend/src/modules/rvw/controllers/reviewController.ts b/backend/src/modules/rvw/controllers/reviewController.ts index 2cdc21dd..8c3c6257 100644 --- a/backend/src/modules/rvw/controllers/reviewController.ts +++ b/backend/src/modules/rvw/controllers/reviewController.ts @@ -14,6 +14,8 @@ import { logger } from '../../../common/logging/index.js'; import { ModelType } from '../../../common/llm/adapters/types.js'; import * as reviewService from '../services/reviewService.js'; import { AgentType } from '../types/index.js'; +import { activityService } from '../../../common/services/activity.service.js'; +import { prisma } from '../../../config/database.js'; /** * 获取用户ID(从JWT Token中获取) @@ -119,6 +121,28 @@ export async function createTask( // 创建任务 const task = await reviewService.createTask(file, filename, userId, tenantId, modelType); + // 埋点:稿件上传 + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { tenants: true }, + }); + if (user) { + activityService.log( + user.tenant_id, + user.tenants?.name || null, + user.id, + user.name, + 'RVW', + '稿件上传', + 'CREATE', + `taskId:${task.id}, file:${filename}, size:${fileSizeBytes}`, + ); + } + } catch { + // 埋点失败不影响主流程 + } + logger.info('[RVW:Controller] 任务已创建', { taskId: task.id }); return reply.send({ diff --git a/docs/03-业务模块/ADMIN-运营管理端/04-开发计划/06-用户运营权限与运营日志增强实施计划.md b/docs/03-业务模块/ADMIN-运营管理端/04-开发计划/06-用户运营权限与运营日志增强实施计划.md new file mode 100644 index 00000000..837e2c1d --- /dev/null +++ b/docs/03-业务模块/ADMIN-运营管理端/04-开发计划/06-用户运营权限与运营日志增强实施计划.md @@ -0,0 +1,184 @@ +# 用户运营权限与运营日志增强实施计划 + +> **文档类型:** 可执行实施清单 / 开发计划 +> **所属模块:** ADMIN-运营管理端 +> **创建日期:** 2026-03-09 +> **优先级:** P0 +> **状态:** 待开发 + +--- + +## 1. 背景与目标 + +为提升运营管理端的精细化运营能力,本次迭代聚焦两件事: + +1. 新增“用户运营”权限,可访问: + - 租户管理 + - 用户管理 + - 运营日志/运营看板 +2. 增强运营日志与埋点体系,补齐关键行为数据,支持: + - DAU / MAU + - 模块停留时长 + - 访问频次与点击流 + - API Token 消耗统计 + - 今日最活跃用户排行 + +--- + +## 2. 运营设计计划(产品侧) + +## 2.1 角色与权限设计 + +- 新增权限码:`ops:user-ops` +- 权限说明:可查看并操作“租户管理、用户管理、运营日志(含看板)”相关能力 +- 不包含高危权限:例如租户删除、Prompt发布等(仍由原高权限控制) +- 授权入口:运营管理端“用户管理”中可分配该权限 + +## 2.2 指标体系设计 + +- 活跃指标:DAU、MAU、今日最活跃用户 TopN +- 模块指标:模块访问次数、模块使用人数、模块停留时长 +- 行为指标:顶部导航点击流、关键按钮点击流、关键流程漏斗 +- 成本指标:API Token(按模块/用户/租户/时间) + +## 2.3 埋点事件模型(统一规范) + +建议统一字段: + +- `eventId`(UUID) +- `occurredAt` +- `tenantId`, `userId` +- `module`, `submodule` +- `eventName`, `action`, `result` +- `sessionId`, `traceId`, `requestId` +- `pagePath`, `elementId` +- `durationMs` +- `tokenPrompt`, `tokenCompletion`, `tokenTotal` +- `properties`(JSON 扩展) + +--- + +## 3. 开发改造计划(技术侧) + +## 3.1 范围拆分 + +### P0(必须) + +1. 权限闭环: + - 新增 `ops:user-ops` + - 后端权限校验生效(避免仅有权限码无执行) + - 前端菜单显示、路由访问、接口鉴权一致 +2. 关键埋点补齐(用户明确提出): + - ASL:意图识别、启动 Deep Research + - AIA:智能体相关操作 + - PKB:创建知识库 + - DC:智能数据清洗关键动作 + - IIT/CRA:质控关键动作 + - RVW:稿件上传 + - 顶部导航点击 +3. 看板最小可用增强: + - MAU + - 今日最活跃用户 + - 模块访问频次 Top + +### P1(增强) + +- 会话/页面进入退出事件,用于停留时长 +- 点击流路径分析(页面与关键控件) +- Token 统计标准化(跨模块统一口径) +- 日级聚合表(降低大盘查询压力) + +### P2(优化) + +- 用户行为分层运营(高活跃/沉默/流失) +- 运营告警(埋点缺失、异常峰值、失败率) + +--- + +## 4. 详细可执行清单 + +## 4.1 权限改造清单 + +- [ ] 在权限种子中新增 `ops:user-ops` +- [ ] 为目标角色/用户开放权限配置入口 +- [ ] 后端 `requirePermission` 补齐真实校验 +- [ ] `tenant/user/stats` 路由统一接入该权限(或兼容旧权限) +- [ ] 前端管理端菜单按权限显示 +- [ ] 前端管理端路由加权限守卫 +- [ ] 增加权限回归测试(有权限/无权限) + +## 4.2 埋点补齐清单(按模块) + +- [ ] SYSTEM:顶部导航点击 `top_nav_clicked` +- [ ] ASL:`intent_recognized`、`deep_research_started` +- [ ] AIA:`agent_selected`、`agent_chat_started`、`agent_chat_completed` +- [ ] PKB:`knowledge_base_created` +- [ ] DC:`dc_cleaning_started`、`dc_cleaning_completed` +- [ ] IIT/CRA:`cra_qc_started`、`cra_qc_completed` +- [ ] RVW:`manuscript_uploaded` + +## 4.3 运营统计增强清单 + +- [ ] 新增 MAU 查询口径与接口返回 +- [ ] 新增“今日最活跃用户”榜单接口 +- [ ] 新增模块访问频次排行接口 +- [ ] 新增 token 聚合统计接口(按模块/用户/租户) +- [ ] 前端看板新增对应卡片与图表 + +--- + +## 5. 关键埋点覆盖缺口(当前结论) + +以下为当前优先补齐项(P0): + +1. 顶部导航点击未统一埋点 +2. ASL 意图识别、启动 Deep Research 未覆盖 +3. CRA 质控未进入统一运营埋点链路 +4. RVW 稿件上传未覆盖 +5. 停留时长缺基础事件(enter/leave/session) +6. MAU 与最活跃用户榜单缺标准接口 +7. Token 统计跨模块口径未统一 + +--- + +## 6. 验收标准(Definition of Done) + +## 6.1 权限验收 + +- 有 `ops:user-ops` 的用户可访问租户管理、用户管理、运营日志 +- 无权限用户访问对应页面与接口均被拦截(前后端一致) + +## 6.2 埋点验收 + +- 上述 7 类核心事件全部可在运营日志检索到 +- 事件字段完整(至少含 module/eventName/userId/tenantId/time) +- 关键流程成功率与失败率可按事件统计 + +## 6.3 看板验收 + +- 可查看 DAU、MAU、今日最活跃用户、模块访问频次、Token 统计 +- 支持按时间范围与租户筛选 + +--- + +## 7. 风险与回滚 + +- 风险1:权限变更导致管理端误拦截 + - 应对:路由改造采用灰度开关或兼容双权限过渡 +- 风险2:埋点量增大影响查询性能 + - 应对:优先落明细 + 增加日聚合表 +- 风险3:事件命名不统一导致报表失真 + - 应对:统一事件字典并加 lint 校验 + +回滚策略: + +- 权限改造保留旧权限兜底(短期双轨) +- 看板新指标按 feature flag 控制展示 + +--- + +## 8. 里程碑建议(两周版本) + +- **第1周:** 权限闭环 + P0 埋点补齐(事件入库) +- **第2周:** MAU/最活跃用户/Token 看板 + 验收与文档更新 + diff --git a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md index f2018af1..6ce6ff05 100644 --- a/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md +++ b/docs/05-部署文档/00-阿里云SAE最新真实状态记录.md @@ -12,10 +12,10 @@ | 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 | |---------|---------|---------|---------|-------------| | **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-09 | -| **前端Nginx服务** | ✅ 运行中 | **v2.6** | SAE | 2026-03-09 | +| **前端Nginx服务** | ✅ 运行中 | **v2.7** | SAE | 2026-03-09 | | **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 | -| **Node.js后端** | ✅ 运行中 | **v2.9** | SAE | 2026-03-09 | -| **R统计引擎** | ✅ 运行中 | **v1.0.2** | SAE | 2026-03-09 | +| **Node.js后端** | ✅ 运行中 | **v2.10** | SAE | 2026-03-09 | +| **R统计引擎** | ✅ 运行中 | **v1.0.5** | SAE | 2026-03-09 | | **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 | --- @@ -36,9 +36,9 @@ | 仓库名称 | 最新版本 | 镜像大小 | VPC地址 | |---------|---------|---------|---------| | **python-extraction** | **v1.2** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.2` | -| **ssa-r-statistics** | **v1.0.2** | ~2.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.2` | -| **ai-clinical_frontend-nginx** | **v2.6** | ~96MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.6` | -| **backend-service** | **v2.9** | ~897MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.9` | +| **ssa-r-statistics** | **v1.0.5** | ~2.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.5` | +| **ai-clinical_frontend-nginx** | **v2.7** | ~100MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.7` | +| **backend-service** | **v2.10** | ~900MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.10` | --- @@ -127,10 +127,10 @@ postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyun | 应用名称 | 状态 | 规格 | 实例数 | 端口 | 内网地址 | 镜像版本 | |---------|------|------|-------|------|---------|---------| -| **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.197.22:8080` | **v1.0.2** | +| **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.197.26:8080` | **v1.0.5** | | **python-extraction-test** | ✅ 运行中 | **2核4GB** | 1 | 8000 | `http://172.17.173.102:8000` | **v1.2** | -| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.173.108:3001` | **v2.9** | -| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.23:80` | **v2.6** | +| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.173.109:3001` | **v2.10** | +| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.27:80` | **v2.7** | **环境变量配置**: @@ -144,7 +144,7 @@ DATABASE_URL=postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5. EXTRACTION_SERVICE_URL=http://172.17.173.102:8000 # R统计引擎地址 -R_SERVICE_URL=http://172.17.197.22:8080 +R_SERVICE_URL=http://172.17.197.26:8080 # OSS配置 OSS_ACCESS_KEY_ID=LTAI5tB2Dt3NdvBL3G7nYGv7 @@ -191,7 +191,7 @@ LEGACY_MYSQL_DATABASE=xzyx_online **前端Nginx(frontend-nginx-service)**: ```bash -BACKEND_SERVICE_HOST=172.17.173.108 +BACKEND_SERVICE_HOST=172.17.173.109 BACKEND_SERVICE_PORT=3001 ``` @@ -259,11 +259,11 @@ TEMP_DIR=/tmp/extraction_service ### 3.2 前端Nginx服务 -**当前部署版本**:v2.4 +**当前部署版本**:v2.7 **镜像信息**: - **仓库名称**:`ai-clinical_frontend-nginx` -- **镜像版本**:`v2.4` ✅(当前部署版本) +- **镜像版本**:`v2.7` ✅(当前部署版本) - **镜像大小**:约50MB - **基础镜像**:`nginx:alpine` - **构建时间**:2026-03-05 @@ -271,7 +271,7 @@ TEMP_DIR=/tmp/extraction_service **部署状态**: - ✅ 已成功部署到SAE(2026-03-05) -- ✅ 服务运行正常(内网地址:http://172.17.173.107:80) +- ✅ 服务运行正常(内网地址:http://172.17.197.27:80) - ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt) **v2.5版本更新内容**: @@ -293,11 +293,11 @@ AIclinicalresearch/frontend-v2/ ### 3.3 Node.js后端服务 -**当前部署版本**:v2.6 +**当前部署版本**:v2.10 **镜像信息**: - **仓库名称**:`backend-service` -- **镜像版本**:`v2.6` ✅(已部署) +- **镜像版本**:`v2.10` ✅(已部署) - **镜像大小**:~838MB - **基础镜像**:`node:alpine` - **构建时间**:2026-03-05 @@ -314,7 +314,7 @@ AIclinicalresearch/frontend-v2/ **部署状态**: - ✅ 已成功部署到SAE(2026-03-05) -- ✅ 服务运行正常(内网地址:http://172.17.173.106:3001) +- ✅ 服务运行正常(内网地址:http://172.17.173.109:3001) - ✅ 健康检查通过 **Git文件结构**: @@ -364,6 +364,38 @@ AIclinicalresearch/extraction_service/ ## 🔄 四、部署历史记录 +### 2026-03-09(0309二次部署 - DB补迁移 + R修复 + 后端/前端升级) + +#### 部署概览 +- **部署时间**:2026-03-09(第二轮) +- **部署范围**:数据库迁移(1项) + R统计引擎 + Node.js后端 + 前端Nginx +- **主要变更**:AIA 附件持久化、R 包诊断与错误映射修复、SSE 稳定性与用户友好重试 + +#### 数据库变更(1项) +- ✅ 应用迁移:`20260309_add_aia_attachments_persistence` +- ✅ 迁移状态:RDS 25/25,Schema Up To Date +- ✅ 备份文件:`backup_before_be_fe_deploy_20260309.dump`(约 47.99MB) + +#### R统计引擎更新(v1.0.2 → v1.0.5) +- ✅ 新增 `/api/v1/debug/packages` 运行时包诊断接口 +- ✅ 构建期关键包完整性校验(缺包即构建失败) +- ✅ 修复错误映射占位符未替换与 `%||%` 操作符缺失 +- ✅ 内网地址变更:`172.17.197.22` → `172.17.197.26` + +#### Node.js后端更新(v2.9 → v2.10) +- ✅ 部署 BE 变更(SSE 头部修复、优雅停机、AIA 附件链路稳定性、缓存护栏、短信能力) +- ✅ 内网地址变更:`172.17.173.108` → `172.17.173.109` + +#### 前端Nginx更新(v2.6 → v2.7) +- ✅ 部署 FE 变更(Nginx SSE 兼容 + SSA 网络异常友好提示与自动重试) +- ✅ 内网地址变更:`172.17.197.23` → `172.17.197.27` + +#### 环境变量同步 +- ✅ `nodejs-backend-test`:`R_SERVICE_URL=http://172.17.197.26:8080` +- ✅ `frontend-nginx-service`:`BACKEND_SERVICE_HOST=172.17.173.109` + +--- + ### 2026-03-09(0309部署 - 数据库4迁移 + R/后端/前端全量更新) #### 部署概览 @@ -725,4 +757,4 @@ AIclinicalresearch/extraction_service/ > **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新! > **最后更新**:2026-03-09 -> **当前版本**:前端v2.6 | 后端v2.9 | Python v1.2 | R统计v1.0.2 | PostgreSQL 15 +> **当前版本**:前端v2.7 | 后端v2.10 | Python v1.2 | R统计v1.0.5 | PostgreSQL 15 diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index 7f7fb9a9..e0fb3a05 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -3,7 +3,7 @@ > **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行 > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 -> **最后清零**: 2026-03-09(0309 部署完成后清零) +> **最后清零**: 2026-03-09(0309 二次部署完成后清零) --- @@ -15,24 +15,19 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| DB-1 | AIA 新增 `attachments` 持久化表(附件文本真相源) | `prisma/migrations/20260309_add_aia_attachments_persistence/migration.sql` | 高 | 解决“附件仅缓存”导致偶发“内容已过期或不存在”,支持缓存 miss 回源数据库 | +| — | *暂无* | | | | ### 后端变更 (Node.js) | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| BE-1 | 移除全部 SSE 端点 `Connection: keep-alive` 响应头(HTTP/2 禁止头部) | `chat.routes.ts`, `session.routes.ts`, `workflow.routes.ts`, `OpenAIStreamAdapter.ts`, `ExtractionController.ts`, `researchController.ts`, `conversationController.ts`, `chatController.ts`×2, `StreamAIController.ts` | 重新构建镜像 | 修复 SAE 环境下 SSE 流式响应 `ERR_HTTP2_PROTOCOL_ERROR` | -| BE-2 | 优雅停机增强:健康检查停机时返回 503 + 30s 强制超时兜底 | `healthCheck.ts`, `health/index.ts`, `index.ts` | 重新构建镜像 | CLB 在滚动更新时不再向濒死 Pod 派发请求 | -| BE-3 | AIA 附件链路稳定性修复(上传落库 + 发送回源 + 错误分层) | `aia/services/attachmentService.ts`, `aia/services/conversationService.ts` | 重新构建镜像 | 上传阶段持久化附件文本与提取状态;发送时缓存未命中自动回源 DB 并回填,显著降低“对话中途上传附件无法识别”概率 | -| BE-4 | 生产环境缓存安全护栏:禁止 `CACHE_TYPE=memory` 启动 | `config/env.ts` | 重新构建镜像 | 防止多实例缓存不共享导致附件/会话等状态偶发丢失,符合云原生规范 | -| BE-5 | 登录验证码接入阿里云短信(保留 mock 模式) | `auth.service.ts`, `common/sms/aliyunSms.service.ts`, `config/env.ts` | 重新构建镜像 | `sendVerificationCode` 改为真实短信发送;生产建议 `SMS_PROVIDER=aliyun`,开发可继续 `mock` | +| — | *暂无* | | | | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| FE-1 | Nginx `Connection` 头部条件化(`map $http_upgrade $connection_upgrade`) | `nginx.conf` | 重新构建镜像 | SSE 请求不再携带错误的 `Connection: upgrade`,WebSocket 不受影响 | -| FE-2 | SSA 对话网络错误友好提示 + 指数退避自动重试 2 次 + 手动重试按钮 | `useSSAChat.ts`, `SSAChatPane.tsx`, `ssa.css` | 重新构建镜像 | 瞬时网络错误自动重试 2 次(2s/4s 指数退避),失败后中文友好提示 + 蓝色重试按钮 | +| — | *暂无* | | | | ### Python 微服务变更 @@ -50,7 +45,7 @@ | # | 变更内容 | 服务 | 变量名 | 备注 | |---|---------|------|--------|------| -| ENV-1 | 新增短信网关配置(登录验证码) | nodejs-backend | `SMS_PROVIDER`,`SMS_ENDPOINT`,`SMS_SIGN_NAME`,`SMS_TEMPLATE_CODE_LOGIN`,`SMS_TEMPLATE_CODE_RESET`,`ALIBABA_CLOUD_ACCESS_KEY_ID`,`ALIBABA_CLOUD_ACCESS_KEY_SECRET` | 若生产使用阿里云短信,需在 SAE 配置完整变量;`SMS_TEMPLATE_CODE_RESET` 可选(默认复用登录模板) | +| — | *暂无* | | | | ### 基础设施变更 @@ -88,6 +83,17 @@ ## 历史(已部署,仅供追溯) +### 0309 二次部署已清零项 + +| # | 变更内容 | 部署日期 | 结果 | +|---|---------|---------|------| +| DB | AIA 新增 `attachments` 持久化表(`20260309_add_aia_attachments_persistence`) | 2026-03-09 | ✅ | +| R | v1.0.2 → v1.0.5(包诊断接口、构建期缺包校验、错误映射修复、`%||%` 修复) | 2026-03-09 | ✅ | +| BE | v2.9 → v2.10(SSE/优雅停机/AIA附件/短信能力等变更) | 2026-03-09 | ✅ | +| FE | v2.6 → v2.7(SSE 代理与友好重试体验优化) | 2026-03-09 | ✅ | +| ENV | nodejs-backend-test: `R_SERVICE_URL` → `http://172.17.197.26:8080` | 2026-03-09 | ✅ | +| ENV | frontend-nginx-service: `BACKEND_SERVICE_HOST` → `172.17.173.109` | 2026-03-09 | ✅ | + ### 0309 部署已清零项 | # | 变更内容 | 部署日期 | 结果 | diff --git a/docs/05-部署文档/0309部署/01-数据库部署完成总结.md b/docs/05-部署文档/0309部署/01-数据库部署完成总结.md index dbbb5e9b..da1ea32f 100644 --- a/docs/05-部署文档/0309部署/01-数据库部署完成总结.md +++ b/docs/05-部署文档/0309部署/01-数据库部署完成总结.md @@ -1,7 +1,7 @@ -# 2026年3月9日部署完成总结 +# 2026年3月9日部署完成总结(含二次部署) > **部署日期**:2026-03-09 -> **部署范围**:数据库迁移(4项) + 种子数据(3项) + R统计引擎 + Node.js后端 + 前端Nginx +> **部署范围**:数据库迁移(5项) + 种子数据(3项) + R统计引擎 + Node.js后端 + 前端Nginx(两轮) > **部署状态**:✅ 全部完成 > **文档日期**:2026-03-09 @@ -9,174 +9,144 @@ ## 部署成果一览 -### 服务版本对比 +### 服务版本对比(最终态) -| 服务 | 部署前 | 部署后 | 变更类型 | -|------|--------|--------|---------| -| PostgreSQL(RDS) | 20/24 迁移 | **24/24 迁移** | 4 个 Prisma 迁移 + 3 个种子脚本 | -| R统计引擎 | v1.0.1 | **v1.0.2** | 新增 execute-code + 错误处理 + AST 预检 | -| Node.js后端 | v2.8 | **v2.9** | 13 项后端变更(RVW/SSA/IIT/认证) | -| 前端Nginx | v2.5 | **v2.6** | 10 项前端变更(ASL/RVW/SSA/IIT/心跳) | +| 服务 | 当日部署前 | 当日部署后(最终) | 说明 | +|------|-----------|-------------------|------| +| PostgreSQL(RDS) | 20/25 迁移 | **25/25 迁移** | 4 项基础迁移 + 1 项 AIA attachments 迁移 | +| R统计引擎 | v1.0.1 | **v1.0.5** | execute-code + 错误处理 + 包诊断 + `%||%` 修复 | +| Node.js后端 | v2.8 | **v2.10** | SSE 稳定性 + 优雅停机 + AIA 附件链路 + 短信能力 | +| 前端Nginx | v2.5 | **v2.7** | SSE 代理优化 + 友好错误提示与自动重试 | | Python微服务 | v1.2 | v1.2(不变) | 无变更 | -### 内网地址变更 +### 当前内网地址(最终态) -| 服务 | 部署前地址 | 部署后地址 | 状态 | -|------|-----------|-----------|------| -| R统计引擎 | `172.17.173.101:8080` | `172.17.197.22:8080` | ✅ 已变更 | -| Node.js后端 | `172.17.173.106:3001` | `172.17.173.108:3001` | ✅ 已变更 | -| 前端Nginx | `172.17.173.107:80` | `172.17.197.23:80` | ✅ 已变更 | -| Python微服务 | `172.17.173.102:8000` | `172.17.173.102:8000` | 不变 | +| 服务 | 内网地址 | 状态 | +|------|---------|------| +| R统计引擎 | `172.17.197.26:8080` | ✅ | +| Node.js后端 | `172.17.173.109:3001` | ✅ | +| 前端Nginx | `172.17.197.27:80` | ✅ | +| Python微服务 | `172.17.173.102:8000` | 不变 | --- ## 一、数据库部署 -### 1.1 部署前准备 +### 1.1 部署前备份 | 项目 | 值 | |------|---| | 备份方式 | `pg_dump --format=custom` via Docker 容器 | -| 备份文件 | `backup_before_0309_deploy.dump` | -| 文件大小 | 46.9 MB | -| 备份时间 | 2026-03-09 08:05 | +| 备份文件(第一轮) | `backup_before_0309_deploy.dump` | +| 备份文件(第二轮) | `backup_before_be_fe_deploy_20260309.dump` | +| 第二轮备份大小 | 47,988,197 bytes(约 45.8MB) | -### 1.2 Prisma 迁移(4 项) +### 1.2 Prisma 迁移(5 项) 使用 `npx prisma migrate deploy`(生产命令)执行。 -| 序号 | 迁移名称 | 对应清单 | 变更内容 | 结果 | -|------|---------|---------|---------|------| -| 1 | `20260307_add_error_details_to_review_task` | DB-3 | `rvw_schema.review_tasks` 新增 `error_details` JSONB 列 | ✅ | -| 2 | `20260308_add_iit_equery_open_dedupe_guard` | DB-6 | 历史重复 open eQuery 收敛为 `auto_closed` + 部分唯一索引 | ✅ | -| 3 | `20260308_default_agent_mode` | DB-4 | `ssa_sessions.execution_mode` 默认值改为 `agent` + 21 条旧数据更新 | ✅ | -| 4 | `20260309_add_token_version_to_platform_users` | DB-7 | `platform_schema.users` 新增 `token_version` INTEGER 列(默认 0) | ✅ | +| 序号 | 迁移名称 | 对应清单 | 结果 | +|------|---------|---------|------| +| 1 | `20260307_add_error_details_to_review_task` | DB-3 | ✅ | +| 2 | `20260308_add_iit_equery_open_dedupe_guard` | DB-6 | ✅ | +| 3 | `20260308_default_agent_mode` | DB-4 | ✅ | +| 4 | `20260309_add_token_version_to_platform_users` | DB-7 | ✅ | +| 5 | `20260309_add_aia_attachments_persistence` | DB-1(二次部署) | ✅ | -### 1.3 种子数据(3 项) - -| 序号 | 脚本 | 对应清单 | 内容 | 结果 | -|------|------|---------|------|------| -| 1 | `npx tsx scripts/seed-modules.js` | DB-1 | upsert 11 个 modules(新增 ASL_SR) | ✅ | -| 2 | `npx tsx scripts/migrate-rvw-prompts.ts` | DB-2 | upsert 4 个 RVW Prompt(新增 DATA_VALIDATION + CLINICAL) | ✅ | -| 3 | `npx tsx prisma/seed-ssa-agent-prompts.ts` | DB-5 | upsert 2 个 SSA Agent Prompt(PLANNER + CODER) | ✅ | - -### 1.4 数据库最终状态 +### 1.3 数据库最终状态 | 项目 | 值 | |------|---| -| Prisma 迁移 | 24/24 ✅(本地与 RDS 完全同步) | +| Prisma 迁移 | **25/25 ✅** | | Schema 数 | 16 | -| modules 模块数 | 11(含 ASL_SR) | -| RVW Prompt 模板 | 4(含 DATA_VALIDATION + CLINICAL) | -| SSA Agent Prompt | 2(PLANNER + CODER) | +| 同步状态 | 本地与 RDS 一致 | --- -## 二、R 统计引擎更新(v1.0.1 → v1.0.2) +## 二、R 统计引擎更新(v1.0.1 → v1.0.5) | 项目 | 值 | |------|---| | ACR 仓库 | `ssa-r-statistics` | -| 镜像版本 | v1.0.1 → **v1.0.2** | -| Digest | `sha256:7c24b688ee7e5e1e61d6f2821902ab825efc5a4113d0f99f92d9c63deebcd79d` | -| 内网地址 | `http://172.17.197.22:8080` | +| 最终镜像版本 | **v1.0.5** | +| v1.0.5 Digest | `sha256:63d45f9cf28116d686fc4a36a1f82fef78f863066b4c3018cd812bf9b94e143a` | +| 内网地址 | `http://172.17.197.26:8080` | -变更内容(3 项): -- ✅ R-1:新增 POST `/api/v1/execute-code` 端点(Agent 通道任意 R 代码执行) -- ✅ R-2:Agent 结构化错误处理增强(20+ 模式匹配 + format_agent_error) -- ✅ R-3:AST 语法预检(parse() 前置于 eval()) +关键变更: +- ✅ `/api/v1/execute-code` 增强(结构化错误、AST 语法预检) +- ✅ 新增 `/api/v1/debug/packages` 运行时包诊断接口 +- ✅ 构建期关键包完整性校验(缺包即构建失败) +- ✅ 修复错误映射占位符未替换(`{package}`)与 `%||%` 操作符缺失 --- -## 三、Node.js 后端更新(v2.8 → v2.9) +## 三、Node.js 后端更新(v2.8 → v2.10) | 项目 | 值 | |------|---| | ACR 仓库 | `backend-service` | -| 镜像版本 | v2.8 → **v2.9** | -| Digest | `sha256:b28b14e4f7aec66102e7e039d6d910c1e957c7903329d1ba6b4ac20ebbd078f9` | -| 内网地址 | `http://172.17.173.108:3001` | +| 最终镜像版本 | **v2.10** | +| Digest | `sha256:7194bab89251583d2fcc8356cfd7ed528ff1ce3e0416662250ace9f022bb5002` | +| 内网地址 | `http://172.17.173.109:3001` | -变更内容(13 项): -- ✅ BE-1:Deep Research V2.0 历史列表 + 删除接口 + getTask 鉴权修复 -- ✅ BE-2:SR 相关路由增加 `requireModule('ASL_SR')` 中间件 -- ✅ BE-3:Unifuncs DeepSearch API S2 → S3(新增 `language: "zh"`) -- ✅ BE-4:RVW 数据验证增加 LLM 核查通道 -- ✅ BE-5:RVW 新增临床专业评估维度(ClinicalAssessmentSkill) -- ✅ BE-6:RVW 稳定性增强(Promise.allSettled + partial_completed) -- ✅ BE-7:DataForensicsSkill LLM 核查独立 60s 超时 -- ✅ BE-8:SSA Agent 通道体验优化(方案 B + 10 项 Bug 修复) -- ✅ BE-9:Phase 5A CoderAgent 防错护栏(4 项改动) -- ✅ BE-10:SSA Agent Prompt 接入运营管理端(三级容灾) -- ✅ BE-11:IIT eQuery 幂等写入 + 去重工具脚本 -- ✅ BE-12:IIT 事件名称友好化 + AI 对话证据块补齐 -- ✅ BE-13:认证链路改造为数据库强一致互踢(tokenVersion) +关键变更: +- ✅ SSE 兼容修复(移除 HTTP/2 禁止头部) +- ✅ 优雅停机增强(停机期健康检查 503 + 超时兜底) +- ✅ AIA 附件持久化与回源链路稳定性修复 +- ✅ 生产缓存安全护栏(禁用 memory) +- ✅ 阿里云短信接入(保留 mock 模式) --- -## 四、前端 Nginx 更新(v2.5 → v2.6) +## 四、前端 Nginx 更新(v2.5 → v2.7) | 项目 | 值 | |------|---| | ACR 仓库 | `ai-clinical_frontend-nginx` | -| 镜像版本 | v2.5 → **v2.6** | -| Digest | `sha256:da4c9fcfe135b25bcac5143e3f919d8a3a205f53d8b0e930e32f6b8325d2cb70` | -| 内网地址 | `http://172.17.197.23:80` | +| 最终镜像版本 | **v2.7** | +| Digest | `sha256:cb1d0776e29bd0326cf0ce796f31c8b529e0c5171b6522cf013af43e1f3f68f6` | +| 内网地址 | `http://172.17.197.27:80` | -变更内容(10 项): -- ✅ FE-1:ASL 左侧导航栏重构为互斥手风琴 -- ✅ FE-2:Deep Research 历史记录功能 -- ✅ FE-3:Panel B SR 工具导航权限控制 -- ✅ FE-4:RVW 数据验证报告增加 LLM 核查结果展示 -- ✅ FE-5:RVW 新增临床专业评估 Tab + Agent 选择项 -- ✅ FE-6:RVW 前端支持 partial_completed 状态 -- ✅ FE-7:SSA Agent 通道体验优化(方案 B + 动态 UI) -- ✅ FE-8:SSA 默认 Agent 模式 + 查看代码修复 + 分析历史卡片 -- ✅ FE-9:IIT D1 筛选入选表规则名称友好显示 -- ✅ FE-10:全局会话心跳(10s)提升互踢感知时效 +关键变更: +- ✅ Nginx SSE 代理兼容配置(Connection 条件化、缓存/缓冲策略) +- ✅ SSA 对话网络错误友好提示 +- ✅ 指数退避自动重试(2 次)+ 手动重试按钮 --- -## 五、环境变量联动更新 +## 五、环境变量联动更新(最终态) -| 服务 | 环境变量 | 旧值 | 新值 | -|------|---------|------|------| -| nodejs-backend-test | `R_SERVICE_URL` | `http://172.17.173.101:8080` | `http://172.17.197.22:8080` | -| frontend-nginx-service | `BACKEND_SERVICE_HOST` | `172.17.173.106` | `172.17.173.108` | +| 服务 | 环境变量 | 新值 | +|------|---------|------| +| nodejs-backend-test | `R_SERVICE_URL` | `http://172.17.197.26:8080` | +| frontend-nginx-service | `BACKEND_SERVICE_HOST` | `172.17.173.109` | -> CLB 负载均衡器由阿里云自动更新,无需手动操作。 +> CLB 由阿里云自动更新,无需手动操作。 --- -## 六、当前系统配置速查 +## 六、当前系统配置速查(最终) ### 服务内网地址 ``` -R统计引擎: http://172.17.197.22:8080 (更新) -Python: http://172.17.173.102:8000 (不变) -后端: http://172.17.173.108:3001 (更新) -前端: http://172.17.197.23:80 (更新) +R统计引擎: http://172.17.197.26:8080 +Python: http://172.17.173.102:8000 +后端: http://172.17.173.109:3001 +前端: http://172.17.197.27:80 ``` ### ACR 镜像版本 | 仓库 | 版本 | |------|-----| -| `ssa-r-statistics` | **v1.0.2** | +| `ssa-r-statistics` | **v1.0.5** | | `python-extraction` | v1.2 | -| `backend-service` | **v2.9** | -| `ai-clinical_frontend-nginx` | **v2.6** | - -### 公网访问 - -``` -CLB: http://8.140.53.236/ -域名: https://iit.xunzhengyixue.com/ -``` +| `backend-service` | **v2.10** | +| `ai-clinical_frontend-nginx` | **v2.7** | --- -> **文档版本**:v2.0 +> **文档版本**:v3.0 > **最后更新**:2026-03-09 > **维护人员**:开发团队 diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index f544e871..3ada1419 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -34,7 +34,7 @@ const PRIMARY_COLOR = '#10b981' * - 权限检查:SUPER_ADMIN / PROMPT_ENGINEER */ const AdminLayout = () => { - const { isAuthenticated, isLoading, user, logout } = useAuth() + const { isAuthenticated, isLoading, user, logout, hasPermission } = useAuth() const location = useLocation() const navigate = useNavigate() const [collapsed, setCollapsed] = useState(false) @@ -55,8 +55,9 @@ const AdminLayout = () => { // 权限检查:可进入管理端的角色 const adminAllowedRoles = ['SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR', 'PHARMA_ADMIN', 'HOSPITAL_ADMIN'] + const hasUserOps = hasPermission('ops:user-ops') const userRole = user?.role || '' - if (!adminAllowedRoles.includes(userRole)) { + if (!adminAllowedRoles.includes(userRole) && !hasUserOps) { return (
@@ -133,6 +134,10 @@ const AdminLayout = () => { } else if (userRole === 'PHARMA_ADMIN' || userRole === 'HOSPITAL_ADMIN') { items.push(projectGroup) } + if (hasUserOps && userRole !== 'SUPER_ADMIN') { + if (items.length > 0) items.push({ type: 'divider' }) + items.push(bizGroup) + } return items })() diff --git a/frontend-v2/src/framework/layout/TopNavigation.tsx b/frontend-v2/src/framework/layout/TopNavigation.tsx index 7399d24e..4f8d77d7 100644 --- a/frontend-v2/src/framework/layout/TopNavigation.tsx +++ b/frontend-v2/src/framework/layout/TopNavigation.tsx @@ -11,6 +11,7 @@ import { import type { MenuProps } from 'antd' import { MODULES } from '../modules/moduleRegistry' import { useAuth } from '../auth' +import apiClient from '../../common/api/axios' /** * 顶部导航栏组件 @@ -25,7 +26,7 @@ import { useAuth } from '../auth' const TopNavigation = () => { const navigate = useNavigate() const location = useLocation() - const { user, logout: authLogout, hasModule } = useAuth() + const { user, logout: authLogout, hasModule, hasPermission } = useAuth() // 根据用户模块权限过滤可显示的模块 const availableModules = MODULES.filter(module => { @@ -40,7 +41,20 @@ const TopNavigation = () => { // 检查用户权限,决定显示哪些切换入口 const userRole = user?.role || '' - const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR'].includes(userRole) + const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR'].includes(userRole) || hasPermission('ops:user-ops') + const reportTopNavClick = async (moduleName: string): Promise => { + try { + await apiClient.post('/api/v1/auth/activity', { + module: 'SYSTEM', + feature: '顶部导航点击', + action: 'CLICK', + info: moduleName, + }) + } catch { + // 埋点失败不影响导航 + } + } + const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole) // 用户菜单 - 动态构建 @@ -121,6 +135,7 @@ const TopNavigation = () => {
{ + void reportTopNavClick(module.name) if (module.isExternal && module.externalUrl) { window.open(module.externalUrl, '_blank', 'noopener'); } else { diff --git a/frontend-v2/src/modules/admin/api/statsApi.ts b/frontend-v2/src/modules/admin/api/statsApi.ts index a312c6f8..ace48603 100644 --- a/frontend-v2/src/modules/admin/api/statsApi.ts +++ b/frontend-v2/src/modules/admin/api/statsApi.ts @@ -2,14 +2,21 @@ * 运营统计 API */ -import { authRequest } from '@/framework/request'; +import apiClient from '@/common/api/axios'; // ==================== 类型定义 ==================== export interface OverviewData { dau: number; + mau: number; dat: number; exportCount: number; + apiTokenTotal: number; + topActiveUser: { + userId: string; + userName: string | null; + actionCount: number; + } | null; moduleStats: Record; } @@ -54,29 +61,29 @@ export interface UserOverview { * 获取今日大盘数据 */ export async function getOverview(): Promise { - const res = await authRequest.get<{ success: boolean; data: OverviewData }>( + const res = await apiClient.get<{ success: boolean; data: OverviewData }>( '/api/admin/stats/overview' ); - return res.data; + return res.data.data; } /** * 获取实时流水账 */ export async function getLiveFeed(limit = 100): Promise { - const res = await authRequest.get<{ success: boolean; data: ActivityLog[] }>( + const res = await apiClient.get<{ success: boolean; data: ActivityLog[] }>( `/api/admin/stats/live-feed?limit=${limit}` ); - return res.data; + return res.data.data; } /** * 获取用户360画像 */ export async function getUserOverview(userId: string): Promise { - const res = await authRequest.get<{ success: boolean; data: UserOverview }>( + const res = await apiClient.get<{ success: boolean; data: UserOverview }>( `/api/admin/users/${userId}/overview` ); - return res.data; + return res.data.data; } diff --git a/frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx b/frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx index daa146db..022c199e 100644 --- a/frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx +++ b/frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx @@ -11,6 +11,8 @@ import { UserOutlined, BankOutlined, ExportOutlined, + FireOutlined, + ApiOutlined, MessageOutlined, BookOutlined, SearchOutlined, @@ -23,7 +25,7 @@ import { ReloadOutlined, } from '@ant-design/icons'; import { getOverview, getLiveFeed } from '../api/statsApi'; -import type { OverviewData, ActivityLog } from '../api/statsApi'; +import type { ActivityLog } from '../api/statsApi'; // ==================== 模块图标映射 ==================== @@ -174,7 +176,7 @@ export default function StatsDashboardPage() { {/* 核心指标卡片 */} - + 今日活跃医生 (DAU)} @@ -185,7 +187,18 @@ export default function StatsDashboardPage() { /> - + + + 近30天活跃用户 (MAU)} + value={overview?.mau ?? 0} + prefix={} + loading={overviewLoading} + valueStyle={{ color: '#4f46e5', fontWeight: 'bold' }} + /> + + + 今日活跃租户 (DAT)} @@ -196,7 +209,7 @@ export default function StatsDashboardPage() { /> - + 今日导出次数} @@ -209,6 +222,31 @@ export default function StatsDashboardPage() { + + + + 今日最活跃用户} + value={overview?.topActiveUser?.userName || '暂无'} + prefix={} + suffix={overview?.topActiveUser ? `${overview.topActiveUser.actionCount} 次` : ''} + loading={overviewLoading} + /> + + + + + 今日 API Token 用量} + value={overview?.apiTokenTotal ?? 0} + prefix={} + loading={overviewLoading} + valueStyle={{ color: '#0891b2', fontWeight: 'bold' }} + /> + + + + {/* 模块使用统计 */} {overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && ( 0) { stop(paste('Missing required R packages:', paste(missing, collapse=', '))) } else { cat('All required R packages installed.\\n') }" + # ===== 安全加固:创建非特权用户 ===== RUN useradd -m -s /bin/bash appuser diff --git a/r-statistics-service/plumber.R b/r-statistics-service/plumber.R index 45d30ee0..3048f588 100644 --- a/r-statistics-service/plumber.R +++ b/r-statistics-service/plumber.R @@ -11,6 +11,9 @@ library(jsonlite) # 环境配置 DEV_MODE <- Sys.getenv("DEV_MODE", "false") == "true" +# 空值合并操作符(避免 `%||%` 未定义导致 execute-code 入口报错) +`%||%` <- function(x, y) if (is.null(x)) y else x + # 加载公共函数 source("utils/error_codes.R") source("utils/data_loader.R") @@ -116,6 +119,32 @@ function() { ) } +#* 诊断:返回 R 运行时包清单(只读) +#* @get /api/v1/debug/packages +#* @serializer unboxedJSON +function() { + required_packages <- c( + "plumber", "jsonlite", "ggplot2", "glue", "dplyr", "tidyr", + "base64enc", "yaml", "car", "httr", "scales", "gridExtra", + "gtsummary", "gt", "broom", "meta" + ) + + installed <- rownames(installed.packages()) + missing <- setdiff(required_packages, installed) + + list( + status = "ok", + r_version = R.version.string, + dev_mode = DEV_MODE, + lib_paths = .libPaths(), + required_count = length(required_packages), + installed_count = length(installed), + missing_required = missing, + required_status = if (length(missing) == 0) "complete" else "incomplete", + sample_installed = head(sort(installed), 120) + ) +} + #* JIT Guardrails Check #* @post /api/v1/guardrails/jit #* @serializer unboxedJSON diff --git a/r-statistics-service/utils/error_codes.R b/r-statistics-service/utils/error_codes.R index 72f6b602..fcd41380 100644 --- a/r-statistics-service/utils/error_codes.R +++ b/r-statistics-service/utils/error_codes.R @@ -60,6 +60,12 @@ ERROR_CODES <- list( type = "system", message_template = "缺少依赖包: {package}", user_hint = "请联系管理员" + ), + E102_FUNCTION_NOT_FOUND = list( + code = "E102", + type = "business", + message_template = "找不到函数: {func}", + user_hint = "请检查函数名是否正确,或确认已加载相关包" ) ) @@ -76,7 +82,7 @@ R_ERROR_MAPPING <- list( "not meaningful for factors" = "E002_TYPE_MISMATCH", "missing value where TRUE/FALSE needed" = "E100_INTERNAL_ERROR", "replacement has" = "E100_INTERNAL_ERROR", - "could not find function" = "E101_PACKAGE_MISSING", + "could not find function" = "E102_FUNCTION_NOT_FOUND", "there is no package called" = "E101_PACKAGE_MISSING", "cannot open the connection" = "E100_INTERNAL_ERROR", "singular gradient" = "E005_SINGULAR_MATRIX", @@ -167,6 +173,34 @@ map_r_error <- function(raw_error_msg) { for (pattern in names(R_ERROR_MAPPING)) { if (grepl(pattern, raw_error_msg, ignore.case = TRUE)) { error_key <- R_ERROR_MAPPING[[pattern]] + + # E101: 提取缺失包名(there is no package called 'xxx') + if (error_key == "E101_PACKAGE_MISSING") { + pkg <- "unknown" + m <- regexec("there is no package called ['\"]([^'\"]+)['\"]", raw_error_msg, ignore.case = TRUE) + mm <- regmatches(raw_error_msg, m)[[1]] + if (length(mm) >= 2) pkg <- mm[2] + return(make_error(ERROR_CODES[[error_key]], package = pkg)) + } + + # E102: 提取找不到的函数名(could not find function "xxx") + if (error_key == "E102_FUNCTION_NOT_FOUND") { + func <- "unknown" + m <- regexec("could not find function ['\"]([^'\"]+)['\"]", raw_error_msg, ignore.case = TRUE) + mm <- regmatches(raw_error_msg, m)[[1]] + if (length(mm) >= 2) func <- mm[2] + return(make_error(ERROR_CODES[[error_key]], func = func)) + } + + # E001: 尝试提取缺失对象名(object 'xxx' not found) + if (error_key == "E001_COLUMN_NOT_FOUND") { + col <- "unknown" + m <- regexec("object ['\"]([^'\"]+)['\"] not found", raw_error_msg, ignore.case = TRUE) + mm <- regmatches(raw_error_msg, m)[[1]] + if (length(mm) >= 2) col <- mm[2] + return(make_error(ERROR_CODES[[error_key]], col = col)) + } + return(make_error(ERROR_CODES[[error_key]], details = raw_error_msg)) } }