.NET 8 中如何进行集成测试实例讲解

f8ba52dc08d1e2f4eaac003a279564ff.jpeg

概述:我软件工程中的集成测试检查程序的不同部分是否很好地协同工作。它确保当一段代码与另一段代码交谈时,它们相互理解并正确共享信息。通过这样做,它有助于及早发现和解决问题,使软件更好、更可靠。集成测试就像确保机器中的所有齿轮都能顺利地组装在一起,因此整个过程也运行平稳。这是确保我们每天使用的软件按预期方式工作的重要一步。集成测试与单元测试集成测试检查软件系统的不同部分是否很好地协同工作,验证模块之间的交互。另一方面,单元测试单独测试单个组件。集成测试验证整体系统行为,而单元测试则侧重于每个组件中的特定功能,确保它们正确且独立地工作。单元测试范围:单元测试侧重于测试代码的各个单元,例如方法或类。它们确保

我软件工程中的集成测试检查程序的不同部分是否很好地协同工作。它确保当一段代码与另一段代码交谈时,它们相互理解并正确共享信息。通过这样做,它有助于及早发现和解决问题,使软件更好、更可靠。集成测试就像确保机器中的所有齿轮都能顺利地组装在一起,因此整个过程也运行平稳。这是确保我们每天使用的软件按预期方式工作的重要一步。

集成测试与单元测试

集成测试检查软件系统的不同部分是否很好地协同工作,验证模块之间的交互。另一方面,单元测试单独测试单个组件。集成测试验证整体系统行为,而单元测试则侧重于每个组件中的特定功能,确保它们正确且独立地工作。

单元测试

  • 范围:单元测试侧重于测试代码的各个单元,例如方法或类。它们确保隔离组件正常工作。

  • 依赖关系:单元测试被设计为隔离的,通常使用模拟来模拟依赖关系。这种隔离允许更快的执行。

  • 速度:单元测试往往更快,因为它们是独立运行的,避免了外部依赖关系。

  • 重点:他们专注于验证代码单元中的特定功能。

  • 设置复杂性:单元测试通常涉及更简单的设置和更少的外部依赖项。

集成测试

  • 范围:集成测试验证多个单元或组件之间的交互。它们确保这些组件按预期协同工作。

  • 依赖关系:集成测试涉及实际依赖关系,例如数据库、API 或其他外部服务。

  • 速度:由于涉及多个组件和实际交互,这些测试可能会变慢。

  • 重点:主要目标是验证数据流和组件之间的交互。

  • 设置复杂性:集成测试可能需要更复杂的设置,包括使用实际服务配置环境。

让我们为 .NET 8 API 创建集成测试。

今天的堆栈

  • .NET 8 Web API

  • x单位

  • 流畅的断言

  • SQLite 内存数据库。

应用程序接口

假设我们有一个示例 Web API,如下所示。

29e5131de4da7beab9d03de816a2bfff.jpeg

InvoiceItem 控制器

[ApiController]
[Route("/api/v1")]
public class InvoiceItemController(IInvoiceItemService invoiceItemService) : ControllerBase
{
    private readonly IInvoiceItemService _invoiceItemService = invoiceItemService;

    [HttpGet("InvoiceItems")]
    public async Task<ActionResult<IEnumerable<InvoiceDto>>> QueryAsync([FromQuery] InvoiceQueryRequestModel request)
    {
         return Ok(await _invoiceItemService.QueryAsync(request));
    }

    [HttpGet("InvoiceItems/{id}")]
    public async Task<ActionResult<InvoiceRequestModel>> GetAsync(long id)
    {
        return Ok(await _invoiceItemService.GetByIdAsync(id));
    }

    [HttpPost("InvoiceItems")]
    public async Task<ActionResult<InvoiceDto>> CreateAsync(InvoiceRequestModel request)
    {
        var result = await _invoiceItemService.CreateAsync(request);
        return StatusCode((int)HttpStatusCode.Created, result);
    }

    [HttpDelete("InvoiceItems/{id}")]
    public async Task<IActionResult> DeleteAsync(long id)
    {
        await _invoiceItemService.DeleteAsync(id);
        return NoContent();
    }

    [HttpPatch("InvoiceItems/{id}")]
    public async Task<ActionResult<InvoiceRequestModel>> UpdateAsync(long id, InvoiceRequestModel request)
    {
        return Ok(await _invoiceItemService.UpdateAsync(id, request));
    }
}

