
要是一个商城系统用户进来看不到商品不重要第一道门就是注册和登录。单机项目里用Session存一下、写个过滤器拦一下就完了可一旦拆成微服务问题立马变味儿三个角色账号、多个服务独立部署、状态怎么共享、商家入驻和普通用户注册要不要分开搞、后台管理员又怎么管这篇实战记录就是我在“用户、商家、后台管理员”三类账号的注册与登录功能上完整踩过一遍沟之后的沉淀。项目本身是一个基于Spring Cloud的微服务商城认证这条线我基本全走了一遍把思路、表结构、代码、隐患都整理出来给正要动手做微服务账号体系的同学做个参考。1. 整体架构与认证方案选型1.1 为什么微服务化之后登录注册会成为一道坎先说个直白的情况单体应用时代所有功能都打包在一个工程里用户登录后往Session里塞一个userId后续请求靠Cookie里的SessionId就能识别。整个过程是“有状态”的因为Session对象就躺在当前服务器内存里。微服务拆分之后业务接口分散在不同的服务上商品服务、订单服务、用户服务、支付服务各自独立部署甚至可能跑在多个节点上。此时再依赖Session就会遇到很现实的问题——用户第一次请求落在了A服务节点登录态写进了A节点的内存下一次请求被负载均衡转发到B节点B节点压根不知道这个用户是谁。想解决就得引入Session共享把Session放到Redis里统一存储但这样做有几个副作用所有服务都要在同一个Redis集群上依赖Session数据性能和可用性被绑定SessionId的校验逻辑散落在每个服务里维护成本也不低。做这套商城系统时我首先想的就是如何把“认证”这块从业务服务里抽出去。认证不该是某个业务服务内部的事情它应该是一个独立的横切关注点。拆分的边界想清楚了后续的开发和扩展才不被动。除了状态共享的问题还需要兼顾三类角色的差异化流程。普通用户注册是最常规的填账号密码或者手机号验证码商家入驻则涉及营业执照、店铺名称、联系人等额外信息还可能要后台审核管理员的账号体系更不能直接开放自助注册。三者的共性在“身份认证”差异在“身份数据”和“注册流程”。所以不能简简单单做一张表、一个接口而是要把账号主体独立出来身份扩展信息按角色各挂各的。1.2 技术栈选型与认证方案对比在方案选型阶段我对比了三种主流做法方案核心思路复杂度适用场景Session Redis共享登录态存入Redis所有服务共享中等传统架构升级团队熟悉Servlet生态JWT无状态令牌签发签名的Token服务端不存状态低自建系统、前后端分离、微服务网关统一鉴权OAuth2授权码模式引入授权服务器完整授权流程高第三方开放平台、多应用授权接入OAuth2体系非常完整适合做开放平台让第三方应用接入用户授权。但自建商城的内部服务之间并不需要这么重的授权协议——我们只需要“用户登录一次多个服务能识别身份”杀鸡不用牛刀。所以最终选了JWT 网关统一鉴权这条路线。JWT方案的本质是“把用户身份信息签名后交给客户端保存”服务端无需存储状态天然适配微服务的无状态要求。配合网关做统一校验所有业务服务都不需要重复写解析逻辑。技术栈上项目使用的是Spring Boot Spring Cloud Gateway Nacos Redis MySQL认证库用Spring Security但只用了它的加密和过滤链基础能力没有开启它那一整套复杂配置。这里也要提一下另一个常见选择Sa-Token。如果你不喜欢Spring Security的配置复杂度Sa-Token提供了更简洁的登录认证、权限校验API而且天然支持分布式Session和JWT切换。项目中没有选它是因为团队成员对Spring Security更熟但Sa-Token绝对是一个重量级备选方案尤其适合中小团队快速落地。2. 多角色账号模型与数据库设计2.1 一种表还是三种表账号模型的设计关键设计账号体系表结构时我一度也纠结过用户一张表、商家一张表、管理员一张表分开存岂不是干干净净仔细推演之后发现这条路有三个坑登录入口没法统一。用户输入账号密码后系统先要去哪张表查用户名可能在用户表里也可能在商家表里得三张表都查一遍才知道这人是谁逻辑绕且性能差。用户名唯一性难以约束。一个用户名在用户表里不存在不代表在商家表里也不存在——如果两张表都注册了同一个用户名登录时就会发生歧义。角色身份可能重叠。现实中一个手机号注册的普通用户后来又申请成为了商家如果两张表切成两套账号这个人就有两套凭证登录状态切换很尴尬。所以我的设计原则是一套凭证主体 多角色扩展信息。核心账号表存“能证明你是你”的字段账号、密码、手机号、状态、角色类型。商家信息、店铺资料则是账号下的扩展数据用另一张表存。管理员账号同样复用核心账号表只是角色类型不同——这样三者的登录校验逻辑完全相同一句话就能概括验证账号密码确定角色返回对应身份。2.2 核心表结构与关键字段说明核心账号表建表SQL大致如下CREATE TABLE user_account ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(64) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, phone varchar(20) DEFAULT NULL COMMENT 手机号, email varchar(100) DEFAULT NULL COMMENT 邮箱, user_type tinyint(4) NOT NULL COMMENT 角色类型1-普通用户 2-商家 3-管理员, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-正常 0-禁用 2-待审核, last_login_time datetime DEFAULT NULL COMMENT 最后登录时间, last_login_ip varchar(50) DEFAULT NULL COMMENT 最后登录IP, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT统一账号表;商家扩展信息表CREATE TABLE seller_info ( id bigint(20) NOT NULL AUTO_INCREMENT, account_id bigint(20) NOT NULL COMMENT 关联user_account.id, store_name varchar(100) NOT NULL COMMENT 店铺名称, contact_name varchar(50) NOT NULL COMMENT 联系人姓名, contact_phone varchar(20) DEFAULT NULL COMMENT 联系电话, license_no varchar(50) DEFAULT NULL COMMENT 营业执照号, audit_status tinyint(4) NOT NULL DEFAULT 0 COMMENT 审核状态0-待审核 1-通过 2-拒绝, audit_remark varchar(255) DEFAULT NULL COMMENT 审核备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_account_id (account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商家扩展信息表;有几个字段设计细节说明一下。username和phone都加了唯一索引这是最后一道兜底防线。哪怕业务代码里忘了判重数据库也会拦住重复注册不会出现脏数据。唯一索引冲突时会抛异常要在服务层捕获并翻译成“该用户名已被注册”的友好提示而不是直接把SQL异常扔给前端。user_type用tinyint数字而不是字符串是为了节省空间、查询更快。代码里用枚举来映射防止魔法数字满天飞。status和audit_status是两个不同维度的状态。status控制账号本身能否登录audit_status只针对商家控制店铺能否营业。普通用户注册后status1直接可用商家注册后status2待审核管理员审核通过后status才变为1。两个状态分开管理逻辑才不会纠缠在一起。3. 注册接口的实战实现3.1 参数校验、密码加密与防重复提交注册接口看似简单写好却不容易。最容易被忽略的是“恶意注册”场景——有人拿脚本循环调用注册接口把你用户表塞满垃圾数据顺便给手机号发一堆验证码短信商家成本直接起飞。我的注册接口做了三层防护第一层参数校验。用户名只允许字母、数字、下划线长度6~20位密码长度至少8位且必须包含字母和数字手机号用正则校验格式。校验不通过直接返回错误这一步就滤掉了大量无效请求。第二层验证码校验。普通用户注册可以用“用户名密码”完成但为了防刷建议在注册页接入图形验证码如果使用了手机号注册短信验证码必须校验。我在Redis里存验证码时设置了5分钟有效期并且做了“同手机号60秒内只能发一条”的频率限制。第三层唯一性判重 唯一索引兜底。先查一次数据库做业务判重这只是为了给用户友好提示真正拦截重复注册的是数据库的唯一索引。两层都要有缺一不可。密码加密这块项目里用的是BCrypt。它和MD5、SHA-256这类摘要算法有着本质区别BCrypt每次加密同一个密码生成的哈希值都不同因为内部自动加入了随机盐而且它故意设计成“慢哈希”随着计算因子增加暴力破解的成本指数级上升。千万不要用MD5加固定盐的方式存密码——现在的GPU跑MD5可以做到每秒几十亿次8位纯数字密码分分钟被爆破完。注册接口的核心逻辑如下Service public class AuthService { Autowired private UserAccountMapper userAccountMapper; Autowired private SellerInfoMapper sellerInfoMapper; Autowired private StringRedisTemplate redisTemplate; Transactional(rollbackFor Exception.class) public Long register(RegisterRequest request) { // 1. 校验手机验证码 String cacheCode redisTemplate.opsForValue().get(sms:code: request.getPhone()); if (cacheCode null || !cacheCode.equals(request.getSmsCode())) { throw new BusinessException(验证码错误或已过期); } // 2. 判断用户名是否已存在 UserAccount existing userAccountMapper.selectByUsername(request.getUsername()); if (existing ! null) { throw new BusinessException(该用户名已被注册); } // 3. BCrypt加密密码 String encodedPassword BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()); // 4. 创建账号 UserAccount account new UserAccount(); account.setUsername(request.getUsername()); account.setPassword(encodedPassword); account.setPhone(request.getPhone()); account.setUserType(request.getUserType()); // 商家账号待审核用户和商家区分 account.setStatus(request.getUserType() UserType.SELLER ? 2 : 1); userAccountMapper.insert(account); // 5. 如果是商家写入商家扩展信息 if (request.getUserType() UserType.SELLER) { SellerInfo seller new SellerInfo(); seller.setAccountId(account.getId()); seller.setStoreName(request.getStoreName()); seller.setContactName(request.getContactName()); seller.setContactPhone(request.getContactPhone()); seller.setLicenseNo(request.getLicenseNo()); seller.setAuditStatus(0); sellerInfoMapper.insert(seller); } // 6. 删除已使用的验证码 redisTemplate.delete(sms:code: request.getPhone()); return account.getId(); } }3.2 商家入驻与事务一致性Transactional失效的坑注册商家时要同时往user_account表和seller_info表写数据。如果第一步成功了第二步失败就会产生一条“有账号但没店铺信息”的脏数据用户登录进去界面都渲染不出来。解决这个问题第一反应自然是加Transactional但我在实测中踩过一个经典的坑——事务自调用失效。场景是这样的我把注册逻辑写在AuthService里register方法调用了同一个类中的另一个insertSellerInfo方法Transactional加在外部register上。看起来没问题实际上Spring事务是通过AOP代理实现的只有通过代理对象调用的方法才会被事务拦截。自调用走的是this引用不是代理对象事务直接失效。这个问题的诊断过程真是一把辛酸泪日志里没有任何报错数据却少了一条查了好久才发现。解决方法有几种把插入商家信息的方法放到另一个Service类中注入后调用或者自己注入代理对象再调用再或者按上面的示例直接把商家信息插入逻辑写在注册方法内部用同一个事务方法完成所有数据库操作。我最终选择了把代码都收敛在同一个方法里简单直接还能保证读者一眼看穿整个注册链路的事务边界。这里也想强调一下微服务架构下“分布式事务”这个词很容易让人紧张但其实很多业务根本不需要分布式事务。商城注册流程中账号表和商家表在同一个MySQL库中一个本地事务完全够用。只有跨服务调用的多数据源场景才需要引入Seata、MQ最终一致性等手段把问题想复杂了反而会增加系统负担。4. 登录认证与JWT签发链路4.1 JWT的生成与解析细节登录接口设计上我实现了“用户名密码”和“手机号验证码”两种方式。密码验证成功后系统要做三件事更新最后登录时间和IP、生成JWT、返回给前端。JWT本身由三部分组成Header、Payload、Signature。Header声明签名算法Payload存业务数据Signature用密钥对前两部分签名。任何对Payload的篡改都会导致Signature校验失败这是JWT防篡改的根基。签发JWT时的关键代码如下public String generateToken(UserAccount account) { // 使用当前时间戳 过期时间 long now System.currentTimeMillis(); long expireAt now tokenExpireSeconds * 1000; JwtBuilder builder Jwts.builder() .setSubject(String.valueOf(account.getId())) .claim(userType, account.getUserType()) .claim(username, account.getUsername()) .setIssuedAt(new Date(now)) .setExpiration(new Date(expireAt)) .signWith(SignatureAlgorithm.HS256, secretKey); return builder.compact(); }这段代码有几个细节需要敲黑板不要往JWT里塞敏感信息。不要把密码、手机号、身份证号放进Payload——JWT虽然防篡改但Payload部分默认是Base64编码任何拿到Token的人都可以直接解码看到内容。我放进Payload的只有userId、userType、username这几个不敏感的身份标识字段。过期时间要区分角色。普通用户Token有效期我设了12小时后台管理员只给30分钟降低管理端Token泄露后的风险窗口。网关层解析Token时读取exp字段自动拦截过期Token。密钥管理。HS256对称加密的密钥如果写死在代码里一旦泄露所有Token都能被伪造。项目初期人少事多先写在配置文件中后来把密钥统一放到了配置中心并用环境变量注入保证各环境的密钥互相隔离。如果团队规模大、服务数量多更稳妥的做法是换成RS256非对称签名业务服务只需要保存公钥验签私钥只保存在认证中心。4.2 网关统一鉴权与安全过滤JWT签发之后去哪校验答案是网关层。Spring Cloud Gateway里我写了一个全局过滤器实现思路是维护一个白名单路径列表/api/auth/login、/api/auth/register、/api/product/**商品浏览不需要登录等。请求进入先判断路径是否在白名单里在则直接放行。不在白名单则从请求头Authorization里取出Token没有Token直接返回401。有Token调用JWT解析工具校验签名和过期时间解析失败返回401解析成功把userId和userType放入请求头转发给下游业务服务。网关过滤器的伪代码如下Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 1. 白名单直接放行 if (whiteList.contains(path)) { return chain.filter(exchange); } // 2. 取token String authHeader exchange.getRequest().getHeaders().getFirst(Authorization); if (StringUtils.isBlank(authHeader) || !authHeader.startsWith(Bearer )) { return unauthorizedResponse(exchange); } String token authHeader.substring(7); // 3. 解析校验 try { Claims claims jwtUtil.parseToken(token); Integer userId Integer.valueOf(claims.getSubject()); Integer userType (Integer) claims.get(userType); // 4. 传递用户信息到下游 ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, String.valueOf(userId)) .header(X-User-Type, String.valueOf(userType)) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { return unauthorizedResponse(exchange); } } }网关层做完鉴权后下游服务就拿到了一份可信的用户身份。业务服务只需要读取X-User-Id和X-User-Type这两个请求头即可不需要再解析JWT——这大大减少了业务服务的重复代码也让鉴权逻辑有了统一的入口。后续如果要从JWT换成其他方案只需要改网关这一层。这里还有个小坑要提醒网关转发时我最初把原始的Authorization头也透传下去了。虽然下游一般不读但如果某个服务日志打印了请求头Token就会被打进日志里有泄露风险。常规做法是在网关处把Authorization头移除只保留业务服务需要的X-User-*头。5. 多角色权限落地与接口保护5.1 三类角色的接口隔离与权限模型账号体系跑通了角色权限怎么落地是注册登录功能的最后一公里。我采用的权限模型很简单网关层做“能进不能进”的判断业务层做“能干什么不能干什么”的判断。网关只会校验Token是否有效、角色是否匹配某个接口前缀比如/api/admin/**必须要求X-User-Type3具体到“商家能不能修改这个订单”“管理员能不能查看那条日志”由业务代码处理。命令空间划分上我把三类角色的接口用前缀隔离角色接口前缀网关策略普通用户/api/user/**登录即可访问商家/api/seller/**Token有效且userType2管理员/api/admin/**Token有效且userType3开放接口/api/auth/、/api/product/不校验Token在系统启动时所有商家账号都会有一个初始status2待审核即使它有合法Token登录后也会被判为“账号不可用”。管理员审核通过后status1账号才能正常登录。这样从“注册”到“可营业”的全链路就闭合成环了。5.2 从请求头还原当前用户身份的通用封装业务服务总不能每个Controller都从request.getHeader(X-User-Id)去取吧太丑了而且容易漏。我的做法是写一个UserContext工具类配合一个简单的HandlerInterceptorpublic class UserContext { private static final ThreadLocalInteger userIdHolder new ThreadLocal(); private static final ThreadLocalInteger userTypeHolder new ThreadLocal(); public static void set(Integer userId, Integer userType) { userIdHolder.set(userId); userTypeHolder.set(userType); } public static Integer getUserId() { return userIdHolder.get(); } public static Integer getUserType() { return userTypeHolder.get(); } public static void clear() { userIdHolder.remove(); userTypeHolder.remove(); } }拦截器里从请求头拆出X-User-Id和X-User-Type放进UserContext请求结束后记得调用clear()清理ThreadLocal防止内存泄漏。这样Controller层直接UserContext.getUserId()就能拿到当前登录人身份非常顺手。这里补充一个排查法上的细节ThreadLocal用完不清理在高并发下容易引发OOM或者数据串号。我曾在定时任务里遇到过线程复用导致的串号问题——上一次请求的用户ID残留在线程里下一次请求没设置就读取到了脏数据。所以清理这一步要像写数据库事务提交一样形成习惯最好用try-finally包住调用链。6. 常见问题排查实录与避坑指南6.1 高发问题速查表开发这套注册登录功能期间我遇到并解决了下面这些典型问题整理出来供大家排查时直接对照。问题现象根本原因解决方法前端请求网关接口报CORS跨域错误前端域与网关域不一致且跨域配置写在了业务服务上统一在网关层配置CORS规则业务服务删除各自的跨域处理Token偶尔失效日志提示exp in pastJWT过期时间使用了本地时间而服务器时区是UTC或反之统一使用UTC时间生成和校验JWT避免时区偏移网关鉴权通过业务服务却认为Token无效网关和业务服务使用了两套不同的密钥解析同一个Token密钥抽到配置中心统一管理只保留一份配置商家注册后账号存在但没有店铺信息事务自调用导致 Transactional 失效将同一个事务边界内的代码收敛到一个方法中或注入代理对象调用同一个手机号被反复发送短信验证码缺少发送频率限制Redis记录60秒冷却时间 单日发送次数上限管理员登录后接口报403网关里userType解析成了字符串和整型比较时匹配不上统一整型类型解析并在日志里打印解析结果方便跟踪用户登录后访问某些页面白屏Token本身有效但账号状态是待审核/禁用登录成功时不仅要校验密码还要校验账号状态前端也要根据状态码给出对应页面提示6.2 几个需要展开的实操细节短时频繁登录失败锁定。为了防止密码爆破我增加了误码锁的机制Redis中存登录失败次数同一用户名连续5次失败后锁定15分钟。需要注意的是锁定是基于用户名的但攻击者可以通过不断换用户名绕过。更完整的方案是“用户名 IP”双维度限制——实际项目中我用IP维度做了兜底锁定的粒度更准确也能挡住针对不同账号的分布式爆破。Token刷新还是重新登录。最初的版本里Token有效期到了就只能重新登录用户体验不好。后续我加了“无感刷新”机制登录接口返回一对token——accessToken2小时和refreshToken7天。RefreshToken存储在Redis中有效期长达7天过期刷新接口验证RefreshToken后重新签发一组token。这里要注意RefreshToken不能像AccessToken那样完全无状态必须存Redis才能实现“吊销”能力——比如用户修改密码后把所有刷新令牌全部作废防止旧Token继续续期。注册后首次登录的引导流程。普通用户注册成功直接跳首页商家注册成功后跳入驻进度页展示“资料审核中”管理员账号不开放注册由初始化脚本创建首次登录强制修改密码。这些流程看着像产品需求其实藏了不少技术活——比如“强制改密”就需要在账号表加一个pwd_reset_flag字段首次登录时校验该字段未改密则拒绝一切业务接口只放行改密接口。6.3 排查工具与辅助手段排这些错的时候我最常用的排查手段是链路线索串联先看认证中心日志确认Token签发是否正常再看网关日志确认Token校验和请求头转发是否成功最后看业务服务日志确认Header中的身份信息是否被正确解析。日志里的X-User-Id、X-User-Type这两个字段贯穿整条链路——我在拦截器里还加了MDCMapped Diagnostic Context把userId放进日志上下文这样排查问题时能从海量日志里快速捞出某一个用户的操作轨迹比大海捞针高效多了。如果你也碰到Token相关的问题第一步建议先把网关过滤器的日志打开打印出原始Authorization头和解析后的claims。80%的问题都能从这个环节找到答案——是Token没传、签名不匹配、过期了还是角色类型解析错误一目了然。整套注册登录做下来我最大的体会是账号体系不值得炫技但绝不能掉以轻心。数据库设计上多花十分钟把角色模型想清楚后面能免掉大量返工加密和防刷这些安全细节一开始就按生产标准做别等上线被攻击了再回来补。还有一点想专门提醒网关鉴权和业务服务鉴权要分工明确不要把两件事揉在一起否则后面加角色、接第三方登录的时候你会被自己写过的代码绕晕。最后再分享一个小技巧认证相关的错误码一定要单独设计一套编号体系比如AUTH_1001表示凭证无效、AUTH_1002表示过期、AUTH_2001表示账号锁定。不要混用业务错误码否则前端看着错误码一头雾水排查线上问题时恨不得穿越回去抽自己一巴掌。这套规范从第一天就立好越早越好。