Spring Boot实战教程:动态为Controller注入“智能”用户ID!!!

🛠️ Spring Boot实战教程:动态为Controller注入“智能”用户ID 💡

嘿,Spring Boot的开发者伙伴们!在构建Web应用时,Controller方法常常需要获取当前用户的ID,并可能需要根据这个ID和业务场景进行一些预处理,才能得到一个真正用于后续业务逻辑的“有效用户ID”。这种逻辑如果散落在每个Controller方法中,不仅重复劳动,还会让代码显得不够优雅。

别担心!本教程将手把手带你学习如何在Spring Boot应用中,利用Spring MVC的自定义HandlerMethodArgumentResolver(处理器方法参数解析器)特性,为你你的Controller方法动态注入一个经过智能处理的“有效用户ID”。我们将以你在productQualification项目中的“寄售结算”模块为例,让你轻松掌握这项实用技能!🚀

📚 本教程你将学会 (What You’ll Learn)

章节主要内容技能点 🛠️
🎯 场景设定:为何需要“智能”用户ID?VIP用户与协作者模式下的用户ID处理挑战。理解需求背景
核心武器:HandlerMethodArgumentResolver简介简述其作用和在Spring MVC中的位置。掌握关键Spring MVC扩展点
🔧 第一步:定义我们的“魔法契约”——@EffectiveAdminId注解创建一个自定义注解来标记需要特殊处理的用户ID参数。自定义Java注解
🔩 第二步:打造“ID转换器”——实现EffectiveAdminIdArgumentResolver编写参数解析器的核心逻辑,集成现有业务服务进行ID转换。实现HandlerMethodArgumentResolver接口,业务逻辑集成
⚙️ 第三步:让Spring Boot认识它——注册参数解析器通过WebMvcConfigurer将自定义解析器配置到Spring Boot应用中。Spring Boot配置,WebMvcConfigurer的使用
🕹️ 第四步:Controller实战应用——注入“智能”ID展示如何在Controller方法中使用新注解,简化用户ID的获取和使用。Controller层代码简化,实践应用
👍 成果展示与优势总结对比改造前后的代码,总结方案带来的好处。效果评估,方案优势
💡 进阶提示与注意事项异常处理、toolType管理、Service层依赖等。深入思考,避免踩坑

🎯 场景设定:为何需要“智能”用户ID?

在我们的productQualification应用中,以“寄售结算”(Consignment Settlement)模块为例,存在以下用户角色和操作场景:

  • VIP用户 (VipAdminId): 是模块数据的主要所有者和管理者。
  • 协作者 (editorId): 由VIP用户邀请,可以代表VIP用户进行数据的查看和操作(包括创建、修改)。
  • 超级管理员 (Super Admin): 拥有全局查看权限,可能不受特定VIP用户上下文的限制。

当一个HTTP请求到达ConsignmentSettlementController时,我们面临的挑战是:如何在Controller方法中获得一个“有效的”用户ID,这个ID能准确反映当前操作应该归属的上下文?

  • 如果操作者是协作者,这个“有效ID”应该是邀请他的那个VIP用户的ID。
  • 如果操作者是VIP用户本人,那么“有效ID”就是他自己的ID。
  • 如果操作者是超级管理员,“有效ID”可能是null(表示全局)或其自身ID。

我们希望避免在每个Controller方法中都重复编写从Session获取原始用户ID,然后调用AdminCommonService判断角色,再调用CopywritingApiService转换ID的逻辑。这正是自定义参数解析器大显身手的时候!

✨ 核心武器:HandlerMethodArgumentResolver简介

Spring MVC中的HandlerMethodArgumentResolver是一个非常强大的接口。它的使命是解析HTTP请求中的信息,并为Controller方法的参数提供值。你可以把它想象成一个专业的“参数管家团队”,每个管家(解析器实现)都擅长处理特定类型的参数。

当Spring MVC准备调用一个Controller方法时,它会询问这个团队:“这个方法的某个参数,你们谁能搞定?”如果我们的自定义“管家”站出来说“我能!”,Spring MVC就会让它去解析并提供参数值。

通过自定义实现这个接口,我们就能在参数绑定阶段注入我们想要的、经过智能处理的“有效用户ID”。

🔧 第一步:定义我们的“魔法契约”——@EffectiveAdminId注解

我们需要一个自定义注解来告诉Spring MVC:“嘿,这个Controller方法参数需要我的专属‘ID转换器’来处理!”

// src/main/java/com/productQualification/api/config/resolver/EffectiveAdminId.java
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 ID),用于 copywritingApiService.getVipAdminId 方法。
     * 这个值需要根据当前Controller或方法对应的业务模块来指定。
     * 例如,在你的ConsignmentSettlementController中,你将其硬编码为10。
     */
    int toolType();
}

