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 和过期时间

// login.js
function handleLoginSuccess(data) {
  const { accessToken, expiresIn } = data; // expiresIn 单位秒
  const expiresAt = Date.now() + expiresIn * 1000;

  localStorage.setItem("accessToken", accessToken);
  localStorage.setItem("expiresAt", expiresAt.toString());
}

 


📦 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 中使用一次即可

// App.js
import React from 'react';
import AppInitializer from './AppInitializer';
import MainLayout from './MainLayout'; // 你自己的主布局组件

function App() {
  return (
    <>
      <AppInitializer />
      <MainLayout />
    </>
  );
}

export default App;

 


📦 4. refreshToken action 示例(可改造)

// store/actions.js
import axios from '../utils/auth'; // 使用你封装的 axios 实例

export const refreshToken = () => async dispatch => {
  try {
    const res = await axios.post('/auth/refresh', {}, {
      withCredentials: true
    });

    const { accessToken, expiresIn } = res.data;
    const expiresAt = Date.now() + expiresIn * 1000;

    localStorage.setItem("accessToken", accessToken);
    localStorage.setItem("expiresAt", expiresAt.toString());

    // 可选:更新状态
    dispatch({ type: 'TOKEN_REFRESHED', payload: accessToken });
  } catch (error) {
    localStorage.clear();
    window.location.href = '/login';
  }
};

 


✅ 补充说明

项目说明
withCredentials: true 如果 refresh token 保存在 HttpOnly Cookie 中,则必须开启
只刷新一次 setTimeout 只设置一次,不轮询,不浪费性能
自动续期

 

遇到的问题:前端:http://localhost:3000 , 后端: https://localhost:7026  后端refreshtoken放在cookie中返回给前端(前端接收不到),前端发送时携带给后端(前端确认获取到cookie后,后端接收不到)

a.后端返回的cookie前端不保存

  • 前端请求需要携带 withCredentials: true

即使服务端写了 Set-Cookie,浏览器默认 跨域请求不会保存 cookie

Axios 登录请求示例:

axios.post('https://localhost:7026/auth/login', loginData, {
  withCredentials: true // ✅ 必须
});

如果没有这个选项,浏览器会忽略 Set-Cookie

 登录指令请求token和refreshToken时就需要携带
  • 后端 CORS 配置允许 credentials

ASP.NET Core 必须同时指定前端 origin 并允许 credentials:

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("http://localhost:3000") // 前端地址
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials(); // ✅ 必须
    });
});

app.UseCors("AllowFrontend");

注意:

  • 不能用 AllowAnyOrigin() + AllowCredentials(),会报错

  • 前端域名和端口必须完全匹配:

    policy.WithOrigins("http://localhost:3000")
  • Cookie 设置

var cookieOptions = new CookieOptions
{
    HttpOnly = true,
    Secure = false,                // 本地调试可 false
    SameSite = SameSiteMode.None,  // 跨域必须
    Expires = DateTimeOffset.UtcNow.AddDays(7)
};
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);

本地调试 Secure=false,生产环境必须 Secure=true + HTTPS

b.浏览器并没有把cookie发给服务端

经过上面处理后,前端成功获取到了服务端返回的token,但是前端发起更新token请求时携带cookie浏览器并没有发给后端

   登录时 cookie 确实存了

  • 你登录时用 Secure = falseSameSite = None,在 https://localhost:7026 下能看到 cookie。

  • 但是,SameSite=None + Secure=false 在 Chrome >= 80 版本下其实会被特殊处理:

    • 如果 SameSite=None,Chrome 会要求 Secure=true 才允许跨站请求携带 cookie(即使是 localhost)。


   刷新接口请求是跨站请求

  • 前端:http://localhost:3000

  • 后端:https://localhost:7026
    这两个在浏览器眼里是跨站,所以:

    • 必须 SameSite=None

    • 必须 Secure=true(即使在 localhost,现代 Chrome 依旧会要求)

