前端身份识别与灰度发布完整指南

A Complete Guide to User Identity and Gray Release in Web Applications


📚 目录

  1. 前置知识:浏览器请求的生命周期
  2. 身份识别三剑客:URL、Cookie、Token
  3. [首次访问 vs 后续访问](#首次访问 vs 后续访问)
  4. [静态资源 vs 动态接口请求](#静态资源 vs 动态接口请求)
  5. 跨域场景下的身份传递
  6. 跨设备登录与账号同步
  7. 单点登录(SSO)原理
  8. 灰度发布:用户分流与版本管理
  9. 回滚机制与前后端交互
  10. 完整实战案例

1. 前置知识:浏览器请求的生命周期

1.1 从输入网址到页面显示

想象你在浏览器地址栏输入 https://www.myshop.com,会发生什么?

复制代码
Step 1: 浏览器发送 HTTP 请求 (请求 HTML)
   ↓
Step 2: 服务器返回 HTML 文档
   ↓
Step 3: 浏览器解析 HTML,发现需要 CSS、JS、图片等资源
   ↓
Step 4: 浏览器再次发送多个请求,获取这些资源
   ↓
Step 5: 浏览器执行 JS 代码
   ↓
Step 6: JS 可能发起 API 请求(如获取用户信息)

关键点

  • Step 1 和 Step 2 :此时 JS 还没执行 ,浏览器拿不到 localStorage 的数据
  • Step 4:静态资源请求(CSS、JS、图片)也是在 JS 执行之前
  • Step 6 :只有到这一步,JS 才能手动读取 localStorage 并发送 API 请求

1.2 核心认知

💡 浏览器请求 HTML 和静态资源时,JS 还没有执行,所以无法访问 localStoragesessionStorage 或 JS 变量。

这就是为什么我们需要 CookieURL 参数 来在初始阶段传递信息。


2. 身份识别三剑客:URL、Cookie、Token

2.1 三者对比

特性 URL 参数 Cookie Token (LocalStorage)
自动发送 ❌ 需要手动拼接 ✅ 浏览器自动携带 ❌ 需要 JS 手动添加到请求头
HTML 请求可用
静态资源请求可用
API 请求可用
跨域传递 ✅ (在 URL 中) ❌ (默认不跨域) ✅ (JS 手动设置)
安全性 ⚠️ 低 (URL 可见) ✅ 可设置 HttpOnly ⚠️ 易受 XSS 攻击
生命周期 一次性 可设置过期时间 永久 (除非清除)

2.2 三者的角色定位

复制代码
┌─────────────┬──────────────┬─────────────────────────┐
│   技术      │    角色      │        比喻             │
├─────────────┼──────────────┼─────────────────────────┤
│ URL 参数    │  引导者      │  介绍信、秘密口令        │
│ Cookie      │  维持者      │  会员手环、门禁卡        │
│ Token       │  认证者      │  身份证、护照           │
└─────────────┴──────────────┴─────────────────────────┘

应用场景

  • URL 参数:首次访问引导、灰度版本指定、分享链接
  • Cookie:会话保持、自动登录、版本标记
  • Token:API 认证、跨域请求、服务间通信

3. 首次访问 vs 后续访问

3.1 第一次访问(匿名用户)

场景 :用户首次访问 https://www.myshop.com

javascript 复制代码
// 浏览器请求
GET https://www.myshop.com
Headers:
  (无 Cookie)

服务器能拿到什么?

  • ✅ IP 地址
  • ✅ User-Agent
  • ✅ Referer (如果有)
  • ❌ Cookie (首次访问没有)
  • ❌ 用户 ID (还没登录)
  • ❌ Token (JS 还没执行)

服务器如何处理?

  1. 按比例或规则分配版本(如 10% 灰度)
  2. 在响应中写入 Cookie 标记用户
javascript 复制代码
// 服务器响应
HTTP/1.1 200 OK
Set-Cookie: user_version=1.0.5; Path=/; Max-Age=86400
Set-Cookie: user_id=guest_abc123; Path=/; Max-Age=86400
Content-Type: text/html

<html>...</html>

3.2 第二次访问(已有 Cookie)

场景:用户第二天再次访问

javascript 复制代码
// 浏览器请求
GET https://www.myshop.com
Headers:
  Cookie: user_version=1.0.5; user_id=guest_abc123

服务器能拿到什么?

  • ✅ Cookie 中的 user_versionuser_id
  • ✅ 可以根据 Cookie 确定用户版本,保持一致性

关键差异

复制代码
第一次:没有 Cookie → 按规则分配 → 写入 Cookie
第二次:有 Cookie → 直接读取 → 保持版本一致

3.3 URL 参数指定版本

场景 :测试人员访问 https://www.myshop.com?version=canary

javascript 复制代码
// 服务器处理优先级
function getVersion(req) {
  // 1. 优先:URL 参数(方便测试)
  if (req.query.version) return req.query.version;
  
  // 2. 其次:Cookie(保持一致性)
  if (req.cookies.user_version) return req.cookies.user_version;
  
  // 3. 最后:比例分配(新用户)
  return randomProportionVersion();
}

4. 静态资源 vs 动态接口请求

4.1 静态资源请求(JS、CSS、图片)

特点

  • 通过 <script src="...">, <link href="...">, <img src="..."> 标签加载
  • 浏览器 只会自动携带 Cookie
  • 无法携带 LocalStorage 中的 Token
html 复制代码
<!-- HTML 中的静态资源请求 -->
<script src="/assets/app.js"></script>
<link rel="stylesheet" href="/assets/style.css">
<img src="/assets/logo.png">
复制代码
浏览器请求:
GET /assets/app.js
Headers:
  Cookie: user_version=1.0.5  ← 自动携带
  (无 Authorization header)   ← 无法携带 Token

鉴权方式

  • ✅ Cookie
  • ✅ Referer 检查
  • ✅ Origin / Host 检查
  • ✅ 签名 URL
  • ❌ Token (无法自动携带)

4.2 动态接口请求(API)

特点

  • 通过 fetch()XMLHttpRequest 发起
  • JS 可以 手动添加任何请求头
javascript 复制代码
// JS 中的 API 请求
const token = localStorage.getItem('token');

fetch('/api/user/profile', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${token}`,  // 手动添加 Token
    'Content-Type': 'application/json'
  },
  credentials: 'include'  // 同时携带 Cookie
});
复制代码
浏览器请求:
GET /api/user/profile
Headers:
  Cookie: user_version=1.0.5           ← 自动携带
  Authorization: Bearer eyJhbGc...    ← JS 手动添加

4.3 对照表

请求类型 HTML 入口 静态资源 (JS/CSS/图片) API 接口
JS 是否执行
自动携带 Cookie
可携带 Token
可用鉴权方式 Cookie, URL Cookie, Referer Cookie, Token

5. 跨域场景下的身份传递

5.1 同域请求(最简单)

场景 :前端 https://www.myshop.com 请求 https://www.myshop.com/api/user

javascript 复制代码
fetch('https://www.myshop.com/api/user');
// Cookie 自动发送,无需任何配置

5.2 跨域请求的基础知识:简单请求 vs 预检请求

跨域请求分为两种类型:简单请求(Simple Request)预检请求(Preflight Request)

简单请求(Simple Request)

简单请求是浏览器可以直接发送的跨域请求,不会触发 OPTIONS 预检

简单请求的条件(同时满足):

  1. 请求方法GETPOSTHEAD 之一
  2. 请求头 :只能是以下字段或浏览器自动添加的字段
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(且值只能是以下之一)
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
  3. 无自定义请求头(除了上述允许的)
  4. 无事件监听器(XMLHttpRequestUpload 对象)

示例:简单请求

javascript 复制代码
// ✅ 简单请求:GET 方法,无自定义头
fetch('https://api.myshop.com/user');

// ✅ 简单请求:POST + application/x-www-form-urlencoded
fetch('https://api.myshop.com/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: 'name=John&age=30'
});

// ❌ 不是简单请求:自定义头 Authorization
fetch('https://api.myshop.com/user', {
  headers: {
    'Authorization': 'Bearer token123'  // 触发预检
  }
});

// ❌ 不是简单请求:Content-Type 为 application/json
fetch('https://api.myshop.com/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // 触发预检
  },
  body: JSON.stringify({ name: 'John' })
});
预检请求(Preflight Request)

预检请求是浏览器在发送实际请求前,先发送一个 OPTIONS 请求来询问服务器是否允许跨域。

触发预检请求的情况

  1. 请求方法PUTDELETEPATCH
  2. 自定义请求头 :包含 AuthorizationX-Custom-Header
  3. Content-Typeapplication/jsonapplication/xml
  4. 其他非简单请求的特征

预检请求流程

复制代码
1. 浏览器发送 OPTIONS 预检请求
   ↓
2. 服务器返回 CORS 响应头
   ↓
3. 浏览器检查响应头,判断是否允许
   ↓
4. 允许 → 发送实际请求
   不允许 → 报错(CORS policy)

示例:预检请求

javascript 复制代码
// 实际请求(会触发预检)
fetch('https://api.myshop.com/user', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123',
    'X-Custom-Header': 'value'
  },
  body: JSON.stringify({ name: 'John' })
});

浏览器自动发送的 OPTIONS 请求

http 复制代码
OPTIONS /user HTTP/1.1
Host: api.myshop.com
Origin: https://www.myshop.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,authorization,x-custom-header

服务器必须响应的头

http 复制代码
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.myshop.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Max-Age: 86400  // 预检结果缓存时间(秒)

后端配置示例(Express + CORS)

javascript 复制代码
// 自动处理预检请求
app.use(cors({
  origin: 'https://www.myshop.com',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header'],
  maxAge: 86400  // 预检结果缓存 24 小时
}));
简单请求 vs 预检请求对比
特性 简单请求 预检请求
请求次数 1 次 2 次(OPTIONS + 实际请求)
性能 ✅ 更快 ⚠️ 稍慢(多一次往返)
适用场景 GET、POST 表单提交 PUT、DELETE、JSON 提交、自定义头
浏览器行为 直接发送 先发 OPTIONS 询问

最佳实践

  • 🎯 优先使用简单请求 :如果是 GET 或表单提交,尽量使用 application/x-www-form-urlencoded
  • 📝 需要 JSON 时 :接受预检请求的开销,使用 application/json
  • 优化预检请求 :设置合理的 Access-Control-Max-Age,减少预检频率

5.3 跨域请求 Cookie(需要配置)

场景 :前端 https://www.myshop.com 请求 https://api.myshop.com/user

javascript 复制代码
// 前端配置
fetch('https://api.myshop.com/user', {
  credentials: 'include'  // 允许跨域携带 Cookie
});

// 后端配置(必须)
app.use(cors({
  origin: 'https://www.myshop.com',  // 允许的源
  credentials: true  // 允许携带凭证
}));

注意

  • ⚠️ 跨域携带 Cookie 时,后端不能设置 Access-Control-Allow-Origin: *
  • ✅ 必须指定具体的 Origin
  • 💡 跨域 Cookie 请求可能是简单请求,也可能触发预检请求(取决于请求方式)

5.4 跨域请求 Token(更灵活)

场景 :前端 https://www.myshop.com 请求 https://another-service.com/api/data

javascript 复制代码
const token = localStorage.getItem('token');

fetch('https://another-service.com/api/data', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${token}`  // Token 可以跨域传递
  }
});

