Ctrl K
ComunidadGuías…

src/services/demo-dataset.ts

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

Archivo original ↗Preguntar a KiBot
import { createHash } from 'node:crypto';
import type { PoolConnection, RowDataPacket } from 'mysql2/promise';
import { parseOpenApiSpec } from '../core/contracts/openapi-tools';
import type { ChatEntry, Client, Route, TargetDef, ToolProvider } from '../core/contracts/types';
import type { DemoDatasetConfig, DemoDatasetProfile } from '../core/config/config';
import { dtAt } from '../core/config/config-codec';
import { ChatConfigRepository } from '../infrastructure/config/config-chat-repository';
import { ClientRepository } from '../infrastructure/config/config-client-repository';
import { RouteRepository } from '../infrastructure/config/config-route-repository';
import { TargetRepository } from '../infrastructure/config/config-target-repository';
import { ToolProviderRepository } from '../infrastructure/config/config-tool-provider-repository';
import type { ConfigStoreContract } from '../infrastructure/config/configstore';

export interface DemoDatasetCounts {
  routes: number;
  clients: number;
  tool_providers: number;
  targets: number;
  channels: number;
  chat_entries: number;
  jobs: number;
  approvals: number;
}

export interface DemoDatasetStatus {
  available: boolean;
  imported: boolean;
  empty: boolean;
  imported_at?: string;
  counts: DemoDatasetCounts;
}

export interface DemoDatasetImportResult extends DemoDatasetStatus {
  ok: true;
  created: string[];
}

export interface DemoDatasetClearResult extends DemoDatasetStatus {
  ok: true;
  removed: string[];
}

interface DemoDatasetManifest {
  schema: 'bailinghub.demo-dataset.v1';
  profile: DemoDatasetProfile;
  resources: {
    targets: DemoOwnedResource[];
    tool_providers: DemoOwnedResource[];
    routes: DemoOwnedResource[];
    clients: DemoOwnedResource[];
    chat_entries: DemoOwnedResource[];
  };
}

interface DemoOwnedResource {
  key: string;
  fingerprint: string;
}

interface DatasetMarkerRow extends RowDataPacket {
  version: number;
  manifest_json: string | DemoDatasetManifest;
  imported_at: string | Date;
  updated_at: string | Date;
}

interface CountRow extends RowDataPacket {
  routes: number | string;
  clients: number | string;
  tool_providers: number | string;
  targets: number | string;
  channels: number | string;
  chat_entries: number | string;
  jobs: number | string;
  approvals: number | string;
  auxiliary: number | string;
  custom_targets: number | string;
}

export const DEMO_DATASET_KEY = 'core-getting-started';
export const DEMO_DATASET_VERSION = 1;
export const DEMO_TARGET = 'demo-agent';
export const DEMO_TOOL_PROVIDER = 'demo-business';
export const DEMO_ROUTE = 'demo_support';
export const DEMO_CLIENT = 'demo-app';
export const DEMO_CHAT_ENTRY = 'pub_demo_support';

const RESOURCE_KEYS = Object.freeze({
  targets: Object.freeze([DEMO_TARGET]),
  tool_providers: Object.freeze([DEMO_TOOL_PROVIDER]),
  routes: Object.freeze([DEMO_ROUTE]),
  clients: Object.freeze([DEMO_CLIENT]),
  chat_entries: Object.freeze([DEMO_CHAT_ENTRY]),
});

const LOCK_SQL = "SELECT GET_LOCK(SHA2(CONCAT('bailinghub:demo-dataset:', DATABASE()), 256), 10) AS acquired";
const UNLOCK_SQL = "SELECT RELEASE_LOCK(SHA2(CONCAT('bailinghub:demo-dataset:', DATABASE()), 256)) AS released";

export class DemoDatasetUnavailableError extends Error {}
export class DemoDatasetConflictError extends Error {
  constructor(message: string, readonly conflicts: string[] = []) { super(message); }
}

function capability(scope: string, options: {
  risk?: 'low' | 'medium' | 'high';
  requiresSubject?: boolean;
  readonly?: boolean;
  idempotent?: boolean;
  approval?: { required?: boolean; prompt?: string };
  whenToUse?: string;
  returns?: string;
} = {}): Record<string, unknown> {
  return {
    version: 1,
    enabled: true,
    scope,
    ...(options.risk && options.risk !== 'low' ? { risk: { level: options.risk } } : {}),
    ...(options.requiresSubject ? { subject: { required: true } } : {}),
    ...(options.approval ? { approval: options.approval } : {}),
    ...((options.readonly !== undefined || options.idempotent !== undefined) ? {
      execution: {
        ...(options.readonly !== undefined ? { readonly: options.readonly } : {}),
        ...(options.idempotent !== undefined ? { idempotent: options.idempotent } : {}),
      },
    } : {}),
    ...((options.whenToUse || options.returns) ? {
      guidance: {
        ...(options.whenToUse ? { when_to_use: options.whenToUse } : {}),
        ...(options.returns ? { returns: options.returns } : {}),
      },
    } : {}),
  };
}

