fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug
## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)
`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。
会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。
实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。
## M3 三个端点的守卫写在 handler 体内
submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。
实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。
## M2 from-public 的错误码构成比赛存在性预言机
比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。
实测:两种情况现在都是 404 contest-not-found。
## 顺带:比赛里的 SQL 题看不到示例数据
改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,18 +74,26 @@ function readCookie(request: Request, name: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getUserByToken(token: string | undefined): Promise<AuthUser | null> {
|
||||
if (!token) return null
|
||||
/**
|
||||
* 会话解析结果。之所以不只返回 `AuthUser | null`:拿不到用户有两种原因,
|
||||
* 而它们对应完全不同的前端行为 —— 未登录该弹登录框,已禁用该说「账号已禁用」。
|
||||
*/
|
||||
export type SessionResult =
|
||||
| { user: AuthUser; reason?: undefined }
|
||||
| { user: null; reason: "anonymous" | "disabled" }
|
||||
|
||||
async function getUserByToken(token: string | undefined): Promise<SessionResult> {
|
||||
if (!token) return { user: null, reason: "anonymous" }
|
||||
|
||||
const raw = await redis.get(sessionKey(token))
|
||||
if (!raw) return null
|
||||
if (!raw) return { user: null, reason: "anonymous" }
|
||||
|
||||
let session: StoredSession
|
||||
try {
|
||||
session = JSON.parse(raw) as StoredSession
|
||||
} catch {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
return { user: null, reason: "anonymous" }
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
@@ -102,21 +110,35 @@ async function getUserByToken(token: string | undefined): Promise<AuthUser | nul
|
||||
.where(eq(schema.user.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user || user.isDisabled) {
|
||||
if (!user) {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
return { user: null, reason: "anonymous" }
|
||||
}
|
||||
|
||||
if (user.isDisabled) {
|
||||
// 会话照删(禁用要立即生效),但要把「是被禁用」这件事告诉调用方。
|
||||
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
|
||||
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
|
||||
await redis.del(sessionKey(token))
|
||||
return { user: null, reason: "disabled" }
|
||||
}
|
||||
|
||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||
return user
|
||||
return { user }
|
||||
}
|
||||
|
||||
export function getSessionUser(c: Context) {
|
||||
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
|
||||
export function resolveSession(c: Context) {
|
||||
return getUserByToken(getCookie(c, config.sessionCookie))
|
||||
}
|
||||
|
||||
export function getRequestSessionUser(request: Request) {
|
||||
return getUserByToken(readCookie(request, config.sessionCookie))
|
||||
/** 只关心「是谁」的调用方用这个 */
|
||||
export async function getSessionUser(c: Context) {
|
||||
return (await resolveSession(c)).user
|
||||
}
|
||||
|
||||
export async function getRequestSessionUser(request: Request) {
|
||||
return (await getUserByToken(readCookie(request, config.sessionCookie))).user
|
||||
}
|
||||
|
||||
async function getStoredSession(c: Context) {
|
||||
|
||||
Reference in New Issue
Block a user