Explorar o código

样本中心单点登录调整

lingmin_package@163.com hai 4 semanas
pai
achega
0c62886872
Modificáronse 44 ficheiros con 1703 adicións e 385 borrados
  1. 63 0
      scripts/drop_removed_modules_tables.sql
  2. 0 0
      scripts/other/annotation_platform_20260131.sql
  3. 0 0
      scripts/other/base_data_20260301.sql
  4. 0 0
      scripts/other/fix_t_task_management_20260205.sql
  5. 0 0
      scripts/other/init_db.py
  6. 0 0
      scripts/other/lq_clear_biz_data_delete.sql
  7. 0 0
      scripts/other/lq_clear_biz_data_truncate.sql
  8. 0 0
      scripts/other/lq_db.sql
  9. 0 0
      scripts/other/lq_db_0203有问题.sql
  10. 0 0
      scripts/other/lq_db_20260131.sql
  11. 0 0
      scripts/other/lq_db_dev_20260202.sql
  12. 0 0
      scripts/other/lq_db_dev_20260301.sql
  13. 0 0
      scripts/other/lq_db_dev_20260308.sql
  14. 0 0
      scripts/other/lq_db_local.sql
  15. 0 0
      scripts/other/lq_db_update.sql
  16. 0 0
      scripts/other/lq_label_dev_20260308.sql
  17. 0 0
      scripts/other/lq_oauth_db_20260130.sql
  18. 0 0
      scripts/other/lq_oauth_db_20260131.sql
  19. 0 0
      scripts/other/t_samp_knowledge_base_update_2026_02_03.sql
  20. 0 0
      scripts/other/t_samp_knowledge_base_update_2026_02_04.sql
  21. 0 0
      scripts/other/t_sys_dict_category.sql
  22. 0 0
      scripts/other/t_sys_dict_category_create_2026_02_22.sql
  23. 0 0
      scripts/other/t_sys_dict_category_item.sql
  24. 0 0
      scripts/other/t_sys_dict_init_data_2026_02_22.sql
  25. 0 0
      scripts/other/t_sys_menu_dict_insert_2026_02_22.sql
  26. 1166 0
      scripts/sample/lq_sample_dev.sql
  27. 11 1
      src/app/config/config.ini
  28. 8 1
      src/app/config/settings.py
  29. 4 4
      src/app/server/app.py
  30. 25 6
      src/views/auth_view.py
  31. 37 16
      src/views/oauth_view.py
  32. 270 0
      src/views/sso_view.py
  33. 0 357
      src/views/system_view.py
  34. 0 0
      项目/其它/FINAL_SUMMARY.md
  35. 0 0
      项目/其它/README.md
  36. 0 0
      项目/其它/REFACTOR_COMPLETE.md
  37. 0 0
      项目/其它/Token机制调整完成报告.md
  38. 0 0
      项目/其它/test.md
  39. 0 0
      项目/其它/字典管理系统设计.md
  40. 0 0
      项目/其它/项目结构.md
  41. 0 0
      项目/其它/项目记录信息.md
  42. 0 0
      项目/其它/项目调整说明.md
  43. 0 0
      项目/其它/项目追加开发说明.md
  44. 119 0
      项目/样本中心需求.md

+ 63 - 0
scripts/drop_removed_modules_tables.sql

