Deploy / deploy (push) Canceled after 0s
- 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>
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
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)
|
||
}
|