Files
OJ2/apps/web/src/shared/api.ts
T
xuyueandClaude Opus 5.5 793848b06f
Deploy / deploy (push) Canceled after 0s
refactor(登录): 登录态从 Cookie 换成 access token + refresh token
- access token 是 15 分钟的 JWT,走 Authorization: Bearer;refresh token 7 天,每次换都轮换
- 退出登录、改密码、禁用账号仍然立刻生效:每个请求核对 Redis 会话和库里新加的 user.token_version
- 在服务器后台改密码 / 禁用,机房那边登着的也会一起下线(原来两边 Redis 分开,管不到)
- WebSocket 握手借子协议带 token;JWT 密钥存在本站 Redis,部署不用加配置
- 去掉 COOKIE_SECURE;上线后所有人要重新登录一次

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:52 +08:00

97 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AuthTokens, LoginRequest, RefreshTokenRequest, RegisterRequest } from "@oj2/contract"
import type {
ClassActivity,
MarkAchievementsRead,
ProblemAuthor,
ProblemList,
Quote,
WebsiteConfig,
} from "@oj2/contract"
import api from "utils/api"
import { getRefreshToken, saveTokens } from "utils/auth"
import { contractDeferred } from "utils/contract"
import { toProblemRow } from "utils/transforms"
import type { PendingAchievement, Profile, Tag } from "utils/types"
export async function login(data: { username: string; password: string }) {
saveTokens(await api.post<AuthTokens>("auth/login", data satisfies LoginRequest))
}
export function signup(data: { username: string; email: string; password: string }) {
return api.post("users", data satisfies RegisterRequest)
}
/** 吊销服务端那张会话。本地的 token 由调用方清(user store 的 clearProfile) */
export async function logout() {
const refreshToken = getRefreshToken()
if (refreshToken) await api.post("auth/logout", { refreshToken } satisfies RefreshTokenRequest)
}
export async function getProfile(username: string = ""): Promise<Profile | null> {
const endpoint = username ? `profiles/${encodeURIComponent(username)}` : "me"
const response = await api.get<unknown>(endpoint)
if (response === null) return null
// 形状与契约一致,不再逐字段搬运。走契约闸门而不是裸 parse():原来这里是
// `userProfileSchema.parse(response) as Profile`,`as` 把校验结果又断言回本地
// 类型(Profile 把 user 收窄成 SessionUser、acmProblemsStatus 收窄成具体形状),
// 形状对不上时页面白屏。现在记一条分歧日志后放行原始数据。
// 用 deferred 版:启动就要拉 /me,同步校验会把 zod 整个打进首屏包
return contractDeferred(
"GET /profiles/:username",
() => import("./profileSchema").then((m) => m.userProfileSchema),
response,
)
}
export function getProblemTagList() {
return api.get<Tag[]>("problem-tags")
}
export function getHitokoto() {
return api.get<Quote>("quotes/random")
}
export function getClassUsernames(classroom: string) {
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
}
// 下面这个是启动就要调的(config store),所以放在 shared 而不是
// oj/api.ts:oj/api.ts 同步 import 了题目详情、提交详情两个 schema(它们的契约闸门必须
// 同步,submissionDetailSchema 里有 `.catch()`,放行原文和解析结果不等价),入口一旦
// 引到它,zod 整个就进了首屏包。
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.results.map(toProblemRow),
total: res.total,
}
}
export function getAuthors(all = false) {
return api.get<ProblemAuthor[]>("problem-authors", {
params: { all: all ? "1" : "0" },
})
}
export function getClassActivity() {
return api.get<ClassActivity>("me/class-activity")
}
export function getPendingAchievements() {
return api.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return api.post("achievements/pending/read", { ids } satisfies MarkAchievementsRead)
}