54 处 handler 都手写同一段: const parsed = X.safeParse(await c.req.json().catch(() => null)) if (!parsed.success) return failure(c, 400, "invalid-request", ...) 漏写 .catch 就是一个畸形请求打出 500。 - http.ts 加 readJson(读 + 校验)和 parseBody(再加上 400)。parseBody 不给 message 时回 zod 的第一条 issue —— 后台表单原来就是这么写的,fallback 文案 「Invalid payload」/「参数错误」只在 issues 为空时才用得上,实际走不到 - 51 处换成 parseBody;另 3 处失败时带别的错误码或和路径参数一起判,换成 readJson - 错误码、状态码、文案、判断顺序都不变 实跑核过:非 JSON 请求体回原来的固定文案,公告标题为空回 zod issue。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
import {
|
||
createAnnouncementRequestSchema,
|
||
updateAnnouncementRequestSchema,
|
||
type AdminAnnouncement,
|
||
type AdminAnnouncementList,
|
||
} from "@oj2/contract"
|
||
import { count, desc, eq } from "drizzle-orm"
|
||
import { Hono } from "hono"
|
||
|
||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||
import { db, schema } from "../../db"
|
||
import { failure, parseBody, success } from "../../http"
|
||
import { queryInteger, sampleUser } from "../helpers"
|
||
|
||
export const adminAnnouncementRoutes = new Hono<AppEnv>()
|
||
|
||
function serialize(row: {
|
||
announcement: typeof schema.announcement.$inferSelect
|
||
user: typeof schema.user.$inferSelect
|
||
realName: string | null
|
||
}) {
|
||
return {
|
||
id: row.announcement.id,
|
||
title: row.announcement.title,
|
||
tag: row.announcement.tag,
|
||
content: row.announcement.content,
|
||
visible: row.announcement.visible,
|
||
top: row.announcement.top,
|
||
createdBy: sampleUser(row.user, row.realName),
|
||
createTime: row.announcement.createTime,
|
||
lastUpdateTime: row.announcement.lastUpdateTime,
|
||
} satisfies AdminAnnouncement
|
||
}
|
||
|
||
function selectOne(id: number) {
|
||
return db
|
||
.select({
|
||
announcement: schema.announcement,
|
||
user: schema.user,
|
||
realName: schema.userProfile.realName,
|
||
})
|
||
.from(schema.announcement)
|
||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(eq(schema.announcement.id, id))
|
||
.limit(1)
|
||
}
|
||
|
||
adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
const [totalRows, rows] = await Promise.all([
|
||
db.select({ value: count() }).from(schema.announcement),
|
||
db
|
||
.select({
|
||
announcement: schema.announcement,
|
||
user: schema.user,
|
||
realName: schema.userProfile.realName,
|
||
})
|
||
.from(schema.announcement)
|
||
.innerJoin(
|
||
schema.user,
|
||
eq(schema.announcement.createdById, schema.user.id),
|
||
)
|
||
.leftJoin(
|
||
schema.userProfile,
|
||
eq(schema.userProfile.userId, schema.user.id),
|
||
)
|
||
.orderBy(desc(schema.announcement.createTime))
|
||
.limit(limit)
|
||
.offset(offset),
|
||
])
|
||
return success(c, {
|
||
// 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍
|
||
results: rows.map(serialize),
|
||
total: totalRows[0]?.value ?? 0,
|
||
} satisfies AdminAnnouncementList)
|
||
})
|
||
|
||
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
|
||
const parsed = await parseBody(c, createAnnouncementRequestSchema)
|
||
if (!parsed.success) return parsed.response
|
||
const now = new Date().toISOString()
|
||
const [created] = await db
|
||
.insert(schema.announcement)
|
||
.values({
|
||
...parsed.data,
|
||
createTime: now,
|
||
lastUpdateTime: now,
|
||
createdById: c.get("user")!.id,
|
||
})
|
||
.returning({ id: schema.announcement.id })
|
||
const [row] = await selectOne(created!.id)
|
||
return success(c, serialize(row!), 201)
|
||
})
|
||
|
||
adminAnnouncementRoutes.get(
|
||
"/announcements/:id",
|
||
requireSuperAdmin,
|
||
async (c) => {
|
||
const [row] = await selectOne(
|
||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||
)
|
||
if (!row)
|
||
return failure(
|
||
c,
|
||
404,
|
||
"announcement-not-found",
|
||
"Announcement does not exist",
|
||
)
|
||
return success(c, serialize(row))
|
||
},
|
||
)
|
||
|
||
adminAnnouncementRoutes.put(
|
||
"/announcements/:id",
|
||
requireSuperAdmin,
|
||
async (c) => {
|
||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const parsed = await parseBody(c, updateAnnouncementRequestSchema)
|
||
if (!parsed.success) return parsed.response
|
||
const updated = await db
|
||
.update(schema.announcement)
|
||
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
|
||
.where(eq(schema.announcement.id, id))
|
||
.returning({ id: schema.announcement.id })
|
||
if (updated.length === 0) {
|
||
return failure(
|
||
c,
|
||
404,
|
||
"announcement-not-found",
|
||
"Announcement does not exist",
|
||
)
|
||
}
|
||
const [row] = await selectOne(id)
|
||
return success(c, serialize(row!))
|
||
},
|
||
)
|
||
|
||
adminAnnouncementRoutes.delete(
|
||
"/announcements/:id",
|
||
requireSuperAdmin,
|
||
async (c) => {
|
||
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
|
||
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
|
||
const deleted = await db
|
||
.delete(schema.announcement)
|
||
.where(
|
||
eq(
|
||
schema.announcement.id,
|
||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||
),
|
||
)
|
||
.returning({ id: schema.announcement.id })
|
||
if (deleted.length === 0) {
|
||
return failure(
|
||
c,
|
||
404,
|
||
"announcement-not-found",
|
||
"Announcement does not exist",
|
||
)
|
||
}
|
||
return success(c, null)
|
||
},
|
||
)
|