.NET6+WebAPI+SqlServer+JWT实现学生成绩管理实战

发布时间:2026/9/7 4:04:23
.NET6+WebAPI+SqlServer+JWT实现学生成绩管理实战 简介这是一个基于.NET 6的ASP.NET Core Web API项目以SQL Server为数据库并结合JWT实现身份验证演示对数据记录进行增删改查的完整流程。面向正在学习.NET 6与Web API开发的初中级开发者可通过该项目了解如何设计规范的RESTful接口借助Swagger生成交互式文档并掌握基于JWT的登录鉴权流程。压缩包共665个文件约34.81MB内含大量C#源码、编译生成的DLL与PDB、JSON配置文件和数据库备份并包含解决方案与项目工程文件便于直接打开解决方案查看层级结构和调试运行。已有1059人浏览学习。整个项目内容较为完整覆盖数据库迁移、异常处理、日志记录与安全实践等关键点可帮助读者将前后端联调与身份认证串联起来适合作为实战练习或课程设计的参考也适合在现有框架上扩展新的业务模块。 做企业内部系统、外包管理项目这两年我接手最多的需求就是“.NET6 WebAPI SqlServer JWT做个增删改查”。听着平平无奇可真要把这套组合从登录鉴权到建表接口完整跑通每一步都有值得记录的细节。这篇文章我用一套“学生成绩管理”的接口项目当载体从环境搭建、JWT闭环到SqlServer的增删改查再到前端Vue联调、Blob下载文件名这些配套问题把完整链路走一遍顺手把踩过的坑和解决办法都掏出来。1. 为什么我坚持推这套组合.NET6 WebAPI SqlServer JWT先给结论如果你的系统不属于“高并发互联网应用”而是企业内部的管理后台、教务系统、进销存这类业务系统这套组合是性价比最高的方案之一。为什么选.NET6而不是.NET Framework或者.NET Core 3.1.NET6是微软的LTS长期支持版本官方支持周期长生态成熟性能和语法体验比Framework时代提升了一个档次。为什么数据库选SqlServer而不是MySQL或PostgreSQL多数企业内部数据库早就在跑SqlServer你是去对接现有数据体系不是去说服企业换库。JWT则是前后端分离项目里做无状态认证的主流方案后端不需要存Session前端把Token放在请求头里即可天然适合接口服务。标题里四个关键词实际对应四件事.NET6语言与运行框架基础WebAPI对外提供HTTP接口的形态SqlServer业务数据持久化存储JWT接口身份认证与授权控制增删改查是项目的主线业务也是所有管理系统的最低公共要求。这套组合适合谁参考刚接触.NET想做一个完整项目练手的新人或者接了外包单需要快速交付可复用脚手架的服务端开发者都可以把本文当一个起点。但我也要先泼一盆冷水增删改查本身不难真正考验技术功底的是认证配置、边界情况、前后端联调这些“周边工程”它们才是实际研发中耗时的大头。2. 从零搭项目环境、依赖和第一个能跑的接口2.1 开发环境与版本选择我的常用开发环境是Visual Studio 2022 .NET 6 SDK SqlServer 2019SqlServer 2022也完全兼容连接串差异不大。如果你不想用VS命令行方式也顺手dotnet new webapi -n StudentManager.Api cd StudentManager.Api code .用VS2022创建项目时选择ASP.NET Core Web API模板框架选.NET 6.0。模板默认带一个演示用控制器和Swagger配置我习惯直接清理掉换成实际业务的控制器免得后面混淆。2.2 引入三个核心NuGet包dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer三个包分别解决三件事EF Core操作SqlServer的驱动、数据库迁移命令工具、JWT Bearer认证中间件。如果你不想用EF Core用SqlSugar或Dapper也行但我建议新手先掌握EF Core它是微软官方ORM和WebAPI结合度最高自动迁移能省掉大量手写建表SQL的时间。2.3 SqlServer表设计与连接串既然是学生成绩管理先建两张表用户表用于登录学生表用于增删改查。CREATE TABLE [User] ( Id INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, PasswordHash NVARCHAR(200) NOT NULL, Role NVARCHAR(20) NOT NULL DEFAULT User ); CREATE TABLE Student ( Id INT IDENTITY(1,1) PRIMARY KEY, StudentNo NVARCHAR(20) NOT NULL, Name NVARCHAR(50) NOT NULL, Major NVARCHAR(50) NULL, Score INT NOT NULL DEFAULT 0, CreateTime DATETIME2 NOT NULL DEFAULT GETDATE() );字段命名上我建议直接在SqlServer里用PascalCase和C#实体类一一对应。很多新手纠结数据库字段到底用student_name还是StudentName用EF Core的情况下PascalCase最省事省去大量列映射配置。连接串写在appsettings.json里ConnectionStrings: { Default: Serverlocalhost;DatabaseStudentManager;User Idsa;Password你的密码;TrustServerCertificatetrue; }这里有个高频坑TrustServerCertificatetrue必须加。SqlServer 2019以上版本默认使用加密连接本地开发时证书通常不受信任不加这个参数会报一长串加密证书相关的错误新手容易卡在这。2.4 第一个能跑的接口Program.cs按最小API风格配置var builder WebApplication.CreateBuilder(args); 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();F5跑起来浏览器打开/swagger能看到Swagger页面就说明骨架没问题。从零到这一步通常十分钟内能搞定。3. JWT认证从签发到鉴权的一整套闭环3.1 JWT的结构与工作原理先把原理说透。JWT全称JSON Web Token由三部分组成Header、Payload、Signature形式是xxxxx.yyyyy.zzzzz。Header声明令牌类型和签名算法形如{alg:HS256,typ:JWT}。Payload存放用户声明比如用户ID、用户名、角色以及过期时间exp。Signature服务端用密钥对Header和Payload做HMAC-SHA256签名防止Token被篡改。服务端签发Token后不存Session前端每次请求把Token放在Authorization: Bearer xxx请求头里服务端验签通过就信任这个用户。这就是“无状态认证”的核心优势不需要Session表横向扩展时也不用考虑Session共享特别适合部署在负载均衡后面的服务。对应到项目里用户登录后拿到Token后续所有受保护接口必须带Token才能访问。3.2 登录接口与Token签发先定义登录模型public class LoginModel { public string UserName { get; set; } public string Password { get; set; } }登录控制器[ApiController] [Route(api/[controller])] public class AuthController : ControllerBase { private readonly IConfiguration _config; public AuthController(IConfiguration config) { _config config; } [HttpPost(login)] [AllowAnonymous] public IActionResult Login([FromBody] LoginModel model) { if (model.UserName admin model.Password 123456) { var token GenerateToken(model.UserName); return Ok(new { Token token, ExpiresIn 7200 }); } return Unauthorized(new { Message 用户名或密码错误 }); } private string GenerateToken(string userName) { var claims new[] { new Claim(ClaimTypes.Name, userName), new Claim(ClaimTypes.Role, Admin) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config[Jwt:Key])); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.Now.AddMinutes(120), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } }真实项目中密码绝不能明文存储至少要用BCrypt或PasswordHasher做哈希再从数据库查用户校验。这里为了聚焦JWT流程先写死账号演示。3.3 认证中间件配置与常见误区Program.cs中配置JWT验证builder.Services.AddAuthentication(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])) }; options.MapInboundClaims false; });管道调用顺序也要写对app.UseAuthentication(); app.UseAuthorization();我见过最多的问题是配置了认证中间件但忘了调app.UseAuthentication()导致[Authorize]特性完全无效或者UseAuthentication写在UseAuthorization后面授权永远不通过。API管道的中间件顺序非常敏感这俩的顺序是硬性要求。另一个容易踩的坑是User.Identity.Name取到null。原因是JWT里的Claim类型默认会被映射成XML命名空间的长地址解决方法是加options.MapInboundClaims false这样控制器里取User.Identity.Name就正常了。JWT密钥也有硬性要求Jwt:Key长度至少32字符否则HMAC-SHA256直接抛异常这是.NET 6下很经典的一个报错。3.4 Token续签的轻量方案很多后台系统要求用户长时间在线但JWT不到期无法主动作废所以续签是刚需。我的做法是提供刷新接口[HttpPost(refresh)] [Authorize] public IActionResult Refresh() { var userName User.Identity?.Name; var newToken GenerateToken(userName); return Ok(new { Token newToken }); }前端在Token过期前主动调一次刷新接口换新Token后重放原请求。这个方案不算完美但足够轻量适合绝大多数管理系统。4. SqlServer增删改查EF Core这么写才顺手4.1 实体与DbContextC#实体类public class Student { public int Id { get; set; } public string StudentNo { get; set; } public string Name { get; set; } public string Major { get; set; } public int Score { get; set; } public DateTime CreateTime { get; set; } }DbContextpublic class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetStudent Students { get; set; } }Program.cs注册builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default)));4.2 查询接口分页加关键词搜索实际项目里的“查”几乎不会是select all一定是分页加条件筛选。我写一个能直接用的版本[HttpGet] [Authorize] public async TaskIActionResult GetStudents(int page 1, int pageSize 10, string keyword null) { var query _db.Students.AsQueryable(); if (!string.IsNullOrEmpty(keyword)) { // 关键词可能是姓名、学号也可能是纯数字的分数 if (int.TryParse(keyword, out int score)) { query query.Where(s s.Score score); } else { query query.Where(s s.Name.Contains(keyword) || s.StudentNo.Contains(keyword)); } } var total await query.CountAsync(); var list await query.OrderByDescending(s s.CreateTime) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return Ok(new { total, list }); }这里特意用了int.TryParse处理“SqlServer字符串转数字”的场景。如果前端传了非数字的keyword直接int.Parse会让接口500崩溃用TryParse把纯数字关键词映射到“按分数查询”否则走“姓名/学号模糊匹配”这是查询接口非常实用的处理方式。4.3 新增、修改、删除新增[HttpPost] [Authorize] public async TaskIActionResult CreateStudent([FromBody] Student student) { if (string.IsNullOrEmpty(student.StudentNo) || string.IsNullOrEmpty(student.Name)) { return BadRequest(new { Message 学号和姓名不能为空 }); } student.CreateTime DateTime.Now; _db.Students.Add(student); await _db.SaveChangesAsync(); return Ok(new { Message 添加成功, Data student }); }修改[HttpPut({id})] [Authorize] public async TaskIActionResult UpdateStudent(int id, [FromBody] Student student) { var existing await _db.Students.FindAsync(id); if (existing null) { return NotFound(new { Message 记录不存在 }); } existing.StudentNo student.StudentNo; existing.Name student.Name; existing.Major student.Major; existing.Score student.Score; await _db.SaveChangesAsync(); return Ok(new { Message 修改成功 }); }删除[HttpDelete({id})] [Authorize] public async TaskIActionResult DeleteStudent(int id) { var student await _db.Students.FindAsync(id); if (student null) { return NotFound(new { Message 记录不存在 }); } _db.Students.Remove(student); await _db.SaveChangesAsync(); return Ok(new { Message 删除成功 }); }修改操作这里要特别强调很多人图省事直接把前端传进来的student对象调Update塞给DbContext。这会带来很严重的隐患——前端漏传的字段会被清空或覆盖。正确做法就是先FindAsync查出原实体再手动逐个字段赋值让EF Core只跟踪需要更新的属性。4.4 SqlServer高频异常排查写增删改查几乎不可能不碰到“数据库插入失败”这类报错。最常见的几种连接串配错或服务没启动报“在与SqlServer建立连接时出现与网络相关的或特定于实例的错误”先去Windows服务里确认SqlServer服务是否运行。唯一键冲突学生表里StudentNo重复导致插入失败建表时就应该加唯一索引或者在业务代码里先做查重。外键关联删除删除父表数据被外键约束挡住要看业务上是该级联删除还是先清理子表。类型转换失败int列传入字符串‘abc’时SqlServer会报转换错误所以查询前先做类型判断接口参数统一用TryParse或正则校验。5. 控制器层与统一返回格式给前端一个舒服的交互5.1 统一返回格式接口如果各自返回结构前后端联调会非常痛苦。我习惯在项目里约定这套统一格式public class ApiResultT { public int Code { get; set; } public string Message { get; set; } public T Data { get; set; } public static ApiResultT Success(T data, string message 成功) { return new ApiResultT { Code 0, Message message, Data data }; } public static ApiResultT Fail(string message, int code 1) { return new ApiResultT { Code code, Message message }; } }接口返回时统一包一层return Ok(ApiResultStudent.Success(student));前端判断Code是否等于0再取Data逻辑完全统一后续再扩展分页、错误码也方便。5.2 接口路径设计心得增删改查接口我建议遵循RESTful风格方法路径用途GET/api/students?page1pageSize10分页查询GET/api/students/{id}查询单条POST/api/students新增PUT/api/students/{id}修改DELETE/api/students/{id}删除RESTful语义清晰前后端沟通成本极低而且Swagger能看到完整结构前端可以自己试接口。5.3 Swagger调试技巧调试阶段我用Postman比较多先调login接口拿Token再在Headers里加Authorization: Bearer token测试受保护接口。Swagger也支持认证需要额外配置builder.Services.AddSwaggerGen(c { c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Description 请输入Token格式bearer 你的token, Name Authorization, In ParameterLocation.Header, Type SecuritySchemeType.Http, Scheme bearer }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, new Liststring() } }); });不加这个配置Swagger页面没有任何地方可以输入Token受保护接口永远401你可能会误以为接口坏了实际只是测试工具没配好。这是非常典型的误判场景。6. 前端联调、文件下载与那些“隐藏的坑”6.1 Vue前端调用姿势后端接口接到Vue前端非常顺。用axios封装请求const service axios.create({ baseURL: https://localhost:7020/api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })请求拦截器统一带上Token后端通过JWT验证身份这是前后端分离项目最常见的认证通信方式。6.2 Blob下载与文件名保持管理系统里另一个高频需求是“下载文件”热搜里“如何保持文件名不变blob”问的就是这个。用blob方式下载时如果不处理响应头Content-Disposition下载下来的文件名会变成乱码或默认名。后端下载接口在响应头返回文件名var fileBytes await System.IO.File.ReadAllBytesAsync(filePath); var fileName 期末成绩表.xlsx; Response.Headers.Add(Content-Disposition, $attachment; filename{Uri.EscapeDataString(fileName)}); return File(fileBytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet);前端解析响应头拿到原始文件名const res await service.get(/students/export, { responseType: blob }) const disposition res.headers[content-disposition] let filename download.xlsx if (disposition disposition.includes(filename)) { filename disposition.split(filename)[1].replace(//g, ) } const blobUrl URL.createObjectURL(res.data) const link document.createElement(a) link.href blobUrl link.download decodeURIComponent(filename) link.click() URL.revokeObjectURL(blobUrl)这里最隐蔽的坑是两个一是前端必须设置responseType: blob否则拿到的不是二进制流而是文本二是Content-Disposition里的filename要URL编码否则中文文件名会乱码。所以后端用Uri.EscapeDataString编码前端用decodeURIComponent解码两边匹配好文件名才能原样保存。6.3 跨域配置前后端分离项目另一个高频坑是CORS跨域。开发环境后端是localhost:7020前端是localhost:5173端口不同就触发跨域。后端Program.cs配置builder.Services.AddCors(options { options.AddPolicy(AllowClient, policy { policy.WithOrigins(http://localhost:5173) .AllowAnyMethod() .AllowAnyHeader(); }); }); app.UseCors(AllowClient);生产环境不要用AllowAnyOrigin把前端真实域名写死更安全。UseCors的位置放在UseRouting之后、UseAuthorization之前顺序错了CORS策略不会生效。6.4 登录态保持与Token自动续期的前端实现第3.4节的refresh接口前端配合方式是在axios响应拦截器里加一个重试逻辑service.interceptors.response.use( response response, async error { const originalRequest error.config if (error.response.status 401 !originalRequest._retry) { originalRequest._retry true const res await service.post(/auth/refresh) localStorage.setItem(token, res.data.data.token) originalRequest.headers.Authorization Bearer ${res.data.data.token} return service(originalRequest) } return Promise.reject(error) } )用户长时间停在页面不操作Token过期后下次请求自动换新Token并重放原请求不需要强制重新登录。注意_retry标记必须加否则401会无限递归刷新接口把请求打爆。最后再说几句踩坑心得整套项目走下来最大的体会是增删改查永远只占工作量的小头大头全在认证、配置、边界条件和前后端联调这些“配套工程”上。很多新手写完CRUD就以为完事了到了实际交付时前端调不通、Token过期不续、下载文件名乱码、跨域被拦截这些才是最耗时间的部分。我建议你把上面这些代码整理成一套自己的模板项目包含统一返回格式、JWT签发与校验、EF Core基础的增删改查、Blob下载示例、前端拦截器这几个模块。这个模板我已经在不下十个内部管理项目中复用稳定性和性价比都相当高。新需求来了直接拿模板改业务实体和字段核心骨架基本不用再动。本文还有配套的精品资源点击获取