登录这个功能凡是做过业务系统的人都写过但真正写得住的没几个。我带过几个不同规模的团队每次新项目立项登录模块永远是被排期压得最狠的那个——产品觉得它没工作量测试觉得它没花样结果上线第一个月工单里三成都是登录相关问题。这套以“路飞学成”为代号的在线学习系统是给一家做职业技能培训的团队搭的学员端名字取自“学员一路学、学有所成”的意思核心场景是学员用手机在通勤路上刷课、用公司电脑做题、晚上回家接着看回放学习进度强绑定账号登录态一断学员的怨气立刻就来。所以这里的“登录”两个字背后其实是一整套账号体系、会话管理、风控策略和跨端体验的组合工程。下面我把这套东西从设计到落地完整拆一遍包括参数怎么算、坑在哪里、为什么这么选。适合正在写登录模块的中初级后端、全栈也适合做产品和技术负责人的朋友拿来对照自查。1. 登录模块的整体设计与选型考量1.1 先把需求边界划清楚别急着写代码很多人拿到“做个登录”这四个字第一反应是打开 IDE 建 Controller这是最容易返工的起点。我在动手前会先逼着产品把四件事说清楚因为这四个答案直接决定后面所有技术选型。第一登录态要活多久。学习平台和电商不一样电商要求短会话、强安全学习平台的用户可能一周只上来两次每次半小时。如果 token 只有 30 分钟学员每次打开都要重新登录课程看到一半跳出去回个消息回来进度就没了这种体验是致命的。所以我们的结论是默认会话 2 小时勾选“记住我”延长到 30 天且只要在活跃期内有任意一次请求就自动续期。第二能不能多端同时在线。这个必须问因为涉及后面的并发踢人逻辑。培训业务的现实是学员手机、平板、公司电脑同时登着很正常你要是一刀切“后登录踢掉先登录”学员在电脑上做题时手机弹个通知点进去电脑就被踢了骂声一片。我们的策略是允许 3 个端同时在线超出时踢掉最久未活跃的那个并且给被踢的端一个明确的提示而不是白屏。第三账号从哪来。是学员自己注册、机构批量导入还是走第三方授权。这家团队三种都有自招学员自助注册企业客户批量导入工号还有一部分走第三方授权快捷登录。三种来源的账号在用户表里必须能区分否则后面做数据统计、做权限、做客服排查全是糊涂账。第四出问题谁负责查。这句话听着像管理问题其实是技术问题——如果登录日志只记了一条“登录成功”客服接到“我登不上去”的工单你根本没法判断是密码错了、账号被锁了、还是网络问题。所以日志字段的设计必须在第一版就定死后面再补是补不回来的。把这四个问题写成一张纸的需求卡片贴在设计文档第一页后面所有争论都回到这张纸上。这步花两小时能省两周。1.2 为什么最终没用纯 Session也没用纯 JWT这是每次架构评审必被拎出来讨论的点我把当时的对比摆出来你们可以直接抄这个框架去说服自己的团队。维度服务端 Session纯 JWT无状态我们的最终方案JWT Redis 混搭存储位置服务端内存或 Redis客户端持有客户端持有 tokenRedis 存状态索引水平扩展需共享存储否则粘性会话天然无状态随便扩无状态校验 一次 Redis 查询主动失效容易删 key 即可极难只能等过期容易删索引即可单点登录控制容易困难容易单次请求开销一次存储查询仅签名校验最快签名校验 一次 Redis 查询移动端友好度一般依赖 Cookie好好纯 JWT 最大的问题不是性能是“我改不了主意”。学员投诉账号被盗你要立刻让这个 token 失效纯 JWT 做不到——除非你改签名密钥但那会把所有人都踢下线。而纯 Session 的问题在于我们有多端、有小程序、有第三方回调Cookie 那套在非浏览器环境下处处别扭。最后的折中是JWT 里只放最小信息用户 ID、端标识、签发时间、过期时间签名用 HS256同时在 Redis 里维护一份login:token:{userId}:{deviceId}的索引值里存 token 的指纹不是原文和最后活跃时间。每次请求先验签再查索引确认这个 token 没被吊销。多花一次 Redis 查询换来强制下线、单点控制、活跃续期三个能力这笔买卖很划算。实测下来 Redis 查询走本地连接池P99 在 1 毫秒以内对登录接口这种低频调用完全不是瓶颈。注意JWT 的 payload 是 Base64 编码不是加密任何人都能解开看。所以里面绝对不要放手机号、身份证、角色权限这类信息只放 ID 和端标识。1.3 三种登录方式的取舍与组合登录入口开几个不是越多越好。每多一个入口就多一条安全边界、多一套错误码、多一份客服话术。这家团队最终保留了三个手机号 密码、手机号 短信验证码、第三方授权快捷登录。密码登录是主力适合老学员和电脑端短信验证码是为了降低首次使用的门槛很多人记不住自己注册时设的密码尤其是企业批量导入的账号第三方授权主要给移动端用一键授权省掉输入。这里有个细节值得说短信验证码登录和密码登录必须是同一套账号体系下的两条通道。我见过有些项目把验证码登录做成了“免注册直接进”结果同一个人用两种方式登录会生成两个账号学习进度分裂客服天天处理合并请求。正确做法是验证码登录时先按手机号查用户查到就发 token查不到就走注册流程补一条用户记录注册和登录共用同一个服务方法。第三方授权也是同样的道理。授权回调拿到的是第三方平台的用户标识我们要在绑定表里查有没有关联到本系统的用户 ID。有就发 token没有就引导绑定手机号绑定成功后再发 token。绝对不能跳过绑定直接建号否则后面做学员归属、做课程权限、做发票全都找不到人。2. 数据表与核心字段设计细节2.1 用户主表怎么切别塞成一张宽表第一版设计里最容易犯的错是把所有东西堆进user一张表密码、手机号、第三方标识、登录时间、登录 IP、失败次数、锁定状态……字段冲到三四十个后面每加一种登录方式就加两列加到最后没人敢动这张表。我的做法是拆成三张user_base存身份主体ID、昵称、头像、状态、注册来源、创建时间user_credential存凭证用户 ID、凭证类型、凭证值、盐版本、更新时间user_binding存第三方关联用户 ID、渠道标识、渠道用户 ID、绑定时间。这样加一种新登录方式只是往user_credential插一行主表完全不用动。有一个坑必须提前说手机号不能明文存储。合规要求是一方面更现实的是数据泄露风险和内部越权查询。我们的方案是手机号密文存一列AES 加密密钥走配置中心同时存一列手机号的 SHA256 哈希值用于精确查询和唯一索引。登录时前端传明文手机号服务端算哈希去查查到再用密文列解密出来做展示。加解密只发生在读写的瞬间对性能影响可以忽略单次 AES 加解密在微秒级。索引设计上user_credential表建(credential_type, credential_hash)联合唯一索引登录查询走这个索引能保证走索引且不重复。这里有个细节联合索引的字段顺序不能反登录时的查询条件永远是“类型 哈希值”把类型放前面过滤性更好。2.2 密码加密BCrypt 的成本因子要算不能拍脑袋密码绝对不能存 MD5也不能存 SHA256 加固定盐。前者是彩虹表一撞就穿后者是撞库成本太低。我们现在统一用 BCrypt它自带随机盐、自带慢哈希天然抗暴力破解。但成本因子cost不是越大越好得算。BCrypt 的迭代次数是 2^costcost10 时大约是 1024 轮单次哈希在我这台测试机上耗时约 55 毫秒cost12 时翻四倍到 4096 轮耗时约 210 毫秒。假设你的登录峰值是 50 QPS全部走密码登录那 cost10 需要 50 × 0.055 ≈ 2.75 个核心专门做哈希计算cost12 就要 10 个核心明显浪费。// 注册与改密时生成 String hash BCrypt.hashpw(rawPassword, BCrypt.gensalt(10)); // 登录时校验注意要先查盐版本再校验 boolean ok BCrypt.checkpw(rawPassword, storedHash);我们最终定的是 cost10同时预留了salt_version字段。以后算力上来了想升到 12不需要让所有人重设密码只要在读密码时判断版本号低于当前值就用新 cost 重新哈希一次覆盖旧值——用户下次登录时无感升级。这个设计我很推荐成本极低但能让你在几年后不用做一次痛苦的强制改密。还有一个很容易被忽略的点用户不存在时也要跑一次假的哈希校验。否则攻击者可以通过响应时间差异判断手机号是否注册过这就是典型的时序攻击。实现上很简单硬编码一个假哈希串用户查不到时拿它跑一遍checkpw再统一返回“账号或密码不正确”。2.3 登录日志与风控字段第一版就要建日志表我见过太多团队用“后面再加”的借口跳过结果出事时只能干瞪眼。这张表我会在建表当天就建好字段如下字段类型说明idbigint自增主键user_idbigint可为空账号不存在时为空credential_hashchar(64)尝试登录的凭证哈希便于反查撞库来源resulttinyint1 成功 / 2 密码错 / 3 锁定 / 4 验证码错 / 5 协议拒绝client_ipvarchar(45)兼容 IPv6device_idvarchar(64)端侧生成的稳定标识user_agentvarchar(255)截断存储risk_scoretinyint本次请求的风控评分created_atdatetime建索引按天分区created_at上和client_ip上都要有索引因为排查时最常用的两个查询就是“某天某 IP 的所有尝试”和“某用户最近十次登录”。这张表增长很快我们做了按天分区加 90 天归档的策略查询只走热数据。risk_score这个字段是我比较坚持要加的。当时没想好具体怎么用就先留了坑后来接风控策略时发现太有用了新设备 20 分、异地 30 分、10 分钟内连续失败 25 分、非常用 UA 15 分加起来超过 60 就触发二次验证。有了历史分数做基线策略调起来有依据不是拍脑袋。3. 后端接口实现与关键代码拆解3.1 登录接口的完整流程与幂等设计先把流程串起来这里我用伪代码加注释的方式比纯文字清楚。整个登录接口我拆成了五个阶段每个阶段失败都返回统一错误码但日志里记录不同的 result。public LoginResult login(LoginRequest req) { // 阶段一前置风控查 IP 和设备的失败计数 RiskContext risk riskService.evaluate(req.getIp(), req.getDeviceId()); if (risk.isBlocked()) { logService.write(req, Result.LOCKED, risk); throw new BizException(操作过于频繁请稍后再试); } // 阶段二图形验证码校验仅在触发条件下要求 if (risk.needCaptcha() !captchaService.verify(req.getCaptchaKey(), req.getCaptchaCode())) { logService.write(req, Result.CAPTCHA_WRONG, risk); throw new BizException(验证码错误); } // 阶段三查用户查不到也要跑假校验防时序攻击 UserCredential cred credentialRepo.findByHash(type, sha256(req.getAccount())); if (cred null) { passwordEncoder.fakeCheck(); // 固定耗时 rateLimiter.incrFail(req.getAccount()); logService.write(req, Result.PWD_WRONG, risk); throw new BizException(账号或密码不正确); } // 阶段四校验凭证与账号状态 if (cred.getStatus() ! ACTIVE) { logService.write(req, Result.LOCKED, risk); throw new BizException(账号状态异常请联系客服); } if (!passwordEncoder.check(req.getPassword(), cred.getHash())) { int fail rateLimiter.incrFail(req.getAccount()); if (fail LOCK_THRESHOLD) lockAccount(cred.getUserId()); logService.write(req, Result.PWD_WRONG, risk); throw new BizException(账号或密码不正确); } // 阶段五签发双 token写登录索引清理失败计数 rateLimiter.clearFail(req.getAccount()); TokenPair pair tokenService.issue(cred.getUserId(), req.getDeviceId(), req.isRememberMe()); sessionIndexRepo.register(cred.getUserId(), req.getDeviceId(), pair); logService.write(req, Result.SUCCESS, risk); return buildResult(pair); }这里有几个设计意图要说清楚。第一阶段三和阶段四分开是为了让“账号不存在”和“密码错误”在响应上完全一致但在日志里能区分——运营排查时非常有用。第二失败计数用 Redis 的INCR加过期时间实现key 是账号哈希不是用户 ID这样账号不存在时也能计数防止攻击者用不存在的账号探测。# 失败计数 key10 分钟窗口 INCR login:fail:{accountHash} EXPIRE login:fail:{accountHash} 600锁定阈值我们定的是 5 次锁定时长 15 分钟。但这里有个体验优化锁定期间用户如果通过短信验证码登录成功应该自动解锁。否则学员输错五次密码等 15 分钟太难受了他会直接打客服电话而你其实可以通过另一条通道确认他就是本人。3.2 图形验证码什么时候弹这个时机决定体验“一进登录页就要输验证码”是我最反感的交互之一。正确做法是动态触发让正常用户永远看不到它。判定逻辑我用了两条线同账号 10 分钟内失败 2 次或者同 IP 10 分钟内失败 5 次满足任一条就要求验证码。这两条线分别对应“针对单账号的撞库”和“针对多账号的扫描”覆盖面比较全。实现上用一个返回布尔值的服务方法public boolean needCaptcha(String accountHash, String ip) { long accountFail redis.get(FAIL_ACCOUNT accountHash); long ipFail redis.get(FAIL_IP ip); return accountFail 2 || ipFail 5; }前端拿到这个布尔值后不要立刻显示验证码而是等下一次点击“登录”时再展示避免用户正在输入密码时表单突然跳动。这个小细节很多人不做但实测下来能明显降低误操作率。图形验证码本身我用的是生成图片 Redis 存答案的方案key 是随机 UUID有效期 2 分钟校验后立刻删除不管对错防止重放。这里踩过一个坑校验后删除会导致用户输错一次就得刷新整张图体验极差。后来改成校验失败时不删除只在成功时删除同时把允许的错误次数限制为 3 次超过就作废。这个改动很小但用户投诉量明显下降。注意验证码的答案在 Redis 里不要存明文存手机号规范化后的值或者大小写无关的哈希都行。另外生成图片时避免使用容易混淆的字符0 和 O、1 和 l实际可用字符集控制在 32 个以内。3.3 双 Token 刷新机制并发刷新是最容易翻车的地方Access Token 有效期 2 小时Refresh Token 有效期 7 天勾选记住我则 30 天。刷新逻辑看着简单拿 refresh 换新的 access但实际项目里 90% 的登录 bug 都出在并发刷新上。场景是这样的学员打开课程页页面同时发了 8 个接口请求此时 access token 刚过期8 个请求同时返回 4018 个请求同时去调刷新接口。如果刷新接口是“用掉即废弃”的逻辑第一个请求刷新成功后面 7 个拿到的是失效的 refresh token全部失败用户被踢回登录页。我的解法是给 refresh token 加一个短暂的重用宽限期。具体的做法是在 Redis 里存一个从旧 refresh token 哈希到新 token 的映射有效期 10 秒。刷新时先查这个映射如果命中就直返回同一组新 token不重复消费。-- refresh.lua原子化刷新避免并发穿透 local oldHash KEYS[1] local newToken ARGV[1] local ttl tonumber(ARGV[2]) local cached redis.call(GET, refresh:reuse: .. oldHash) if cached then return cached -- 宽限期内直接复用 end local valid redis.call(GET, refresh:valid: .. oldHash) if not valid then return nil -- 已失效拒绝 end redis.call(DEL, refresh:valid: .. oldHash) redis.call(SETEX, refresh:reuse: .. oldHash, ttl, newToken) redis.call(SETEX, refresh:valid: .. newToken, ttl * 1000, 1) return newToken这段脚本放在 Redis 里执行保证原子性不会出现两个请求同时判断“有效”然后都去消费的情况。宽限期我设的是 10 秒足够覆盖绝大多数并发请求同时也短到不会被滥用。另一个坑是退出登录时要清理干净。很多人只删了前端的 token服务端索引还在结果 token 被中间人拿到后照样能用。正确做法是退出时把login:token:索引和refresh:valid:全部删掉并且把这个 token 的 jti 加入一个短期黑名单防止它在有效期内的残留请求继续通过。黑名单的 TTL 就是 access token 的剩余有效期过期后自动清理不会无限膨胀。3.4 短信验证码登录的时序与防刷短信接口是要花钱的所以防刷比密码登录更严格。我们定的规则是同一手机号 60 秒内只能发一次1 小时内最多 5 次同一 IP 1 小时内最多 20 次三个条件任一触发就拒绝。发送流程是校验手机号格式 → 查频控 → 生成 6 位随机码 → 存 Rediskey 带手机号哈希和业务场景TTL 5 分钟→ 调短信通道 → 记录发送日志。这里有个细节验证码存 Redis 时要用哈希后的手机号做 key避免 Redis 里出现明文手机号运维导出数据时也不会泄露。校验流程是查 Redis 取码 → 比对 → 成功后立刻删除。这里同样有并发问题用户手快点了两次“验证”第二次会失败。所以删除后要写一个短期的“已使用”标记有效期 30 秒第二次请求命中这个标记就理解为同一次成功登录直接放行而不是报错。还有一个很多人忽略的点验证码校验失败 5 次也要作废这张码否则攻击者可以在 5 分钟内暴力枚举 6 位数字。虽然 100 万种组合不容易撞但配合大量并发还是有机会的成本很低做了更安心。第三方授权的回调处理核心是三步校验回调参数签名、用授权码换用户标识、查绑定表决定是登录还是引导绑定。第二步的服务端调用一定要设超时我设的 3 秒并加重试最多 2 次因为外部接口不稳时会把你的线程池拖死。4. 前端交互与体验打磨4.1 表单校验要分层别一股脑丢给后端前端校验和前端提示是两件不同的事。校验是为了减少无效请求提示是为了让用户知道错在哪。我习惯分三层输入时就地校验格式手机号位数、密码长度失焦时校验业务规则比如密码是否包含用户名提交时统一交给后端并展示后端返回的文案。这里最值得说的是错误提示的措辞。登录失败永远不要提示“该手机号未注册”或“密码错误”统一用“账号或密码不正确”。原因很直白前者等于免费给攻击者提供了账号枚举接口。同理验证码错误就提示“验证码错误或已过期”不要细分。这既是安全要求也减少了用户对着屏幕琢磨“到底是哪错了”的焦虑。另一个体验点是记住上次使用的登录方式。学员第二次打开时默认选中他上次用的方式密码还是验证码手机号自动填上从本地存储读脱敏展示后四位。这个功能做起来不到十行代码但能让复访用户的登录步骤从四步降到一步。4.2 401 拦截与自动续期拦截器里必须排队前端统一的请求拦截器里最关键的就是 401 的处理。我见过最糟的实现是每个请求自己 catch 401 然后各自去刷新结果就是前面说的并发问题。正确的写法是维护一个刷新中的标志位和一个等待队列let refreshing false let queue [] axios.interceptors.response.use( res res, async err { const { config, response } err if (response?.status ! 401 || config._retry) throw err if (refreshing) { // 已经在刷新挂起等待结果 return new Promise(resolve queue.push(t resolve(axios(config)))) } refreshing true config._retry true try { await refreshToken() queue.forEach(fn fn()) queue [] return axios(config) } catch (e) { queue [] redirectToLogin() throw e } finally { refreshing false } } )_retry这个自定义标记是为了防止刷新失败后无限循环重试。这个逻辑看着短但每个细节都是踩出来的不标记 retry 会导致死循环不排队会导致刷新接口被打爆不出队会导致内存泄漏。自动续期还有一个策略问题什么时候续我的做法是每次请求成功后如果 access token 剩余有效期少于 30 分钟就在后台静默刷新一次用户完全无感。不要等到过期了再刷因为那时用户可能正处在关键操作中任何等待都是感知得到的。4.3 多端一致性与被踢下线的提示多端同时在线的规则前面说了允许 3 个端。实现上是登录时检查该用户已有的活跃端数量超过 3 个就按最后活跃时间排序把最旧的移除。被移除的端请求时Redis 索引查不到返回一个专用状态码我们用 4011前端收到后弹一个温和的提示“你的账号在其他设备登录当前设备已退出”并给一个“重新登录”的按钮。这个提示文案很关键。如果直接白屏或者弹“未登录”用户会以为系统坏了。给个明确的原因用户就知道发生什么了甚至会自己理解成“我确实在别处登了”。还有一个跨端的细节同一浏览器多标签页的登录态同步。用户在标签 A 退出登录标签 B 还停留在登录状态点任何按钮才提示要登录很割裂。解法是用localStorage的storage事件做同步A 退出时写一个标记B 监听到就跳转登录页。这个成本极低但能消除一类“我觉得这系统有 bug”的投诉。5. 安全加固与异常场景处理5.1 撞库、暴力破解与设备指纹撞库和暴力破解的区别要搞清楚暴力破解是拿一个账号死磕撞库是拿一批账号密码挨个试。前者靠失败计数就能防住后者必须靠 IP 和设备维度。我们的三层防御是这样设计的。第一层是账号维度失败 5 次锁定 15 分钟前面说过。第二层是 IP 维度用滑动窗口统计10 分钟内同一 IP 失败超过 20 次就临时封禁封禁时长指数退避第一次 5 分钟第二次 15 分钟第三次 1 小时。第三层是设备维度同一设备 24 小时内尝试登录的账号数超过 5 个就判定为可疑设备强制要求短信验证码。第三层是最有效的因为正常用户不会在一天内登录五个不同账号。实现依赖设备标识这个标识是前端首次访问时生成一个随机 UUID 存本地请求时带上。它很容易被伪造但对付自动化脚本足够了因为脚本通常懒得处理这块。真要对抗高级攻击还得上人机校验和行为分析那是另一个量级的投入一般业务没必要。5.2 几个容易被忽略的绕过点我把上线后陆续发现的问题整理成一张表这些都是常规代码审计容易漏的问题表现修复方式密码明文日志调试日志把请求体打出来了日志切面统一脱敏字段名含 pwd/password 一律替换为 ***越权刷新用自己的 refresh 换别人的 token刷新时必须校验 token 内的 userId 与索引中记录的一致验证码复用同一张验证码可用于多人登录校验成功后立即删除失败 3 次也删除索引残留用户删号后 token 仍有效删号时级联清理所有会话索引并加黑名单时间戳信任客户端传时间戳做重放判断一律以服务端时间为准客户端时间只用于展示跨域宽松允许任意 Origin 携带凭证白名单精确匹配不允许通配符配合凭证模式其中越权刷新是最隐蔽也最危险的。攻击者如果拿到了 A 的 refresh token伪造请求时把 userId 改成 B如果你的刷新逻辑只校验 token 签名不校验归属就能拿到 B 的会话。修复方法很简单刷新时以 Redis 索引里存的 userId 为准忽略请求里带的一切用户标识。注意登录接口一定要走 HTTPS这不是建议是底线。前端对密码做一次 SHA256 再传只能防住最原始的抓包防不住中间人真正的保护是传输层加密。6. 常见问题排查实录6.1 问题速查表按现象找原因这张表是我们团队内部 wiki 里被访问最多的一页基本覆盖了 80% 的登录工单。现象高概率原因排查动作刚登录就掉线服务端与网关的时钟不一致JWT 提前过期比对 NTP 同步状态允许 60 秒时钟偏移部分用户始终验证码错误Redis 键名用了明文手机号与加密后的不一致检查键生成逻辑是否统一走哈希函数高峰期大量 401刷新队列没做去重刷新接口被打爆看刷新接口 QPS确认前端是否有排队逻辑微信内打开无法登录第三方回调域名未配置或路径末尾多了斜杠核对回调地址逐字符比对登录后权限全空角色缓存未刷新读到了注册时的初始值登录成功后主动失效权限缓存内存持续上涨会话索引 key 没设过期时间全量扫描login:token:*补 TTL定时任务后全员重登发版重启导致本地缓存的签名密钥重新生成密钥走配置中心不随机生成时钟偏移那条特别值得展开说。JWT 里有exp和iat两个时间戳如果签发服务器和校验服务器的时间差了一分钟以上就会出现“刚签发的 token 立刻被判定为过期”或者“已过期的 token 还能用”。我们在校验时允许 60 秒的时钟偏移同时要求所有节点接入统一的时间同步服务。这个数字不要太放宽60 秒够容错又不至于让过期策略形同虚设。6.2 几个印象比较深的实战坑说两个我印象最深的。第一个是移动端的 token 判断问题。早期我们在小程序端做了一个优化拿到 token 后解析 payload 里的exp提前判断要不要刷新。结果大量用户反馈“莫名其妙要重新登录”。排查后发现是部分手机系统时间不准用户手动改过时间或者时区异常导致客户端解析出来的过期时间和服务端完全不一致。最后的结论是客户端永远不要自己解析 token 判断有效性一律以服务端返回的 401 为准。这个原则后来写进了团队的接口规范所有涉及时间有效性的判断都放服务端。第二个是锁账号导致的连锁反应。我们最初的设计是账号密码错误 5 次锁定 15 分钟锁定期间任何登录方式都拒绝。上线后客服反馈一批学员批量报“账号被锁”原因是有个班的学员用了统一的初始密码好几个人输错了同一个账号——其实是有人把工号填错了几个人都在试同一个工号。这暴露了两个问题初始密码设计不合理以及锁定期间不应该堵死短信通道。后来改成初始密码必须首登强制修改且锁定期间允许短信验证码登录并自动解锁问题就消失了。还有一个小技巧分享一下登录日志里的user_agent字段一开始我们存了完整字符串结果发现同一个人每次请求的 UA 都会因为浏览器小版本更新而变化做设备识别完全没法用。后来改成只取操作系统和浏览器两段做粗粒度识别稳定性好很多。这套东西上线运行了一年多登录相关的工单从最初的每月几十个降到了个位数。我个人最大的体会是登录模块的复杂度不在代码量在于你得提前想到所有“不正常”的情况用户会在电梯里断网、会在两个设备间来回切、会把工号输成别人的、会用手表打开网页。代码写得再优雅只要有一个场景没兜住用户就会觉得整个系统不靠谱。所以这个模块值得多花几天把边界想全把日志记够把提示写清楚——后面能省掉的是无数个加班的夜晚。