diff --git a/quickstart.sh b/quickstart.sh
index b20d61b..a70bc95 100755
--- a/quickstart.sh
+++ b/quickstart.sh
@@ -316,7 +316,11 @@ EOF
## 项目说明
-本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能
+本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能。
+
+## 重要提示
+- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复,不要运行build
+- 尽量使用pnpm执行npm相关的命令
EOF
print_success "新的 CLAUDE.md 已创建"
diff --git a/src/components/providers/session-provider.tsx b/src/components/providers/session-provider.tsx
index 73d3e11..86ab1e9 100644
--- a/src/components/providers/session-provider.tsx
+++ b/src/components/providers/session-provider.tsx
@@ -1,7 +1,32 @@
'use client'
-import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
+import { SessionProvider as NextAuthSessionProvider, signOut, useSession } from "next-auth/react"
+import { useEffect, useRef } from "react"
+import { usePathname } from "next/navigation"
+
+/** 检测会话失效并自动登出 */
+function SessionInvalidationGuard({ children }: { children: React.ReactNode }) {
+ const { data: session, status } = useSession()
+ const pathname = usePathname()
+ const signingOut = useRef(false)
+
+ useEffect(() => {
+ // 已认证但 session 中没有 user(会话被标记失效),自动登出清除 cookie
+ if (status === 'authenticated' && !(session as any)?.user?.id && !signingOut.current && pathname !== '/login') {
+ signingOut.current = true
+ signOut({ callbackUrl: '/login' })
+ }
+ }, [status, session, pathname])
+
+ return <>{children}>
+}
export function SessionProvider({ children }: { children: React.ReactNode }) {
- return {children}
-}
\ No newline at end of file
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index e2cc289..016ae03 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -9,8 +9,8 @@ export default withAuth(
const token = req.nextauth.token
const pathname = req.nextUrl.pathname
- // 如果用户已登录且访问登录页面,重定向到首页
- if (pathname === "/login" && token) {
+ // 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
+ if (pathname === "/login" && token && !token.sessionInvalid) {
return NextResponse.redirect(new URL("/", req.url))
}
@@ -49,7 +49,12 @@ export default withAuth(
if (req.nextUrl.pathname === "/login") {
return true
}
-
+
+ // 会话已被标记失效,强制重新登录
+ if (token?.sessionInvalid) {
+ return false
+ }
+
// 其他路由需要有效的 token
return !!token
},
diff --git a/src/server/auth.ts b/src/server/auth.ts
index ea43f49..5ff6d6e 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -4,6 +4,7 @@ import type { User as NextAuthUser } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { db } from "./db"
+import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
export const authOptions: NextAuthOptions = {
providers: [
@@ -49,6 +50,9 @@ export const authOptions: NextAuthOptions = {
where: { id: user.id },
data: { lastLoginAt: new Date() }
})
+
+ // 清除会话失效标记(用户已重新登录,获得最新权限)
+ await clearSessionInvalidation(user.id)
// 返回用户信息、角色和权限
const roles = user.roles.map((r) => r.name)
@@ -98,10 +102,20 @@ export const authOptions: NextAuthOptions = {
permissions: u.permissions,
isSuperAdmin: u.isSuperAdmin,
}
+ } else if (token.id) {
+ // 后续请求:检查会话是否已被标记失效
+ const invalidated = await isSessionInvalidated(token.id as string)
+ if (invalidated) {
+ token.sessionInvalid = true
+ }
}
return token
},
async session({ session, token }) {
+ // 会话已被标记失效,返回不含用户信息的session
+ if (token.sessionInvalid) {
+ return { expires: session.expires } as any
+ }
// 将JWT token中的信息传递给session
if (session.user) {
const t = token as any
diff --git a/src/server/routers/users.ts b/src/server/routers/users.ts
index 1b37d08..2e4938e 100644
--- a/src/server/routers/users.ts
+++ b/src/server/routers/users.ts
@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server'
import pLimit from 'p-limit'
+import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session'
// 从环境变量获取并发限制,默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
@@ -116,7 +117,7 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
}
- return ctx.db.role.update({
+ const updatedRole = await ctx.db.role.update({
where: { id },
data: {
name,
@@ -129,6 +130,11 @@ export const usersRouter = createTRPCRouter({
}
}
})
+
+ // 标记持有该角色的所有用户会话失效
+ await invalidateSessionsByRoleId(id)
+
+ return updatedRole
}),
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -205,7 +211,10 @@ export const usersRouter = createTRPCRouter({
processedCount += batch.length
}
-
+
+ // 标记所有受影响用户的会话失效
+ await invalidateUserSessions(users.map(u => u.id))
+
return { count: processedCount }
}),
@@ -265,7 +274,7 @@ export const usersRouter = createTRPCRouter({
updateData.password = await bcrypt.hash(password, 12)
}
- return ctx.db.user.update({
+ const updatedUser = await ctx.db.user.update({
where: { id },
data: updateData,
include: {
@@ -273,6 +282,11 @@ export const usersRouter = createTRPCRouter({
dept: true
}
})
+
+ // 标记用户会话失效,强制重新登录以获取最新权限
+ await invalidateUserSessions([id])
+
+ return updatedUser
}),
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
@@ -300,6 +314,9 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
}
+ // 标记用户会话失效
+ await invalidateUserSessions([id])
+
// 删除用户
return ctx.db.user.delete({
where: { id },
diff --git a/src/server/service/session.ts b/src/server/service/session.ts
new file mode 100644
index 0000000..2f83277
--- /dev/null
+++ b/src/server/service/session.ts
@@ -0,0 +1,39 @@
+import 'server-only'
+import { getRedisClient } from '@/server/redis'
+import { db } from '@/server/db'
+
+const SESSION_INVALID_PREFIX = 'session:invalid:'
+const SESSION_INVALID_TTL = 30 * 24 * 60 * 60 // 30天,与JWT maxAge一致
+
+/** 标记指定用户的会话失效 */
+export async function invalidateUserSessions(userIds: string[]) {
+ if (userIds.length === 0) return
+ const redis = getRedisClient()
+ const pipeline = redis.pipeline()
+ for (const id of userIds) {
+ pipeline.set(`${SESSION_INVALID_PREFIX}${id}`, '1', 'EX', SESSION_INVALID_TTL)
+ }
+ await pipeline.exec()
+}
+
+/** 检查用户会话是否已被标记失效 */
+export async function isSessionInvalidated(userId: string): Promise {
+ const redis = getRedisClient()
+ const result = await redis.exists(`${SESSION_INVALID_PREFIX}${userId}`)
+ return result === 1
+}
+
+/** 用户重新登录后清除失效标记 */
+export async function clearSessionInvalidation(userId: string) {
+ const redis = getRedisClient()
+ await redis.del(`${SESSION_INVALID_PREFIX}${userId}`)
+}
+
+/** 标记某角色下所有用户的会话失效 */
+export async function invalidateSessionsByRoleId(roleId: number) {
+ const users = await db.user.findMany({
+ where: { roles: { some: { id: roleId } } },
+ select: { id: true },
+ })
+ await invalidateUserSessions(users.map(u => u.id))
+}