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:
@@ -8,6 +8,7 @@ import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { authService } from './auth.service.js';
|
||||
import type { PasswordLoginRequest, VerificationCodeLoginRequest, ChangePasswordRequest } from './auth.service.js';
|
||||
import { logger } from '../logging/index.js';
|
||||
import { activityService } from '../services/activity.service.js';
|
||||
|
||||
/**
|
||||
* 密码登录
|
||||
@@ -21,6 +22,18 @@ export async function loginWithPassword(
|
||||
try {
|
||||
const result = await authService.loginWithPassword(request.body);
|
||||
|
||||
// 埋点:记录登录行为
|
||||
activityService.log(
|
||||
result.user.tenantId,
|
||||
result.user.tenantName || null,
|
||||
result.user.id,
|
||||
result.user.name,
|
||||
'SYSTEM',
|
||||
'用户登录',
|
||||
'LOGIN',
|
||||
'密码登录'
|
||||
);
|
||||
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: result,
|
||||
@@ -49,6 +62,18 @@ export async function loginWithVerificationCode(
|
||||
try {
|
||||
const result = await authService.loginWithVerificationCode(request.body);
|
||||
|
||||
// 埋点:记录登录行为
|
||||
activityService.log(
|
||||
result.user.tenantId,
|
||||
result.user.tenantName || null,
|
||||
result.user.id,
|
||||
result.user.name,
|
||||
'SYSTEM',
|
||||
'用户登录',
|
||||
'LOGIN',
|
||||
'验证码登录'
|
||||
);
|
||||
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: result,
|
||||
|
||||
@@ -263,6 +263,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const permissions = await this.getUserPermissions(user.role);
|
||||
const modules = await this.getUserModules(userId);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -277,6 +278,7 @@ export class AuthService {
|
||||
departmentName: user.departments?.name,
|
||||
isDefaultPassword: user.is_default_password,
|
||||
permissions,
|
||||
modules, // 新增:返回模块列表
|
||||
};
|
||||
}
|
||||
|
||||
@@ -418,13 +420,25 @@ export class AuthService {
|
||||
* 获取用户可访问的模块列表
|
||||
*
|
||||
* 逻辑:
|
||||
* 1. 查询用户所有租户关系
|
||||
* 2. 对每个租户,检查租户订阅的模块
|
||||
* 3. 如果用户有自定义模块权限,使用自定义权限
|
||||
* 4. 否则继承租户的全部模块权限
|
||||
* 5. 去重后返回所有可访问模块
|
||||
* 1. SUPER_ADMIN 角色拥有所有模块权限
|
||||
* 2. 查询用户所有租户关系
|
||||
* 3. 对每个租户,检查租户订阅的模块
|
||||
* 4. 如果用户有自定义模块权限,使用自定义权限
|
||||
* 5. 否则继承租户的全部模块权限
|
||||
* 6. 去重后返回所有可访问模块
|
||||
*/
|
||||
private async getUserModules(userId: string): Promise<string[]> {
|
||||
// 先获取用户角色
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
// SUPER_ADMIN 拥有所有模块权限
|
||||
if (user?.role === 'SUPER_ADMIN') {
|
||||
return ['AIA', 'PKB', 'ASL', 'DC', 'RVW', 'IIT', 'SSA', 'ST'];
|
||||
}
|
||||
|
||||
// 获取用户的所有租户关系
|
||||
const tenantMembers = await prisma.tenant_members.findMany({
|
||||
where: { user_id: userId },
|
||||
|
||||
310
backend/src/common/services/activity.service.ts
Normal file
310
backend/src/common/services/activity.service.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Activity Service - 运营埋点服务
|
||||
*
|
||||
* 用于记录用户行为,支持 DAU/DAT 统计和用户360画像
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. Fire-and-Forget 模式:不阻塞主业务
|
||||
* 2. 错误隔离:埋点失败不影响主流程
|
||||
* 3. 零风险保证:外层 try-catch 确保绝不崩溃
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @date 2026-01-25
|
||||
*/
|
||||
|
||||
import { prisma } from '../../config/database.js';
|
||||
import { logger } from '../logging/index.js';
|
||||
|
||||
// 模块代码类型
|
||||
export type ModuleCode =
|
||||
| 'AIA' // AI智能问答
|
||||
| 'PKB' // 个人知识库
|
||||
| 'ASL' // AI智能文献
|
||||
| 'DC' // 数据清洗整理
|
||||
| 'RVW' // 稿件审查系统
|
||||
| 'IIT' // IIT Manager Agent
|
||||
| 'SSA' // 智能统计分析 (预留)
|
||||
| 'ST' // 统计分析工具 (预留)
|
||||
| 'SYSTEM'; // 系统级行为
|
||||
|
||||
// 动作类型
|
||||
export type ActionType =
|
||||
| 'LOGIN' // 登录系统
|
||||
| 'USE' // 使用功能
|
||||
| 'EXPORT' // 导出/下载
|
||||
| 'CREATE' // 创建资源
|
||||
| 'DELETE' // 删除资源
|
||||
| 'ERROR'; // 错误记录
|
||||
|
||||
/**
|
||||
* Activity Service
|
||||
*/
|
||||
export const activityService = {
|
||||
/**
|
||||
* 核心埋点方法 (Fire-and-Forget 模式)
|
||||
*
|
||||
* 异步执行,不阻塞主业务,即使失败也不影响主流程
|
||||
*
|
||||
* @param tenantId - 租户ID
|
||||
* @param tenantName - 租户名称(冗余字段,避免JOIN)
|
||||
* @param userId - 用户ID
|
||||
* @param userName - 用户名称
|
||||
* @param module - 模块代码
|
||||
* @param feature - 细分功能
|
||||
* @param action - 动作类型
|
||||
* @param info - 详情信息(可选)
|
||||
*/
|
||||
log(
|
||||
tenantId: string,
|
||||
tenantName: string | null,
|
||||
userId: string,
|
||||
userName: string | null,
|
||||
module: ModuleCode,
|
||||
feature: string,
|
||||
action: ActionType,
|
||||
info?: string | object
|
||||
): void {
|
||||
try {
|
||||
// 参数校验:必填字段缺失时静默返回
|
||||
if (!tenantId || !userId || !module || !feature || !action) {
|
||||
logger.debug('埋点参数不完整,跳过记录', { tenantId, userId, module });
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 info 字段
|
||||
const infoStr = info
|
||||
? (typeof info === 'object' ? JSON.stringify(info) : String(info))
|
||||
: null;
|
||||
|
||||
// 异步写入,不等待结果
|
||||
prisma.simple_logs.create({
|
||||
data: {
|
||||
tenant_id: tenantId,
|
||||
tenant_name: tenantName,
|
||||
user_id: userId,
|
||||
user_name: userName,
|
||||
module,
|
||||
feature,
|
||||
action,
|
||||
info: infoStr,
|
||||
}
|
||||
}).catch(e => {
|
||||
// 埋点失败只记录日志,不影响主业务
|
||||
logger.warn('埋点写入失败(可忽略)', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
module,
|
||||
feature,
|
||||
action,
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
// 即使同步代码出错,也绝不影响主业务
|
||||
logger.warn('埋点调用异常(可忽略)', {
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取今日核心大盘数据 (DAU + DAT + 导出数)
|
||||
*/
|
||||
async getTodayOverview(): Promise<{
|
||||
dau: number;
|
||||
dat: number;
|
||||
exportCount: number;
|
||||
moduleStats: Record<string, number>;
|
||||
}> {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// 查询 DAU/DAT/导出数
|
||||
const stats = await prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(DISTINCT user_id) as dau,
|
||||
COUNT(DISTINCT tenant_id) as dat,
|
||||
COUNT(CASE WHEN action = 'EXPORT' THEN 1 END) as export_count
|
||||
FROM admin_schema.simple_logs
|
||||
WHERE created_at >= ${todayStart}
|
||||
` as Array<{ dau: bigint; dat: bigint; export_count: bigint }>;
|
||||
|
||||
// 查询模块使用统计
|
||||
const moduleStats = await prisma.$queryRaw`
|
||||
SELECT module, COUNT(*) as count
|
||||
FROM admin_schema.simple_logs
|
||||
WHERE created_at >= ${todayStart}
|
||||
GROUP BY module
|
||||
` as Array<{ module: string; count: bigint }>;
|
||||
|
||||
const moduleMap: Record<string, number> = {};
|
||||
moduleStats.forEach(m => {
|
||||
moduleMap[m.module] = Number(m.count);
|
||||
});
|
||||
|
||||
return {
|
||||
dau: Number(stats[0]?.dau || 0),
|
||||
dat: Number(stats[0]?.dat || 0),
|
||||
exportCount: Number(stats[0]?.export_count || 0),
|
||||
moduleStats: moduleMap,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取实时流水账
|
||||
*
|
||||
* @param limit - 返回条数,默认100
|
||||
*/
|
||||
async getLiveFeed(limit = 100): Promise<Array<{
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
tenantName: string | null;
|
||||
userName: string | null;
|
||||
module: string;
|
||||
feature: string;
|
||||
action: string;
|
||||
info: string | null;
|
||||
}>> {
|
||||
const logs = await prisma.simple_logs.findMany({
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
created_at: true,
|
||||
tenant_name: true,
|
||||
user_name: true,
|
||||
module: true,
|
||||
feature: true,
|
||||
action: true,
|
||||
info: true,
|
||||
}
|
||||
});
|
||||
|
||||
return logs.map(log => ({
|
||||
id: log.id,
|
||||
createdAt: log.created_at,
|
||||
tenantName: log.tenant_name,
|
||||
userName: log.user_name,
|
||||
module: log.module,
|
||||
feature: log.feature,
|
||||
action: log.action,
|
||||
info: log.info,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户360画像
|
||||
*
|
||||
* @param userId - 用户ID
|
||||
*/
|
||||
async getUserOverview(userId: string): Promise<{
|
||||
profile: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
tenantName: string | null;
|
||||
} | null;
|
||||
assets: {
|
||||
aia: { conversationCount: number };
|
||||
pkb: { kbCount: number; docCount: number };
|
||||
dc: { taskCount: number };
|
||||
rvw: { reviewTaskCount: number; completedCount: number };
|
||||
};
|
||||
activities: Array<{
|
||||
createdAt: Date;
|
||||
module: string;
|
||||
feature: string;
|
||||
action: string;
|
||||
info: string | null;
|
||||
}>;
|
||||
}> {
|
||||
const [user, aiaStats, kbs, dcStats, rvwStats, logs] = await Promise.all([
|
||||
// 1. 基础信息
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { tenants: true }
|
||||
}),
|
||||
|
||||
// 2. AIA 资产 (会话数)
|
||||
prisma.conversation.count({
|
||||
where: { userId, deletedAt: null }
|
||||
}),
|
||||
|
||||
// 3. PKB 资产 (知识库数 + 文档数)
|
||||
prisma.knowledgeBase.findMany({
|
||||
where: { userId },
|
||||
include: { _count: { select: { documents: true } } }
|
||||
}),
|
||||
|
||||
// 4. DC 资产 (任务数)
|
||||
prisma.dCExtractionTask.count({ where: { userId } }),
|
||||
|
||||
// 5. RVW 资产 (审稿任务数)
|
||||
prisma.reviewTask.groupBy({
|
||||
by: ['status'],
|
||||
where: { userId },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// 6. 最近行为 (从 SimpleLog 查最近 20 条)
|
||||
prisma.simple_logs.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
created_at: true,
|
||||
module: true,
|
||||
feature: true,
|
||||
action: true,
|
||||
info: true,
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// 计算 PKB 文档总数
|
||||
const totalDocs = kbs.reduce((sum: number, kb: { _count: { documents: number } }) => sum + kb._count.documents, 0);
|
||||
|
||||
// 计算 RVW 统计
|
||||
const rvwTotal = rvwStats.reduce((sum: number, s: { _count: number }) => sum + s._count, 0);
|
||||
const rvwCompleted = rvwStats.find((s: { status: string }) => s.status === 'completed')?._count || 0;
|
||||
|
||||
return {
|
||||
profile: user ? {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'), // 脱敏
|
||||
tenantName: user.tenants?.name || null,
|
||||
} : null,
|
||||
assets: {
|
||||
aia: { conversationCount: aiaStats },
|
||||
pkb: { kbCount: kbs.length, docCount: totalDocs },
|
||||
dc: { taskCount: dcStats },
|
||||
rvw: { reviewTaskCount: rvwTotal, completedCount: rvwCompleted },
|
||||
},
|
||||
activities: logs.map((log: { created_at: Date; module: string; feature: string; action: string; info: string | null }) => ({
|
||||
createdAt: log.created_at,
|
||||
module: log.module,
|
||||
feature: log.feature,
|
||||
action: log.action,
|
||||
info: log.info,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理过期日志(180天前的数据)
|
||||
* 用于定时任务调用
|
||||
*/
|
||||
async cleanupOldLogs(): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 180);
|
||||
|
||||
const result = await prisma.$executeRaw`
|
||||
DELETE FROM admin_schema.simple_logs
|
||||
WHERE created_at < ${cutoffDate}
|
||||
`;
|
||||
|
||||
logger.info('运营日志清理完成', { deletedCount: result, cutoffDate });
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +82,13 @@ console.log('✅ 文件上传插件已配置: 最大文件大小 10MB');
|
||||
await registerHealthRoutes(fastify);
|
||||
logger.info('✅ 健康检查路由已注册');
|
||||
|
||||
// ============================================
|
||||
// 【公开API】无需认证的公共接口
|
||||
// ============================================
|
||||
import { getTenantLoginConfig } from './modules/admin/controllers/tenantController.js';
|
||||
fastify.get('/api/v1/public/tenant-config/:tenantCode', getTenantLoginConfig);
|
||||
logger.info('✅ 公开API已注册: /api/v1/public/tenant-config/:tenantCode');
|
||||
|
||||
// ============================================
|
||||
// 【平台基础设施】认证模块
|
||||
// ============================================
|
||||
@@ -100,10 +107,13 @@ logger.info('✅ Prompt管理路由已注册: /api/admin/prompts');
|
||||
// ============================================
|
||||
import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js';
|
||||
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
||||
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
|
||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users');
|
||||
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||
await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' });
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats');
|
||||
|
||||
// ============================================
|
||||
// 【临时】平台基础设施测试API
|
||||
|
||||
54
backend/src/modules/admin/__tests__/test-stats-api.http
Normal file
54
backend/src/modules/admin/__tests__/test-stats-api.http
Normal 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}}
|
||||
|
||||
124
backend/src/modules/admin/__tests__/test-stats-api.ps1
Normal file
124
backend/src/modules/admin/__tests__/test-stats-api.ps1
Normal 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
|
||||
126
backend/src/modules/admin/controllers/statsController.ts
Normal file
126
backend/src/modules/admin/controllers/statsController.ts
Normal 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 || '清理日志失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '获取租户登录配置失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
backend/src/modules/admin/routes/statsRoutes.ts
Normal file
68
backend/src/modules/admin/routes/statsRoutes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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] 流式生成失败', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user