Ctrl K
ComunidadGuías…

backend-ts/src/app-utils.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { existsSync, readdirSync, rmSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import fastifyStatic from '@fastify/static';
import type { FastifyInstance } from 'fastify';
import type { SpawnPtyOptions, PtyLike } from './harness/terminal/remote-terminal.js';
import type { ContainerRegistry } from './harness/container/container-registry.js';
import type { RuntimeChannelServer } from './harness/container/runtime-channel-server.js';
import type { DockerToolRouter } from './harness/container/docker-tool-router.js';
import type { SessionService } from './session/session.service.js';
import type { TaskTerminalService } from './session/task-terminal.service.js';
import type { FeishuInboundQueueRepository } from './feishu/inbound-queue.repository.js';
import type { MysqlFeishuProgressCardRepository } from './feishu/progress-card.repository.js';
import type { AuditLogService } from './audit/audit.service.js';
import { TERMINAL_AUDIT_META, type TerminalAuditRecorder } from './harness/terminal/terminal-manager.js';
import { truncateAuditError } from './audit/audit.interceptor.js';
import { PtyChannelHub } from './harness/container/pty-bridge.js';
import { harnessLog } from './harness/log.js';

// ========== 通用工具 ==========

export function expandHome(v: string): string {
  if (v.startsWith('$HOME')) return v.replace(/^\$HOME/, process.env.HOME ?? '');
  if (v.startsWith('~/')) return resolve(process.env.HOME ?? '', v.slice(2));
  return v;
}

/**
 * 共享目录路径解析(共享目录方案 §4.1 误配防护):空值或相对路径若被 resolve 静默
 * 按 cwd 解析,容器模式下 assertSafe 的 owningRoot 校验对 share 条目恒真通过
 * (shareDir 自身即登记根),唯一兜底的父目录防护又依赖 cwd 与 distDir 的相对
 * 布局——误配时整个后端工作树(含 .env 密钥)会被 ro 挂进所有用户容器。
 * 启动即拒:必须在 resolve 之前校验原始值,否则相对路径已被静默转绝对。
 */
export function resolveShareDir(raw: string): string {
  const expanded = expandHome(raw);
  if (!expanded || !isAbsolute(expanded)) {
    throw new Error(`MAO_SHARE_DIR must be a non-empty absolute path, got '${raw}'`);
  }
  return resolve(expanded);
}

export function parseSqlTimeMs(value: string | null | undefined): number | null {
  if (value == null || value === '') return null;
  const ms = Date.parse(value.includes('T') ? value : value.replace(' ', 'T'));
  return Number.isFinite(ms) ? ms : null;
}

export function runtimeSessionCleanup(runtimeRoot: string): (userId: number, sessionId: number) => void {
  return (userId: number, sessionId: number): void => {
    // 归属者目录无条件回收(即使根目录枚举失败也不影响)。
    const cleaned = new Set<string>([resolve(runtimeRoot, String(userId), String(sessionId))]);
    try {
      // 群聊会话的运行期产物按「运行身份」分别落盘(技术方案 2026-11-04 §5.2/§5.9):
      // dump_user_messages / open_web_page 缓存 / 技能软链等都在 {runtimeRoot}/{发言者}/{sid} 下,
      // 删除会话时不能只回收归属者目录,否则每个发言者都会留下永不回收的残留。
      // sessionId 全局唯一,按 {runtimeRoot}/*/{sid} 扫描删除(删除是低频操作,可接受一次枚举)。
      // 严格只认纯数字一级目录(用户 id 形态):runtimeRoot 下还有 channels 等非用户目录,
      // channel 目录名恰为 userId——若全量扫描,sessionId == 某 userId 时会误删该用户
      // 的容器通道目录(socket/.token),打挂其 docker 运行面(评审 P0,2026-11-04)。
      for (const entry of readdirSync(runtimeRoot, { withFileTypes: true })) {
        if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
        cleaned.add(resolve(runtimeRoot, entry.name, String(sessionId)));
      }
    } catch (e) {
      console.error(`Failed to enumerate runtime dirs for session ${sessionId}`, e);
    }
    try {
      for (const sessionDir of cleaned) {
        if (!existsSync(sessionDir)) continue;
        try {
          rmSync(sessionDir, { recursive: true, force: true });
        } catch (e) {
          // 单个目录 EBUSY/EACCES 不应跳过其余发言者目录(残留可下次再清,但不能漏清)。
          console.error(`Failed to clean runtime dir ${sessionDir} for session ${sessionId}`, e);
        }
      }
    } catch (e) {
      console.error(`Failed to clean runtime dir for session ${sessionId}`, e);
    }
  };
}

// ========== 上传静态托管 ==========

// 分隔符双兼容:fastifyStatic setHeaders 拿到的是平台本地路径(Windows 为反斜杠),
// 正则只认 / 会漏掉 electron 更新清单,导致 Windows 部署错误长缓存。
// 字符类内写作 [\\/](反斜杠 + 正斜杠都接受)
const UPLOAD_CACHE_EXCLUDED = /(^|[\\/])(releases[\\/][^\\/]*\.(json|ya?ml)|latest[^\\/]*\.ya?ml)$/i;

function uploadCacheHeaders(path: string): Record<string, string> {
  if (UPLOAD_CACHE_EXCLUDED.test(path)) return { 'Cache-Control': 'no-cache' };
  return { 'Cache-Control': 'public, max-age=604800' };
}

export async function registerUploadStatic(app: FastifyInstance, uploadDir: string, apiPrefix: string): Promise<void> {
  await app.register(fastifyStatic, {
    root: uploadDir,
    prefix: '/uploads/',
    decorateReply: false,
    setHeaders: (reply, path) => {
      for (const [k, v] of Object.entries(uploadCacheHeaders(path))) reply.header(k, v);
    },
  });
  await app.register(fastifyStatic, {
    root: uploadDir,
    prefix: `${apiPrefix.replace(/\/$/, '')}/uploads/`,
    decorateReply: false,
    setHeaders: (reply, path) => {
      for (const [k, v] of Object.entries(uploadCacheHeaders(path))) reply.header(k, v);
    },
  });
}

// ========== 终端审计 ==========

export function createTerminalAudit(auditService: AuditLogService): TerminalAuditRecorder {
  return (event, terminal, ctx) => {
    const meta = TERMINAL_AUDIT_META[event];
    void auditService.record({
      action: meta.action,
      objectType: 'terminal',
      objectId: terminal.terminalId,
      method: meta.method,
      path: meta.path(terminal.sessionId, terminal.terminalId),
      userId: terminal.userId,
      username: ctx?.username ?? null,
      ip: ctx?.ip ?? null,
      status: ctx?.errorMessage == null ? 200 : 500,
      success: ctx?.errorMessage == null ? 1 : 0,
      errorMessage: truncateAuditError(ctx?.errorMessage),
    }).catch((e) => console.error('Failed to record terminal audit log', e));
  };
}

// ========== 容器 PTY 工厂 ==========

export interface ContainerPtyFactoryDeps {
  dockerRouter: DockerToolRouter;
  containerRegistry: ContainerRegistry;
  channelServer: RuntimeChannelServer;
  /** 键为 containerKey(userId, agentId)(2026-11-07 复合容器方案 §5.7)。 */
  ptyChannelHubs: Map<string, PtyChannelHub>;
  ptyHubGeneration: Map<string, number>;
  nodePtyFactory: (options: SpawnPtyOptions) => Promise<PtyLike> | PtyLike;
}

export function createContainerPtyFactory(deps: ContainerPtyFactoryDeps): (options: SpawnPtyOptions) => Promise<PtyLike> {
  const { dockerRouter, containerRegistry, channelServer, ptyChannelHubs, ptyHubGeneration, nodePtyFactory } = deps;

  return async (options: SpawnPtyOptions): Promise<PtyLike> => {
    const userId = options.userId;
    if (userId != null && await dockerRouter.enabledFor(userId)) {
      // agentId 由 terminal.routes 从会话填充;缺省(旧调用方/测试)视为无法路由,
      // 明确报错而不是落到宿主机 node-pty(docker 用户的工作区文件宿主机不可见)
      if (options.agentId == null) {
        throw new Error(`容器终端缺少 Agent 维度(agentId),无法路由: userId=${userId}`);
      }
      const agentId = options.agentId;
      const key = `${userId}:${agentId}`;
      const entry = await containerRegistry.ensureRunning(userId, agentId, options.cwd);
      const connection = channelServer.getConnection(userId, agentId);
      if (!connection) throw new Error(`容器终端通道未就绪: userId=${userId}, agentId=${agentId}`);
      let hub = ptyChannelHubs.get(key);
      if (!hub || ptyHubGeneration.get(key) !== entry.startedAtMs) {
        hub?.dispose();
        hub = new PtyChannelHub(connection);
        ptyChannelHubs.set(key, hub);
        ptyHubGeneration.set(key, entry.startedAtMs);
      } else {
        hub.bindConnection(connection);
      }
      const bridge = await hub.open(userId, options);
      harnessLog('info', `[container] terminal pty opened in container: user=${userId}, agent=${agentId}, pid=${bridge.pid}`);
      return bridge;
    }
    return nodePtyFactory(options);
  };
}

// ========== 取消持久化 ==========

export interface PersistCancelledDeps {
  sessionService: SessionService;
  taskTerminal: TaskTerminalService;
  feishuProgressCardRepo: MysqlFeishuProgressCardRepository;
}

export function createPersistCancelledHelper(deps: PersistCancelledDeps) {
  const { sessionService, taskTerminal, feishuProgressCardRepo } = deps;

  return async (sessionId: number): Promise<boolean> => {
    const session = await sessionService.getSession(sessionId).catch(() => null);
    if (session == null) return false;
    if (session.phase !== 'RUNNING' && session.phase !== 'RESUMING') return false;
    await sessionService.cleanupIncompleteTail(sessionId);
    await taskTerminal.finishExecution(sessionId, session.userId, 'CANCELLED', randomUUID());
    try { await feishuProgressCardRepo.deleteBySessionId(sessionId); } catch (error) {
      console.warn(`清理飞书进度卡片映射失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
    }
    return true;
  };
}

// ========== 平台队列接力 ==========

export interface PlatformQueueRelayDeps {
  sessionService: { getSession(sessionId: number): Promise<{ userId?: number | null } | null> };
  /** WS 侧排队消息消费入口(streaming-ws-handler 的 autoConsumeQueue)。 */
  wsHandler: { autoConsumeQueue(sessionId: number, userId: number, options?: { relayToIm?: boolean }): Promise<void> };
}

/**
 * IM (飞书/钉钉/微信)执行收尾、入站队列排空且会话空闲后的接力消费:
 * 按会话归属者消费 message_queue(定时任务/Webhook/插队)——否则「定时任务在会话忙时入队、
 * 之后该会话只有 IM 通道执行」的消息会永远停在 QUEUED(管理后台显示「排队中」)。
 * relayToIm=false 防止「IM 链 → 平台链 → IM 链」互相无限传递。失败静默留日志,不外抛。
 */
export function createPlatformQueueRelay(deps: PlatformQueueRelayDeps): (sessionId: number) => void {
  const { sessionService, wsHandler } = deps;
  return (sessionId: number): void => {
    void (async () => {
      const session = await sessionService.getSession(sessionId).catch(() => null);
      if (session?.userId == null) return;
      await wsHandler.autoConsumeQueue(sessionId, session.userId, { relayToIm: false });
    })().catch((error: unknown) => {
      console.warn(`IM 队列排空后接力消费平台队列失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
    });
  };
}

// ========== 排队位置 ==========

export interface QueuePositionDeps {
  feishuInboundQueueRepo: FeishuInboundQueueRepository;
}

export function createQueuePositionHelper(deps: QueuePositionDeps) {
  const { feishuInboundQueueRepo } = deps;

  return async (sessionId: number): Promise<number> => (await feishuInboundQueueRepo.countPending(sessionId));
}