Ver Fonte

按最新的流程接入

lingmin_package@163.com há 3 semanas atrás
pai
commit
6f538f62bb
4 ficheiros alterados com 367 adições e 176 exclusões
  1. 4 4
      src/app/config/config.ini
  2. 2 0
      src/app/server/app.py
  3. 326 171
      src/views/sso_view.py
  4. 35 1
      项目/样本中心需求.md

+ 4 - 4
src/app/config/config.ini

@@ -112,10 +112,10 @@ download_base_url=http://192.168.92.61:9003
 
 # SSO客户端配置(样本中心接入外部统一认证平台)
 [sso]
-SSO_BASE_URL=http://localhost:8200
+SSO_BASE_URL=http://192.168.92.61:8200
 CLIENT_ID=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
 CLIENT_SECRET=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
-REDIRECT_URI=http://localhost:8000/auth/callback
-FRONTEND_URL=http://localhost:3001
+REDIRECT_URI=http://localhost:3000/auth/callback
+FRONTEND_URL=http://localhost:3000
 SCOPE=email
-SSO_LOGOUT_REDIRECT_URL=http://localhost:3000/login
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login

+ 2 - 0
src/app/server/app.py

@@ -160,6 +160,8 @@ app.add_middleware(
         "/auth/login",
         "/auth/captcha",
         "/auth/refresh",
+        "/auth/sso/authorize",
+        "/api/oauth/exchange-code",
         "/docs",
         "/openapi.json",
         "/redoc",

+ 326 - 171
src/views/sso_view.py

@@ -30,6 +30,249 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="", tags=["SSO接入"])
 
 
+async def _sso_exchange_code(code: str, db: AsyncSession, request: Request) -> dict:
+    """
+    SSO 授权码交换流程(核心逻辑)
+
+    用 code 向外部 SSO 换 token → 获取用户信息 → 同步本地 DB → 签发本地 JWT
+    返回 { token, refresh_token, user }
+    """
+    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("========== [SSO 码交换] 开始 ==========")
+    logger.info(f"[SSO 码交换] code 前10位: {code[:10]}...")
+    logger.info(f"[SSO 码交换] SSO_BASE_URL: {sso_base}")
+    logger.info(f"[SSO 码交换] CLIENT_ID: {client_id[:10]}...")
+    logger.info(f"[SSO 码交换] REDIRECT_URI: {redirect_uri}")
+
+    # Step 1: 用 code 换取外部 SSO 的 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
+    }
+
+    logger.info(f"[SSO 码交换] Step 1: POST {token_url}")
+    logger.info(f"[SSO 码交换] token_data keys: {list(token_data.keys())}")
+    logger.info(f"[SSO 码交换] grant_type: {token_data['grant_type']}")
+    logger.info(f"[SSO 码交换] code: {token_data['code']}")
+    logger.info(f"[SSO 码交换] redirect_uri: {token_data['redirect_uri']}")
+    logger.info(f"[SSO 码交换] client_id: {token_data['client_id']}")
+
+    async with httpx.AsyncClient() as client:
+        token_response = await client.post(token_url, data=token_data)
+        logger.info(f"[SSO 码交换] Token POST 响应状态码: {token_response.status_code}")
+        logger.info(f"[SSO 码交换] Token POST 响应内容: {token_response.text[:500]}")
+
+        if token_response.status_code != 200:
+            # 尝试 GET 方式(兼容非标准实现)
+            logger.warning(f"[SSO 码交换] POST 失败,尝试 GET 方式...")
+            token_response_get = await client.get(token_url, params=token_data)
+            logger.info(f"[SSO 码交换] Token GET 响应状态码: {token_response_get.status_code}")
+            logger.info(f"[SSO 码交换] Token GET 响应内容: {token_response_get.text[:500]}")
+            if token_response_get.status_code == 200:
+                token_response = token_response_get
+            else:
+                logger.error(f"[SSO 码交换] GET 方式也失败了")
+                raise Exception(f"获取令牌失败: {token_response.text}")
+
+        token_result = token_response.json()
+        logger.info(f"[SSO 码交换] Token 响应 JSON keys: {list(token_result.keys())}")
+        sso_access_token = token_result.get("access_token")
+        logger.info(f"[SSO 码交换] access_token 存在: {bool(sso_access_token)}")
+        if sso_access_token:
+            logger.info(f"[SSO 码交换] access_token 前20位: {sso_access_token[:20]}...")
+        if not sso_access_token:
+            raise Exception(f"无效的令牌响应: {token_result}")
+
+    # Step 2: 用 access_token 获取用户信息
+    userinfo_url = f"{sso_base}/oauth/userinfo"
+    logger.info(f"[SSO 码交换] Step 2: GET {userinfo_url}")
+    async with httpx.AsyncClient() as client:
+        userinfo_response = await client.get(
+            userinfo_url,
+            headers={"Authorization": f"Bearer {sso_access_token}"}
+        )
+        logger.info(f"[SSO 码交换] Userinfo 响应状态码: {userinfo_response.status_code}")
+        logger.info(f"[SSO 码交换] Userinfo 响应内容: {userinfo_response.text[:500]}")
+
+        if userinfo_response.status_code != 200:
+            raise Exception(f"获取用户信息失败: {userinfo_response.text}")
+
+        userinfo = userinfo_response.json()
+        logger.info(f"[SSO 码交换] Userinfo JSON: {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
+    # 角色信息(如果 SSO 返回了 roles)
+    sso_roles = userinfo.get("roles", [])
+
+    logger.info(f"[SSO 码交换] Step 3: 用户信息解析")
+    logger.info(f"[SSO 码交换] external_user_id: {external_user_id}")
+    logger.info(f"[SSO 码交换] username: {username}")
+    logger.info(f"[SSO 码交换] email: {email}")
+    logger.info(f"[SSO 码交换] real_name: {real_name}")
+    logger.info(f"[SSO 码交换] sso_roles: {sso_roles}")
+
+    # 先按 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 user:
+        logger.info(f"[SSO 码交换] 按 username 找到用户: {user.id}")
+    else:
+        logger.info(f"[SSO 码交换] 按 username 未找到用户,尝试按 email 查找: {email}")
+        # 再按 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:
+            logger.info(f"[SSO 码交换] 按 email 找到用户: {user.id}")
+        else:
+            logger.info(f"[SSO 码交换] 按 email 也未找到用户,将创建新用户")
+
+    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)
+                logger.info(f"[SSO 码交换] 分配默认角色: {default_role.code} ({default_role.id})")
+            else:
+                logger.warning(f"[SSO 码交换] 未找到默认角色 'user'")
+        except Exception as role_err:
+            logger.warning(f"[SSO 码交换] 分配默认角色失败: {role_err}")
+
+    # Step 4: 根据 SSO 返回的角色同步本地角色
+    if sso_roles:
+        try:
+            # 查询用户当前角色
+            stmt = select(UserRole).where(UserRole.user_id == user.id)
+            result = await db.execute(stmt)
+            current_user_roles = result.scalars().all()
+            current_role_ids = {ur.role_id for ur in current_user_roles}
+
+            # 查询 SSO 角色对应的本地角色
+            stmt = select(Role).where(
+                and_(Role.code.in_(sso_roles), Role.is_active == True)
+            )
+            result = await db.execute(stmt)
+            sso_local_roles = result.scalars().all()
+
+            # 添加新角色
+            for role in sso_local_roles:
+                if role.id not in current_role_ids:
+                    user_role = UserRole(user_id=user.id, role_id=role.id)
+                    db.add(user_role)
+
+            logger.info(f"SSO角色同步: user={username}, roles={sso_roles}")
+        except Exception as role_err:
+            logger.warning(f"SSO角色同步失败: {role_err}")
+
+    await db.commit()
+
+    # Step 5: 生成本地 JWT
+    logger.info(f"[SSO 码交换] Step 5: 生成本地 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})
+    logger.info(f"[SSO 码交换] 本地 access_token 前20位: {local_access_token[:20]}...")
+
+    # Step 6: 保存 token 到数据库
+    logger.info(f"[SSO 码交换] Step 6: 保存 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)
+    else:
+        expire_minutes = config_handler.get_int("admin_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30)
+        expires_at = datetime.utcnow() + timedelta(minutes=expire_minutes)
+
+    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 码交换] Token 已保存到数据库")
+
+    # Step 7: 获取用户的角色列表返回给前端
+    logger.info(f"[SSO 码交换] Step 7: 获取用户角色列表")
+    stmt = select(Role).join(UserRole, Role.id == UserRole.role_id).where(UserRole.user_id == user.id)
+    result = await db.execute(stmt)
+    roles = [r.code for r in result.scalars().all()]
+    logger.info(f"[SSO 码交换] 用户角色列表: {roles}")
+
+    logger.info(f"[SSO 码交换] 最终成功! user={user.username}, token={local_access_token[:20]}...")
+    logger.info(f"[SSO 码交换] ========== [SSO 码交换] 完成 ==========")
+
+    return {
+        "token": local_access_token,
+        "refresh_token": local_refresh_token,
+        "user": {
+            "id": user.id,
+            "username": user.username,
+            "email": user.email,
+            "phone": user.phone,
+            "is_superuser": user.is_superuser,
+            "is_active": user.is_active,
+            "roles": roles,
+        }
+    }
+
+
 @router.get("/auth/sso/authorize")
 async def sso_authorize_url(redirect: bool = False):
     """获取外部SSO授权URL(供前端使用)
@@ -77,194 +320,106 @@ async def sso_callback(
     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
-            )
+    """处理外部SSO回调(旧流程:后端 302 重定向到前端)"""
+    logger.info("========== [SSO 回调 /auth/callback] 开始 ==========")
+    logger.info(f"[SSO 回调] URL 参数: code={str(code)[:10] if code else None}, error={error}, state={state}")
+    logger.info(f"[SSO 回调] 完整 URL: {request.url}")
+    logger.info(f"[SSO 回调] query_params: {dict(request.query_params)}")
 
-        if not code:
-            logger.error("SSO回调缺少code参数")
-            return RedirectResponse(
-                url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
-                status_code=302
-            )
+    frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
 
-        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", "")
+    # 处理外部SSO返回的错误
+    if error:
+        logger.error(f"[SSO 回调] SSO返回错误: {error}, {error_description}")
+        return RedirectResponse(
+            url=f"{frontend_url}/login?error={error}&error_description={error_description or ''}",
+            status_code=302
+        )
 
-        logger.info(f"收到SSO回调, code={code[:10]}...")
+    if not code:
+        logger.error("[SSO 回调] 缺少 code 参数")
+        return RedirectResponse(
+            url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
+            status_code=302
+        )
 
-        # 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
-        }
+    try:
+        # 调用核心码交换逻辑
+        result = await _sso_exchange_code(code, db, request)
 
-        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
-                )
+        # 重定向回前端,携带本地token
+        callback_url = (
+            f"{frontend_url}/oauth/callback"
+            f"?token={result['token']}"
+            f"&refresh_token={result['refresh_token']}"
+        )
+        logger.info(f"[SSO 回调] 重定向到: {callback_url[:80]}...")
+        logger.info(f"[SSO 回调] ========== [SSO 回调] 完成 ==========")
+        return RedirectResponse(url=callback_url, 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}")
+    except Exception as e:
+        logger.exception("[SSO 回调] 核心逻辑处理异常")
+        return RedirectResponse(
+            url=f"{frontend_url}/login?error=server_error&error_description={str(e)}",
+            status_code=302
+        )
 
-            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}")
+@router.post("/api/oauth/exchange-code")
+async def sso_exchange_code_api(
+    request: Request,
+    db: AsyncSession = Depends(get_db)
+):
+    """
+    SSO 免登接口(新流程)
 
-        # 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
+    前端从统一认证平台回调获得 code 后,调用此接口换本地 JWT。
 
-        # 先按username查找
-        stmt = select(User).where(
-            and_(User.username == username, User.is_deleted == False)
-        )
-        result = await db.execute(stmt)
-        user = result.scalar_one_or_none()
+    请求体: { "code": "xxx" }
+    返回: { code: "000000", data: { token, refresh_token, user } }
+    """
+    logger.info("========== [SSO 码交换 API] 开始 ==========")
+    logger.info(f"[SSO 码交换 API] 请求方法: {request.method}")
+    logger.info(f"[SSO 码交换 API] 请求头 Content-Type: {request.headers.get('content-type', 'N/A')}")
+    logger.info(f"[SSO 码交换 API] 客户端IP: {request.client.host if request.client else 'N/A'}")
 
-        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()
+    try:
+        raw_body = await request.body()
+        logger.info(f"[SSO 码交换 API] 原始请求体: {raw_body.decode('utf-8')}")
 
-        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}")
+        body = await request.json()
+        code = body.get("code")
 
-        await db.commit()
+        logger.info(f"[SSO 码交换 API] 解析后的 body: {body}")
+        logger.info(f"[SSO 码交换 API] code 前10位: {str(code)[:10] if code else None}")
 
-        # 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()
+        if not code:
+            logger.error("[SSO 码交换 API] 请求体中缺少 code 字段")
+            return ResponseSchema(
+                code="100001",
+                message="缺少授权码",
+                data=None
+            )
 
-        logger.info(f"SSO登录成功: user={user.username}, token={local_access_token[:20]}...")
+        result = await _sso_exchange_code(code, db, request)
 
-        # Step 6: 重定向回前端,携带本地token
-        callback_url = (
-            f"{frontend_url}/oauth/callback"
-            f"?token={local_access_token}"
-            f"&refresh_token={local_refresh_token}"
+        logger.info(f"[SSO 码交换 API] 返回 data.user.username: {result['user']['username']}")
+        logger.info(f"[SSO 码交换 API] 返回 data.user.roles: {result['user']['roles']}")
+        logger.info(f"[SSO 码交换 API] ========== [SSO 码交换 API] 成功 ==========")
+
+        return ResponseSchema(
+            code="000000",
+            message="登录成功",
+            data=result
         )
-        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
+        logger.exception("[SSO 码交换 API] 异常")
+        logger.info(f"[SSO 码交换 API] 异常类型: {type(e).__name__}")
+        logger.info(f"[SSO 码交换 API] 异常信息: {str(e)}")
+        logger.info(f"[SSO 码交换 API] ========== [SSO 码交换 API] 失败 ==========")
+        return ResponseSchema(
+            code="500001",
+            message=f"登录失败: {str(e)}",
+            data=None
         )

+ 35 - 1
项目/样本中心需求.md

@@ -116,4 +116,38 @@
   home_url,不会影响其他应用的正常访问。
 
   ---
-  是否需要我进一步调整任何逻辑,或者帮你验证样本中心的 t_sys_app 配置数据?
+  是否需要我进一步调整任何逻辑,或者帮你验证样本中心的 t_sys_app 配置数据?
+
+
+
+
+
+
+### 统一认证平台 SSO 免登 优化  - 样本中心  LQAdminPlatform(后端) 、LQAdminFront(前端)  
+    - 统一认证平台 点击子应用图标 请求子应用端访问授权码接口优化
+      - 直接请求应用端后台 可能会出现网络问题,导致页面白屏效果
+      - 现在调整为请求前端地址,前端页面可以增加处理中效果,提升用户体验
+      - 把应用端接收授权码配置调整为前端应用 {redirect_url: "http://localhost:3000/auth/callback?code=xxx"}
+      - 应用前端拿到授权码后,再请求应用后端接口把授权码提交给统一认证平台获取token
+      - 成果获取token后,就可以获取用户信息了
+      - 最后再调整到应用首页
+      - 如果过程中处理异常,则直接在应用前端页面提示错误信息
+ 
+
+
+### 统一认证平台 SSO 免登 优化  - 样本中心  LQAdminPlatform(后端) 、LQAdminFront(前端) 
+        ● 完成。新的 SSO 免登流程:
+
+        统一认证平台 → 点击子应用图标
+            ↓
+        302 跳转到 http://localhost:3000/auth/callback?code=xxx (前端)
+            ↓
+        前端页面显示 "正在登录..." loading 效果
+            ↓按
+        前端 POST /api/oauth/exchange-code { code: "xxx" }
+            ↓
+        后端:code 换 SSO token → 获取用户信息+角色 → 同步本地DB → 签发本地 JWT
+            ↓
+        返回 { token, refresh_token, user }
+            ↓
+        前端保存 token 到 localStorage → 跳转到首页 /