/** 与 demo/business/server.mjs 的公开契约对齐;只读 profile 从定义层就不声明写工具。 */
export function demoToolSpec(profile: DemoDatasetProfile): Record<string, unknown> {
  const paths: Record<string, unknown> = {
    '/orders': {
      get: {
        operationId: 'list_demo_orders',
        summary: '查询当前操作主体可见的订单列表',
        description: '按订单号或客户名查询 demo 订单。无过滤条件时返回当前主体可见的最近订单。',
        'x-agent-capability': capability('demo.order.read', {
          requiresSubject: true,
          readonly: true,
          whenToUse: '用户询问订单状态、付款状态、物流状态或历史订单时使用。',
          returns: '订单号、客户、商品、金额、状态、物流状态。',
        }),
        parameters: [
          { name: 'order_no', in: 'query', required: false, description: '订单号,例如 SO-1001。', schema: { type: 'string' } },
          { name: 'customer', in: 'query', required: false, description: '客户名,可用于模糊过滤。', schema: { type: 'string' } },
        ],
        responses: { '200': { description: 'OK' } },
      },
    },
    '/failure-demo': {
      get: {
        operationId: 'demo_failure_probe',
        summary: '触发一个可观测失败',
        description: '用于演示中枢 trace 如何记录业务工具 5xx、错误正文和排障路径。',
        'x-agent-capability': capability('demo.failure.read', { requiresSubject: true, readonly: true }),
        responses: { '500': { description: 'Intentional demo failure' } },
      },
    },
  };
  if (profile === 'full-local') {
    paths['/tickets'] = {
      post: {
        operationId: 'create_demo_ticket',
        summary: '为当前操作主体创建售后工单',
        description: '当用户明确要求登记问题、创建工单或需要人工跟进时使用。',
        'x-agent-capability': capability('demo.ticket.create', {
          risk: 'medium', requiresSubject: true, idempotent: false,
          whenToUse: '用户要求人工处理、售后跟进、开工单或记录问题时使用。',
          returns: '工单编号与当前状态。',
        }),
        requestBody: {
          required: true,
          content: { 'application/json': { schema: {
            type: 'object',
            properties: { order_no: { type: 'string' }, title: { type: 'string' }, message: { type: 'string' } },
            required: ['title', 'message'],
          } } },
        },
        responses: { '200': { description: 'OK' } },
      },
    };
    paths['/refunds'] = {
      post: {
        operationId: 'request_demo_refund',
        summary: '提交退款申请',
        description: '本地单组织 demo 的高风险工具:会产生内存中的退款申请,必须进入审批车道。',
        'x-agent-capability': capability('demo.refund.request', {
          risk: 'high', requiresSubject: true,
          approval: { required: true, prompt: '确认要为订单 {{order_no}} 申请退款 {{amount}} 元吗?' },
        }),
        requestBody: {
          required: true,
          content: { 'application/json': { schema: {
            type: 'object',
            properties: { order_no: { type: 'string' }, amount: { type: 'number' }, reason: { type: 'string' } },
            required: ['order_no', 'amount', 'reason'],
          } } },
        },
        responses: { '202': { description: 'Accepted' } },
      },
    };
  }
  return {
    openapi: '3.1.0',
    info: { title: 'Bailing demo business tools', version: '1.0.0' },
    'x-bailing-authz-probe': { method: 'POST', path: '/.well-known/bailing/authz-probe' },
    paths,
  };
}

export function parseDemoDatasetManifest(value: unknown): DemoDatasetManifest {
  let parsed: unknown;
  try { parsed = typeof value === 'string' ? JSON.parse(value) as unknown : value; }
  catch { throw new DemoDatasetConflictError('演示数据所有权 manifest 已损坏,为避免误删已停止操作'); }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new DemoDatasetConflictError('演示数据所有权 manifest 已损坏,为避免误删已停止操作');
  }
  const manifest = parsed as Partial<DemoDatasetManifest>;
  if (manifest.schema !== 'bailinghub.demo-dataset.v1'
    || (manifest.profile !== 'full-local' && manifest.profile !== 'stateless-readonly')
    || !manifest.resources || typeof manifest.resources !== 'object') {
    throw new DemoDatasetConflictError('演示数据所有权 manifest 格式不受当前 Core 支持');
  }
  for (const [kind, allowed] of Object.entries(RESOURCE_KEYS)) {
    const rows = (manifest.resources as unknown as Record<string, unknown>)[kind];
    const expected = kind === 'chat_entries' && manifest.profile === 'stateless-readonly' ? [] : allowed;
    if (!Array.isArray(rows) || rows.length !== expected.length) {
      throw new DemoDatasetConflictError(`演示数据 manifest 的 ${kind} 清单与 Core 固定允许列表不匹配`);
    }
    const keys = rows.map((row) => row && typeof row === 'object' && !Array.isArray(row)
      ? String((row as Record<string, unknown>)['key'] ?? '')
      : '');
    const fingerprints = rows.map((row) => row && typeof row === 'object' && !Array.isArray(row)
      ? String((row as Record<string, unknown>)['fingerprint'] ?? '')
      : '');
    if (JSON.stringify(keys) !== JSON.stringify(expected) || fingerprints.some((item) => !/^[a-f0-9]{64}$/.test(item))) {
      throw new DemoDatasetConflictError(`演示数据 manifest 的 ${kind} 所有权证据无效`);
    }
  }
  return manifest as DemoDatasetManifest;
}

