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

@@ -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,

View File

@@ -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 },

View 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;
}
};