如果 Secure=false,浏览器直接在跨站请求时不带 cookie(本地 Application 里还能看到,但请求发不出去)。


     现象和结论

    • 登录后 cookie 在 Application 里 → 保存没问题

    • refresh 请求后端拿不到 cookie → 浏览器跨站不发送 cookie

    • 401 → 因为后端检查 refreshToken 时是 null

步骤1:前端项目https访问

$env:HTTPS="true"
npm start

前端项目从https://localhost:3000/login启动

步骤2:设置策略

  options.AddPolicy("AllowFrontend", policy =>
  {
      policy.WithOrigins("https://localhost:3000")  // React 开发服务器地址
            .AllowAnyHeader()
            .AllowAnyMethod()
             .AllowCredentials(); ;//允许携带cookie
  });

改成http3

步骤3:服务端返回设置cookie

  var cookieOptions = new CookieOptions
  {
      HttpOnly = true,
      // Secure = false,
      Secure = true,                 // HTTPS 下生效,生产环境需要设置
      SameSite = SameSiteMode.None,  // 跨域前端请求必须
      Expires = DateTimeOffset.UtcNow.AddDays(7) // refreshToken 有效期
  };

 

 (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 是最直观的。

为什么抛异常是常规做法

  1. UI 层好捕获

    • 你在 onFinishawait dispatch(fetchLogin(values)),如果 fetchLoginthrow,外层就能直接用 try/catch 捕获,不用解析返回值。

    • 这样 UI 层的错误处理逻辑(message.error()、恢复按钮状态等)很自然。

  2. 避免业务和网络错误混淆

    • 抛异常不仅可以捕获 网络错误axios 报错),还可以捕获 业务错误(后端返回 code != 0)。

    • 统一走 catch,不需要额外判断多层返回值。

  3. 便于链式调用

    • 如果未来某个页面需要 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 封装(推荐灵活度高)

核心思路:

  1. 接收一个回调函数 fn 和等待时间 delay

  2. 内部维护一个「锁」或「时间戳」,在一定时间内阻止重复执行

  3. 返回一个包装后的新函数,直接替代原来的事件回调

// hooks/useThrottleFn.js
import { useRef, useCallback } from 'react';

export default function useThrottleFn(fn, delay = 1000) {
  const lastCall = useRef(0);
  const isRunning = useRef(false);

  return useCallback(async (...args) => {
    const now = Date.now();

    // 如果已经在执行,不允许再次执行
    if (isRunning.current) return;

    // 节流:必须等到 delay 之后才能再次执行
    if (now - lastCall.current < delay) return;

    isRunning.current = true;
    lastCall.current = now;

    try {
      await fn(...args); // 执行真正的回调
    } finally {
      isRunning.current = false; // 请求完成后解除锁
    }
  }, [fn, delay]);
}

特点

  • 可以用在任何异步/同步事件上

  • 节流与防多次点击合并成一个逻辑

  • 代码干净,不会到处写 loading 状态

使用示例(登录按钮)

import useThrottleFn from '@/hooks/useThrottleFn';

function LoginForm() {
  const handleLogin = async (values) => {
    console.log('正在发起登录请求', values);
    await new Promise(r => setTimeout(r, 2000)); // 模拟接口
    console.log('登录成功');
  };

  const throttledLogin = useThrottleFn(handleLogin, 1500);

  return (
    <Button type="primary" onClick={() => throttledLogin({ username: 'test' })}>
      登录
    </Button>
  );
}

 

特点

  • 可以用在任何异步/同步事件上

  • 节流与防多次点击合并成一个逻辑

  • 代码干净,不会到处写 loading 状态


方案二:组件封装(方便统一 UI + 逻辑)

适合全局规范化,比如你的后台系统里所有「提交按钮」都能自动防重复点击。

// components/SafeButton.jsx
import React, { useState } from 'react';
import { Button } from 'antd';

