asp.net core webapi+react.js
(1)jwt
流程
客户端:发送登录请求->获取用户信息,accesstoken(携带用户id,有效时长等信息),refreshtoken
服务端: 响应请求->返回用户信息,accesstoken,将refreshtoken存储在cookie中(HttpOnly)
客户端: 使用Bearer accesstoken发送请求,若accesstoken失效(服务端返回401),使用refreshtoken获取accesstoken,重新发起请求,仍失败通过登录重复流程。
问题1:
可能同时有多个接口发起请求(比如某些定时任务,如获取用户信息,权限菜单等),所有需要JWT验证的接口都会返回401,token失效重复请求更新token,
(
1. 页面同时发出请求 A、B、C
2. 此时 accessToken 已过期,A、B、C 全部返回 401
3. 如果每个请求都自己发 /auth/refresh,会造成:
❌ 多次刷新,浪费资源,可能还冲突:token覆盖
)
解决方案:多个请求同时返回 401 时只刷新一次 token,所有失败的请求在 token 刷新成功后自动重试
请求A、B、C发送时:
↓
accessToken 失效 → 返回 401 → 进入 response 拦截器
↓
A:发起 /auth/refresh 请求(isRefreshing = true)
B、C:等待,不刷新,注册到 refreshSubscribers
↓
refresh 成功 → 调用 onRefreshed(token)
↓
A、B、C 自动重发请求,使用新的 token
// auth.js
import axios from 'axios';
let isRefreshing = false;
let refreshSubscribers = [];
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}
function onRefreshed(token) {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
}
// 创建 axios 实例(不绑定 token)
const api = axios.create({
baseURL: '/api'
});
// 请求拦截器:每次请求前注入 accessToken
api.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:统一处理 401
api.interceptors.response.use(
res => res,
async error => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
!originalRequest._retry
) {
originalRequest._retry = true;
if (isRefreshing) {
// 正在刷新 token,等它完成再重试原请求
return new Promise(resolve => {
subscribeTokenRefresh(newToken => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest));
});
});
}
isRefreshing = true;
try {
// 使用 withCredentials 来自动携带 refreshToken Cookie
const response = await axios.post('/api/auth/refresh', {}, {
withCredentials: true
});
const newToken = response.data.accessToken;
// 存储新 token,并更新请求头
localStorage.setItem('accessToken', newToken);
onRefreshed(newToken);
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (err) {
localStorage.clear();
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
(2)用户信息和菜单刷新
首次加载拉取用户信息和菜单,之后根据 Token 的过期时间自动刷新用户信息,不使用固定间隔轮询。
| 功能 | 实现方式 |
|---|---|
| 登录后保存 accessToken + 过期时间 | localStorage |
| 初始化加载菜单和用户信息 | useEffect 中调用接口 |
| 提前 1 分钟自动刷新 token | setTimeout 动态设置 |
| 只刷新一次 | 避免轮询,高性能 |
1. 登录成功后保存 token 和过期时间
📦 2. 创建初始化组件 AppInitializer.js
// AppInitializer.js import { useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { fetchUserInfo, fetchMenu, refreshToken } from './store/actions'; // 按你的项目路径调整 const AppInitializer = () => { const dispatch = useDispatch(); useEffect(() => { // 初始加载 dispatch(fetchUserInfo()); dispatch(fetchMenu()); // 获取 accessToken 过期时间 const expiresAtStr = localStorage.getItem("expiresAt"); const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : 0; const now = Date.now(); // 提前 1 分钟刷新 const timeout = expiresAt - now - 60 * 1000; if (timeout > 0) { const timer = setTimeout(() => { dispatch(refreshToken()) .then(() => { // 刷新用户数据和菜单 dispatch(fetchUserInfo()); dispatch(fetchMenu()); }); }, timeout); return () => clearTimeout(timer); } }, [dispatch]); return null; // 不渲染任何 UI }; export default AppInitializer;
📦 3. 在 App.js 中使用一次即可
📦 4. refreshToken action 示例(可改造)
✅ 补充说明
| 项目 | 说明 |
|---|---|
withCredentials: true |
如果 refresh token 保存在 HttpOnly Cookie 中,则必须开启 |
| 只刷新一次 | setTimeout 只设置一次,不轮询,不浪费性能 |
| 自动续期 |
遇到的问题:前端:http://localhost:3000 , 后端: https://localhost:7026 后端refreshtoken放在cookie中返回给前端(前端接收不到),前端发送时携带给后端(前端确认获取到cookie后,后端接收不到)
a.后端返回的cookie前端不保存
-
前端请求需要携带
withCredentials: true
即使服务端写了 Set-Cookie,浏览器默认 跨域请求不会保存 cookie。
Axios 登录请求示例:
如果没有这个选项,浏览器会忽略 Set-Cookie。
-
后端 CORS 配置允许 credentials
ASP.NET Core 必须同时指定前端 origin 并允许 credentials:
(3)Redux Thunk 异步 action中请求异常错误处理-抛出异常
可以直接用 try/catch 包裹请求逻辑,然后在 catch 或失败分支里抛出错误给调用方(比如表单提交 onFinish 里的 await dispatch(fetchLogin(values)))。这样 UI 层就能拿到失败信息并用 message.error() 提示。
fetchLogin 改造:
const fetchLogin = (loginform) => {
return async (dispatch) => {
try {
const payload = {
logintype: 'sms',
data: loginform
};
const res = await loginAPI(payload);
// 后端接口约定成功与否
if (res.code !== 0) {
// 这里根据你的接口字段改,比如 code 0 表示成功
throw new Error(res.message || '登录失败');
}
// 成功时存 token 和菜单
dispatch(setToken(res.data.token));
dispatch(setMenu(res.data.menus));
return true; // 表示登录成功
} catch (error) {
// 统一抛给调用方
throw error;
}
};
};
UI 层 onFinish 改造
const [authLoading, setAuthLoading] = useState(false); const onFinish = async (values) => { if (!agree) { message.warning('请同意服务条款'); return; } setAuthLoading(true); try { await dispatch(fetchLogin(values)); navigator('/'); message.success('登录成功'); } catch (error) { message.error(error.message || '登录失败,请检查账号或验证码'); } finally { setAuthLoading(false); } };
在这种 React + Redux Thunk 登录场景里,抛出异常算是非常常规、推荐的做法。
尤其是在需要UI 层感知异步失败的时候,直接 throw 是最直观的。
为什么抛异常是常规做法
-
UI 层好捕获
-
你在
onFinish里await dispatch(fetchLogin(values)),如果fetchLogin内throw,外层就能直接用try/catch捕获,不用解析返回值。 -
这样 UI 层的错误处理逻辑(
message.error()、恢复按钮状态等)很自然。
-
-
避免业务和网络错误混淆
-
抛异常不仅可以捕获 网络错误(
axios报错),还可以捕获 业务错误(后端返回code != 0)。 -
统一走
catch,不需要额外判断多层返回值。
-
-
便于链式调用
-
如果未来某个页面需要
fetchLogin后做更多逻辑(比如获取用户信息),异常会直接中断链式调用,防止错误情况下继续执行。
-
常见的 3 种处理方式对比
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 抛异常(推荐) | throw new Error(msg) |
UI 捕获简单、语义清晰、支持 try/catch 链式中断 |
要记得 catch 防止未处理异常 |
| 返回布尔值 | return false / return true |
UI 层不需要 try/catch |
需要每次手动判断 if (!res) {},容易漏 |
| 返回统一对象 | { success, message } |
接口规范化 | 调用方代码啰嗦,错误无法自然中断 |
💡 总结
-
在 Redux Thunk 里,抛异常 + try/catch 处理 是很主流的方式,尤其是在登录、注册、支付等需要 UI 明确反馈的功能。
-
如果团队有统一的
errorBoundary或全局错误捕获,还能直接复用这些异常处理机制。
(4)请求或提交按钮防重点机制+节流
方案一:Hook 封装(推荐灵活度高)
核心思路:
-
接收一个回调函数
fn和等待时间delay -
内部维护一个「锁」或「时间戳」,在一定时间内阻止重复执行
-
返回一个包装后的新函数,直接替代原来的事件回调
使用示例(登录按钮):
特点:
-
可以用在任何异步/同步事件上
-
节流与防多次点击合并成一个逻辑
-
代码干净,不会到处写 loading 状态
方案二:组件封装(方便统一 UI + 逻辑)
适合全局规范化,比如你的后台系统里所有「提交按钮」都能自动防重复点击。
使用示例:
特点:
-
所有按钮默认都有防多次点击能力
-
可以统一 loading 样式
-
适合规范化 UI 组件库
方案选择建议
-
如果你希望 灵活调用、场景不固定 → 用 Hook
-
如果你希望 统一交互规范、所有按钮都有防重 → 用 SafeButton 组件
-
两个方案可以结合:
SafeButton内部也用useThrottleFn
SafeButton + useThrottleFn 组合版
特点:
-
SafeButton 自带 loading 状态
-
内部用 useThrottleFn 做节流+防多次点击(等待时间结束才能再次触发)
-
onClick 支持异步函数
-
支持传入等待时间 wait(默认 1000ms)
1. 通用节流 Hook useThrottleFn.js
// src/hooks/useThrottleFn.js
import { useRef, useCallback } from 'react';
export default function useThrottleFn(fn, wait = 1000) {
const lastCall = useRef(0);
const isRunning = useRef(false);
return useCallback(async (...args) => {
const now = Date.now();
if (isRunning.current) return;
if (now - lastCall.current < wait) return;
isRunning.current = true;
lastCall.current = now;
try {
await fn(...args);
} finally {
isRunning.current = false;
}
}, [fn, wait]);
}
2. SafeButton 组件 SafeButton.jsx
// src/components/SafeButton.jsx import React, { useState } from 'react'; import { Button } from 'antd'; import useThrottleFn from '@/hooks/useThrottleFn'; export default function SafeButton({ onClick, wait = 1000, children, ...rest }) { const [loading, setLoading] = useState(false); // 包装点击事件,异步执行时自动 loading const throttledClick = useThrottleFn(async (...args) => { setLoading(true); try { await onClick?.(...args); } finally { setLoading(false); } }, wait); return ( <Button {...rest} loading={loading} onClick={throttledClick}> {children} </Button> ); }
3. 使用示例
import React from 'react'; import SafeButton from '@/components/SafeButton'; function Demo() { const handleClick = async () => { console.log('点击触发'); await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟接口请求 console.log('接口请求完成'); }; return ( <SafeButton type="primary" wait={1500} onClick={handleClick}> 防重复点击按钮 </SafeButton> ); } export default Demo;
说明
-
你传给 SafeButton 的
onClick可以是同步或异步函数,SafeButton 会帮你自动管理 loading 状态。 -
等待时间内重复点击都会被忽略,避免重复请求。
-
这套组合可以直接用在登录按钮、提交订单按钮、导出按钮等任意需要防重的地方。
(5)Connection id "0HNERK58NDV2F", Request id "0HNERK58NDV2F:00000007": An unhandled exception was thrown by the application. System.InvalidOperationException: StatusCode cannot be set because the response has already started. at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ThrowResponseAlreadyStartedException(String value)
这个异常的核心原因是:响应已经开始写入(Response Started),但是代码又尝试去设置 StatusCode(比如 401/403),这在 ASP.NET Core 里是不允许的,所以抛了 InvalidOperationException。
从你的调用栈来看,这里是 JWT Bearer 中间件 在 HandleChallengeAsync 时尝试返回 401,但此时响应头或部分响应内容已经被写出。
典型触发场景
-
在响应已经开始后调用
Challenge()或Unauthorized()-
例如在 Controller/Filter 里写入了一部分数据(或 flush 了响应流)后,再触发认证失败。
-
-
中间件顺序错误
-
如果
UseAuthentication()/UseAuthorization()在UseSwagger()之后,Swagger 的响应可能已经写出,然后认证中间件还尝试拦截。
-
-
异常处理冲突
-
你的
ExceptionHandlerMiddleware(或全局异常捕获)在响应已部分发送后,试图写自定义错误响应。
-
解决思路
1. 检查中间件顺序(Startup / Program.cs)
JWT 认证和授权应该放在管道早期,Swagger 之后容易触发此问题:
如果 UseSwagger() 在认证前,就可能导致返回部分 HTML 后才发现权限不足。
(6)依赖注入:每张数据表都会有个Repository,还有service,都放在program.cs里面写依赖,很乱
builder.Services.AddSingleton<ILoggingService, LoggingService>(); builder.Services.AddScoped<IJwtService, JwtService>(); builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>(); builder.Services.AddScoped<IRefreshTokenService, RedisRefreshTokenService>(); builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserPermissionRepository, UserPermissionRepository>(); builder.Services.AddScoped<IMenuRepository, MenuRepository>(); builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<ISmsService, SmsService>(); builder.Services.AddScoped<IMenuService, MenuService>(); builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IPermissionService, PermissionService>();
优化改进方案:
1️⃣ 按模块封装依赖注入
可以给每个模块(如用户模块、权限模块)写一个静态扩展方法,统一注册这个模块的所有 Service/Repository。
示例:User 模块
然后在 Program.cs 里只需要:
2️⃣ 使用泛型 Repository + 通用服务
如果你是标准 CRUD,很多 Repository 的实现都是类似的,可以写 通用接口和实现:
3️⃣ 自动扫描程序集注册依赖
如果你的 Service/Repository 命名规范统一(如 XxxService, XxxRepository),可以用反射 自动扫描并注册:
var assembly = AppDomain.CurrentDomain.GetAssemblies() .First(a => a.GetName().Name == "LiaoLiao.Application"); foreach (var type in assembly.GetTypes()) { if (type.Name.EndsWith("Service")) { var interfaceType = type.GetInterfaces().FirstOrDefault(); if (interfaceType != null) builder.Services.AddScoped(interfaceType, type); } else if (type.Name.EndsWith("Repository")) { var interfaceType = type.GetInterfaces().FirstOrDefault(); if (interfaceType != null) builder.Services.AddScoped(interfaceType, type); } }
这样以后新增 Service/Repository 就不用修改 Program.cs 了。
(7)多端登录及权限控制
用户ID+设备ID->RefreshToken(用户ID和设备ID对应一个refreshtoken,即refreshtoken存储时需要存储响应用户ID和设备ID)
1️⃣ Token 设计
a. AccessToken
-
短期有效(如 10–15 分钟)
-
包含:
-
用户 ID(
sub) -
用户角色或权限摘要(可选)
-
客户端标识(
client_id) -
签发时间、过期时间(
iat、exp)
-
-
不存数据库,依赖 JWT 签名验证即可
b. RefreshToken
-
长期有效(如 30 天)
-
存数据库,绑定:
-
UserId -
ClientId(区分不同设备/客户端) -
过期时间
-
是否撤销
-
-
用于刷新 AccessToken
2️⃣ 多客户端支持
-
每个客户端独立 RefreshToken
-
Web、iOS、Android、PC 等,每个客户端登录都生成新的 RefreshToken
-
数据库记录形式:
UserId + ClientId + RefreshToken + ExpireAt + IsRevoked
-
-
AccessToken 依赖 RefreshToken 刷新
-
客户端 AccessToken 到期 → 使用 RefreshToken 获取新的 AccessToken
-
可以选择更新 RefreshToken(提高安全性)
-
3️⃣ 权限变更处理
a. Role/Permission 版本控制(常用做法)
-
数据库在
User或Role表增加Version或UpdatedAt字段 -
AccessToken 中带
role_version或类似字段 -
每次请求时:
-
优点:AccessToken 无需立即失效,也能安全处理权限变更
b. RefreshToken 撤销
-
用户登出或被禁用 → 撤销对应客户端 RefreshToken
-
全端登出或权限全局变更 → 撤销该用户所有 RefreshToken
4️⃣ 安全细节(生产实践)
-
短期 AccessToken + 长期 RefreshToken:减小被盗风险
-
RefreshToken 绑定客户端:保证多客户端独立管理
-
RefreshToken 安全存储:
-
Web → HttpOnly Cookie
-
Mobile → Keychain / Keystore
-
-
RefreshToken 一次性使用(可选):刷新后立即替换,避免重复使用
-
AccessToken 不存数据库:减轻负载
-
Role/Permission 版本控制:确保权限变更可即时生效
(8)多客户端模式下,可能存在的重复提交和并发问题,如何解决,比如表单重复提交
像 订单重复提交 / 表单重复提交 / 支付重复提交 这类问题,本质上都是 幂等性问题,常规解决思路就是那几类方案,区别只是在于业务场景和成本取舍。
🔑 常见业务场景 & 对应解决方案
1. 下单、支付(最典型)
-
幂等性 Key(推荐):前端带一个唯一请求号,服务端只处理一次。
-
数据库唯一约束:订单号唯一,重复提交直接报错。
-
支付场景:通常使用“商户订单号”作为唯一标识,支付网关也会保证幂等。
👉 电商/支付系统 必用,属于行业标准做法。
2. 用户注册 / 账号操作
-
数据库唯一约束(邮箱/手机号唯一)。
-
幂等性校验(防止重复注册请求,避免脏数据)。
👉 一般直接用数据库唯一约束即可。
3. 提交表单(普通业务,如留言/评论)
-
前端按钮防重复点击(禁用按钮 / Loading 状态)。
-
后端幂等性校验(可选):比如用户 1 分钟内不能重复提交相同内容。
👉 成本低的业务通常只做前端防护。
4. 库存扣减 / 秒杀 / 抢购
-
分布式锁(Redis/etcd)保证扣减操作原子性。
UPDATE Product SET Stock = Stock - 1, Version = Version + 1 WHERE ProductId = @id AND Version = @version
var lockKey = $"lock:order:{userId}:{productId}"; if(await _redis.LockTakeAsync(lockKey, "1", TimeSpan.FromSeconds(5))) { try { /* 创建订单逻辑 */ } finally { await _redis.LockReleaseAsync(lockKey, "1"); } }
-
数据库行锁 + 乐观锁(UPDATE 时检查版本号/数量)。
👉 高频并发场景必须用锁,否则超卖。
5. 幂等性 API(RESTful 风格)
-
遵循 Idempotency Key 标准(如 Stripe API):
-
客户端请求时带
Idempotency-Key头 -
服务端根据这个 Key 做去重,保证一次请求只处理一次
-
👉 金融、支付、下单类 API 普遍采用。
🚀 总结
-
防重复提交 = 幂等性保证。
-
常规手段有:
-
幂等 Key(最通用,推荐 ✅)
-
数据库唯一约束(简单高效)
-
分布式锁(高并发场景)
-
前端防抖(用户体验层面)
-
-
实际项目一般会 多种手段结合,比如:
-
下单:幂等 Key + 唯一约束
-
秒杀:分布式锁 + 唯一约束
-
表单:前端防抖 + 简单防重复校验
-
(9)数据库层面的解决方案
行锁(悲观锁,Pessimistic Lock)
思想:先锁住,再操作。
适合并发很高、冲突概率大的场景,比如库存扣减。
在数据库里常见做法是:
然后在同一个事务里操作:
特点
-
事务 A 执行
SELECT ... FOR UPDATE时,事务 B 对同一行的读写都会被阻塞,直到事务 A 提交。 -
确保同一时间只有一个事务能修改这行数据。
-
缺点:高并发下会阻塞,吞吐量下降。
2️⃣ 乐观锁(Optimistic Lock)
思想:不加锁,先试着更新,如果发现别人动过,就失败重试。
适合冲突概率低,但要求高并发的场景。
做法:在表里加一个 Version 字段:
更新时带上 Version 条件:
执行结果:
-
如果
Version = 5,更新成功,说明没人改过。 -
如果
Version != 5,更新失败(受影响行数=0),说明已经被别人改了。
服务端可以检测到失败,再去 重新读取数据并重试。
示例代码(C# EF Core)
3️⃣ 行锁 vs 乐观锁 对比
| 特性 | 行锁(悲观锁) | 乐观锁(版本号) |
|---|---|---|
| 并发性能 | 低(阻塞) | 高(无阻塞) |
| 适用场景 | 高并发、高冲突(如库存秒杀) | 低冲突(如订单状态更新) |
| 实现方式 | SELECT ... FOR UPDATE |
WHERE Version = ? |
| 成本 | 占用数据库锁,可能死锁 | 需要多一次重试逻辑 |
4️⃣ 在订单防重复提交里的应用
比如「防止一个用户多次下单」:
-
行锁:
确保只有一个事务能操作这个订单。
-
乐观锁:
订单表里有个StatusVersion,更新时带条件:
如果返回影响行数=0,说明状态已经被别人改过 → 拒绝当前请求。
✅ 总结
-
悲观锁(行锁):直接数据库加锁,保证强一致,但并发下会阻塞。
-
乐观锁(版本号):靠条件更新 + 重试,适合高并发、冲突不频繁的场景。
-
在订单/库存场景里,一般会用 乐观锁 + 唯一约束,秒杀这种极端高并发才会加 分布式锁 + 行锁。
(10)权限管理->权限变更处理
1️⃣ 强制下线(更新 AccessToken / TokenVersion + 401)
优点:
-
权限立即生效
-
安全性高,用户无法继续使用旧权限
缺点:
-
用户体验差:突然被登出
-
对短时频繁操作的用户可能打断流程
2️⃣ 刷新目录(更新 Redis 缓存,保留现有 AccessToken,有效期内使用最新权限)
优点:
-
用户体验好,登录不中断
-
权限变更可动态生效
-
配合 JWT + 中间件可以即时更新访问权限
缺点:
-
AccessToken 仍然有效,如果业务要求绝对安全(比如财务操作),可能需要强制刷新
-
中间件和缓存逻辑稍复杂,需要保证 Redis 权限目录及时更新
✅ 实践建议
-
大部分业务:刷新目录即可,用户无感知,权限即时生效
-
高敏感业务(财务、交易等):可以结合刷新目录 + 强制 TokenVersion 更新 → 强制下线
可以把这个策略设计成 分级策略:
| 场景 | 动作 | 用户体验 |
|---|---|---|
| 普通权限变更 | 刷新权限目录(Redis) | 无感知,实时生效 |
| 高敏感权限变更 | 刷新目录 + 强制下线 | 被迫重新登录 |
a.刷新目录
1️⃣ 后端中间件逻辑
核心思路:
-
JWT 中只包含最小信息:
UserId+RoleId+TokenVersion(可选) -
Redis 缓存角色权限:Key=
role:{RoleId}:permissions,Value=["user:add","user:view",...] -
每次请求中间件校验:
-
获取用户
RoleId -
从 Redis 取最新权限列表
-
如果 Redis 缓存不存在 → 从数据库加载 → 写回 Redis
-
-
检查请求接口是否在权限列表中
-
没权限 → 返回 403 或自定义“无权限”
-
有权限 → 继续处理
-
-
2️⃣ 权限变更时刷新目录
-
后台修改角色权限 → 更新数据库
-
同步 刷新 Redis 缓存,或直接删除 Key,让下次请求重新加载
关键点:不需要前端定时刷新,用户下一次请求接口时,中间件会自动获取最新权限。
3️⃣ 用户点击无权限操作时
-
前端请求接口 → 中间件校验发现权限不足 → 返回 403
-
前端可提示“无权限”或“权限已更新,请刷新界面”
-
下次接口请求时,中间件会重新加载 Redis/数据库权限,保证权限动态生效
所以无需定时刷新,只要中间件校验每次请求,就能做到动态权限刷新。
总结:
-
刷新目录是 懒加载 + 缓存更新 模式
-
用户无需刷新页面,点击操作时触发中间件校验即可
-
Redis 保证性能,数据库做唯一可信源
b.权限修改实现
-
JWT 只带
sub(UserId) +roleId(可选带tokenVersion) -
每次请求由中间件用 HttpMethod + 路由模板 做权限判断
-
权限列表优先走 Redis 缓存,未命中则 读数据库并写回缓存
-
后台改动角色权限后,删除或更新 Redis,下次请求自动生效
-
前端无需定时刷新,用户点击即触发校验;无权限返回 403
1) 权限码约定
用 "{METHOD}:{TEMPLATE}" 作为权限码,例如:
-
GET:/api/users -
POST:/api/users -
DELETE:/api/users/{id}
关键点:使用路由模板(
/api/users/{id}),别用实际路径(/api/users/123),否则无法和数据库匹配。
2) EF 实体(简化单角色)
3) Redis 权限服务(懒加载 + 失效)
后台改动角色权限后,调用
InvalidateRoleAsync(roleId);不需要前端做任何事。
4) 中间件(基于模板自动判权)
这段中间件就是“刷新目录”的关键:每次请求都取最新的角色权限缓存,缓存失效时自动回源数据库。
5) 权限变更时刷新目录(后台管理调用)
在你的后台管理(比如“保存角色权限”接口)里,加上这句即可:
下次请求就会自动加载最新权限,无需用户重新登录、无需前端定时刷新。
6) Program.cs 注册
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using StackExchange.Redis; using System.Text; var builder = WebApplication.CreateBuilder(args); var cfg = builder.Configuration; // DbContext builder.Services.AddDbContext<AppDbContext>(opt => opt.UseSqlite(cfg.GetConnectionString("Default") ?? "Data Source=app.db")); // Redis builder.Services.AddSingleton<IConnectionMultiplexer>(sp => ConnectionMultiplexer.Connect(cfg.GetConnectionString("Redis") ?? "localhost:6379")); builder.Services.AddScoped<IPermissionCache, PermissionCache>(); // JWT(示例) var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:SigningKey"]!)); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateIssuerSigningKey = true, IssuerSigningKey = key, ClockSkew = TimeSpan.Zero }; }); builder.Services.AddAuthorization(); builder.Services.AddControllers(); var app = builder.Build(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); // 权限中间件放在 MapControllers 之前的管道里 app.UseMiddleware<PermissionGuardMiddleware>(); app.MapControllers(); app.Run();
7) 示例 Controller(展示模板匹配)
你只需要在
Permissions表录入对应权限码(或用“启动时扫描路由自动入库”的方式生成),并把权限分配给角色即可。
8) 可选:结合 TokenVersion(强制下线场景)
如果你需要“高敏感权限改动时强制下线”,在中间件前再加一段:
9) 常见问题与实践建议
-
权限码入库:手工录入容易漏;建议用启动时路由扫描自动写库(你若需要,我也可以给你补上这部分)。
-
大小写/尾斜杠:统一规范:Method 全大写,模板以
/开头,无尾斜杠。 -
Swagger/登录/健康检查:加入白名单前缀放行。
-
高并发:给 Redis 设置合理 TTL(如 12h)+ 后台变更时主动删除 Key 优先。
-
多环境差异:不同环境的路由可能不同,建议把扫描写库仅在某个环境启用,或标记来源服务名。
c.启动时路由扫描 → 自动生成 Permissions 表
(1)登录时加载权限并存 Redis
public class AuthService : IAuthService { private readonly IRedisService _redis; private readonly IUserRepository _users; private readonly IPermissionRepository _permissions; public async Task<LoginResult> LoginAsync(string username, string password) { var user = await _users.ValidateAsync(username, password); if (user == null) throw new Exception("用户名或密码错误"); // 查询用户角色权限 var perms = await _permissions.GetUserPermissionsAsync(user.Id); // 存 Redis string key = $"User:Permissions:{user.Id}"; await _redis.SetAsync(key, perms, TimeSpan.FromHours(1)); // 存权限版本号(时间戳) await _redis.SetAsync($"User:PermissionVersion:{user.Id}", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); // 生成 JWT(只放 userId) var token = JwtHelper.Generate(user.Id); return new LoginResult { Token = token }; } }
(2)权限校验中间件
public class PermissionMiddleware { private readonly RequestDelegate _next; private readonly IRedisService _redis; private readonly IPermissionRepository _permissions; public PermissionMiddleware(RequestDelegate next, IRedisService redis, IPermissionRepository permissions) { _next = next; _redis = redis; _permissions = permissions; } public async Task InvokeAsync(HttpContext context) { var userId = context.User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value; if (string.IsNullOrEmpty(userId)) { context.Response.StatusCode = 401; return; } var path = context.Request.Path.Value; var method = context.Request.Method; var permissionCode = $"{method}:{path}"; string key = $"User:Permissions:{userId}"; var perms = await _redis.GetAsync<List<string>>(key); if (perms == null) { // Redis 过期,自动刷新 perms = await _permissions.GetUserPermissionsAsync(int.Parse(userId)); await _redis.SetAsync(key, perms, TimeSpan.FromHours(1)); } if (!perms.Contains(permissionCode)) { context.Response.StatusCode = 403; await context.Response.WriteAsync("无权限"); return; } await _next(context); } }
(3)权限变更时刷新缓存
public class PermissionService : IPermissionService { private readonly IRedisService _redis; private readonly IPermissionRepository _permissions; public async Task UpdateRolePermissionsAsync(int roleId, List<string> newPermissions) { await _permissions.UpdateRolePermissionsAsync(roleId, newPermissions); // 找出所有用户 var userIds = await _permissions.GetUserIdsByRoleId(roleId); foreach (var uid in userIds) { var perms = await _permissions.GetUserPermissionsAsync(uid); await _redis.SetAsync($"User:Permissions:{uid}", perms, TimeSpan.FromHours(1)); // 更新权限版本号 await _redis.SetAsync($"User:PermissionVersion:{uid}", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); } } }

浙公网安备 33010602011771号