这个注解非常简洁,它有一个toolType属性,用于指示当前操作属于哪个业务模块的上下文,这是我们后续ID转换逻辑的关键输入。

🔩 第二步:打造“ID转换器”——实现EffectiveAdminIdArgumentResolver

这是我们解决方案的核心!这个类将负责从Session中获取原始用户ID,并利用你项目中已有的AdminCommonServiceCopywritingApiService来执行智能转换。

// src/main/java/com/productQualification/api/config/resolver/EffectiveAdminIdArgumentResolver.java
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;      // Session中用户ID的键
import com.productQualification.user.domain.Admin;                 // 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 // 标记为Spring组件,使其能够被自动扫描和依赖注入
public class EffectiveAdminIdArgumentResolver implements HandlerMethodArgumentResolver {

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

    @Autowired
    private AdminCommonService adminCommonService;

    @Autowired
    private CopywritingApiService copywritingApiService;

    /**
     * 判断此解析器是否支持给定的方法参数。
     * 如果参数被 @EffectiveAdminId 注解并且类型是 Integer,则返回true。
     */
    @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); // false: 如果session不存在,不创建新的
        if (session == null) {
            log.warn("用户会话不存在或已过期,无法解析EffectiveAdminId");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户会话不存在或已过期");
        }

        Object adminIdObj = session.getAttribute(Constants.ADMIN_ID); // 从Session获取原始AdminID
        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);
        // supportsParameter 已保证注解存在,但再次检查更安全
        if (annotation == null) { // 理论上不会执行到这里
            log.error("参数 {} 配置错误:缺少 @EffectiveAdminId 注解,但supportsParameter返回true", parameter.getParameterName());
            return originalAdminId; // 或者抛出配置相关的服务器内部错误
        }
        int toolType = annotation.toolType(); // 获取注解中指定的toolType

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

        // --- 核心的ID转换逻辑 ---
        if (adminCommonService.hasRole(originalAdminId, Admin.ROLE_SUPER)) { // 判断是否为超级管理员
            log.debug("用户 {} 是超级管理员,EffectiveAdminId 设置为 null (表示全局权限)", originalAdminId);
            return null; // 超级管理员,返回null (Service层需要能处理这种情况)
        } else {
            // 非超级管理员,调用CopywritingApiService进行ID转换
            try {
                Integer effectiveId = copywritingApiService.getVipAdminId(originalAdminId, toolType);
                log.debug("用户 {} (非超管) for ToolType {}, 解析得到 EffectiveAdminId: {}", originalAdminId, toolType, effectiveId);
                return effectiveId; // 返回转换后的VipAdminId
            } catch (Exception e) { // 假设getVipAdminId在权限不足或用户未被邀请时会抛出异常
                log.warn("为用户 {} 解析EffectiveAdminId (ToolType: {}) 时出错: {}", originalAdminId, toolType, e.getMessage());
                // 抛出403 Forbidden,表示用户有身份认证,但无权访问该资源或上下文
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "操作权限不足或协作配置错误: " + e.getMessage(), e);
            }
        }
    }
}

代码解读 🧐:
这个解析器完美地利用了你项目中已有的AdminCommonService(用于判断超管角色)和CopywritingApiService(用于获取协作者对应的VipAdminId)。它健壮地处理了各种边界情况,如Session无效、用户未登录、权限不足等,并通过抛出ResponseStatusException来返回明确的HTTP错误状态。

⚙️ 第三步:让Spring Boot认识它——注册参数解析器

我们需要告诉Spring Boot我们的新“参数管家”已经准备好上岗了!这通过一个实现了WebMvcConfigurer接口的配置类来完成。

// src/main/java/com/productQualification/api/config/resolver/ArgumentResolverConfig.java
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); // 将它添加到Spring MVC的参数解析器列表中
    }
}

这个配置非常简单,就是把我们的EffectiveAdminIdArgumentResolver注册到Spring MVC中。你已经有了一个类似的JwtInterceptorConfig.java,可以将addArgumentResolvers的逻辑合并到那里,或者像这样保持为一个独立的配置类,以实现更好的职责分离。

🕹️ 第四步:Controller实战应用——注入“智能”ID

现在,魔法准备就绪!我们可以在ConsignmentSettlementController中使用了。

Mermaid 流程图:Controller参数解析与Service调用 🌟

HTTP请求 (携带原始Session信息)
Spring MVC
Controller方法参数
是否有@EffectiveAdminId?
EffectiveAdminIdArgumentResolver
解析参数
解析得到 effectiveAdminId
(VipAdminId 或 null)
effectiveAdminId 作为参数值
注入Controller方法
Controller方法执行
service.doSomething(effectiveAdminId, ...)
Service层使用effectiveAdminId
进行数据操作和权限校验
(Service层无需改动原有逻辑)
返回结果
其他参数解析器处理