export default function SafeButton({ onClick, wait = 1000, children, ...rest }) {
  const [loading, setLoading] = useState(false);

  const handleClick = async (e) => {
    if (loading) return;
    setLoading(true);
    try {
      await onClick?.(e);
    } finally {
      setTimeout(() => setLoading(false), wait);
    }
  };

  return (
    <Button {...rest} loading={loading} onClick={handleClick}>
      {children}
    </Button>
  );
}

 

使用示例

<SafeButton type="primary" wait={1500} onClick={handleLogin}>
  登录
</SafeButton>

<SafeButton type="primary" danger onClick={submitOrder}>
  提交订单
</SafeButton>

 

特点

  • 所有按钮默认都有防多次点击能力

  • 可以统一 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,但此时响应头或部分响应内容已经被写出。


典型触发场景

  1. 在响应已经开始后调用 Challenge()Unauthorized()

    • 例如在 Controller/Filter 里写入了一部分数据(或 flush 了响应流)后,再触发认证失败。

  2. 中间件顺序错误

    • 如果 UseAuthentication() / UseAuthorization()UseSwagger() 之后,Swagger 的响应可能已经写出,然后认证中间件还尝试拦截。

  3. 异常处理冲突

    • 你的 ExceptionHandlerMiddleware(或全局异常捕获)在响应已部分发送后,试图写自定义错误响应。


解决思路

1. 检查中间件顺序(Startup / Program.cs)

JWT 认证和授权应该放在管道早期,Swagger 之后容易触发此问题:

 
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.UseSwagger();
app.UseSwaggerUI();

app.MapControllers();

 

如果 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 模块

// Extensions/UserServiceExtensions.cs
public static class UserServiceExtensions
{
    public static IServiceCollection AddUserModule(this IServiceCollection services)
    {
        services.AddScoped<IUserRepository, UserRepository>();
        services.AddScoped<IUserService, UserService>();
        return services;
    }
}

然后在 Program.cs 里只需要:

builder.Services.AddUserModule();

其他模块比如 Role、Permission 也可以写类似的扩展方法,Program.cs 就会非常简洁:

builder.Services.AddUserModule()
                .AddRoleModule()
                .AddPermissionModule();

 


2️⃣ 使用泛型 Repository + 通用服务

如果你是标准 CRUD,很多 Repository 的实现都是类似的,可以写 通用接口和实现

// 通用 Repository
public interface IRepository<T> where T : class
{
    Task<List<T>> GetAllAsync();
    Task<T?> GetByIdAsync(int id);
    Task<T> AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly AppDbContext _db;
    public Repository(AppDbContext db) => _db = db;

    public async Task<List<T>> GetAllAsync() => await _db.Set<T>().ToListAsync();
    public async Task<T?> GetByIdAsync(int id) => await _db.Set<T>().FindAsync(id);
    public async Task<T> AddAsync(T entity)
    {
        _db.Set<T>().Add(entity);
        await _db.SaveChangesAsync();
        return entity;
    }
    public async Task UpdateAsync(T entity)
    {
        _db.Set<T>().Update(entity);
        await _db.SaveChangesAsync();
    }
    public async Task DeleteAsync(T entity)
    {
        _db.Set<T>().Remove(entity);
        await _db.SaveChangesAsync();
    }
}

然后 Program.cs 只需要:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

