拓冰建站拓冰建站
首页 / 资讯中心 / 正文

.NET6 WebAPI实战:SQL Server + JWT实现学生成绩管理系统

简介一套基于.NET6的Web API实战示例面向需要快速掌握ASP.NET Core与SQL Server整合开发的中高级.NET工程师。项目围绕常见业务数据的增删改查场景演示了从数据库表设计、ORM映射到接口发布的全流程并集成JWT身份验证与Swagger接口文档同时梳理了数据库迁移、全局异常处理、防SQL注入等生产环境必备实践适合作为企业级WebAPI开发的可运行参考。资源共665个文件涵盖核心源码cs、csproj、sln、运行依赖的dll、json配置、数据库备份DemoDB.bak等压缩包整体34.81MB。目前已有1059人学习读者可直接对照源码理解JWT令牌的生成与校验、权限控制、CRUD接口的实现方式还能借助Swagger页面自行测试接口并参考其中SqlSugar、Dapper等多种数据访问写法降低在真实项目中整合.NET6、SQL Server和JWT的门槛。1. 项目概述与核心需求拆解先说结论这是一套几乎所有企业级后端开发都绕不开的“标准套餐”——.NET6 WebAPI提供接口服务SQL Server负责数据持久化JWT完成身份认证与授权最后落点到一张学生成绩表的增删改查。听起来平平无奇但真正动手做过的人都知道这里面暗坑不少从SQL Server的安装配置、连接串的写法到JWT签名密钥的管理、过期时间的设置再到EF Core的迁移坑、前端blob下载文件名乱码每一步都能卡住一批人。我当初做这个项目的时候需求很明确给一个内部教学管理系统写后端接口前端用的是Vue要求接口必须带Token验证数据要落到SQL Server里并且要支持对成绩记录的完整增删改查。我之所以选择.NET6而不是.NET Framework核心原因有三个跨平台部署能力、性能提升明显、以及微软官方对.NET6的长期支持策略。这个项目适合谁学习正在从.NET Framework转到.NET6的开发者、需要快速搭建带认证功能API的新手、以及被“JWT到底怎么和数据库操作串起来”这个问题困扰的初学者。我下面写的内容全部来自我自己实际跑到通的代码你可以直接照抄。2. 前期准备数据库与开发环境搭建2.1 安装SQL Server与SSMS图形化工具很多人的第一个坑其实从这里就开始了。SQL Server的安装步骤说简单也简单但说麻烦也真麻烦特别是你装完之后发现“配置管理器”找不到、远程调试连不上、密码忘了这种情况。先说版本选择。如果你是个人开发或学习用途SQL Server Developer版完全够用功能和Enterprise版一致只是不能用于生产环境。如果公司有正版授权直接用Enterprise。安装时记住几个关键选项功能选择建议至少勾选“数据库引擎服务”和“SQL Server Management Studio”SSMS两个组件实例配置默认实例MSSQLSERVER即可如果你机器上已经装了旧版建议用命名实例避免冲突身份验证模式一定选“混合模式”然后设置sa账号的密码。理由很简单纯Windows身份验证在你后续用连接字符串从程序里连接时容易出幺蛾子混合模式兼容性最好指定SQL Server管理员把这个Windows用户加进去否则后续权限会折腾死你SSMS就是图形化工具安装完之后你在开始菜单找到它用sa账号登录能连上本地实例说明数据库引擎已经跑起来了。注意别把SSMS和SQL Server服务本身搞混——SSMS只是一个客户端真正提供服务的是Windows服务里的“SQL Server (MSSQLSERVER)”这个服务。2.2 创建数据库与成绩表打开SSMS新建查询执行一段建库和建表脚本。这里我直接给出一个符合实际需求的成绩表设计CREATE DATABASE StudentDB; GO USE StudentDB; GO CREATE TABLE StudentScore ( Id INT IDENTITY(1,1) PRIMARY KEY, StudentNo NVARCHAR(20) NOT NULL, StudentName NVARCHAR(50) NOT NULL, CourseName NVARCHAR(50) NOT NULL, Score DECIMAL(5,2) NOT NULL, ExamDate DATETIME NOT NULL, CreatedAt DATETIME DEFAULT GETDATE() ); GO -- 插入几条测试数据 INSERT INTO StudentScore (StudentNo, StudentName, CourseName, Score, ExamDate) VALUES (2024001, N张三, N高等数学, 88.5, 2025-01-15), (2024002, N李四, N高等数学, 92.0, 2025-01-15), (2024001, N张三, N线性代数, 76.5, 2025-01-20), (2024003, N王五, N大学英语, 85.0, 2025-01-22); GO这里有几个细节想特别说明。表名用了StudentScore而不是直接用中文字段命名采用的是PascalCase风格并且没有加表前缀。这是因为EF Core默认约定就是这种风格如果你在SQL Server里用下划线或者全大写后面写实体类的时候还得配置映射白白增加工作量。分数我用的是DECIMAL(5,2)意思是总共5位数字小数点后保留2位最大能存999.99对成绩场景来说绰绰有余。ExamDate用DATETIME而不是Date因为考试成绩一般要精确到具体时间点。如果你做的是真实项目这个表肯定还要加CreatedBy、UpdatedAt这种审计字段但这里没必要保持简洁。2.3 用VS2022创建WebAPI项目打开Visual Studio 2022选择“创建新项目”搜索“ASP.NET Core Web API”选C#版本。框架选择.NET 6.0注意这里有个坑如果你本机只装了.NET 8 SDKVS2022里可能找不到.NET6的模板选项需要在“Visual Studio Installer”里勾选“.NET 6.0运行时”或者直接用.NET 8项目文件里的TargetFramework写成net6.0也能跑。我个人建议还是用.NET6因为它是LTS版本生态稳定第三方库兼容性好。创建项目时别勾选“使用控制器”因为VS2022默认模板会有WeatherForecast示例你还得手动删干脆从一开始就不勾。以及“配置HTTPS”建议勾上本地调试用HTTPS没坏处发布到服务器时再决定要不要关。项目创建完之后先看一眼结构。标准的.NET6项目里Program.cs是入口所有服务注册、中间件配置都写在这个文件里这和.NET5之前的Startup.cs风格差别挺大的。后面我们的大部分工作就是在这个文件里加服务注册和中间件。3. 核心实现SQL Server增删改查3.1 引入EF Core并配置数据库上下文我在这里选用Entity Framework Core作为ORM理由是开发效率高、支持LINQ查询、有完善的迁移机制。如果你追求极致性能可以换Dapper但相同功能的代码量会明显增加。第一次做这个项目EF Core是更合理的选择。在NuGet包管理器里安装以下三个包Microsoft.EntityFrameworkCore.SqlServer Microsoft.EntityFrameworkCore.Tools Microsoft.EntityFrameworkCore.Design然后创建实体类和数据上下文。实体类对应数据库表结构using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace StudentApi.Models { [Table(StudentScore)] public class StudentScore { [Key] public int Id { get; set; } [Required, MaxLength(20)] public string StudentNo { get; set; } [Required, MaxLength(50)] public string StudentName { get; set; } [Required, MaxLength(50)] public string CourseName { get; set; } [Column(TypeName decimal(5,2))] public decimal Score { get; set; } public DateTime ExamDate { get; set; } public DateTime CreatedAt { get; set; } } }注意几个关键点[Table]属性指定表名因为EF Core默认会把实体名映射成StudentScores复数形式但我们的表名是单数不指定就对不上了。Score字段的[Column(TypeName decimal(5,2))]必须写否则EF Core生成的映射类型可能不兼容。CreatedAt就让它默认值生成实体里不做特殊处理。接着创建ApplicationDbContextusing Microsoft.EntityFrameworkCore; using StudentApi.Models; namespace StudentApi.Data { public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptionsApplicationDbContext options) : base(options) { } public DbSetStudentScore StudentScores { get; set; } } }在appsettings.json里配置连接字符串{ ConnectionStrings: { DefaultConnection: Serverlocalhost;DatabaseStudentDB;User Idsa;Password你的密码;TrustServerCertificateTrue; }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }这里TrustServerCertificateTrue是一定要加的否则连接SQL Server时如果是自签名证书会直接报证书验证失败的错误这个坑我踩过一次折腾了半个小时才发现。EncryptFalse也可以但在.NET6里官方推荐用TrustServerCertificateTrue代替。在Program.cs里注册DbContextusing Microsoft.EntityFrameworkCore; using StudentApi.Data; var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextApplicationDbContext(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();3.2 实现增删改查接口接下来写真正干活的代码。我建议在项目里加上Service层和Repository层虽然对这个简单项目来说有点多余但养成好习惯后续项目变大时你能省下重构成本的力气。这里为了篇幅不过度膨胀我把逻辑直接写在Controller里但代码结构保持清晰。创建StudentScoreControllerusing Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using StudentApi.Data; using StudentApi.Models; namespace StudentApi.Controllers { [Route(api/[controller])] [ApiController] public class StudentScoreController : ControllerBase { private readonly ApplicationDbContext _context; public StudentScoreController(ApplicationDbContext context) { _context context; } // GET: api/StudentScore [HttpGet] public async TaskActionResultIEnumerableStudentScore GetScores() { return await _context.StudentScores.ToListAsync(); } // GET: api/StudentScore/5 [HttpGet({id})] public async TaskActionResultStudentScore GetScore(int id) { var score await _context.StudentScores.FindAsync(id); if (score null) { return NotFound(); } return score; } // POST: api/StudentScore [HttpPost] public async TaskActionResultStudentScore PostScore(StudentScore score) { score.CreatedAt DateTime.Now; _context.StudentScores.Add(score); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetScore), new { id score.Id }, score); } // PUT: api/StudentScore/5 [HttpPut({id})] public async TaskIActionResult PutScore(int id, StudentScore score) { if (id ! score.Id) { return BadRequest(); } _context.Entry(score).State EntityState.Modified; try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!_context.StudentScores.Any(e e.Id id)) { return NotFound(); } throw; } return NoContent(); } // DELETE: api/StudentScore/5 [HttpDelete({id})] public async TaskIActionResult DeleteScore(int id) { var score await _context.StudentScores.FindAsync(id); if (score null) { return NotFound(); } _context.StudentScores.Remove(score); await _context.SaveChangesAsync(); return NoContent(); } } }这段代码是标准的WebAPI增删改查模板但我遇到的实际问题不止这些。首先POST接口里手动设置CreatedAt DateTime.Now是有原因的如果不设EF Core插入时会带着默认值0001-01-01而SQL Server里数据库字段默认值是GETDATE()两者优先级有冲突最省事的方式就是代码里直接赋值。其次PUT接口里有个隐藏问题_context.Entry(score).State EntityState.Modified;会把实体的所有字段都标记为已修改即使前端只传了Score一个字段其他字段也会被覆盖成默认值。更稳妥的做法是先取出原实体再逐个字段更新。但对于内部管理系统前端一般会把完整对象传回来简化处理可以接受。3.3 做一次简单的迁移在程序包管理器控制台执行Add-Migration InitCreate Update-Database第一个命令会在项目里生成Migrations目录记录模型和数据库的同步状态第二个命令把生成的SQL脚本执行到数据库里。如果你在SSMS里手动建过表运行Update-Database会报“对象已存在”的错误解决方式是先删掉手动建的表再执行迁移。这里分享一个经验在开发阶段直接用EnsureCreated()或者手动执行SQL脚本建表都行但项目一旦要上生产务必用正规的迁移流程否则后续字段变更、版本回滚都会很痛苦。3.4 实用查询语句与常见需求增删改查只是基础真实项目里查询条件通常很复杂。分享几个高频查询场景的LINQ写法条件查询按学号查成绩[HttpGet(byStudent)] public async TaskActionResultIEnumerableStudentScore GetScoresByStudent(string studentNo) { var scores await _context.StudentScores .Where(s s.StudentNo studentNo) .OrderByDescending(s s.ExamDate) .ToListAsync(); return scores; }分页查询[HttpGet(paged)] public async TaskActionResultPagedResultStudentScore GetPagedScores(int page 1, int pageSize 10) { var query _context.StudentScores.AsQueryable(); var total await query.CountAsync(); var items await query .OrderByDescending(s s.ExamDate) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResultStudentScore { Total total, Items items }; }字符串转数字和聚合查询有的场景需要统计某个学生的总成绩、平均分LINQ写法[HttpGet(summary/{studentNo})] public async TaskActionResultobject GetSummary(string studentNo) { var data await _context.StudentScores .Where(s s.StudentNo studentNo) .GroupBy(s s.StudentNo) .Select(g new { TotalScore g.Sum(s s.Score), AverageScore g.Average(s s.Score), CourseCount g.Count() }) .FirstOrDefaultAsync(); return data; }SQL Server字符串转数字的需求也经常出现比如学号字段存的是NVARCHAR但你想和整数类型比较SQL里可以CAST(StudentNo AS INT)LINQ里对应的是Convert.ToInt32(s.StudentNo)但要注意转换失败会抛异常字符串里混入非数字字符时尤其小心。4. JWT认证让接口变得安全4.1 JWT的原理拆解JWTJSON Web Token本质上就是一个经过签名的JSON字符串它由三部分组成Header头部、Payload负载、Signature签名用.分隔格式长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6ImFkbWluIiwibmJmIjoxNzExMzEyMjY3LCJleHAiOjE3MTEzOTg2NjcsImlhdCI6MTcxMTMxMjI2NywiaXNzIjoiU3R1ZGVudEFwaSIsImF1ZCI6IlN0dWRlbnRDbGllbnQifQ.zc8xN9VNZxNn1z_aINDFFj8h8Rq0X0HN4hF811JwWEUHeader声明签名算法比如HS256Payload存放用户信息如用户名、角色、签发时间iat、过期时间expSignature用服务端密钥对前两部分进行HMAC-SHA256签名防止内容被篡改服务端校验Token时只需要用自己手里的密钥重新算一遍签名和Token自带的签名比对一致就说明Token没被篡改过再去检查exp是否过期。这个机制的好处是服务端无需保存Token状态天然支持分布式部署这也是JWT相比于传统Session认证的核心优势。当然JWT也有坑Token一旦签发在过期之前无法主动撤销。如果用户点了“登出”前端把Token丢掉就行但服务端没法让这个Token立刻失效。这就是下文“Token续签”需求的由来。4.2 生成Token的实现代码首先要安装两个NuGet包Microsoft.AspNetCore.Authentication.JwtBearer然后修改appsettings.json加入JWT配置Jwt: { Key: YourSuperSecretKeyHereYourSuperSecretKeyHere, Issuer: StudentApi, Audience: StudentClient, ExpireMinutes: 60 }这个Key值得专门说两句。它是一个用于签名的对称密钥长度建议至少32个字节即256位否则HS256算法会报“invalid key size”错误。另外千万不要把生产环境的Key硬编码在代码里最好放到环境变量或密钥管理服务里。真实项目中一旦Key泄露攻击者可以任意伪造Token后果非常严重。新增一个JwtHelper类封装Token生成逻辑using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; namespace StudentApi.Helpers { public class JwtHelper { public static string GenerateToken(string userId, string userName, string role, IConfiguration config) { var jwtSettings config.GetSection(Jwt); var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings[Key])); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims new ListClaim { new Claim(JwtRegisteredClaimNames.Sub, userId), new Claim(JwtRegisteredClaimNames.UniqueName, userName), new Claim(ClaimTypes.Role, role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var token new JwtSecurityToken( issuer: jwtSettings[Issuer], audience: jwtSettings[Audience], claims: claims, expires: DateTime.Now.AddMinutes(Convert.ToDouble(jwtSettings[ExpireMinutes])), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } } }代码本身不难理解关键在于Claims的设计。UniqueName放用户名ClaimTypes.Role放角色这样后面做基于角色的权限控制如管理员才能删除时通过[Authorize(Roles Admin)]特性就能实现。Jti是一个随机标识主要用于Token的追踪和唯一性识别。4.3 集成JWT认证与授权中间件回到Program.cs注册认证服务和鉴权中间件using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; // ... 前面的代码 builder.Services.AddAuthentication(options { options.DefaultAuthenticateScheme JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidateAudience true, ValidateLifetime true, ValidateIssuerSigningKey true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidAudience builder.Configuration[Jwt:Audience], IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key])) }; }); // app构建完之后在UseAuthorization之前加入 app.UseAuthentication(); app.UseAuthorization();这段配置的顺序很关键UseAuthentication必须放在UseAuthorization之前因为要先通过认证确定用户身份授权层才能判断该身份有没有权限访问资源。很多新手在这里搞反结果接口始终返回401或者权限判断不生效。然后在需要保护的Controller上加上[Authorize]特性[Authorize] [Route(api/[controller])] [ApiController] public class StudentScoreController : ControllerBase { // ... }如果你希望某些接口匿名可访问比如登录接口在对应的Action上加[AllowAnonymous]即可。通过[Authorize]加在类上、个别Action去掉的方式可以灵活控制接口的开放程度。4.4 登录接口与Token返回现在需要一个登录接口来签发Token不然我们的课程成绩查询接口就没法调用。创建一个AuthControllerusing Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using StudentApi.Data; using StudentApi.Helpers; namespace StudentApi.Controllers { [Route(api/[controller])] [ApiController] public class AuthController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IConfiguration _config; public AuthController(ApplicationDbContext context, IConfiguration config) { _context context; _config config; } [AllowAnonymous] [HttpPost(login)] public async TaskIActionResult Login(LoginRequest request) { // 这里仅为示例实际密码必须加密存储并比较哈希 if (request.Username ! admin || request.Password ! 123456) { return Unauthorized(new { message 用户名或密码错误 }); } var token JwtHelper.GenerateToken(1, request.Username, Admin, _config); return Ok(new { token, expiresIn 3600 }); } } public class LoginRequest { public string Username { get; set; } public string Password { get; set; } } }代码很直白但我要强烈建议生产项目里密码绝不能这么比对。必须使用BCrypt或PBKDF2存储密码哈希登录时比对哈希值明文密码坚决不保存。这个登录逻辑只是一个能跑通全流程的最小示例真正的健壮性设计是另外一门课。4.5 Swagger里配置JWT验证调试接口时Swagger是非常好用的工具但默认情况下它不会把Token加到请求头里每次都要手动打开Postman复制Token麻烦得很。配置一段代码让Swagger支持“Authorize”按钮builder.Services.AddSwaggerGen(c { c.AddSecurityDefinition(Bearer, new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Description JWT Authorization header using the Bearer scheme. Enter Bearer [space] and then your token, Name Authorization, In Microsoft.OpenApi.Models.ParameterLocation.Header, Type Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey, Scheme Bearer }); c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement { { new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Reference new Microsoft.OpenApi.Models.OpenApiReference { Type Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id Bearer } }, Array.Emptystring() } }); });配置完之后Swagger页面上会出现一个“Authorize”按钮点击输入Bearer 你的Token后面所有请求都会带上Authorization头。5. 前端联调与Vue接入JWT5.1 Vue前端如何调用接口并携带Token后端接口写完前端用Vue进行对接是常见的落地场景。核心思路登录时拿到Token存起来后续每次请求在拦截器里把Token放进请求头。以axios为例封装一个请求工具// request.js import axios from axios import { useRouter } from vue-router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带Token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理401错误 request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) // 跳转登录页 const router useRouter() router.push(/login) } return Promise.reject(error) } ) export default request调用方式// api/studentScore.js import request from ./request export const getScores () request.get(/StudentScore) export const createScore (data) request.post(/StudentScore, data) export const updateScore (id, data) request.put(/StudentScore/${id}, data) export const deleteScore (id) request.delete(/StudentScore/${id})这里有几个细节第一baseURL: /api表示所有请求走同源路径需要配合后端配置CORS或使用反向代理如Vite代理否则前端跨域会非常痛苦。开发阶段我用的是Vite的server.proxy配置// vite.config.js export default { server: { proxy: { /api: { target: https://localhost:7136, changeOrigin: true, secure: false } } } }第二响应拦截器里做401统一处理是非常实用的套路。Token过期时任何接口都可能返回401如果每个页面都写一套处理逻辑维护成本翻倍统一在拦截器里踢回登录页是最省事的方案。5.2 文件下载如何保持文件名不变我做的项目里有个导出成绩单的功能后端返回的是文件流前端要触发下载同时保持服务端指定的文件名不变。这个问题看似简单但实践中很多前端新手会栽在中文文件名乱码上。后端的下载接口这样写[Authorize] [HttpGet(export)] public async TaskIActionResult ExportScores() { var scores await _context.StudentScores.ToListAsync(); var csv GenerateCsv(scores); var bytes Encoding.UTF8.GetBytes(csv); var fileName $学生成绩表_{DateTime.Now:yyyyMMdd}.csv; return File(bytes, text/csv, fileName); }前端用blob接收并触发下载同时处理文件名export const exportScores () { return request.get(/StudentScore/export, { responseType: blob }) } const handleExport async () { const res await exportScores() const disposition res.headers[content-disposition] // 从 content-disposition 中解析出文件名 let fileName export.csv if (disposition) { const utf8Match disposition.match(/filename\*UTF-8([^;])/) const fallbackMatch disposition.match(/filename?([^])?/) if (utf8Match) { fileName decodeURIComponent(utf8Match[1]) } else if (fallbackMatch) { fileName fallbackMatch[1] } } const blob new Blob([res.data]) const url window.URL.createObjectURL(blob) const link document.createElement(a) link.href url link.setAttribute(download, fileName) document.body.appendChild(link) link.click() link.remove() window.URL.revokeObjectURL(url) }关键点在于文件名解析。后端返回时设置了Content-Disposition头浏览器支持下filename*参数用于UTF-8编码的文件名。如果你用axios的responseType: blob响应头里其实是可以拿到content-disposition的只不过需要后端在CORS配置里显式暴露这个头否则前端读不到。后端CORS配置里加上builder.Services.AddCors(options { options.AddPolicy(AllowFrontend, policy { policy.WithOrigins(http://localhost:5173) .AllowAnyHeader() .AllowAnyMethod() .WithExposedHeaders(Content-Disposition); }); });这个.WithExposedHeaders(Content-Disposition)少写了前端就永远取不到文件名默认只能下载成uuid串。5.3 JWT的过期处理与Token续签方案Token过期是JWT使用中绕不开的问题。我在项目里遇到过用户正填着表单突然Token过期请求返回401表单数据全白填了。体验很差用户会直接骂人。几种常见的解决方案方案一前端定时刷新Token。在Token即将过期时比如还剩5分钟用旧的Token调一个刷新接口换新的Token。这种方案实现简单但需要后端额外提供刷新接口且无法解决用户长时间不操作、Token已经过期的问题。方案二使用Refresh Token刷新令牌。登录时同时发放Access Token短时效比如30分钟和Refresh Token长时效比如7天。Access Token过期后前端带Refresh Token去换取新的Access Token。这个方案更安全也更常被用在生产系统里。实现思路在数据库里存一张RefreshToken表字段包括Token、UserId、ExpiresAt、IsRevoked登录时同时生成两个TokenRefreshToken存入数据库增加/api/auth/refresh接口校验RefreshToken有效且未过期后签发新的AccessToken方案三让Token永不过期用Redis记录黑名单。在JWT上做文章每次登出或重置密码时把Token加入黑名单。这在超大用户规模的系统里管理复杂度高不建议小项目搞。对小项目我个人的建议是直接采用方案二的简化版AccessToken有效期设短一点RefreshToken设长一点定期轮换。下面给出核心代码// AuthController 中增加刷新接口 [AllowAnonymous] [HttpPost(refresh)] public async TaskIActionResult Refresh(RefreshRequest request) { var principal JwtHelper.ValidateToken(request.RefreshToken, _config); if (principal null) { return Unauthorized(new { message 刷新令牌无效 }); } var userName principal.Identity?.Name; var newToken JwtHelper.GenerateToken(1, userName, Admin, _config); return Ok(new { token newToken, expiresIn 3600 }); }前端定时检查Token有效期到期前主动刷新配合axios拦截器在收到401时自动尝试刷新一次。核心逻辑不复杂关键是把刷新逻辑封装好别让业务代码各处散落。6. 常见问题与调试技巧实录6.1 SQL Server连接失败报错汇总我统计了一下自己做这个项目时遇到的数据库相关报错整理成了一张排查表报错信息原因与解决方案“无法连接到服务器”先确认SQL Server服务是否在运行Windows服务里检查“SQL Server (MSSQLSERVER)”状态连接字符串里的Server是否写对默认实例写localhost或小数点“用户sa登录失败”安装时没开启混合模式或密码不对。用Windows身份验证登录SSMS在安全性→登录名→sa里改密码并启用“证书链是由不受信任的颁发机构颁发的”连接字符串缺TrustServerCertificateTrue加上即可“数据库插入失败”检查目标表名是否存在EF Core映射是否正确仔细看Exception里的InnerException真实错误信息往往藏在里面“无法打开密钥/注册表项损坏”SQL Server配置管理器里的服务账号权限问题用管理员身份运行SSMS重新配置服务账号6.2 JWT相关的401和403问题JWT报401和403是两类完全不同的错误。401表示认证失败意思是“我不知道你是谁”403表示授权不足意思是“我知道你是谁但你没权限干这件事”。常见的401原因Token过期了exp字段时间已过Token签名不对Key不一致Token格式不对比如前端没有加Bearer前缀请求头名称写错必须是Authorization常见的403原因[Authorize(Roles Admin)]和Token里的角色Claim对不上[Authorize]和[AllowAnonymous]用的位置不对调试JWT有一个特别好用的工具叫“jwt在线解析”把Token粘进去直接能看到Header、Payload、签名以及每个Claim的值。后端报401的时候我第一件事就是打开这个工具看exp字段是不是已经过期了以及iss和aud是否和后端配置的一致。6.3 SQL Server执行计划怎么看这个技能说起来有点跑题但凡是做后端开发的迟早得面对“这条SQL为什么这么慢”的灵魂拷问。在SSMS里选中SQL语句按Ctrl L或点击“显示估计的执行计划”按钮就能看到SQL Server给出的执行计划图。关键看三个东西表扫描Table Scan/聚集索引扫描Clustered Index Scan说明这条语句没有走索引数据量大时性能很差键查找Key Lookup索引不够覆盖查询字段每次都要回表取数据预估行数 vs 实际行数如果误差巨大说明统计信息过期了我们这张StudentScore表如果在StudentNo字段上建索引CREATE INDEX IX_StudentScore_StudentNo ON StudentScore(StudentNo);那么按学号查询就会变成索引查找Index Seek性能比全表扫描好一个数量级。生产环境建索引时要注意索引不是越多越好每个索引都会拖慢写入速度需要权衡。6.4 几个容易被忽视的小坑日期序列化问题。.NET6 WebAPI默认使用System.Text.Json对DateTime序列化后的格式是2025-01-15T10:30:00带个T字母。前端new Date(2025-01-15T10:30:00)能正常解析但如果你直接展示字符串看着就别扭。解决办法是在注册控制器时配置中文日期格式builder.Services.AddControllers() .AddJsonOptions(options { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); });或者在实体上配置[JsonConverter(typeof(DateFormatConverter))]自定义输出格式。如果是前端Vue项目也可以在dayjs/moment里做格式化选择哪个方案取决于你的项目规范。文件下载文件名乱码。之前在5.2里提过后端返回中文文件名时建议显式设置Content-Disposition前端优先解析filename*。我的经验是后端编码用System.Net.WebUtility.UrlEncode先处理一下文件名到了前端再decodeURIComponent能在大部分浏览器上保持良好的兼容性。发布时的坑。本地调试正常发布到Windows服务器后接口全挂大概率是服务器没装ASP.NET Core运行时。发布时选择“框架依赖”模式的话服务器必须装对应版本的.NET运行时选“自包含”模式则不需要但发布包体积大几十MB。个人项目我建议用自包含模式省心。7. 收尾一些实际经验之谈一路做到这里这个项目的完整链路已经跑通了SQL Server存储数据.NET6 WebAPI提供增删改查接口JWT保护接口安全Vue前端对接调用。整套东西单独拆开看都不难但组合在一起涉及的知识点密度其实很高。我最大的感受是很多人学这个项目容易陷入两个极端要么只跑通代码不管原理要么只啃理论不落地。JWT的签名机制、EF Core的变更追踪、SQL Server的索引策略这些都是可以深挖的点但第一遍做时先把链路跑通再回头逐个知识点打深学习效率最高。如果你是第一次接触这套组合我的建议是先不要纠结太多设计模式照着上面代码把项目跑起来用Postman调一遍各个接口然后在Swagger里体验一下带Token请求和匿名请求的区别接着用Vue写个极简页面把增删改查串起来最后想办法把Token续签的逻辑加上你的实战能力就和小白拉开差距了。最后分享一个调试小技巧开发时Program.cs里的app.UseSwaggerUI()不要关哪怕你的前端已经调通了。Swagger不只是接口文档工具它其实是调试后端接口最快的一条路径发请求、看Token、查返回结构都在页面上完成比Postman免去配置的繁琐比浏览器开发者工具更直观。项目走到哪一步出了问题打开Swagger先排除后端再查前端能省下大量时间。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门