// 后端只需验证 Token,无需特殊 CORS 配置

Token 的优势

  • ✅ 可以在多个服务之间传递
  • ✅ 不受同源策略限制
  • ✅ 服务器之间也可以传递

5.5 服务器之间调用

场景:A 服务器需要调用 B 服务器

javascript 复制代码
// ❌ 错误:浏览器的 Cookie 不会自动传递给 B 服务器
// A 服务器
app.get('/proxy', (req, res) => {
  // req.cookies 是浏览器发给 A 的
  // 但 A 调用 B 时,这些 Cookie 不会自动发送
  axios.get('http://b-server.com/api/data');
});

// ✅ 正确:手动传递 Token 或使用内部密钥
app.get('/proxy', async (req, res) => {
  const token = req.cookies.user_token;  // 从浏览器 Cookie 获取
  
  const response = await axios.get('http://b-server.com/api/data', {
    headers: {
      'Authorization': `Bearer ${token}`  // 手动添加到请求头
    }
  });
  
  res.json(response.data);
});

核心原则

Cookie 是浏览器和服务器之间的约定,服务器之间通信必须手动传递 Token 或密钥。


6. 跨设备登录与账号同步

6.1 问题场景

用户在 A 设备 (手机)已登录游客账号,现在想在 B 设备(电脑)继续使用,但不想输入账号密码。

