基于账权平台 service-master 源码分析 · 2026-08-05
通过对源码的全面扫描,当前单体应用中"账权"相关功能可归纳为以下五大域:
| 功能域 | 核心实体 | 核心 Service | 核心 Controller |
|---|---|---|---|
| 用户认证 | UserInfo, SsoConfig | UserInfoServiceImpl, SsoServiceImpl, Sso2ServiceImpl | UserController |
| 权限管理 | SysRole, SysPermission, SysMenu, SysRolePermission, SysMemberRole | PermissionServiceImpl | PermissionController |
| 租户管理 | CustomerInfo, CustomerUser, SysOrganization | CustomerInfoServiceImpl, CustomerUserServiceImpl, SysOrganizationServiceImpl | CustomerController, SysOrganizationController |
| 套餐配额 | SysPackagePlan, SysPackagePlanPermission, SysQuota, CustomerPackage, CustomerPackageQuota | SysPackagePlanServiceImpl, CustomerPackageServiceImpl, SysQuotaServiceImpl | SysPackagePlanController, CustomerPackageController |
| 开放认证 | SysAccessToken | SysAccessTokenServiceImpl | AccessTokenController |
溯源(Trace*)和过磅(Weight*)等业务模块 不直接 import 任何账权 Service,它们仅通过 AuthContext.getLoginInfo().getCustomer().getId() 获取当前租户 ID。这意味着业务模块与账权模块的耦合是通过共享上下文(ThreadLocal + Redis)间接耦合,而非直接代码调用——这为服务化拆分提供了极好的基础。
AuthContext 来自外部库 apes-commons,是整个系统的"认证上下文总线"。所有业务模块通过它获取当前登录用户和租户信息:
// 典型使用模式(出现 52 个文件中)
LoginCustomer customer = AuthContext.getLoginInfo().getCustomer();
Long customerId = customer.getId();
// 然后在查询中添加 WHERE customer_id = customerId
OperationLogUtil 内部依赖 AuthContext 获取操作人信息,被 11 个文件调用。它通过 @PostConstruct 注入静态 SysOperationLogService,形成了"静态方法 → Spring Bean → AuthContext"的隐式依赖链。
| 调用方 | 被调用方 | 调用内容 |
|---|---|---|
| UserInfoServiceImpl | PermissionServiceImpl | getUserRoles() |
| UserInfoServiceImpl | CustomerUserServiceImpl | getCustomers(), getCustomerListByUserIds() |
| UserInfoServiceImpl | CustomerInfoServiceImpl | getById() |
| PermissionServiceImpl | SsoServiceImpl / Sso2ServiceImpl | getUserConfig(), getPageUrl() |
| PermissionServiceImpl | UserInfoMapper | selectById() |
| PermissionServiceImpl | CustomerPackageMapper | selectList() |
| PermissionServiceImpl | SysPackagePlanMapper | selectById() |
| AccessTokenFilter | CustomerInfoServiceImpl | getById() |
登录态存储在 Redis 中(Key = 加密 Token,Value = 用户 JSON;Key = Token:customer,Value = 企业 JSON),TTL 1 天。这是拆分的最有利条件——任何服务只要能访问同一个 Redis,就能读取登录态。
核心认证逻辑(LoginAspect AOP 拦截、AuthContext ThreadLocal 管理、@Login 注解)封装在外部 JAR 包 apes-commons:0.0.7-SNAPSHOT 中,源码不在项目内。当前所有 Controller 通过 @Login 注解声明需要登录校验,由 LoginAspect 从 Redis 读取 Token 并填充 AuthContext。
核心判断依据是"业务模块与账权模块之间不存在直接代码调用"。耦合仅发生在 AuthContext(ThreadLocal 上下文)层面,这种耦合可以通过网关解析 Token + HTTP Header 传递的方式完全消除。改造工作量主要集中在替换 AuthContext 和解耦 apes-commons,而非重构业务代码。
| 维度 | 当前痛点 | 独立后的收益 | 优先级 |
|---|---|---|---|
| 多产品复用 | 账权逻辑锁在单体中,新产品(如独立 AI Agent 平台、移动端)要么整体依赖单体,要么重复实现 | 一套账权服务支撑多个前端产品线,避免重复建设 | 高 |
| 独立演进 | 账权变更需整体重新部署,业务模块也跟着重启 | 账权服务独立发布、独立扩缩容,不影响业务服务 | 中 |
| 开放平台 | AccessToken 机制已在单体内,但第三方接入需走单体 API,无法独立管控 | 独立 OAuth2 服务,支持第三方应用授权、API 网关统一鉴权 | 中 |
| 安全合规 | 用户密码、Token 等敏感数据与业务数据混在同一库,权限边界模糊 | 账权数据库独立,可实施更严格的安全策略(网络隔离、审计、加密) | 低 |
| 性能隔离 | 大量登录/鉴权请求与业务请求混在同一进程,相互影响 | 账权服务可独立扩容,登录高峰不影响业务处理 | 低 |
如果团队有多产品线复用账权的需求(如独立 AI Agent 平台、移动端 App、开放平台),则拆分收益明确,值得投入。如果当前只有一个前端产品且近期无扩展计划,则优先在单体内做模块化隔离(包结构拆分 + 接口抽象)即可,暂不需微服务化。
| 组件 | 职责 | 技术选型 |
|---|---|---|
| API Gateway | 统一入口,路由转发,Token 解析与校验,将用户信息注入 HTTP Header 传递给下游服务 | Spring Cloud Gateway |
| Identity Service | 用户/权限/租户/套餐/AccessToken 的全部管理,Token 签发与校验 | Spring Boot + MyBatis Plus |
| Business Service | 溯源/过磅/农业等业务功能,通过 Header 接收用户上下文 | 原有单体业务代码剥离 |
| auth-sdk | 轻量级 SDK JAR,供 Business/AI 服务引入,提供:Header 解析、AuthContext 替代品、权限校验注解 | 纯 Java JAR,无 Spring 依赖 |
| Redis | 共享会话存储(Token → 用户/租户信息),所有服务共用 | Redisson(沿用现有) |
| 消息队列(可选) | 用户/租户信息变更时广播通知,减少跨服务实时查询 | RabbitMQ / RocketMQ |
核心变更:用"网关解析 Token → HTTP Header 传递"替代当前的"每个服务各自从 Redis 读取 Token → 填充 ThreadLocal"。
请求 → AccessTokenFilter → DataSourceHeaderAspect(clean AuthContext)
→ LoginAspect(从Redis读Token → 填充AuthContext)
→ Controller(@Login) → Service(AuthContext.getLoginInfo())
请求 → API Gateway
├─ 从 Header 提取 Token
├─ 调用 Identity Service 的 /auth/verify 接口(或直接读 Redis)
├─ 解析出 userId, customerId, roles, permissions
└─ 注入 HTTP Header: X-User-Id, X-Customer-Id, X-User-Name, X-Roles
→ 路由到下游服务
├─ auth-sdk 的 AuthFilter 解析 Header → 填充 ServiceAuthContext
└─ Controller → Service(ServiceAuthContext.getUserId())
// auth-sdk 提供的核心类(替代 apes-commons 的 AuthContext)
public class ServiceAuthContext {
private static final ThreadLocal<AuthInfo> CONTEXT = new ThreadLocal<>();
public static void set(AuthInfo info) { CONTEXT.set(info); }
public static AuthInfo get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
// 便捷方法
public static Long getUserId() { return get() != null ? get().getUserId() : null; }
public static Long getCustomerId() { return get() != null ? get().getCustomerId() : null; }
public static String getUserName() { return get() != null ? get().getUserName() : null; }
public static List<String> getRoles() { return get() != null ? get().getRoles() : Collections.emptyList(); }
}
public class AuthInfo {
private Long userId;
private Long customerId;
private String userName;
private String userPhone;
private List<String> roles;
private List<String> permissions;
// getters/setters...
}
// 网关注入的 Header → ThreadLocal 的 Filter
@Component
@Order(1)
public class AuthHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) req;
AuthInfo info = new AuthInfo();
info.setUserId(parseLong(request.getHeader("X-User-Id")));
info.setCustomerId(parseLong(request.getHeader("X-Customer-Id")));
info.setUserName(request.getHeader("X-User-Name"));
info.setRoles(parseList(request.getHeader("X-Roles")));
ServiceAuthContext.set(info);
try {
chain.doFilter(req, res);
} finally {
ServiceAuthContext.clear();
}
}
}
为降低改造成本,auth-sdk 可提供一个 AuthContext 兼容适配器,使其 API 与 apes-commons.AuthContext 保持一致(getLoginInfo().getUser().getId() 等),这样业务代码只需将 import 从 cn.apes.commons.auth.AuthContext 改为 cn.apes.sdk.auth.AuthContext,方法调用不变。
当前使用 RSA 加密的随机 ID 作为 Token,存储在 Redis 中。建议升级为 JWT + Redis 混合方案:
| 方案 | 说明 | 优缺点 |
|---|---|---|
| 方案 A:JWT(推荐) | Token 本身携带 userId, customerId, roles 等信息,网关直接解析,无需查 Redis | ✓ 无状态,性能最优 ✓ 网关可独立解析 ✗ Token 无法主动失效(需配合 Redis 黑名单) |
| 方案 B:Redis Token(兼容现有) | 保持现有机制,网关从 Redis 读取 Token 信息后注入 Header | ✓ 改造成本最低 ✓ Token 可主动失效 ✗ 每次请求需查 Redis ✗ 网关依赖 Redis |
| 方案 C:JWT + Redis 黑名单 | JWT 无状态解析 + Redis 存储已注销的 Token(黑名单) | ✓ 性能好 ✓ 可主动失效 ✗ 实现略复杂 |
推荐方案 C,JWT Payload 设计:
{
"sub": "userId", // 用户ID
"cid": "customerId", // 租户ID
"cname": "customerName", // 租户名称
"uname": "nickName", // 用户昵称
"phone": "138****1234", // 脱敏手机号
"roles": ["admin", "user"],// 角色列表
"perms": ["trace:view", "weight:edit"], // 权限码列表
"iat": 1691234567, // 签发时间
"exp": 1691320967 // 过期时间(24h)
}
当前系统仅校验登录态,不校验具体权限(无 @PreAuthorize、无 Shiro 注解),权限控制依赖前端菜单过滤。服务化后建议增加后端权限校验:
// auth-sdk 提供的权限注解 + AOP
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String[] value(); // 权限码,如 "trace:product:add"
Logical logical() default Logical.AND;
}
// AOP 切面
@Aspect
@Component
public class PermissionAspect {
@Around("@annotation(requiresPermission)")
public Object check(ProceedingJoinPoint pjp, RequiresPermission requiresPermission) throws Throwable {
List<String> userPerms = ServiceAuthContext.get().getPermissions();
String[] required = requiresPermission.value();
boolean hasPermission = requiresPermission.logical() == Logical.AND
? Arrays.asList(required).stream().allMatch(userPerms::contains)
: Arrays.asList(required).stream().anyMatch(userPerms::contains);
if (!hasPermission) {
throw new ForbiddenException("权限不足");
}
return pjp.proceed();
}
}
// 使用示例
@RestController
@RequestMapping("/trace/product")
public class TraceProductController {
@RequiresPermission("trace:product:add")
@PostMapping
public Res add(@RequestBody TraceProductDTO dto) { ... }
@RequiresPermission("trace:product:delete")
@DeleteMapping("/{id}")
public Res delete(@PathVariable Long id) { ... }
}
业务服务偶尔需要查询用户/租户信息(如显示操作人姓名)。方案:
| 方案 | 适用场景 | 实现 |
|---|---|---|
| JWT Payload 携带 | 仅需 userId, customerId, userName 等基础信息 | 直接从 ServiceAuthContext 读取,零网络开销 |
| Identity Service API | 需要完整用户/租户信息 | Feign Client 调用 GET /auth/user/{id}、GET /auth/customer/{id} |
| Redis 缓存 + MQ 同步 | 高频查询的场景 | 用户信息变更时通过 MQ 广播,业务服务更新本地 Redis 缓存 |
// Feign Client 示例
@FeignClient(name = "identity-service", url = "${service.identity.url}")
public interface IdentityFeignClient {
@GetMapping("/auth/user/{userId}")
Res<UserDTO> getUser(@PathVariable("userId") Long userId);
@GetMapping("/auth/customer/{customerId}")
Res<CustomerDTO> getCustomer(@PathVariable("customerId") Long customerId);
@GetMapping("/auth/user/{userId}/permissions")
Res<List<String>> getUserPermissions(@PathVariable("userId") Long userId,
@RequestParam("customerId") Long customerId);
@GetMapping("/auth/user/{userId}/menus")
Res<List<MenuDTO>> getUserMenus(@PathVariable("userId") Long userId,
@RequestParam("customerId") Long customerId);
}
当前 OperationLogUtil 静态方法内部依赖 AuthContext,改造为依赖 ServiceAuthContext:
// 改造前
public static void log(String module, String action, String description) {
LoginUser user = AuthContext.getLoginInfo().getUser(); // 依赖 apes-commons
LoginCustomer customer = AuthContext.getLoginInfo().getCustomer();
// ...
}
// 改造后
public static void log(String module, String action, String description) {
AuthInfo auth = ServiceAuthContext.get(); // 依赖 auth-sdk
if (auth == null) return;
logService.log(auth.getUserId(), auth.getUserName(),
auth.getCustomerId(), auth.getCustomerName(),
module, action, maskDescription(description));
}
由于 OperationLogUtil 的 log() 方法签名不变,11 个调用方文件无需修改任何代码——只需更新 OperationLogUtil 自身的实现(从 AuthContext 切换到 ServiceAuthContext),并将 OperationLogUtil 迁移到 auth-sdk 中。
| 归属 | 表名 | 说明 |
|---|---|---|
| auth_db (Identity Service) |
user_info |
用户基础信息 |
customer_info | 租户(企业)信息 | |
customer_user | 用户-租户关联 | |
sys_role | 角色 | |
sys_permission | 权限 | |
sys_menu | 菜单 | |
sys_role_permission | 角色-权限关联 | |
sys_member_role | 用户-角色关联(含租户ID) | |
sys_organization | 组织架构 | |
sys_access_token | AccessToken | |
sys_package_plan | 套餐计划 | |
sys_package_plan_permission | 套餐-权限关联 | |
sys_quota / sys_package_plan_quota | 配额模板 | |
sso_config | SSO 配置 | |
| auth_db (扩展) |
customer_package | 客户套餐关联 |
customer_package_quota | 客户配额实例 | |
customer_quota_change_log | 配额变更日志 | |
customer_package_extend_apply | 套餐延期申请 | |
| business_db (Business Service) |
trace_* (20+ 表) | 溯源业务表 |
weight_* (10+ 表) | 过磅业务表 | |
customer_ui_config / ext_link 等 | 业务配置表 | |
| ai_db (AI Service) |
chat_session / chat_message / tool_* 等 |
AI 对话相关表 |
当前系统中,业务表通过 customer_id 关联 customer_info。拆库后:
customer_id 作为外键标识,不需要 JOIN 查询 customer_info——租户名称等信息从 JWT Payload 或 ServiceAuthContext 获取customer_id 列表,再批量调用 Identity Service API 补全名称(或使用 Redis 缓存)sys_operation_log 表已冗余存储 user_name 和 customer_name,可放入 business_db 或独立日志库,不依赖 auth_db套餐配额(如溯源码生成数量限制)需要在业务服务中检查。方案:
// 业务服务通过 Feign 调用 Identity Service 检查配额
@PostMapping("/trace/code/generate")
public Res generateCode(@RequestBody GenerateDTO dto) {
// 1. 检查配额
QuotaCheckResult result = identityFeignClient.checkQuota(
ServiceAuthContext.getCustomerId(), "trace_code", dto.getCount()
);
if (!result.isAllowed()) {
return Res.fail("配额不足,剩余: " + result.getRemaining());
}
// 2. 执行业务
traceCodeService.generate(dto);
// 3. 扣减配额(Identity Service 内部处理)
identityFeignClient.consumeQuota(
ServiceAuthContext.getCustomerId(), "trace_code", dto.getCount()
);
return Res.ok();
}
| 改造项 | 影响文件数 | 改造方式 | 难度 |
|---|---|---|---|
| AuthContext → ServiceAuthContext | 52 | 替换 import(兼容 API) | 低 |
| OperationLogUtil 实现 | 1(自身) | 内部实现替换,迁移到 auth-sdk | 低 |
| OperationLogUtil 调用方 | 11 | 更新 import(方法签名不变) | 低 |
| apes-commons 替换 | 全局 | Maven 依赖替换为 auth-sdk | 中 |
| @Login 注解替换 | 62(所有Controller) | 替换注解 import 或保留兼容 | 中 |
| DataSourceHeaderAspect | 1 | 重构或移除(网关接管数据源路由) | 中 |
| AccessTokenFilter | 1 | 迁移到网关层 | 中 |
| 账权 Service 间调用 | ~8 | 保持同服务内调用(在 Identity Service 内部) | 低 |
| 业务 Service → 账权 Service | 0 | 无需改造(本来就没有直接调用) | 无 |
| 数据库拆分 | — | 导出 auth 相关表到新库 | 中 |
为了将 52 个文件的改动降到最低,auth-sdk 提供 API 完全兼容的 AuthContext 替代品:
// auth-sdk 中的兼容 AuthContext(包名可保持一致或通过 Maven relocation)
package cn.apes.sdk.auth;
// 保持与 apes-commons.AuthContext 完全相同的 API
public class AuthContext {
private static final ThreadLocal<UserSession> HOLDER = new ThreadLocal<>();
public static UserSession getLoginInfo() {
return HOLDER.get();
}
public static void setLoginInfo(UserSession session) {
HOLDER.set(session);
}
public static void clean() {
HOLDER.remove();
}
}
// UserSession 保持字段结构一致
public class UserSession {
private LoginUser user;
private LoginCustomer customer;
private String token;
// getters/setters...
}
// AuthHeaderFilter 从 HTTP Header 构建 UserSession
@Component
@Order(1)
public class AuthHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
// 从网关注入的 Header 构建 UserSession
LoginUser user = new LoginUser();
user.setId(parseLong(request.getHeader("X-User-Id")));
user.setNickName(request.getHeader("X-User-Name"));
user.setPhone(request.getHeader("X-User-Phone"));
LoginCustomer customer = new LoginCustomer();
customer.setId(parseLong(request.getHeader("X-Customer-Id")));
customer.setCustomerName(request.getHeader("X-Customer-Name"));
UserSession session = new UserSession();
session.setUser(user);
session.setCustomer(customer);
session.setToken(request.getHeader("X-Token"));
AuthContext.setLoginInfo(session);
try {
chain.doFilter(req, res);
} finally {
AuthContext.clean();
}
}
}
这样业务代码的改动仅为一行 import 替换:
// 改前
import cn.apes.commons.auth.AuthContext;
// 改后
import cn.apes.sdk.auth.AuthContext;
// 方法调用完全不变
AuthContext.getLoginInfo().getCustomer().getId(); // ✓
当前 62 个 Controller 类/方法使用 @Login 注解。在 auth-sdk 中提供同名注解,并由 AuthHeaderFilter 替代 LoginAspect 的功能:
// auth-sdk 中提供 @Login 注解(标记需要登录的接口)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
boolean required() default true;
}
// auth-sdk 中的 LoginAspect(仅校验 AuthContext 是否有值,不查 Redis)
@Aspect
@Component
@Order(2)
public class LoginAspect {
@Around("@within(login) || @annotation(login)")
public Object check(ProceedingJoinPoint pjp, Login login) throws Throwable {
if (login.required() && AuthContext.getLoginInfo() == null) {
throw new UnauthorizedException("未登录");
}
return pjp.proceed();
}
}
通过 Maven 的 maven-shade-plugin 的 relocation 功能,可以将 cn.apes.commons.auth 包名重定向到 cn.apes.sdk.auth,这样所有业务代码的 import 语句完全不需要修改——只需在 pom.xml 中将 apes-commons 依赖替换为 auth-sdk。
目标:在单体应用内将账权代码与业务代码做包级隔离,建立清晰边界。
cn.apes.cloud.auth 包下(Controller/Service/Entity/Mapper/Config)cn.apes.cloud.business 包下AuthFacade 接口,业务模块只依赖接口AuthContext 的使用规范,消除不规范的直接 Mapper 调用风险低 可在 1-2 周内完成 需全量回归测试
目标:抽取 auth-sdk JAR,替换 apes-commons,引入网关。
auth-sdk Maven 项目,包含:兼容 AuthContext、AuthHeaderFilter、@Login 注解、LoginAspect、OperationLogUtilcn.apes.commons.auth 包名兼容风险中 2-3 周 需重点测试认证流程
目标:将账权代码从单体中剥离,部署为独立微服务。
identity-service Maven 项目,迁移 Phase 1 隔离的账权代码auth_db/auth/** → Identity Service,/trace/** 等 → Business Service风险高 3-4 周 需数据库迁移 + 全量回归
目标:完善权限校验、配额管理、开放平台等高级能力。
@RequiresPermission 全量覆盖风险中 持续迭代
| 风险 | 影响 | 概率 | 应对措施 |
|---|---|---|---|
| 认证中断 迁移过程中 Token 机制变更导致用户掉线 |
高 | 中 | Phase 2 保持 Redis Token 机制不变,仅改上下文传递方式;JWT 升级推迟到 Phase 4;支持新旧 Token 并存期 |
| 跨服务调用延迟 业务服务需远程调用 Identity Service 查询用户/权限信息 |
中 | 高 | JWT Payload 携带基础信息减少远程调用;Redis 缓存热点用户/租户信息;MQ 广播变更 |
| 数据库拆分数据不一致 迁移过程中 auth_db 与 business_db 数据不同步 |
高 | 低 | Phase 3 先双写(同时写旧库和新库),验证一致后切换读新库,最后停止写旧库 |
| apes-commons 兼容问题 shade relocation 可能遗漏某些内部类或反射调用 |
中 | 中 | Phase 2 充分测试;保留 apes-commons 作为 fallback 依赖直到 Phase 3 完成后移除 |
| 网关单点故障 所有请求经过网关,网关宕机导致全站不可用 |
高 | 低 | 网关集群部署 + 负载均衡;网关无状态化设计;配置降级策略(网关故障时直接路由) |
| 配额检查性能 业务操作前需远程检查配额,增加请求延迟 |
中 | 中 | Redis 缓存配额信息(TTL 5 分钟);批量操作时一次性检查;允许短时超卖(最终一致) |
| 团队学习成本 微服务架构对团队提出更高要求 |
中 | 高 | Phase 1-2 不引入复杂中间件(无注册中心/配置中心);先用 Nginx 做网关,后续再引入 Spring Cloud Gateway |
账权模块可以抽象为独立服务。核心依据是当前架构中业务模块与账权模块之间不存在直接代码调用(0 个业务 Service import 账权 Service),耦合仅通过 AuthContext ThreadLocal 间接发生,且登录态已存储在 Redis 中天然支持分布式。通过"网关解析 Token → HTTP Header 传递 → auth-sdk 解析"的方式,可以将 52 个文件的 AuthContext 耦合以最小改动(import 替换)的方式消除。
| 阶段 | 核心工作量 | 预估人天 |
|---|---|---|
| Phase 1 | 包迁移 + 接口抽象 + 回归测试 | 5-10 人天 |
| Phase 2 | auth-sdk 开发 + 网关搭建 + shade 配置 + 全量测试 | 10-15 人天 |
| Phase 3 | 服务拆分 + 数据库迁移 + 双写过渡 + Feign 接入 | 15-20 人天 |
| Phase 4 | JWT + 权限注解 + OAuth2 + 持续优化 | 10+ 人天 |
| 合计 | 40-55 人天 |
本方案基于 service-master 源码静态分析编制。实际实施前建议结合运行时监控数据(API 调用频率、AuthContext 使用热点)做进一步验证。