这样每张表的 Repository 都可以复用,不用一张表写一个 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

    • 签发时间、过期时间(iatexp

  • 不存数据库,依赖 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 版本控制(常用做法)

  • 数据库在 UserRole 表增加 VersionUpdatedAt 字段

  • AccessToken 中带 role_version 或类似字段

  • 每次请求时:

     
    if(jwt.RoleVersion < user.RoleVersion) throw new UnauthorizedException("权限已更新,需要重新登录");
  • 优点:AccessToken 无需立即失效,也能安全处理权限变更

b. RefreshToken 撤销

  • 用户登出或被禁用 → 撤销对应客户端 RefreshToken

  • 全端登出或权限全局变更 → 撤销该用户所有 RefreshToken


4️⃣ 安全细节(生产实践)

  1. 短期 AccessToken + 长期 RefreshToken:减小被盗风险

  2. RefreshToken 绑定客户端:保证多客户端独立管理

  3. RefreshToken 安全存储

    • Web → HttpOnly Cookie

    • Mobile → Keychain / Keystore

  4. RefreshToken 一次性使用(可选):刷新后立即替换,避免重复使用

  5. AccessToken 不存数据库:减轻负载

  6. 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 风格)

👉 金融、支付、下单类 API 普遍采用。


🚀 总结

  • 防重复提交 = 幂等性保证

  • 常规手段有:

    1. 幂等 Key(最通用,推荐 ✅)

    2. 数据库唯一约束(简单高效)

    3. 分布式锁(高并发场景)

    4. 前端防抖(用户体验层面)

  • 实际项目一般会 多种手段结合,比如:

    • 下单:幂等 Key + 唯一约束

    • 秒杀:分布式锁 + 唯一约束

    • 表单:前端防抖 + 简单防重复校验

(9)数据库层面的解决方案

行锁(悲观锁,Pessimistic Lock)

思想:先锁住,再操作
适合并发很高、冲突概率大的场景,比如库存扣减。

在数据库里常见做法是:

 
-- 取行并加锁,直到事务结束才释放 SELECT Stock FROM Product WHERE ProductId = 1001 FOR UPDATE;

然后在同一个事务里操作:

 
UPDATE Product SET Stock = Stock - 1 WHERE ProductId = 1001;

特点

  • 事务 A 执行 SELECT ... FOR UPDATE 时,事务 B 对同一行的读写都会被阻塞,直到事务 A 提交。

  • 确保同一时间只有一个事务能修改这行数据。

  • 缺点:高并发下会阻塞,吞吐量下降。


2️⃣ 乐观锁(Optimistic Lock)

思想:不加锁,先试着更新,如果发现别人动过,就失败重试
适合冲突概率低,但要求高并发的场景。

做法:在表里加一个 Version 字段:

 
ALTER TABLE Product ADD COLUMN Version INT DEFAULT 0;

更新时带上 Version 条件:

 
UPDATE Product SET Stock = Stock - 1, Version = Version + 1 WHERE ProductId = 1001 AND Version = 5;

执行结果:

  • 如果 Version = 5,更新成功,说明没人改过。

  • 如果 Version != 5,更新失败(受影响行数=0),说明已经被别人改了。

服务端可以检测到失败,再去 重新读取数据并重试

