《别再被 SSO 骗了!前端单点登录原理+避坑指南》

作者:你的代码僚机(一个在屎山里挖宝藏的前端)

开篇暴击:当老板说"所有系统统一登录"

"我们有 10 个业务系统,每个都要单独登录,用户天天骂!明天上线 SSO 统一登录!"

听到这句话的那一刻,我的手在抖,腿在颤,内心只有一个念头: "这 SSO 到底是 Single Sign On 还是 Single Sign Of Death?" 💀

作为一个在 SSO 项目里反复横跳三年的老前端,今天我就用最直白的语言,把单点登录的原理、实战代码和避坑指南全掏出来!不讲废话,直接上干货!


一、原理篇:SSO 到底是个啥?

1.1 传统登录 vs SSO 登录

传统登录(一把梭)

复制代码
用户登录 → 生成 Session → 存 Cookie → 下次请求带 Cookie → 验证 Session

问题来了:用户访问系统 A 登录了,访问系统 B 还要登录!用户:???老板:开除你!

SSO 登录(单点登录)

css 复制代码
用户访问系统 A → 重定向到 SSO 服务器 → 登录成功 → 生成令牌 → 重定向回系统 A → 系统 A 验证令牌 → 登录成功
用户访问系统 B → 重定向到 SSO 服务器 → 检测已登录 → 直接返回系统 B → 登录成功

核心思想一处登录,处处通行! 🚪

1.2 OAuth2.0 四种模式,前端用哪种?

OAuth2.0 有四种授权模式,我们前端最常用的是:

模式 全称 适用场景 前端用吗?
授权码模式 Authorization Code Web 应用(最安全) 推荐
隐式模式 Implicit 旧版单页应用 ❌ 已废弃
密码模式 Resource Owner Password Credentials 信任的内部系统 ⚠️ 谨慎使用
客户端模式 Client Credentials 机器对机器 ❌ 不适用

结论 :我们用 授权码模式!为什么?因为它支持令牌刷新,安全性高,而且符合我们的需求!

1.3 JWT:SSO 的灵魂伴侣

JWT(JSON Web Token)是 SSO 的核心组件,它由三部分组成:

