学习009-08 Create Custom Endpoints(创建自定义端点)

Create Custom Endpoints(创建自定义端点)

Follow the steps below to implement custom endpoints for the Web API Service:
按照以下步骤为Web API服务实现自定义端点:

1.Right-click the Web API Service project in the Visual Studio Solution Explorer and select Add -> New Item in the context menu. Choose the API Controller – Empty template in the invoked window.
右键单击Visual Studio解决方案资源管理器中的Web API服务项目,然后在上下文菜单中选择Add->New Item。在调用的窗口中选择API控制器-清空模板。
在这里插入图片描述

2.Add custom endpoint methods to the new Controller (Get, Post, Put, and Delete methods in the code sample below).
将自定义端点方法添加到新控制器(下面代码示例中的Get、Post、put和Delete方法)。

3.If you wish to use Web API authentication, decorate the new Controller with the AuthorizeAttribute. See the following topic for more information on how to configure authentication: Authenticate and Authorize Web API Endpoints.
如果您希望使用Web API身份验证,请使用AuthorizeAtual装饰新控制器。有关如何配置身份验证的更多信息,请参阅以下主题:身份验证和授权Web API端点。

The Controller’s code:
控制器的代码:

C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;


namespace MainDemo.Blazor.Server.Controllers;
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class CustomEndpointController : ControllerBase {
    [HttpGet]
    public IEnumerable<string> Get() {
        return new string[] { "value1", "value2" };
    }


    [HttpGet("{id}")]
    public string Get(int id) {
        return "value";
    }


    [HttpPost]
    public void Post([FromBody] string value) {
    }


    [HttpPut("{id}")]
    public void Put(int id, [FromBody] string value) {
    }


    [HttpDelete("{id}")]
    public void Delete(int id) {
    }
}

The result in the Swagger UI:
Swagger UI中的结果:
在这里插入图片描述

Authorize Endpoint Requests(授权端点请求)

Decorate a controller or its actions with the AuthorizeAttribute to restrict access. Only authenticated users will have access permissions. AuthorizeAttribute is mandatory if a controller action accesses services that use the Security System (for example IObjectSpaceFactory or ISecurityProvider). In such instances, we recommend that you decorate the entire controller with the AuthorizeAttribute to avoid faulty behavior:
使用AuthorizeAtcade装饰控制器或其操作以限制访问。只有经过身份验证的用户才具有访问权限。如果控制器操作访问使用安全系统的服务(例如IObjectSpaceFactory或ISecurityProvider),则AuthorizeAtcade是强制性的。在这种情况下,我们建议您使用AuthorizeAtcade装饰整个控制器以避免错误行为:

C#

using DevExpress.ExpressApp.Core;
using DevExpress.ExpressApp.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace MainDemo.Blazor.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CustomEndPointController : ControllerBase {
    private readonly ISecurityProvider _securityProvider;
    private readonly IObjectSpaceFactory _securedObjectSpaceFactory;
    public CustomEndPointController(ISecurityProvider securityProvider, IObjectSpaceFactory securedObjectSpaceFactory) {
        _securityProvider = securityProvider;
        _securedObjectSpaceFactory = securedObjectSpaceFactory;
    }
    // ...
}

Note
If an endpoint does not access any secured services, you can skip the AuthorizeAttribute and make the endpoint available to unauthenticated users. Refer to the Non-Secured Endpoint Examples section for examples on how to implement endpoints that can work without authentication.
如果终结点不访问任何受保护的服务,您可以跳过AuthorizeAtual,并使该终结点可供未经身份验证的用户使用。有关如何实现无需身份验证即可工作的终结点的示例,请参阅非安全终结点示例部分。

