要是一个商城系统,用户进来看不到商品不重要,第一道门就是注册和登录。单机项目里用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家扩展信息表';有几个字段设计细节说明一下。
username和phone都加了唯一索引,这是最后一道兜底防线。哪怕业务代码里忘了判重,数据库也会拦住重复注册,不会出现脏数据。唯一索引冲突时会抛异常,要在服务层捕获并翻译成“该用户名已被注册”的友好提示,而不是直接把SQL异常扔给前端。
user_type用tinyint数字而不是字符串,是为了节省空间、查询更快。代码里用枚举来映射,防止魔法数字满天飞。
status和audit_status是两个不同维度的状态。status控制账号本身能否登录,audit_status只针对商家,控制店铺能否营业。普通用户注册后status=1直接可用;商家注册后status=2(待审核),管理员审核通过后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 Mono<Void> 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-Type=3);具体到“商家能不能修改这个订单”“管理员能不能查看那条日志”,由业务代码处理。
命令空间划分上,我把三类角色的接口用前缀隔离:
| 角色 | 接口前缀 | 网关策略 |
|---|---|---|
| 普通用户 | /api/user/** | 登录即可访问 |
| 商家 | /api/seller/** | Token有效且userType=2 |
| 管理员 | /api/admin/** | Token有效且userType=3 |
| 开放接口 | /api/auth/、/api/product/ | 不校验Token |
在系统启动时,所有商家账号都会有一个初始status=2(待审核),即使它有合法Token,登录后也会被判为“账号不可用”。管理员审核通过后,status=1,账号才能正常登录。这样从“注册”到“可营业”的全链路就闭合成环了。
5.2 从请求头还原当前用户身份的通用封装
业务服务总不能每个Controller都从request.getHeader("X-User-Id")去取吧?太丑了,而且容易漏。
我的做法是写一个UserContext工具类,配合一个简单的HandlerInterceptor:
public class UserContext { private static final ThreadLocal<Integer> userIdHolder = new ThreadLocal<>(); private static final ThreadLocal<Integer> 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 past | JWT过期时间使用了本地时间,而服务器时区是UTC(或反之) | 统一使用UTC时间生成和校验JWT,避免时区偏移 |
| 网关鉴权通过,业务服务却认为Token无效 | 网关和业务服务使用了两套不同的密钥解析同一个Token | 密钥抽到配置中心统一管理,只保留一份配置 |
| 商家注册后账号存在但没有店铺信息 | 事务自调用导致 @Transactional 失效 | 将同一个事务边界内的代码收敛到一个方法中,或注入代理对象调用 |
| 同一个手机号被反复发送短信验证码 | 缺少发送频率限制 | Redis记录60秒冷却时间 + 单日发送次数上限 |
| 管理员登录后接口报403 | 网关里userType解析成了字符串,和整型比较时匹配不上 | 统一整型类型解析,并在日志里打印解析结果,方便跟踪 |
| 用户登录后访问某些页面白屏 | Token本身有效,但账号状态是待审核/禁用 | 登录成功时不仅要校验密码,还要校验账号状态;前端也要根据状态码给出对应页面提示 |
6.2 几个需要展开的实操细节
短时频繁登录失败锁定。为了防止密码爆破,我增加了误码锁的机制:Redis中存登录失败次数,同一用户名连续5次失败后锁定15分钟。需要注意的是,锁定是基于用户名的,但攻击者可以通过不断换用户名绕过。更完整的方案是“用户名 + IP”双维度限制——实际项目中我用IP维度做了兜底,锁定的粒度更准确,也能挡住针对不同账号的分布式爆破。
Token刷新还是重新登录。最初的版本里Token有效期到了就只能重新登录,用户体验不好。后续我加了“无感刷新”机制:登录接口返回一对token——accessToken(2小时)和refreshToken(7天)。RefreshToken存储在Redis中,有效期长达7天,过期刷新接口验证RefreshToken后,重新签发一组token。这里要注意,RefreshToken不能像AccessToken那样完全无状态,必须存Redis才能实现“吊销”能力——比如用户修改密码后,把所有刷新令牌全部作废,防止旧Token继续续期。
注册后首次登录的引导流程。普通用户注册成功直接跳首页;商家注册成功后跳入驻进度页,展示“资料审核中”;管理员账号不开放注册,由初始化脚本创建,首次登录强制修改密码。这些流程看着像产品需求,其实藏了不少技术活——比如“强制改密”就需要在账号表加一个pwd_reset_flag字段,首次登录时校验该字段,未改密则拒绝一切业务接口,只放行改密接口。
6.3 排查工具与辅助手段
排这些错的时候,我最常用的排查手段是链路线索串联:先看认证中心日志,确认Token签发是否正常;再看网关日志,确认Token校验和请求头转发是否成功;最后看业务服务日志,确认Header中的身份信息是否被正确解析。日志里的X-User-Id、X-User-Type这两个字段,贯穿整条链路——我在拦截器里还加了MDC(Mapped Diagnostic Context),把userId放进日志上下文,这样排查问题时能从海量日志里快速捞出某一个用户的操作轨迹,比大海捞针高效多了。
如果你也碰到Token相关的问题,第一步建议先把网关过滤器的日志打开,打印出原始Authorization头和解析后的claims。80%的问题都能从这个环节找到答案——是Token没传、签名不匹配、过期了,还是角色类型解析错误,一目了然。
整套注册登录做下来,我最大的体会是:账号体系不值得炫技,但绝不能掉以轻心。数据库设计上多花十分钟把角色模型想清楚,后面能免掉大量返工;加密和防刷这些安全细节,一开始就按生产标准做,别等上线被攻击了再回来补。还有一点想专门提醒:网关鉴权和业务服务鉴权要分工明确,不要把两件事揉在一起,否则后面加角色、接第三方登录的时候,你会被自己写过的代码绕晕。
最后再分享一个小技巧:认证相关的错误码,一定要单独设计一套编号体系,比如AUTH_1001表示凭证无效、AUTH_1002表示过期、AUTH_2001表示账号锁定。不要混用业务错误码,否则前端看着错误码一头雾水,排查线上问题时恨不得穿越回去抽自己一巴掌。这套规范从第一天就立好,越早越好。