feat(阶段3): oj 侧端点铺开(基线提交,未经评审)
由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线, 后续修复单独成 commit,便于区分与回退。 覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/ content/contest/flowchart/problemset/site)与对应 Zod 契约。 已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据; judge 与 flowchart worker 均 ready。 未核验:权限边界与数据泄露,评审进行中。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
160
apps/api/src/services/achievements.ts
Normal file
160
apps/api/src/services/achievements.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { and, count, eq, isNull, ne, notInArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { isAccepted, JudgeStatus } from "../judge/status"
|
||||
import { objectValue } from "../routes/helpers"
|
||||
|
||||
function numberMetric(metrics: Record<string, unknown>, key: string) {
|
||||
const value = metrics[key]
|
||||
return typeof value === "number" ? value : 0
|
||||
}
|
||||
|
||||
function localDate(value: string) {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
|
||||
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
|
||||
.where(eq(schema.userAchievement.userId, userId))
|
||||
const filters = [eq(schema.achievement.visible, true)]
|
||||
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
|
||||
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
|
||||
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
|
||||
const candidates = await db.select().from(schema.achievement).where(and(...filters))
|
||||
const created: typeof schema.achievement.$inferSelect[] = []
|
||||
for (const achievement of candidates) {
|
||||
const value = metrics[achievement.metric]
|
||||
if (typeof value !== "number") continue
|
||||
const hit = achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
|
||||
if (!hit) continue
|
||||
const inserted = await db.insert(schema.userAchievement).values({
|
||||
userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime: new Date().toISOString(),
|
||||
backfilled: false,
|
||||
notified: false,
|
||||
}).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }).returning({ id: schema.userAchievement.id })
|
||||
if (inserted.length) {
|
||||
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }).where(eq(schema.achievement.id, achievement.id))
|
||||
created.push(achievement)
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.submission.id, submissionId)).limit(1)
|
||||
if (!row || row.submission.contestId !== null) return []
|
||||
|
||||
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
ne(schema.submission.id, row.submission.id),
|
||||
))
|
||||
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
const firstAc = accepted && !priorAccepted
|
||||
const firstTry = accepted && priorRows.length === 0
|
||||
const date = localDate(row.submission.createTime)
|
||||
const hour = new Date(row.submission.createTime).getHours()
|
||||
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId: row.submission.userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
||||
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.submission_count = numberMetric(value, "submission_count") + 1
|
||||
if (firstAc) {
|
||||
value.accepted_count = numberMetric(value, "accepted_count") + 1
|
||||
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
|
||||
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
|
||||
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
|
||||
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
|
||||
const perDay = objectValue(value._ac_per_day)
|
||||
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
||||
value._ac_per_day = perDay
|
||||
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number"))
|
||||
}
|
||||
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : []
|
||||
if (!activeDates.includes(date)) activeDates.push(date)
|
||||
value._active_dates = activeDates
|
||||
value.active_days = activeDates.length
|
||||
if (accepted) {
|
||||
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
|
||||
if (last !== date) {
|
||||
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1
|
||||
? numberMetric(value, "_current_ac_streak") + 1
|
||||
: 1
|
||||
value._last_ac_date = date
|
||||
value._current_ac_streak = current
|
||||
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
|
||||
}
|
||||
}
|
||||
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : []
|
||||
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
|
||||
value._languages = languages
|
||||
value.languages_used = languages.length
|
||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
||||
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
|
||||
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
|
||||
return value
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(row.submission.userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId))
|
||||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
||||
}
|
||||
|
||||
export async function updateAchievementsForProblemSet(userId: number) {
|
||||
const [[badgeRow], [completedRow]] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
|
||||
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
eq(schema.problemsetProgress.isCompleted, true),
|
||||
)),
|
||||
])
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
||||
const [stat] = await tx.select().from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.badge_count = badgeRow?.value ?? 0
|
||||
value.problemset_completed = completedRow?.value ?? 0
|
||||
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.id, stat.id))
|
||||
return value
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.userId, userId))
|
||||
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
||||
}
|
||||
105
apps/api/src/services/ai.ts
Normal file
105
apps/api/src/services/ai.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { config } from "../config"
|
||||
|
||||
interface ChatMessage {
|
||||
role: "system" | "user"
|
||||
content: string
|
||||
}
|
||||
|
||||
function requestBody(messages: ChatMessage[], stream: boolean) {
|
||||
return {
|
||||
model: config.aiModel,
|
||||
messages,
|
||||
stream,
|
||||
temperature: 0,
|
||||
thinking: { type: "disabled" },
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeChat(system: string, user: string) {
|
||||
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], false)),
|
||||
})
|
||||
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||
return payload.choices?.[0]?.message?.content?.trim() ?? ""
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
system: string,
|
||||
user: string,
|
||||
onComplete?: (value: string) => Promise<void>,
|
||||
) {
|
||||
const encoder = new TextEncoder()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||
if (!config.aiKey) {
|
||||
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], true)),
|
||||
})
|
||||
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
send("event: start\n\n")
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line.startsWith("data:")) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") continue
|
||||
try {
|
||||
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> }
|
||||
const choice = item.choices?.[0]
|
||||
const content = choice?.delta?.content
|
||||
if (content) {
|
||||
chunks.push(content)
|
||||
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// Provider keepalive or a partial non-data line.
|
||||
}
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
const full = chunks.join("").trim()
|
||||
if (onComplete) await onComplete(full)
|
||||
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
||||
} catch (error) {
|
||||
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
|
||||
} finally {
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"x-accel-buffering": "no",
|
||||
},
|
||||
})
|
||||
}
|
||||
85
apps/api/src/services/contest.ts
Normal file
85
apps/api/src/services/contest.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import type { Context } from "hono"
|
||||
|
||||
import type { AppEnv } from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { getContestPassword } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export type ContestRow = typeof schema.contest.$inferSelect
|
||||
|
||||
export function contestStatus(contest: ContestRow) {
|
||||
const now = Date.now()
|
||||
if (Date.parse(contest.startTime) > now) return "1" as const
|
||||
if (Date.parse(contest.endTime) < now) return "-1" as const
|
||||
return "0" as const
|
||||
}
|
||||
|
||||
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
|
||||
}
|
||||
|
||||
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
|
||||
}
|
||||
|
||||
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
|
||||
if (!candidate || !expected) return false
|
||||
if (candidate === expected) return true
|
||||
const parts = candidate.split("#")
|
||||
if (parts.length !== 2) return false
|
||||
const [signature, expiresAt] = parts
|
||||
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
||||
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
|
||||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||||
}
|
||||
|
||||
export async function findVisibleContest(id: number) {
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
||||
return contest ?? null
|
||||
}
|
||||
|
||||
export async function canAccessContest(
|
||||
c: Context<AppEnv>,
|
||||
contest: ContestRow,
|
||||
checkType: "details" | "problems" | "ranks" | "submissions",
|
||||
) {
|
||||
const user = c.get("user")
|
||||
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
|
||||
if (isContestAdmin(user, contest)) return { ok: true as const }
|
||||
if (contest.password) {
|
||||
const stored = await getContestPassword(c, contest.id)
|
||||
if (!checkContestPassword(stored, contest.password)) {
|
||||
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
|
||||
}
|
||||
}
|
||||
if (contestStatus(contest) === "1" && checkType !== "details") {
|
||||
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
|
||||
}
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
function ipv4Number(value: string) {
|
||||
const parts = value.split(".").map(Number)
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
||||
return parts.reduce((result, part) => (result * 256 + part) >>> 0, 0)
|
||||
}
|
||||
|
||||
export function ipAllowed(ip: string | null, ranges: unknown) {
|
||||
if (!Array.isArray(ranges) || ranges.length === 0) return true
|
||||
if (!ip) return false
|
||||
const target = ipv4Number(ip.replace(/^::ffff:/, ""))
|
||||
if (target === null) return false
|
||||
return ranges.some((raw) => {
|
||||
const value = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as { value?: unknown }).value ?? "") : ""
|
||||
const [address, prefixText = "32"] = value.split("/")
|
||||
const network = ipv4Number(address ?? "")
|
||||
const prefix = Number(prefixText)
|
||||
if (network === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
|
||||
return (target & mask) === (network & mask)
|
||||
})
|
||||
}
|
||||
75
apps/api/src/services/format-code.ts
Normal file
75
apps/api/src/services/format-code.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { config } from "../config"
|
||||
|
||||
export class CodeFormatError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly kind: "syntax" | "tool",
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function runFormatter(command: string[], code: string) {
|
||||
let process: ReturnType<typeof Bun.spawn>
|
||||
try {
|
||||
process = Bun.spawn(command, {
|
||||
stdin: new Blob([code]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
} catch (error) {
|
||||
throw new CodeFormatError(String(error), "tool")
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => process.kill(), 5_000)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
process.exited,
|
||||
new Response(process.stdout as ReadableStream<Uint8Array>).text(),
|
||||
new Response(process.stderr as ReadableStream<Uint8Array>).text(),
|
||||
])
|
||||
clearTimeout(timeout)
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
function formatSql(code: string) {
|
||||
return code
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean)
|
||||
.map((statement) =>
|
||||
statement.replace(
|
||||
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
|
||||
(keyword) => keyword.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
|
||||
}
|
||||
|
||||
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
|
||||
if (language === "sql") return formatSql(code)
|
||||
|
||||
if (language === "python") {
|
||||
const result = await runFormatter(
|
||||
[config.ruffPath, "format", "--stdin-filename", "code.py", "-"],
|
||||
code,
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
const filename = language === "c" ? "code.c" : "code.cpp"
|
||||
const result = await runFormatter(
|
||||
[
|
||||
config.clangFormatPath,
|
||||
`-assume-filename=${filename}`,
|
||||
"-style={BasedOnStyle: LLVM, IndentWidth: 4, BreakBeforeBraces: Attach}",
|
||||
],
|
||||
code,
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new CodeFormatError(result.stderr || "Formatting failed", "tool")
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
38
apps/api/src/services/options.ts
Normal file
38
apps/api/src/services/options.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { inArray } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export const websiteOptionDefaults = {
|
||||
website_base_url: "http://127.0.0.1",
|
||||
website_name: "Online Judge",
|
||||
website_name_shortcut: "oj",
|
||||
website_footer: "Online Judge Footer",
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [] as string[],
|
||||
enable_maxkb: true,
|
||||
}
|
||||
|
||||
export async function getOptions<const T extends readonly string[]>(keys: T) {
|
||||
const rows = await db
|
||||
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value })
|
||||
.from(schema.optionsSysoptions)
|
||||
.where(inArray(schema.optionsSysoptions.key, [...keys]))
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown>
|
||||
}
|
||||
|
||||
export async function getWebsiteOptions() {
|
||||
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults>
|
||||
const values = await getOptions(keys)
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
|
||||
) as typeof websiteOptionDefaults
|
||||
}
|
||||
|
||||
export async function getBooleanOption(
|
||||
key: keyof typeof websiteOptionDefaults,
|
||||
fallback: boolean,
|
||||
) {
|
||||
const values = await getOptions([key])
|
||||
return typeof values[key] === "boolean" ? values[key] : fallback
|
||||
}
|
||||
41
apps/api/src/services/profile.ts
Normal file
41
apps/api/src/services/profile.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { sessionUserSchema, userProfileSchema } from "@oj2/contract"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export async function getUserProfileById(userId: number, showRealName: boolean) {
|
||||
const [row] = await db
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.user.id, userId), eq(schema.user.isDisabled, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return null
|
||||
return userProfileSchema.parse({
|
||||
id: row.profile.id,
|
||||
user: sessionUserSchema.parse({
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
email: row.user.email,
|
||||
adminType: row.user.adminType,
|
||||
problemPermission: row.user.problemPermission,
|
||||
createTime: row.user.createTime,
|
||||
lastLogin: row.user.lastLogin,
|
||||
openApi: row.user.openApi,
|
||||
isDisabled: row.user.isDisabled,
|
||||
className: row.user.className,
|
||||
}),
|
||||
realName: showRealName ? row.profile.realName : null,
|
||||
acmProblemsStatus: row.profile.acmProblemsStatus,
|
||||
avatar: row.profile.avatar,
|
||||
blog: row.profile.blog,
|
||||
mood: row.profile.mood,
|
||||
github: row.profile.github,
|
||||
school: row.profile.school,
|
||||
major: row.profile.major,
|
||||
language: row.profile.language,
|
||||
acceptedNumber: row.profile.acceptedNumber,
|
||||
submissionNumber: row.profile.submissionNumber,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user