示例代码(C# EF Core)

var product = await _db.Products.FirstAsync(p => p.Id == 1001);

var oldVersion = product.Version;

product.Stock -= 1;
product.Version += 1;

var affected = await _db.SaveChangesAsync();

if (affected == 0)
{
    // 表示更新失败(版本冲突)
    throw new Exception("并发冲突,请重试");
}

3️⃣ 行锁 vs 乐观锁 对比

特性行锁(悲观锁)乐观锁(版本号)
并发性能 低(阻塞) 高(无阻塞)
适用场景 高并发、高冲突(如库存秒杀) 低冲突(如订单状态更新)
实现方式 SELECT ... FOR UPDATE WHERE Version = ?
成本 占用数据库锁,可能死锁 需要多一次重试逻辑

4️⃣ 在订单防重复提交里的应用

比如「防止一个用户多次下单」:

  • 行锁

    SELECT * FROM Orders WHERE OrderNo = 'xxx' FOR UPDATE;

    确保只有一个事务能操作这个订单。

  • 乐观锁
    订单表里有个 StatusVersion,更新时带条件:

    UPDATE Orders SET Status = 'Paid', StatusVersion = StatusVersion + 1 WHERE OrderId = 123 AND StatusVersion = 1;

如果返回影响行数=0,说明状态已经被别人改过 → 拒绝当前请求。


总结

    • 悲观锁(行锁):直接数据库加锁,保证强一致,但并发下会阻塞。

    • 乐观锁(版本号):靠条件更新 + 重试,适合高并发、冲突不频繁的场景。

    • 在订单/库存场景里,一般会用 乐观锁 + 唯一约束,秒杀这种极端高并发才会加 分布式锁 + 行锁

(10)权限管理->权限变更处理

1️⃣ 强制下线(更新 AccessToken / TokenVersion + 401)

优点:

  • 权限立即生效

  • 安全性高,用户无法继续使用旧权限

缺点:

  • 用户体验差:突然被登出

  • 对短时频繁操作的用户可能打断流程


2️⃣ 刷新目录(更新 Redis 缓存,保留现有 AccessToken,有效期内使用最新权限)

优点:

  • 用户体验好,登录不中断

  • 权限变更可动态生效

  • 配合 JWT + 中间件可以即时更新访问权限

缺点:

  • AccessToken 仍然有效,如果业务要求绝对安全(比如财务操作),可能需要强制刷新

  • 中间件和缓存逻辑稍复杂,需要保证 Redis 权限目录及时更新


✅ 实践建议

  • 大部分业务:刷新目录即可,用户无感知,权限即时生效

  • 高敏感业务(财务、交易等):可以结合刷新目录 + 强制 TokenVersion 更新 → 强制下线


可以把这个策略设计成 分级策略

场景动作用户体验
普通权限变更 刷新权限目录(Redis) 无感知,实时生效
高敏感权限变更 刷新目录 + 强制下线 被迫重新登录

a.刷新目录

1️⃣ 后端中间件逻辑

核心思路:

  1. JWT 中只包含最小信息UserId + RoleId + TokenVersion(可选)

  2. Redis 缓存角色权限:Key=role:{RoleId}:permissions,Value=["user:add","user:view",...]

  3. 每次请求中间件校验

    • 获取用户 RoleId

    • 从 Redis 取最新权限列表

      • 如果 Redis 缓存不存在 → 从数据库加载 → 写回 Redis

    • 检查请求接口是否在权限列表中

      • 没权限 → 返回 403 或自定义“无权限”

      • 有权限 → 继续处理

 
var permissionsJson = await _redis.StringGetAsync($"role:{roleId}:permissions");
if (string.IsNullOrEmpty(permissionsJson))
{
    var dbPermissions = await _db.RolePermissions
        .Where(rp => rp.RoleId == roleId)
        .Select(rp => rp.PermissionCode)
        .ToListAsync();
    permissionsJson = JsonConvert.SerializeObject(dbPermissions);
    await _redis.StringSetAsync($"role:{roleId}:permissions", permissionsJson);
}
var permissions = JsonConvert.DeserializeObject<List<string>>(permissionsJson);

if (!permissions.Contains(requestPermissionCode))
{
    context.Response.StatusCode = 403;
    await context.Response.WriteAsync("无权限");
    return;
}

 



2️⃣ 权限变更时刷新目录

  • 后台修改角色权限 → 更新数据库

  • 同步 刷新 Redis 缓存,或直接删除 Key,让下次请求重新加载

await _redis.KeyDeleteAsync($"role:{roleId}:permissions");

关键点:不需要前端定时刷新,用户下一次请求接口时,中间件会自动获取最新权限。


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 实体(简化单角色)

public class User
{
    public int Id { get; set; }
    public string UserName { get; set; } = default!;
    public string PasswordHash { get; set; } = default!;
    public int RoleId { get; set; }
    public Role Role { get; set; } = default!;
    public int TokenVersion { get; set; } = 1; // 可选:用于强制下线
}

public class Role
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public ICollection<RolePermission> RolePermissions { get; set; } = new List<RolePermission>();
}