function canonicalJson(value: unknown): string {
  if (value === null) return 'null';
  if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
  if (typeof value === 'object') {
    const record = value as Record<string, unknown>;
    return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort()
      .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
  }
  return JSON.stringify(value) ?? 'null';
}

function fingerprint(value: unknown): string {
  return createHash('sha256').update(canonicalJson(value)).digest('hex');
}

function targetFingerprint(value: TargetDef): string { return fingerprint(value); }
function providerFingerprint(value: ToolProvider): string {
  const { spec_access_probe: _specProbe, authz_probe: _authzProbe, ...stable } = value;
  return fingerprint(stable);
}
function routeFingerprint(value: Route): string { return fingerprint(value); }
function clientFingerprint(value: Client): string {
  const { last_used_at: _lastUsedAt, ...stable } = value;
  return fingerprint(stable);
}
function chatEntryFingerprint(value: ChatEntry): string { return fingerprint(value); }

function integer(value: unknown): number {
  return Math.max(0, Number(value) || 0);
}

function iso(value: string | Date): string {
  return new Date(value).toISOString();
}

function storedJson(value: unknown): { ok: boolean; value: unknown } {
  if (typeof value !== 'string') return { ok: true, value };
  try { return { ok: true, value: JSON.parse(value) as unknown }; }
  catch { return { ok: false, value: null }; }
}

function sourceProviders(tools: unknown): { ok: boolean; values: string[] } {
  const parsedResult = storedJson(tools);
  if (!parsedResult.ok) return { ok: false, values: [] };
  const parsed = parsedResult.value;
  if (parsed === null || parsed === undefined) return { ok: true, values: [] };
  if (typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, values: [] };
  const record = parsed as Record<string, unknown>;
  const sources = Array.isArray(record['sources']) ? record['sources'] : record['source'] ? [record['source']] : [];
  return { ok: true, values: sources.flatMap((source) => {
    if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
    const provider = String((source as Record<string, unknown>)['provider'] ?? '').trim();
    return provider ? [provider] : [];
  }) };
}

function jsonStringArray(value: unknown): { ok: boolean; values: string[] } {
  const parsed = storedJson(value);
  if (!parsed.ok) return { ok: false, values: [] };
  if (parsed.value === null || parsed.value === undefined) return { ok: true, values: [] };
  if (!Array.isArray(parsed.value)) return { ok: false, values: [] };
  return { ok: true, values: parsed.value.map(String) };
}

function audienceClients(value: unknown): { ok: boolean; values: string[] } {
  const parsed = storedJson(value);
  if (!parsed.ok) return { ok: false, values: [] };
  if (parsed.value === null || parsed.value === undefined) return { ok: true, values: [] };
  if (typeof parsed.value !== 'object' || Array.isArray(parsed.value)) return { ok: false, values: [] };
  const clients = (parsed.value as Record<string, unknown>)['clients'];
  if (clients === undefined) return { ok: true, values: [] };
  if (!Array.isArray(clients)) return { ok: false, values: [] };
  return { ok: true, values: clients.map(String) };
}

function definition(config: DemoDatasetConfig, nowIso: string): {
  target: TargetDef;
  provider: ToolProvider;
  route: Route;
  client: Omit<Client, 'token'>;
  chatEntry: ChatEntry | null;
} {
  const parsed = parseOpenApiSpec(JSON.stringify(demoToolSpec(config.profile)));
  if (!parsed.ok) throw new Error(`Core 内置演示工具契约无效:${parsed.error}`);
  const full = config.profile === 'full-local';
  return {
    target: {
      name: DEMO_TARGET,
      kind: 'inhub',
      stateless: true,
      needs_project: false,
      timeout_ms: 30_000,
      enabled: true,
      description: '开源体验用确定性 Agent;不依赖外部 LLM,完整走工具治理出口。',
    },
    provider: {
      name: DEMO_TOOL_PROVIDER,
      base_url: config.businessBaseUrl,
      spec_source: 'inline',
      spec_json: parsed.canonicalJson,
      spec_refreshed_at: nowIso,
      secret: config.toolSecret,
      log_payload: false,
      timeout_ms: 8000,
      rate_limit_per_min: 120,
      auto_refresh_min: 0,
      enabled: true,
      description: full
        ? '本地单组织 demo 业务系统:订单、工单、退款审批与故障观测。'
        : '无状态只读 demo 业务系统:只暴露静态订单查询与故障观测,不保存租户请求。',
    },
    route: {
      route_key: DEMO_ROUTE,
      name: full ? 'Demo 售后助手' : 'Demo 只读订单助手',
      enabled: true,
      target: DEMO_TARGET,
      target_config: {},
      profile: 'demo',
      permission: full ? 'readwrite' : 'readonly',
      session_policy: 'per_key',
      session_key_field: 'visitor_uid',
      tools: {
        sources: [{
          provider: DEMO_TOOL_PROVIDER,
          allow: full
            ? ['demo.order.*', 'demo.ticket.*', 'demo.refund.*', 'demo.failure.*']
            : ['demo.order.*', 'demo.failure.*'],
          subject_field: 'operator_uid',
        }],
        max_calls: full ? 5 : 3,
        ...(full ? { approval: { type: 'business_webhook', url: `${config.businessBaseUrl}/approvals` } } : {}),
      },
      memory: { recent_messages: 8, recent_budget_chars: 6000 },
      retry: { max: 1, backoff_ms: 1000 },
      description: full
        ? '本地开源 demo:用确定性 Agent 展示查询、写工具与审批。'
        : '共享体验环境 demo:仅调用无状态只读工具,不产生业务侧写入或回调。',
    },
    client: {
      app_id: DEMO_CLIENT,
      name: 'Demo 业务系统',
      allowed_routes: [DEMO_ROUTE],
      allowed_channels: [],
      rate_limit_per_min: 60,
      enabled: true,
      description: '中枢内置演示接入方,仅可触发 demo_support。',
    },
    chatEntry: full ? {
      entry_key: DEMO_CHAT_ENTRY,
      name: 'Demo 网页助手',
      route_key: DEMO_ROUTE,
      enabled: false,
      allowed_origins: [],
      rate_limit_per_min: 20,
      ticket_client: DEMO_CLIENT,
      title: '订单助手',
      greeting: '你好,我可以查订单、创建售后工单,高风险退款会先进入审批。',
      color: '#2f7d68',
      appearance: { width: 400, height: 600, position: 'right' },
      description: '本地演示入口:默认停用,请先设置明确 Origin 白名单再启用。',
    } : null,
  };
}

