简介:面向希望理解跨设备会话延续与共享浏览器实现的开发者,这份资源围绕“克隆Session”展开,涉及HTTP会话管理、Cookie捕获与传输、请求头注入、浏览器代理或扩展以及实时同步等关键环节,可用于团队协作、远程办公等场景的技术验证与学习。压缩包共129个文件,约77.46MB,以pak界面资源、dll动态库、pdb调试符号、xml配置、bin数据及exe可执行文件为主,并配有ini、config等运行配置,较完整地保留了浏览器运行所需的基础文件结构。已有362人学习,适合具备Web开发与网络基础的中级读者参考,从中可了解CEF或Chromium类浏览器项目的组织方式,以及Session复制、请求识别与多端状态一致性背后的工程思路,同时需关注加密传输、身份验证与隐私合规等安全边界。
1. 共享浏览器遇到的第一个问题:session 不是文件,而是一整套环境
上个月帮异地同事复现一个只在登录后出现的接口问题,我按常见做法把浏览器 Cookies 文件拷过去,对方把文件放进去、重启浏览器,打开站点还是回到登录页。后来整理这个「克隆Session 共享浏览器」的安装方案才明白:cookie 只是 session 的一部分,剩下的一半在启动参数、浏览器小版本、profile 目录结构和时区里。拷一个文件,等于只搬了半个现场。这类环境在测试复现、异地协作排查、QA 交接登录态这些场景里非常常见,搭不好就会出现“我这边能复现、你那边是登录页”的现象,越查越像环境问题。下面就按我实际装过的顺序,从选版本、起容器、导出 session、异地还原一路写到问题排查。
2. 环境清单与版本选型:容器、浏览器、Node 三条线先对齐
这个方案由三层组成:最下层是容器里的虚拟屏幕和浏览器,中间层是把画面搬到浏览器里的 noVNC,最上层是用 Playwright 读写 session 的脚本。三层里任何一层的版本对不上,表现出来的现象都是“登录态没过去”,很容易误判成脚本写错了。
我一般会让宿主机只负责运行容器,其余组件全部放进镜像。这样异地那台机器不需要装 Chromium、不需要配图形库、也不需要统一字体;镜像标签一致,行为就一致。
2.1 宿主机与容器基线
宿主机用任何能跑 Docker 的 Linux 都行,我习惯 Ubuntu 22.04 或 Debian 12,内核 5.15 以上。Docker 版本不低于 24,原因是 24 之后--shm-size和 cgroup v2 的默认行为更省心,Chromium 在默认 64MB 共享内存下崩渲染进程是老问题。
# 宿主机只做三件事:装 docker、给数据目录、放行两个端口 docker --version # 建议 >= 24.0 mkdir -p /srv/shared-browser/{profile-a,profile-b} chown -R 1000:1000 /srv/shared-browser/srv/shared-browser下按人分目录,一个目录对应一个浏览器实例。这个约定很重要:profile 目录一旦被两个进程同时打开,第二个进程不会报错,它会静默退出,把窗口交给第一个实例。第一次遇到时我以为是容器没起来,查了半天日志。
容器的启动参数里,我固定加三个:
docker run -d --name shared-browser-a \ --shm-size=1g \ -e TZ=UTC \ -e PROFILE_DIR=/data/profile \ -p 6080:6080 -p 9222:9222 \ -v /srv/shared-browser/profile-a:/data \ shared-browser:1.0--shm-size=1g是为了避免页面稍重就把渲染进程撑崩;TZ=UTC是让容器内时间统一,cookie 的过期判断、日志时间戳都以 UTC 为准,异地机器时区不同也不会互相干扰;9222是 CDP 端口,导出脚本要靠它连进正在运行的浏览器,不能只开 noVNC 的 6080。
提示:9222 只在容器网络内使用,宿主机映射出去时最好绑
127.0.0.1:9222:9222,不要直接暴露到公网网卡。
2.2 基础镜像为什么不用 Ubuntu 22.04 的 chromium-browser
这是我在这个方案里最关键的一次调整。Ubuntu 22.04 源里的chromium-browser只是一个过渡包,实际会去拉 snap,容器里没有 snapd,装到一半就失败,或者装上一个空壳。新手按网上教程装完,which chromium有输出,一启动就报找不到库。
常见做法有两条:一是换 Debian 12 基础镜像,源里的chromium是正常 deb 包,版本随源走;二是直接用 Playwright 官方镜像,浏览器已经在/ms-playwright里。我选第一条,因为镜像小、可控,而且我要用系统包管理器统一处理字体和依赖。
Debian 12 的chromium当前落在 120 这一代。这个版本号本身不重要,重要的是导出端和还原端必须是同一个小版本。Chromium 的 profile 结构只保证向前兼容,高版本写过的Cookies、Local Storage目录,用低版本打开会触发 profile 重置,表现就是“cookie 明明在文件里,打开还是未登录”。
2.3 无头环境四件套:Xvfb、openbox、x11vnc、noVNC
浏览器要“共享”,就得有人能看见画面。所以这个镜像不是纯 headless,而是虚拟屏幕加远程画面:
| 组件 | 作用 | 我用的版本线 | 装错的典型现象 |
|---|---|---|---|
| Xvfb | 提供虚拟 X 屏幕 | Debian 12 源内版本 | 没有 DISPLAY,浏览器起不来 |
| openbox | 窗口管理器 | 3.6.x | 窗口无边框、输入焦点乱跳 |
| x11vnc | 把 X 屏幕转成 VNC | 0.9.16 | noVNC 连上黑屏 |
| noVNC + websockify | VNC 转 WebSocket 给浏览器 | 1.3.0 | 页面能开,握手即断 |
少 openbox 这一条最容易被忽略。没有窗口管理器时,Chromium 的--window-size不生效,弹窗会跑到屏幕外,鼠标点击坐标也会偏。我第一次搭的时候省了它,结果同事说“能看见浏览器,但点不动”,加了 openbox 就好了。
2.4 端口、目录与版本对照
开工前把这些固定下来,后面写脚本就不用改来改去:
| 项 | 值 | 说明 |
|---|---|---|
| CDP 端口 | 9222 | 导出/还原脚本连接入口 |
| VNC 端口 | 5900 | 只监听回环,不给外部 |
| noVNC 端口 | 6080 | 唯一对外端口 |
| profile 目录 | /data/profile | 挂载宿主机目录 |
| 导出文件 | /data/state.json | 权限 0600 |
| Node | 20 LTS | 与 Playwright 1.4x 匹配 |
| 浏览器 | Debian 12 源内 chromium | 两端必须同版本 |
Node 我锁 20 LTS,是因为 Playwright 1.4x 系列在 Node 18 上也能跑,但addInitScript传参的序列化行为在 18 和 20 上有细微差别,跨机器调试时不值得冒这个风险。npm i playwright-core即可,不要装完整的playwright,那会额外下载一份浏览器,跟系统里的 chromium 版本对不上,反而是隐患。
# 只装 core,跳过浏览器下载,指向系统 chromium npm init -y PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i playwright-core@1.44playwright-core不带浏览器,所有启动都要显式给executablePath: '/usr/bin/chromium'。这一步多写一行,换来的是“脚本用的浏览器”和“人看到的浏览器”是同一个二进制,session 才不会在两套 Chromium 之间来回折腾。
3. 起一个能看见画面的共享浏览器容器
这一章落到可抄的部分:镜像怎么写、启动顺序为什么不能乱、noVNC 怎么加一道鉴权、起来之后怎么确认画面真的通了。
3.1 Dockerfile:依赖分两层装
FROM debian:12-slim ENV DEBIAN_FRONTEND=noninteractive \ DISPLAY=:99 \ TZ=UTC \ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 # 第一层:图形栈 + 浏览器 + 字体,一次装完,避免多层缓存互相打架 RUN apt-get update && apt-get install -y --no-install-recommends \ chromium xvfb openbox x11vnc novnc websockify \ x11-utils fonts-noto-cjk fonts-noto-color-emoji \ ca-certificates curl sqlite3 tini procps \ && rm -rf /var/lib/apt/lists/* # 第二层:Node 20 RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json ./ RUN npm install --omit=dev && npm cache clean --force COPY entrypoint.sh export-state.js restore-state.js ./ RUN chmod +x entrypoint.sh EXPOSE 5900 6080 9222 ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/app/entrypoint.sh"]x11-utils提供xdpyinfo,启动脚本要用它判断虚拟屏幕是否就绪;fonts-noto-cjk是中文站点的必需品,缺了就是一片方框;tini做 1 号进程,负责把信号传给子进程,否则docker stop要等十秒才杀掉,浏览器来不及把 profile 写回磁盘,Cookies这个 SQLite 可能停在 WAL 里没落盘。
3.2 启动脚本:顺序错了画面就出不来
#!/usr/bin/env bash set -euo pipefail PROFILE_DIR="${PROFILE_DIR:-/data/profile}" SCREEN="${SCREEN:-1600x900x24}" VNC_PORT="${VNC_PORT:-5900}" WEB_PORT="${WEB_PORT:-6080}" mkdir -p "$PROFILE_DIR" # 1. 虚拟屏幕。--nolisten tcp 避免 X 自己再开一个监听 Xvfb :99 -screen 0 "$SCREEN" -nolisten tcp & for i in $(seq 1 40); do xdpyinfo -display :99 >/dev/null 2>&1 && break sleep 0.25 done # 2. 窗口管理器,必须在浏览器之前 openbox & # 3. x11vnc:-shared 允许多人看同一画面,-localhost 只给本机 websockify 用 x11vnc -display :99 -rfbport "$VNC_PORT" -localhost -shared -forever -nopw \ -bg -o /var/log/x11vnc.log # 4. noVNC 把 5900 包成 WebSocket websockify --web=/usr/share/novnc "$WEB_PORT" "localhost:$VNC_PORT" & # 5. 浏览器最后起,保证前面三件套都已就绪 exec chromium \ --user-data-dir="$PROFILE_DIR" \ --password-store=basic \ --no-first-run --no-default-browser-check \ --disable-dev-shm-usage \ --no-sandbox \ --remote-debugging-port=9222 \ --remote-debugging-address=0.0.0.0 \ --window-size=1600,900逐条说下参数取舍。--password-store=basic是整篇里最容易被漏、后果最大的一个:Linux 上 Chromium 默认用系统密钥环加密Cookies里的值,容器里没有密钥环它会退回固定密钥,但一旦宿主或镜像里有 gnome-keyring,就会用机器相关的密钥,profile 整包搬到另一台机器上,cookie 值解不开,等于没搬。显式指定basic就把这条路堵死了,代价是 cookie 在磁盘上是弱加密,所以镜像只跑内部测试账号。
--no-sandbox是因为容器内以 root 运行,不加它 Chromium 直接拒绝启动。这条有安全代价,我的做法是容器只挂内部测试域名、只登测试账号,不往里放真实凭据。
--disable-dev-shm-usage让 Chromium 用/tmp而不是/dev/shm,和前面--shm-size=1g是双保险,页面重的时候不至于整页白屏。
3.3 noVNC 不能裸奔,加一道 basic auth
镜像里x11vnc用了-nopw,因为它只监听回环,外部拿不到。真正对外的是 6080,这一层必须加鉴权。我一般不再引入额外组件,直接用 nginx 在 noVNC 前面挡一层:
server { listen 443 ssl; server_name browser.example.test; auth_basic "shared-browser"; auth_basic_user_file /etc/nginx/.htpasswd; location / { proxy_pass http://127.0.0.1:6080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } }关键只有两行:Upgrade和Connection,noVNC 走的是 WebSocket,缺了这两行,页面能打开、点“连接”之后一直转圈然后断开,日志里什么都看不到。proxy_read_timeout给到 3600 秒,否则挂在那里不动一分钟就被断流。
htpasswd用htpasswd -c /etc/nginx/.htpasswd tester生成,一人一个账号,方便出问题时按人定位。
3.4 第一次起来的确认动作
docker build -t shared-browser:1.0 . docker run -d --name shared-browser-a --shm-size=1g \ -p 127.0.0.1:6080:6080 -p 127.0.0.1:9222:9222 \ -v /srv/shared-browser/profile-a:/data \ shared-browser:1.0 sleep 5 curl -s http://127.0.0.1:9222/json/version | head -n 6/json/version返回里能看到Browser和webSocketDebuggerUrl两个字段,说明 CDP 通了;再打开https://browser.example.test/vnc.html,能看到一个空白的 Chromium 窗口、鼠标能点、地址栏能输入,说明画面链路也通了。两头都通,才算这个容器装完,后面导 session 才有意义。
4. session 克隆:导出、传输、还原三段落
这一章是「克隆Session 共享浏览器」的核心。先说清 Playwright 的storageState到底能带什么,再说两条路线——只带 storageState 的轻量路线,和整包 profile 的重路线——各自的适用边界。
4.1 storageState 覆盖什么,覆盖不了什么
context.storageState()出来的是一个 JSON,只有两个字段:cookies和origins。origins里每个 origin 对应一组localStorage键值。就这些。
| 数据 | storageState 是否包含 | 备注 |
|---|---|---|
| cookies(含 HttpOnly) | 是 | 走 CDP 读,不受 HttpOnly 限制 |
| localStorage | 是 | 按 origin 分组 |
| sessionStorage | 否 | 关页面就没,需要addInitScript单独补 |
| IndexedDB | 否 | 部分站点把 token 存这里,会漏 |
| Service Worker 缓存 | 否 | 影响离线态,不影响登录 |
| 扩展、书签、历史 | 否 | 走整包 profile 路线 |
判断标准很简单:抓一次登录后的请求,看鉴权凭据在Cookie头里,还是在Authorization: Bearer里。在 cookie 里,storageState 够用;如果是前端从 IndexedDB 取 token 再塞进请求头,就得整包 profile,或者针对该站点单独写 IndexedDB 导出。
4.2 在容器里导出 storageState
// export-state.js —— 连上正在运行的浏览器,把 session 抠出来 const fs = require('fs'); const { chromium } = require('playwright-core'); const CDP = process.env.CDP_ENDPOINT || 'http://127.0.0.1:9222'; const TARGET = process.env.TARGET_ORIGIN; // 例如 https://app.example.test const OUT = process.env.OUT || '/data/state.json'; (async () => { const browser = await chromium.connectOverCDP(CDP); const ctx = browser.contexts()[0]; // 复用已有 context,不要新建 const page = ctx.pages()[0] || await ctx.newPage(); await page.goto(TARGET, { waitUntil: 'domcontentloaded' }); // storageState 里的 localStorage 只覆盖已经访问过的 origin, // 所以先把页面导航过去,再读一遍当前 origin 的 localStorage 覆盖回去 const local = await page.evaluate(() => Object.fromEntries(Object.entries(localStorage)) ); const state = await ctx.storageState(); state.origins = [{ origin: new URL(TARGET).origin, localStorage: Object.entries(local).map(([name, value]) => ({ name, value })), }]; fs.writeFileSync(OUT, JSON.stringify(state, null, 2), { mode: 0o600 }); console.log('cookies:', state.cookies.length, '| localStorage keys:', Object.keys(local).length); // 注意:CDP 连上的实例不要调 browser.close(),会连带关掉别人正在看的窗口 process.exit(0); })();connectOverCDP是关键,它连的是已经在跑的那个浏览器,而不是新起一个。如果换成chromium.launch(),导出来的是新实例的空态,白忙一场。mode: 0o600别省,这个文件里带着能直接登录的凭据。
导出后先自查一遍,别急着传:
jq '.cookies | length' /data/state.json jq -r '.cookies[].name' /data/state.json | sort -u | head -n 20 jq '.origins[].origin' /data/state.json如果关键的那个会话 cookie 不在列表里,说明页面没真正进到登录后状态,先回去看画面,不要在脚本上找原因。
4.3 整包 profile:什么时候必须走重路线
整包迁移适合两种场景:站点把凭据放在 IndexedDB;或者要连扩展、书签一起搬。做法是先把浏览器停掉,再打包,顺序不能反。
# 1. 先优雅退出,让 Cookies 这个 SQLite 把 WAL 落盘 docker exec shared-browser-a pkill -TERM chromium || true sleep 3 # 2. 只打 profile 目录,排除缓存和锁文件 tar -C /data -czf /data/profile-$(date +%Y%m%d).tgz \ --exclude='profile/Default/Cache' \ --exclude='profile/Default/Code Cache' \ --exclude='profile/Default/GPUCache' \ --exclude='profile/SingletonLock' \ --exclude='profile/SingletonSocket' \ --exclude='profile/SingletonCookie' \ profile sha256sum /data/profile-*.tgz | tee /data/profile.tgz.sha256排除缓存目录能让包小一大截,更重要的是排除Singleton*三个文件:这三个是进程锁,带着一起走,新机器上浏览器会认为“已有实例在跑”,然后直接退出,日志一句不写。
打包完可以先验一下 cookie 有没有正常落盘:
sqlite3 /data/profile/Default/Cookies \ "select host_key, name, length(encrypted_value), expires_utc from cookies order by host_key limit 20;"expires_utc是 WebKit 时间戳,从 1601 年起的微秒数,不是 Unix 秒。看到一列十几位的大数是对的,看到 0 就是会话 cookie。encrypted_value有长度说明值在,长度是 0 就要怀疑--password-store没配对。
4.4 异地还原:先灌 cookie,再灌 localStorage
// restore-state.js —— 在另一台机器上把 session 灌回去 const fs = require('fs'); const { chromium } = require('playwright-core'); const STATE = process.env.STATE || '/data/state.json'; const PROFILE = process.env.PROFILE_DIR || '/data/profile-restored'; const ORIGIN = process.env.TARGET_ORIGIN; (async () => { const state = JSON.parse(fs.readFileSync(STATE, 'utf8')); const ctx = await chromium.launchPersistentContext(PROFILE, { executablePath: '/usr/bin/chromium', headless: false, args: ['--password-store=basic', '--no-first-run', '--disable-dev-shm-usage', '--no-sandbox'], viewport: { width: 1600, height: 900 }, }); // __Host- 前缀的 cookie 不允许带 domain,必须 path=/ 且 secure=true const cookies = state.cookies.map((c) => { const fixed = { ...c }; if (c.name.startsWith('__Host-')) { delete fixed.domain; fixed.path = '/'; fixed.secure = true; } if (fixed.expires === -1) delete fixed.expires; // 会话 cookie 不要写成 0 return fixed; }); await ctx.addCookies(cookies); // localStorage 只能按 origin 精确写,写成通用赋值会污染别的站点 await ctx.addInitScript((origins) => { const hit = origins.find((o) => o.origin === location.origin); if (!hit) return; for (const { name, value } of hit.localStorage) { localStorage.setItem(name, value); } }, state.origins); const page = await ctx.newPage(); const resp = await page.request.get(`${ORIGIN}/api/whoami`).catch(() => null); console.log('status =', resp && resp.status()); console.log('body =', resp && (await resp.text()).slice(0, 200)); })();三个细节值得单独说。launchPersistentContext而不是newContext,因为要让还原后的 profile 留在磁盘上,人打开 noVNC 还能接着用;addInitScript必须在newPage之前调,它在页面脚本执行前注入,晚了 localStorage 已经被站点读完了;expires: -1直接删字段,写成 0 会被当成 1970 年过期,cookie 立刻失效,这个错很隐蔽,现象是“cookie 条数对得上,就是登不上”。
5. 排查:五个最容易出问题的地方和对应做法
5.1 现象:noVNC 页面能开,连上是黑屏
原因基本在 x11vnc 这一层。要么x11vnc起在 Xvfb 之前,连上去时屏幕还不存在;要么-localhost加上 websockify 的目标写成了容器名而不是localhost,握手过不去;要么x11vnc进程已经被-bg派生出去,主进程退出,容器看着在跑,实际没有 VNC 服务。
解决顺序:先ps -ef | grep -E 'Xvfb|x11vnc|websockify'看三个进程在不在;再看/var/log/x11vnc.log末尾有没有XOpenDisplay failed;最后确认websockify的目标参数。我在启动脚本里加了xdpyinfo轮询,就是为了把第一个原因彻底消掉。
5.2 现象:cookie 灌进去了,打开还是要登录
概率最高的三个原因。一是站点有__Host-前缀的 cookie,带上 domain 就被浏览器丢弃,静默失败;二是登录态里还有一段存在sessionStorage,storageState天生不带;三是导出端和还原端的 Chromium 小版本不一致,cookie 的sameSite或partitionKey字段被丢掉。
定位办法是在还原后用 CDP 把 cookie 倒出来对比:
# 还原后重新读一次,和 state.json 里的 name 列表比对 TARGET_ORIGIN=https://app.example.test OUT=/data/after.json node export-state.js diff <(jq -r '.cookies[].name' /data/state.json | sort) \ <(jq -r '.cookies[].name' /data/after.json | sort)有差异就是被丢弃了,按前缀规则补字段;没差异就去看 sessionStorage,在浏览器控制台执行JSON.stringify(sessionStorage),非空就说明还差这一段。
5.3 现象:profile 整包搬过去,浏览器起不来或直接重置
Profile error occurred或者打开是一片空白的新用户界面,说明 profile 被判为不兼容。原因通常是两端 Chromium 版本不同,高版本写的数据结构低版本读不懂;另一个高频原因是搬了Singleton*锁文件,浏览器认为有实例占用,直接退出。
解决:镜像标签固定,导出和还原用同一个 tag,docker images里只保留一个;打包时按前面的--exclude排除锁文件;实在要跨版本,先在新版本里开一个空 profile,再把Default/Cookies、Default/Local Storage这两个目录单独搬过去,别整包搬。
5.4 现象:起第二个实例,没有任何报错,就是没窗口
--user-data-dir撞了。Chromium 允许同一个 profile 目录被多次启动,但只有第一个生效,后面的一次会把参数转发给已有实例然后退出,退出码还是 0,docker logs里干干净净。
解决:一个实例一个 profile 目录,一个实例一个 CDP 端口,命名上成对出现(profile-a配9222,profile-b配9223)。我在启动脚本里加了一行前置检查,撞目录就直接失败,比无声退出好查:
if [ -e "$PROFILE_DIR/SingletonLock" ]; then echo "profile already in use: $PROFILE_DIR" >&2 exit 1 fi5.5 现象:中文站点全是方框,输入框还打不出中文
镜像里没有 CJK 字体,或者装了但没刷字体缓存。fonts-noto-cjk装上之后要跑一次fc-cache -fv,Debian 的 slim 镜像里有时不会自动刷。输入法这一层在容器里基本无解,常见做法是把要输入的内容在宿主机写好,通过 noVNC 的剪贴板面板贴进去,别指望在容器里装输入法。
字体这块提前做掉最省事:把fonts-noto-cjk写进 Dockerfile 的依赖层,比事后进容器补装干净得多,也不会出现“我这台机器正常、同事那台方框”的情况。
6. 装完怎么验证跑通:四步验收
装完不等于跑通。我现在固定跑四步,任何一步不过就不往下走。
第一步看画面。打开https://browser.example.test/vnc.html,地址栏能输入、鼠标点击有响应、chrome://version里显示的Profile Path是/data/profile。这一步确认图形链路和挂载目录都对。
第二步确认 CDP:
curl -s http://127.0.0.1:9222/json/version | jq -r '.Browser, .webSocketDebuggerUrl'两个字段都有输出才算通。只有Browser没有webSocketDebuggerUrl,说明--remote-debugging-address没配对。
第三步走一次完整的 session 克隆,把导出、传输、还原串起来:
# 导出端 TARGET_ORIGIN=https://app.example.test OUT=/data/state.json node export-state.js # 传输 rsync -avz --checksum /data/state.json dev@10.0.0.12:/data/inbox/ # 还原端 TARGET_ORIGIN=https://app.example.test STATE=/data/inbox/state.json \ PROFILE_DIR=/data/profile-restored node restore-state.js第四步比对结果。这一步最容易被跳过,也最能暴露问题:
| 验收项 | 怎么查 | 通过标准 |
|---|---|---|
| cookie 条数 | `jq '.cookies | length' state.json` |
| 关键 cookie | jq -r '.cookies[].name' | 会话 cookie 在列 |
| localStorage | `jq '.origins[].localStorage | length'` |
| 登录态 | /api/whoami返回 | 200 且用户标识一致 |
| 带锁文件 | ls /data/profile-restored/SingletonLock | 不存在 |
whoami那一步我要求打印出用户标识而不是只看状态码。有一次状态码 200,返回的却是匿名用户,原因是还原时expires写成了 0,cookie 存在但立即过期;只盯状态码根本发现不了。
从那以后我每次做 session 迁移,都强制先把「导出端 whoami、还原端 whoami」两行输出贴在一起对比,两边一致才算交付,再忙也不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取