@@ -0,0 +1,63 @@
+-- ============================================================
+-- 样本中心改造:删除已移除模块相关的数据库表
+-- 涉及模块:应用管理、统一认证平台(SSO服务端)
+-- 执行前请备份数据库!
+-- ============================================================
+-- 说明:
+--   本次改造移除了"应用管理"和"统一认证平台服务端"功能,
+--   以下脚本用于清理对应的数据库表。
+--   t_oauth_access_tokens / t_oauth_token_blacklist 仍被本地
+--   JWT 认证(含SSO回调后的本地Token发放)使用,予以保留。
+-- ============================================================
+
+-- --------------------------------------------------------
+-- 步骤1:解除 t_oauth_access_tokens 对 t_sys_app 的外键依赖
+--        该表仍需保留用于本地认证,只需删除外键约束
+-- --------------------------------------------------------
+SET @fk_name = (
+    SELECT CONSTRAINT_NAME
+    FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
+    WHERE TABLE_NAME = 't_oauth_access_tokens'
+      AND COLUMN_NAME = 'app_id'
+      AND REFERENCED_TABLE_NAME = 't_sys_app'
+      AND TABLE_SCHEMA = DATABASE()
+);
+SET @sql = IF(@fk_name IS NOT NULL,
+    CONCAT('ALTER TABLE t_oauth_access_tokens DROP FOREIGN KEY ', @fk_name),
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- --------------------------------------------------------
+-- 步骤2:删除 OAuth 授权码表(统一认证平台服务端专用)
+--        该表仅用于 OAuth2 授权码流程,现已无服务端授权需求
+-- --------------------------------------------------------
+DROP TABLE IF EXISTS t_oauth_authorization_codes;
+
+-- --------------------------------------------------------
+-- 步骤3:删除同步日志表(应用管理专用)
+--        记录应用数据同步日志,随应用管理模块一并移除
+-- --------------------------------------------------------
+DROP TABLE IF EXISTS t_sys_sync_log;
+
+-- --------------------------------------------------------
+-- 步骤4:删除应用权限表(应用管理专用)
+--        定义各应用的权限点,随应用管理模块一并移除
+-- --------------------------------------------------------
+DROP TABLE IF EXISTS t_sys_app_permission;
+
+-- --------------------------------------------------------
+-- 步骤5:删除应用表(应用管理模块核心表)
+--        存储 OAuth2 客户端/应用注册信息,现已无应用管理需求
+-- --------------------------------------------------------
+DROP TABLE IF EXISTS t_sys_app;
+
+-- ============================================================
+-- 保留表说明(请勿删除):
+--   t_oauth_access_tokens    -- 本地JWT令牌存储(本地登录 + SSO回调后发放)
+--   t_oauth_token_blacklist  -- 令牌黑名单(登出时使用)
+--   t_sys_login_log          -- 登录日志(系统管理仍在使用)
+--   t_sys_operation_log      -- 操作日志(系统管理仍在使用)
+-- ============================================================

+ 0 - 0
scripts/annotation_platform_20260131.sql → scripts/other/annotation_platform_20260131.sql


+ 0 - 0
scripts/base_data_20260301.sql → scripts/other/base_data_20260301.sql


+ 0 - 0
scripts/fix_t_task_management_20260205.sql → scripts/other/fix_t_task_management_20260205.sql


+ 0 - 0
scripts/init_db.py → scripts/other/init_db.py


+ 0 - 0
scripts/lq_clear_biz_data_delete.sql → scripts/other/lq_clear_biz_data_delete.sql


+ 0 - 0
scripts/lq_clear_biz_data_truncate.sql → scripts/other/lq_clear_biz_data_truncate.sql


+ 0 - 0
scripts/lq_db.sql → scripts/other/lq_db.sql


+ 0 - 0
scripts/lq_db_0203有问题.sql → scripts/other/lq_db_0203有问题.sql


+ 0 - 0
scripts/lq_db_20260131.sql → scripts/other/lq_db_20260131.sql


+ 0 - 0
scripts/lq_db_dev_20260202.sql → scripts/other/lq_db_dev_20260202.sql


+ 0 - 0
scripts/lq_db_dev_20260301.sql → scripts/other/lq_db_dev_20260301.sql


+ 0 - 0
scripts/lq_db_dev_20260308.sql → scripts/other/lq_db_dev_20260308.sql


+ 0 - 0
scripts/lq_db_local.sql → scripts/other/lq_db_local.sql


+ 0 - 0
scripts/lq_db_update.sql → scripts/other/lq_db_update.sql


+ 0 - 0
scripts/lq_label_dev_20260308.sql → scripts/other/lq_label_dev_20260308.sql


+ 0 - 0
scripts/lq_oauth_db_20260130.sql → scripts/other/lq_oauth_db_20260130.sql


+ 0 - 0
scripts/lq_oauth_db_20260131.sql → scripts/other/lq_oauth_db_20260131.sql


+ 0 - 0
scripts/t_samp_knowledge_base_update_2026_02_03.sql → scripts/other/t_samp_knowledge_base_update_2026_02_03.sql


+ 0 - 0
scripts/t_samp_knowledge_base_update_2026_02_04.sql → scripts/other/t_samp_knowledge_base_update_2026_02_04.sql


+ 0 - 0
scripts/t_sys_dict_category.sql → scripts/other/t_sys_dict_category.sql


+ 0 - 0
scripts/t_sys_dict_category_create_2026_02_22.sql → scripts/other/t_sys_dict_category_create_2026_02_22.sql


+ 0 - 0
scripts/t_sys_dict_category_item.sql → scripts/other/t_sys_dict_category_item.sql


+ 0 - 0
scripts/t_sys_dict_init_data_2026_02_22.sql → scripts/other/t_sys_dict_init_data_2026_02_22.sql


+ 0 - 0
scripts/t_sys_menu_dict_insert_2026_02_22.sql → scripts/other/t_sys_menu_dict_insert_2026_02_22.sql


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1166 - 0
scripts/sample/lq_sample_dev.sql


+ 11 - 1
src/app/config/config.ini

@@ -17,7 +17,7 @@ RELOAD=True
 # @ 编码为 %40
 # 例如:密码 lq@123 应该写成 lq%40123
 #DATABASE_URL=mysql+aiomysql://root:admin@localhost:3306/lq_db
-DATABASE_URL=mysql+aiomysql://root:Lq123456!@192.168.92.61:13306/lq_db
+DATABASE_URL=mysql+aiomysql://root:Lq123456!@192.168.92.61:13306/lq_sample_dev
 DATABASE_ECHO=False
 
 # Milvus向量数据库配置信息
@@ -109,3 +109,13 @@ EMBEDDING_API_KEY=dummy
 admin_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzIwMjYwMTI5MTUxMTM4XzkzYjIyMjZkIiwidXNlcm5hbWUiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJleHAiOjEwNDEyMzM3MDQ3LCJpYXQiOjE3NzI0MjM0NDcsInR5cGUiOiJhY2Nlc3MifQ.k5e_gyb9OvBJnztwYLcaZA80dgVKI_6LmfcfCNFTEi8
 project_api_url=http://192.168.92.61:9003/api/external/projects
 download_base_url=http://192.168.92.61:9003
+
+# SSO客户端配置(样本中心接入外部统一认证平台)
+[sso]
+SSO_BASE_URL=http://localhost:8200
+CLIENT_ID=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
+CLIENT_SECRET=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
+REDIRECT_URI=http://localhost:8000/auth/callback
+FRONTEND_URL=http://localhost:3001
+SCOPE=email
+SSO_LOGOUT_REDIRECT_URL=http://localhost:3000/login

+ 8 - 1
src/app/config/settings.py

@@ -102,7 +102,14 @@ class Settings(BaseSettings):
     EMBEDDING_BASE_URL: str = "http://192.168.91.253:9003/v1"
     EMBEDDING_MODEL: str = "Qwen3-Embedding-8B"
     EMBEDDING_API_KEY: str = "dummy"
-    
+
+    # SSO客户端配置(样本中心接入外部统一认证平台)
+    SSO_BASE_URL: str = "http://192.168.92.61:8200"
+    CLIENT_ID: str = ""
+    CLIENT_SECRET: str = ""
+    REDIRECT_URI: str = "http://localhost:8000/auth/callback"
+    FRONTEND_URL: str = "http://localhost:5173"
+
     @field_validator("ALLOWED_EXTENSIONS", mode="before")
     @classmethod
     def parse_allowed_extensions(cls, v):

+ 4 - 4
src/app/server/app.py

@@ -49,9 +49,9 @@ logger = get_logger("lqadmin_server")
 
 # 导入视图路由
 from views.system_view import router as system_router
-from views.oauth_view import router as oauth_router
 from views.sample_view import router as sample_router
 from views.auth_view import router as auth_router
+from views.sso_view import router as sso_router
 from views.knowledge_base_view import router as knowledge_base_router
 from views.snippet_view import router as snippet_router
 from views.tag_view import router as tag_router
@@ -123,7 +123,7 @@ async def lifespan(app: FastAPI):
 app = FastAPI(
     title=config_handler.get("admin_app", "APP_NAME", "后台管理"),
     version=config_handler.get("admin_app", "APP_VERSION", "1.0.0"),
-    description="OAuth2单点登录认证中心 - 统一管理平台",
+    description="四川路桥样本中心",
     docs_url="/docs" if config_handler.get_bool("admin_app", "DEBUG", False) else None,
     redoc_url="/redoc" if config_handler.get_bool("admin_app", "DEBUG", False) else None,
     lifespan=lifespan
@@ -298,7 +298,7 @@ async def root():
             "name": config_handler.get("admin_app", "APP_NAME", "后台管理"),
             "version": config_handler.get("admin_app", "APP_VERSION", "1.0.0"),
             "docs": "/docs" if config_handler.get_bool("admin_app", "DEBUG", False) else None,
-            "modules": ["系统管理", "授权管理", "样本中心"]
+            "modules": ["系统管理", "样本中心"]
         }
     )
 
@@ -310,8 +310,8 @@ async def root():
 app.include_router(system_api_router, prefix="/api/external/v1")
 # 新的模块化视图路由
 app.include_router(system_router, prefix="/api/v1")
-app.include_router(oauth_router, prefix="")
 app.include_router(auth_router, prefix="/api/v1")
+app.include_router(sso_router, prefix="")
 app.include_router(sample_router, prefix="/api/v1/sample")
 app.include_router(sample_router, prefix="/api")  # 兼容外部测试脚本的路径 (/api/external/...)
 app.include_router(knowledge_base_router, prefix="/api/v1")

+ 25 - 6
src/views/auth_view.py

@@ -9,6 +9,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
 
 from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from fastapi.responses import JSONResponse
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 from sqlalchemy.ext.asyncio import AsyncSession
 from typing import Optional
@@ -23,6 +24,7 @@ from app.schemas.auth import (
 )
 from app.services.auth_service import AuthService
 from app.core.exceptions import AuthenticationError, ValidationError
+from app.core.config import config_handler
 from app.schemas.base import ResponseSchema
 import base64
 import io
@@ -54,6 +56,7 @@ def get_user_agent(request: Request) -> str:
 @router.post("/login", response_model=ResponseSchema)
 async def login(
     request: Request,
+    response: Response,
     login_data: LoginRequest,
     db: AsyncSession = Depends(get_db)
 ):
@@ -72,8 +75,22 @@ async def login(
         )
         
         logger.info(f"登录成功: username={login_data.username}, user_id={user.id}")
+        # 将 access_token 写入 cookie,供 OAuth2 authorize 端点识别登录状态
+        access_token = token_response.access_token
+        if access_token and response:
+            response.set_cookie(
+                key="access_token",
+                value=access_token,
+                httponly=True,
+                path="/",
+                max_age=token_response.expires_in or 3600,
+                samesite="lax"
+            )
+            logger.info(f"已设置 access_token cookie, max_age={token_response.expires_in or 3600}")
+        else:
+            logger.warning(f"未设置 cookie: access_token={bool(access_token)}, response={bool(response)}")
         return ResponseSchema(
-        code="000000",
+            code="000000",
             message="登录成功",
             data=token_response.dict()
         )
@@ -152,17 +169,19 @@ async def logout(
             token=token,
             refresh_token=refresh_token
         )
