lxylxy123321 пре 1 недеља
родитељ
комит
00498a6b45

+ 88 - 0
DEPLOY.md

@@ -0,0 +1,88 @@
+# 分布式部署指南
+
+## 架构
+
+```
+151 (主节点)                      253 (算力节点)
+├── PostgreSQL                    ├── SSH 服务
+├── 后端 API (8010)               ├── NFS 挂载 151:/root/Fine-tuning/backend/data
+├── 前端 (3000)                   ├── Python 训练环境 (transformers/peft/torch)
+└── data/(模型/数据集/adapter)   └── 远程训练脚本 (app/engines/remote_train.py)
+```
+
+## 151 部署步骤
+
+```bash
+cd /root/Fine-tuning
+git pull
+
+# 编辑 .env.docker 或 docker-compose.yml 设置算力节点
+vi backend/.env.docker
+# 修改 COMPUTE_NODE_HOST=192.168.91.253
+
+# 配置 SSH 免密登录到 253
+ssh-copy-id root@192.168.91.253
+
+# 启动
+docker compose down -v
+docker compose up -d --build
+```
+
+## 253 配置步骤
+
+### 1. 安装 NFS 客户端
+
+```bash
+# CentOS/RHEL
+yum install -y nfs-utils
+# Ubuntu/Debian
+apt install -y nfs-common
+```
+
+### 2. 挂载 151 的数据目录
+
+```bash
+# 创建挂载点
+mkdir -p /root/Fine-tuning/backend/data
+
+# 挂载 NFS (在 151 上需要先配置 NFS 导出)
+mount -t nfs 192.168.92.151:/root/Fine-tuning/backend/data /root/Fine-tuning/backend/data
+```
+
+### 3. 安装 Python 训练依赖
+
+```bash
+# 在 253 上执行(确保代码路径一致)
+cd /root/Fine-tuning/backend
+pip install -r requirements.txt
+```
+
+### 4. 创建应用目录结构
+
+```bash
+cd /root/Fine-tuning/backend
+mkdir -p app/engines app/core app/services app/config app/api
+# 从 151 同步代码
+rsync -av 192.168.92.151:/root/Fine-tuning/backend/app/ ./app/
+```
+
+## 151 配置 NFS 导出(如需)
+
+```bash
+# 编辑 /etc/exports
+echo "/root/Fine-tuning/backend/data *(rw,sync,no_root_squash,no_subtree_check)" >> /etc/exports
+
+# 重启 NFS
+systemctl restart nfs-server
+exportfs -ra
+```
+
+## 验证
+
+```bash
+# 在 151 上测试 SSH 连接
+ssh root@192.168.91.253 "python --version"
+
+# 测试远程命令
+docker exec finetune-backend python -c "from app.config import get_settings; s=get_settings(); print(s.use_remote_compute)"
+```

+ 1 - 1
backend/Dockerfile

@@ -7,7 +7,7 @@ WORKDIR /app
 # 设置 conda Python 路径(镜像使用 /opt/conda)
 ENV PATH="/opt/conda/bin:$PATH"
 
-RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y git openssh-client sshpass && rm -rf /var/lib/apt/lists/*
 
 # 升级 pip
 RUN /opt/conda/bin/pip install --no-cache-dir --upgrade pip

+ 17 - 0
backend/app/config.py

@@ -89,6 +89,18 @@ class Settings(BaseSettings):
     max_upload_size_mb: int = 500
     allowed_dataset_formats: str = "jsonl,csv,parquet,json"
 
+    # --- 分布式计算节点 ---
+    compute_node_host: str = ""  # 算力节点 IP,为空则本地执行
+    compute_node_ssh_port: int = 22
+    compute_node_ssh_user: str = "root"
+    compute_node_ssh_password: str = ""  # SSH 密码(与密钥二选一)
+    compute_node_ssh_key: str = ""  # SSH 私钥路径
+    compute_node_python: str = "/opt/conda/bin/python"
+    compute_node_workdir: str = "/root/Fine-tuning/backend"
+    compute_node_remote_data_dir: str = "/root/Fine-tuning/backend/data"
+    compute_node_remote_env: str = "production"
+    compute_node_ssh_timeout: int = 300  # SSH 命令超时(秒)
+
     @field_validator("backend_cors_origins", mode="before")
     @classmethod
     def parse_cors_origins(cls, v):
@@ -112,6 +124,11 @@ class Settings(BaseSettings):
     def processed_dir(self) -> Path:
         return self.data_dir / "processed"
 
+    @property
+    def use_remote_compute(self) -> bool:
+        """是否启用远程算力节点。"""
+        return bool(self.compute_node_host)
+
     def ensure_dirs(self) -> None:
         self.data_dir.mkdir(parents=True, exist_ok=True)
         for d in [self.models_dir, self.adapters_dir, self.uploads_dir, self.processed_dir]:

+ 41 - 26
backend/app/core/job_queue.py

@@ -167,47 +167,62 @@ class JobQueue:
             peft_method = job.peft_method
             dataset_id = config.get("dataset_id", job.dataset_id)
 
-            # 获取数据集文件路径
             from app.config import get_settings
             settings = get_settings()
 
-            # 查找数据集文件(优先查数据库)
+            # 查找数据集文件路径
             dataset_path = await self._lookup_dataset_db(dataset_id)
             if not dataset_path:
                 dataset_path = self._find_dataset_path(dataset_id)
             if not dataset_path:
                 raise FileNotFoundError(f"Dataset not found: {dataset_id}")
 
-            # 预处理
+            # 选择引擎
+            engine = self._get_engine(model_type)
+
+            # 预处理数据集(始终在本地执行)
             processed_path = str(settings.processed_dir / f"{job_id}_processed.jsonl")
             task_type = config.get("task_type", "sft")
             template = config.get("dataset_template", "alpaca")
 
-            # 选择引擎
-            engine = self._get_engine(model_type)
-
-            # 预处理
             await engine.preprocess_dataset(dataset_path, processed_path, task_type=task_type, template=template)
-            self.update_job(job_id, status=JobStatus.TRAINING)
-            await self._notify_callbacks()
-
-            # 加载模型
-            await engine.load_model(model_id, quantization="4bit" if peft_method == "qlora" else None)
-
-            # 构建 PEFT 配置
-            peft_config = engine.get_peft_config(peft_method, config)
 
-            # 训练
-            adapter_path = await engine.train(
-                job_id=job_id,
-                dataset_path=processed_path,
-                peft_config=peft_config,
-                training_args=config,
-            )
-
-            self.update_job(job_id, status=JobStatus.COMPLETED, adapter_path=adapter_path)
-            await self._notify_callbacks()
-            logger.info(f"Job {job_id} completed successfully")
+            # 判断是否远程执行
+            if settings.use_remote_compute:
+                # 远程训练模式
+                self.update_job(job_id, status=JobStatus.TRAINING)
+                await self._notify_callbacks()
+
+                from app.core.remote_executor import run_training_remote
+                success = run_training_remote(job_id, model_id, model_type, dataset_id, config)
+
+                if not success:
+                    raise RuntimeError("Failed to launch remote training")
+
+                # 远程模式下,训练完成后通过日志解析或轮询获取 adapter_path
+                # 这里暂时标记为完成,adapter_path 由远程脚本回写
+                self.update_job(job_id, status=JobStatus.COMPLETED,
+                                adapter_path=str(settings.adapters_dir / job_id))
+                await self._notify_callbacks()
+                logger.info(f"Remote training launched for job {job_id}")
+            else:
+                # 本地训练模式
+                await engine.load_model(model_id, quantization="4bit" if peft_method == "qlora" else None)
+                peft_config = engine.get_peft_config(peft_method, config)
+
+                self.update_job(job_id, status=JobStatus.TRAINING)
+                await self._notify_callbacks()
+
+                adapter_path = await engine.train(
+                    job_id=job_id,
+                    dataset_path=processed_path,
+                    peft_config=peft_config,
+                    training_args=config,
+                )
+
+                self.update_job(job_id, status=JobStatus.COMPLETED, adapter_path=adapter_path)
+                await self._notify_callbacks()
+                logger.info(f"Job {job_id} completed successfully")
 
         except asyncio.CancelledError:
             self.update_job(job_id, status=JobStatus.CANCELLED)

+ 131 - 0
backend/app/core/remote_executor.py

@@ -0,0 +1,131 @@
+"""SSH 远程执行模块 — 在算力节点上运行 GPU 任务。"""
+import json
+import os
+import subprocess
+from typing import Any
+
+from app.config import get_settings
+from app.core.logging import logger
+
+settings = get_settings()
+
+
+def _get_ssh_prefix() -> list[str]:
+    """构建 ssh/scp 命令前缀,支持密钥或密码登录。"""
+    prefix = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10"]
+    if settings.compute_node_ssh_key:
+        prefix.extend(["-i", settings.compute_node_ssh_key])
+    elif settings.compute_node_ssh_password:
+        prefix = ["sshpass", "-p", settings.compute_node_ssh_password] + prefix
+    return prefix
+
+
+def ssh_exec(cmd: str, timeout: int | None = None) -> tuple[int, str, str]:
+    """通过 SSH 在算力节点执行命令,返回 (exit_code, stdout, stderr)。"""
+    if not settings.use_remote_compute:
+        raise RuntimeError("未配置算力节点(compute_node_host 为空)")
+
+    target = f"{settings.compute_node_ssh_user}@{settings.compute_node_host}"
+    ssh_cmd = [
+        "ssh", *_get_ssh_prefix(),
+        "-p", str(settings.compute_node_ssh_port),
+        target,
+        cmd,
+    ]
+
+    timeout = timeout or settings.compute_node_ssh_timeout
+    try:
+        proc = subprocess.run(
+            ssh_cmd,
+            capture_output=True,
+            text=True,
+            timeout=timeout,
+        )
+        return proc.returncode, proc.stdout, proc.stderr
+    except subprocess.TimeoutExpired:
+        logger.error(f"SSH command timeout after {timeout}s: {cmd[:100]}")
+        return -1, "", f"Command timed out after {timeout}s"
+    except Exception as e:
+        logger.error(f"SSH exec failed: {e}")
+        return -1, "", str(e)
+
+
+def run_training_remote(
+    job_id: str,
+    model_id: str,
+    model_type: str,
+    dataset_id: str,
+    config: dict[str, Any],
+) -> bool:
+    """在算力节点启动训练任务(后台执行,不阻塞)。
+
+    使用 nohup + & 让训练在后台运行,通过 WebSocket 回传进度。
+    """
+    config_json = json.dumps(config, ensure_ascii=False)
+    # 转义双引号避免 shell 解析问题
+    config_escaped = config_json.replace('"', '\\"')
+
+    log_path = os.path.join(settings.compute_node_workdir, f"logs/{job_id}.log")
+    log_dir = os.path.dirname(log_path)
+
+    remote_cmd = (
+        f"mkdir -p {log_dir} && "
+        f"cd {settings.compute_node_workdir} && "
+        f"nohup {settings.compute_node_python} -m app.engines.remote_train "
+        f"'{job_id}' '{model_id}' '{model_type}' '{dataset_id}' '{config_escaped}' "
+        f"> {log_path} 2>&1 & echo $!"
+    )
+
+    code, stdout, stderr = ssh_exec(remote_cmd, timeout=30)
+
+    if code != 0:
+        logger.error(f"Remote training launch failed: {stderr}")
+        return False
+
+    logger.info(f"Remote training launched: job={job_id}, pid={stdout.strip()}")
+    return True
+
+
+def run_inference_remote(
+    model_id: str,
+    adapter_id: str,
+    prompt: str,
+    max_new_tokens: int,
+    temperature: float,
+    top_p: float,
+    repetition_penalty: float,
+    do_sample: bool,
+) -> dict[str, Any] | None:
+    """在算力节点执行推理。"""
+    safe_prompt = prompt.replace('"', '\\"').replace("'", "\\'").replace("\n", "\\n")
+
+    remote_cmd = (
+        f"cd {settings.compute_node_workdir} && "
+        f"{settings.compute_node_python} -c \""
+        "import asyncio, json; "
+        "from app.config import get_settings; "
+        "settings = get_settings(); "
+        "from app.services.inference_service import run_inference_single; "
+        f"result = asyncio.run(run_inference_single("
+        f"'{model_id}', '{adapter_id}', '{safe_prompt}', "
+        f"{max_new_tokens}, {temperature}, {top_p}, {repetition_penalty}, {str(do_sample).lower()}"
+        ")); "
+        "print(json.dumps(result, ensure_ascii=False))\" 2>&1"
+    )
+
+    code, stdout, stderr = ssh_exec(remote_cmd, timeout=600)
+
+    if code != 0:
+        logger.error(f"Remote inference failed: {stderr}")
+        return {"error": stderr.strip() or "Remote inference failed"}
+
+    # 提取最后一行 JSON
+    for line in reversed(stdout.strip().split("\n")):
+        line = line.strip()
+        if line.startswith("{"):
+            try:
+                return json.loads(line)
+            except json.JSONDecodeError:
+                continue
+
+    return {"error": f"Invalid JSON response: {stdout[:500]}"}

+ 5 - 0
backend/app/engines/__main__.py

@@ -0,0 +1,5 @@
+"""远程训练入口:python -m app.engines.remote_train <args>"""
+from app.engines.remote_train import main
+
+if __name__ == "__main__":
+    main()

+ 98 - 0
backend/app/engines/remote_train.py

@@ -0,0 +1,98 @@
+"""远程训练入口脚本 — 在算力节点上执行。"""
+import asyncio
+import json
+import os
+import sys
+import signal
+from pathlib import Path
+
+# 禁用 FlashAttention
+os.environ["PYTORCH_NO_FLASH"] = "1"
+os.environ["FLASH_ATTENTION_ENABLED"] = "0"
+
+
+async def run_training(job_id: str, model_id: str, model_type: str, dataset_id: str, config: dict):
+    """执行单个训练任务(远程调用入口)。"""
+    from app.config import get_settings
+    from app.core.logging import logger
+
+    settings = get_settings()
+
+    # 查找数据集
+    from app.core.db import async_session, DatasetRecord
+    from sqlalchemy import select
+
+    dataset_path = None
+    async with async_session() as session:
+        result = await session.execute(select(DatasetRecord).where(
+            (DatasetRecord.id == dataset_id) | (DatasetRecord.name == dataset_id)
+        ))
+        record = result.scalar_one_or_none()
+        if record:
+            dataset_path = record.file_path
+
+    if not dataset_path:
+        # 尝试 uploads 目录
+        upload_path = settings.uploads_dir / dataset_id
+        if upload_path.exists():
+            dataset_path = str(upload_path)
+
+    if not dataset_path:
+        raise FileNotFoundError(f"Dataset not found: {dataset_id}")
+
+    # 预处理
+    processed_path = str(settings.processed_dir / f"{job_id}_processed.jsonl")
+    task_type = config.get("task_type", "sft")
+    template = config.get("dataset_template", "alpaca")
+
+    # 选择引擎
+    if model_type == "vision":
+        from app.engines.vision_engine import vision_engine
+        engine = vision_engine
+    elif model_type == "multimodal":
+        from app.engines.multimodal_engine import multimodal_engine
+        engine = multimodal_engine
+    else:
+        from app.engines.text_engine import text_engine
+        engine = text_engine
+
+    peft_method = config.get("peft_method", "lora")
+
+    # 预处理数据集
+    await engine.preprocess_dataset(dataset_path, processed_path, task_type=task_type, template=template)
+
+    # 加载模型
+    await engine.load_model(model_id, quantization="4bit" if peft_method == "qlora" else None)
+
+    # 构建 PEFT 配置
+    peft_config = engine.get_peft_config(peft_method, config)
+
+    # 训练
+    adapter_path = await engine.train(
+        job_id=job_id,
+        dataset_path=processed_path,
+        peft_config=peft_config,
+        training_args=config,
+    )
+
+    logger.info(f"Remote training completed: {job_id} -> {adapter_path}")
+    return adapter_path
+
+
+def main():
+    """命令行入口:python -m app.engines.remote_train <job_id> <model_id> <model_type> <dataset_id> <config_json>"""
+    if len(sys.argv) < 6:
+        print("Usage: python -m app.engines.remote_train <job_id> <model_id> <model_type> <dataset_id> <config_json>")
+        sys.exit(1)
+
+    job_id = sys.argv[1]
+    model_id = sys.argv[2]
+    model_type = sys.argv[3]
+    dataset_id = sys.argv[4]
+    config = json.loads(sys.argv[5])
+
+    asyncio.run(run_training(job_id, model_id, model_type, dataset_id, config))
+
+
+if __name__ == "__main__":
+    main()

+ 78 - 17
backend/app/services/inference_service.py

@@ -1,11 +1,10 @@
+"""推理服务 — 支持本地执行和 SSH 远程执行两种模式。"""
 import json
 from pathlib import Path
 from typing import Any
 
 from app.config import get_settings
-from app.core.db import async_session, TrainingJobModel
 from app.core.logging import logger
-from sqlalchemy import select
 
 settings = get_settings()
 
@@ -20,24 +19,65 @@ async def generate(
     do_sample: bool = True,
 ) -> dict[str, Any]:
     """使用已训练的 adapter 生成文本。"""
+    # 从 adapter config 中获取 base model ID
+    base_model_id = _get_base_model_id(adapter_path)
+    if not base_model_id:
+        return {"error": "无法找到基础模型信息,请确保训练任务已完成"}
+
+    if settings.use_remote_compute:
+        # 远程执行模式
+        from app.core.remote_executor import run_inference_remote
+
+        adapter_dir = Path(adapter_path)
+        adapter_id = adapter_dir.name
+
+        result = run_inference_remote(
+            model_id=base_model_id,
+            adapter_id=adapter_id,
+            prompt=prompt,
+            max_new_tokens=max_new_tokens,
+            temperature=temperature,
+            top_p=top_p,
+            repetition_penalty=repetition_penalty,
+            do_sample=do_sample,
+        )
+        if result:
+            return result
+        return {"error": "Remote inference failed"}
+
+    # 本地执行模式
+    return _generate_local(
+        adapter_path=adapter_path,
+        base_model_id=base_model_id,
+        prompt=prompt,
+        max_new_tokens=max_new_tokens,
+        temperature=temperature,
+        top_p=top_p,
+        repetition_penalty=repetition_penalty,
+        do_sample=do_sample,
+    )
+
+
+def _generate_local(
+    adapter_path: str,
+    base_model_id: str,
+    prompt: str,
+    max_new_tokens: int,
+    temperature: float,
+    top_p: float,
+    repetition_penalty: float,
+    do_sample: bool,
+) -> dict[str, Any]:
+    """本地执行推理。"""
     try:
         import torch
         from transformers import AutoModelForCausalLM, AutoTokenizer
+        from peft import PeftModel
 
-        # 推断 base model
-        base_model_id = _get_base_model_id(adapter_path)
-        if not base_model_id:
-            # 尝试从训练记录中获取
-            return {"error": "无法找到基础模型信息,请确保训练任务已完成"}
-
-        # 加载 tokenizer
         tokenizer = AutoTokenizer.from_pretrained(adapter_path, trust_remote_code=True)
         if tokenizer.pad_token is None:
             tokenizer.pad_token = tokenizer.eos_token
 
-        # 加载 base model + adapter
-        from peft import PeftModel
-
         base_model = AutoModelForCausalLM.from_pretrained(
             base_model_id,
             torch_dtype=torch.float16,
@@ -46,10 +86,8 @@ async def generate(
         model = PeftModel.from_pretrained(base_model, adapter_path)
         model.eval()
 
-        # Tokenize prompt
         inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
 
-        # Generate
         with torch.no_grad():
             outputs = model.generate(
                 **inputs,
@@ -61,7 +99,6 @@ async def generate(
                 pad_token_id=tokenizer.eos_token_id,
             )
 
-        # Decode
         generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
         generated_only = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
 
@@ -69,7 +106,7 @@ async def generate(
             "prompt": prompt,
             "generated_text": generated_text,
             "generated_only": generated_only,
-            "tokens_generated": outputs.shape[1] - inputs["input_ids"].shape[1],
+            "tokens_generated": int(outputs.shape[1] - inputs["input_ids"].shape[1]),
         }
 
     except Exception as e:
@@ -94,7 +131,7 @@ async def get_available_adapters() -> list[dict[str, Any]]:
         return []
 
     result = []
-    for d in adapters_dir.iterdir():
+    for d in sorted(adapters_dir.iterdir()):
         if not d.is_dir():
             continue
         adapter_config = d / "adapter_config.json"
@@ -108,3 +145,27 @@ async def get_available_adapters() -> list[dict[str, Any]]:
                 "peft_type": cfg.get("peft_type", "unknown"),
             })
     return result
+
+
+async def run_inference_single(
+    model_id: str,
+    adapter_id: str,
+    prompt: str,
+    max_new_tokens: int,
+    temperature: float,
+    top_p: float,
+    repetition_penalty: float,
+    do_sample: bool,
+) -> dict[str, Any]:
+    """供远程 SSH 调用的单条推理入口。"""
+    adapter_path = str(settings.adapters_dir / adapter_id)
+    return _generate_local(
+        adapter_path=adapter_path,
+        base_model_id=model_id,
+        prompt=prompt,
+        max_new_tokens=max_new_tokens,
+        temperature=temperature,
+        top_p=top_p,
+        repetition_penalty=repetition_penalty,
+        do_sample=do_sample,
+    )

+ 12 - 8
docker-compose.yml

@@ -33,16 +33,20 @@ services:
       - BACKEND_HOST=0.0.0.0
       - BACKEND_PORT=8010
       - DATABASE_URL=postgresql+asyncpg://finetune:finetune123@postgres:5432/finetuning
-      # 沐曦 maca 环境变量
-      - MACA_PATH=/opt/maca
-      - LD_LIBRARY_PATH=/opt/maca/lib:/opt/maca/mxgpu_llvm/lib:/opt/maca/ompi/lib
-      - MACA_CLANG_PATH=/opt/maca/mxgpu_llvm/bin
+      # --- 分布式计算节点(可选) ---
+      # 设置为 253 的 IP 即启用远程算力模式
+      - COMPUTE_NODE_HOST=192.168.91.253
+      - COMPUTE_NODE_SSH_PORT=22
+      - COMPUTE_NODE_SSH_USER=root
+      - COMPUTE_NODE_SSH_PASSWORD=ictrek
+      # - COMPUTE_NODE_SSH_KEY=/root/.ssh/id_rsa  # 优先用密钥,密码为备选
+      - COMPUTE_NODE_PYTHON=/opt/conda/bin/python
+      - COMPUTE_NODE_WORKDIR=/root/Fine-tuning/backend
+      - COMPUTE_NODE_REMOTE_DATA_DIR=/root/Fine-tuning/backend/data
+      - COMPUTE_NODE_REMOTE_ENV=production
+      - COMPUTE_NODE_SSH_TIMEOUT=300
     depends_on:
       - postgres
-    devices:
-      - /dev/mxcd:/dev/mxcd
-    privileged: true
-    shm_size: "2gb"
     networks:
       - finetune-net
 

+ 590 - 0
样本中心提供API接口文档_外部.md

@@ -0,0 +1,590 @@
+# 样本中心对外API接口文档
+
+## 1. 概述
+
+### 1.1 文档目的
+本文档定义样本中心对外提供的API接口规范,供外部系统(如采集系统)接入使用。
+
+### 1.2 基础信息
+| 项目 | 说明 |
+|------|------|
+| Base URL | `https://{host}/api/v1` |
+| 数据格式 | JSON |
+| 字符编码 | UTF-8 |
+| 认证方式 | Bearer Token |
+
+---
+
+## 2. 认证与鉴权
+
+### 2.1 Token 设计方案
+
+采用 **API Key + Secret 签名机制**,外部系统通过以下方式获取访问 Token:
+
+#### 2.1.1 凭证分配
+每个接入系统在样本中心注册后,分配一对凭证:
+- `app_id`:应用唯一标识
+- `app_secret`:应用密钥(仅初始化时展示一次,需妥善保存)
+
+#### 2.1.2 获取访问令牌
+
+**POST /api/v1/auth/token**
+
+| 项目 | 说明 |
+|------|------|
+| Content-Type | application/json |
+
+**请求参数:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| app_id | string | 是 | 应用标识 |
+| app_secret | string | 是 | 应用密钥 |
+
+**请求示例:**
+```json
+{
+  "app_id": "collect_system_001",
+  "app_secret": "sk_xxxxxxxxxxxxxxxx"
+}
+```
+
+**响应参数:**
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| access_token | string | 访问令牌 |
+| expires_in | integer | 过期时间(秒),默认 7200 |
+| token_type | string | 令牌类型,固定 "Bearer" |
+
+**响应示例:**
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "access_token": "eyJhbGciOiJIUzI1NiIs...",
+    "expires_in": 7200,
+    "token_type": "Bearer"
+  }
+}
+```
+
+#### 2.1.3 Token 使用
+所有业务接口需在 HTTP Header 中携带 Token:
+```
+Authorization: Bearer {access_token}
+X-App-Id: {app_id}
+```
+
+#### 2.1.4 Token 管理规则
+- Token 有效期 2 小时,过期后需重新获取
+- 支持多 Token 并存,新 Token 获取不影响旧 Token 使用
+- 单 app_id 同时最多持有 3 个有效 Token
+- Token 被吊销后即时失效
+
+---
+
+## 3. 统一响应格式
+
+所有接口返回统一的 JSON 结构:
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": { ... },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| code | string | 状态码,"000000" 表示成功,非 "000000" 表示失败 |
+| message | string | 提示信息 |
+| data | object/array/null | 业务数据 |
+| request_id | string | 请求追踪 ID |
+
+### 3.1 错误码定义
+
+| 错误码 | 说明 |
+|--------|------|
+| 000000 | 成功 |
+| 000400 | 请求参数错误 |
+| 000401 | 认证失败(Token 无效/过期) |
+| 000403 | 权限不足 |
+| 000404 | 资源不存在 |
+| 000429 | 请求频率超限 |
+| 000500 | 服务端内部错误 |
+| 001001 | 知识库不存在 |
+| 001002 | 知识库未启用 |
+| 002001 | 批量入库参数校验失败 |
+| 002002 | 批量入库任务不存在 |
+| 002003 | 批量入库任务数据格式错误 |
+
+---
+
+## 4. 接口详情
+
+### 4.1 知识库列表查询
+
+**GET /api/v1/knowledge-bases**
+
+查询当前应用有权限访问的已启用知识库列表。
+
+**请求参数(Query):**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| page | integer | 否 | 页码,默认 1 |
+| page_size | integer | 否 | 每页条数,默认 20,最大 100 |
+
+**响应参数:**
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "total": 10,
+    "page": 1,
+    "page_size": 20,
+    "items": [
+      {
+        "id": "5138215886300893584",
+        "name": "建设工程知识库",
+        "parent_table": "kb_parent_table_001",
+        "child_table": "kb_child_table_001",
+        "document_count": 1520,
+        "status": 1,
+        "created_at": "2026-05-10T10:30:00Z",
+        "created_by": "admin",
+        "metadata_schema": [
+          {
+            "field_name_cn": "文档编号",
+            "field_name_en": "doc_number",
+            "field_type": "string",
+            "description": "文档的唯一编号"
+          },
+          {
+            "field_name_cn": "发布日期",
+            "field_name_en": "publish_date",
+            "field_type": "date",
+            "description": "文档发布日期"
+          }
+        ]
+      }
+    ]
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+**列表项字段说明:**
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| id | string | 知识库ID |
+| name | string | 知识库名称 |
+| parent_table | string | 父集合表名 |
+| child_table | string | 子集合表名 |
+| document_count | integer | 文档数量 |
+| status | integer | 状态:1-启用,0-禁用 |
+| created_at | string | 创建时间,ISO 8601 格式 |
+| created_by | string | 创建人 |
+| metadata_schema | array | 元数据字典定义列表 |
+
+**metadata_schema 字段说明:**
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| field_name_cn | string | 字段中文名称 |
+| field_name_en | string | 字段英文名称 |
+| field_type | string | 字段类型:string/integer/float/date/boolean/array |
+| description | string | 字段描述 |
+
+---
+
+### 4.2 知识库详情查询
+
+**GET /api/v1/knowledge-bases/{id}**
+
+查询指定知识库的详细信息,包括元数据字典定义。
+
+**路径参数:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| id | string | 是 | 知识库ID |
+
+**响应参数:**
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "id": "5138215886300893584",
+    "name": "建设工程知识库",
+    "description": "建设工程相关法规文档",
+    "parent_table": "kb_parent_table_001",
+    "child_table": "kb_child_table_001",
+    "document_count": 1520,
+    "status": 1,
+    "created_at": "2026-05-10T10:30:00Z",
+    "created_by": "admin",
+    "updated_at": "2026-05-15T14:20:00Z",
+    "metadata_schema": [
+      {
+        "field_name_cn": "文档编号",
+        "field_name_en": "doc_number",
+        "field_type": "string",
+        "description": "文档的唯一编号"
+      },
+      {
+        "field_name_cn": "发布日期",
+        "field_name_en": "publish_date",
+        "field_type": "date",
+        "description": "文档发布日期"
+      },
+      {
+        "field_name_cn": "文档来源",
+        "field_name_en": "source",
+        "field_type": "string",
+        "description": "文档来源机构"
+      }
+    ]
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+---
+
+### 4.3 知识库批量入库
+
+**POST /api/v1/knowledge-bases/{kb_id}/batch-import**
+
+提交批量入库任务,系统将片段向量化后存入向量数据库。
+
+**路径参数:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| kb_id | string | 是 | 知识库ID |
+
+**请求体:**
+
+```json
+{
+  "task_no": "IMP202605170001",
+  "callback_url": "https://collect-system.example.com/api/callback/import-result",
+  "parents": [
+    {
+      "index": 0,
+      "parent_id": "5138215886300893584",
+      "hierarchy": "建设工程质量管理条例",
+      "text": "第一条 为了加强对建设工程质量的管理...",
+      "metadata": {
+        "doc_number": "国务院令第279号",
+        "publish_date": "2000-01-30"
+      },
+      "doc_id": "doc_001",
+      "tag_list": ["法规", "质量管理"],
+      "permission": {
+        "visible_roles": ["role_admin", "role_engineer"],
+        "visible_users": ["user_001"]
+      }
+    }
+  ],
+  "children": [
+    {
+      "index": 0,
+      "parent_id": "5138215886300893584",
+      "hierarchy": "第一章 总则",
+      "text": "第一条 为了加强对建设工程质量的管理,保证建设工程质量...",
+      "metadata": {
+        "doc_number": "国务院令第279号",
+        "section": "第一章"
+      },
+      "doc_id": "doc_001",
+      "tag_list": ["法规", "总则"],
+      "permission": {
+        "visible_roles": ["role_admin"]
+      }
+    }
+  ]
+}
+```
+
+**请求参数说明:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| task_no | string | 是 | 入库任务号,由调用方生成,用于记录当前入库任务状态及后续查询进度 |
+| callback_url | string | 否 | 回调地址,任务完成后样本中心将处理结果推送至该地址,不传则不回调 |
+| parents | array | 是 | 父段信息列表 |
+| children | array | 否 | 子段信息列表 |
+
+**parents/children 项字段说明:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| index | integer | 是 | 分片索引号 |
+| parent_id | string | 是 | 父段ID,子段通过此字段关联父段 |
+| hierarchy | string | 否 | 章节信息 |
+| text | string | 是 | 段文本信息 |
+| metadata | object | 否 | 段元数据信息,键值对结构 |
+| doc_id | string | 否 | 文档ID |
+| tag_list | array | 否 | 标签列表 |
+| permission | object | 否 | 权限配置 |
+
+**permission 字段说明:**
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| visible_roles | array | 可见角色 ID 列表 |
+| visible_users | array | 可见用户 ID 列表 |
+
+**响应参数:**
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "task_id": "task_20260517xxxxxxxx",
+    "status": "pending"
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| task_id | string | 任务ID |
+| status | string | 任务状态:pending-待处理 |
+
+---
+
+### 4.4 批量入库任务查询
+
+**GET /api/v1/knowledge-bases/batch-import/{task_id}**
+
+查询批量入库任务的处理状态和结果。
+
+**路径参数:**
+
+| 参数名 | 类型 | 必录 | 说明 |
+|--------|------|------|------|
+| task_id | string | 是 | 入库任务ID |
+
+**响应参数:**
+
+**进行中:**
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "task_id": "task_20260517xxxxxxxx",
+    "task_no": "IMP202605170001",
+    "status": "processing",
+    "progress": {
+      "total": 100,
+      "processed": 45,
+      "succeeded": 43,
+      "failed": 2
+    },
+    "created_at": "2026-05-17T10:00:00Z",
+    "updated_at": "2026-05-17T10:01:30Z"
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+**已完成:**
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "task_id": "task_20260517xxxxxxxx",
+    "task_no": "IMP202605170001",
+    "status": "completed",
+    "progress": {
+      "total": 100,
+      "processed": 100,
+      "succeeded": 98,
+      "failed": 2
+    },
+    "created_at": "2026-05-17T10:00:00Z",
+    "completed_at": "2026-05-17T10:05:00Z",
+    "failures": [
+      {
+        "index": 12,
+        "parent_id": "5138215886300893584",
+        "error": "文本内容为空,跳过入库"
+      },
+      {
+        "index": 56,
+        "parent_id": "5138215886300893584",
+        "error": "向量化模型调用超时"
+      }
+    ]
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+**失败:**
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "task_id": "task_20260517xxxxxxxx",
+    "task_no": "IMP202605170001",
+    "status": "failed",
+    "error": "向量数据库连接异常",
+    "created_at": "2026-05-17T10:00:00Z",
+    "completed_at": "2026-05-17T10:00:05Z"
+  },
+  "request_id": "req_xxxxxxxxxxxxxxxx"
+}
+```
+
+**字段说明:**
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| task_id | string | 任务ID,系统生成 |
+| task_no | string | 入库任务号,调用方传入 |
+| status | string | 任务状态:pending-待处理,processing-处理中,completed-已完成,failed-失败 |
+| progress | object | 进度信息 |
+| progress.total | integer | 总条数 |
+| progress.processed | integer | 已处理条数 |
+| progress.succeeded | integer | 成功条数 |
+| progress.failed | integer | 失败条数 |
+| failures | array | 失败明细列表 |
+| error | string | 整体失败原因(仅 status=failed 时返回) |
+| created_at | string | 任务创建时间 |
+| updated_at | string | 状态更新时间 |
+| completed_at | string | 任务完成时间 |
+
+---
+
+## 5. 任务状态流转
+
+```
+pending → processing → completed
+                      → failed
+```
+
+| 状态 | 说明 |
+|------|------|
+| pending | 任务已接收,排队等待处理 |
+| processing | 任务正在处理中 |
+| completed | 任务处理完成(可能部分失败) |
+| failed | 任务整体失败,无数据入库 |
+
+---
+
+## 6. 轮询与回调机制
+
+### 6.1 轮询方式
+外部系统提交任务后,通过 **GET /api/v1/knowledge-bases/batch-import/{task_id}** 轮询任务状态。
+
+**建议轮询策略:**
+- 初始间隔 2 秒
+- 每次轮询后间隔 × 1.5,上限 30 秒
+- 任务终态(completed/failed)后停止轮询
+
+### 6.2 回调方式(可选)
+
+外部系统在提交批量入库任务时,若无法主动轮询,可在请求体中传入 `callback_url`。任务完成后样本中心主动向该地址推送处理结果。
+
+**回调请求:** POST {callback_url}
+
+```json
+{
+  "task_id": "task_20260517xxxxxxxx",
+  "task_no": "IMP202605170001",
+  "status": "completed",
+  "kb_id": "5138215886300893584",
+  "progress": {
+    "total": 100,
+    "processed": 100,
+    "succeeded": 98,
+    "failed": 2
+  },
+  "completed_at": "2026-05-17T10:05:00Z"
+}
+```
+
+回调失败时最多重试 3 次,间隔 10s / 30s / 60s。
+
+---
+
+
+## 7. 接口总览
+
+| 序号 | 方法 | 路径 | 说明 |
+|------|------|------|------|
+| 1 | POST | /api/v1/auth/token | 获取访问令牌 |
+| 2 | GET | /api/v1/knowledge-bases | 知识库列表查询 |
+| 3 | GET | /api/v1/knowledge-bases/{id} | 知识库详情查询 |
+| 4 | POST | /api/v1/knowledge-bases/{kb_id}/batch-import | 批量入库提交 |
+| 5 | GET | /api/v1/knowledge-bases/batch-import/{task_id} | 入库任务查询 |
+
+---
+
+## 9. 异步任务表设计
+
+### 9.1 表结构定义
+
+**表名:`t_samp_task_management`**
+
+样本中心异步任务表,用于记录所有异步任务的生命周期。
+
+
+### 9.2 任务状态枚举
+
+| 状态值 | 说明 |
+|--------|------|
+| pending | 任务已接收,排队等待处理 |
+| processing | 任务正在处理中 |
+| completed | 任务处理完成(可能部分失败) |
+| failed | 任务整体失败,无数据入库 |
+
+### 9.3 回调状态枚举
+
+| 状态值 | 说明 |
+|--------|------|
+| pending | 待处理 |
+| processing | 处理中 |
+| success | 回调成功 |
+| failed | 回调失败 |
+
+### 9.4 任务处理逻辑
+
+1. **任务接收**:批量入库任务接收请求后,先将任务保存到任务表,状态设为 `pending`
+2. **异步处理**:发起异步任务进行知识片段入库处理,同时更新任务表状态为 `processing`
+3. **任务完成**:处理完成后更新任务表状态为 `completed`,记录完成时间
+4. **任务失败**:处理失败时更新任务表状态为 `failed`,同时写入错误信息到 `error_message`
+5. **回调通知**:任务处理完成后,若 `callback_url` 不为空,则发起回调请求,并根据回调结果更新 `callback_status`
+6. **状态查询**:入库任务查询接口直接查询任务表的 `status` 和 `callback_status`
+
+### 9.5 任务类型扩展
+
+任务表通过 `task_type` 字段支持多种任务类型,当前支持:
+
+| task_type | 说明 |
+|-----------|------|
+| bi | 批量入库任务 |
+
+后续可按需扩展其他任务类型(如:批量删除、数据同步等)。
+
+
+
+

+ 681 - 0
统一认证平台接入流程及API接口文档(1)(1).md

@@ -0,0 +1,681 @@
+# 统一认证平台(LQAI-middle-platform)接入流程及 API 接口文档
+
+> **参考样本**: 样本中心(LQAdminPlatform)已实现的统一认证接入方案
+> **统一认证平台地址**: `http://192.168.92.61:8200`
+> **统一认证平台前端**: `http://192.168.92.61:9200`
+
+---
+
+## 一、系统架构概览
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                统一认证平台 (LQAI-middle-platform)            │
+│                    端口: 8200 (后端) / 9200 (前端)            │
+│                                                             │
+│  功能:                                                       │
+│  • 用户注册 / 登录 / 密码管理                                  │
+│  • 应用管理 (子应用注册、client_id/secret 生成)                │
+│  • OAuth2 授权码流程                                          │
+│  • RBAC 角色权限管理                                          │
+│  • JWT Token 签发与验证                                       │
+│  • 子应用工作台(点击子应用图标触发 SSO 跳转)                  │
+│                                                             │
+│  数据存储: MySQL (lq_ai_middle_platform) + Redis             │
+└──────────────────────┬──────────────────────────────────────┘
+                       │ OAuth2 / HTTP
+                       │
+        ┌──────────────┼──────────────┐
+        │              │              │
+   ┌────▼────┐  ┌─────▼─────┐  ┌────▼────┐
+   │样本中心  │  │Agent平台   │  │ 标注平台 │  ... 其他子系统
+   │(8000)   │  │ (8002)    │  │ (9003)  │
+   └─────────┘  └───────────┘  └─────────┘
+```
+
+### 角色定位
+
+| 角色 | 说明 |
+|------|------|
+| **统一认证平台** (LQAI-middle-platform) | OAuth2 授权服务器(Authorization Server),同时也是一个 SSO 中心 |
+| **子系统** (如样本中心 LQAdminPlatform) | OAuth2 客户端(Client),同时自身也是一个小型 OAuth2 服务器,可以为下游系统提供授权 |
+| **子系统前端** | 负责 SSO 回调处理和 Token 交换 |
+
+---
+
+## 二、SSO 单点登录接入流程
+
+### 2.1 整体流程图
+
+```
+用户在统一认证平台工作台点击子系统图标
+            │
+            ▼
+① 统一认证平台后端生成 OAuth2 授权码 code
+   POST /auth/sso-redirect  →  返回重定向URL: {frontend_url}/auth/callback?code=xxx
+            │
+            ▼
+② 302 重定向到子系统前端 /auth/callback?code=xxx
+            │
+            ▼
+③ 子系统前端 POST /api/oauth/exchange-code { "code": "xxx" }
+            │
+            ▼
+④ 子系统后端与统一认证平台进行后端到后端通信:
+   4a. POST {SSO_BASE_URL}/oauth/token  →  换取 SSO access_token
+   4b. GET  {SSO_BASE_URL}/oauth/userinfo →  获取用户信息+角色
+            │
+            ▼
+⑤ 子系统后端同步用户到本地数据库(查找或创建用户、同步角色)
+            │
+            ▼
+⑥ 子系统后端签发本地 JWT,返回 { token, refresh_token, user }
+            │
+            ▼
+⑦ 子系统前端保存 Token,跳转首页
+```
+
+### 2.2 详细步骤说明
+
+#### Step 1: 统一认证平台生成授权码
+
+当用户在统一认证平台前端工作台的"应用列表"中点击某个子系统图标时:
+
+- 统一认证平台后端调用 `POST /auth/sso-redirect` 接口
+- 后端为当前登录用户生成一个 OAuth2 授权码(authorization code),存入 Redis,有效期 10 分钟
+- 返回子系统的回调 URL,格式为 `{REDIRECT_URI}?code={auth_code}`
+
+#### Step 2: 重定向到子系统前端
+
+- 统一认证平台前端通过 302 重定向将用户引导至子系统前端的回调页面
+- URL 格式: `http://localhost:3000/auth/callback?code=xxxxxx`
+
+#### Step 3: 子系统前端调用换码接口
+
+子系统前端从 URL 参数中提取 `code`,调用子系统后端的换码接口:
+
+```typescript
+// 示例代码
+const code = new URLSearchParams(window.location.search).get('code');
+const response = await fetch('/api/oauth/exchange-code', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({ code })
+});
+const result = await response.json();
+// result.data.token       → 本地 JWT access_token
+// result.data.refresh_token → 刷新令牌
+// result.data.user         → 用户信息(含角色)
+```
+
+#### Step 4-5: 子系统后端与统一认证平台交互 + 同步用户
+
+子系统后端收到 `code` 后执行 `_sso_exchange_code` 核心流程:
+
+1. **用 code 换 SSO access_token** — 调用统一认证平台的 `/oauth/token` 端点
+2. **获取用户信息** — 调用统一认证平台的 `/oauth/userinfo` 端点
+3. **同步用户** — 在本地数据库查找或创建用户,同步角色信息
+
+#### Step 6-7: 签发本地 JWT 并返回
+
+- 子系统后端用自己的 `SECRET_KEY` 签发本地 JWT
+- 返回给前端 `{ token, refresh_token, user }`
+- 前端保存 Token 到 localStorage/sessionStorage,跳转首页
+
+---
+
+## 三、子系统接入前准备
+
+### 3.1 在统一认证平台注册子应用
+
+通过统一认证平台的系统管理模块(或数据库直接插入)在 `t_sys_app` 表中注册子系统:
+
+| 字段 | 说明 | 示例值 |
+|------|------|--------|
+| `name` | 子系统名称 | "样本中心" |
+| `app_key` | 客户端 ID(client_id),32 位随机字符串 | `WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6` |
+| `app_secret` | 客户端密钥(client_secret),64 位随机字符串 | `9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ` |
+| `redirect_uris` | 允许的重定向 URI(JSON 数组) | `["http://localhost:3000/auth/callback"]` |
+| `scope` | 授权范围(JSON 数组) | `["profile", "email"]` |
+| `is_active` | 是否启用 | `1` |
+| `is_trusted` | 是否信任(跳过授权确认页) | `1` |
+| `home_url` | 子系统首页 URL | `http://localhost:3000` |
+| `icon_url` | 子系统图标 URL | 可选 |
+
+### 3.2 子系统侧配置
+
+在子系统的配置文件中添加 SSO 配置段:
+
+```ini
+[sso]
+SSO_BASE_URL=http://192.168.92.61:8200
+CLIENT_ID=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
+CLIENT_SECRET=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
+REDIRECT_URI=http://localhost:3000/auth/callback
+FRONTEND_URL=http://localhost:3000
+SCOPE=email
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
+```
+
+> **注意**: `CLIENT_ID` 和 `CLIENT_SECRET` 必须与统一认证平台 `t_sys_app` 表中注册的 `app_key` 和 `app_secret` 一致。
+
+### 3.3 角色映射
+
+子系统需要确保本地 `t_sys_role` 表中存在与统一认证平台对应的角色。样本中心的角色包括:
+
+| 统一认证平台角色 code | 说明 |
+|----------------------|------|
+| `super_admin` | 超级管理员 |
+| `sam_sys_admin` | 样本中心系统管理员 |
+| `sam_data_operator` | 样本中心数据操作员 |
+
+统一认证平台 `/oauth/userinfo` 返回的 `roles` 字段格式为对象数组:
+
+```json
+{
+  "roles": [
+    { "name": "超级管理员", "code": "super_admin" },
+    { "name": "样本中心系统管理员", "code": "sam_sys_admin" }
+  ]
+}
+```
+
+子系统从每个对象中提取 `code` 字段进行角色匹配和本地同步。
+
+---
+
+## 四、统一认证平台核心 API 接口
+
+以下接口由 **统一认证平台(LQAI-middle-platform,端口 8200)** 提供,子系统需要调用。
+
+### 4.1 获取 SSO 授权 URL
+
+> 供子系统前端构建"跳转到统一认证平台登录"的链接
+
+- **接口**: `GET /oauth/authorize`
+- **认证**: 不需要
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `response_type` | string | 是 | 固定值 `code` |
+| `client_id` | string | 是 | 子应用的 app_key |
+| `redirect_uri` | string | 是 | 回调地址(必须在 t_sys_app 中注册过) |
+| `scope` | string | 否 | 授权范围,空格分隔,默认 `profile` |
+| `state` | string | 否 | 防 CSRF 状态参数 |
+
+**响应**: 302 重定向(如果用户已登录则重定向到 `redirect_uri?code=xxx`,未登录则重定向到登录页面)
+
+### 4.2 获取授权码 URL(SSO 免登)
+
+> 统一认证平台内部使用,当用户点击子系统图标时调用
+
+- **接口**: `POST /auth/sso-redirect`
+- **认证**: 需要用户已登录统一认证平台
+- **请求体**:
+
+```json
+{
+  "client_id": "WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6",
+  "redirect_uri": "http://localhost:3000/auth/callback"
+}
+```
+
+- **响应**:
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": {
+    "redirect_url": "http://localhost:3000/auth/callback?code=xxxxxx"
+  }
+}
+```
+
+### 4.3 令牌交换端点
+
+> **子系统后端调用此接口,用授权码换取 SSO access_token**
+
+- **接口**: `POST /oauth/token`
+- **Content-Type**: `application/x-www-form-urlencoded`
+- **认证**: 不需要
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `grant_type` | string | 是 | 固定值 `authorization_code` |
+| `code` | string | 是 | 授权码 |
+| `redirect_uri` | string | 是 | 与 Step 1 中使用的 redirect_uri 一致 |
+| `client_id` | string | 是 | 子应用的 app_key |
+| `client_secret` | string | 是 | 子应用的 app_secret |
+
+**成功响应** (200):
+
+```json
+{
+  "access_token": "eyJhbGciOiJIUzI1NiIs...",
+  "token_type": "Bearer",
+  "expires_in": 7200,
+  "refresh_token": "xxxxxxxxxxxxxxxxxxxx",
+  "scope": "profile email"
+}
+```
+
+**失败响应**:
+
+```json
+{
+  "error": "invalid_grant",
+  "error_description": "授权码无效"
+}
+```
+
+| error 值 | 说明 |
+|----------|------|
+| `invalid_request` | 缺少必填参数 |
+| `unsupported_grant_type` | grant_type 不支持 |
+| `invalid_client` | client_id 或 client_secret 错误 |
+| `invalid_grant` | 授权码无效/已使用/过期,或 redirect_uri 不匹配 |
+
+### 4.4 获取用户信息
+
+> **子系统后端调用此接口,获取当前登录用户的详细信息**
+
+- **接口**: `GET /oauth/userinfo`
+- **认证**: Bearer Token(使用 4.3 返回的 access_token)
+
+**请求头**:
+
+```
+Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
+```
+
+**成功响应** (200):
+
+```json
+{
+  "sub": "user_xxx",
+  "username": "admin",
+  "email": "admin@example.com",
+  "avatar_url": "https://...",
+  "real_name": "管理员",
+  "company": "四川路桥",
+  "department": "技术部",
+  "position": "工程师",
+  "roles": [
+    { "name": "超级管理员", "code": "super_admin" },
+    { "name": "样本中心系统管理员", "code": "sam_sys_admin" }
+  ]
+}
+```
+
+> 返回的字段根据 token 的 scope 过滤:`profile` scope 返回基本信息,`email` scope 返回邮箱,`phone` scope 返回手机号。
+
+**失败响应**:
+
+```json
+{
+  "error": "invalid_token",
+  "error_description": "User not found or inactive"
+}
+```
+
+---
+
+## 五、子系统侧需要实现的 API 接口
+
+以下是子系统(如样本中心)自身需要实现的接口,用于完成 SSO 接入。
+
+### 5.1 授权码交换接口(核心免登接口)
+
+- **接口**: `POST /api/oauth/exchange-code`
+- **Content-Type**: `application/json`
+- **认证**: 不需要
+- **用途**: 前端收到 SSO 回调的 code 后,调用此接口换取本地 JWT
+
+**请求体**:
+
+```json
+{
+  "code": "xxxxxx"
+}
+```
+
+**成功响应**:
+
+```json
+{
+  "code": "000000",
+  "message": "登录成功",
+  "data": {
+    "token": "eyJhbGciOiJIUzI1NiIs...",
+    "refresh_token": "xxxxxxxxxxxxxxxxxxxx",
+    "user": {
+      "id": "user_xxx",
+      "username": "admin",
+      "email": "admin@example.com",
+      "phone": "138xxxx",
+      "is_superuser": true,
+      "is_active": true,
+      "roles": ["super_admin", "sam_sys_admin"]
+    }
+  }
+}
+```
+
+**失败响应**:
+
+```json
+{
+  "code": "100001",
+  "message": "缺少授权码",
+  "data": null
+}
+```
+
+```json
+{
+  "code": "500001",
+  "message": "登录失败: 获取令牌失败",
+  "data": null
+}
+```
+
+### 5.2 获取 SSO 授权 URL
+
+- **接口**: `GET /auth/sso/authorize`
+- **认证**: 不需要
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `redirect` | bool | 否 | 为 true 时直接 302 重定向到 SSO 授权页 |
+
+**响应** (redirect=false):
+
+```json
+{
+  "code": "000000",
+  "message": "获取授权URL成功",
+  "data": {
+    "authorize_url": "http://192.168.92.61:8200/oauth/authorize?client_id=xxx&redirect_uri=xxx&response_type=code&scope=email"
+  }
+}
+```
+
+### 5.3 SSO 回调端点(旧流程,后端 302 重定向方式)
+
+- **接口**: `GET /auth/callback`
+- **认证**: 不需要
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `code` | string | 是 | SSO 授权码 |
+| `error` | string | 否 | SSO 返回的错误码 |
+| `error_description` | string | 否 | 错误描述 |
+| `state` | string | 否 | 状态参数 |
+
+**响应**: 302 重定向到 `前端URL/oauth/callback?token=xxx&refresh_token=xxx`
+
+> **注意**: 这是旧流程,建议使用 `POST /api/oauth/exchange-code` 的新流程(前端直接调用 API 换码,不依赖后端 302 重定向)。
+
+---
+
+## 六、子系统侧的其他认证相关 API
+
+除了 SSO 接入外,子系统还需要提供标准认证接口供前端使用。
+
+### 6.1 本地密码登录
+
+- **接口**: `POST /api/v1/auth/login`
+- **Content-Type**: `application/json`
+
+**请求体**:
+
+```json
+{
+  "username": "admin",
+  "password": "Admin123456",
+  "remember_me": false
+}
+```
+
+**响应**:
+
+```json
+{
+  "code": "000000",
+  "message": "登录成功",
+  "data": {
+    "access_token": "eyJ...",
+    "refresh_token": "xxx...",
+    "token_type": "Bearer",
+    "expires_in": 1200
+  }
+}
+```
+
+### 6.2 刷新 Token
+
+- **接口**: `POST /api/v1/auth/refresh`
+- **请求体**: `{ "refresh_token": "xxx" }`
+
+### 6.3 登出
+
+- **接口**: `POST /api/v1/auth/logout`
+- **请求体**: `{ "token": "xxx", "refresh_token": "xxx" }`
+- **响应** (含 SSO 登出重定向 URL):
+
+```json
+{
+  "code": "000000",
+  "message": "登出成功",
+  "data": {
+    "sso_logout_url": "http://192.168.92.61:9200/login"
+  }
+}
+```
+
+### 6.4 获取当前用户信息
+
+- **接口**: `GET /api/v1/auth/userinfo` 或 `GET /api/v1/auth/me`
+- **认证**: Bearer Token
+- **响应**:
+
+```json
+{
+  "code": "000000",
+  "data": {
+    "id": "user_xxx",
+    "username": "admin",
+    "email": "admin@example.com",
+    "phone": "138xxxx",
+    "roles": ["super_admin"],
+    "permissions": []
+  }
+}
+```
+
+### 6.5 获取验证码
+
+- **接口**: `GET /api/v1/auth/captcha`
+- **认证**: 不需要
+
+---
+
+## 七、统一认证平台的 OAuth2 服务端 API
+
+统一认证平台自身也是一个 OAuth2 授权服务器,可以为下游子系统提供 OAuth2 服务。
+
+### 7.1 应用管理(系统管理员操作)
+
+通过统一认证平台的系统管理模块管理子应用,相关接口前缀 `/api/v1`:
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `GET` | `/api/v1/system/app/list` | 获取应用列表 |
+| `POST` | `/api/v1/system/app/create` | 创建应用(自动生成 app_key/app_secret) |
+| `PUT` | `/api/v1/system/app/update/{id}` | 更新应用信息 |
+| `DELETE` | `/api/v1/system/app/delete/{id}` | 删除应用 |
+| `POST` | `/api/v1/system/app/reset_secret/{id}` | 重置应用密钥 |
+| `GET` | `/api/v1/system/app/accessible` | 获取当前用户可访问的应用列表 |
+
+### 7.2 角色管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `GET` | `/api/v1/system/role/list` | 获取角色列表 |
+| `POST` | `/api/v1/system/role/create` | 创建角色 |
+| `PUT` | `/api/v1/system/role/update/{id}` | 更新角色 |
+| `DELETE` | `/api/v1/system/role/delete/{id}` | 删除角色 |
+| `GET` | `/api/external/v1/system/role/user_list/{role_code}` | 按角色 code 查询用户列表 |
+
+---
+
+## 八、Token 机制说明
+
+### 8.1 统一认证平台侧 Token
+
+| Token 类型 | 有效期 | 存储方式 | 说明 |
+|-----------|--------|---------|------|
+| Access Token | 30 分钟(默认)| Redis (`auth:access:{user_id}:{token}`) | 访问令牌,含用户身份和角色 |
+| Refresh Token | 30 天 | Redis (`auth:refresh:{user_id}:{token}`) | 刷新令牌,用于换取新 Access Token |
+| OAuth Access Token | 120 分钟 | Redis (`auth:oauth_access:{client_id}:{token}`) | OAuth2 流程中颁发给子应用的令牌 |
+| OAuth Authorization Code | 10 分钟 | Redis (`auth:oauth_code:{code}`) | 一次性授权码,使用后即失效 |
+
+### 8.2 子系统侧 Token
+
+| Token 类型 | 有效期 | 存储方式 | 说明 |
+|-----------|--------|---------|------|
+| Access Token | 20 分钟 | MySQL + Redis | 子系统本地登录令牌 |
+| Refresh Token | 24 小时 | MySQL + Redis | 刷新令牌 |
+
+### 8.3 滑动过期机制
+
+子系统实现了 JWT Token 的滑动过期机制:
+
+- 当 Token 使用时间超过其总生命周期的 **50%** 时,中间件会自动生成新 Token
+- 新 Token 通过响应头 `X-New-Token` 返回给前端
+- 前端检测到 `X-New-Token` 后替换本地旧 Token
+- 无需前端主动刷新即可保持登录状态
+
+---
+
+## 九、前端接入参考代码
+
+### 9.1 SSO 免登流程(推荐方式)
+
+```typescript
+// 1. 统一认证平台重定向到子系统前端,URL 携带 code 参数
+// 例如: http://localhost:3000/auth/callback?code=xxxxxx
+
+// 2. 在回调页面中提取 code 并调用换码接口
+const code = new URLSearchParams(window.location.search).get('code');
+
+if (code) {
+  const response = await fetch('/api/oauth/exchange-code', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ code })
+  });
+  const result = await response.json();
+
+  if (result.code === '000000') {
+    // 3. 保存 Token
+    localStorage.setItem('token', result.data.token);
+    localStorage.setItem('refresh_token', result.data.refresh_token);
+    localStorage.setItem('user', JSON.stringify(result.data.user));
+
+    // 4. 跳转到首页
+    router.push('/home');
+  } else {
+    // 登录失败,跳回登录页
+    router.push(`/login?error=${result.message}`);
+  }
+}
+```
+
+### 9.2 前端请求拦截器(携带 Token)
+
+```typescript
+// Axios 请求拦截器示例
+axios.interceptors.request.use(config => {
+  const token = localStorage.getItem('token');
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`;
+  }
+  return config;
+});
+
+// 响应拦截器处理 Token 刷新
+axios.interceptors.response.use(
+  response => {
+    const newToken = response.headers['x-new-token'];
+    if (newToken) {
+      localStorage.setItem('token', newToken);
+    }
+    return response;
+  },
+  error => {
+    if (error.response?.status === 401) {
+      // Token 过期,清除并跳转登录
+      localStorage.removeItem('token');
+      localStorage.removeItem('refresh_token');
+      router.push('/login');
+    }
+    return Promise.reject(error);
+  }
+);
+```
+
+---
+
+## 十、接入 Checklist
+
+- [ ] 在统一认证平台注册子应用,获取 `client_id` 和 `client_secret`
+- [ ] 在子应用配置文件中添加 `[sso]` 配置段
+- [ ] 子应用后端实现 `POST /api/oauth/exchange-code` 换码接口
+- [ ] 子应用前端实现回调页面 `/auth/callback`,从 URL 提取 code 并调换码接口
+- [ ] 子应用本地数据库初始化角色表(`t_sys_role`),确保角色 code 与统一认证平台一致
+- [ ] 子应用配置 CORS 允许前端域名访问
+- [ ] 配置 Token 滑动过期中间件
+- [ ] 配置登出时返回 SSO 登出重定向 URL
+- [ ] 测试完整 SSO 流程:统一认证平台登录 → 点击子系统 → 自动免登进入子系统
+
+---
+
+## 十一、错误码说明
+
+### 统一响应格式
+
+所有接口使用统一的响应格式:
+
+```json
+{
+  "code": "000000",
+  "message": "success",
+  "data": { ... },
+  "timestamp": "2026-05-10T12:00:00Z"
+}
+```
+
+### 业务错误码
+
+| 错误码 | 说明 |
+|--------|------|
+| `000000` | 成功 |
+| `100001` | 缺少授权码 |
+| `400001` | SSO 授权码无效 |
+| `400002` | SSO 用户信息格式异常 |
+| `500001` | 服务器内部错误 |
+
+### OAuth2 标准错误
+
+| error | 说明 |
+|-------|------|
+| `invalid_request` | 请求参数不完整或格式错误 |
+| `invalid_client` | client_id 或 client_secret 错误 |
+| `invalid_grant` | 授权码无效、已使用、过期或 redirect_uri 不匹配 |
+| `invalid_scope` | 请求的 scope 超出应用注册范围 |
+| `unsupported_response_type` | response_type 不支持(仅支持 code) |
+| `unsupported_grant_type` | grant_type 不支持(仅支持 authorization_code) |
+| `access_denied` | 用户拒绝授权 |
+| `server_error` | 服务器内部错误 |