async function markerFor(connection: PoolConnection, lock = false): Promise<DatasetMarkerRow | null> {
  const [rows] = await connection.query<DatasetMarkerRow[]>(
    `SELECT version,manifest_json,imported_at,updated_at FROM bz_demo_datasets WHERE dataset_key=? LIMIT 1${lock ? ' FOR UPDATE' : ''}`,
    [DEMO_DATASET_KEY],
  );
  return rows[0] ?? null;
}

async function countStatus(connection: Pick<PoolConnection, 'query'>): Promise<{ counts: DemoDatasetCounts; empty: boolean }> {
  const [rows] = await connection.query<CountRow[]>(
    `SELECT
      (SELECT COUNT(*) FROM bz_routes) AS routes,
      (SELECT COUNT(*) FROM bz_clients) AS clients,
      (SELECT COUNT(*) FROM bz_tool_providers) AS tool_providers,
      (SELECT COUNT(*) FROM bz_targets) AS targets,
      (SELECT COUNT(*) FROM bz_channels) AS channels,
      (SELECT COUNT(*) FROM bz_chat_entries) AS chat_entries,
      (SELECT COUNT(*) FROM bz_jobs) AS jobs,
      (SELECT COUNT(*) FROM bz_tool_approvals) AS approvals,
      (SELECT COUNT(*) FROM bz_targets WHERE name <> 'llm') AS custom_targets,
      ((SELECT COUNT(*) FROM bz_credentials) +
       (SELECT COUNT(*) FROM bz_projects) +
       (SELECT COUNT(*) FROM bz_kb_bases) +
       (SELECT COUNT(*) FROM bz_storage_buckets) +
       (SELECT COUNT(*) FROM bz_executor_tokens)) AS auxiliary`,
  );
  const row = rows[0]!;
  const counts: DemoDatasetCounts = {
    routes: integer(row.routes),
    clients: integer(row.clients),
    tool_providers: integer(row.tool_providers),
    targets: integer(row.targets),
    channels: integer(row.channels),
    chat_entries: integer(row.chat_entries),
    jobs: integer(row.jobs),
    approvals: integer(row.approvals),
  };
  const empty = counts.routes + counts.clients + counts.tool_providers + counts.channels
    + counts.chat_entries + counts.jobs + counts.approvals + integer(row.custom_targets) + integer(row.auxiliary) === 0;
  return { counts, empty };
}

async function statusFor(
  connection: Pick<PoolConnection, 'query'>,
  available: boolean,
): Promise<DemoDatasetStatus> {
  const [{ counts, empty }, markerRows] = await Promise.all([
    countStatus(connection),
    connection.query<DatasetMarkerRow[]>(
      'SELECT version,manifest_json,imported_at,updated_at FROM bz_demo_datasets WHERE dataset_key=? LIMIT 1',
      [DEMO_DATASET_KEY],
    ),
  ]);
  const marker = markerRows[0][0];
  if (marker) {
    if (Number(marker.version) !== DEMO_DATASET_VERSION) {
      throw new DemoDatasetConflictError(`演示数据版本 ${marker.version} 与当前 Core 版本 ${DEMO_DATASET_VERSION} 不匹配`);
    }
    parseDemoDatasetManifest(marker.manifest_json);
  }
  return {
    available,
    imported: Boolean(marker),
    empty,
    ...(marker ? { imported_at: iso(marker.imported_at) } : {}),
    counts,
  };
}