css 复制代码
Header.Payload.Signature
  • Header :声明类型和加密算法(如 {"alg": "HS256", "typ": "JWT"}
  • Payload :承载用户信息(如 {"sub": "1234567890", "name": "John Doe", "exp": 1516239022}
  • Signature :签名,防止令牌被篡改(HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret))`

重点提醒 :JWT 的 Payload 是明文传输的!不要放密码、身份证号等敏感信息!敏感信息要用加密方式存储在服务端!

1.4 完整流程图解

css 复制代码
系统 BSSO 服务器系统 A用户系统 BSSO 服务器系统 A用户场景 1:首次访问系统 A场景 2:访问系统 B(已登录)1. 访问系统 A2. 未登录,重定向到 SSO 登录页3. 显示登录表单4. 输入账号密码5. 验证成功,生成授权码6. 重定向到系统 A 的回调地址(携带授权码)7. 用授权码换令牌8. 返回访问令牌9. 保存令牌10. 登录成功,显示首页11. 访问系统 B12. 检测未登录,重定向到 SSO13. 检测已登录(有 SSO 会话)14. 直接重定向到系统 B 的回调地址15. 用授权码换令牌16. 返回访问令牌17. 保存令牌18. 登录成功,显示首页

关键点

  1. 用户只需要在 SSO 服务器登录一次
  2. 每个系统通过"授权码"换"访问令牌",而不是直接传密码
  3. 令牌有过期时间,支持刷新机制

二、实战篇:手把手搭建 SSO 系统

2.1 环境准备

我们需要三个服务:

  1. SSO 服务器http://sso.local:3000(模拟授权服务器)
  2. 系统 Ahttp://system-a.local:3001(业务系统 A)
  3. 系统 Bhttp://system-b.local:3002(业务系统 B)

技术栈

  • SSO 服务器:Node.js + Express + JWT
  • 系统 A/B:Vue 3 + TypeScript + Axios

2.2 SSO 服务器实现

### 复制代码
// sso-server/index.js
const express = require('express');
const jwt = require('jsonwebtoken');
const cors = require('cors');
​
const app = express();
app.use(cors());
app.use(express.json());
​
// 模拟用户数据库
const users = [
  { id: '1', username: 'admin', password: '123456' },
  { id: '2', username: 'user', password: 'password' }
];
​
// 模拟授权码存储(实际应该存数据库)
const authorizationCodes = new Map();
​
// 模拟访问令牌存储
const accessTokens = new Map();
​
// 配置
const JWT_SECRET = 'your-secret-key-change-this-in-production';
const JWT_EXPIRES_IN = '2h'; // 令牌 2 小时过期
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // 刷新令牌 7 天过期
​
// 生成访问令牌
function generateAccessToken(userId) {
  return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
​
// 生成刷新令牌
function generateRefreshToken(userId) {
  return jwt.sign({ userId }, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
}
​
// 登录接口
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  
  const user = users.find(u => u.username === username && u.password === password);
  if (!user) {
    return res.status(401).json({ error: '用户名或密码错误' });
  }
​
  const accessToken = generateAccessToken(user.id);
  const refreshToken = generateRefreshToken(user.id);
​
  accessTokens.set(accessToken, {
    userId: user.id,
    createdAt: Date.now()
  });
​
  res.json({
    accessToken,
    refreshToken,
    expiresIn: JWT_EXPIRES_IN
  });
});
​
// 授权码生成接口(模拟)
app.get('/authorize', (req, res) => {
  const { clientId, redirectUri, state } = req.query;
​
  // 校验参数
  if (!clientId || !redirectUri || !state) {
    return res.status(400).json({ error: '缺少必要参数' });
  }
​
  // 生成授权码
  const code = Math.random().toString(36).substring(2, 15);
  authorizationCodes.set(code, {
    clientId,
    redirectUri,
    state,
    createdAt: Date.now()
  });
​
  // 重定向到回调地址(携带授权码)
  res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});
​
// 用授权码换令牌接口
app.post('/token', (req, res) => {
  const { code, clientId, clientSecret, redirectUri } = req.body;
​
  const authCode = authorizationCodes.get(code);
  if (!authCode) {
    return res.status(400).json({ error: '授权码无效' });
  }
​
  // 校验 clientId 和 redirectUri
  if (authCode.clientId !== clientId || authCode.redirectUri !== redirectUri) {
    return res.status(400).json({ error: '参数校验失败' });
  }
​
  // 生成新的访问令牌和刷新令牌
  const userId = '1'; // 模拟用户 ID
  const accessToken = generateAccessToken(userId);
  const refreshToken = generateRefreshToken(userId);
​
  accessTokens.set(accessToken, {
    userId,
    createdAt: Date.now()
  });
​
  // 删除已使用的授权码
  authorizationCodes.delete(code);
​
  res.json({
    accessToken,
    refreshToken,
    expiresIn: JWT_EXPIRES_IN,
    tokenType: 'Bearer'
  });
});
​
// 刷新令牌接口
app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;
​
  try {
    const decoded = jwt.verify(refreshToken, JWT_SECRET);
    const newAccessToken = generateAccessToken(decoded.userId);
    
    res.json({
      accessToken: newAccessToken,
      expiresIn: JWT_EXPIRES_IN,
      tokenType: 'Bearer'
    });
  } catch (error) {
    res.status(401).json({ error: '刷新令牌无效' });
  }
});
​
// 获取用户信息接口
app.get('/userinfo', (req, res) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: '缺少访问令牌' });
  }
​
  const accessToken = authHeader.substring(7);
  const tokenInfo = accessTokens.get(accessToken);
​
  if (!tokenInfo) {
    return res.status(401).json({ error: '访问令牌无效' });
  }
​
  const user = users.find(u => u.id === tokenInfo.userId);
  res.json({
    id: user.id,
    username: user.username
  });
});
​
app.listen(3000, () => {
  console.log('SSO 服务器运行在 http://localhost:3000');
});

2.3 系统 A 实现(Vue 3 + TypeScript)

javascript 复制代码
// src/utils/auth.ts
import axios from 'axios';
​
const SSO_SERVER = 'http://sso.local:3000';
const CLIENT_ID = 'system-a';
const REDIRECT_URI = 'http://system-a.local:3001/callback';
​
// 存储令牌
let accessToken: string | null = null;
let refreshToken: string | null = null;
let tokenExpiresAt: number = 0;
​
// 初始化
export function initAuth() {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
​
  if (code) {
    // 有授权码,用授权码换令牌
    exchangeCodeForToken(code);
  } else {
    // 检查是否已登录
    checkLoginStatus();
  }
}
​
// 用授权码换令牌
async function exchangeCodeForToken(code: string) {
  try {
    const response = await axios.post(`${SSO_SERVER}/token`, {
      code,
      clientId: CLIENT_ID,
      clientSecret: 'your-client-secret', // 实际应该用更安全的方式存储
      redirectUri: REDIRECT_URI
    });
​
    const { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn } = response.data;
​
    // 保存令牌
    setTokens(newAccessToken, newRefreshToken, expiresIn);
​
    // 清除 URL 参数
    window.history.replaceState({}, document.title, window.location.pathname);
​
    // 跳转到首页
    window.location.href = '/';
  } catch (error) {
    console.error('交换令牌失败:', error);
    // 跳转到登录页
    login();
  }
}
​
// 设置令牌
function setTokens(accessToken: string, refreshToken: string, expiresIn: string) {
  accessToken = accessToken;
  refreshToken = refreshToken;
​
  // 解析过期时间
  const [value, unit] = expiresIn.match(/(\d+)([a-z]+)/i) || [2, 'h'];
  const expiresInSeconds = unit === 'm' ? parseInt(value) * 60 : parseInt(value) * 3600;
  tokenExpiresAt = Date.now() + expiresInSeconds;
​
  // 保存到 localStorage(实际应该用更安全的方式)
  localStorage.setItem('access_token', accessToken);
  localStorage.setItem('refresh_token', refreshToken);
  localStorage.setItem('token_expires_at', tokenExpiresAt.toString());
}
​
// 检查登录状态
function checkLoginStatus() {
  const storedAccessToken = localStorage.getItem('access_token');
  const storedRefreshToken = localStorage.getItem('refresh_token');
  const storedExpiresAt = localStorage.getItem('token_expires_at');
​
  if (storedAccessToken && storedRefreshToken && storedExpiresAt) {
    accessToken = storedAccessToken;
    refreshToken = storedRefreshToken;
    tokenExpiresAt = parseInt(storedExpiresAt);
​
    // 检查令牌是否过期
    if (Date.now() >= tokenExpiresAt) {
      // 令牌已过期,尝试刷新
      refreshAccessToken();
    } else {
      // 令牌有效,获取用户信息
      getUserInfo();
    }
  } else {
    // 未登录,跳转到 SSO 登录页
    login();
  }
}
​
// 刷新访问令牌
async function refreshAccessToken() {
  try {
    const response = await axios.post(`${SSO_SERVER}/refresh`, {
      refreshToken
    });
​
    const { accessToken: newAccessToken, expiresIn } = response.data;
​
    // 更新令牌
    setTokens(newAccessToken, refreshToken, expiresIn);
​
    // 重新获取用户信息
    getUserInfo();
  } catch (error) {
    console.error('刷新令牌失败:', error);
    // 令牌刷新失败,跳转到登录页
    login();
  }
}
​
// 获取用户信息
async function getUserInfo() {
  try {
    const response = await axios.get(`${SSO_SERVER}/userinfo`, {
      headers: {
        Authorization: `Bearer ${accessToken}`
      }
    });
​
    console.log('用户信息:', response.data);
    // 保存用户信息到状态管理(如 Pinia)
    // store.commit('setUser', response.data);
​
    // 用户已登录,继续访问系统
  } catch (error) {
    console.error('获取用户信息失败:', error);
    // 跳转到登录页
    login();
  }
}
​
// 跳转到 SSO 登录页
export function login() {
  // 生成随机 state 参数(防止 CSRF 攻击)
  const state = Math.random().toString(36).substring(2, 15);
​
  // 保存 state 到 sessionStorage
  sessionStorage.setItem('sso_state', state);
​
  // 重定向到 SSO 登录页
  const loginUrl = `${SSO_SERVER}/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${state}&response_type=code`;
  window.location.href = loginUrl;
}
​
// 退出登录
export function logout() {
  // 清除本地令牌
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  localStorage.removeItem('token_expires_at');
​
  // 清除状态管理
  // store.commit('setUser', null);
​
  // 跳转到 SSO 退出页(可选)
  // window.location.href = `${SSO_SERVER}/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`;
​
  // 跳转到登录页
  window.location.href = '/';
}

2.4 系统 B 实现(与系统 A 类似)

系统 B 的实现与系统 A 几乎完全相同,只需要修改以下配置:

ini 复制代码
// src/utils/auth.ts
const CLIENT_ID = 'system-b'; // 修改为 system-b
const REDIRECT_URI = 'http://system-b.local:3002/callback'; // 修改为 system-b 的回调地址

三、避坑篇:SSO 的 10 大坑,踩过才知道有多痛

坑 1:JWT 过期后静默刷新的坑

问题描述: 用户正在使用系统,突然令牌过期,页面白屏,用户:???

错误做法

scss 复制代码
// ❌ 错误:直接跳转登录页
if (Date.now() >= tokenExpiresAt) {
  login(); // 用户正在编辑文档,突然跳转登录页,文档丢失!
}

正确做法

scss 复制代码
// ✅ 正确:静默刷新令牌
if (Date.now() >= tokenExpiresAt - 5 * 60 * 1000) { // 提前 5 分钟刷新
  await refreshAccessToken(); // 静默刷新
  // 继续用户操作
}

原理

  • JWT 过期前,提前 5 分钟用刷新令牌换新令牌
  • 用户无感知,操作不中断
  • 刷新令牌过期后,才跳转登录页

坑 2:回调 URL 白名单校验缺失

问题描述 : 攻击者构造恶意回调地址:http://evil.com?code=xxx,用户点击后,攻击者可以获取授权码!

错误做法

javascript 复制代码
// ❌ 错误:不校验 redirect_uri
app.get('/authorize', (req, res) => {
  const { clientId, redirectUri, state } = req.query;
  // 直接生成授权码,不校验 redirect_uri 是否在白名单
  const code = Math.random().toString(36).substring(2, 15);
  res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});

正确做法

javascript 复制代码
// ✅ 正确:校验 redirect_uri 白名单
const ALLOWED_REDIRECT_URIS = [
  'http://system-a.local:3001/callback',
  'http://system-b.local:3002/callback'
];

app.get('/authorize', (req, res) => {
  const { clientId, redirectUri, state } = req.query;

  if (!ALLOWED_REDIRECT_URIS.includes(redirectUri)) {
    return res.status(400).json({ error: '回调地址不在白名单内' });
  }

  // 生成授权码
  const code = Math.random().toString(36).substring(2, 15);
  res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});

问题描述: SSO 服务器和业务系统域名不同,Cookie 无法携带,导致无法维持登录状态!

解决方案

方案 1:JSONP + PostMessage(不推荐)

arduino 复制代码
// ❌ 不推荐:JSONP 有安全风险

方案 2:CORS + withCredentials(推荐)

php 复制代码
// SSO 服务器
app.use(cors({
  origin: ['http://system-a.local:3001', 'http://system-b.local:3002'],
  credentials: true
}));

// 业务系统
axios.defaults.withCredentials = true;

方案 3:Token 传递(最推荐)

arduino 复制代码
// 令牌通过 URL 参数或 localStorage 传递,不依赖 Cookie

坑 4:state 参数未校验导致 CSRF 攻击

问题描述 : 攻击者诱导用户访问:http://sso.local:3000/authorize?client_id=xxx&redirect_uri=http://attacker.com,用户登录后,攻击者可以获取授权码!

错误做法

ini 复制代码
// ❌ 错误:不校验 state 参数
const code = authorizationCodes.get(code);
if (!code) {
  return res.status(400).json({ error: '授权码无效' });
}
// 直接重定向,不校验 state
res.redirect(`${code.redirectUri}?code=${code}&state=${state}`);

正确做法

javascript 复制代码
// ✅ 正确:校验 state 参数
const code = authorizationCodes.get(code);
if (!code) {
  return res.status(400).json({ error: '授权码无效' });
}

// 校验 state
const storedState = sessionStorage.getItem('sso_state');
if (code.state !== storedState) {
  return res.status(400).json({ error: 'state 参数校验失败' });
}

// 删除已使用的 state
sessionStorage.removeItem('sso_state');

// 重定向
res.redirect(`${code.redirectUri}?code=${code}&state=${code.state}`);

坑 5:令牌存储在 localStorage 的安全风险

问题描述: localStorage 可以被 XSS 攻击读取,导致令牌泄露!

解决方案

方案 1:HttpOnly Cookie(最安全)

ini 复制代码
// 服务端设置 HttpOnly Cookie
res.setHeader('Set-Cookie', `access_token=${accessToken}; HttpOnly; Secure; SameSite=Strict`);

方案 2:内存存储 + 刷新页面丢失(折中方案)

csharp 复制代码
// 令牌存储在内存变量中,刷新页面后需要重新登录
let accessToken: string | null = null;

方案 3:加密存储(次安全)

javascript 复制代码
// 使用加密库(如 crypto-js)加密存储
import CryptoJS from 'crypto-js';

const encrypted = CryptoJS.AES.encrypt(accessToken, 'secret-key').toString();
localStorage.setItem('access_token', encrypted);

坑 6:刷新令牌被重复使用

问题描述: 攻击者截获刷新令牌,可以无限期地刷新访问令牌,导致长期控制用户账户!

错误做法

ini 复制代码
// ❌ 错误:刷新令牌可以重复使用
app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;
  const decoded = jwt.verify(refreshToken, JWT_SECRET);
  const newAccessToken = generateAccessToken(decoded.userId);
  res.json({ accessToken: newAccessToken });
});

正确做法

php 复制代码
// ✅ 正确:刷新令牌一次性使用
const refreshTokens = new Map();

app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;
  
  // 检查刷新令牌是否已使用
  if (refreshTokens.has(refreshToken)) {
    return res.status(401).json({ error: '刷新令牌已被使用' });
  }

  try {
    const decoded = jwt.verify(refreshToken, JWT_SECRET);
    const newAccessToken = generateAccessToken(decoded.userId);
    
    // 标记刷新令牌已使用
    refreshTokens.set(refreshToken, true);
    
    // 生成新的刷新令牌
    const newRefreshToken = generateRefreshToken(decoded.userId);
    
    res.json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken,
      expiresIn: JWT_EXPIRES_IN
    });
  } catch (error) {
    res.status(401).json({ error: '刷新令牌无效' });
  }
});

坑 7:SSO 会话与系统会话不同步

问题描述: 用户在系统 A 退出登录,但在系统 B 仍然显示登录状态!

错误做法

javascript 复制代码
// ❌ 错误:只清除本地会话
app.post('/logout', (req, res) => {
  localStorage.removeItem('access_token');
  res.json({ success: true });
});

正确做法

javascript 复制代码
// ✅ 正确:同步清除 SSO 会话和本地会话
app.post('/logout', async (req, res) => {
  // 清除本地令牌
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  
  // 通知 SSO 服务器清除会话
  await axios.post(`${SSO_SERVER}/logout`, {
    accessToken
  });
  
  res.json({ success: true });
});

坑 8:令牌刷新失败后的用户体验差

问题描述: 令牌刷新失败后,直接跳转登录页,用户正在编辑的内容丢失!

错误做法

javascript 复制代码
// ❌ 错误:刷新失败直接跳转登录页
async function refreshAccessToken() {
  try {
    const response = await axios.post(`${SSO_SERVER}/refresh`, { refreshToken });
    setTokens(response.data.accessToken);
  } catch (error) {
    login(); // 用户正在编辑文档,突然跳转登录页!
  }
}

正确做法

scss 复制代码
// ✅ 正确:刷新失败后提示用户并保存内容
async function refreshAccessToken() {
  try {
    const response = await axios.post(`${SSO_SERVER}/refresh`, { refreshToken });
    setTokens(response.data.accessToken);
  } catch (error) {
    // 保存用户当前操作
    saveDraft();
    
    // 提示用户重新登录
    ElMessage.warning('登录已过期,请重新登录');
    
    // 延迟跳转,让用户有机会保存内容
    setTimeout(() => {
      login();
    }, 3000);
  }
}

坑 9:并发请求导致多次刷新令牌

问题描述: 多个请求同时发现令牌过期,导致多次刷新令牌,SSO 服务器压力大!

错误做法

javascript 复制代码
// ❌ 错误:每个请求都独立刷新令牌
async function refreshTokenIfExpired() {
  if (Date.now() >= tokenExpiresAt) {
    await refreshAccessToken(); // 多个请求同时执行
  }
}

正确做法

ini 复制代码
// ✅ 正确:使用锁机制避免并发刷新
let isRefreshing = false;
let refreshPromise: Promise<void> | null = null;

async function refreshTokenIfExpired() {
  if (Date.now() >= tokenExpiresAt) {
    if (!isRefreshing) {
      isRefreshing = true;
      refreshPromise = refreshAccessToken().finally(() => {
        isRefreshing = false;
      });
    }
    await refreshPromise;
  }
}

坑 10:SSO 服务器单点故障

问题描述: SSO 服务器宕机,所有业务系统都无法登录!

解决方案

方案 1:SSO 服务器集群 + 负载均衡

复制代码
用户 → 负载均衡 → SSO 服务器集群(多台)

方案 2:本地缓存 + 离线登录

ini 复制代码
// 本地缓存最近登录的用户信息
const cachedUser = localStorage.getItem('cached_user');
if (cachedUser && Date.now() - cachedUser.timestamp < 24 * 60 * 60 * 1000) {
  // 允许离线登录
  user = JSON.parse(cachedUser.data);
}

方案 3:降级方案

scss 复制代码
// SSO 服务器不可用时,使用本地认证
try {
  await ssoLogin();
} catch (error) {
  // 降级到本地认证
  await localLogin(username, password);
}

四、总结

SSO 不是银弹,但用好了是神器!记住这几点:

  1. 安全第一:校验 redirect_uri、state 参数,使用 HttpOnly Cookie
  2. 用户体验:静默刷新令牌,避免用户操作中断
  3. 防御性编程:所有接口都要校验参数,不要信任客户端传来的数据
  4. 监控告警:监控令牌刷新失败、授权码重复使用等异常情况

最后送大家一句话

"代码可以重写,但用户流失无法挽回。"

SSO 是用户体验的放大器,用好了用户赞你,用不好用户骂你!所以,别再被 SSO 骗了,把它玩明白!


相关推荐
不懂代码的切图仔2 小时前
移动端h5实现横屏在线签名
前端·微信小程序
少卿2 小时前
OpenClaw 的 summarize 技能——开发者的智能摘要利器
前端·后端·程序员
麦秋2 小时前
前端静态页面自动生成(Figma MCP + VS code + Github copilot)
前端·vue.js
不甜情歌2 小时前
JS对象入门|从创建到原理,一篇吃透核心知识点
前端·javascript
DongHao2 小时前
我不想一开始就把 Axios 封装的太完美
前端·http·axios
有一个好名字3 小时前
claude code安装
linux·运维·前端
BIBABULALA3 小时前
Mini Virtual Machine — 可视化虚拟机模拟器(91行)
前端·css·css3
筱璦3 小时前
期货软件开发「启动加载页 / 初始化窗口」
前端·c#·策略模式·期货
只与明月听3 小时前
RAG深入学习之Emabedding
前端·python·面试