feat(admin): Implement operational monitoring MVP and login optimization

Summary:
- Add SimpleLog table for activity tracking (admin_schema)
- Implement ActivityService with fire-and-forget pattern
- Add stats API endpoints (overview/live-feed/user-overview/cleanup)
- Complete activity logging for 7 modules (SYSTEM/AIA/PKB/ASL/DC/RVW/IIT)
- Update Admin Dashboard with DAU/DAT metrics and live feed
- Fix user module permission display logic
- Fix login redirect to /ai-qa instead of homepage
- Replace top navigation LOGO with brand image
- Fix PKB workspace layout CSS conflict (rename to .pa-chat-container)

New files:
- backend/src/common/services/activity.service.ts
- backend/src/modules/admin/controllers/statsController.ts
- backend/src/modules/admin/routes/statsRoutes.ts
- frontend-v2/src/modules/admin/api/statsApi.ts
- docs/03-.../04-operational-monitoring-mvp-plan.md
- docs/03-.../04-operational-monitoring-mvp-implementation.md

Tested: All features verified locally
This commit is contained in:
2026-01-25 22:16:16 +08:00
parent 303dd78c54
commit 01a17f1e6f
36 changed files with 2962 additions and 95 deletions

View File

@@ -0,0 +1,54 @@
### ===========================================
### 运营统计 API 测试
### 需要先登录获取 Token
### ===========================================
### 变量定义
@baseUrl = http://localhost:3001
@adminPhone = 13800138000
@adminPassword = admin123
### ===========================================
### 1. 登录获取 Token (SUPER_ADMIN)
### ===========================================
# @name login
POST {{baseUrl}}/api/v1/auth/login/password
Content-Type: application/json
{
"phone": "{{adminPhone}}",
"password": "{{adminPassword}}"
}
### 保存 Token
@token = {{login.response.body.data.tokens.accessToken}}
@userId = {{login.response.body.data.user.id}}
### ===========================================
### 2. 获取今日大盘数据 (DAU/DAT/导出数)
### ===========================================
# @name overview
GET {{baseUrl}}/api/admin/stats/overview
Authorization: Bearer {{token}}
### ===========================================
### 3. 获取实时流水账
### ===========================================
# @name liveFeed
GET {{baseUrl}}/api/admin/stats/live-feed?limit=50
Authorization: Bearer {{token}}
### ===========================================
### 4. 获取用户360画像
### ===========================================
# @name userOverview
GET {{baseUrl}}/api/admin/users/{{userId}}/overview
Authorization: Bearer {{token}}
### ===========================================
### 5. 清理过期日志(谨慎使用)
### ===========================================
# @name cleanup
POST {{baseUrl}}/api/admin/stats/cleanup
Authorization: Bearer {{token}}

View File