async function lockReservedSlots(connection: PoolConnection, profile: DemoDatasetProfile): Promise<string[]> {
  const checks: Array<{ label: string; sql: string; key: string }> = [
    { label: `target:${DEMO_TARGET}`, sql: 'SELECT name FROM bz_targets WHERE name=? FOR UPDATE', key: DEMO_TARGET },
    { label: `tool-provider:${DEMO_TOOL_PROVIDER}`, sql: 'SELECT name FROM bz_tool_providers WHERE name=? FOR UPDATE', key: DEMO_TOOL_PROVIDER },
    { label: `route:${DEMO_ROUTE}`, sql: 'SELECT route_key FROM bz_routes WHERE route_key=? FOR UPDATE', key: DEMO_ROUTE },
    { label: `client:${DEMO_CLIENT}`, sql: 'SELECT app_id FROM bz_clients WHERE app_id=? FOR UPDATE', key: DEMO_CLIENT },
    ...(profile === 'full-local' ? [{
      label: `chat-entry:${DEMO_CHAT_ENTRY}`,
      sql: 'SELECT entry_key FROM bz_chat_entries WHERE entry_key=? FOR UPDATE',
      key: DEMO_CHAT_ENTRY,
    }] : []),
  ];
  const occupied: string[] = [];
  for (const item of checks) {
    const [rows] = await connection.query<RowDataPacket[]>(item.sql, [item.key]);
    if (rows.length) occupied.push(item.label);
  }
  return occupied;
}

async function reservedRuntimeConflicts(connection: PoolConnection): Promise<string[]> {
  const conflicts: string[] = [];
  const [sessions] = await connection.query<RowDataPacket[]>(
    'SELECT id FROM bz_sessions WHERE route_key=? LIMIT 1 FOR UPDATE', [DEMO_ROUTE],
  );
  if (sessions.length) conflicts.push(`session-route:${DEMO_ROUTE}`);
  const [threads] = await connection.query<RowDataPacket[]>(
    'SELECT thread_id FROM bz_threads WHERE route_key=? LIMIT 1 FOR UPDATE', [DEMO_ROUTE],
  );
  if (threads.length) conflicts.push(`thread-route:${DEMO_ROUTE}`);
  const [jobs] = await connection.query<RowDataPacket[]>(
    "SELECT job_id FROM bz_jobs WHERE target=? OR client_app_id=? OR JSON_UNQUOTE(JSON_EXTRACT(dispatch,'$.route_key'))=? LIMIT 1 FOR UPDATE",
    [DEMO_TARGET, DEMO_CLIENT, DEMO_ROUTE],
  );
  if (jobs.length) conflicts.push('job-demo-identity');
  const [approvals] = await connection.query<RowDataPacket[]>(
    'SELECT id FROM bz_tool_approvals WHERE provider=? LIMIT 1 FOR UPDATE', [DEMO_TOOL_PROVIDER],
  );
  if (approvals.length) conflicts.push(`tool-approval-provider:${DEMO_TOOL_PROVIDER}`);
  const [ratings] = await connection.query<RowDataPacket[]>(
    'SELECT job_id FROM bz_job_ratings WHERE entry_key=? LIMIT 1 FOR UPDATE', [DEMO_CHAT_ENTRY],
  );
  if (ratings.length) conflicts.push(`job-rating-entry:${DEMO_CHAT_ENTRY}`);
  const [embeddings] = await connection.query<RowDataPacket[]>(
    'SELECT id FROM bz_tool_embeddings WHERE provider=? LIMIT 1 FOR UPDATE', [DEMO_TOOL_PROVIDER],
  );
  if (embeddings.length) conflicts.push(`tool-embedding-provider:${DEMO_TOOL_PROVIDER}`);
  return conflicts;
}

async function reservedConflicts(connection: PoolConnection, profile: DemoDatasetProfile): Promise<string[]> {
  return [
    ...await lockReservedSlots(connection, profile),
    ...await reservedRuntimeConflicts(connection),
  ];
}

async function externalReferences(
  connection: PoolConnection,
  manifest?: DemoDatasetManifest,
  importingProfile?: DemoDatasetProfile,
): Promise<string[]> {
  const conflicts: string[] = [];
  // 清理很少发生;在 SERIALIZABLE 交易中锁住相关配置表的现有行与插入空隙,
  // 避免“检查无引用→并发新建引用→删除”留下断链配置。
  const [routeRows] = await connection.query<Array<RowDataPacket & {
    route_key: string; target: string; tools: unknown; audience: unknown;
  }>>(
    'SELECT route_key,target,tools,audience FROM bz_routes FOR UPDATE',
  );
  for (const row of routeRows) {
    if (manifest && row.route_key === DEMO_ROUTE) continue;
    if (row.target === DEMO_TARGET) conflicts.push(`route:${row.route_key}->target:${DEMO_TARGET}`);
    const providers = sourceProviders(row.tools);
    if (!providers.ok) conflicts.push(`route:${row.route_key}->unreadable-tools`);
    else if (providers.values.includes(DEMO_TOOL_PROVIDER)) conflicts.push(`route:${row.route_key}->tool-provider:${DEMO_TOOL_PROVIDER}`);
    const clients = audienceClients(row.audience);
    if (!clients.ok) conflicts.push(`route:${row.route_key}->unreadable-audience`);
    else if (clients.values.includes(DEMO_CLIENT)) conflicts.push(`route:${row.route_key}->client:${DEMO_CLIENT}`);
  }
  const [chatRows] = await connection.query<Array<RowDataPacket & {
    entry_key: string; route_key: string; ticket_client: string | null;
  }>>(
    'SELECT entry_key,route_key,ticket_client FROM bz_chat_entries FOR UPDATE',
  );
  const ownsChatEntry = Boolean(manifest?.resources.chat_entries.some((item) => item.key === DEMO_CHAT_ENTRY));
  for (const row of chatRows) {
    if (ownsChatEntry && row.entry_key === DEMO_CHAT_ENTRY) continue;
    if (row.route_key === DEMO_ROUTE) conflicts.push(`chat-entry:${row.entry_key}->route:${DEMO_ROUTE}`);
    if (row.ticket_client === DEMO_CLIENT) conflicts.push(`chat-entry:${row.entry_key}->client:${DEMO_CLIENT}`);
  }
  const [pageRows] = await connection.query<Array<RowDataPacket & { id: number; entry_key: string }>>(
    'SELECT id,entry_key FROM bz_page_contexts FOR UPDATE',
  );
  if (ownsChatEntry || (!manifest && importingProfile === 'full-local')) {
    conflicts.push(...pageRows
      .filter((row) => row.entry_key === DEMO_CHAT_ENTRY)
      .map((row) => `page-context:${row.id}->chat-entry:${DEMO_CHAT_ENTRY}`));
  }
  const [channelRows] = await connection.query<Array<RowDataPacket & { name: string; route_key: string }>>(
    'SELECT name,route_key FROM bz_channels FOR UPDATE',
  );
  conflicts.push(...channelRows
    .filter((row) => row.route_key === DEMO_ROUTE)
    .map((row) => `channel:${row.name}->route:${DEMO_ROUTE}`));
  const [clientRows] = await connection.query<Array<RowDataPacket & { app_id: string; allowed_routes: unknown }>>(
    'SELECT app_id,allowed_routes FROM bz_clients FOR UPDATE',
  );
  for (const row of clientRows) {
    if (manifest && row.app_id === DEMO_CLIENT) continue;
    const routes = jsonStringArray(row.allowed_routes);
    if (!routes.ok) conflicts.push(`client:${row.app_id}->unreadable-routes`);
    else if (routes.values.includes(DEMO_ROUTE)) {
      conflicts.push(`client:${row.app_id}->route:${DEMO_ROUTE}`);
    }
  }
  const [executorRows] = await connection.query<Array<RowDataPacket & { name: string; allowed_targets: unknown }>>(
    'SELECT name,allowed_targets FROM bz_executor_tokens FOR UPDATE',
  );
  for (const row of executorRows) {
    const targets = jsonStringArray(row.allowed_targets);
    if (!targets.ok) conflicts.push(`executor-token:${row.name}->unreadable-targets`);
    else if (targets.values.includes(DEMO_TARGET)) conflicts.push(`executor-token:${row.name}->target:${DEMO_TARGET}`);
  }
  return [...new Set(conflicts)];
}

function isDuplicateEntry(error: unknown): boolean {
  if (!error || typeof error !== 'object') return false;
  const record = error as Record<string, unknown>;
  return record['code'] === 'ER_DUP_ENTRY' || Number(record['errno']) === 1062;
}

async function writeDefinition(
  connection: PoolConnection,
  config: DemoDatasetConfig,
  nowIso: string,
  insertOnly: boolean,
): Promise<string[]> {
  const item = definition(config, nowIso);
  const targets = new TargetRepository(() => connection);
  const providers = new ToolProviderRepository(() => connection);
  const routes = new RouteRepository(() => connection);
  const clients = new ClientRepository(() => connection);
  const chatEntries = new ChatConfigRepository(() => connection);
  if (config.profile === 'full-local' && config.clientToken && config.clientToken.length < 16) {
    throw new Error('DEMO_CLIENT_TOKEN 至少 16 位');
  }
  if (insertOnly) {
    await targets.create(item.target);
    await providers.create(item.provider);
    await routes.create(item.route);
    await clients.create(item.client, config.profile === 'full-local' ? config.clientToken : undefined);
    if (item.chatEntry) await chatEntries.create(item.chatEntry);
  } else {
    await targets.upsert(item.target);
    await providers.upsert(item.provider);
    await routes.upsert(item.route);
    await clients.upsert(item.client, false);
    if (config.profile === 'full-local' && config.clientToken) {
      await clients.replaceToken(DEMO_CLIENT, config.clientToken);
    }
    if (item.chatEntry) await chatEntries.upsert(item.chatEntry);
  }
  return [
    `target:${DEMO_TARGET}`,
    `tool-provider:${DEMO_TOOL_PROVIDER}`,
    `route:${DEMO_ROUTE}`,
    `client:${DEMO_CLIENT}`,
    ...(item.chatEntry ? [`chat-entry:${DEMO_CHAT_ENTRY}`] : []),
  ];
}

async function currentOwnedResources(connection: PoolConnection): Promise<{
  target: TargetDef | null;
  provider: ToolProvider | null;
  route: Route | null;
  client: Client | null;
  chatEntry: ChatEntry | null;
}> {
  const targets = new TargetRepository(() => connection);
  const providers = new ToolProviderRepository(() => connection);
  const routes = new RouteRepository(() => connection);
  const clients = new ClientRepository(() => connection);
  const chatEntries = new ChatConfigRepository(() => connection);
  const [targetRows, provider, route, client, chatEntry] = await Promise.all([
    targets.list(),
    providers.get(DEMO_TOOL_PROVIDER),
    routes.get(DEMO_ROUTE),
    clients.get(DEMO_CLIENT),
    chatEntries.get(DEMO_CHAT_ENTRY),
  ]);
  return {
    target: targetRows.find((item) => item.name === DEMO_TARGET) ?? null,
    provider,
    route,
    client,
    chatEntry,
  };
}

async function manifestFromCurrent(connection: PoolConnection, profile: DemoDatasetProfile): Promise<DemoDatasetManifest> {
  const current = await currentOwnedResources(connection);
  if (!current.target || !current.provider || !current.route || !current.client
    || (profile === 'full-local' && !current.chatEntry)) {
    throw new Error('Core 演示配置写入后未能完整读回,交易已回滚');
  }
  return {
    schema: 'bailinghub.demo-dataset.v1',
    profile,
    resources: {
      targets: [{ key: DEMO_TARGET, fingerprint: targetFingerprint(current.target) }],
      tool_providers: [{ key: DEMO_TOOL_PROVIDER, fingerprint: providerFingerprint(current.provider) }],
      routes: [{ key: DEMO_ROUTE, fingerprint: routeFingerprint(current.route) }],
      clients: [{ key: DEMO_CLIENT, fingerprint: clientFingerprint(current.client) }],
      chat_entries: profile === 'full-local'
        ? [{ key: DEMO_CHAT_ENTRY, fingerprint: chatEntryFingerprint(current.chatEntry!) }]
        : [],
    },
  };
}

async function verifyOwnedResources(connection: PoolConnection, manifest: DemoDatasetManifest): Promise<void> {
  const current = await currentOwnedResources(connection);
  const checks: Array<{ label: string; current: unknown | null; expected: string; actual(value: any): string }> = [
    { label: `target:${DEMO_TARGET}`, current: current.target, expected: manifest.resources.targets[0]!.fingerprint, actual: targetFingerprint },
    { label: `tool-provider:${DEMO_TOOL_PROVIDER}`, current: current.provider, expected: manifest.resources.tool_providers[0]!.fingerprint, actual: providerFingerprint },
    { label: `route:${DEMO_ROUTE}`, current: current.route, expected: manifest.resources.routes[0]!.fingerprint, actual: routeFingerprint },
    { label: `client:${DEMO_CLIENT}`, current: current.client, expected: manifest.resources.clients[0]!.fingerprint, actual: clientFingerprint },
    ...manifest.resources.chat_entries.map((item) => ({
      label: `chat-entry:${item.key}`,
      current: current.chatEntry,
      expected: item.fingerprint,
      actual: chatEntryFingerprint,
    })),
  ];
  const conflicts = checks.flatMap((item) => item.current && item.actual(item.current) !== item.expected ? [item.label] : []);
  if (conflicts.length) {
    throw new DemoDatasetConflictError(
      `演示资源导入后已被修改或同名重建,Core 不会覆盖或删除这些对象:${conflicts.join(', ')}`,
      conflicts,
    );
  }
}

async function clearDefinition(connection: PoolConnection, manifest: DemoDatasetManifest): Promise<string[]> {
  const chatEntries = new ChatConfigRepository(() => connection);
  const clients = new ClientRepository(() => connection);
  const routes = new RouteRepository(() => connection);
  const providers = new ToolProviderRepository(() => connection);
  const targets = new TargetRepository(() => connection);
  for (const item of manifest.resources.chat_entries) await chatEntries.delete(item.key);
  for (const item of manifest.resources.clients) await clients.delete(item.key);
  for (const item of manifest.resources.routes) await routes.delete(item.key);
  for (const item of manifest.resources.tool_providers) await providers.delete(item.key);
  for (const item of manifest.resources.targets) await targets.delete(item.key);
  return [
    ...manifest.resources.chat_entries.map((item) => `chat-entry:${item.key}`),
    ...manifest.resources.clients.map((item) => `client:${item.key}`),
    ...manifest.resources.routes.map((item) => `route:${item.key}`),
    ...manifest.resources.tool_providers.map((item) => `tool-provider:${item.key}`),
    ...manifest.resources.targets.map((item) => `target:${item.key}`),
  ];
}

export class DemoDatasetService {
  constructor(
    private readonly store: Pick<ConfigStoreContract, 'db'>,
    private readonly config: DemoDatasetConfig | null | undefined,
    private readonly now: () => string = () => new Date().toISOString(),
  ) {}

  async status(): Promise<DemoDatasetStatus> {
    return await statusFor(this.store.db, Boolean(this.config));
  }