Be sure to apply the AuthorizeAttribute in the following cases:
请务必在以下情况下应用 AuthorizeAttribute:

  • You run a standalone Web API Service and access a secured service in a controller action. When the code accesses the service, XAF Security System attempts to authenticate the user even if the AuthorizeAttribute is not used. This operation will fail with an exception if the request does not contain an authentication header.

  • 您运行独立的Web API服务并在控制器操作中访问受保护的服务。当代码访问该服务时,XAF安全系统会尝试对用户进行身份验证,即使未使用AuthorizeAtcade。如果请求不包含身份验证标头,此操作将失败并出现异常。

  • JWT-based authentication is not the default authentication method in your application. For example, this is the case if you use Web API Service as a part of an XAF Blazor application, where the default authentication method is cookie-based. When a controller action without the AuthorizeAttribute accesses a secured service, the ASP.NET Core authentication system attempts to authenticate a user with the default method (a cookie). In this case, the XAF Security System throws an exception even if an authentication header is specified, because the ASP.NET Core authentication system failed to authenticate the user based on a cookie. However, if you specify the AuthorizeAttribute, the ASP.NET Core authorization system tries all available authentication methods, so it handles JWT authentication correctly.

  • 基于JWT的身份验证不是您的应用程序中的默认身份验证方法。例如,如果您使用Web API服务作为XAF Blazor应用程序的一部分,则会出现这种情况,其中默认身份验证方法是基于cookie的。当没有AuthorizeAtcade的控制器操作访问安全服务时,ASP.NETCore身份验证系统会尝试使用默认方法(cookie)对用户进行身份验证。在这种情况下,即使指定了身份验证标头,XAF安全系统也会抛出异常,因为ASP.NETCore身份验证系统未能根据cookie对用户进行身份验证。但是,如果您指定AuthorizeAtcade,ASP.NETCore授权系统会尝试所有可用的身份验证方法,因此它会正确处理JWT身份验证。

See the Secured Endpoint Examples section for examples of custom endpoints that require the AuthorizeAttribute.
请参阅安全端点示例部分,了解需要Authorize属性的自定义端点示例。

Access an Object Space(访问对象空间)

Use one of the following techniques to access an Object Space from a custom endpoint controller:
使用以下技术之一从自定义端点控制器访问对象空间:

Use IDataService (Recommended)(使用IDataService(推荐))

Inject the IDataService and call its GetObjectSpace method to obtain a secured Object Space instance for the specified type:
注入IDataService并调用其GetObjectSpace方法以获取指定类型的安全对象空间实例:

C#

using DevExpress.ExpressApp.WebApi.Services;
using MainDemo.Module.BusinessObjects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace MainDemo.Blazor.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CustomEndpointController : ControllerBase {
    private readonly IDataService dataService;
    public CustomEndpointController(IDataService dataService) => this.dataService = dataService;


    [HttpGet(nameof(MyEndpoint))]
    [Authorize]
    public ActionResult MyEndpoint() {
        var objectSpace = dataService.GetObjectSpace(typeof(Employee));
        // ...
    }
}

You do not need to dispose an Object Space obtained from the IDataService. This service manages Object Spaces internally and disposes of them automatically.
您不需要处置从IDataService获得的对象空间。此服务在内部管理对象空间并自动处置它们。

Use IObjectSpaceFactory(使用IObjectSpaceFactory)

If your scenario requires you to create a new Object Space instance, use the IObjectSpaceFactory.CreateObjectSpace method. Note that you need to correctly dispose of Object Spaces returned by this method:
如果您的场景需要您创建一个新的对象空间实例,请使用IObjectSpaceFactory. CreateObjectSpace方法。请注意,您需要正确处理此方法返回的对象空间:

C#

