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
This commit is contained in:
2026-03-09 22:27:11 +08:00
parent d30bf95815
commit 971e903acf
23 changed files with 810 additions and 180 deletions

View File

@@ -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) {

View File

@@ -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,
});
}
}
/**
* <20><>ȡ<EFBFBD><C8A1>ǰ<EFBFBD>û<EFBFBD><C3BB>ɷ<EFBFBD><C9B7>ʵ<EFBFBD>ģ<EFBFBD><C4A3>

View File

@@ -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(', ')}`,
});
}
};
}

View File

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

View File

@@ -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<string, number>;
}> {
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,
};
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });

View File

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

View File

@@ -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({

View File

@@ -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 看板 + 验收与文档更新

View File

@@ -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
**前端Nginxfrontend-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
**部署状态**
- ✅ 已成功部署到SAE2026-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/
**部署状态**
- ✅ 已成功部署到SAE2026-03-05
- ✅ 服务运行正常内网地址http://172.17.173.106:3001
- ✅ 服务运行正常内网地址http://172.17.173.109:3001
- ✅ 健康检查通过
**Git文件结构**
@@ -364,6 +364,38 @@ AIclinicalresearch/extraction_service/
## 🔄 四、部署历史记录
### 2026-03-090309二次部署 - DB补迁移 + R修复 + 后端/前端升级)
#### 部署概览
- **部署时间**2026-03-09第二轮
- **部署范围**数据库迁移1项 + R统计引擎 + Node.js后端 + 前端Nginx
- **主要变更**AIA 附件持久化、R 包诊断与错误映射修复、SSE 稳定性与用户友好重试
#### 数据库变更1项
- ✅ 应用迁移:`20260309_add_aia_attachments_persistence`
- ✅ 迁移状态RDS 25/25Schema 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-090309部署 - 数据库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

View File

@@ -3,7 +3,7 @@
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
> **最后清零**: 2026-03-090309 部署完成后清零)
> **最后清零**: 2026-03-090309 二次部署完成后清零)
---
@@ -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.10SSE/优雅停机/AIA附件/短信能力等变更) | 2026-03-09 | ✅ |
| FE | v2.6 → v2.7SSE 代理与友好重试体验优化) | 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 部署已清零项
| # | 变更内容 | 部署日期 | 结果 |

View File

@@ -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 @@
## 部署成果一览
### 服务版本对比
### 服务版本对比(最终态)
| 服务 | 部署前 | 部署后 | 变更类型 |
|------|--------|--------|---------|
| PostgreSQLRDS | 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/心跳) |
| 服务 | 当日部署前 | 当日部署后(最终) | 说明 |
|------|-----------|-------------------|------|
| PostgreSQLRDS | 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 PromptPLANNER + 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 | 2PLANNER + 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-2Agent 结构化错误处理增强20+ 模式匹配 + format_agent_error
-R-3AST 语法预检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-1Deep Research V2.0 历史列表 + 删除接口 + getTask 鉴权修复
-BE-2SR 相关路由增加 `requireModule('ASL_SR')` 中间件
-BE-3Unifuncs DeepSearch API S2 → S3新增 `language: "zh"`
-BE-4RVW 数据验证增加 LLM 核查通道
-BE-5RVW 新增临床专业评估维度ClinicalAssessmentSkill
- ✅ BE-6RVW 稳定性增强Promise.allSettled + partial_completed
- ✅ BE-7DataForensicsSkill LLM 核查独立 60s 超时
- ✅ BE-8SSA Agent 通道体验优化(方案 B + 10 项 Bug 修复)
- ✅ BE-9Phase 5A CoderAgent 防错护栏4 项改动)
- ✅ BE-10SSA Agent Prompt 接入运营管理端(三级容灾)
- ✅ BE-11IIT eQuery 幂等写入 + 去重工具脚本
- ✅ BE-12IIT 事件名称友好化 + 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-1ASL 左侧导航栏重构为互斥手风琴
-FE-2Deep Research 历史记录功能
-FE-3Panel B SR 工具导航权限控制
- ✅ FE-4RVW 数据验证报告增加 LLM 核查结果展示
- ✅ FE-5RVW 新增临床专业评估 Tab + Agent 选择项
- ✅ FE-6RVW 前端支持 partial_completed 状态
- ✅ FE-7SSA Agent 通道体验优化(方案 B + 动态 UI
- ✅ FE-8SSA 默认 Agent 模式 + 查看代码修复 + 分析历史卡片
- ✅ FE-9IIT 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
> **维护人员**:开发团队

View File

@@ -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 (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
@@ -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
})()

View File

@@ -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<void> => {
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 = () => {
<div
key={module.id}
onClick={() => {
void reportTopNavClick(module.name)
if (module.isExternal && module.externalUrl) {
window.open(module.externalUrl, '_blank', 'noopener');
} else {

View File

@@ -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<string, number>;
}
@@ -54,29 +61,29 @@ export interface UserOverview {
* 获取今日大盘数据
*/
export async function getOverview(): Promise<OverviewData> {
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<ActivityLog[]> {
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<UserOverview> {
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;
}

View File

@@ -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() {
{/* 核心指标卡片 */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} sm={8}>
<Col xs={24} sm={12} md={6}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600"> (DAU)</span>}
@@ -185,7 +187,18 @@ export default function StatsDashboardPage() {
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={12} md={6}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600">30 (MAU)</span>}
value={overview?.mau ?? 0}
prefix={<UserOutlined className="text-indigo-500" />}
loading={overviewLoading}
valueStyle={{ color: '#4f46e5', fontWeight: 'bold' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600"> (DAT)</span>}
@@ -196,7 +209,7 @@ export default function StatsDashboardPage() {
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={12} md={6}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600"></span>}
@@ -209,6 +222,31 @@ export default function StatsDashboardPage() {
</Col>
</Row>
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} md={12}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600"></span>}
value={overview?.topActiveUser?.userName || '暂无'}
prefix={<FireOutlined className="text-orange-500" />}
suffix={overview?.topActiveUser ? `${overview.topActiveUser.actionCount}` : ''}
loading={overviewLoading}
/>
</Card>
</Col>
<Col xs={24} md={12}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title={<span className="text-gray-600"> API Token </span>}
value={overview?.apiTokenTotal ?? 0}
prefix={<ApiOutlined className="text-cyan-500" />}
loading={overviewLoading}
valueStyle={{ color: '#0891b2', fontWeight: 'bold' }}
/>
</Card>
</Col>
</Row>
{/* 模块使用统计 */}
{overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && (
<Card

View File

@@ -40,6 +40,9 @@ RUN R -e "install.packages(c( \
'meta' \
), repos='https://cloud.r-project.org/', Ncpus=2)"
# 构建期校验:关键包缺失则直接失败,阻止坏镜像发布
RUN R -e "required <- c('plumber','jsonlite','ggplot2','glue','dplyr','tidyr','base64enc','yaml','car','httr','scales','gridExtra','gtsummary','gt','broom','meta'); installed <- rownames(installed.packages()); missing <- setdiff(required, installed); if (length(missing) > 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

View File

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

View File

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