我将详细介绍 API 的其他组件。让我们考虑一下此 API 的以下实体设计。

public class Invoice
{
    [Key]
    public long Id { get; set; }

    public long CategoryId { get; set; }
    public virtual InvoiceCategory? Category { get; set; }

    public long? SubCategoryId { get; set; }
    public virtual InvoiceSubCategory? SubCategory { get; set; }

    public double TonsOfCO2 { get; set; }
}

public class InvoiceCategory
{
    [Key]
    public long Id { get; set; }

    public string Name { get; set; }
}

public class InvoiceSubCategory
{
    [Key]
    public long Id { get; set; }

    public string Name { get; set; }
}

public class InvoiceDbContext(DbContextOptions<InvoiceDbContext> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices { get; set; }
    public DbSet<InvoiceCategory> InvoiceCategories { get; set; }
    public DbSet<InvoiceSubCategory> InvoiceSubCategories { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new InvoiceCategoryConfiguration());
        modelBuilder.ApplyConfiguration(new InvoiceSubCategoryConfiguration());
        modelBuilder.ApplyConfiguration(new InvoiceRowConfiguration());
    }
}

实体配置

public class InvoiceCategoryConfiguration : IEntityTypeConfiguration<InvoiceCategory>
{
    public void Configure(EntityTypeBuilder<InvoiceCategory> builder) => SeedDefaultCategories(builder);

    private static void SeedDefaultCategories(EntityTypeBuilder<InvoiceCategory> builder) =>
        builder.HasData(
            new InvoiceCategory
            {
                Id = 1,
                Name = "Category 1"
            },
            new InvoiceCategory
            {
                Id = 2,
                Name = "Category 2"
            },
            new InvoiceCategory
            {
                Id = 3,
                Name = "Category 3"
            }
        );
}

public class InvoiceRowConfiguration : IEntityTypeConfiguration<Invoice>
{
    public void Configure(EntityTypeBuilder<Invoice> builder)
    {
        ConfigureCategoryRelationship(builder);
        ConfigureSubCategoryRelationship(builder);
        SeedDefaultData(builder);
    }

    private static void ConfigureCategoryRelationship(EntityTypeBuilder<Invoice> builder) =>
        builder
            .HasOne(e => e.Category)
            .WithMany()
            .HasForeignKey(e => e.CategoryId)
            .IsRequired();


    private static void ConfigureSubCategoryRelationship(EntityTypeBuilder<Invoice> builder) =>
        builder
            .HasOne(e => e.SubCategory)
            .WithMany()
            .HasForeignKey(e => e.SubCategoryId);

    private static void SeedDefaultData(EntityTypeBuilder<Invoice> builder)
    {
        builder.HasData(
            new Invoice
            {
                Id = 1,
                CategoryId = 1,
                SubCategoryId = 1,
                TonsOfCO2 = 1
            },
            new Invoice
            {
                Id = 2,
                CategoryId = 2,
                SubCategoryId = 2,
                TonsOfCO2 = 2
            }
        );
    }
}

public class InvoiceSubCategoryConfiguration : IEntityTypeConfiguration<InvoiceSubCategory>
{
    public void Configure(EntityTypeBuilder<InvoiceSubCategory> builder) => SeedDefaultSubCategories(builder);

    private void SeedDefaultSubCategories(EntityTypeBuilder<InvoiceSubCategory> builder) =>
        builder.HasData(
            new InvoiceSubCategory
            {
                Id = 1,
                Name = "Sub Category 1"
            },
            new InvoiceSubCategory
            {
                Id = 2,
                Name = "Sub Category 2"
            },
            new InvoiceSubCategory
            {
                Id = 3,
                Name = "Sub Category 3"
            }
        );
}

发票项目服务

public interface IInvoiceItemService
{
    Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQueryRequestModel);
    Task<InvoiceDto> GetByIdAsync(long id);
    Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoice);
    Task DeleteAsync(long id);
    Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice);
}

public class InvoiceItemService(IInvoiceItemRepository invoiceItemRepository) : IInvoiceItemService
{
    private readonly IInvoiceItemRepository _invoiceItemRepository = invoiceItemRepository;

    public async Task<InvoiceDto> GetByIdAsync(long id) =>
        await _invoiceItemRepository.GetByIdAsync(id);