using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Core;
using MainDemo.Module.BusinessObjects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace MainDemo.Blazor.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CustomEndpointController : ControllerBase, IDisposable {
    private readonly IObjectSpaceFactory securedObjectSpaceFactory;
    private readonly List<IObjectSpace> objectSpaces = new List<IObjectSpace>();
    public CustomEndpointController(IObjectSpaceFactory securedObjectSpaceFactory) => this.securedObjectSpaceFactory = securedObjectSpaceFactory;

    [HttpGet(nameof(MyEndpoint))]
    [Authorize]
    public ActionResult MyEndpoint() {
        var objectSpace = GetObjectSpace(typeof(Employee));
        //...
    }
    protected virtual IObjectSpace GetObjectSpace(Type objectType) {
        if(objectSpaces.Count > 0) {
            foreach(var os in objectSpaces) {
                if(os.IsKnownType(objectType)) {
                    return os;
                }
            }
        }
        IObjectSpace objectSpace = securedObjectSpaceFactory.CreateObjectSpace(objectType);
        objectSpaces.Add(objectSpace);
        return objectSpace;
    }

    public void Dispose() {
        foreach(var os in objectSpaces) {
            os?.Dispose();
        }
        objectSpaces.Clear();
    }
}

In the code sample above, the controller class implements IDisposable and disposes of all created Object Spaces in the Dispose method. We recommend this approach in most cases.
在上面的代码示例中,控制器类实现了IDisposable并在Dispose方法中处理所有创建的对象空间。我们在大多数情况下推荐这种方法。

Note that it is often incorrect to create Object Spaces in a using block. In these cases, objects returned by a controller action outlive the Object Space that returned them. If these objects implement the IObjectSpaceLink interface and try to access the Object Space in one of their property getters, an exception occurs when an ASP.NET Core serializer attempts to serialize an object.
请注意,在使用块中创建对象空间通常是不正确的。在这些情况下,控制器操作返回的对象比返回它们的对象空间活得更久。如果这些对象实现了IObjectSpaceLink接口并尝试在其属性getter中访问对象空间,则当ASP.NETCore序列化程序尝试序列化对象时会发生异常。

Non-Secured Endpoint Examples(非安全端点示例)

Get Server Time(获取服务器时间)

To check the server’s current time across different time zones, use a GET request as shown below. You can optionally decorate this controller action with the AuthorizeAttribute to restrict this operation to authenticated users only.
要检查服务器跨不同时区的当前时间,请使用如下所示的GET请求。您可以选择使用AuthorizeAtcade装饰此控制器操作,以将此操作仅限制为经过身份验证的用户。

C#

[HttpGet("api/Custom/ServerTime/{timezone}")]
// [Authorize]
public ActionResult<string> GetServerTime(string timezone) {
    try {
        TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
        DateTime serverTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
        return Ok($"Server time in {timezone}: {serverTime}");
    }
    catch (TimeZoneNotFoundException) {
        return BadRequest($"Invalid timezone: {timezone}");
    }
}

Clear Logs(清除日志)

Use a POST request to clear logs stored on the server machine. The AuthorizeAttribute can restrict this operation to authenticated users only.
使用POST请求清除存储在服务器机器上的日志。AuthorizeAtoral可以将此操作限制为仅通过身份验证的用户。

C#

[HttpPost(nameof(ClearLogs))]
// [Authorize]
[SwaggerOperation("Clears logs older than today")]
public IActionResult ClearLogs() {
    try {
        var logDirectory = @"C:\path\to\your\logs";
        var di = new DirectoryInfo(logDirectory);
        foreach (var file in di.GetFiles()) {
            if (file.CreationTime < DateTime.Today) {
                file.Delete();
            }
        }
        return Ok(new { status = "Logs older than today have been deleted successfully" });
    }
    catch (Exception e){
        return BadRequest(e);
    }
}

Check the Database Connection Health(检查数据库连接运行状况)

To check the health of the Web API Service application’s database connection, use a GET request. For this operation, you can use the INonSecuredObjectSpaceFactory service, which operates outside of the Security System and does not require user authentication.
要检查Web API Service应用程序的数据库连接的健康状况,请使用GET请求。对于此操作,您可以使用INonSecuredObjectSpaceFactory服务,该服务在安全系统之外运行,不需要用户身份验证。

C#