public class Permission
{
    public int Id { get; set; }
    public string Code { get; set; } = default!;        // 如 "GET:/api/users/{id}"
    public string HttpMethod { get; set; } = default!;
    public string RouteTemplate { get; set; } = default!;
    public string? Description { get; set; }
    public bool IsActive { get; set; } = true;
}

public class RolePermission
{
    public int RoleId { get; set; }
    public Role Role { get; set; } = default!;
    public int PermissionId { get; set; }
    public Permission Permission { get; set; } = default!;
}

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<User> Users => Set<User>();
    public DbSet<Role> Roles => Set<Role>();
    public DbSet<Permission> Permissions => Set<Permission>();
    public DbSet<RolePermission> RolePermissions => Set<RolePermission>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<RolePermission>().HasKey(x => new { x.RoleId, x.PermissionId });
        mb.Entity<Permission>().HasIndex(x => x.Code).IsUnique();
        mb.Entity<Permission>().Property(x => x.Code).HasMaxLength(256);
        mb.Entity<Permission>().Property(x => x.HttpMethod).HasMaxLength(16);
        mb.Entity<Permission>().Property(x => x.RouteTemplate).HasMaxLength(256);
    }
}

 


3) Redis 权限服务(懒加载 + 失效)

 
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;

public interface IPermissionCache
{
    Task<HashSet<string>> GetRolePermissionsAsync(int roleId, CancellationToken ct = default);
    Task InvalidateRoleAsync(int roleId);
    Task WarmupRoleAsync(int roleId, CancellationToken ct = default); // 可选:预热
}

public class PermissionCache : IPermissionCache
{
    private readonly IDatabase _redis;
    private readonly AppDbContext _db;
    private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web);
    private const string RoleKey = "role:{0}:permissions"; // role:{roleId}:permissions

    public PermissionCache(IConnectionMultiplexer mux, AppDbContext db)
    {
        _redis = mux.GetDatabase();
        _db = db;
    }

    public async Task<HashSet<string>> GetRolePermissionsAsync(int roleId, CancellationToken ct = default)
    {
        var key = string.Format(RoleKey, roleId);
        var cached = await _redis.StringGetAsync(key);
        if (cached.HasValue)
        {
            var list = JsonSerializer.Deserialize<List<string>>(cached!, _jsonOpts) ?? new();
            return new HashSet<string>(list, StringComparer.OrdinalIgnoreCase);
        }

        // 未命中 → DB 加载
        var fromDb = await _db.RolePermissions
            .Where(rp => rp.RoleId == roleId && rp.Permission.IsActive)
            .Select(rp => rp.Permission.Code)
            .Distinct()
            .ToListAsync(ct);

        // 写回缓存(可设置 TTL)
        await _redis.StringSetAsync(key, JsonSerializer.Serialize(fromDb, _jsonOpts), TimeSpan.FromHours(12));
        return new HashSet<string>(fromDb, StringComparer.OrdinalIgnoreCase);
    }

    public Task InvalidateRoleAsync(int roleId)
    {
        var key = string.Format(RoleKey, roleId);
        return _redis.KeyDeleteAsync(key);
    }

    public async Task WarmupRoleAsync(int roleId, CancellationToken ct = default)
    {
        _ = await GetRolePermissionsAsync(roleId, ct);
    }
}

 

后台改动角色权限后,调用 InvalidateRoleAsync(roleId);不需要前端做任何事。


4) 中间件(基于模板自动判权)

using Microsoft.AspNetCore.Routing;