    public async Task<InvoiceDto> CreateAsync(InvoiceRequestModel emissionBreakdownRow) =>
        await _invoiceItemRepository.CreateAsync(emissionBreakdownRow);

    public async Task DeleteAsync(long id) =>
        await _invoiceItemRepository.DeleteAsync(id);

    public async Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel emissionBreakdownRow) => 
        await _invoiceItemRepository.UpdateAsync(id, emissionBreakdownRow);

    public async Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel emissionBreakdownQuery) =>
        await _invoiceItemRepository.QueryAsync(emissionBreakdownQuery);
}

发票项目存储库

public interface IInvoiceItemRepository
{
    Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQuery);
    Task<InvoiceDto> GetByIdAsync(long id);
    Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoice);
    Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice);
    Task DeleteAsync(long id);
}

public class InvoiceItemRepository(InvoiceDbContext context, IMapper mapper) : IInvoiceItemRepository
{
    private readonly InvoiceDbContext _context = context;
    private readonly IMapper _mapper = mapper;

    public async Task<IEnumerable<InvoiceDto>> QueryAsync(InvoiceQueryRequestModel invoiceQuery)
    {
        var query = _context.Invoices
            .Include(x => x.Category)
            .Include(x => x.SubCategory)
            .AsQueryable();

        // Apply filters
        if (invoiceQuery.CategoryId != null)
            query = query.Where(row => row.CategoryId == invoiceQuery.CategoryId);

        if (invoiceQuery.SubCategoryId != null)
            query = query.Where(row => row.SubCategoryId == invoiceQuery.SubCategoryId);

        // Apply sorting
        if (!string.IsNullOrEmpty(invoiceQuery.SortField))
        {
            query = invoiceQuery.SortField switch
            {
                "CategoryId" => query.OrderBy(row => row.CategoryId),
                "SubCategoryId" => query.OrderBy(row => row.SubCategoryId),
                "TonsOfCO2" => query.OrderBy(row => row.TonsOfCO2),
                "Id" => query.OrderBy(row => row.Id),
                _ => query.OrderBy(row => row.Id),
            };
        }
        else
        {
            query = query.OrderBy(row => row.Id);
        }

        // Apply paging
        query = query.Skip((invoiceQuery.PageToken - 1) * invoiceQuery.PageSize).Take(invoiceQuery.PageSize);

        var result = await query.ToListAsync();
        return _mapper.Map<List<InvoiceDto>>(result);
    }

    public async Task<InvoiceDto> CreateAsync(InvoiceRequestModel invoiceQuery)
    {
        var existingEntity = await _context.Invoices
            .FirstOrDefaultAsync(e => e.CategoryId == invoiceQuery.CategoryId &&
                e.SubCategoryId == invoiceQuery.SubCategoryId);

        if (existingEntity != null)
        {
            throw new ServerException(Messages.DuplicateValues);
        }

        try
        {
            var entity = _mapper.Map<Invoice>(invoiceQuery);
            await _context.Invoices.AddAsync(entity);
            await _context.SaveChangesAsync();
            return _mapper.Map<InvoiceDto>(entity);
        }
        // To throw a meaning message from the API 
        catch
        {
            throw new ServerException(Messages.CreationFailed);
        }
    }

    public async Task<InvoiceDto> GetByIdAsync(long id)
    {
        var invoice = await (_context.Invoices
            .Include(x => x.Category)
            .Include(x => x.SubCategory)).FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new NotFoundException(Messages.NotFound);
        return _mapper.Map<InvoiceDto>(invoice);
    }

    public async Task DeleteAsync(long id)
    {
        var entity = await _context.Invoices.FindAsync(id)
            ?? throw new NotFoundException(Messages.NotFound);
        _context.Invoices.Remove(entity);
        await _context.SaveChangesAsync();
    }

    public async Task<InvoiceDto> UpdateAsync(long id, InvoiceRequestModel invoice)
    {
        var entity = await _context.Invoices.FindAsync(id)
            ?? throw new NotFoundException(Messages.NotFound);

        entity.TonsOfCO2 = invoice.TonsOfCO2;
        entity.CategoryId = invoice.CategoryId;
        entity.SubCategoryId = invoice.SubCategoryId;

        await _context.SaveChangesAsync();
        return _mapper.Map<InvoiceDto>(entity);
    }
}

测试项目

让我们在 VS 中创建新项目时通过选择“xUnit Test Project”来创建单元测试项目。

95085718ff4e25feab2a264ddb7667ed.jpeg

安装以下软件包。

3f30f3988a1339d1458fee6fa2fa773f.jpeg

在 ASP.NET Core 中实现集成测试时,可能需要覆盖配置设置以进行测试。

配置覆盖

在集成测试期间,您通常需要与“program.cs”中的设置和配置不同的设置和配置。常见方案包括连接到不同的数据库(例如,内存中的 SQLite)和模拟外部依赖项。可以通过更改 Options 类型的属性来使用 Options 模式覆盖配置值。确保测试设置在运行应用程序之前和之后提供必要的配置值。

数据库覆盖

对于 SQLite 内存中测试,请安装 EF Core InMemory NuGet 包。之后,我们将覆盖相关配置并利用现有的数据库上下文、实体和迁移。

让我们通过继承 WebApplicationFactory 类来创建一个工厂类,如下所示。

public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);
        Program.IsFromTest = true;
        
        builder.ConfigureTestServices(services =>
        {
            var dbContext = services.SingleOrDefault(x => x.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));
            services.Remove(dbContext);

            var dbConnection = services.SingleOrDefault(x => x.ServiceType == typeof(DbConnection));
            services.Remove(dbConnection);

            services.AddSingleton<DbConnection>(container =>
            {
                var connection = new SqliteConnection("DataSource=:memory:");
                connection.Open();
                return connection;
            });

            services.AddDbContext<InvoiceDbContext>((container, options) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                options.UseSqlite(connection);
            });
        });
        builder.UseEnvironment("Development");
    }
}

此代码片段自定义集成测试的 Web 主机配置。它删除现有的数据库相关服务,添加 SQLite 内存中连接,并将 InvoiceDbContext 配置为使用此连接。

班级声明

public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class

CustomWebApplicationFactory 继承自“WebApplicationFactory<TProgram>”。泛型类型参数“TProgram”必须是类的引用类型。

方法覆盖

protected override void ConfigureWebHost(IWebHostBuilder builder){  
  
}

此方法重写基类的 ConfigureWebHost 方法。它允许在应用程序启动之前自定义配置 Web 主机构建器。

基本方法调用

base.ConfigureWebHost(builder);

调用基类的 ConfigureWebHost 方法来执行任何默认配置。 这可确保保留基本行为。

服务配置

builder.ConfigureTestServices(services => { … });

专门为测试配置服务。在 lambda 表达式中,您可以修改服务集合 (services)。

删除现有服务

var dbContext = services.SingleOrDefault(x => x.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));

检索 DbContextOptions<InvoiceDbContext 的注册服务(通常与 Entity Framework Core 相关)。

services.Remove(dbContext);

从集合中删除现有的 DbContextOptions<InvoiceDbContext> 服务。对 DbConnection 执行类似的步骤。

添加 SQLite 内存中连接

services.AddSingleton\<DbConnection>(container => { … });

添加 DbConnection 类型的单一实例服务。创建数据源设置为“:memory:”的内存中 SQLite 连接。

配置 DbContext

services.AddDbContext<InvoiceDbContext>((container, options) => { … });

添加 InvoiceDbContext 类型的作用域服务。将 InvoiceDbContext 配置为使用之前创建的 SQLite 连接。

环境设置:

builder.UseEnvironment("Development");

将 Web 主机的环境设置为“开发”。这会影响各种行为,例如日志记录和异常处理。

让我们为“Invoice Controller”创建测试类。初始化自定义 Web 应用程序工厂,并创建 HttpClient 实例。

public class InvoiceControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly CustomWebApplicationFactory<Program> _factory;
    private HttpClient _httpClient;

    public InvoiceControllerTests(CustomWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _httpClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

    private void CreateDatabase()
    {
        using var scope = _factory.Services.CreateScope();
        var scopedService = scope.ServiceProvider;
        var db = scopedService.GetRequiredService<InvoiceDbContext>();

        db.Database.EnsureCreated();
    }
}

此类表示“InvoiceController”的一组测试用例。它实现了“IClassFixture<CustomWebApplicationFactory<Program>>”接口,该接口允许它使用自定义 Web 应用程序工厂进行测试设置。 用于保存自定义 Web 应用程序工厂实例的私有字段“_factory”。用于存储“HttpClient”实例的私有字段“_httpClient”。

私有方法“CreateDatabase”负责创建具有必要架构的数据库。它在自定义 Web 应用程序工厂创建的范围内运行。InvoiceDbContext 是从服务提供商处获取的。'EnsureCreated()' 方法确保数据库架构存在。

测试“GetAsync”终结点的第一个测试方法。

[Fact]
public async Task GetAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
{
    // Arrange
    CreateDatabase();

    // Act
    var response = await _httpClient.GetAsync("/api/v1/InvoiceItems/1");
    var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    result?.Category?.Name.Should().Be("Category 1");
}

让我们也为其他端点添加测试方法。以下是“发票控制器”的已完成测试类。

public class InvoiceControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly CustomWebApplicationFactory<Program> _factory;
    private HttpClient _httpClient;

    public InvoiceControllerTests(CustomWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _httpClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

    private void CreateDatabase()
    {
        using var scope = _factory.Services.CreateScope();
        var scopedService = scope.ServiceProvider;
        var db = scopedService.GetRequiredService<InvoiceDbContext>();

        db.Database.EnsureCreated();
    }

    [Fact]
    public async Task GetAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
    {
        // Arrange
        CreateDatabase();

        // Act
        var response = await _httpClient.GetAsync("/api/v1/InvoiceItems/1");
        var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        result?.Category?.Name.Should().Be("Category 1");
    }

    [Fact]
    public async Task QueryAsync_InvoiceExist_ReturnsSuccessWithInvoiceItems()
    {
        // Arrange
        CreateDatabase();

        // Act
        var response = await _httpClient.GetAsync("/api/v1/InvoiceItems?PageToken=1&PageSize=5");
        var result = await response.Content.ReadFromJsonAsync<List<InvoiceDto>>();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        result.Should().HaveCount(2);
    }

    [Fact]
    public async Task QueryAsync_InvalidInput_ReturnsBadRequest()
    {
        // Arrange
        CreateDatabase();

        // Act
        var response = await _httpClient.GetAsync("/api/v1/InvoiceItems?PageToken=0&PageSize=5");
        
        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task CreateAsync_ValidInput_ReturnsCreatedWithInvoiceItems()
    {
        // Arrange
        CreateDatabase();
        var requestContent = new InvoiceRequestModel() { CategoryId = 3, SubCategoryId = 3, TonsOfCO2 = 3 };
        var content = JsonConvert.SerializeObject(requestContent);
        HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        // Act
        var response = await _httpClient.PostAsync("/api/v1/InvoiceItems", httpContent);
        var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        result.Id.Should().Be(3);
    }

    [Fact]
    public async Task CreateAsync_ThrowException_ReturnsInternalServerError()
    {
        // Arrange
        CreateDatabase();
        var requestContent = new InvoiceRequestModel() { CategoryId = 1, SubCategoryId = 1, TonsOfCO2 = 1 };
        var content = JsonConvert.SerializeObject(requestContent);
        HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        // Act
        var response = await _httpClient.PostAsync("/api/v1/InvoiceItems", httpContent);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
    }

    [Fact]
    public async Task DeleteAsync_ReturnsNoContent()
    {
        // Arrange
        CreateDatabase();

        // Act
        var response = await _httpClient.DeleteAsync("/api/v1/InvoiceItems/2");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NoContent);
    }

    [Fact]
    public async Task UpdateAsync_ValidInput_ReturnsSuccessWithInvoiceItems()
    {
        // Arrange
        CreateDatabase();
        var requestContent = new InvoiceRequestModel() { CategoryId = 1, SubCategoryId = 1, TonsOfCO2 = 25 };
        var content = JsonConvert.SerializeObject(requestContent);
        HttpContent httpContent = new StringContent(content, Encoding.UTF8, "application/json");
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        // Act
        var response = await _httpClient.PatchAsync("/api/v1/InvoiceItems/1", httpContent);
        var result = await response.Content.ReadFromJsonAsync<InvoiceDto>();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        result.TonsOfCO2.Should().Be(25);
    }
}

是时候运行测试用例并检查结果了。 45fb6d2062f03baa200be58645fc174c.jpeg

如果你喜欢我的文章,请给我一个赞!谢谢

-

技术群:添加小编微信并备注进群

小编微信:mm1552923   

公众号:dotNet编程大全    

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值