[ApiController]
[Route("api/[controller]")]
public class CustomController : ControllerBase {
    private readonly INonSecuredObjectSpaceFactory _nonSecuredObjectSpaceFactory;
    public CustomController(INonSecuredObjectSpaceFactory nonSecuredObjectSpaceFactory) => _nonSecuredObjectSpaceFactory = nonSecuredObjectSpaceFactory;


    [HttpGet(nameof(DbConnectionHealthCheck))]
    [SwaggerOperation("Returns the current database connection health")]
    // [Authorize]
    public IActionResult DbConnectionHealthCheck() {
        try {
            using var objectSpace = _nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace(typeof(ApplicationUser));
            return Ok(new { status = "Healthy" });
        }
        catch (Exception e) {
            return StatusCode(500,e.Message);
        }      
    }
}

Secured Endpoint Examples(安全端点示例)

Current User Identifier(当前用户标识符)

Use a GET request to obtain the current user’s ID. This operation requires the ISecurityProvider service.
使用GET请求获取当前用户的ID。此操作需要ISecurityProvider服务。

C#

[ApiController]
[Route("api/[controller]")]
public class CustomController : ControllerBase {
    private readonly ISecurityProvider _securityProvider;
    public CustomController(ISecurityProvider securityProvider) => _securityProvider = securityProvider;


    [HttpGet()]
    [SwaggerOperation("Returns the current user's identifier")]
    [Authorize]
    public IActionResult GetUserId()
        => Ok(_securityProvider.GetSecurity().UserId);
}

Obtain an Object or a Collection of Objects(获取对象或对象集合)

Use a GET request to fetch a serialized business object. You can return an anonymous object with an arbitrary structure from the controller action to control which data to include in the response. This operation uses the IDataService and takes Security System configuration into account .
使用GET请求获取序列化的业务对象。您可以从控制器操作返回具有任意结构的匿名对象,以控制响应中包含哪些数据。此操作使用IDataService并考虑安全系统配置。

C#

[ApiController]
[Route("api/[controller]")]
public class CustomController : ControllerBase {
    private readonly IObjectSpace objectSpace;
    public CustomController(IDataService dataService) {
         objectSpace = dataService.GetObjectSpace(typeof(Employee));
    }


    [HttpGet(nameof(Employee)+"/{id}")]
    [SwaggerOperation("Returns an Employee object based on its ID")]
    [Authorize]
    public ActionResult GetEmployee(int id) {
        var employee = objectSpace.GetObjectByKey<Employee>(id);
        return employee == null ? NotFound($"Employee ({id}) not found.") : Ok (new {employee.EmployeeId,employee.DepartmentName});
    }


    [HttpGet(nameof(Employee)+"/{department}")]
    [SwaggerOperation("Returns all Employees in the specified department")]
    [Authorize]
    public ActionResult GetEmployees(string department) {
        return Ok(objectSpace.GetObjectsQuery<Employee>()
        .Select(employee => new { employee.EmployeeId, employee.DepartmentName })
            .Where(employee => employee.DepartmentName == department));
    }
}

Note
DevExpress Web API Service automatically exposes similar actions used to create, read, delete, and update a business object if this object is registered as a part of the OData model in the application’s Startup.cs file:
如果业务对象在应用程序的Startup. cs文件中注册为OData模型的一部分,DevExpress Web API Service会自动公开用于创建、读取、删除和更新业务对象的类似操作:

C#

services.AddXafWebApi(builder => {
   builder.ConfigureOptions(options => {
       options.BusinessObject<Employee>()

For information on how to override the logic implemented for these default endpoints, see Execute Custom Operations on Endpoint Requests.
有关如何覆盖为这些默认端点实现的逻辑的信息,请参阅对端点请求执行自定义操作。

Stream an Image(流式传输图像)

Use a GET request to stream an image from a byte array field. You can use the same technique to return a file of any type from an arbitrary source.
使用GET请求从字节数组字段流式传输图像。您可以使用相同的技术从任意源返回任何类型的文件。

C#

[HttpGet("EmployeePhoto/{employeeId}")]
[Authorize]
public FileStreamResult EmployeePhoto(int employeeId) {
    var objectSpace = dataService.GetObjectSpace(typeof(Employee));
    var bytes = objectSpace.GetObjectByKey<Employee>(employeeId).Photo;
    return File(new MemoryStream(bytes), "application/octet-stream");
}

Create a New Object(创建一个新对象)

To create an object, use a POST request. This operation uses the ISecurityProvider service to obtain permission to create objects of the specified type and the IDataService to create objects.
要创建对象,请使用POST请求。此操作使用ISecurityProvider服务来获取创建指定类型对象的权限,并使用IDataService来创建对象。

C#

[ApiController]
[Route("api/[controller]")]
public class CustomController : ControllerBase {
    private readonly IDataService _dataService;
    private readonly ISecurityProvider _securityProvider;
    public CustomController(IDataService dataService, ISecurityProvider securityProvider) {
        _dataService = dataService;
        _securityProvider = securityProvider;
    }


    [HttpPost(nameof(CreateUserEmployee)+"/{email}")]
    [SwaggerOperation("Creates a new user based on email")]
    [Authorize]
    public IActionResult CreateUserEmployee(string email) {
        var strategy = (SecurityStrategy)_securityProvider.GetSecurity();
        if (!strategy.CanCreate(typeof(Employee)))
            return Forbid("You do not have permissions to add a new employee!");
        var objectSpace = _dataService.GetObjectSpace(typeof(Employee));
        if (objectSpace.FirstOrDefault<Employee>( e => e.Email == email) != null)
            return ValidationProblem("Email is already registered!");
        var employee = objectSpace.CreateObject<Employee>();
        employee.Email = email;
        objectSpace.CommitChanges();
        return Ok();
    }
}

Stream a PDF File(流式传输PDF文件)

Use a GET request to stream a PDF document. The code below illustrates a use case, in which the Office File API‘s Mail Merge feature is used to dynamically generate a PDF document. The resulting document is then added to the server response.
使用GET请求流式传输PDF文档。下面的代码说明了一个用例,其中Office File API的邮件合并功能用于动态生成PDF文档。然后将生成的文档添加到服务器响应中。

Note
For more information on this solution and a complete code example, refer to the following blog post: JavaScript — Consume the DevExpress Backend Web API with Svelte (Part 7. Mail Merge).
有关此解决方案的更多信息和完整的代码示例,请参阅以下博客文章:JavaScript-使用带有Svelte的DevExpress后端Web API(第7部分。邮件合并)。

C#

using DevExpress.Data.Filtering;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Core;
using DevExpress.Persistent.BaseImpl.EF;
using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Collections;
using System.Net.Mime;

[Authorize]
[Route("api/[controller]")]
public class MailMergeController : ControllerBase, IDisposable {
  private readonly IObjectSpaceFactory objectSpaceFactory;

  public MailMergeController(IObjectSpaceFactory objectSpaceFactory) {
    this.objectSpaceFactory = objectSpaceFactory;
  }

  private IObjectSpace objectSpace;

  public void Dispose() {
    if (objectSpace != null) {
      objectSpace.Dispose();
      objectSpace = null;
    }
  }

  [HttpGet("MergeDocument({mailMergeId})/{objectIds?}")]
  public async Task<object> MergeDocument(
    [FromRoute] string mailMergeId,
    [FromRoute] string? objectIds) {
    // Fetch the mail merge data by the given ID
    objectSpace = objectSpaceFactory.CreateObjectSpace<RichTextMailMergeData>();
    RichTextMailMergeData mailMergeData =
      objectSpace.GetObjectByKey<RichTextMailMergeData>(new Guid(mailMergeId));

    // Fetch the list of objects by their IDs
    List<Guid> ids = objectIds?.Split(',').Select(s => new Guid(s)).ToList();
    IList dataObjects = ids != null
      ? objectSpace.GetObjects(mailMergeData.DataType, new InOperator("ID", ids))
      : objectSpace.GetObjects(mailMergeData.DataType);

    using RichEditDocumentServer server = new();
    server.Options.MailMerge.DataSource = dataObjects;
    server.Options.MailMerge.ViewMergedData = true;
    server.OpenXmlBytes = mailMergeData.Template;

    MailMergeOptions mergeOptions = server.Document.CreateMailMergeOptions();
    mergeOptions.MergeMode = MergeMode.NewSection;

    using RichEditDocumentServer exporter = new();
    server.Document.MailMerge(mergeOptions, exporter.Document);

    MemoryStream output = new();
    exporter.ExportToPdf(output);

    output.Seek(0, SeekOrigin.Begin);
    return File(output, MediaTypeNames.Application.Pdf);
  }
}

Note that this code uses the RichTextMailMergeData type to access persistent document templates. If you intend to use similar code in your application, make sure to add RichTextMailMergeData to your DBContext (if using EF Core) and call the WebApiOptions.BusinessObject method for this type to generate endpoints:
请注意,此代码使用RichTextMailMergeData类型来访问持久文档模板。如果您打算在应用程序中使用类似的代码,请确保将RichTextMailMergeData添加到DBContext(如果使用EF Core)并为此类型调用WebApiOptions. BusinessObject方法以生成端点:

File: MySolution.WebApi/Startup.cs

C#

public class Startup {
  public void ConfigureServices(IServiceCollection services) {
    // ...
    services.AddXafWebApi(builder => {
      builder.ConfigureOptions(options => {
        // ...
        options.BusinessObject<RichTextMailMergeData>();
      });
      // ...
    });
    // ...
  }
}

Example Solutions(示例解决方案)

The following example solutions implement client applications for a Web API Service backend:
以下示例解决方案为Web API服务后端实现客户端应用程序:

  • Blazor WebAssembly App
  • .NET MAUI (iOS/Android) App

In both solutions, the backend implements several custom endpoints including the following:
在这两种解决方案中,后端实现了几个自定义端点,包括:
CanCreate
Checks the current user’s permission to create new posts.(检查当前用户创建新帖子的权限。)
Archive
Archives the specified post to disk.(将指定的帖子存档到磁盘。)
AuthorPhoto
Responds with a photo of the specified post’s author.(回复指定帖子作者的照片。)

Also see our blog post series on how to implement a Svelte app with a custom Web API Service backend: JavaScript with Svelte + ASP.NET Core Web API/OData App.
另请参阅我们的博客文章系列,了解如何使用自定义Web API服务后端实现Svelte应用程序:JavaScript with Svelte+ASP.NETCore Web API/OData应用程序。

  • 10
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
在Spring Boot 2.2版本中,引入了spring-boot-starter-oauth2-authorization-server模块,该模块提供了OAuth 2.0和OpenID Connect(OIDC)的授权服务器功能。在1.2版本中,新增了一些语法来自定义配置OIDC实例。 首先,你可以使用@Configuration注解创建一个配置类,并使用@EnableAuthorizationServer注解启用授权服务器功能。然后,你可以使用@Bean注解创建一个AuthorizationServerConfigurer实例,并重写configure方法来自定义配置。 下面是一个示例代码: ```java @Configuration @EnableAuthorizationServer public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { // 配置授权服务器的安全性 security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 配置客户端信息 clients.inMemory() .withClient("client-id") .secret("client-secret") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("read", "write") .redirectUris("http://localhost:8080/callback"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // 配置授权服务器的端点 endpoints.authenticationManager(authenticationManager); } } ``` 在上面的示例中,我们通过重写configure方法来配置授权服务器的安全性、客户端信息和端点。你可以根据自己的需求进行相应的配置。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

汤姆•猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值