ConsignmentSettlementController.java 改造示例:
(这里我们重点展示你提供的listConsignmentSettlementByPageWithSearchsaveConsignmentSummary方法)

// src/main/java/com/productQualification/api/controller/consignmentSettlement/ConsignmentSettlementController.java
package com.productQualification.api.controller.consignmentSettlement;

import com.productQualification.api.config.resolver.EffectiveAdminId; // 导入自定义注解
import com.productQualification.api.entity.ConsignmentSettlement;
import com.productQualification.api.entity.ConsignmentSummary;
import com.productQualification.api.entity.PageWithSearch;
import com.productQualification.api.service.consignmentSettlement.ConsignmentSettlementService;
import com.productQualification.api.service.consignmentSettlement.ConsignmentSummaryService;
import com.productQualification.common.entity.BaseResult;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; // 用于捕获解析器抛出的异常

import javax.validation.Valid;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;


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

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

    @Autowired
    private ConsignmentSettlementService consignmentSettlementService;
    @Autowired
    private ConsignmentSummaryService consignmentSummaryService;
    // ... 其他Service注入 ...

    // 为 "寄售结算" 模块定义一个统一的工具类型常量
    // 🚨 重要:这个值必须与你在 EffectiveAdminIdArgumentResolver 中期望的类型,
    // 以及 CopywritingApiService.getVipAdminId 中处理逻辑一致!
    // 你在之前的代码中,Controller里用的是10,而我建议的是20,请统一。
    // 这里我先用你在Controller中已使用的 10。
    private static final int CONSIGNMENT_TOOL_TYPE = 10;

    @PostMapping("/listConsignmentSettlementByPageWithSearch")
    @ApiOperation("分页获取ConsignmentSettlement数据(带搜索条件)")
    public BaseResult listConsignmentSettlementByPageWithSearch(
            // ✨✨✨ 看这里!参数 adminId 现在由我们的解析器提供 ✨✨✨
            @EffectiveAdminId(toolType = CONSIGNMENT_TOOL_TYPE) Integer adminId,
            @Valid @RequestBody @ApiParam(value = "分页和搜索条件") PageWithSearch pageWithSearch
    ) {
        try {
            log.info("Controller: listConsignmentSettlementByPageWithSearch, 有效AdminID: {}", adminId);
            // 这里的 adminId 已经是 VipAdminId (如果操作者是协作者或VIP)
            // 或者是 null (如果操作者是超级管理员,且解析器如此返回)
            // Service层可以直接使用这个adminId,无需关心它是如何转换来的!
            Page<ConsignmentSettlement> consignmentSettlementPage =
                    consignmentSettlementService.findPaginatedConsignmentSettlementByAdminIdAndSearch(
                            adminId,
                            pageWithSearch
                    );
            return BaseResult.success(consignmentSettlementPage);
        } catch (ResponseStatusException e) { // 捕获解析器可能抛出的HTTP状态异常
            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,
            // 如果你仍然需要原始操作者ID用于特定日志或审计(Service层需支持),可以这样保留:
            // @ApiIgnore @SessionAttribute(Constants.ADMIN_ID) Integer originalOperatorId,
            @RequestBody List<ConsignmentSummary> consignmentSummaries
    ) {
        try {
            log.info("Controller: saveConsignmentSummary, 有效AdminID用于Service: {}", adminIdForService);
            // (省略参数校验、过滤等Controller层逻辑...)
             if (consignmentSummaries == null || consignmentSummaries.isEmpty()) {
                return BaseResult.success("保存列表为空,不写入数据库");
            }
            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 来设置数据的归属ID。
                // Service层在保存时将使用这个ID。
                summary.setAdminId(adminIdForService);
                summary.setOrderNo(batchOrderNo);
                // 如果需要记录原始操作者,且实体有对应字段,且Service不处理:
                // summary.setCreatorId(originalOperatorId); // (需要originalOperatorId参数)
            }

            consignmentSummaryService.saveAll(summariesToSave); // Service层接收到已处理好adminId的列表

            return BaseResult.success("保存成功,批次订单号:" + batchOrderNo);
        } catch (ResponseStatusException e) { // 捕获解析器可能抛出的HTTP状态异常
            log.warn("请求处理失败 (状态码 {}): {}", e.getStatus(), e.getReason());
            return BaseResult.failure(e.getStatus().value(), e.getReason());
        } catch (Exception e) {
            // 如果你保留了 originalOperatorId,可以在日志中使用它
            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;
    }

    // 你可以将此模式应用到 `ConsignmentSettlementController` 中所有需要这种
    // “智能”adminId 的查询和写操作方法上!
}

