从SpringBoot到DotNet_2.重构异步与完成用户模块

一、了解C#中的异步

​ 假设项目部署的服务器的CPU只有1C1T,当一个请求进入服务器进行方法执行并等待返回的时候,CPU资源就会被占用,直到这个方法结束,在此期间别的请求也无法进入,就相当于在前端一直转圈等待。

​ 上面的情况就是典型的单线程模型,在这种情况下同步方法会一直占用CPU,直到任务完成。而异步方法在遇到需要等待的操作时,会释放CPU,允许其他任务执行,提高并发性能。一旦异步操作完成,会通过回调或等待的方式再次占用CPU。

​ 我们都知道执行 I/O 是非常消耗时间的,主要反映在从数据库中读取到内存并返回结果集这一过程,所以我们希望不要等待数据库,继续执行下一个请求,当数据库返回数据以后再回头继续处理上一个请求,同时在海量的请求时,安排尽可能多、多核性能更强的 CPU 的服务器,与异步的I/O读取相结合。

同步方法和异步方法

​ 同步方法是按照程序的执行顺序一步一步执行的方法。当调用一个同步方法时,程序会等待方法执行完毕,然后再继续执行下一个语句。在同步方法中,只有一个线程参与执行,不涉及多线程的概念。

​ 异步方法允许程序在执行某个任务的同时,继续执行其他任务。异步方法使用asyncawait关键字来声明和等待异步操作。异步方法通常用于执行耗时的操作,如文件读写、网络请求等,以避免阻塞主线程。

copy
class Program { static async Task Main() { // 异步方法调用 await AsyncMethod(); Console.WriteLine("Main method continues..."); } static async Task AsyncMethod() { Console.WriteLine("AsyncMethod is executing..."); // 执行一些异步的操作,使用 await 关键字等待异步操作完成 await Task.Delay(2000); Console.WriteLine("AsyncMethod completed."); } }

​ 在异步方法中,使用TaskTask<T>类来表示异步操作,并使用await关键字等待异步操作的完成。异步方法通常返回TaskTask<T>类型,表示异步操作的状态和结果。

二、完成异步重构

(一)重构数据仓库

​ 进行重构的重点在于将有关数据库读写操作的部分改为异步,对于一些返回值为空的方法,这些方法实际上并没有和数据库直接进行交互,而是将改变后的数据写入到数据库上下文中,真正写入数据库的时候也就是调用 Task<bool> SaveAsync();的时候才真正和数据库进行交互,也就是真正需要异步操作的时候。

copy
public interface ITouristRouteRepository { Task<IEnumerable<TouristRoute>> GetTouristRoutesAsync(string keyword, string operatorType, int? ratingValue); Task<TouristRoute> GetTouristRouteAsync(Guid touristRouteId); Task<bool> TouristRouteExistsAsync(Guid touristRouteId); Task<IEnumerable<TouristRoutePicture>> GetPicturesByTouristRouteIdAsync(Guid touristRouteId); Task<TouristRoutePicture> GetPictureByIdAsync(int pictureId); void AddTouristRoute(TouristRoute touristRoute); Task<bool> SaveAsync(); void AddTouristRoutePicture(Guid touristRouteId, TouristRoutePicture touristRoutePicture); void DeleteTouristRoute(TouristRoute touristRoute); void DeleteTouristRoutePicture(TouristRoutePicture picture); Task<IEnumerable<TouristRoute>> GetTouristRoutesByIDListAsync(IEnumerable<Guid> iDs); void DeleteTouristRoutes(IEnumerable<TouristRoute> routesFromRepo); }

​ 在impl实现上面的方法就好了,注意在实现的时候方法是一个被async修饰的方法,返回值是一个Task<>,需要进行异步操作的部分使用await修饰。

(二)重构Action

​ 重构控制器第一需要给Action方法上加上async方法表示这是一个异步方法,在需要进行异步调用方法前加上await表示遇到这种费时间的操作先把 cpu 让出来让别的请求能进入。

​ 对于查询操作,需要进行异步操作的部分在于从数据库取出所需的数据集合这一步;对于修改操作不仅从数据库中拿出来需要异步,在将修改好的数据写回到数据库也需要进行异步操作,即await _touristRouteRepository.SaveAsync();;而对于添加、删除这两个操作,控制器是直接或者间接接受了需要进行操作的数据实体,将数据实体作为参数传给数据库上下文进行操作,操作完成后保存写回数据库,所以Add、Delete这种操作只需要进行最后的await _touristRouteRepository.SaveAsync();即可。

​ 修改后的相关代码会放到链接中。

三、JWT授权与认证

(一)JWT原理剖析

​ JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。JWT 的作用是用户授权(Authorization)而不是用户的身份认证(Authentication):用户认证指的是使用用户名、密码来验证当前用户的身份(若未登录则返回401未授权),用户授权指当前用户有足够的权限访问特定的资源(没有权限返回403Forbidden)。

1.有状态登录和无状态登录

Session登录是有状态的,因为服务器需要存储和管理用户的会话状态。这意味着每次请求都需要与服务器交互以验证用户的身份和获取相关的状态信息。

​ 有状态登录通常需要使用一些手段来处理负载均衡和会话共享,以确保在多个服务器之间共享会话状态。

JWT登录是无状态的,因为服务器不存储任何会话状态。所有必要的信息都被编码到JWT中,客户端发起请求时携带JWT,服务器只需验证JWT的有效性即可。

​ 无状态登录更容易在分布式系统中扩展,并且对负载均衡和横向扩展更为友好;JWT 使用 RSA 加密,私钥不泄露就保证了 token 是绝对安全的。

​ 然而 token 一旦发布就无法取消,只能等待他自然失效,所以如果 token 被别人获取就容易造成安全威胁;JWT 的 Header 和 Payload 明文传输,这个问题可以使用 SSL 来解决。

2.Session 和 jwt 登录的区别

​ 用户登录后,服务器会创建一个会话(session),将会话相关的信息存储在服务器端的内存或持久化存储中(如数据库)。客户端只会得到一个用于标识会话的标识符,通常是通过Cookie或URL参数传递。

image-20231225154100330

​ 用户登录后,服务器生成一个JWT,将用户的身份信息和其他必要的信息直接编码到JWT中,并将JWT发送给客户端。客户端通常会将JWT存储在本地,如LocalStorage或Cookie。

image-20231225154147714

3.JWT的结构

​ 在jwt的官网中,实例的 jwt 被 “.” 分为三部分,三种颜色对应解码后的三个部分。

Header(头部):

  • Header 包含了两部分信息:token 的类型("typ")和所使用的签名算法("alg")。

Payload(负载):

  • Payload 包含了关于用户(或其他主题)的信息,以及其他声明和数据。这些信息通常被称为 "claims"。

Signature(签名):

  • Signature 是由前两部分使用所指定的签名算法进行签名生成的,用于验证消息的完整性和确保消息的来源。

image-20231226152017202

(二)模拟用户登录

Microsoft.AspNetCore.Authentication.JwtBearer 是 ASP.NET Core 中用于 JSON Web Token (JWT) 身份验证的认证中间件。这中间件用于验证和处理通过 JWT 进行的身份验证,通常在 ASP.NET Core Web API 中使用。这个中间件的作用包括Token验证、身份认证、授权等。 将这个依赖包添加到项目中。

image-20231226153303376

​ 下面仿写一个控制器模拟登录过程,整个过程包括三部分:1、验证用户名密码(暂时省略);2、生成 jwt; 3、将 jwt 返回给客户端。重点在于生成 JWT 部分:

Header:指定HMAC SHA-256为签名算法;

PayLoad:使用claims数组来表示 payload 部分。Claims 是关于实体(通常是用户)和其他数据的声明性语句。每个声明都是由键值对组成,它包含了有关实体的信息,例如用户 ID、角色、权限等。

​ 在负载中,JwtSecurityToken 类提供了一个 claims 参数,该参数接受一个 Claim 数组。这个数组可以包含多个声明,每个声明都是一个 Claim 对象,表示一个键值对的声明。

Signature:从配置中读取私钥,然后创建了一个 SymmetricSecurityKey 实例,该实例将用作对称密钥。最后,SigningCredentials 封装了这个对称密钥和签名算法,以便用于生成 JWT 的签名。

创建 JWT:从配置文件中JWT 的发行方 JWT 的预期接收方,发布时间为现在,有效期一天;随后将生成的 jwt 转化为字符串。

copy
[ApiController] [Route("auth")] public class AuthenticateController : ControllerBase { private readonly IConfiguration _configuration; public AuthenticateController(IConfiguration configuration) { _configuration = configuration; } [HttpPost("login")] public IActionResult login(LoginDto loginDto) { // 1.验证用户名密码 // 2.生成 token // header var signingAlgorithm = SecurityAlgorithms.HmacSha256; // payload var claims = new[] { // sub new Claim(JwtRegisteredClaimNames.Sub, "fake_user_id") }; // signiture var secretByte = Encoding.UTF8.GetBytes(_configuration["Authentication:SecretKey"]); var signingKey = new SymmetricSecurityKey(secretByte); var signingCredentials = new SigningCredentials(signingKey, signingAlgorithm); var token = new JwtSecurityToken( issuer: _configuration["Authentication:Issuer"], audience: _configuration["Authentication:Audience"], claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddDays(1), signingCredentials ); var tokenStr = new JwtSecurityTokenHandler().WriteToken(token); // 3.return 200 ok + jwt return Ok(tokenStr); } }

​ 配置文件中的信息:

copy
"Authentication": { "SecretKey": "长度符合要求就行", "Issuer": "fakexiecheng.com", "Audience": "fakexiecheng.com" }

​ 该 api 的路由为 /auth/login,参数为LoginDto

copy
public class LoginDto { [Required] public string Email { get; set; } [Required] public string Password { get; set; } }

image-20231226154308770

​ 将返回的字符串使用工具解析(官网就有的功能),输入正确的密钥之后就可以看见Signature Verified表明验证成功。

image-20231226154446916

(三)启用 JWT 授权

​ 配置 JWT Bearer 认证

copy
// JWT 授权配置 // 启用身份验证服务,并指定默认的身份验证方案为 JwtBearerDefaults.AuthenticationScheme builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) // 配置 JWT Bearer 认证 .AddJwtBearer(options => { // 获取密钥字节数组 var secretByte = Encoding.UTF8.GetBytes(builder.Configuration["Authentication:SecretKey"]); // 配置 JWT Bearer 认证的验证参数 options.TokenValidationParameters = new TokenValidationParameters() { // 启用验证签发者,确保 JWT 的签发者是有效的 ValidateIssuer = true, // 设置有效的签发者 ValidIssuer = builder.Configuration["Authentication:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Authentication:Audience"], // 启用验证 JWT 的生命周期,确保 JWT 没有过期 ValidateLifetime = true, // 指定签名密钥,使用对称密钥进行签名验证 IssuerSigningKey = new SymmetricSecurityKey(secretByte) }; }); // 隐式加载,启动路由中间件,如果写,应该在所有中间件的前面 app.UseRouting(); // 启用身份验证中间件 app.UseAuthentication(); // 启用授权中间件 有什么权限 app.UseAuthorization();

​ 配置完成之后给控制器加上[Authorize]的 Attribute,表示授权后(登录的用户)才能访问。

copy
// Post /api/touristroutes [HttpPost] [Authorize] public async Task<IActionResult> CreateTouristRoute([FromBody] TouristRouteForCreationDto touristRouteForCreationDto) { var touristRouteModel = _mapper.Map<TouristRoute>(touristRouteForCreationDto); _touristRouteRepository.AddTouristRoute(touristRouteModel); await _touristRouteRepository.SaveAsync(); var touristRouteToReturn = _mapper.Map<TouristRouteDto>(touristRouteModel); return CreatedAtRoute( "GetTouristRouteById", new { touristRouteId = touristRouteToReturn.Id }, touristRouteToReturn ); }

​ 想登录的 API 发送请求获得 Token 后将 Token 携带想创建发送请求,返回 201 而不是 401,表示已经登陆且成功操作。

image-20231226160822518

image-20231226160933923

(四)添加用户角色

​ 在上面的步骤中,已经可以获得 Token 表示用户是否登录,也就是已经完成未登录返回401错误,目前还需要完成只有管理员用户角色才能修改,对于普通的用户之让他们能查询就好。

​ 在 JWT(JSON Web Token)中,Claim 表示有关实体(通常是用户)和其他相关信息的声明性语句。Claim 包含了关于 JWT 负载(Payload)的一部分,用于描述用户的身份、角色、权限以及其他相关的信息。在创建 Claims 数组的时候使用Claim的构造器添加一个表示用户角色的字段,表示想在(用于测试的这个指定的用户)的用户角色是Admin

copy
var claims = new[] { // sub new Claim(JwtRegisteredClaimNames.Sub, "fake_user_id"), // ++ new Claim(ClaimTypes.Role, "Admin") };

​ 同时在需要被限制的 API 上更改注解:

copy
[HttpPost] [Authorize(Roles = "Admin")] public async Task<IActionResult> CreateTouristRoute([FromBody] TouristRouteForCreationDto touristRouteForCreationDto)

​ 同样请求该接口,将这个ROLE注解掉之后,请求返回了403Forbidden,这说明非Admin角色不能进入该Action

image-20231226162239539

image-20231226162147029

四、完成登录和注册

(一)设计用户模型

​ 上面的操作已经能够完成未授权无权限两种异常情况的处理,下面就是创建合适的数据库,将虚拟用户换成 DB 中实际存在的用户。

Microsoft.AspNetCore.Identity.EntityFrameworkCore 框架是用于管理用户、身份验证和授权的一个强大框架。它提供了一套标准的用户和角色管理功能,可用于构建安全的、用户认证的 ASP.NET Core 应用程序。

image-20231226162636990

​ EF 的强大之处在于需要费时间去配置的东西他都能生成,比如生成用户权限相关的数据库,仅需要让DBContext继承IdentityDbContext ,具体而言,IdentityDbContext<IdentityUser> 已经包含了 IdentityUserIdentityRole 等实体的配置,以及这些实体之间的关联关系。这样,当你创建迁移并应用到数据库时,EF Core 会自动为这些实体生成对应的数据库表。这样做的好处是,你无需手动定义这些实体的数据库映射,EF Core 会根据 IdentityDbContext 中的配置自动生成数据库表结构。

copy
namespace FakeXiecheng.Data; public class AppDbContext : IdentityDbContext<IdentityUser> //DbContext

​ 在 ASP.NET Core 中配置身份认证(Identity)的服务,使用 AddIdentity 方法来添加身份认证服务,并通过泛型参数指定了用户实体类型为 IdentityUser,角色实体类型为 IdentityRole,同时使用 AddEntityFrameworkStores 将身份认证的数据存储与应用程序的数据库上下文 (AppDbContext) 进行关联。

copy
// IdentityDbContext builder.Services.AddIdentity<IdentityUser, IdentityRole>() .AddEntityFrameworkStores<AppDbContext>();

​ 熟悉的配方,将项目 rebuild 之后通过 ef migration 创建新的迁移并更新数据库。

image-20231226163538560

​ 这样数据库中就多了一堆和 ROLE 相关的数据表了。

image-20231226163636674

  1. AspNetRoles 表:

    ​ 存储应用程序中定义的角色信息。每个角色都有一个唯一的标识符 (Id) 和一个角色名 (Name)。在身份验证和授权中,角色通常用于控制对某些资源或操作的访问权限。

  2. AspNetRoleClaims 表:

    ​ 存储角色的声明信息。声明是与角色相关联的键值对,用于在授权中提供额外的信息。例如,一个角色可以关联一个声明,表示该角色具有某种特定的权限。

  3. AspNetUsers 表:

    ​ 存储应用程序的用户信息。每个用户都有一个唯一的标识符 (Id) 和标识符 (UserName)。其他字段存储用户的电子邮件、密码哈希等信息。

  4. AspNetUserClaims 表:

    ​ 存储用户的声明信息。与角色声明类似,用户声明是键值对,提供有关用户的额外信息,用于在授权中确定用户的权限。用于直接与用户绑定的权限声明。

  5. AspNetUserLogins 表:

    ​ 存储用户使用外部登录提供程序进行身份验证的信息。例如,如果用户使用 Google 或 Facebook 登录,相关的登录信息将存储在这个表中。

  6. AspNetUserTokens 表:

    ​ 存储用户的令牌信息,通常用于实现身份验证令牌或密码重置令牌等功能。这些令牌可以在用户需要进行身份验证或重置密码时生成和验证。(原本是用来保存Session的,现在没啥用了)

    ​ 以 AspNetUsers这个表为例,这张表中包含了用户角色验证的基本信息和其他基本字段,如果需要定义更多字段可以去定义合适的类来实现IdentityUser

image-20240105142523193

(二)完成用户注册

​ 为注册设计一个单独的 Dto,可以使用Attrbute验证两次密码是否相同(不过在前端就进行验证明显更好)。

copy
public class RegisterDto { [Required] public string Email { get; set; } [Required] public string Password { get; set; } [Compare(nameof(Password),ErrorMessage = "两次输入的密码不一致")] public string ConfirmPassword { get; set; } }

​ 下面的ActionIdentityUser 是 ASP.NET Core Identity 框架提供的内置用户实体类。它包含了一些基本的字段,例如用户名 (UserName)、电子邮件 (Email) 等,以及与用户身份验证和授权相关的一些属性和方法。

_userManager 是 ASP.NET Core Identity 框架提供的 UserManager<TUser> 类型的一个实例,用于处理用户的管理和操作。

​ 在进行密码密文加密的时候使用_userManager.CreateAsync(user, registerDto.Password);就能够完成将前端传入的密码Hash并联合用户的其他传入数据保存到数据库中。

copy
// 注入 UserManager<IdentityUser> [HttpPost] [AllowAnonymous] public async Task<IActionResult> Register([FromBody] RegisterDto registerDto) { // 1、创建用户 var user = new IdentityUser() { UserName = registerDto.Email, Email = registerDto.Email }; // 2、保存用户hash敏感信息 var result = await _userManager.CreateAsync(user, registerDto.Password); // 3、返回 jwt if (!result.Succeeded) { return BadRequest(result.Errors); } return Ok(); }

​ 对于密码.NET默认要求包含字母大小写是、数字和非数字字母的字符且长度不得少于6位,这可比Spring-Mybatis好用多了。(密码不相同就不展示了,没啥意义)。

image-20240105144203878

(三)完善用户登录

​ 上面在模拟用户登录的时候已经完成了 JWT 的生成,但是缺少了用户的验证:

SignInManager 是 ASP.NET Core Identity 框架提供的一个管理用户登录的服务类,他提供了一系列的方法,用于处理用户的登录、注销以及相关的身份验证操作。它通常与 UserManager 一起使用,UserManager 用于管理用户信息,而 SignInManager 用于处理用户的登录状态。

copy
// 注入 SignInManager<IdentityUser> [HttpPost("login")] public async Task<IActionResult> login(LoginDto loginDto) { // 1.验证用户名密码 // 用户名、密码、多次登陆失败是否锁定 var loginResult = await _signInManager.PasswordSignInAsync( loginDto.Email, loginDto.Password, false, false ); if(!loginResult.Succeeded) { return BadRequest(); } // 2.生成 token var user = await _userManager.FindByNameAsync(loginDto.Email); // header var signingAlgorithm = SecurityAlgorithms.HmacSha256; // payload var claims = new List<Claim> { // Subject 用于标识 JWT 的主体,即令牌所代表的用户或实体 值通常是唯一标识用户的字符串 new Claim(JwtRegisteredClaimNames.Sub, user.Id), //new Claim(ClaimTypes.Role, "Admin") }; /** 获得的用户角色是一个集合的形式,要给每个角色都创建声明 可以理解为,"给Role这个 JSON 子对象赋值 "Roles": ["Admin", "Editor"] " 我们需要将 UserRole 这一个 Claim 添加到对应用户的 Claim 中 **/ var roleNames = await _userManager.GetRolesAsync(user); foreach(var roleName in roleNames) { var roleClaim = new Claim(ClaimTypes.Role, roleName); claims.Add(roleClaim); } // signiture 省略 //... // Token 省略 // ... var tokenStr = new JwtSecurityTokenHandler().WriteToken(token); // 3.return 200 ok + jwt return Ok(tokenStr); }

image-20240109101440050

​ 通常在使用 ASP.NET Core Identity 框架结合使用 Bearer 令牌进行身份验证时,通过在 Action 或 Controller 上添加 [Authorize(AuthenticationSchemes = "Bearer")] 特性,你告诉应用程序只有提供有效的 Bearer 令牌的请求才能访问被标记的资源。(不然会404

copy
[Authorize(Roles = "Admin", AuthenticationSchemes = "Bearer")]

image-20240109101836632

​ 成辣,为什么 403 捏?因为User相关的表是自动生成的,用户角色还没数据,😢

image-20240109102129325

(四)定制符合要求的用户属性

​ 使用Identity框架只是生成了一些基本的通用字段,ASP.NET Core Identity 框架提供了一种灵活的方式来拓展用户字段,以满足特定应用程序的需求。

image-20240109104226650

1.创建自定义用户类

UserRolesClaimsLoginsTokens 这些属性是用来表示与用户关联的一些额外信息,通常由 Identity 框架使用。它们是关联表,与用户实体之间存在一对多的关系,用于存储用户的角色、声明、登录信息以及令牌等。

copy
public class ApplicationUser : IdentityUser { public string? Address { get; set; } // ShoppingCart // Order public virtual ICollection<IdentityUserRole<string>> UserRoles { get; set; } public virtual ICollection<IdentityUserClaim<string>> Claims { get; set; } public virtual ICollection<IdentityUserLogin<string>> Logins { get; set; } public virtual ICollection<IdentityUserToken<string>> Tokens { get; set; } }

2.替换 IdentityUser

​ 将Program.csDbcontext、和控制器中的identityUser替换为自定义的ApplicationUser

3.生成种子数据

​ 在AppDbContextOnModelCreating中添加生成种子数据。

copy
// 1. 更新用户与角色的外键关系 modelBuilder.Entity<ApplicationUser>(b => { b.HasMany(x => x.UserRoles) .WithOne() .HasForeignKey(ur => ur.UserId) .IsRequired(); }); // 2. 添加角色 var adminRoleId = "308660dc-ae51-480f-824d-7dca6714c3e2"; // guid modelBuilder.Entity<IdentityRole>().HasData( new IdentityRole { Id = adminRoleId, Name = "Admin", NormalizedName = "Admin".ToUpper() } ); // 3. 添加用户 var adminUserId = "90184155-dee0-40c9-bb1e-b5ed07afc04e"; ApplicationUser adminUser = new ApplicationUser { Id = adminUserId, UserName = "admin@fakexiecheng.com", NormalizedUserName = "admin@fakexiecheng.com".ToUpper(), Email = "admin@fakexiecheng.com", NormalizedEmail = "admin@fakexiecheng.com".ToUpper(), TwoFactorEnabled = false, EmailConfirmed = true, PhoneNumber = "123456789", PhoneNumberConfirmed = false }; PasswordHasher<ApplicationUser> ph = new PasswordHasher<ApplicationUser>(); adminUser.PasswordHash = ph.HashPassword(adminUser, "Fake123$"); modelBuilder.Entity<ApplicationUser>().HasData(adminUser); // 4. 给用户加入管理员权限 // 通过使用 linking table:IdentityUserRole modelBuilder.Entity<IdentityUserRole<string>>() .HasData(new IdentityUserRole<string>() { RoleId = adminRoleId, UserId = adminUserId });

4.进行数据迁移

copy
dotnet ef migrations ApplicationuserMigration dotnet ef database update

UserRoles表包含两个外键,一个指向 Users 表的用户(UserId),另一个指向 Roles 表的角色(RoleId),通过 UserRoles 表,Users 表与 Roles 表之间形成了多对多的关系,实现了用户与角色的灵活关联。用户可以属于多个角色,而一个角色也可以包含多个用户。

image-20240109144015050

5.测试Admin用户

​ 根据种子数据的内容,管理员用户是:

copy
{ "email":"admin@fakexiecheng.com", "password":"Fake123$" }

image-20240109144556118

​ 以管理员角色更新资源返回201

image-20240109144540401

posted @   Purearc  阅读(24)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示
🚀