-        
+
+        sso_logout_url = config_handler.get("sso", "SSO_LOGOUT_REDIRECT_URL", "")
+
         return ResponseSchema(
-        code="000000",
+            code="000000",
             message="登出成功",
-            data=None
+            data={"redirect_url": sso_logout_url or None}
         )
-        
+
     except Exception as e:
         logger.exception("登出内部错误")
         return ResponseSchema(
-        code="500001",
+            code="500001",
             message="服务器内部错误",
             data=None
         )

+ 37 - 16
src/views/oauth_view.py

@@ -34,6 +34,7 @@ security_optional = HTTPBearer(auto_error=False)
 # OAuth2 授权端点
 @router.get("/authorize")
 async def oauth_authorize(
+    request: Request,
     response_type: str,
     client_id: str,
     redirect_uri: str,
@@ -43,31 +44,31 @@ async def oauth_authorize(
     """OAuth2授权端点"""
     try:
         logger.info(f"OAuth授权请求: client_id={client_id}, redirect_uri={redirect_uri}, scope={scope}")
-        
+
         # 验证必要参数
         if not response_type or not client_id or not redirect_uri:
             error_url = f"{redirect_uri}?error=invalid_request&error_description=Missing required parameters"
             if state:
                 error_url += f"&state={state}"
             return {"error": "invalid_request", "redirect_url": error_url}
-        
+
         # 验证response_type
         if response_type != "code":
             error_url = f"{redirect_uri}?error=unsupported_response_type&error_description=Only authorization code flow is supported"
             if state:
                 error_url += f"&state={state}"
             return {"error": "unsupported_response_type", "redirect_url": error_url}
-        
+
         # 调用 service 层验证客户端和重定向URI
         oauth_service = OAuthService()
         success, app_info, message = await oauth_service.validate_client_and_redirect_uri(client_id, redirect_uri)
-        
+
         if not success:
             error_url = f"{redirect_uri}?error=invalid_client&error_description={message}"
             if state:
                 error_url += f"&state={state}"
             return {"error": "invalid_client", "redirect_url": error_url}
-        
+
         # 验证scope
         app_scopes = app_info.get("scope", [])
         requested_scopes = scope.split() if scope else []
@@ -77,21 +78,41 @@ async def oauth_authorize(
             if state:
                 error_url += f"&state={state}"
             return {"error": "invalid_scope", "redirect_url": error_url}
-        
-        # TODO: 检查用户登录状态
-        # 这里应该检查用户是否已登录(通过session或cookie)
-        # 如果未登录,应该重定向到登录页面
-        
-        # 临时方案:返回登录页面,让用户先登录
-        # 生产环境应该使用session管理
-        
-        # 构建登录页面URL,登录后返回授权页面
+
+        # 检查用户登录状态(通过 cookie 中的 access_token)
+        all_cookies = request.cookies
+        access_token = all_cookies.get("access_token")
+        logger.info(f"Cookie 检查: access_token 存在={bool(access_token)}, 所有 cookies={list(all_cookies.keys())}")
+        if access_token:
+            payload = verify_token(access_token)
+            logger.info(f"Token 验证结果: payload={payload is not None}")
+            if payload:
+                user_id = payload.get("sub")
+                username = payload.get("username", "")
+                logger.info(f"用户已通过 cookie 登录: {username} ({user_id}),直接授权")
+
+                # 生成授权码
+                auth_code = secrets.token_urlsafe(32)
+                await oauth_service.store_authorization_code(user_id, client_id, auth_code, redirect_uri, scope)
+
+                callback_url = f"{redirect_uri}?code={auth_code}"
+                if state:
+                    callback_url += f"&state={state}"
+
+                logger.info(f"已登录用户自动授权: {callback_url}")
+                return RedirectResponse(url=callback_url, status_code=302)
+            else:
+                logger.warning("Cookie 中的 access_token 验证失败,将跳转到登录页")
+        else:
+            logger.info("Cookie 中未找到 access_token,将跳转到登录页")
+
+        # 未登录,重定向到登录页面
         login_page_url = f"/oauth/login?response_type={response_type}&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}"
         if state:
             login_page_url += f"&state={state}"
-        
+
         logger.info(f"需要用户登录,重定向到登录页面: {login_page_url}")
-        
+
         return RedirectResponse(url=login_page_url, status_code=302)
         
         # 非受信任应用需要用户授权确认

+ 270 - 0
src/views/sso_view.py

@@ -0,0 +1,270 @@
+"""
+SSO客户端接入视图
+处理外部统一认证平台的OAuth2回调
+"""
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
+
+import logging
+import uuid
+from urllib.parse import quote
+from fastapi import APIRouter, Request, Depends
+from fastapi.responses import RedirectResponse, JSONResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_
+from datetime import datetime, timedelta
+import httpx
+
+from app.base import get_db
+from app.core.config import config_handler
+from app.models.user import User, UserRole, Role
+from app.models.token import OAuthAccessToken
+from app.utils.security import create_access_token, create_refresh_token, hash_password, generate_random_string
+from app.schemas.base import ResponseSchema
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="", tags=["SSO接入"])
+
+
+@router.get("/auth/sso/authorize")
+async def sso_authorize_url(redirect: bool = False):
+    """获取外部SSO授权URL(供前端使用)
+
+    Args:
+        redirect: 为True时直接重定向到授权URL,为False时返回JSON
+    """
+    try:
+        sso_base = config_handler.get("sso", "SSO_BASE_URL", "")
+        client_id = config_handler.get("sso", "CLIENT_ID", "")
+        redirect_uri = config_handler.get("sso", "REDIRECT_URI", "")
+
+        scope = config_handler.get("sso", "SCOPE", "read write")
+        authorize_url = (
+            f"{sso_base}/oauth/authorize"
+            f"?client_id={client_id}"
+            f"&redirect_uri={redirect_uri}"
+            f"&response_type=code"
+            f"&scope={quote(scope, safe='')}"
+        )
+
+        if redirect:
+            return RedirectResponse(url=authorize_url, status_code=302)
+
+        return ResponseSchema(
+            code="000000",
+            message="获取授权URL成功",
+            data={"authorize_url": authorize_url}
+        )
+    except Exception as e:
+        logger.exception("获取SSO授权URL失败")
+        return ResponseSchema(
+            code="500001",
+            message=f"获取授权URL失败: {str(e)}",
+            data=None
+        )
+
+
+@router.get("/auth/callback")
+async def sso_callback(
+    request: Request,
+    code: str = None,
+    state: str = None,
+    error: str = None,
+    error_description: str = None,
+    db: AsyncSession = Depends(get_db)
+):
+    """处理外部SSO回调"""
+    try:
+        frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
+
+        # 处理外部SSO返回的错误
+        if error:
+            logger.error(f"SSO返回错误: {error}, {error_description}")
+            return RedirectResponse(
+                url=f"{frontend_url}/login?error={error}&error_description={error_description or ''}",
+                status_code=302
+            )
+
+        if not code:
+            logger.error("SSO回调缺少code参数")
+            return RedirectResponse(
+                url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
+                status_code=302
+            )
+
+        sso_base = config_handler.get("sso", "SSO_BASE_URL", "")
+        client_id = config_handler.get("sso", "CLIENT_ID", "")
+        client_secret = config_handler.get("sso", "CLIENT_SECRET", "")
+        redirect_uri = config_handler.get("sso", "REDIRECT_URI", "")
+
+        logger.info(f"收到SSO回调, code={code[:10]}...")
+
+        # Step 1: 用code换取access_token
+        token_url = f"{sso_base}/oauth/token"
+        token_data = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": redirect_uri,
+            "client_id": client_id,
+            "client_secret": client_secret
+        }
+
+        async with httpx.AsyncClient() as client:
+            token_response = await client.post(token_url, data=token_data)
+            logger.info(f"Token接口状态码: {token_response.status_code}")
+
+            if token_response.status_code != 200:
+                # 尝试GET方式(兼容某些非标准实现)
+                token_response_get = await client.get(token_url, params=token_data)
+                logger.info(f"Token接口GET状态码: {token_response_get.status_code}")
+                if token_response_get.status_code == 200:
+                    token_response = token_response_get
+                else:
+                    logger.error(f"获取token失败: {token_response.text}")
+                    return RedirectResponse(
+                        url=f"{frontend_url}/login?error=token_failed&error_description=获取令牌失败",
+                        status_code=302
+                    )
+
+            token_result = token_response.json()
+            access_token = token_result.get("access_token")
+            if not access_token:
+                logger.error(f"Token响应中缺少access_token: {token_result}")
+                return RedirectResponse(
+                    url=f"{frontend_url}/login?error=invalid_token_response&error_description=无效的令牌响应",
+                    status_code=302
+                )
+
+        # Step 2: 用access_token获取用户信息
+        userinfo_url = f"{sso_base}/oauth/userinfo"
+        async with httpx.AsyncClient() as client:
+            userinfo_response = await client.get(
+                userinfo_url,
+                headers={"Authorization": f"Bearer {access_token}"}
+            )
+            logger.info(f"Userinfo接口状态码: {userinfo_response.status_code}")
+
+            if userinfo_response.status_code != 200:
+                logger.error(f"获取用户信息失败: {userinfo_response.text}")
+                return RedirectResponse(
+                    url=f"{frontend_url}/login?error=userinfo_failed&error_description=获取用户信息失败",
+                    status_code=302
+                )
+
+            userinfo = userinfo_response.json()
+            logger.info(f"获取到外部用户信息: {userinfo}")
+
+        # Step 3: 在本地查找或创建用户
+        external_user_id = str(userinfo.get("sub", userinfo.get("id", "")))
+        username = userinfo.get("username") or userinfo.get("login") or external_user_id
+        email = userinfo.get("email") or f"{username}@placeholder.local"
+        real_name = userinfo.get("name") or userinfo.get("real_name") or username
+
+        # 先按username查找
+        stmt = select(User).where(
+            and_(User.username == username, User.is_deleted == False)
+        )
+        result = await db.execute(stmt)
+        user = result.scalar_one_or_none()
+
+        if not user:
+            # 再按email查找
+            stmt = select(User).where(
+                and_(User.email == email, User.is_deleted == False)
+            )
+            result = await db.execute(stmt)
+            user = result.scalar_one_or_none()
+
+        if user:
+            # 更新用户最后登录时间
+            user.last_login_at = datetime.utcnow()
+            user.last_login_ip = request.client.host if request.client else None
+            logger.info(f"SSO登录: 更新现有用户 {username}")
+        else:
+            # 创建新用户
+            user_id = str(uuid.uuid4())
+            user = User(
+                id=user_id,
+                username=username,
+                email=email,
+                password_hash=hash_password(generate_random_string(32)),
+                is_active=True,
+                is_superuser=False,
+                last_login_at=datetime.utcnow(),
+                last_login_ip=request.client.host if request.client else None
+            )
+            db.add(user)
+            await db.flush()
+            logger.info(f"SSO登录: 创建新用户 {username}, id={user_id}")
+
+            # 尝试分配默认角色
+            try:
+                stmt = select(Role).where(
+                    and_(Role.code == "user", Role.is_active == True)
+                )
+                result = await db.execute(stmt)
+                default_role = result.scalar_one_or_none()
+                if default_role:
+                    user_role = UserRole(
+                        user_id=user.id,
+                        role_id=default_role.id
+                    )
+                    db.add(user_role)
+            except Exception as role_err:
+                logger.warning(f"分配默认角色失败: {role_err}")
+
+        await db.commit()
+
+        # Step 4: 生成本地JWT
+        token_payload = {
+            "sub": user.id,
+            "username": user.username,
+            "email": user.email,
+            "is_superuser": user.is_superuser
+        }
+        local_access_token = create_access_token(token_payload)
+        local_refresh_token = create_refresh_token({"sub": user.id})
+
+        # Step 5: 保存token到数据库
+        admin_expire_minutes = config_handler.get_int("admin_app", "ADMIN_TOKEN_EXPIRE_MINUTES", None)
+        if admin_expire_minutes is not None:
+            expires_at = datetime.utcnow() + timedelta(minutes=admin_expire_minutes)
+            expires_in_seconds = admin_expire_minutes * 60
+        else:
+            expire_minutes = config_handler.get_int("admin_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30)
+            expires_at = datetime.utcnow() + timedelta(minutes=expire_minutes)
+            expires_in_seconds = expire_minutes * 60
+
+        oauth_token = OAuthAccessToken(
+            user_id=user.id,
+            app_id=None,
+            token=local_access_token,
+            refresh_token=local_refresh_token,
+            token_type="Bearer",
+            scope="profile email",
+            expires_at=expires_at
+        )
+        db.add(oauth_token)
+        await db.commit()
+
+        logger.info(f"SSO登录成功: user={user.username}, token={local_access_token[:20]}...")
+
+        # Step 6: 重定向回前端,携带本地token
+        callback_url = (
+            f"{frontend_url}/oauth/callback"
+            f"?token={local_access_token}"
+            f"&refresh_token={local_refresh_token}"
+        )
+        return RedirectResponse(url=callback_url, status_code=302)
+
+    except Exception as e:
+        logger.exception("SSO回调处理异常")
+        frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
+        return RedirectResponse(
+            url=f"{frontend_url}/login?error=server_error&error_description={str(e)}",
+            status_code=302
+        )

+ 0 - 357
src/views/system_view.py

@@ -1143,360 +1143,3 @@ async def get_all_roles_simple(credentials: dict = Depends(get_current_user_with
         return ApiResponse(code="500001", message="服务器内部错误", timestamp=datetime.now(timezone.utc).isoformat()).model_dump()
 
 
-
-
-
-@router.get("/apps")
-async def get_apps(
-    page: int = 1,
-    page_size: int = 20,
-    keyword: str = "",
-    status: str = "",
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """获取应用列表"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 调用 service 层检查用户角色
-        system_service_ext = SystemServiceExt()
-        is_app_manager = await system_service_ext.check_user_app_manager_role(user_id)
-        
-        # 调用 service 层获取应用列表
-        apps, total = await system_service_ext.get_apps(page, page_size, user_id, is_app_manager, keyword, status)
-        
-        return ApiResponse(
-            code="000000",
-            message="获取应用列表成功",
-            data={
-                "items": apps,
-                "total": total,
-                "page": page,
-                "page_size": page_size
-            },
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-        
-    except Exception as e:
-        logger.exception("获取应用列表错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-@router.get("/apps/{app_id}")
-async def get_app_detail(
-    app_id: str,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """获取应用详情(包含密钥)"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        app_detail = await system_service_ext.get_app_detail(app_id, user_id)
-        
-        if not app_detail:
-            return ApiResponse(
-        code="200001",
-                message="应用不存在或无权限",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        return ApiResponse(
-            code="000000",
-            message="获取应用详情成功",
-            data=app_detail,
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-        
-    except Exception as e:
-        logger.exception("获取应用详情错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-@router.post("/apps")
-async def create_app(
-    request: Request,
-    app_data: dict,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """创建应用"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 验证必要字段
-        if not app_data.get('name') or not app_data.get('redirect_uris'):
-            return ApiResponse(
-        code="100001",
-                message="缺少必要参数",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        success, message, app_info = await system_service_ext.create_app(app_data, user_id)
-        
-        if success:
-            return ApiResponse(
-                code="000000",
-                message=message,
-                data=app_info,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        else:
-            return ApiResponse(
-        code="500001",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-    except Exception as e:
-        logger.exception("创建应用错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-@router.put("/apps/{app_id}/status")
-async def toggle_app_status(
-    app_id: str,
-    status_data: dict,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """切换应用状态"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        is_active = status_data.get('is_active')
-        
-        if is_active is None:
-            return ApiResponse(
-        code="100001",
-                message="缺少必要参数",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        success, message = await system_service_ext.toggle_app_status(app_id, is_active, user_id)
-        
-        if success:
-            return ApiResponse(
-                code="000000",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        else:
-            return ApiResponse(
-        code="200001",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-    except Exception as e:
-        logger.exception("切换应用状态错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-@router.put("/apps/{app_id}")
-async def update_app(
-    app_id: str,
-    app_data: dict,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """更新应用信息"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 验证必要参数
-        name = app_data.get('name', '').strip()
-        if not name:
-            return ApiResponse(
-        code="100001",
-                message="应用名称不能为空",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        success, message, app_result = await system_service_ext.update_app(app_id, app_data, user_id)
-        
-        if success:
-            return ApiResponse(
-                code="000000",
-                message=message,
-                data=app_result,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        else:
-            return ApiResponse(
-        code="200001",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-    except Exception as e:
-        logger.exception("更新应用错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-@router.delete("/apps/{app_id}")
-async def delete_app(
-    app_id: str,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """删除应用"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        success, message = await system_service_ext.delete_app_by_id(app_id, user_id)
-        
-        if success:
-            return ApiResponse(
-                code="000000",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        else:
-            return ApiResponse(
-        code="200001",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-    except Exception as e:
-        logger.exception("删除应用错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-        
-    except Exception as e:
-        logger.exception("删除应用错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-
-
-@router.post("/apps/{app_id}/reset-secret")
-async def reset_app_secret(
-    app_id: str,
-    credentials: dict = Depends(get_current_user_with_refresh)
-):
-    """重置应用密钥"""
-    try:
-        # 验证令牌
-        payload = credentials
-        if not payload:
-            return ApiResponse(
-        code="200002",
-                message="无效的访问令牌",
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-        user_id = payload.get("sub")
-        
-        # 调用 service 层
-        system_service_ext = SystemServiceExt()
-        success, message, new_secret = await system_service_ext.reset_app_secret(app_id, user_id)
-        
-        if success:
-            return ApiResponse(
-                code="000000",
-                message=message,
-                data={"app_secret": new_secret},
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        else:
-            return ApiResponse(
-        code="200001",
-                message=message,
-                timestamp=datetime.now(timezone.utc).isoformat()
-            ).model_dump()
-        
-    except Exception as e:
-        logger.exception("重置应用密钥错误")
-        return ApiResponse(
-        code="500001",
-            message="服务器内部错误",
-            timestamp=datetime.now(timezone.utc).isoformat()
-        ).model_dump()
-
-def generate_random_string(length=32):
-    """生成随机字符串"""
-    import secrets
-    import string
-    alphabet = string.ascii_letters + string.digits
-    return ''.join(secrets.choice(alphabet) for _ in range(length))

+ 0 - 0
项目/FINAL_SUMMARY.md → 项目/其它/FINAL_SUMMARY.md


+ 0 - 0
项目/README.md → 项目/其它/README.md


+ 0 - 0
项目/REFACTOR_COMPLETE.md → 项目/其它/REFACTOR_COMPLETE.md


+ 0 - 0
项目/Token机制调整完成报告.md → 项目/其它/Token机制调整完成报告.md


+ 0 - 0
项目/test.md → 项目/其它/test.md


+ 0 - 0
项目/字典管理系统设计.md → 项目/其它/字典管理系统设计.md


+ 0 - 0
项目/项目结构.md → 项目/其它/项目结构.md


+ 0 - 0
项目/项目记录信息.md → 项目/其它/项目记录信息.md


+ 0 - 0
项目/项目调整说明.md → 项目/其它/项目调整说明.md


+ 0 - 0
项目/项目追加开发说明.md → 项目/其它/项目追加开发说明.md


+ 119 - 0
项目/样本中心需求.md

@@ -0,0 +1,119 @@
+
+
+### 样本中心需求
+  - 功能定义: 定义样本中心  LQAdminPlatform(后端) 、LQAdminFront(前端)  
+  - 去除:统一认证平台 、应用管理 模块功能逻辑 (包括:后端和前端)
+  - 前端界面标题调整为:四川路桥样本中心
+  - 增加:样本中心接入单点登录 , 其中 SSO_BASE_URL、 CLIENT_ID、CLIENT_SECRET、REDIRECT_URI 配置项
+    - SSO_BASE_URL(统一认证平台URL地址):http://192.168.92.61:8200
+    - CLIENT_ID(应用key App Key): WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
+    - CLIENT_SECRET(应用密钥App Secret): 9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
+    - REDIRECT_URI(回调地址):http://localhost:8000/auth/callback
+    - 获取授权码:http://192.168.92.61:8200/oauth/authorize?client_id=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6&redirect_uri=http://localhost:8000/auth/callback&response_type=code&scope=read%20write
+    - 获取Access Token:http://192.168.92.61:8200/oauth/token?client_id=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6&client_secret=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ&grant_type=authorization_code&
+    - 获取用户信息:http://192.168.92.61:8200/oauth/userinfo
+  
+
+
+
+
+
+
+
+### 统一认证平台
+  - AI中台管理:统一认证平台(8200 )后端:LQAI-middle-platform, 前端:LQAI-middle-platform-front
+  - 样本中心: 子应用  后端:LQAdminPlatform  前端:LQAdminFront
+  - 要求: 统一认证平台(统一认证平台:8200)已经登录了,点击子应用图标(样本中心:8000) 直接跳转子应用首页 无需再次登录
+  
+
+
+
+  ● 方案B实现完成
+
+  我修改了4个文件,下面是完整的修改总结:
+
+  ---
+  一、修改的文件
+
+  ┌────────────────┬────────────────────────────────────────────────────┬───────────────────────────┐    
+  │      项目      │                      文件路径                      │         修改内容          │    
+  ├────────────────┼────────────────────────────────────────────────────┼───────────────────────────┤    
+  │ 统一认证平台后 │ LQAI-middle-platform/src/app/schemas/auth.py       │ 新增 SSORedirectRequest   │    
+  │ 端             │                                                    │ Schema                    │    
+  ├────────────────┼────────────────────────────────────────────────────┼───────────────────────────┤    
+  │ 统一认证平台后 │                                                    │ 新增 POST                 │    
+  │ 端             │ LQAI-middle-platform/src/views/auth_view.py        │ /api/v1/auth/sso-redirect │    
+  │                │                                                    │  接口                     │    
+  ├────────────────┼────────────────────────────────────────────────────┼───────────────────────────┤    
+  │ 统一认证平台前 │                                                    │ 新增                      │    
+  │ 端             │ LQAI-middle-platform-front/src/api/auth.ts         │ getSSORedirectUrl(appId)  │    
+  │                │                                                    │ API 方法                  │    
+  ├────────────────┼────────────────────────────────────────────────────┼───────────────────────────┤    
+  │                │                                                    │ 修改                      │    
+  │ 统一认证平台前 │ LQAI-middle-platform-front/src/views/dashboard/Ind │ handleAppClick:先请求    │    
+  │ 端             │ ex.vue                                             │ SSO 免登接口,失败则降级  │    
+  │                │                                                    │ 直接打开                  │    
+  └────────────────┴────────────────────────────────────────────────────┴───────────────────────────┘    
+
+  ---
+  二、SSO 免登流程(点击子应用后)
+
+  1. 统一认证平台前端点击"样本中心"
+         │
+         ▼
+  2. 调用 POST /api/v1/auth/sso-redirect {app_id: "样本中心ID"}
+     (携带当前登录用户的 Bearer Token)
+         │
+         ▼
+  3. 统一认证平台后端:
+     ├── 验证用户已登录(JWT + Redis 校验)
+     ├── 查询 t_sys_app 获取样本中心的 app_key、redirect_uris
+     ├── 生成 OAuth2 授权码(一次性,10分钟有效)
+     ├── 存入 Redis
+     └── 返回 {redirect_url: "http://localhost:8000/auth/callback?code=xxx"}
+         │
+         ▼
+  4. 前端 window.open(redirect_url, '_blank')
+         │
+         ▼
+  5. 样本中心后端 /auth/callback?code=xxx
+     ├── 用 code 向统一认证平台换取 access_token
+     ├── 用 access_token 获取用户信息
+     ├── 在本地查找/创建用户
+     ├── 生成本地 JWT token
+     └── 重定向到前端 /oauth/callback?token=xxx&refresh_token=xxx
+         │
+         ▼
+  6. 样本中心前端 OAuthCallback.vue
+     ├── 从 URL 提取 token 保存到本地
+     ├── 获取用户信息
+     └── 跳转到 /dashboard(首页)
+
+  整个流程用户无需输入密码,无感知完成登录。
+
+  ---
+  三、部署前需要确认的配置
+
+  1. 样本中心在统一认证平台的注册信息(t_sys_app 表):
+    - 首页:
+    - app_key:样本中心的 client_id(需与样本中心 config.ini 的 CLIENT_ID 一致)
+    - app_secret:与样本中心 config.ini 的 CLIENT_SECRET 一致
+    - redirect_uris:必须包含样本中心的回调地址,如 ["http://localhost:8000/auth/callback"]
+    - is_active:必须为 1
+    - scope:建议 ["profile", "email"]
+  2. 样本中心 config.ini 的 SSO 配置:
+  [sso]
+  SSO_BASE_URL=http://192.168.92.61:8200
+  CLIENT_ID=样本中心在统一平台的client_id
+  CLIENT_SECRET=样本中心在统一平台的client_secret
+  REDIRECT_URI=http://localhost:8000/auth/callback
+  FRONTEND_URL=http://localhost:3000   # 样本中心前端实际部署地址
+
+  ---
+  四、降级机制
+
+  如果某个子应用没有配置 OAuth2 回调地址,前端会自动降级:接口返回错误后,仍会用原来的方式直接打开       
+  home_url,不会影响其他应用的正常访问。
+
+  ---
+  是否需要我进一步调整任何逻辑,或者帮你验证样本中心的 t_sys_app 配置数据?

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio