深入Spring MVC之Nether:自定义HandlerMethodArgumentResolver简化用户上下文处理!!!

🚀 深入Spring MVC之Nether:自定义HandlerMethodArgumentResolver简化用户上下文处理 (基于productQualification项目实战)

各位奋战在一线的Spring开发者们,大家好!👋 在我们的日常开发中,Controller层经常需要获取当前登录用户的ID,并根据这个ID结合业务场景(比如特定的“工具类型”)来确定一个有效的操作上下文ID。如果这种逻辑在多个地方重复,代码就会显得臃肿不堪。

productQualification项目中,我们就遇到了类似的需求:协作者(editorId)进行操作时,需要将其行为映射到邀请他/她的VIP用户(VipAdminId)的上下文中。今天,我们就来分享如何利用Spring MVC的自定义HandlerMethodArgumentResolver(处理器方法参数解析器)特性,结合我们项目中的AdminCommonServiceCopywritingApiService,优雅地解决了这个问题!🎉

📜 本文核心速览 (Table of Contents)

章节主要内容看点 ✨
🎯 项目背景与痛点 (Project Context & Pain Points)productQualification中协作者权限处理的挑战,Controller代码重复问题。真实项目场景,引出解决方案的必要性
💡 解决方案:HandlerMethodArgumentResolver登场!介绍Spring MVC参数解析器机制,以及我们为何选择它。核心技术点阐述
🛠️ 实战三部曲:构建我们的“ID转换引擎”1. @EffectiveAdminId注解定义
2. EffectiveAdminIdArgumentResolver实现
3. ArgumentResolverConfig注册
核心代码展示,结合项目服务 (AdminCommonService, CopywritingApiService)
🎮 ConsignmentSettlementController的华丽变身展示Controller如何使用新注解,简化参数获取。实战应用,效果显著
👍 这样做的好处? (Advantages)代码整洁、逻辑复用、Service层“无感知”(在特定前提下)。方案价值总结
⚠️ 关键前提与注意事项 (Prerequisites & Caveats)Service层对传入ID的信任机制,审计记录的考量。客观分析,避免误用
🌟 总结与展望 (Conclusion & Outlook)自定义参数解析器带来的便利,鼓励探索Spring MVC的更多潜力。完美收官,技能提升

🎯 项目背景与痛点 (Project Context & Pain Points)

在我们的productQualification项目中,有一个核心的协作模式:VIP用户可以邀请其他用户作为协作者(editorId)来共同管理某些“工具”或模块的数据。例如,在“寄售结算”(Consignment Settlement)模块中,我们希望:

  1. 当协作者登录并操作时,系统能自动识别出他当前应该代表哪个VIP用户(我们称之为VipAdminId)。
  2. 所有的数据查询和写操作(创建、更新、删除)都应该在这个VipAdminId的上下文中进行,数据归属也应指向这个VipAdminId
  3. 超级管理员(Super Admin)则拥有全局视野,其查询范围不受特定VipAdminId限制。

最初,我们可能会在每个ConsignmentSettlementController的方法中都加入类似的逻辑:

// 伪代码 - 旧的重复逻辑
Integer originalAdminId = (Integer) session.getAttribute(Constants.ADMIN_ID);
Integer effectiveAdminId;
if (adminCommonService.hasRole(originalAdminId, Admin.ROLE_SUPER)) {
    effectiveAdminId = null; // 超管
} else {
    effectiveAdminId = copywritingApiService.getVipAdminId(originalAdminId, CONSIGNMENT_TOOL_TYPE);
}
// 然后用 effectiveAdminId 去调用 Service...

这样的代码一旦多起来,Controller就会变得非常臃肿,难以维护!😱 我们需要一种更优雅的方式!

💡 解决方案:HandlerMethodArgumentResolver登场!

Spring MVC框架提供了一个强大的扩展接口——HandlerMethodArgumentResolver。它允许我们自定义Controller方法参数的解析过程。简单来说,我们可以写一个自己的“参数魔术师”,在请求到达Controller方法之前,就智能地把我们需要的、处理好的参数值准备好,然后直接注入到方法参数中!

我们的目标:让Controller方法只需要声明一个带有特定注解的Integer adminId参数,这个参数的值就会自动是我们期望的VipAdminId(或超管的null)。

🛠️ 实战三部曲:构建我们的“ID转换引擎”

让我们一步步构建这个引擎!

Mermaid 流程图:ID转换引擎构建与工作流程 ⚙️

是 (超管)
否 (非超管)
用户发起请求
(例如 /api/consignmentSettlement/list)
Spring MVC 接收请求
DispatcherServlet 查找Controller方法
参数是否包含 @EffectiveAdminId?
EffectiveAdminIdArgumentResolver.supportsParameter() 返回 true
EffectiveAdminIdArgumentResolver.resolveArgument() 执行
从Session获取
originalAdminId
获取@EffectiveAdminId注解的
toolType
调用 AdminCommonService.hasRole(originalAdminId, Admin.ROLE_SUPER)?
返回 null (或超管特定值)
调用 CopywritingApiService.getVipAdminId(originalAdminId, toolType)
返回 VipAdminId
VipAdminId 或 null
作为参数值注入Controller方法
其他参数解析器处理
Controller方法执行
(adminId参数已是转换后的值)
调用Service层
(使用转换后的adminId)

第一步:定义“契约”——自定义注解 @EffectiveAdminId

这是我们与参数解析器沟通的“契约”。

package com.productQualification.api.config.resolver; // 你的包路径

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface EffectiveAdminId {
    /**
     * 工具类型 (Tool Type),用于 copywritingApiService.getVipAdminId 方法。
     * 例如,'页面审核'是10,'寄售结算'可能是20。
     */
    int toolType();
}

这个注解告诉解析器:“这个参数需要你特殊处理,并且对应的工具类型是这个值!”

第二步:实现“引擎核心”——EffectiveAdminIdArgumentResolver

这是我们ID转换逻辑的家。

package com.productQualification.api.config.resolver;

import com.productQualification.api.service.CopywritingApiService;
import com.productQualification.api.service.common.AdminCommonService;
import com.productQualification.common.constants.Constants; // Constants.ADMIN_ID
import com.productQualification.user.domain.Admin; // Admin.ROLE_SUPER
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.server.ResponseStatusException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Component
public class EffectiveAdminIdArgumentResolver implements HandlerMethodArgumentResolver {

    private static final Logger log = LoggerFactory.getLogger(EffectiveAdminIdArgumentResolver.class);

    @Autowired
    private AdminCommonService adminCommonService;

    @Autowired
    private CopywritingApiService copywritingApiService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(EffectiveAdminId.class) &&
               parameter.getParameterType().equals(Integer.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        HttpServletRequest httpRequest = webRequest.getNativeRequest(HttpServletRequest.class);
        if (httpRequest == null) {
            log.error("无法获取HttpServletRequest对象,无法解析EffectiveAdminId");
            throw new IllegalStateException("无法获取HttpServletRequest对象"); // 内部错误
        }

        HttpSession session = httpRequest.getSession(false);
        if (session == null) {
            log.warn("用户会话不存在或已过期,无法解析EffectiveAdminId");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户会话不存在或已过期");
        }

        Object adminIdObj = session.getAttribute(Constants.ADMIN_ID); // Constants.ADMIN_ID 是Session中存储用户ID的键
        if (!(adminIdObj instanceof Integer)) {
            log.warn("Session中未找到ADMIN_ID或其类型不正确,无法解析EffectiveAdminId");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户未登录或会话数据无效");
        }
        Integer originalAdminId = (Integer) adminIdObj;

        EffectiveAdminId annotation = parameter.getParameterAnnotation(EffectiveAdminId.class);
        if (annotation == null) { // 防御性编程,理论上已被supportsParameter覆盖
            log.error("参数 {} 配置错误:缺少 @EffectiveAdminId 注解", parameter.getParameterName());
            return originalAdminId; // 或抛出配置错误
        }
        int toolType = annotation.toolType();

        log.debug("开始解析EffectiveAdminId: 原始AdminID={}, ToolType={}", originalAdminId, toolType);

        if (adminCommonService.hasRole(originalAdminId, Admin.ROLE_SUPER)) { // Admin.ROLE_SUPER 是超级管理员角色标识
            log.debug("用户 {} 是超级管理员,EffectiveAdminId 设置为 null", originalAdminId);
            return null; // 超级管理员,返回null,Service层需处理此情况
        } else {
            try {
                // 调用已有的服务进行ID转换
                Integer effectiveId = copywritingApiService.getVipAdminId(originalAdminId, toolType);
                log.debug("用户 {} (非超管) for ToolType {}, 解析得到 EffectiveAdminId: {}", originalAdminId, toolType, effectiveId);
                return effectiveId;
            } catch (Exception e) { // 假设getVipAdminId在权限不足时抛出运行时异常
                log.warn("为用户 {} 解析EffectiveAdminId (ToolType: {}) 时出错: {}", originalAdminId, toolType, e.getMessage());
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "操作权限不足或协作配置错误: " + e.getMessage(), e);
            }
        }
    }
}

这段代码的精华 ✨:
它从Session中获取原始登录用户的adminId,然后根据@EffectiveAdminId注解中指定的toolType,调用我们项目中已有的adminCommonService.hasRolecopywritingApiService.getVipAdminId方法,来决定最终应该注入到Controller方法参数中的adminId值。如果是超管,则注入null(表示全局权限);如果是协作者,则注入其对应的VipAdminId;如果权限解析失败,则抛出403 Forbidden

第三步:向Spring MVC引荐我们的“引擎”——注册参数解析器

我们需要一个配置类来告诉Spring MVC:“我有新帮手了!”

package com.productQualification.api.config.resolver; // 与你项目中其他配置类放一起

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class ArgumentResolverConfig implements WebMvcConfigurer { // 你已经有了这个类,很好!

    @Autowired
    private EffectiveAdminIdArgumentResolver effectiveAdminIdArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(effectiveAdminIdArgumentResolver);
        // 如果有其他自定义解析器,也可以在这里一起加入“全家桶”
    }
}

这段配置非常简单,就是把我们自己写的EffectiveAdminIdArgumentResolver加到Spring MVC的参数解析器大家庭里。

🎮 ConsignmentSettlementController的华丽变身

现在,让我们看看ConsignmentSettlementController是如何焕然一新的!

Sequence Diagram: saveConsignmentSummary调用流程 (时序图) 🎬

客户端 ConsignmentSettlementController EffectiveAdminIdArgumentResolver AdminCommonService (AC) CopywritingApiService (CAS) ConsignmentSummaryService (CSS) HttpSession AC CAS CSS POST /saveConsignmentSummary (携带summaries) Spring MVC准备调用saveConsignmentSummary方法 (Spring MVC) supportsParameter(@EffectiveAdminId adminIdForService) true (Spring MVC) resolveArgument() 获取原始adminId (例如 editor123) editor123 hasRole(editor123, ROLE_SUPER) false getVipAdminId(editor123, CONSIGNMENT_TOOL_TYPE) vip789 (editor123对应的VipAdminId) 返回 vip789 此时 saveConsignmentSummary 方法的 adminIdForService 参数值为 vip789 (内部逻辑) for each summary in summaries: summary.setAdminId(vip789) saveAll(modifiedSummaries) 保存成功 返回成功响应 客户端 ConsignmentSettlementController EffectiveAdminIdArgumentResolver AdminCommonService (AC) CopywritingApiService (CAS) ConsignmentSummaryService (CSS) HttpSession AC CAS CSS

Controller 代码示例:
(这里仅展示saveConsignmentSummarylistConsignmentSettlementByPageWithSearch 作为代表,你可以将此模式应用到所有需要的方法)

package com.productQualification.api.controller.consignmentSettlement;

import com.productQualification.api.config.resolver.EffectiveAdminId; // 引入自定义注解
// ... 其他import ...

@RestController
@RequestMapping("/api/consignmentSettlement")
public class ConsignmentSettlementController {

    // ... (Service注入等不变) ...
    @Autowired
    private ConsignmentSettlementService consignmentSettlementService;
    @Autowired
    private ConsignmentSummaryService consignmentSummaryService; // 确保已注入

    private static final Logger log = LoggerFactory.getLogger(ConsignmentSettlementController.class);
    private static final int CONSIGNMENT_TOOL_TYPE = 10; // 🚨 与你代码中定义一致

    @PostMapping("/listConsignmentSettlementByPageWithSearch")
    @ApiOperation("分页获取ConsignmentSettlement数据(带搜索条件)")
    public BaseResult listConsignmentSettlementByPageWithSearch(
            @EffectiveAdminId(toolType = CONSIGNMENT_TOOL_TYPE) Integer adminId, // ✨参数变身!✨
            @Valid @RequestBody PageWithSearch pageWithSearch) {
        try {
            // 这里的 adminId 已经是 VipAdminId (或超管的 null) 了
            Page<ConsignmentSettlement> consignmentSettlementPage =
                    consignmentSettlementService.findPaginatedConsignmentSettlementByAdminIdAndSearch(
                            adminId, // 直接传递给Service
                            pageWithSearch
                    );
            return BaseResult.success(consignmentSettlementPage);
        } catch (ResponseStatusException e) {
            log.warn("权限校验或参数解析失败 (状态码: {}): {}", e.getStatus(), e.getReason());
            return BaseResult.failure(e.getStatus().value(), e.getReason());
        } catch (Exception e) {
            log.error("分页查询ConsignmentSettlement失败 (解析后AdminID: {}): {}", adminId, e.getMessage(), e);
            return BaseResult.failure(500, "查询失败:" + e.getMessage());
        }
    }

    @PostMapping("/saveConsignmentSummary")
    @ApiOperation("保存寄售总表 (基于时间戳+随机数生成批次订单号)")
    public BaseResult saveConsignmentSummary(
            @EffectiveAdminId(toolType = CONSIGNMENT_TOOL_TYPE) Integer adminIdForService, // ✨参数变身!✨
            // 如果你还想在Controller层面知道原始操作者是谁(例如用于更详细的日志):
            // @ApiIgnore @SessionAttribute(Constants.ADMIN_ID) Integer originalOperatorId,
            @RequestBody List<ConsignmentSummary> consignmentSummaries) {
        try {
            // ... (省略参数校验、过滤、订单号生成等controller层逻辑)
            // 假设你已经过滤了 summariesToSave
            List<ConsignmentSummary> summariesToSave = consignmentSummaries.stream()
                    .filter(Objects::nonNull)
                    .filter(summary -> summary.getStockInCount() != null && summary.getStockInCount() != 0)
                    .collect(Collectors.toList());
             if (summariesToSave.isEmpty()) {
                return BaseResult.success("没有有效的库存变动 (非零) 记录需要保存");
            }
            String batchOrderNo = generateBatchOrderNo(); // 你的方法

            for (ConsignmentSummary summary : summariesToSave) {
                // 关键:使用解析后的 adminIdForService 来设置数据的归属
                summary.setAdminId(adminIdForService);
                summary.setOrderNo(batchOrderNo);
            }

            consignmentSummaryService.saveAll(summariesToSave);

            return BaseResult.success("保存成功,批次订单号:" + batchOrderNo);
        } catch (ResponseStatusException e) {
            log.warn("权限校验或参数解析失败 (状态码: {}): {}", e.getStatus(), e.getReason());
            return BaseResult.failure(e.getStatus().value(), e.getReason());
        } catch (Exception e) {
            log.error("保存ConsignmentSummary列表时发生错误 (解析后AdminID: {}): {}",
                   adminIdForService, e.getMessage(), e);
            return BaseResult.failure(500, "保存处理失败: " + e.getMessage());
        }
    }
    // 辅助方法,仅为示例
    private String generateBatchOrderNo() {
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
        String randomPart = String.format("%04d", ThreadLocalRandom.current().nextInt(0, 10000));
        return "CON" + timestamp + "-" + randomPart;
    }
    // ... 对所有其他需要此“智能adminId”的Controller方法,都进行类似的参数替换。
}

现在,ConsignmentSettlementController 中的方法只需要声明一个带有 @EffectiveAdminId 注解的 Integer adminId 参数,它就能自动获得我们期望的“有效上下文ID”,下游的Service层(前提是它信任并使用这个传入的ID)就能正确地处理数据归属和权限了!是不是非常Nice?😎

👍 这样做的好处? (Advantages)

采用这种自定义参数解析器的方式,为我们的productQualification项目带来了实实在在的好处:

  • Controller代码更简洁 (Cleaner Controller Code): 大量重复的ID解析逻辑被移除,Controller方法更专注于业务编排。
  • 逻辑集中,易于维护 (Centralized & Maintainable Logic): 所有关于“有效AdminId”的解析规则都集中在 EffectiveAdminIdArgumentResolver 中。如果未来解析规则需要调整(比如引入新的用户角色或工具类型判断),我们只需要修改这一个地方!
  • 对Service层“零”或“最小”侵入 (Minimal Intrusion to Service Layer): 这是我们追求的关键目标!只要Service层原先的设计是接收一个adminId参数并用它来处理数据归属和权限,那么Service层几乎不需要任何改动。
  • 类型安全与声明式编程 (Type Safe & Declarative): 通过 @EffectiveAdminId 注解,我们清晰地声明了哪些参数需要这种特殊的上下文处理,增强了代码的可读性。

⚠️ 关键前提与注意事项 (Prerequisites & Caveats)

虽然这个方案很香,但也有几个重要的“但是”需要我们牢记:

  1. Service层的“信任”是基石: 此方案能让Service层“无感知”的前提是,Service层的方法必须完全信任并使用从Controller传递过来的那个 adminId 参数作为数据归属和权限判断的核心。如果Service层内部有自己的逻辑去重新从Session获取原始adminId并覆盖掉我们传入的值,那这个方案就“破功”了。😅
  2. 审计记录 (Audit Trails): 我们在引言中提到,暂时不要求严格区分实际操作者是editorId还是VipAdminId。如果你的Service层或JPA Auditing机制(如使用@CreatedBy, @LastModifiedBy)是基于最终传入的adminId(现在是VipAdminId)来记录创建者/修改者,那么数据库中记录的“操作人”就会是VipAdminId,而不是原始的editorId。如果你的业务需求中,必须明确区分这两者,那么Service层可能还是需要修改,以接收并分别处理“数据归属ID (VipAdminId)”和“实际操作者ID (editorId)”。
  3. toolType的准确性: @EffectiveAdminId(toolType = ...) 中的 toolType 必须与你的业务模块在copywritingApiService.getVipAdminId中定义的类型完全一致,否则ID转换会出错。建议将这些toolType定义为常量。
  4. 异常处理: EffectiveAdminIdArgumentResolver 中对权限不足等情况抛出了ResponseStatusException。你需要确保你的Spring应用有合适的全局异常处理器(@ControllerAdvice)来捕获这类异常,并返回统一格式的错误响应给前端。

🌟 总结与展望 (Conclusion & Outlook)

通过在productQualification项目中引入自定义的HandlerMethodArgumentResolver——EffectiveAdminIdArgumentResolver,我们成功地将复杂的、重复的用户上下文ID解析逻辑从Controller层中抽离出来,实现了更简洁、更易维护的Controller代码。同时,在满足特定前提(Service层信任传入的adminId)的情况下,对Service层的影响降到了最低。

这不仅是Spring MVC框架灵活性和可扩展性的又一次精彩展示,也为我们处理类似的用户上下文、多租户或复杂权限场景提供了宝贵的实战经验。希望这个分享能对你在日常开发中遇到的类似问题有所启发!持续学习,持续探索,让我们的代码越来越优雅!✨


🧠 思维导图 (Markdown Mind Map)

在这里插入图片描述


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值