  async import(): Promise<DemoDatasetImportResult> {
    if (!this.config) throw new DemoDatasetUnavailableError('当前部署未配置演示业务服务,无法导入演示数据');
    return await this.withLockedTransaction(async (connection) => {
      const marker = await markerFor(connection, true);
      if (marker) {
        if (Number(marker.version) !== DEMO_DATASET_VERSION) {
          throw new DemoDatasetConflictError(`演示数据版本 ${marker.version} 与当前 Core 版本 ${DEMO_DATASET_VERSION} 不匹配`);
        }
        const manifest = parseDemoDatasetManifest(marker.manifest_json);
        if (manifest.profile !== this.config!.profile) {
          throw new DemoDatasetConflictError(
            `已导入的演示 profile 为 ${manifest.profile},当前配置为 ${this.config!.profile};请先清理再切换`,
          );
        }
        await lockReservedSlots(connection, manifest.profile);
        await verifyOwnedResources(connection, manifest);
        const references = await externalReferences(connection, manifest);
        if (references.length) {
          throw new DemoDatasetConflictError(
            `演示资源已被其他配置引用,为避免刷新时改变这些依赖已停止导入:${references.join(', ')}`,
            references,
          );
        }
      } else {
        const conflicts = await reservedConflicts(connection, this.config!.profile);
        if (conflicts.length) {
          throw new DemoDatasetConflictError(
            `保留的 demo 资源名已被占用,Core 不会猜测所有权或覆盖已有配置:${conflicts.join(', ')}`,
            conflicts,
          );
        }
        const references = await externalReferences(connection, undefined, this.config!.profile);
        if (references.length) {
          throw new DemoDatasetConflictError(
            `现有配置已引用 Core 保留的 demo 标识,首次导入可能会意外接通这些对象:${references.join(', ')}`,
            references,
          );
        }
      }
      const nowIso = this.now();
      let created: string[];
      try {
        created = await writeDefinition(connection, this.config!, nowIso, !marker);
      } catch (error) {
        if (!marker && isDuplicateEntry(error)) {
          throw new DemoDatasetConflictError(
            '演示资源在首次导入期间被其他操作占用,Core 已回滚且不会覆盖该对象',
            ['reserved-demo-resource'],
          );
        }
        throw error;
      }
      const manifest = await manifestFromCurrent(connection, this.config!.profile);
      const nowSql = dtAt(new Date(nowIso).getTime());
      if (marker) {
        await connection.query(
          'UPDATE bz_demo_datasets SET version=?,manifest_json=?,updated_at=? WHERE dataset_key=?',
          [DEMO_DATASET_VERSION, JSON.stringify(manifest), nowSql, DEMO_DATASET_KEY],
        );
      } else {
        await connection.query(
          'INSERT INTO bz_demo_datasets (dataset_key,version,manifest_json,imported_at,updated_at) VALUES (?,?,?,?,?)',
          [DEMO_DATASET_KEY, DEMO_DATASET_VERSION, JSON.stringify(manifest), nowSql, nowSql],
        );
      }
      return { ok: true, ...await statusFor(connection, true), created };
    });
  }

  async clear(): Promise<DemoDatasetClearResult> {
    return await this.withLockedTransaction(async (connection) => {
      const marker = await markerFor(connection, true);
      if (!marker) return { ok: true, ...await statusFor(connection, Boolean(this.config)), removed: [] };
      if (Number(marker.version) !== DEMO_DATASET_VERSION) {
        throw new DemoDatasetConflictError(`演示数据版本 ${marker.version} 与当前 Core 版本 ${DEMO_DATASET_VERSION} 不匹配`);
      }
      const manifest = parseDemoDatasetManifest(marker.manifest_json);
      await lockReservedSlots(connection, manifest.profile);
      await verifyOwnedResources(connection, manifest);
      const references = await externalReferences(connection, manifest);
      if (references.length) {
        throw new DemoDatasetConflictError(
          `演示资源已被其他配置引用,为避免破坏用户配置已停止清理:${references.join(', ')}`,
          references,
        );
      }
      const removed = await clearDefinition(connection, manifest);
      await connection.query('DELETE FROM bz_demo_datasets WHERE dataset_key=?', [DEMO_DATASET_KEY]);
      return { ok: true, ...await statusFor(connection, Boolean(this.config)), removed };
    });
  }

  private async withLockedTransaction<T>(work: (connection: PoolConnection) => Promise<T>): Promise<T> {
    const connection = await this.store.db.getConnection();
    let locked = false;
    try {
      const [lockRows] = await connection.query<Array<RowDataPacket & { acquired: number }>>(LOCK_SQL);
      locked = Number(lockRows[0]?.acquired) === 1;
      if (!locked) throw new Error('演示数据操作锁获取超时,请稍后重试');
      await connection.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
      await connection.beginTransaction();
      try {
        const result = await work(connection);
        await connection.commit();
        return result;
      } catch (error) {
        await connection.rollback().catch(() => undefined);
        throw error;
      }
    } finally {
      if (locked) await connection.query(UNLOCK_SQL).catch(() => undefined);
      connection.release();
    }
  }
}