public class PermissionGuardMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IPermissionCache _permCache;
    private readonly ILogger<PermissionGuardMiddleware> _logger;

    // 可选白名单(无需鉴权):登陆、swagger、健康检查等
    private static readonly string[] _whitelistPrefixes = new[]
    {
        "/swagger", "/health", "/api/auth/login"
    };

    public PermissionGuardMiddleware(RequestDelegate next, IPermissionCache permCache, ILogger<PermissionGuardMiddleware> logger)
    {
        _next = next;
        _permCache = permCache;
        _logger = logger;
    }

    public async Task Invoke(HttpContext ctx)
    {
        // 白名单:按路径前缀放行
        var path = ctx.Request.Path.Value ?? "";
        if (_whitelistPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
        {
            await _next(ctx);
            return;
        }

        // 需要已登录
        if (!(ctx.User?.Identity?.IsAuthenticated ?? false))
        {
            ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await ctx.Response.WriteAsync("Unauthorized");
            return;
        }

        // 取得路由模板(/api/users/{id})
        var endpoint = ctx.GetEndpoint() as RouteEndpoint;
        var template = endpoint?.RoutePattern?.RawText ?? path; // 尽量用模板
        var method = ctx.Request.Method.ToUpperInvariant();
        var code = $"{method}:{template}";

        // 从 token 读取角色或用户
        var roleIdStr = ctx.User.FindFirst("roleId")?.Value;
        if (string.IsNullOrEmpty(roleIdStr) || !int.TryParse(roleIdStr, out var roleId))
        {
            ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await ctx.Response.WriteAsync("Invalid token(no roleId)");
            return;
        }

        // 拉取权限(Redis → DB 懒加载)
        var perms = await _permCache.GetRolePermissionsAsync(roleId);

        if (!perms.Contains(code))
        {
            _logger.LogWarning("Forbidden. role={RoleId}, need={Code}", roleId, code);
            ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
            await ctx.Response.WriteAsync("Forbidden");
            return;
        }

        await _next(ctx);
    }
}

 

这段中间件就是“刷新目录”的关键:每次请求都取最新的角色权限缓存,缓存失效时自动回源数据库。


5) 权限变更时刷新目录(后台管理调用)

在你的后台管理(比如“保存角色权限”接口)里,加上这句即可:

 
// 事务提交成功后 await _permissionCache.InvalidateRoleAsync(roleId);

下次请求就会自动加载最新权限,无需用户重新登录、无需前端定时刷新。


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(展示模板匹配)

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    [HttpGet]                  // 权限码: GET:/api/users
    public IActionResult List() => Ok(new { ok = true });

    [HttpPost]                 // 权限码: POST:/api/users
    public IActionResult Create() => Ok(new { ok = true });

    [HttpDelete("{id}")]       // 权限码: DELETE:/api/users/{id}
    public IActionResult Delete(int id) => Ok(new { ok = true });
}

 

你只需要在 Permissions 表录入对应权限码(或用“启动时扫描路由自动入库”的方式生成),并把权限分配给角色即可。


8) 可选:结合 TokenVersion(强制下线场景)

如果你需要“高敏感权限改动时强制下线”,在中间件前再加一段:

 
// 可选校验:tokenVersion
var uid = int.Parse(ctx.User.FindFirst("sub")!.Value);
var tokenVer = int.Parse(ctx.User.FindFirst("tokenVersion")?.Value ?? "1");

// 建议优先读 Redis,不存在回源 DB
var redis = ctx.RequestServices.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
var verStr = await redis.StringGetAsync($"user:{uid}:tokenVersion");
int latestVer;
if (verStr.IsNullOrEmpty)
{
    var db = ctx.RequestServices.GetRequiredService<AppDbContext>();
    latestVer = await db.Users.Where(u => u.Id == uid).Select(u => u.TokenVersion).FirstAsync();
    await redis.StringSetAsync($"user:{uid}:tokenVersion", latestVer, TimeSpan.FromHours(12));
}
else latestVer = (int)verStr;

if (tokenVer != latestVer)
{
    ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
    await ctx.Response.WriteAsync("Token expired due to permission change");
    return;
}

 


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());
        }
    }
}
posted @ 2025-08-14 14:23  头号程序媛  阅读(20)  评论(0)    收藏  举报