@@ -0,0 +1,124 @@
# ===========================================
# Operations Stats API Test
# ===========================================
$baseUrl = "http://localhost:3001"
$phone = "13800000001" # SUPER_ADMIN
$password = "123456"
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host "Operations Stats API Test" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
# 1. Login
Write-Host "`n[1/4] Login..." -ForegroundColor Yellow
$loginBody = @{
phone = $phone
password = $password
} | ConvertTo-Json
try {
$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/auth/login/password" `
-Method POST `
-Body $loginBody `
-ContentType "application/json"
$token = $loginResponse.data.tokens.accessToken
$userId = $loginResponse.data.user.id
$userName = $loginResponse.data.user.name
Write-Host " [OK] Login success!" -ForegroundColor Green
Write-Host " User: $userName ($userId)" -ForegroundColor Gray
} catch {
Write-Host " [FAIL] Login failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
$headers = @{
"Authorization" = "Bearer $token"
}
# 2. Test Overview
Write-Host "`n[2/4] Get Overview (DAU/DAT)..." -ForegroundColor Yellow
try {
$overviewResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/stats/overview" `
-Method GET `
-Headers $headers
Write-Host " [OK] Success!" -ForegroundColor Green
Write-Host " ----------------------------" -ForegroundColor Gray
Write-Host " DAU (Daily Active Users): $($overviewResponse.data.dau)" -ForegroundColor White
Write-Host " DAT (Daily Active Tenants): $($overviewResponse.data.dat)" -ForegroundColor White
Write-Host " Export Count: $($overviewResponse.data.exportCount)" -ForegroundColor White
if ($overviewResponse.data.moduleStats) {
Write-Host " Module Stats:" -ForegroundColor White
$overviewResponse.data.moduleStats.PSObject.Properties | ForEach-Object {
Write-Host " $($_.Name): $($_.Value)" -ForegroundColor Gray
}
}
} catch {
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
}
# 3. Test Live Feed
Write-Host "`n[3/4] Get Live Feed..." -ForegroundColor Yellow
try {
$liveFeedResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/stats/live-feed?limit=10" `
-Method GET `
-Headers $headers
$logsCount = $liveFeedResponse.data.Count
Write-Host " [OK] Success! Total: $logsCount records" -ForegroundColor Green
if ($logsCount -gt 0) {
Write-Host " ----------------------------" -ForegroundColor Gray
Write-Host " Recent 5 activities:" -ForegroundColor White
$liveFeedResponse.data | Select-Object -First 5 | ForEach-Object {
$time = [DateTime]::Parse($_.createdAt).ToString("HH:mm:ss")
$tenant = if ($_.tenantName) { $_.tenantName } else { "-" }
$user = if ($_.userName) { $_.userName } else { "-" }
Write-Host " $time | $($_.action) | [$($_.module)] $($_.feature) | $user@$tenant" -ForegroundColor Gray
}
} else {
Write-Host " (No records yet)" -ForegroundColor Gray
}
} catch {
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
}
# 4. Test User Overview
Write-Host "`n[4/4] Get User Overview (360 Profile)..." -ForegroundColor Yellow
try {
$userOverviewResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/users/$userId/overview" `
-Method GET `
-Headers $headers
Write-Host " [OK] Success!" -ForegroundColor Green
Write-Host " ----------------------------" -ForegroundColor Gray
$profile = $userOverviewResponse.data.profile
$assets = $userOverviewResponse.data.assets
Write-Host " User: $($profile.name) ($($profile.phone))" -ForegroundColor White
Write-Host " Tenant: $($profile.tenantName)" -ForegroundColor White
Write-Host " Assets:" -ForegroundColor White
Write-Host " AIA Conversations: $($assets.aia.conversationCount)" -ForegroundColor Gray
Write-Host " PKB KBs: $($assets.pkb.kbCount) ($($assets.pkb.docCount) docs)" -ForegroundColor Gray
Write-Host " DC Tasks: $($assets.dc.taskCount)" -ForegroundColor Gray
Write-Host " RVW Reviews: $($assets.rvw.reviewTaskCount) (completed: $($assets.rvw.completedCount))" -ForegroundColor Gray
$activitiesCount = $userOverviewResponse.data.activities.Count
Write-Host " Recent Activities: $activitiesCount" -ForegroundColor White
} catch {
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
}
Write-Host "`n=============================================" -ForegroundColor Cyan
Write-Host "Test Complete!" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan

View File

@@ -0,0 +1,126 @@
/**
* Stats Controller - 运营统计控制器
*
* 提供运营看板数据接口
*
* @version 1.0.0
* @date 2026-01-25
*/
import type { FastifyRequest, FastifyReply } from 'fastify';
import { activityService } from '../../../common/services/activity.service.js';
import { logger } from '../../../common/logging/index.js';
/**
* 获取今日大盘数据
* GET /api/admin/stats/overview
*/
export async function getOverview(
request: FastifyRequest,
reply: FastifyReply
) {
try {
const data = await activityService.getTodayOverview();
return reply.send({
success: true,
data,
});
} catch (error: any) {
logger.error('[StatsController] 获取大盘数据失败', { error: error.message });
return reply.status(500).send({
success: false,
message: error.message || '获取大盘数据失败',
});
}
}
/**
* 获取实时流水账
* GET /api/admin/stats/live-feed?limit=100
*/
export async function getLiveFeed(
request: FastifyRequest<{ Querystring: { limit?: string } }>,
reply: FastifyReply
) {
try {
const limit = Math.min(Number(request.query.limit) || 100, 500); // 最大500条
const data = await activityService.getLiveFeed(limit);
return reply.send({
success: true,
data,
});
} catch (error: any) {
logger.error('[StatsController] 获取流水账失败', { error: error.message });
return reply.status(500).send({
success: false,
message: error.message || '获取流水账失败',
});
}
}
/**
* 获取用户360画像
* GET /api/admin/users/:id/overview
*/
export async function getUserOverview(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
try {
const { id } = request.params;
if (!id) {
return reply.status(400).send({
success: false,
message: '用户ID不能为空',
});
}
const data = await activityService.getUserOverview(id);
if (!data.profile) {
return reply.status(404).send({
success: false,
message: '用户不存在',
});
}
return reply.send({
success: true,
data,
});
} catch (error: any) {
logger.error('[StatsController] 获取用户画像失败', { error: error.message });
return reply.status(500).send({
success: false,
message: error.message || '获取用户画像失败',
});
}
}
/**
* 清理过期日志(管理接口)
* POST /api/admin/stats/cleanup
*/
export async function cleanupLogs(
request: FastifyRequest,
reply: FastifyReply
) {
try {
const deletedCount = await activityService.cleanupOldLogs();
return reply.send({
success: true,
data: {
deletedCount,
message: `已清理 ${deletedCount} 条过期日志`,
},
});
} catch (error: any) {
logger.error('[StatsController] 清理日志失败', { error: error.message });
return reply.status(500).send({
success: false,
message: error.message || '清理日志失败',
});
}
}

View File

@@ -273,3 +273,37 @@ export async function getUserModules(
}
}
/**
* 获取租户登录页配置公开API无需认证
* GET /api/v1/public/tenant-config/:tenantCode
*
* 用于前端租户专属登录页获取配置信息
*/
export async function getTenantLoginConfig(
request: FastifyRequest<{ Params: { tenantCode: string } }>,
reply: FastifyReply
) {
try {
const { tenantCode } = request.params;
const config = await tenantService.getTenantLoginConfig(tenantCode);
if (!config) {
return reply.status(404).send({
success: false,
message: '租户不存在或已禁用',
});
}
return reply.send({
success: true,
data: config,
});
} catch (error: any) {
logger.error('[TenantController] 获取租户登录配置失败', { error: error.message });
return reply.status(500).send({
success: false,
message: error.message || '获取租户登录配置失败',
});
}
}

View File

@@ -0,0 +1,68 @@
/**
* Stats Routes - 运营统计路由
*
* API 前缀: /api/admin/stats
*
* @version 1.0.0
* @date 2026-01-25
*/
import type { FastifyInstance } from 'fastify';
import * as statsController from '../controllers/statsController.js';
import { authenticate, requirePermission } from '../../../common/auth/auth.middleware.js';
export async function statsRoutes(fastify: FastifyInstance) {
// ==================== 运营统计 ====================
/**
* 获取今日大盘数据 (DAU/DAT/导出数)
* GET /api/admin/stats/overview
*
* 权限: SUPER_ADMIN, PROMPT_ENGINEER
*/
fastify.get('/overview', {
preHandler: [authenticate, requirePermission('tenant:view')],
handler: statsController.getOverview,
});
/**
* 获取实时流水账
* GET /api/admin/stats/live-feed?limit=100
*
* 权限: SUPER_ADMIN, PROMPT_ENGINEER
*/
fastify.get('/live-feed', {
preHandler: [authenticate, requirePermission('tenant:view')],
handler: statsController.getLiveFeed,
});
/**
* 清理过期日志
* POST /api/admin/stats/cleanup
*
* 权限: SUPER_ADMIN
*/
fastify.post('/cleanup', {
preHandler: [authenticate, requirePermission('tenant:delete')],
handler: statsController.cleanupLogs,
});
}
/**
* 用户概览路由(挂载到 /api/admin/users
*
* 需要单独注册,因为路径是 /api/admin/users/:id/overview
*/
export async function userOverviewRoute(fastify: FastifyInstance) {
/**
* 获取用户360画像
* GET /api/admin/users/:id/overview
*
* 权限: SUPER_ADMIN
*/
fastify.get('/:id/overview', {
preHandler: [authenticate, requirePermission('user:view')],
handler: statsController.getUserOverview,
});
}

View File

@@ -301,6 +301,48 @@ class TenantService {
};
});
}
/**
* 获取租户登录页配置公开API
* 用于前端租户专属登录页获取配置信息
*/
async getTenantLoginConfig(tenantCode: string): Promise<{
name: string;
logo?: string;
primaryColor: string;
systemName: string;
modules: string[];
isReviewOnly: boolean;
} | null> {
// 根据 code 查找租户
const tenant = await prisma.tenants.findUnique({
where: { code: tenantCode },
include: {
tenant_modules: {
where: { is_enabled: true },
},
},
});
if (!tenant || tenant.status !== 'ACTIVE') {
return null;
}
// 获取启用的模块代码列表
const modules = tenant.tenant_modules.map(tm => tm.module_code);
// 判断是否是纯审稿租户
const isReviewOnly = modules.length === 1 && modules[0] === 'RVW';
return {
name: tenant.name,
logo: undefined, // TODO: 未来可从 tenant 扩展字段获取
primaryColor: isReviewOnly ? '#6366f1' : '#1890ff',
systemName: isReviewOnly ? '智能审稿系统' : 'AI临床研究平台',
modules,
isReviewOnly,
};
}
}
export const tenantService = new TenantService();

View File

@@ -250,12 +250,20 @@ export async function getUserById(userId: string, scope: UserQueryScope): Promis
);
// 计算最终模块权限
// 修复逻辑:如果用户有任何自定义模块配置,则没有配置的模块默认关闭
// 如果用户没有任何自定义配置,则继承租户的全部模块权限
const hasCustomModuleConfig = userModulesInTenant.length > 0;
const allowedModules = tenantModules.map((tm) => {
const userModule = userModulesInTenant.find((um) => um.module_code === tm.module_code);
return {
code: tm.module_code,
name: getModuleName(tm.module_code),
isEnabled: userModule ? userModule.is_enabled : true, // 默认继承租户权限
// 有自定义配置:必须有记录且启用才显示为启用
// 无自定义配置:继承租户权限(全部显示为启用)
isEnabled: hasCustomModuleConfig
? (userModule ? userModule.is_enabled : false)
: true,
};
});

View File

@@ -17,6 +17,7 @@ import { streamChat, createStreamingService } from '../../../common/streaming/in
import type { OpenAIMessage, StreamOptions } from '../../../common/streaming/index.js';
import * as agentService from './agentService.js';
import * as attachmentService from './attachmentService.js';
import { activityService } from '../../../common/services/activity.service.js';
import type {
Conversation,
Message,
@@ -360,6 +361,27 @@ export async function sendMessageStream(
tokens: aiMessage.tokens,
hasThinking: !!thinkingContent,
});
// 9. 埋点:记录智能体使用
try {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { tenants: true }
});
if (user) {
const agent = await agentService.getAgentById(conversation.agentId);
activityService.log(
user.tenant_id,
user.tenants?.name || null,
userId,
user.name,
'AIA',
agent?.name || conversation.agentId,
'USE',
`tokens: ${aiMessage.tokens}`
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
},
onError: (error) => {
logger.error('[AIA:ConversationService] 流式生成失败', {

View File

@@ -13,6 +13,7 @@ import { llmScreeningService } from './llmScreeningService.js';
import { jobQueue } from '../../../common/jobs/index.js';
import { CheckpointService } from '../../../common/jobs/CheckpointService.js';
import type { Job } from '../../../common/jobs/types.js';
import { activityService } from '../../../common/services/activity.service.js';
// 创建断点服务实例
const checkpointService = new CheckpointService(prisma);
@@ -123,7 +124,7 @@ export function registerScreeningWorkers() {
if (completedBatches >= totalBatches) {
// 所有批次完成,标记任务为完成
await prisma.aslScreeningTask.update({
const task = await prisma.aslScreeningTask.update({
where: { id: taskId },
data: {
status: 'completed',
@@ -131,8 +132,35 @@ export function registerScreeningWorkers() {
},
});
// 获取项目信息用于埋点
const project = await prisma.project.findUnique({
where: { id: task.projectId },
});
logger.info('All batches completed, task marked as completed', { taskId });
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
// 埋点:记录文献筛选完成
try {
if (project) {
const user = await prisma.user.findUnique({
where: { id: project.userId },
include: { tenants: true }
});
if (user) {
activityService.log(
user.tenant_id,
user.tenants?.name || null,
user.id,
user.name,
'ASL',
'文献筛选',
'USE',
`项目:${project.name}, 文献:${totalBatches * 10}`
);
}
}
} catch (e) { /* 埋点失败不影响主业务 */ }
}
} catch (error) {

View File

@@ -15,6 +15,7 @@ import { conflictDetectionService } from '../services/ConflictDetectionService.j
import { jobQueue } from '../../../../common/jobs/index.js';
import { CheckpointService } from '../../../../common/jobs/CheckpointService.js';
import type { Job } from '../../../../common/jobs/types.js';
import { activityService } from '../../../../common/services/activity.service.js';
// 创建断点服务实例
const checkpointService = new CheckpointService(prisma);
@@ -127,7 +128,7 @@ export function registerExtractionWorkers() {
if (completedBatches >= totalBatches) {
// 所有批次完成,标记任务为完成
await prisma.dCExtractionTask.update({
const task = await prisma.dCExtractionTask.update({
where: { id: taskId },
data: {
status: 'completed',
@@ -137,6 +138,26 @@ export function registerExtractionWorkers() {
logger.info('All batches completed, task marked as completed', { taskId });
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
// 埋点:记录 Tool B 数据提取完成
try {
const user = await prisma.user.findUnique({
where: { id: task.userId },
include: { tenants: true }
});
if (user) {
activityService.log(
user.tenant_id,
user.tenants?.name || null,
user.id,
user.name,
'DC',
'Tool B 数据提取',
'USE',
`项目:${task.projectName}, 记录:${totalBatches * 10}`
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
}
} catch (error) {

View File

@@ -16,6 +16,7 @@ import { aiCodeService } from '../services/AICodeService.js';
import { sessionService } from '../services/SessionService.js';
import { LLMFactory } from '../../../../common/llm/adapters/LLMFactory.js';
import { ModelType } from '../../../../common/llm/adapters/types.js';
import { activityService } from '../../../../common/services/activity.service.js';
// ==================== 请求参数类型定义 ====================
@@ -180,6 +181,23 @@ export class AIController {
);
logger.info(`[AIController] 处理成功: 重试${result.retryCount}次后成功`);
// 埋点:记录 Tool C AI代码执行
try {
const user = (request as any).user;
if (user && result.executeResult?.success) {
activityService.log(
user.tenantId,
user.tenantName || null,
user.id,
user.name,
'DC',
'Tool C AI代码',
'USE',
`session:${sessionId}, retries:${result.retryCount}`
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
return reply.code(200).send({
success: true,

View File

@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import { logger } from '../../../common/logging/index.js';
import { jobQueue } from '../../../common/jobs/index.js';
import { activityService } from '../../../common/services/activity.service.js';
/**
* 同步管理器
@@ -261,6 +262,26 @@ export class SyncManager {
duration: `${totalDuration}ms`
});
// 埋点:记录 REDCap 同步
try {
const owner = await this.prisma.user.findUnique({
where: { id: project.ownerId },
include: { tenants: true }
});
if (owner && uniqueRecordIds.length > 0) {
activityService.log(
owner.tenant_id,
owner.tenants?.name || null,
owner.id,
owner.name,
'IIT',
'REDCap同步',
'USE',
`项目:${project.name}, 记录:${uniqueRecordIds.length}`
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
return uniqueRecordIds.length;
} catch (error: any) {

View File

@@ -7,6 +7,7 @@ import {
searchKnowledgeBase as ragSearchKnowledgeBase,
type RagSearchResult,
} from './ragService.js';
import { activityService } from '../../../common/services/activity.service.js';
/**
* 知识库服务
@@ -58,6 +59,26 @@ export async function createKnowledgeBase(
ekbKbId: result.ekbKbId,
});
// 埋点:记录知识库创建
try {
const userInfo = await prisma.user.findUnique({
where: { id: userId },
include: { tenants: true }
});
if (userInfo) {
activityService.log(
userInfo.tenant_id,
userInfo.tenants?.name || null,
userId,
userInfo.name,
'PKB',
'创建知识库',
'CREATE',
name
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
// 4. 转换BigInt为Number
return {
...knowledgeBase,

View File

@@ -14,6 +14,7 @@ import {
type SearchResult,
type IngestResult,
} from '../../../common/rag/index.js';
import { activityService } from '../../../common/services/activity.service.js';
// ==================== 类型定义 ====================
@@ -65,7 +66,29 @@ export async function searchKnowledgeBase(
// 查找对应的 EKB 知识库
const ekbKb = await findOrCreateEkbKnowledgeBase(userId, knowledgeBase.name, knowledgeBase.description);
return searchWithPgvector(ekbKb.id, query, { topK, minScore, mode });
const results = await searchWithPgvector(ekbKb.id, query, { topK, minScore, mode });
// 埋点:记录 RAG 检索
try {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { tenants: true }
});
if (user) {
activityService.log(
user.tenant_id,
user.tenants?.name || null,
userId,
user.name,
'PKB',
'RAG检索',
'USE',
`kb:${knowledgeBase.name}, results:${results.length}`
);
}
} catch (e) { /* 埋点失败不影响主业务 */ }
return results;
}
/**

View File

@@ -22,6 +22,7 @@ import { reviewEditorialStandards } from '../services/editorialService.js';
import { reviewMethodology } from '../services/methodologyService.js';
import { calculateOverallScore, getMethodologyStatus } from '../services/utils.js';
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
import { activityService } from '../../../common/services/activity.service.js';
/**
* 审查任务数据结构
@@ -154,6 +155,33 @@ export function registerReviewWorker() {
console.log(` 综合得分: ${overallScore}`);
console.log(` 耗时: ${durationSeconds}`);
// ========================================
// 4. 埋点:记录审查完成
// ========================================
try {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { tenants: true }
});
if (user) {
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : '方法学').join('+');
activityService.log(
user.tenant_id,
user.tenants?.name || null,
userId,
user.name,
'RVW',
`${agentNames}审查`,
'USE',
`审查完成: 规范${editorialScore ?? '-'}分/方法学${methodologyScore ?? '-'}分, 耗时${durationSeconds}`
);
}
} catch (e) {
// 埋点失败不影响主业务
logger.warn('[reviewWorker] 埋点失败', { error: e });
}
return {
taskId,
overallScore,