首页 / 资讯中心 / 文章详情

.NET6 WebAPI + SQL Server + JWT 增删改查完整实现指南

.NET6 WebAPI + SQL Server + JWT 增删改查完整实现指南 ★ FEATURED ARTICLE
简介这是一份基于.NET6的Web API实战项目源码包服务于需要上手ASP.NET Core Web API、SQL Server数据交互及JWT权限控制的.NET开发者。项目中既有完整CRUD接口实现也演示了Swagger自动文档生成、Entity Framework/Dapper等ORM使用、数据库迁移操作以及从用户登录签发Token到请求校验的无状态认证流程。同时涵盖RESTful API规范、全局异常过滤、SQL注入与XSS防护等工程要点。压缩包共665个文件以dll、cs、pdb、json为主兼具源码、编译输出、调试符号与配置文件整体34.81MB便于直接还原项目与阅读逻辑。资源附带数据库备份和工程说明已有1060人浏览学习。尤其适合想利用完整示例快速掌握.NET6下分层架构、服务封装与接口安全设计的开发者可当作课设或生产实践打磨的参照。1. 看穿这个标题.NET6 WebAPI SQL Server JWT 的增删改查到底指什么你要做的这件事用一句话讲清楚就是用 .NET6 WebAPI 搭一套带 JWT 认证的后端接口数据落在 SQL Server 里对外只暴露一组结构清晰的增删改查端点。这个组合是内部管理系统、小程序后端和前后端分离项目的常见起步形态。它解决的核心问题不是“怎么写登录”而是“接口怎么在不裸奔的前提下把数据安全地交出去”。适合两类人一类是刚学会 C# 基础语法、想交付一个完整后端的新手另一类是接外包或做内部工具、需要在最短时间内搭一个可维护基座的开发者。理解这一点后你会发现真正花时间的地方不在 CRUD 本身而在 JWT 的签名参数和 SQL Server 上下文生命周期上。2. 先把地基打正SqlServer 接入与三层结构怎么搭2.1 实体、DbContext 怎么摆才不会越写越乱在真实工程里用 EF Core 接入 SQL Server 是默认操作因为增删改查几乎不用写 SQL如果团队有 DBA 且要求所有访问走存储过程Dapper 会是替换项。这个标题只写了 SqlServer没限定 ORM我以 EF Core 为主线因为它能让你在一个下午就把数据访问跑通而且后续加软删除、分页、迁移都顺手。先看一个业务实体。这里用 Product 做示例因为它足够简单又能覆盖增删改查的典型字段。public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public string Category { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public bool IsDeleted { get; set; } }逻辑说明Name 和 Category 初始化成 string.Empty是为了避免可空引用警告。IsDeleted 是软删除标志位内部系统里几乎所有表都值得保留这一列后面 Delete 接口不会真正删行而是把标志位置为 true。Price 用 decimal 而不是 float是为了避免金额精度问题SQL Server 对应 decimal 类型也更合适。接下来是 DbContext它是 EF Core 访问 SQL Server 的入口。public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetProduct Products SetProduct(); }逻辑说明构造函数里的 options 由依赖注入容器传进来连接字符串在 Program.cs 里通过 AddDbContext 配置。DbSet 暴露给 Service 层查询使用裸写 SQL 才能满足的需求在这个规模下不需要引入。再说分层。Controller 只做 HTTP 语义Service 做业务逻辑DbContext 只在 Service 里出现。Controller 直接操作 DbContext 不是不能跑但几个接口写下来就会出现大量重复查询代码查列表、查单条、判断是否存在。把这些收进 Service 之后Controller 每个方法只剩参数校验和状态码返回后续改查询逻辑也不用动接口层。注册上下文时要注意生命周期常见写法是builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection)));参数说明UseSqlServer 指定 SQL Server providerGetConnectionString 从 appsettings.json 里读取连接串。DbContext 默认生命周期是 Scoped也就是每个请求新建一个上下文实例这正好匹配 EF Core 的线程安全规则。不要手痒改成 Singleton并发场景下会出现一堆莫名其妙的异常。2.2 Program.cs 最小注册清单与连接串参数无论你建项目时用的是 Controller 模板还是最小 API 模板最终落到 Program.cs 的注册清单大同小异。下面是一份够用的最小配置。var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();逻辑说明这是 Controller 模式的最小启动配置。注意这里还没有 UseAuthentication因为 JWT 中间件在下一章才加。顺序习惯上要把 UseAuthentication 放在 UseAuthorization 之前否则认证结果不会带到授权环节后面排查 401 时会重点讲。连接串长这样ConnectionStrings: { DefaultConnection: Server.;DatabaseDemoDb;User Idsa;PasswordYourPassword;TrustServerCertificateTrue;EncryptFalse; }参数说明Server. 代表本机默认实例命名实例要写 .\SQLEXPRESS。EncryptTrue 是 SQL Server 连接加密的默认值本地开发没装证书时直接会连不上所以要么 TrustServerCertificateTrue要么 EncryptFalse。生产环境建议 EncryptTrue 并配置受信证书。如果你用 Windows 集成登录把 User Id 和 Password 删掉换成 Integrated SecurityTrue。这里最容易被忽略的是 TrustServerCertificate。明明账号密码都对报错却提示证书链未被信任就是它在作怪。本地开发别纠结先把连接跑通再补加密这是我踩过几次的坑。还要确认一点如果用 EF Core 工具生成数据库需要装 dotnet-ef 工具然后执行迁移命令。常见做法是先写实体再执行 dotnet ef migrations add Init最后 dotnet ef database update。如果只是做个演示也可以让 DBA 先把表建好EF Core 只要表和实体对应上就能正常工作。表名默认会是 Products如果不想用复数就在 OnModelCreating 里配置 ToTable(Product)。我一般保持复数避免和实体名混淆。3. JWT 不是黑匣子从签发 Token 到鉴权策略的完整链路3.1 为什么不用 CookieJWT 选型逻辑前后端分离之后Cookie 方案会遇到跨域、CSRF、移动端不支持 Cookie 等问题。JWT 把用户身份编码进一个自包含的 Token请求时在 Authorization 头里带过来服务端验签即可不需要查询会话表。这个特性让它在 API 场景里成为默认选项。但 JWT 不是加密它的 payload 部分用 Base64 编码谁都能解码只是不能伪造。绝不能把密码、身份证号这类敏感信息放进去。很多人第一次写 JWT 时以为别人看不到实际上放到网上随便一个解析工具就能看到内容。所以 Token 里一般只放 userId、userName、role 这类非敏感标识。选 Cookie 还是 JWT 不是跟风。如果要求服务端能立即踢人Session 加 Cookie 其实更顺手JWT 要踢人就得加黑名单或把过期时间缩短。对标准增删改查 API我通常默认 JWT因为客户端无状态、接手的人看代码也容易懂。标题既然锁定了 JWT就顺着这条路走到底。3.2 签发一个可用的 Token代码和关键参数先装 NuGet 包 Microsoft.AspNetCore.Authentication.JwtBearerSystem.IdentityModel.Tokens.Jwt 一般会被带进来。签发 Token 的逻辑通常单独抽一个 TokenService。using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class TokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) { _config config; } public string CreateToken(int userId, string userName, string role) { var claims new ListClaim { new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Name, userName), new Claim(ClaimTypes.Role, role) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config[Jwt:Key]!)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_config[Jwt:ExpireMinutes])), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } }逻辑说明claims 是 Token 自带的身份声明Sub 是用户 IdName 是用户名ClaimTypes.Role 会映射成标准角色声明。SymmetricSecurityKey 是对称签名密钥HS256 算法要求密钥长度至少 128 位建议直接配 32 个以上字符。expires 用 UTC 时间避免服务器时区导致 Token 过期时间混乱。参数说明Issuer 和 Audience 在 appsettings 里配置客户端和 API 两边要对得上。ExpireMinutes 建议设 120 分钟内部系统可以更长但超过 1 天对安全不利。配置长这样Jwt: { Issuer: Demo.Api, Audience: Demo.Client, Key: replace-with-a-random-string-at-least-32-chars, ExpireMinutes: 120 }提示密钥别写死在 Git 仓库里。常见做法是 appsettings 放开发值生产环境用环境变量覆盖比如在 IIS 或容器里配置 Jwt__Key。有的项目把默认 Key 发到代码库结果整个系统都能被伪造 Token这个教训很深刻。3.3 让请求带上身份认证中间件与鉴权策略配置签发 Token 只是前半段后半段是让 API 在收到请求时验证令牌。这部分写在 Program.cs 里。using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key]!)), ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization();逻辑说明AddAuthentication 注册认证服务AddJwtBearer 配置 JWT 验证细节。TokenValidationParameters 里 ValidateIssuer 和 ValidateAudience 必须开启否则别的系统拿着相同密钥签的 Token 也能访问。ValidateIssuerSigningKey 确保用同一个密钥验签。ValidateLifetime 默认开启负责检查过期时间。参数说明ClockSkew 默认是 5 分钟意思是即使 Token 过期系统也允许前后 5 分钟的时钟偏移结果就是 Token 过期后还能再访问一段不长的时间。对内网 API 我直接压到 30 秒。NameClaimType 和 RoleClaimType 这两项如果不做设置默认也能工作出现自定义 Claim 取不到的时候再去改。构建应用之后中间件顺序是关键app.UseAuthentication(); app.UseAuthorization();逻辑说明UseAuthentication 会解析请求头里的 Token 并填充用户身份UseAuthorization 才检查 [Authorize] 标签。顺序反了身份信息还没建立授权自然失败。控制器侧这样用[ApiController] [Route(api/[controller])] [Authorize] public class ProductsController : ControllerBase { }逻辑说明类上标注 [Authorize]整个控制器都要求登录。登录接口本身要标注 [AllowAnonymous]否则没人能拿到 Token。角色限制用 [Authorize(Roles admin)]并且 Token 里要包含对应角色 Claim。别把所有接口都允许匿名后再在代码里手动判断那样很容易漏。4. 把增删改查写成不挨骂的接口DTO、Service 与状态码选择4.1 Service 层 DTO到底谁碰 DbContextController 里直接用 DbContext 不是不能跑但会让代码越到后期越乱。常见做法是加一个 Service 层Controller 只负责接收 HTTP 请求、做参数校验、返回合适状态码数据访问全在 Service。Controller 瘦下来之后接口的增删改查语义一眼就看懂。先定义 DTO。DTO 的作用是隔离外部输入和数据库实体避免把 EF 跟踪元数据和实体结构直接暴露给前端。public class ProductDto { public int Id { get; set; } public string Name { get; set; } string.Empty; public string Category { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } } public class CreateProductRequest { public string Name { get; set; } string.Empty; public string Category { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } } public class UpdateProductRequest { public string Name { get; set; } string.Empty; public string Category { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } }逻辑说明Create 和 Update 的输入模型几乎一样区别在于 Update 不带 IdId 从路由里取。如果你把实体直接当输入模型[ApiController] 会把 EF 元数据牵扯进来还容易暴露多余字段。Service 接口与实现public interface IProductService { TaskListProductDto GetPageAsync(int page, int pageSize); TaskProductDto? GetByIdAsync(int id); TaskProductDto CreateAsync(CreateProductRequest request); Taskbool UpdateAsync(int id, UpdateProductRequest request); Taskbool DeleteAsync(int id); }public class ProductService : IProductService { private readonly AppDbContext _db; public ProductService(AppDbContext db) { _db db; } public async TaskListProductDto GetPageAsync(int page, int pageSize) { var items await _db.Products .Where(p !p.IsDeleted) .OrderByDescending(p p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p new ProductDto { Id p.Id, Name p.Name, Category p.Category, Price p.Price, Stock p.Stock, CreatedAt p.CreatedAt }) .AsNoTracking() .ToListAsync(); return items; } public async TaskProductDto? GetByIdAsync(int id) { var entity await _db.Products .AsNoTracking() .FirstOrDefaultAsync(p p.Id id !p.IsDeleted); return entity is null ? null : MapToDto(entity); } public async TaskProductDto CreateAsync(CreateProductRequest request) { var entity new Product { Name request.Name, Category request.Category, Price request.Price, Stock request.Stock, CreatedAt DateTime.UtcNow, IsDeleted false }; _db.Products.Add(entity); await _db.SaveChangesAsync(); return MapToDto(entity); } public async Taskbool UpdateAsync(int id, UpdateProductRequest request) { var entity await _db.Products .FirstOrDefaultAsync(p p.Id id !p.IsDeleted); if (entity is null) return false; entity.Name request.Name; entity.Category request.Category; entity.Price request.Price; entity.Stock request.Stock; await _db.SaveChangesAsync(); return true; } public async Taskbool DeleteAsync(int id) { var entity await _db.Products .FirstOrDefaultAsync(p p.Id id !p.IsDeleted); if (entity is null) return false; entity.IsDeleted true; await _db.SaveChangesAsync(); return true; } private static ProductDto MapToDto(Product p) { return new ProductDto { Id p.Id, Name p.Name, Category p.Category, Price p.Price, Stock p.Stock, CreatedAt p.CreatedAt }; } }逻辑说明GetPageAsync 用 Where 过滤软删除OrderByDescending 让新数据在前Skip/Take 会被 EF Core 翻译成 SQL Server 的 OFFSET/FETCH 分页不是把全部数据拉到内存再分页。Select 手工映射成 DTO避免 JSON 序列化时把实体导航属性带进去。AsNoTracking 告诉 EF Core 只读不创建跟踪实体查询性能更好但 Update 和 Delete 时不能加它因为需要跟踪实体状态才能生成 UPDATE 或 DELETE 语句。Create 和 Update 都先从库里查出来再改不用 Attach 那种看起来省事的写法。小系统没必要冒脏数据风险性能差异可以忽略。Delete 用软删除保留历史记录这是内部系统通行做法如果确定要物理删除把 entity.IsDeleted true 换成 _db.Products.Remove(entity) 就行。参数说明page 从 1 开始pageSize 建议控制在 10 到 100 之间。分页参数要在 Controller 里做校验page 小于 1 或者 pageSize 超过上限就返回 400。4.2 四个接口的落地写法与状态码选择Controller 代码直接体现 HTTP 语义[ApiController] [Route(api/[controller])] [Authorize] public class ProductsController : ControllerBase { private readonly IProductService _service; public ProductsController(IProductService service) { _service service; } [HttpGet] public async TaskActionResultApiResultListProductDto GetPage( [FromQuery] int page 1, [FromQuery] int pageSize 10) { var data await _service.GetPageAsync(page, pageSize); return Ok(ApiResult.Success(data)); } [HttpGet({id:int})] public async TaskActionResultApiResultProductDto GetById(int id) { var data await _service.GetByIdAsync(id); return data is null ? NotFound(ApiResult.Fail(数据不存在, 404)) : Ok(ApiResult.Success(data)); } [HttpPost] public async TaskActionResultApiResultProductDto Create(CreateProductRequest request) { var data await _service.CreateAsync(request); return CreatedAtAction(nameof(GetById), new { id data.Id }, ApiResult.Success(data)); } [HttpPut({id:int})] public async TaskActionResultApiResultbool Update(int id, UpdateProductRequest request) { var updated await _service.UpdateAsync(id, request); return updated ? Ok(ApiResult.Success(true)) : NotFound(ApiResult.Fail(数据不存在, 404)); } [HttpDelete({id:int})] public async TaskActionResultApiResultbool Delete(int id) { var deleted await _service.DeleteAsync(id); return deleted ? NoContent() : NotFound(ApiResult.Fail(数据不存在, 404)); } }逻辑说明每个方法都是异步 Task用 ActionResultApiResult 作为返回类型。创建成功后返回 CreatedAtAction状态码是 201同时带 Location 头指向新资源的 GET 地址。Delete 返回 204表示成功且无内容比返回 200 更贴合 REST 语义。如果你团队的前端不习惯处理 204统一返回 200 也可以但前后端要约定清楚不要混合着来。参数说明路由约束 {id:int} 避免了字符串 id 进来后再转型出错。[FromQuery] 显式声明分页参数来源避免跟路由参数混淆。NotFound 和 Ok 里面都用 ApiResult 包裹出错时前端只需要解析同一个 JSON 结构。4.3 统一返回结构与分页参数怎么设统一返回结构的目的是让前端可以用一个拦截器处理所有接口。如果不做遇到异常时 ASP.NET Core 会返回默认错误体前端就得兼容两套结构。public class ApiResultT { public bool Success { get; set; } public int Code { get; set; } public string Message { get; set; } string.Empty; public T? Data { get; set; } public static ApiResultT Ok(T data) { return new ApiResultT { Success true, Code 200, Message ok, Data data }; } public static ApiResultT Fail(string message, int code 400) { return new ApiResultT { Success false, Code code, Message message }; } }逻辑说明静态方法让 Controller 里不用 new 一大堆对象。更工程化的做法是在 Program.cs 里注册全局异常过滤器把未捕获异常统一转成 ApiResult 格式这部分我放到最后一章。分页数据结构public class PageResultT { public int PageIndex { get; set; } public int PageSize { get; set; } public int Total { get; set; } public ListT Items { get; set; } new(); }参数说明PageIndex 和 PageSize 回传给前端Total 用于计算总页数。字段命名用 PageIndex 而不是 Page避免和请求参数混淆。如果你要接前端表格组件这个结构基本可以直接用。5. 常见问题排查JWT 与 SQL Server 场景下五个最容易翻车的位置5.1 Token 接口一直 401先查中间件顺序和 Bearer 前缀现象接口标了 [Authorize]带着 Token 访问始终返回 401服务端日志里连 TokenValidated 事件都没触发。原因最常见有两个。一个是 Program.cs 里漏了 app.UseAuthentication()或者把它写在了 UseAuthorization 后面另一个是请求头格式写成了 Authorization: 一串 Token少了 Bearer 前缀JwtBearer 中间件默认只认 Bearer 方案。解决确认中间件顺序是 UseAuthentication 在前、UseAuthorization 在后。客户端加请求头时写成 Authorization: Bearer eyJ...注意 Bearer 和 Token 之间有且只有一个空格。5.2 明明设置了 2 小时过期过期后还能访问现象Token 的 expires 设置成 2 小时实际跑了 2 小时 5 分还能正常访问。原因JwtBearerOptions 的 ClockSkew 默认是 5 分钟它允许验证时存在最多 5 分钟时钟偏差等效于 Token 生命周期比设定长。解决在 TokenValidationParameters 里设置 ClockSkew TimeSpan.FromSeconds(30)或者直接设 TimeSpan.Zero。如果服务器和客户端存在明显时间偏差设零会导致正常 Token 被判过期30 秒是个折中值。5.3 自定义 Claim 取不到值现象签发时写 new Claim(permission, product:edit)在接口里用 User.FindFirst(permission) 返回 null。原因JwtSecurityTokenHandler 默认启用 claim 类型映射把短名称映射成微软默认的 XML 命名空间长 URI所以按短名称查找失败。解决启动时执行 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear()同时清理 DefaultOutboundClaimTypeMap。如果不想全局清理读取时用完整 URI但代码很难看。我一般全局清理可读性更好。5.4 HS256 签名密钥长度踩坑现象写了个短的 Key 比如 abc123调试时抛异常错误码类似 IDX10653提示 key size 不足。原因HS256 不是随便一个字符串都能当签名密钥对称密钥至少需要 128 位也就是 16 字节很多 Token 库实际要求不低于 256 位。解决配置一个至少 32 字符的随机字符串。不要直接写在代码里放 appsettings并由部署环境用环境变量覆盖。如果已经报了错换长 Key 后要清理本地已签发的旧 Token重新获取再试。5.5 DbContext 异步与连接池现象接口偶尔抛出连接池超时错误信息类似 Timeout expired或者并发请求一高页面就卡住。原因常见是 DbContext 被注册成 Singleton或者 Service 里用了 Task.Run 再调 DbContext。EF Core 上下文不是线程安全的并发请求共用同一个上下文必然出问题。另一种是同步调用 GetAwaiter().GetResult()在高并发下阻塞线程池连接池很快耗尽。解决DbContext 保持默认 Scoped 生命周期全链路用 async/awaitController 和 Service 方法都标 async Task不要出现 .Result 或 .Wait()。连接串里可以先调大 Max Pool Size但这只是临时止血根因在同步调用治标不治本。6. 用 Swagger 验证链路从手动加 Header 到自动带 Token 的收尾技巧接口写完不能只靠 Postman 手测。能让团队所有人一键验收才算真正收尾。先在 Swagger 里配置 JWT Bearer 安全定义点一下 Authorize 按钮输入 Token后续所有请求自动带 Authorization 头不用每次手动拼字符串。builder.Services.AddSwaggerGen(options { options.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Name Authorization, Type SecuritySchemeType.Http, Scheme bearer, BearerFormat JWT, In ParameterLocation.Header, Description 输入你的 JWT Token }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, Array.Emptystring() } }); });逻辑说明SecuritySchemeType.Http 配合 SchemebearerSwagger 会在请求头自动拼上 Bearer 前缀你只需要在弹窗里粘贴 Token 本体。AddSecurityRequirement 让所有带 [Authorize] 的接口都显示锁图标未登录时点击调用会直接返回 401。我给自己定了一个验收顺序先调登录接口拿到 Token把 Token 贴进 Swagger 的 Authorize 弹窗然后依次走 GET、POST、PUT、DELETE重点看四件事——不带 Token 时是否 401、带 Token 分页是否按页返回、更新不存在的 Id 是否 404、删除后再次查询是否消失。这个顺序跑一遍这套 JWT 加增删改查基本就能交付了。最后一个习惯真正上线前把 Jwt:Key 放到服务器环境变量代码仓库里只留占位符。某次模拟项目里我们就是因为密钥写死在配置里被安全评审一票打回全程返工。把这一段链路跑通并定好规范后续接权限系统、加日志中间件都会顺很多。希望这些经验帮到你少走这一步。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站