目录一、扫码登录要解决的真问题二、完整流程从生成二维码到换取登录态三、动手写一个能跑的扫码登录后端四、为什么是长轮询而不是 WebSocket五、一次领取三层保护缺一不可六、状态机pending 到 confirmed 到 consumed七、二维码登录劫持原理与三道防线八、它和 CSRF、和第三方扫码的区别九、一份能直接抄的排查清单一、扫码登录要解决的真问题先摆平一个常见误解扫码登录不是「更方便的输密码方式」它解决的是两个更底层的问题。问题一PC 端没有输入凭证的能力。密码登录的前提是「用户能在输入框里敲字符」而扫码登录的适用场景恰恰是输入能力被拿走的地方场景密码登录为什么不行收银一体机、货架终端无物理键盘触屏软键盘慢到崩溃还容易把密码敲错框电视、车机、大屏遥控器输入输一个密码要按几十次方向键内网机器禁止复制粘贴口令粘不进去只能手敲问题二二维码天然携带了设备绑定信息。二维码内容不是裸账号而是一串服务端自己签发的一次性随机串本文叫ticket服务端生成、服务端知道它对应哪台 PC、几十秒内有效、天生只能用一次。二维码把「谁在登录」从「用户口述」变成了「服务端自己记账」—— 密码登录时服务端只知道「有人提交了正确的密码」扫码登录时服务端知道「是这台 PC 的请求被持有账号 X 的手机确认了」。顺带说清定位扫码登录不替代密码它是把凭证从 PC 端转移到手机端。手机端点「确认登录」等价于在 PC 端输入密码确认页每次点击的权限都要给足。二、完整流程从生成二维码到换取登录态拆成五步每一步都有明确的接口和明确的状态第 1 步PC 生成二维码。PC 端调POST /api/qr/create服务端生成随机ticket32 位 hex落缓存状态置pending记下过期时间我设 180 秒把「扫码 URL ticket」渲染成二维码。第 2 步手机扫码查状态。手机解析出ticket问服务端。服务端返回pending同时返回要在手机上展示给人看的信息—— 这一步常被忽略但它是防劫持的关键。第 3 步手机确认服务端发一次性 code。用户点「确认登录」服务端把 ticket 改成confirmed同时生成一个一次性授权码code写进缓存TTL 60 秒。第 4 步PC 长轮询拿 code。没确认就挂着等确认了立刻返回。第 5 步PC 用 code 换登录态。PC 提交code服务端校验后签发属于这台 PC 的那个会话Cookie / Token。注意第 3、4 步的分工手机只发 code从不碰 PC 的登录态PC 只拿 code从不碰手机端的会话凭证。中间那个code是一次性的交接棒 —— 为什么非要过这一道因为载荷必须与发起方绑定PC 拿 code 换自己的会话而不是把「我是 u_10086」写在 URL 里传。三、动手写一个能跑的扫码登录后端四个接口一个 Flask 文件。服务端骨架# -*- coding: utf-8 -*- 晚风奶茶WANFENG会员系统 · 扫码登录靶场 Python 3.13 Flask 3.1 redis-py 5.xRedis 7.x 协议 ⚠️ 本机靶场所有接口只跑在 127.0.0.1禁止部署到公网。 import hashlib import json import os import time import uuid from urllib.parse import unquote import redis from flask import Flask, jsonify, request app Flask(__name__) R redis.Redis.from_url(os.getenv(REDIS_URL, redis://127.0.0.1:6379/0), decode_responsesTrue) # DEMO_INSECURE1 时关掉账号一致性和 Origin 校验用来复现二维码登录劫持 INSECURE os.getenv(DEMO_INSECURE) 1 QR_TTL 180 # 二维码有效期 3 分钟 CODE_TTL 60 # 授权码有效期 1 分钟 POLL_MAX 60 # 长轮询总上限秒 POLL_STEP 2 # 长轮询内部检查间隔秒 ALLOWED_ORIGINS {http://127.0.0.1:8020, http://127.0.0.1:8021} # 演示用的「手机端已登录会话」真实系统里 uid 从手机端登录态取不信前端传值 PHONE_SESSIONS { phone-demo-9f2a: {uid: u_10086, phone: 13800138000}, phone-demo-77bc: {uid: u_20751, phone: 13900139000}, } def sha256(s: str) - str: return hashlib.sha256(s.encode()).hexdigest() def origin_ok() - bool: origin request.headers.get(Origin) return origin is None or origin in ALLOWED_ORIGINSRedis 里两个 key# qr:ticket:{ticket} Hash status/pc/account/device/code/uid TTL 180 # qr:code:{code} String {uid: ..., pc: ...} TTL 60 # # pc 字段存 PC 会话 id 的 sha256不是明文 —— 日志万一漏了也不至于直接可用3.1 PC 端生成二维码app.post(/api/qr/create) def qr_create(): PC 生成二维码随机 ticket 落库绑定本 PC 会话 body request.get_json(silentTrue) or {} account body.get(account, ) # PC 登录页上输入的账号 pc_sid request.cookies.get(pc_sid) or uuid.uuid4().hex ticket uuid.uuid4().hex R.hset(fqr:ticket:{ticket}, mapping{ status: pending, pc: sha256(pc_sid), account: account, device: body.get(device, ), }) R.expire(fqr:ticket:{ticket}, QR_TTL) resp jsonify({ ticket: ticket, qr_url: fhttp://127.0.0.1:8020/scan?ticket{ticket}, expires_in: QR_TTL, poll: {interval: POLL_STEP, timeout: POLL_MAX}, display_account: account, # 回显给 PC 页面让用户核对 }) resp.set_cookie(pc_sid, pc_sid, httponlyTrue, samesiteLax, max_age3600) return resp实测响应和 Redis 里的状态{ display_account: 13800138000, expires_in: 180, poll: { interval: 2, timeout: 60 }, qr_url: http://127.0.0.1:8020/scan?ticketac9daf95905c46e98247e37620b56880, ticket: ac9daf95905c46e98247e37620b56880 }HGETALL qr:ticket:ac9daf95905c46e98247e37620b56880 {status: pending, pc: ada7a543f632e1604a342eea5841f5fdebfae3386f39a3d541b0de8d1fd0eb0c, account: 13800138000, device: Windows 11} TTL qr:ticket:ac9daf95905c46e98247e37620b56880 1803.2 手机端查状态 确认app.get(/api/qr/status) def qr_status(): 手机扫码后先查状态拿到要展示给人看的信息 ticket request.args.get(ticket, ) data R.hgetall(fqr:ticket:{ticket}) if not data: return jsonify({ok: False, error: 二维码已过期或不存在}), 404 if data.get(status) ! pending: return jsonify({ok: False, error: 该二维码已被处理, status: data[status]}), 409 return jsonify({ ok: True, status: pending, need_confirm: True, display_account: data.get(account, ), pc_device: data.get(device, ), pc_location: unquote(request.headers.get(X-Geo, 未知地区)), }) app.post(/api/qr/confirm) def qr_confirm(): 用户在手机上点「确认登录」 if not INSECURE and not origin_ok(): return jsonify({ok: False, error: 跨站请求被拒绝}), 403 body request.get_json(silentTrue) or {} ticket body.get(ticket, ) sess PHONE_SESSIONS.get(request.headers.get(X-Phone-Session, )) if not sess: return jsonify({ok: False, error: 手机端未登录}), 401 key fqr:ticket:{ticket} data R.hgetall(key) if not data: return jsonify({ok: False, error: 二维码已过期}), 404 # 账号一致性PC 端必须先声明要登哪个账号且要和手机端当前账号完全一致 if not INSECURE: if not data.get(account): return jsonify({ok: False, error: PC 端未声明登录账号拒绝确认}), 400 if data[account] ! sess[phone]: return jsonify({ok: False, error: PC 端请求登录的账号与当前账号不一致, pc_account: data[account], phone_account: sess[phone]}), 409 code uuid.uuid4().hex payload json.dumps({uid: sess[uid], pc: data.get(pc, )}) r R.eval(CONFIRM_LUA, 2, key, fqr:code:{code}, code, QR_TTL, sess[uid], payload, CODE_TTL) if r -1: return jsonify({ok: False, error: 二维码已过期}), 404 if r 0: return jsonify({ok: False, error: 该二维码已被确认过}), 409 return jsonify({ok: True, ticket: ticket})这里最关键的一行是uid的来源。我没从请求体读用户 ID而是从X-Phone-Session查出来sess PHONE_SESSIONS.get(request.headers.get(X-Phone-Session, )) uid sess[uid]如果写成uid request.json.get(uid)任何人都能把 uid 改成别人的 ID 来确认登录 —— 授权动作就变成「谁都能替别人点确认」。授权凭证必须来自服务端签发的会话不是请求体。3.3 PC 端长轮询 兑换登录态app.get(/api/qr/poll) def qr_poll(): PC 长轮询服务端挂起连接每 2 秒查一次最多 60 秒 ticket request.args.get(ticket, ) key fqr:ticket:{ticket} deadline time.time() POLL_MAX while True: data R.hgetall(key) if not data: return jsonify({status: expired, msg: 二维码已过期请刷新}) if data.get(status) confirmed: code R.eval(CLAIM_LUA, 1, key) if code: return jsonify({status: confirmed, code: code}) return jsonify({status: expired, msg: 该二维码已被其他请求领取}) if data.get(status) consumed: return jsonify({status: expired, msg: 该二维码已被使用}) if time.time() deadline: return jsonify({status: timeout, msg: 等待超时请刷新重试}) time.sleep(POLL_STEP) app.post(/api/session/exchange) def session_exchange(): PC 拿 code 换自己的登录态GETDEL 原子取删并校验 PC 会话绑定 body request.get_json(silentTrue) or {} code body.get(code, ) pc_sid request.cookies.get(pc_sid, ) raw R.getdel(fqr:code:{code}) # 原子并发下只可能被兑换一次 if not raw: return jsonify({ok: False, error: 授权码无效或已被使用}), 401 payload json.loads(raw) if payload.get(pc) ! sha256(pc_sid): return jsonify({ok: False, error: 授权码不属于当前设备}), 403 token uuid.uuid4().hex R.setex(fmilktea:session:{token}, 3600, payload[uid]) resp jsonify({ok: True, token: token, uid: payload[uid]}) resp.set_cookie(milktea_session, token, httponlyTrue, samesiteLax) return resp跑一遍完整流程PC 端拿到的最终结果--- A5. PC 用 code 换登录态 --- HTTP 200 { ok: true, token: b929ca5387b04ad8bb210ea33e6d9038, uid: u_10086 } --- A6. 验证登录态 --- HTTP 200 { logged_in: true, uid: u_10086 } --- A7. 同一个 code 再兑换一次应 401--- HTTP 401 { error: 授权码无效或已被使用, ok: false }A7 是重点同一个 code 第二次兑换直接 401。这不靠「用完记得删」实现靠的是GETDEL这一个原子命令下文详述。四、为什么是长轮询而不是 WebSocket扫码登录的信息推送有四种常见做法方案怎么工作扫码登录场景的适配度短轮询客户端每 2 秒问一次「好了吗」能用但请求数 等待时长 / 间隔用户不扫就是纯浪费长轮询服务端挂住连接直到有结果或超时✅ 最合适一次连接解决用户不扫时占用可控SSE服务端通过常驻 HTTP 流单向推送要处理断线重连、Last-Event-ID续传收益不明显WebSocket双向长连接协议杀鸡用牛刀多一套连接池和鉴权还有一个常被忽略的理由降级容易—— 它本质还是普通 HTTP 请求超时了客户端重发就行。但长轮询必须有上限。这是实操里最容易翻车的地方deadline time.time() POLL_MAX # POLL_MAX 60 while True: ... if time.time() deadline: return jsonify({status: timeout, msg: 等待超时请刷新重试}) time.sleep(POLL_STEP) # POLL_STEP 2两个参数缺一不可间隔2 秒太小压垮服务端太大用户觉得卡总上限60 秒必须有 —— 没有上限用户离开页面后连接会一直挂着直到中间层proxy_read_timeout替你杀掉它用户看到的是「转圈转到天荒地老」不是明确的「二维码已过期」。实测无人扫码时的行为--- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { msg: 等待超时请刷新重试, status: timeout }关于退避压力大时可以指数退避2s → 4s → 8s但退避上限要和总超时对齐—— 总超时 60 秒而间隔退避到 32 秒体感会明显变差。我建议固定 2 秒60 秒 × 30 次 30 个请求扛得住。五、一次领取三层保护缺一不可「一次领取」one-time use听上去是一件事其实是三件独立的事任何一层单独做都不够。第一层ticket 一次性ticket 的「一次性」体现在状态机只允许往前推一次pending → confirmed → consumed。我用 Lua 脚本因为「读状态、判断、写入」这三步必须原子完成# ---- 原子脚本 1pending - confirmed重复确认直接被挡 ---------- CONFIRM_LUA local st redis.call(HGET, KEYS[1], status) if not st then return -1 end if st ~ pending then return 0 end redis.call(HSET, KEYS[1], status, confirmed, code, ARGV[1], uid, ARGV[3]) redis.call(EXPIRE, KEYS[1], ARGV[2]) redis.call(SET, KEYS[2], ARGV[4], EX, ARGV[5]) return 1 # ---- 原子脚本 2confirmed - consumedconfirmed 只被读一次 ------ CLAIM_LUA local st redis.call(HGET, KEYS[1], status) if not st then return nil end if st ~ confirmed then return nil end redis.call(HSET, KEYS[1], status, consumed) redis.call(EXPIRE, KEYS[1], 30) return redis.call(HGET, KEYS[1], code) CLAIM_LUA值得单独说PC 轮询拿到 code 的那一刻ticket 状态就从confirmed变成consumed。这不是优化是安全属性 —— 否则被复制走的 ticket 还能被第二个 PC 再领一次。第二层code 一次性短效code 的属性是60 秒内有效用过即删。错误写法raw R.get(key) # ① 先读 if raw: ... R.delete(key) # ② 再删这中间有一个竞态窗口。我写了个 20 线程对照实验# -*- coding: utf-8 -*- 兑换 code 的并发竞态先 GET 再 DEL vs GETDEL import threading, time, uuid, redis R redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) N 20 def race_get_then_del(): 错误写法GET 和 DEL 是两条命令中间有竞态窗口 key demo:code: uuid.uuid4().hex R.set(key, PAYLOAD, ex60) ok [] lock threading.Lock() barrier threading.Barrier(N) def worker(): barrier.wait() v R.get(key) # ① 读到 time.sleep(0.02) # ② 网络/序列化耗时窗口就在这 if v: R.delete(key) # ③ 再删 with lock: ok.append(v) ts [threading.Thread(targetworker) for _ in range(N)] [t.start() for t in ts]; [t.join() for t in ts] return len(ok)实测输出并发线程数 20同一个 code 只应被兑换 1 次 GET DEL 分几次成功兑换: 6 GETDEL 分几次成功兑换: 1一个 code 被成功兑换了 6 次。这 6 个线程都读到了 code都以为自己拿到了授权。Redis 6.2 之后的GETDEL一条命令完成「读 删」def race_getdel(): 正确写法GETDEL 是一条原子命令 key demo:code: uuid.uuid4().hex R.set(key, PAYLOAD, ex60) ok [] lock threading.Lock() barrier threading.Barrier(N) def worker(): barrier.wait() v R.getdel(key) # 原子读删 if v: with lock: ok.append(v) ts [threading.Thread(targetworker) for _ in range(N)] [t.start() for t in ts]; [t.join() for t in ts] return len(ok)除了GETDEL还有两种等价方案方案写法适用GETDELR.getdel(key)Redis 6.2首选最简单SET NX占位if R.set(lock_key, 1, nxTrue, ex60): ...需要「先占坑再干活」的场景Lua 脚本读 校验 删 写在一个脚本里校验逻辑复杂时本文CLAIM_LUA就是这种注意GETDEL的副作用删完才校验 PC 会话绑定所以校验失败返回 403 时 code 也已经没了 —— 这是对的行为。一次失败尝试就该消耗凭证否则攻击者能拿别人的 code 无限试。第三层code 与 PC 端会话绑定前两层保证「同一个 code 只能用一次」但没保证「只能被正确的那个 PC用一次」。code 一旦出现在 URL 里或被截获攻击者就能拿它换登录态。第三层是code 里存了生成它的那个 PC 会话 id 的哈希兑换时校验。raw R.getdel(fqr:code:{code}) if not raw: return jsonify({ok: False, error: 授权码无效或已被使用}), 401 payload json.loads(raw) if payload.get(pc) ! sha256(pc_sid): # ← 绑定校验 return jsonify({ok: False, error: 授权码不属于当前设备}), 403实测把 code 拿到另一台 PC 上兑换。--- B3. 把 code 拿到另一台 PC 上兑换应 403--- HTTP 403 { error: 授权码不属于当前设备, ok: false }六、状态机pending 到 confirmed 到 consumed合起来ticket 就是一个四状态机。所有流转必须是原子的否则状态会走岔。PC 生成二维码 手机点确认 PC 领走 code ○ ───────────────→ pending ───────────→ confirmed ───────────→ consumed │ │ │ 超时TTL 到期 │ └──────────────→ expired ←────────────┘pending允许手机查和确认confirmed只允许 PC 领一次 codeconsumed和expired什么都不接受。价值在于每个非法转移都有明确的拒绝理由--- B1. 同一个二维码重复确认应 409--- HTTP 409 { error: 该二维码已被确认过, ok: false } --- B2. 查一个不存在的 ticket应 404--- HTTP 404 { error: 二维码已过期或不存在, ok: false }为什么一定要用 Lua 而不是「先 HGET 再 HSET」因为「读 status、判断是不是 pending、写成 confirmed」分成三条命令时两个并发的确认请求可能都读到pending然后都写confirmed于是生成两个 code、覆盖掉其中一个。CONFIRM_LUA把判断和写入塞进一个脚本在 Redis 里原子执行。返回值三态-1ticket 不存在4040状态不是 pending4091成功。七、二维码登录劫持原理与三道防线先说清楚它和 CSRF 有什么不一样。CSRF网站替用户发请求用户没做任何操作浏览器自动带着 Cookie 发了个 POST。二维码登录劫持用户主动点了确认但确认的不是自己那台设备。区别在于 —— 授权动作真实发生了是受害者亲手点的确认按钮服务端看到的是一个完全合法的请求ticket 合法、code 合法、请求来自持有账号的手机。服务端没有任何技术手段能识别这次确认「不该发生」唯一防线是让用户在确认前看出不对劲。攻击链条我在自己的靶场上完整复现了一遍DEMO_INSECURE1只打127.0.0.1第 1、2 步攻击者生成属于他自己的二维码再送到受害者面前。他拿到一个绑定他自己 PC 会话的ticket贴在共享充电宝上、印在传单上、或者把真实登录页上的码换掉。第 3 步受害者扫码看到「确认登录」点了确认。--- 步骤2. 受害者手机扫码服务端回给他要展示的信息 --- HTTP 200 { display_account: , need_confirm: true, ok: true, pc_device: Ubuntu 24.04 / Firefox 133, pc_location: 浙江 杭州, status: pending } --- 步骤3. 受害者点了「确认登录」 --- HTTP 200 { ok: true, ticket: 3ba9ef4f3509419887a6c231043e3179 }第 4 步攻击者的 PC 轮询到 code 并兑换。--- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { code: 433ab7afd64e40f2b981968591e8e93a, status: confirmed } --- 步骤5. 攻击者兑换 code拿到了受害者账号的登录态 --- HTTP 200 { ok: true, token: 17f490a397d84570b60aef7af3ab83dc, uid: u_10086 } --- 步骤6. 攻击者以受害者身份访问会员接口 --- HTTP 200 { logged_in: true, uid: u_10086 }看清楚第 5 步uid是u_10086受害者的账号。攻击者拿到的是受害者账号的会话—— 因为点确认的是受害者本人。这也解释了为什么第三层「code 绑定 PC 会话」挡不住它code 里的pc哈希就是攻击者那台 PC 的他用自己的 PC 兑换校验天然通过。三道防线防线一让用户在确认前能核对信息。这是唯一真正有效的防线核心是给用户足够的信息去发现异常PC 端扫码前就显示「你正在用哪个账号登录」脱敏移动端在确认按钮上方必须显示当前设备和地点。用户扫的是别人的码页面上却显示着自己的账号 —— 这就是他能察觉的破绽。靶场里这三个字段的唯一作用就是被人看到。防线二Origin 校验与 CSRF 防护。确认接口必须拒绝跨站请求if not INSECURE and not origin_ok(): return jsonify({ok: False, error: 跨站请求被拒绝}), 403实测--- B4. 跨站 Origin 提交确认应 403--- HTTP 403 { error: 跨站请求被拒绝, ok: false }这能挡住「攻击者做个网页用受害者浏览器自动提交确认」这种半自动手法但挡不住「受害者亲手扫码」—— 那种情况下 Origin 是干净的。所以这层是补充不是主防线。防线三二维码内容用短时效随机串。二维码里绝不能放 uid、手机号、或任何「长期有效 可猜」的东西必须是服务端签发、几十秒过期、只能用一次的随机串。我用uuid.uuid4().hex128 位随机TTL 180 秒 ——攻击者就算截获了也只能在这个很短窗口内用掉。再加账号一致性校验兜底 —— PC 端必须先声明要登哪个账号手机端确认时账号必须完全一致if not data.get(account): return jsonify({ok: False, error: PC 端未声明登录账号拒绝确认}), 400 if data[account] ! sess[phone]: return jsonify({ok: False, error: PC 端请求登录的账号与当前账号不一致, ...}), 409把加固版服务跑一遍同样的攻击链条链条在第 3 步就断了--- 步骤3. 受害者点了「确认登录」 --- HTTP 400 { error: PC 端未声明登录账号拒绝确认, ok: false } --- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { msg: 等待超时请刷新重试, status: timeout } --- 步骤6. 攻击者以受害者身份访问会员接口 --- HTTP 200 { logged_in: false, uid: null }最后还有高风险操作的二次验证新设备、新地点、涉及资金的操作再要一次短信验证码以及一个容易被忽略的点 ——确认页不能只写「确认登录」四个字要给出足够上下文让用户判断。八、它和 CSRF、和第三方扫码的区别这三个概念经常被混着讲边界其实很清楚维度扫码登录本系统内第三方 App 扫码登录CSRF认证委托给谁本系统自己委托给已有账号体系对方说「这个人是 u_10086」不涉及认证手机端在做什么用本系统的登录态确认用第三方的登录态换本系统凭证浏览器自动带 Cookie 发请求授权动作用户点确认用户点确认用户没做任何动作主要风险二维码被替换劫持授权码被截获、state 校验缺失站点替用户发请求关键防线核对信息 短随机串state 一次性 codeSameSite CSRF Token第三方扫码登录多出来一个关键环节state。它是随机串同时放进「授权请求 URL」和「回调 URL」回调时校验两者是否一致 —— 防止攻击者把别人的授权码和自己的state拼在一起。本质上是防 CSRF 的手法。本系统的扫码登录没有第三方所以必须自己实现等价的东西—— 我这里对应角色的是ticket PC 会话绑定 Origin 校验。还有个概念必须分清二维码 ≠ 二维码携带的内容安全。二维码只是编码方式里面 URL 可以是任意内容安全性全来自服务端对那个串的管控。九、一份能直接抄的排查清单扫码登录出问题时按这个顺序查现象先查什么二维码扫出来是空白/乱码前端渲染问题。查qr_url拼接和qrcode库版本扫码提示「二维码已过期」ticket 的 TTL 是不是太短我设 180 秒手机和服务器有没有时钟偏差「我明明扫了」但 PC 一直转圈看/api/qr/poll有没有进来。PC 和手机是不是同一个 RedisREDIS_URL「该二维码已被确认过」409有重复确认请求。查前端有没有重复提交「授权码无效或已被使用」401code 过期60 秒 TTL或已被兑换。查qr:code:*的 TTL「授权码不属于当前设备」403PC 会话pc_sidCookie丢了或换了。常见于多标签页、跨域三个最常见的坑最后再强调一次坑一二维码过期时间太短。我见过设成 30 秒的用户掏手机、找摄像头、对焦、切换 APP —— 40 秒就没了。180 秒到 3 分钟更合理。坑二轮询没有超时上限。用户关掉页面后连接还挂着必须有POLL_MAX。坑三code 被日志打印出来。这条最隐蔽2026-10-30 22:50:15,302 INFO 用户确认登录coded408ef2c83c443f5956f6fa39b2c538a, tickete61e8aea1e4a4fd8acbf4aba0734211b日志系统保存周期更长、权限更宽松还有第三方采集这行日志等于把一次性凭证抄送给了所有能看日志的人。正确做法是只记前 8 位logging.info(用户确认登录code%s, ticket%s, code[:8] ****, ticket[:8] ****) # 用户确认登录coded408ef2c****, tickete61e8aea****排查时的两个提效技巧一是直接看 Redis 里的状态redis-cli HGETALL qr:ticket:ticket redis-cli TTL qr:ticket:ticket redis-cli KEYS qr:code:*二是用 curl 绕过前端验证。长轮询在前端有各种封装curl 能让你看到服务端到底返回了什么curl -i -X POST http://127.0.0.1:8020/api/qr/create \ -H Content-Type: application/json \ -H Origin: http://127.0.0.1:8020 \ -d {account:13800138000,device:Windows 11}
企业数字化 ERP 产品动态
相关推荐
LInux | sudo 与 su 操作 sudo 与 su 操作手册
目录
sudoers 配置sudo 常用操作sudo 安全机制sudo 高级用法安全规范故障排查同类工具对比su、sudo 环境与 PATH 行为 1 sudoers 配置
sudo 的权限控制依托 /etc/sudoers,文件默认权限 440。
不直接使用 vi、vim 编辑,语法错误会… · 2026/11/5 5:31:08
焊接还是压接?USB、DC 线焊点虚焊的判定与工艺边界 焊点是冶金连接,压接是冷变形机械连接,失效模式不同:压接坏了是电阻慢慢爬、通电发热;焊点坏了常常一次震动就断,而静态通电还是通的。判焊点好坏,第一判据是润湿角,不是「亮不亮」。大电流、强… · 2026/11/5 5:30:08
嵌入式 Linux 笔记:应用程序、设备驱动与内核之间的关系 一、整体关系在典型嵌入式 Linux 系统中:应用程序运行在用户空间,负责实现产品业务功能。设备驱动通常运行在内核空间,负责控制具体硬件。Linux 内核负责进程调度、内存管理、文件系统、网络协议栈、设备管理,并提供系统调用和设备… · 2026/11/5 5:30:08
2026年Typora依然是最佳Markdown编辑器的三大理由 1. 为什么在2026年我依然选择Typora作为一个重度Markdown用户,我几乎尝试过市面上所有主流编辑器。从早期的Mou到后来的VS CodeMarkdown插件,从Notion到Obsidian,最终在2026年这个时间节点,我依然选择回归Typora。这不是简单的怀旧… · 2026/11/5 6:13:22
区块链与智能冷链优化中非海鲜贸易 1. 项目背景与行业痛点中非海鲜贸易长期以来面临着一个有趣的矛盾:一方面非洲西海岸盛产优质龙虾等海产品,另一方面中国作为全球最大的海鲜消费市场存在巨大需求。然而传统的跨境海鲜贸易链路存在几个核心痛点:冷链物流成本高昂(占… · 2026/11/5 6:13:22
自动驾驶边云协同架构设计与实践 1. 自动驾驶系统的架构演进与挑战自动驾驶技术正在经历从单车智能向协同智能的转变过程。早期的自动驾驶系统主要依赖车载计算单元处理传感器数据,这种架构面临着算力瓶颈、感知范围有限和长尾场景应对能力不足等问题。一辆普通自动驾驶车辆每天产生的数据量可达4TB… · 2026/11/5 6:13:22
CMS系统选型指南:WordPress、Drupal与Shopify对比 1. CMS系统基础概念解析CMS(Content Management System)本质上是一个允许非技术人员通过图形界面管理网站内容的软件平台。想象一下,这就像给你的网站装了一个"驾驶舱"——不需要懂发动机原理也能开车。从技术架构看,现… · 2026/11/5 6:13:22
图论问题:如何最小化连接两棵树后的直径 1. 题目背景与问题解析UVa 12677 "Join Two Kingdoms"是一道经典的图论问题,通常出现在ACM国际大学生程序设计竞赛的训练题库中。题目设定两个独立的王国(可以抽象为两棵树),需要通过建造一座桥梁将它们连接起来&#x… · 2026/11/5 6:13:22
CarPlay Plugin深度解析:MFi认证与AVTP协议桥接实战 简介:本资源是一款面向iOS车载系统开发者的CarPlay通信插件SDK,聚焦Apple CarPlay与iPhone的无线交互优化,适用于具备Objective-C/Swift基础、正开展MFi认证硬件集成或WAC协议适配的中高级开发者。压缩包含340个文件,主体为198个C… · 2026/11/5 6:12:21
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38