6.2 方案一:二维码同步(推荐)

流程图
复制代码
┌─────────────┐                    ┌─────────────┐
│  A 设备      │                    │  B 设备      │
│  (已登录)    │                    │  (新设备)    │
└──────┬──────┘                    └──────┬──────┘
       │                                  │
       │ 1. 点击"同步到新设备"              │
       ├─────────────────────────────────►│
       │                                  │
       │ 2. 生成一次性授权码                │
       │    auth_code = XYZ789            │
       │    (有效期 5 分钟)                │
       │                                  │
       │ 3. 生成二维码显示                 │
       │    [QR Code: XYZ789]             │
       │                                  │
       │◄─────────────────────────────────┤ 4. 扫描二维码
       │                                  │    获取 XYZ789
       │                                  │
       │                                  │ 5. 请求同步
       │                                  │    POST /api/account/sync
       │                                  │    { code: "XYZ789" }
       │                                  │
       │ 6. 后端验证                       │
       │    ✓ 授权码有效                   │
       │    ✓ 未过期                      │
       │    ✓ 查找对应账号                 │
       │                                  │
       │                                  │◄─ 7. 返回 Token
       │                                  │    { token: "..." }
       │                                  │
       │                                  │ 8. 登录成功 ✓
       └──────────────────────────────────┘
后端实现
javascript 复制代码
// 数据库表:sync_codes
// | id | auth_code | guest_account_id | status | expires_at |

// A 设备:生成同步码
app.post('/api/account/create-sync-code', async (req, res) => {
  const userId = req.user.id;  // 从 Token 获取
  const authCode = generateRandomCode();  // 生成 6 位随机码
  
  await db.insert('sync_codes', {
    auth_code: authCode,
    guest_account_id: userId,
    status: 'pending',
    expires_at: Date.now() + 5 * 60 * 1000  // 5 分钟后过期
  });
  
  res.json({ code: authCode });
});

// B 设备:使用同步码登录
app.post('/api/account/sync', async (req, res) => {
  const { code } = req.body;
  
  // 1. 查找授权码
  const syncCode = await db.findOne('sync_codes', {
    auth_code: code,
    status: 'pending'
  });
  
  // 2. 验证
  if (!syncCode) return res.status(400).json({ error: 'Invalid code' });
  if (syncCode.expires_at < Date.now()) {
    return res.status(400).json({ error: 'Code expired' });
  }
  
  // 3. 标记为已使用
  await db.update('sync_codes', { id: syncCode.id }, { status: 'used' });
  
  // 4. 生成新 Token 给 B 设备
  const token = generateToken({ userId: syncCode.guest_account_id });
  
  res.json({ token });
});
前端实现
javascript 复制代码
// A 设备:生成二维码
import QRCode from 'qrcode';