核心变化:Controller方法参数中,原来从Session获取adminId的地方,现在被替换为使用@EffectiveAdminId注解的参数。Controller方法体内部不再需要关心ID的转换逻辑,直接使用这个由参数解析器提供的“有效用户ID”即可。

👍 成果展示与优势总结

通过上述步骤,我们成功地为ConsignmentSettlementController(以及项目中其他需要类似功能的Controller)动态注入了经过智能处理的“有效用户ID”。

主要优势:

  • Controller代码极致简洁: 移除了所有重复的ID获取和转换逻辑,Controller方法更专注于业务流程。
  • 🧩 逻辑高度复用: ID转换的核心逻辑集中在EffectiveAdminIdArgumentResolver中,一处修改,所有使用@EffectiveAdminId的地方都会生效。
  • 🛡️ 对Service层“零侵入” (在理想情况下): 这是本教程的核心目标!只要你的Service层之前是设计为接收一个adminId并用其进行操作(数据归属、权限校验),那么Service层代码完全不需要修改。它继续幸福地接收一个adminId参数,并不知道这个参数背后发生的“魔法”。
  • 🎯 声明式意图: 通过@EffectiveAdminId注解,清晰地表达了参数的特殊含义和预期的处理方式,增强了代码的可读性。
  • 🧪 可测试性提升: Controller更容易进行单元测试,因为其依赖的adminId参数可以直接mock或提供预期的值,而无需模拟复杂的Session环境或依赖注入多个服务来进行ID转换。

💡 进阶提示与注意事项

  1. toolType的准确管理: 这是本方案正确工作的关键。务必确保:
    • @EffectiveAdminId(toolType = ...)中的toolType值与你的业务模块(以及CopywritingApiService.getVipAdminId方法中期望的类型)严格对应。
    • 建议将这些toolType定义为易于管理的常量(例如在你Controller的顶部,或者一个专门的常量类中)。
  2. Service层的“信任契约”: 再次强调,此方案“对Service层零侵入”的有效性,依赖于你的Service层方法完全信任并使用从Controller传递过来的adminId参数。如果Service层内部有任何逻辑会忽略、覆盖这个传入的adminId(例如,Service层自己又从Session或其他地方重新获取原始adminId并使用它),那么这个方案就会失效。
  3. 写操作的数据归属设置: 对于创建新数据的操作(例如saveConsignmentSummary中的summary.setAdminId(adminIdForService)),你必须在Controller层(或者一个非常早的请求预处理阶段,但在参数解析器之后)将解析得到的effectiveAdminId设置到待保存的DTO或实体对象的相应归属字段上,然后Service层才能正确地将其持久化。参数解析器本身只负责提供值给Controller方法参数,它不直接修改请求体中的对象。
  4. 审计信息 (如果需要区分所有者和操作者):
    • 正如前面提到的,如果你的系统需要严格区分“数据所有者 (VipAdminId)”和“实际执行操作的人 (editorId)”并记录到不同的数据库字段(例如 owner_idcreator_id/last_modified_by_id),那么:
      • 你可能需要在Controller方法签名中,除了使用@EffectiveAdminId获取ownerId之外,再额外通过@SessionAttribute获取原始的operatorId
      • 然后将这两个ID都传递给Service层。
      • 这种情况下,Service层的方法签名就需要修改以接收这两个不同的ID,并在内部逻辑中分别使用它们。这超出了“对Service层零侵入”的范畴,但可能是满足完整审计需求的必要步骤。
    • 如果当前方案导致审计字段(如@CreatedBy)记录的是VipAdminId而不是editorId,并且这是可以接受的(因为你之前提到“不需要区分操作者记录”),那么Service层就无需为此修改。
  5. 异常处理: EffectiveAdminIdArgumentResolver中我们使用了ResponseStatusException来抛出如401、403等HTTP状态码。你的Spring Boot应用应该有相应的异常处理机制(例如,默认的或自定义的@ControllerAdvice)来将这些异常转换为对前端友好的错误响应。Controller方法中捕获ResponseStatusException是为了在日志中记录更具体的上下文,并可以按需返回BaseResult

恭喜你!现在你已经掌握了在Spring Boot应用中使用自定义HandlerMethodArgumentResolver来动态注入“智能”用户ID的实用技巧。这将使你的Controller代码更加优雅、简洁和易于维护。快去你的productQualification项目中全面应用这个技巧,享受编码的乐趣吧!🚀


🧠 思维导图 (Markdown Mind Map)

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值