async function createSyncQR() {
  const res = await fetch('/api/account/create-sync-code', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${localStorage.getItem('token')}`
    }
  });
  
  const { code } = await res.json();
  
  // 生成二维码
  const qrDataURL = await QRCode.toDataURL(code);
  document.getElementById('qr-code').src = qrDataURL;
}

// B 设备:扫描并登录
async function scanAndSync(scannedCode) {
  const res = await fetch('/api/account/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code: scannedCode })
  });
  
  const { token } = await res.json();
  localStorage.setItem('token', token);
  
  // 登录成功,刷新页面
  location.reload();
}

6.3 方案二:魔法链接(备选)

场景:无法使用摄像头,通过链接同步

javascript 复制代码
// A 设备:生成同步链接
const syncURL = `https://www.myshop.com/sync?token=abc123`;

// 用户复制链接,在 B 设备打开

// B 设备:检测 URL 参数
const urlParams = new URLSearchParams(window.location.search);
const syncToken = urlParams.get('token');

if (syncToken) {
  // 自动调用同步接口
  syncAccount(syncToken);
}

安全加固

  • ✅ 链接有效期 1-2 分钟
  • ✅ A 设备二次确认(WebSocket 通知)
  • ✅ 明确警告:"此链接包含登录权限,请勿分享!"

7. 单点登录(SSO)原理

7.1 什么是单点登录

定义:在一个地方登录,多个系统都能访问。

场景

  • 登录 Google 账号,可以访问 Gmail、YouTube、Drive
  • 企业内部系统:登录 OA 后,ERP、CRM 都免登录

7.2 SSO 流程

复制代码
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  应用 A       │    │  应用 B       │    │  认证中心     │
│  (未登录)     │    │  (未登录)     │    │  (SSO Server)│
└──────┬───────┘    └──────┬───────┘    └──────┬───────┘
       │                   │                   │
       │ 1. 访问应用 A      │                   │
       │───────────────────────────────────────►│
       │                   │                   │
       │ 2. 发现未登录,重定向到认证中心           │
       │◄──────────────────────────────────────┤
       │   Redirect: https://sso.com/login     │
       │            ?redirect=https://app-a.com│
       │                   │                   │
       │ 3. 用户输入账号密码 │                   │
       │───────────────────────────────────────►│
       │                   │                   │
       │ 4. 认证成功,颁发 Ticket               │
       │◄──────────────────────────────────────┤
       │   Redirect: https://app-a.com         │
       │            ?ticket=TGT-123456         │
       │                   │                   │
       │ 5. 应用 A 验证 Ticket                  │
       │───────────────────────────────────────►│
       │                   │                   │
       │ 6. 返回用户信息,登录成功               │
       │◄──────────────────────────────────────┤
       │                   │                   │
       │ ✓ 已登录           │                   │
       │                   │                   │
       └───────────────────┼───────────────────┘
                           │
                           │ 7. 访问应用 B
                           │────────────────────────►│
                           │                         │
                           │ 8. 检测到 SSO Cookie     │
                           │    自动登录,无需输入密码│
                           │◄────────────────────────┤
                           │                         │
                           │ ✓ 已登录                │

7.3 核心机制

认证中心(SSO Server)

  • 统一管理用户登录状态
  • 颁发 TicketToken
  • 维护全局会话

应用系统

  • 检测到未登录,重定向到认证中心
  • 从 URL 获取 Ticket,向认证中心验证
  • 验证通过,创建本地会话

关键 Cookie

  • TGC (Ticket Granting Cookie):存在认证中心域名下,表示用户已登录
  • 应用 A、B 都会检查这个 Cookie,实现免登录

8. 灰度发布:用户分流与版本管理

8.1 什么是灰度发布

定义:新版本只发布给部分用户,观察效果后再全量上线。

优势

  • ✅ 降低风险(出问题影响范围小)
  • ✅ 快速验证(真实用户反馈)
  • ✅ 可回滚(随时切回旧版本)

8.2 灰度分配策略

优先级顺序
javascript 复制代码
function getUserVersion(req) {
  // 1. 优先:数据库固定分配(如内部员工)
  const fixedVersion = await db.getUserVersion(req.user?.id);
  if (fixedVersion) return fixedVersion;
  
  // 2. 其次:URL 参数(测试用)
  if (req.query.version) return req.query.version;
  
  // 3. 再次:Cookie 标记(保持一致性)
  if (req.cookies.user_version) return req.cookies.user_version;
  
  // 4. 最后:比例分配(新用户)
  return randomProportionVersion(req.ip, config.grayPercent);
}
比例分配算法
javascript 复制代码
function randomProportionVersion(userId, grayPercent = 10) {
  // 方案 1:哈希取模(稳定性好,同一用户永远分到同一组)
  const hash = md5(userId);
  const num = parseInt(hash.substring(0, 8), 16);
  return (num % 100) < grayPercent ? '1.0.5' : '1.0.4';
  
  // 方案 2:随机数(灵活但不稳定)
  return Math.random() < (grayPercent / 100) ? '1.0.5' : '1.0.4';
}

8.3 版本资源管理

问题:旧版本资源 404

场景

复制代码
10:00 用户打开网页,加载 v1.0.4 的 HTML
10:10 发布 v1.0.5,删除 v1.0.4 的 JS/CSS
10:15 用户点击功能,请求 v1.0.4 的 chunk-abc123.js → 404 ❌
解决方案:资源聚合池
复制代码
dist/
├─ current/              # 当前入口 HTML
│  ├─ index.html
│  └─ manifest.json
│
├─ assets/               # 最近 N 个版本的静态资源(聚合池)
│  ├─ chunk-abc123.js    # v1.0.4
│  ├─ chunk-def456.js    # v1.0.4
│  ├─ chunk-xyz789.js    # v1.0.5
│  └─ chunk-qwe321.js    # v1.0.5
│
└─ versions/             # 每个版本完整备份
   ├─ 1.0.4/
   │  ├─ index.html
   │  └─ assets/
   └─ 1.0.5/
      ├─ index.html
      └─ assets/
发布流程
bash 复制代码
# 1. 构建新版本
npm run build  # 产出 dist/

# 2. 部署到 current/
cp -r dist/* server/dist/current/

# 3. 聚合资源到 assets/(追加,不覆盖)
cp -r dist/assets/* server/dist/assets/

# 4. 备份完整版本
cp -r dist/ server/dist/versions/1.0.5/

# 5. 清理旧版本(保留最近 5 个版本)
cleanOldVersions(5);

8.4 Nginx 配置

nginx 复制代码
server {
  listen 80;
  server_name www.myshop.com;
  
  # 1. 入口 HTML(短缓存)
  location / {
    root /var/www/dist/current;
    try_files $uri /index.html;
    
    # 不缓存 HTML
    add_header Cache-Control "no-cache, no-store, must-revalidate";
  }
  
  # 2. 静态资源(长缓存)
  location /assets/ {
    root /var/www/dist;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
  
  # 3. API 转发
  location /api/ {
    proxy_pass http://backend-server;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

8.5 前端检测版本更新

javascript 复制代码
// 方案 1:轮询检测
let currentVersion = window.APP_VERSION;  // 从 HTML 注入

setInterval(async () => {
  const res = await fetch('/manifest.json');
  const data = await res.json();
  
  if (data.version !== currentVersion) {
    // 提示用户刷新
    showUpdateNotification();
  }
}, 60000);  // 每分钟检测一次

// 方案 2:WebSocket 推送
const ws = new WebSocket('wss://www.myshop.com/version-notify');

ws.onmessage = (event) => {
  const { version } = JSON.parse(event.data);
  
  if (version !== currentVersion) {
    showUpdateNotification();
  }
};

// 刷新页面
function showUpdateNotification() {
  if (confirm('New version available. Refresh now?')) {
    location.reload(true);  // 强制刷新
  }
}

9. 回滚机制与前后端交互

9.1 为什么需要回滚

场景

  • 新版本上线后,发现严重 Bug
  • 错误率突然升高
  • 用户投诉激增

目标

  • 快速恢复到旧版本
  • 最小化用户影响

9.2 回滚流程

复制代码
┌─────────────┐          ┌─────────────┐          ┌─────────────┐
│  监控系统    │          │  后端服务    │          │  前端用户    │
└──────┬──────┘          └──────┬──────┘          └──────┬──────┘
       │                        │                        │
       │ 1. 检测到错误率升高     │                        │
       │────────────────────────►│                        │
       │                        │                        │
       │                        │ 2. 决策回滚             │
       │                        │    切换版本配置         │
       │                        │    1.0.5 → 1.0.4       │
       │                        │                        │
       │                        │ 3. 清除用户版本映射     │
       │                        │    DELETE user_version  │
       │                        │                        │
       │                        │ 4. 通知所有在线用户     │
       │                        │───────────────────────►│
       │                        │    WebSocket / 轮询     │
       │                        │    { action: "reload" } │
       │                        │                        │
       │                        │                        │ 5. 前端刷新
       │                        │◄───────────────────────┤
       │                        │    GET /index.html      │
       │                        │                        │
       │                        │ 6. 返回旧版本 HTML      │
       │                        │───────────────────────►│
       │                        │    v1.0.4               │
       │                        │                        │
       │                        │                        │ 7. 加载旧版本资源
       │                        │◄───────────────────────┤
       │                        │    GET /assets/chunk... │
       │                        │                        │
       │                        │ 8. 返回旧版本资源       │
       │                        │───────────────────────►│
       │                        │    (从 assets/ 获取)   │
       │                        │                        │
       │                        │                        │ ✓ 回滚完成

9.3 后端实现

javascript 复制代码
// 1. 版本配置管理
const versionConfig = {
  current: '1.0.5',
  fallback: '1.0.4',
  grayPercent: 10,
  forceRollback: false  // 回滚开关
};

// 2. 回滚 API
app.post('/admin/rollback', async (req, res) => {
  // 切换当前版本
  versionConfig.current = versionConfig.fallback;
  versionConfig.forceRollback = true;
  
  // 清除所有用户的版本映射
  await db.clearAllUserVersions();
  
  // 通知所有在线用户
  notifyAllUsers({ action: 'reload', version: versionConfig.current });
  
  res.json({ success: true, currentVersion: versionConfig.current });
});

// 3. WebSocket 通知
function notifyAllUsers(message) {
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(message));
    }
  });
}

// 4. 入口 HTML 返回
app.get('/', (req, res) => {
  const version = versionConfig.forceRollback 
    ? versionConfig.fallback 
    : getUserVersion(req);
  
  const htmlPath = `/var/www/dist/versions/${version}/index.html`;
  res.sendFile(htmlPath);
});

9.4 前端实现

javascript 复制代码
// 1. WebSocket 连接
let ws;

function connectVersionNotifier() {
  ws = new WebSocket('wss://www.myshop.com/version-notify');
  
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    
    if (data.action === 'reload') {
      handleVersionChange(data.version);
    }
  };
  
  ws.onclose = () => {
    // 断线重连
    setTimeout(connectVersionNotifier, 5000);
  };
}

// 2. 处理版本变更
function handleVersionChange(newVersion) {
  // 保存当前页面状态
  const currentState = {
    path: location.pathname,
    scrollY: window.scrollY,
    formData: getFormData()  // 如果有表单
  };
  
  sessionStorage.setItem('pre-reload-state', JSON.stringify(currentState));
  
  // 显示通知
  showNotification('System updated, reloading...', 2000);
  
  // 延迟刷新
  setTimeout(() => {
    location.reload(true);  // 强制刷新,绕过缓存
  }, 2000);
}

// 3. 恢复页面状态
window.addEventListener('load', () => {
  const savedState = sessionStorage.getItem('pre-reload-state');
  
  if (savedState) {
    const state = JSON.parse(savedState);
    
    // 恢复滚动位置
    window.scrollTo(0, state.scrollY);
    
    // 恢复表单数据
    restoreFormData(state.formData);
    
    // 清除状态
    sessionStorage.removeItem('pre-reload-state');
  }
});

// 4. 备用:轮询检测
function pollVersionCheck() {
  setInterval(async () => {
    try {
      const res = await fetch('/api/current-version');
      const { version } = await res.json();
      
      if (version !== window.APP_VERSION) {
        handleVersionChange(version);
      }
    } catch (err) {
      console.error('Version check failed:', err);
    }
  }, 30000);  // 每 30 秒检测一次
}

// 启动
connectVersionNotifier();
pollVersionCheck();

9.5 灰度回滚(部分用户)

javascript 复制代码
// 场景:只回滚灰度用户,不影响稳定版用户

app.post('/admin/rollback-gray', async (req, res) => {
  // 1. 查找所有灰度用户
  const grayUsers = await db.findAll('user_versions', {
    version: '1.0.5'
  });
  
  // 2. 将他们回滚到旧版本
  await db.updateMany('user_versions', 
    { version: '1.0.5' },
    { version: '1.0.4' }
  );
  
  // 3. 只通知灰度用户
  grayUsers.forEach(user => {
    notifyUser(user.id, { action: 'reload', version: '1.0.4' });
  });
  
  res.json({ success: true, affectedUsers: grayUsers.length });
});

10. 完整实战案例

10.1 场景描述

项目 :电商网站(www.myshop.com
目标:发布新版商品详情页,灰度 10% 用户

10.2 技术架构

复制代码
┌─────────────────────────────────────────────────────┐
│                    用户浏览器                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐   │
│  │ HTML     │  │ JS/CSS   │  │ localStorage     │   │
│  │ (短缓存)  │  │ (长缓存)  │  │ token: "..."     │   │
│  └──────────┘  └──────────┘  └──────────────────┘   │
└─────────────────────────────────────────────────────┘
              │                   │
              ↓                   ↓
┌──────────────────────────────────────────────────────┐
│                    Nginx (网关)                       │
│  • 静态资源:/assets/ → 长缓存                         │
│  • 入口 HTML:/ → 短缓存                               │
│  • API 转发:/api/ → 后端服务                          │
└──────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────┐
│                  Node.js 后端服务                      │
│  • 用户身份识别                                        │
│  • 灰度版本分配                                        │
│  • API 业务逻辑                                        │
│  • WebSocket 推送                                     │
└──────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────┐
│                    MySQL 数据库                        │
│  • user_versions (用户版本映射)                        │
│  • sync_codes (同步码)                                │
│  • users (用户信息)                                    │
└──────────────────────────────────────────────────────┘

10.3 完整代码实现

数据库设计
sql 复制代码
-- 用户版本映射表
CREATE TABLE user_versions (
  id INT PRIMARY KEY AUTO_INCREMENT,
  user_id VARCHAR(255) NOT NULL,
  version VARCHAR(50) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP,
  INDEX idx_user_id (user_id)
);

-- 同步码表(跨设备登录)
CREATE TABLE sync_codes (
  id INT PRIMARY KEY AUTO_INCREMENT,
  auth_code VARCHAR(20) NOT NULL UNIQUE,
  guest_account_id VARCHAR(255) NOT NULL,
  status ENUM('pending', 'used', 'expired') DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP,
  INDEX idx_auth_code (auth_code)
);

-- 用户表
CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(255),
  email VARCHAR(255),
  password_hash VARCHAR(255),
  is_guest BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
后端服务(Node.js + Express)
javascript 复制代码
const express = require('express');
const cookieParser = require('cookie-parser');
const WebSocket = require('ws');
const crypto = require('crypto');

const app = express();
app.use(express.json());
app.use(cookieParser());

// ========== 配置 ==========
const config = {
  versions: {
    stable: '1.0.4',
    canary: '1.0.5'
  },
  grayPercent: 10,  // 灰度比例
  forceRollback: false
};

// ========== 灰度分配逻辑 ==========
async function getUserVersion(req) {
  // 1. 强制回滚
  if (config.forceRollback) {
    return config.versions.stable;
  }
  
  // 2. URL 参数指定
  if (req.query.version) {
    setVersionCookie(res, req.query.version);
    return req.query.version;
  }
  
  // 3. Cookie 已分配
  if (req.cookies.user_version) {
    return req.cookies.user_version;
  }
  
  // 4. 数据库固定分配
  if (req.user) {
    const dbVersion = await db.query(
      'SELECT version FROM user_versions WHERE user_id = ?',
      [req.user.id]
    );
    if (dbVersion.length > 0) {
      return dbVersion[0].version;
    }
  }
  
  // 5. 比例分配
  const userId = req.cookies.guest_id || generateGuestId();
  const version = hashDistribute(userId, config.grayPercent);
  
  return version;
}

// 哈希分配算法
function hashDistribute(userId, grayPercent) {
  const hash = crypto.createHash('md5').update(userId).digest('hex');
  const num = parseInt(hash.substring(0, 8), 16);
  return (num % 100) < grayPercent 
    ? config.versions.canary 
    : config.versions.stable;
}

// 设置版本 Cookie
function setVersionCookie(res, version) {
  res.cookie('user_version', version, {
    maxAge: 24 * 60 * 60 * 1000,  // 24 小时
    httpOnly: false,  // 允许 JS 读取
    sameSite: 'lax'
  });
}

// ========== 路由 ==========

// 入口 HTML
app.get('/', async (req, res) => {
  const version = await getUserVersion(req);
  
  // 设置 Cookie
  setVersionCookie(res, version);
  
  // 返回对应版本的 HTML
  const htmlPath = `/var/www/dist/versions/${version}/index.html`;
  res.sendFile(htmlPath);
});

// 当前版本 API(前端轮询)
app.get('/api/current-version', (req, res) => {
  res.json({ 
    version: config.forceRollback 
      ? config.versions.stable 
      : config.versions.canary 
  });
});

// 回滚 API(管理员)
app.post('/admin/rollback', async (req, res) => {
  config.forceRollback = true;
  
  // 清除所有用户版本映射
  await db.query('DELETE FROM user_versions');
  
  // 通知所有在线用户
  notifyAllClients({ 
    action: 'reload', 
    version: config.versions.stable 
  });
  
  res.json({ success: true });
});

// 跨设备登录:生成同步码
app.post('/api/account/create-sync-code', authenticate, async (req, res) => {
  const authCode = Math.random().toString(36).substring(2, 8).toUpperCase();
  const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
  
  await db.query(
    'INSERT INTO sync_codes (auth_code, guest_account_id, expires_at) VALUES (?, ?, ?)',
    [authCode, req.user.id, expiresAt]
  );
  
  res.json({ code: authCode });
});

// 跨设备登录:使用同步码
app.post('/api/account/sync', async (req, res) => {
  const { code } = req.body;
  
  const result = await db.query(
    'SELECT * FROM sync_codes WHERE auth_code = ? AND status = "pending" AND expires_at > NOW()',
    [code]
  );
  
  if (result.length === 0) {
    return res.status(400).json({ error: 'Invalid or expired code' });
  }
  
  const syncCode = result[0];
  
  // 标记为已使用
  await db.query(
    'UPDATE sync_codes SET status = "used" WHERE id = ?',
    [syncCode.id]
  );
  
  // 生成 Token
  const token = generateToken({ userId: syncCode.guest_account_id });
  
  res.json({ token });
});

// ========== WebSocket 推送 ==========
const wss = new WebSocket.Server({ port: 8080 });

function notifyAllClients(message) {
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(message));
    }
  });
}

// ========== 启动服务 ==========
app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});
前端代码(Vue 3)
vue 复制代码
<template>
  <div id="app">
    <!-- 版本更新提示 -->
    <div v-if="showUpdateNotice" class="update-notice">
      <p>New version available!</p>
      <button @click="reloadApp">Refresh Now</button>
    </div>
    
    <!-- 主内容 -->
    <router-view />
    
    <!-- 跨设备同步 -->
    <button @click="showSyncModal = true">Sync to Other Device</button>
    
    <div v-if="showSyncModal" class="modal">
      <h3>Scan QR Code to Sync</h3>
      <canvas ref="qrCanvas"></canvas>
      <p>Code: {{ syncCode }}</p>
      <button @click="closeSyncModal">Close</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import QRCode from 'qrcode';

// ========== 版本检测 ==========
const showUpdateNotice = ref(false);
const currentVersion = window.APP_VERSION;

// WebSocket 连接
let ws;

function connectVersionNotifier() {
  ws = new WebSocket('ws://localhost:8080');
  
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    
    if (data.action === 'reload' && data.version !== currentVersion) {
      showUpdateNotice.value = true;
    }
  };
  
  ws.onclose = () => {
    setTimeout(connectVersionNotifier, 5000);
  };
}

// 轮询备用
function pollVersionCheck() {
  setInterval(async () => {
    const res = await fetch('/api/current-version');
    const { version } = await res.json();
    
    if (version !== currentVersion) {
      showUpdateNotice.value = true;
    }
  }, 60000);
}

// 刷新应用
function reloadApp() {
  // 保存状态
  sessionStorage.setItem('pre-reload-path', location.pathname);
  location.reload(true);
}

// 恢复状态
onMounted(() => {
  const savedPath = sessionStorage.getItem('pre-reload-path');
  if (savedPath && savedPath !== location.pathname) {
    router.push(savedPath);
    sessionStorage.removeItem('pre-reload-path');
  }
  
  connectVersionNotifier();
  pollVersionCheck();
});

// ========== 跨设备同步 ==========
const showSyncModal = ref(false);
const syncCode = ref('');
const qrCanvas = ref(null);

async function generateSyncCode() {
  const token = localStorage.getItem('token');
  
  const res = await fetch('/api/account/create-sync-code', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });
  
  const { code } = await res.json();
  syncCode.value = code;
  
  // 生成二维码
  QRCode.toCanvas(qrCanvas.value, code, {
    width: 200,
    margin: 2
  });
}

function closeSyncModal() {
  showSyncModal.value = false;
  syncCode.value = '';
}

// 监听模态框打开
watch(showSyncModal, (newVal) => {
  if (newVal) {
    nextTick(() => {
      generateSyncCode();
    });
  }
});
</script>

<style scoped>
.update-notice {
  position: fixed;
  top: 20px;
  right: 20px;
  background: #4caf50;
  color: white;
  padding: 15px 20px;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  z-index: 9999;
}

.update-notice button {
  margin-top: 10px;
  background: white;
  color: #4caf50;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 30px;
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.2);
  text-align: center;
  z-index: 9999;
}

.modal canvas {
  margin: 20px 0;
}
</style>
CI/CD 发布脚本
bash 复制代码
#!/bin/bash

# deploy.sh - 灰度发布脚本

VERSION=$1
GRAY_PERCENT=${2:-10}

echo "========== Deploying version $VERSION =========="

# 1. 构建前端
echo "Building frontend..."
npm run build

# 2. 部署到服务器
echo "Deploying to server..."

# 备份完整版本
ssh user@server "mkdir -p /var/www/dist/versions/$VERSION"
scp -r dist/* user@server:/var/www/dist/versions/$VERSION/

# 更新 current 入口
scp -r dist/index.html user@server:/var/www/dist/current/
scp -r dist/manifest.json user@server:/var/www/dist/current/

# 聚合资源到 assets/
ssh user@server "cp -n /var/www/dist/versions/$VERSION/assets/* /var/www/dist/assets/ 2>/dev/null || true"

# 3. 更新后端配置
echo "Updating backend config..."
ssh user@server "curl -X POST http://localhost:3000/admin/update-config \
  -H 'Content-Type: application/json' \
  -d '{\"canary\":\"$VERSION\",\"grayPercent\":$GRAY_PERCENT}'"

# 4. 清理旧版本(保留最近 5 个)
echo "Cleaning old versions..."
ssh user@server "cd /var/www/dist/versions && ls -t | tail -n +6 | xargs rm -rf"

echo "========== Deployment complete =========="
echo "Current version: $VERSION"
echo "Gray percent: $GRAY_PERCENT%"

10.4 完整流程演示

场景 1:新用户首次访问
复制代码
1. 用户访问 https://www.myshop.com
   └─ 浏览器请求:GET /
      Headers: (无 Cookie)

2. 后端处理:
   ✓ 检测无 Cookie
   ✓ 生成 guest_id: guest_abc123
   ✓ 哈希分配:hash(guest_abc123) % 100 = 7 < 10
   ✓ 分配版本:1.0.5 (灰度版)

3. 后端响应:
   Set-Cookie: user_version=1.0.5; guest_id=guest_abc123
   Content: /versions/1.0.5/index.html

4. 浏览器加载:
   ✓ 解析 HTML
   ✓ 请求资源:/assets/chunk-xyz789.js
   ✓ 执行 JS,连接 WebSocket

5. 用户正常使用 v1.0.5
场景 2:用户第二天再次访问
复制代码
1. 用户访问 https://www.myshop.com
   └─ 浏览器请求:GET /
      Headers: Cookie: user_version=1.0.5; guest_id=guest_abc123

2. 后端处理:
   ✓ 读取 Cookie: user_version=1.0.5
   ✓ 直接返回对应版本

3. 用户继续使用 v1.0.5(版本保持一致)
场景 3:发现 Bug,紧急回滚
复制代码
1. 监控系统检测到错误率升高(v1.0.5)

2. 管理员执行回滚:
   POST /admin/rollback

3. 后端处理:
   ✓ 设置 forceRollback = true
   ✓ 清除数据库中所有 user_versions
   ✓ WebSocket 推送:{ action: 'reload', version: '1.0.4' }

4. 所有在线用户收到通知:
   ✓ 弹出提示:"System updated, reloading..."
   ✓ 2 秒后自动刷新

5. 用户刷新后:
   └─ 浏览器请求:GET /
      Headers: Cookie: user_version=1.0.5 (旧 Cookie)
   
   └─ 后端检测到 forceRollback=true
      ✓ 忽略 Cookie
      ✓ 强制返回 v1.0.4

6. 用户加载 v1.0.4,Bug 解决 ✓
场景 4:跨设备登录
复制代码
1. 用户在手机(A 设备)点击"同步到其他设备"

2. 前端请求:
   POST /api/account/create-sync-code
   Headers: Authorization: Bearer <token>

3. 后端生成同步码:
   ✓ auth_code: A3X9K2
   ✓ 有效期:5 分钟
   ✓ 返回前端

4. 前端生成二维码显示

5. 用户在电脑(B 设备)扫描二维码

6. B 设备请求:
   POST /api/account/sync
   Body: { code: "A3X9K2" }

7. 后端验证:
   ✓ 同步码有效
   ✓ 查找对应账号:guest_abc123
   ✓ 生成新 Token 返回

8. B 设备保存 Token,登录成功
   ✓ localStorage.setItem('token', token)
   ✓ 刷新页面,进入已登录状态

9. A、B 设备现在共享同一账号 ✓

11. 总结与最佳实践

11.1 核心概念总结

概念 核心作用 生命周期
URL 参数 首次引导、版本指定 单次请求
Cookie 会话保持、自动携带 可设置过期
Token 身份认证、跨域传递 登录会话
灰度发布 降低风险、逐步验证 发布周期
回滚机制 快速恢复、止损 异常处理

11.2 最佳实践

✅ DO(推荐)
  1. 入口 HTML 短缓存,静态资源长缓存

    nginx 复制代码
    location / {
      add_header Cache-Control "no-cache";
    }
    location /assets/ {
      add_header Cache-Control "max-age=31536000, immutable";
    }
  2. 保留历史版本资源,避免 404

    bash 复制代码
    dist/
    ├─ assets/      # 聚合最近 5 个版本
    └─ versions/    # 完整备份
  3. Cookie + Token 组合使用

    • Cookie:HTML 请求、静态资源
    • Token:API 请求、跨域场景
  4. 灰度分配使用哈希算法,保持稳定性

    javascript 复制代码
    hash(userId) % 100 < grayPercent
  5. WebSocket + 轮询双保险

    • 主要:WebSocket 实时推送
    • 备用:轮询兜底
❌ DON'T(避免)
  1. 不要删除旧版本资源

    bash 复制代码
    # ❌ 错误:直接覆盖
    rm -rf dist/assets/*
    cp new-assets/* dist/assets/
    
    # ✅ 正确:追加保留
    cp -n new-assets/* dist/assets/
  2. 不要在 URL 中传递敏感 Token

    javascript 复制代码
    // ❌ 错误
    fetch(`/api/data?token=${token}`);
    
    // ✅ 正确
    fetch('/api/data', {
      headers: { 'Authorization': `Bearer ${token}` }
    });
  3. 不要假设浏览器缓存会自动更新

    • 始终考虑用户长时间不刷新的情况
    • 通过版本检测主动通知
  4. 不要在静态资源请求中依赖 LocalStorage

    • <script><link> 无法携带 Token
    • 静态资源鉴权使用 Cookie 或 Referer

11.3 常见问题 FAQ

Q1: 用户清除了 Cookie,会不会重新分配版本?

A: 是的。Cookie 被清除后,用户下次访问会被视为新用户,重新走分配逻辑。可以通过数据库持久化用户 ID 和版本映射来避免。

Q2: 灰度用户能否手动切回稳定版?

A: 可以。提供一个开关,让用户选择退出灰度:

javascript 复制代码
// 用户点击"退出灰度"
fetch('/api/opt-out-gray', { method: 'POST' });

// 后端更新数据库
db.update('user_versions', { user_id }, { version: 'stable' });

Q3: Service Worker 会不会导致旧版本无法更新?

A: 可能会。建议:

  • Service Worker 中检测版本变化,自动 skipWaiting()
  • 或者不缓存 index.html,只缓存静态资源

Q4: 多个后端服务如何共享用户版本信息?

A: 使用共享存储(Redis)或统一网关:

javascript 复制代码
// Redis 存储
redis.set(`user:${userId}:version`, '1.0.5', 'EX', 86400);

// 其他服务读取
const version = await redis.get(`user:${userId}:version`);

Q5: 如何监控灰度效果?

A: 埋点统计:

javascript 复制代码
// 前端上报
window.APP_VERSION = '1.0.5';

function reportMetric(event, data) {
  fetch('/api/metrics', {
    method: 'POST',
    body: JSON.stringify({
      version: window.APP_VERSION,
      event,
      data,
      timestamp: Date.now()
    })
  });
}

// 使用
reportMetric('page_view', { page: '/product/123' });
reportMetric('error', { message: err.message });

12. 延伸阅读

推荐资源

相关技术

  • 特性开关(Feature Flags):比灰度发布更细粒度的功能控制
  • A/B 测试:对比不同版本的用户行为和转化率
  • 蓝绿部署:两套环境无缝切换
  • 金丝雀部署:灰度发布的别名

结语

前端灰度发布和用户身份识别是一个系统工程,涉及:

  • 浏览器机制:Cookie、LocalStorage、请求生命周期
  • 网络协议:HTTP 缓存、跨域、WebSocket
  • 后端架构:版本管理、资源聚合、数据库设计
  • 运维流程:CI/CD、监控告警、回滚机制

理解这些概念的核心原理,才能在实际项目中灵活运用,打造稳定、可控、用户体验良好的发布流程。

希望这篇指南能帮助你从零开始,掌握现代 Web 应用的身份识别与灰度发布技术!

💡 提示:本文档使用 Markdown 编写,推荐使用支持 Mermaid 图表的阅读器查看完整效果。
📝 贡献:如有问题或建议,欢迎提交 Issue 或 Pull Request。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax