Migrate from Supabase to local Express API

This commit is contained in:
2026-06-14 16:57:50 +08:00
parent 217b81c506
commit 6359231b99
24 changed files with 3208 additions and 1192 deletions
+790
View File
@@ -0,0 +1,790 @@
import express, { type NextFunction, type Request, type Response } from 'express'
import cookieParser from 'cookie-parser'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import multer from 'multer'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
type Role = 'user' | 'admin'
type CloudStatus = 'pending' | 'approved' | 'rejected'
interface AuthUser {
id: string
email: string
username: string
avatar_url: string | null
role: Role
is_disabled: boolean
created_at: string
}
interface JwtPayload {
sub: string
}
declare global {
namespace Express {
interface Request {
user?: AuthUser
}
}
}
const { Pool } = pg
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const uploadRoot = process.env.UPLOAD_ROOT
? path.resolve(process.env.UPLOAD_ROOT)
: path.join(rootDir, 'server/uploads')
const cloudUploadRoot = path.join(uploadRoot, 'clouds')
const cookieName = 'opencloud_session'
const jwtSecret = process.env.JWT_SECRET || 'opencloud-dev-secret-change-me'
const port = Number(process.env.PORT || 3001)
if (!process.env.DATABASE_URL) {
console.warn('DATABASE_URL is not set. API routes will fail until a PostgreSQL connection string is provided.')
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
const app = express()
const upload = multer({
storage: multer.memoryStorage(),
limits: {
files: 2,
fileSize: 20 * 1024 * 1024,
},
})
app.use(express.json({ limit: '1mb' }))
app.use(cookieParser())
app.use('/uploads', express.static(uploadRoot))
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>) {
return (req: Request, res: Response, next: NextFunction) => {
void fn(req, res, next).catch(next)
}
}
function assertDatabaseUrl() {
if (!process.env.DATABASE_URL) {
const error = new Error('缺少 DATABASE_URL,后端无法连接 PostgreSQL。')
Object.assign(error, { status: 500 })
throw error
}
}
function signSession(userId: string) {
return jwt.sign({ sub: userId } satisfies JwtPayload, jwtSecret, { expiresIn: '7d' })
}
function setSessionCookie(res: Response, userId: string) {
res.cookie(cookieName, signSession(userId), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/',
})
}
function clearSessionCookie(res: Response) {
res.clearCookie(cookieName, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
})
}
function publicUser(row: Record<string, unknown>): AuthUser {
return {
id: String(row.id),
email: String(row.email),
username: String(row.username),
avatar_url: (row.avatar_url as string | null) ?? null,
role: row.role as Role,
is_disabled: Boolean(row.is_disabled),
created_at: String(row.created_at),
}
}
function publicProfile(row: Record<string, unknown>) {
return {
id: String(row.id),
username: String(row.username),
avatar_url: (row.avatar_url as string | null) ?? null,
role: row.role as Role,
is_disabled: Boolean(row.is_disabled),
created_at: String(row.created_at),
}
}
async function fetchUserById(id: string) {
assertDatabaseUrl()
const { rows } = await pool.query(
'select id,email,username,avatar_url,role,is_disabled,created_at from users where id = $1',
[id],
)
return rows[0] ? publicUser(rows[0]) : null
}
async function optionalAuth(req: Request, _res: Response, next: NextFunction) {
try {
const token = req.cookies?.[cookieName]
if (!token) return next()
const payload = jwt.verify(token, jwtSecret) as JwtPayload
const user = await fetchUserById(payload.sub)
if (user && !user.is_disabled) req.user = user
return next()
} catch {
return next()
}
}
function requireAuth(req: Request, _res: Response, next: NextFunction) {
if (!req.user) {
const error = new Error('请先登录。')
Object.assign(error, { status: 401 })
return next(error)
}
return next()
}
function requireAdmin(req: Request, _res: Response, next: NextFunction) {
if (req.user?.role !== 'admin') {
const error = new Error('需要管理员权限。')
Object.assign(error, { status: 403 })
return next(error)
}
return next()
}
app.use(optionalAuth)
function toCloudRow(row: Record<string, unknown>) {
const cloudTypeId = row.cloud_type_id as number | null
const username = row.username as string | null
return {
id: String(row.id),
user_id: String(row.user_id),
cloud_type_id: cloudTypeId,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: String(row.image_url),
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: row.latitude === null ? null : Number(row.latitude),
longitude: row.longitude === null ? null : Number(row.longitude),
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
status: row.status as CloudStatus,
is_hidden: Boolean(row.is_hidden),
created_at: String(row.created_at),
cloud_types: cloudTypeId
? {
id: cloudTypeId,
name: String(row.cloud_type_name),
name_en: String(row.cloud_type_name_en),
genus: String(row.cloud_type_genus),
rarity: row.cloud_type_rarity,
description: (row.cloud_type_description as string | null) ?? null,
icon_url: (row.cloud_type_icon_url as string | null) ?? null,
created_at: String(row.cloud_type_created_at),
}
: null,
profiles: username
? {
id: String(row.user_id),
username,
avatar_url: (row.avatar_url as string | null) ?? null,
role: row.user_role as Role,
is_disabled: Boolean(row.user_is_disabled),
created_at: String(row.user_created_at),
}
: null,
}
}
const cloudSelect = `
c.*,
ct.name as cloud_type_name,
ct.name_en as cloud_type_name_en,
ct.genus as cloud_type_genus,
ct.rarity as cloud_type_rarity,
ct.description as cloud_type_description,
ct.icon_url as cloud_type_icon_url,
ct.created_at as cloud_type_created_at,
u.username,
u.avatar_url,
u.role as user_role,
u.is_disabled as user_is_disabled,
u.created_at as user_created_at
`
const cloudJoin = `
from clouds c
left join cloud_types ct on ct.id = c.cloud_type_id
join users u on u.id = c.user_id
`
function parseString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function parseNullableNumber(value: unknown) {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
function parseNullableInteger(value: unknown) {
const number = parseNullableNumber(value)
return number === null ? null : Math.trunc(number)
}
function parseBoolean(value: unknown) {
return value === true || value === 'true' || value === '1'
}
function normalizeUploadExtension(file: Express.Multer.File) {
const extension = path.extname(file.originalname).toLowerCase().replace(/[^a-z0-9.]/g, '')
if (extension && ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) return extension
if (file.mimetype === 'image/png') return '.png'
if (file.mimetype === 'image/webp') return '.webp'
if (file.mimetype === 'image/gif') return '.gif'
return '.jpg'
}
async function writeCloudFile(userId: string, file: Express.Multer.File, suffix = '') {
const userDir = path.join(cloudUploadRoot, userId)
await mkdir(userDir, { recursive: true })
const random = Math.random().toString(36).slice(2, 10)
const extension = suffix ? '.jpg' : normalizeUploadExtension(file)
const filename = `${Date.now()}-${random}${suffix}${extension}`
const absolutePath = path.join(userDir, filename)
await writeFile(absolutePath, file.buffer)
return {
absolutePath,
publicPath: `/uploads/clouds/${userId}/${filename}`,
}
}
async function removePublicFile(publicPath: string | null) {
if (!publicPath || !publicPath.startsWith('/uploads/clouds/')) return
const absolutePath = path.resolve(uploadRoot, publicPath.replace(/^\/uploads\//, ''))
if (!absolutePath.startsWith(cloudUploadRoot)) return
await rm(absolutePath, { force: true })
}
app.get('/api/health', (_req, res) => {
res.json({ ok: true })
})
app.post('/api/auth/register', asyncHandler(async (req, res) => {
assertDatabaseUrl()
const email = parseString(req.body.email)?.toLowerCase()
const password = typeof req.body.password === 'string' ? req.body.password : ''
const username = parseString(req.body.username)
if (!email || !username || password.length < 8) {
res.status(400).json({ message: '请填写有效邮箱、用户名和至少 8 位密码。' })
return
}
const passwordHash = await bcrypt.hash(password, 12)
try {
const { rows } = await pool.query(
`insert into users (email, password_hash, username)
values ($1, $2, $3)
returning id,email,username,avatar_url,role,is_disabled,created_at`,
[email, passwordHash, username],
)
const user = publicUser(rows[0])
setSessionCookie(res, user.id)
res.status(201).json({ user, profile: publicProfile(rows[0]) })
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
res.status(409).json({ message: '邮箱或昵称已被使用。' })
return
}
throw error
}
}))
app.post('/api/auth/login', asyncHandler(async (req, res) => {
assertDatabaseUrl()
const email = parseString(req.body.email)?.toLowerCase()
const password = typeof req.body.password === 'string' ? req.body.password : ''
if (!email || !password) {
res.status(400).json({ message: '请输入邮箱和密码。' })
return
}
const { rows } = await pool.query(
'select id,email,password_hash,username,avatar_url,role,is_disabled,created_at from users where email = $1',
[email],
)
const row = rows[0]
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
res.status(401).json({ message: '邮箱或密码错误。' })
return
}
if (row.is_disabled) {
res.status(403).json({ message: '账号已被禁用。' })
return
}
const user = publicUser(row)
setSessionCookie(res, user.id)
res.json({ user, profile: publicProfile(row) })
}))
app.post('/api/auth/logout', (_req, res) => {
clearSessionCookie(res)
res.json({ ok: true })
})
app.get('/api/auth/me', (req, res) => {
res.json({ user: req.user ?? null, profile: req.user ? publicProfile(req.user as unknown as Record<string, unknown>) : null })
})
app.patch('/api/auth/password', requireAuth, asyncHandler(async (req, res) => {
const password = typeof req.body.password === 'string' ? req.body.password : ''
if (password.length < 6) {
res.status(400).json({ message: '新密码至少需要 6 位。' })
return
}
const passwordHash = await bcrypt.hash(password, 12)
await pool.query('update users set password_hash = $1, updated_at = now() where id = $2', [passwordHash, req.user!.id])
res.json({ ok: true })
}))
app.get('/api/profiles/check-username', asyncHandler(async (req, res) => {
const username = parseString(req.query.username)
const exclude = parseString(req.query.exclude)
if (!username) {
res.json({ available: false })
return
}
const params: unknown[] = [username]
let sql = 'select id from users where username = $1'
if (exclude) {
params.push(exclude)
sql += ' and id <> $2'
}
sql += ' limit 1'
const { rows } = await pool.query(sql, params)
res.json({ available: rows.length === 0 })
}))
app.patch('/api/me/profile', requireAuth, asyncHandler(async (req, res) => {
const username = parseString(req.body.username)
if (!username || username.length < 2 || username.length > 32) {
res.status(400).json({ message: '昵称长度需要在 2 到 32 个字符之间。' })
return
}
try {
const { rows } = await pool.query(
`update users set username = $1, updated_at = now()
where id = $2
returning id,email,username,avatar_url,role,is_disabled,created_at`,
[username, req.user!.id],
)
res.json({ profile: publicProfile(rows[0]), user: publicUser(rows[0]) })
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
res.status(409).json({ message: '这个昵称已经被使用,请换一个。' })
return
}
throw error
}
}))
app.get('/api/cloud-types', asyncHandler(async (_req, res) => {
const { rows } = await pool.query('select * from cloud_types order by id')
res.json({ data: rows })
}))
app.get('/api/me/collections', requireAuth, asyncHandler(async (req, res) => {
const { rows } = await pool.query(
`select uc.*, c.image_url, c.thumbnail_url, c.captured_at, c.created_at as cloud_created_at, c.location_name
from user_collections uc
left join clouds c on c.id = uc.first_cloud_id
where uc.user_id = $1
order by uc.unlocked_at asc`,
[req.user!.id],
)
res.json({
data: rows.map(row => ({
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
first_cloud_id: row.first_cloud_id,
unlocked_at: row.unlocked_at,
firstCloud: row.first_cloud_id
? {
id: row.first_cloud_id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
captured_at: row.captured_at,
created_at: row.cloud_created_at,
location_name: row.location_name,
}
: null,
})),
})
}))
app.get('/api/profiles/:id', asyncHandler(async (req, res) => {
const profileId = String(req.params.id)
const user = await fetchUserById(profileId)
if (!user) {
res.status(404).json({ message: '用户不存在。' })
return
}
res.json({ profile: publicProfile(user as unknown as Record<string, unknown>) })
}))
app.get('/api/profiles/:id/clouds', asyncHandler(async (req, res) => {
const profileId = String(req.params.id)
const own = req.user?.id === profileId && req.query.own === 'true'
const params: unknown[] = [profileId]
let sql = `select ${cloudSelect} ${cloudJoin} where c.user_id = $1`
if (!own) sql += ` and c.status = 'approved' and c.is_hidden = false`
sql += ' order by c.captured_at desc nulls last, c.created_at desc'
const { rows } = await pool.query(sql, params)
res.json({ data: rows.map(toCloudRow) })
}))
app.get('/api/clouds', asyncHandler(async (req, res) => {
const page = Math.max(1, Number(req.query.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize || 50)))
const params: unknown[] = []
const where = [`c.status = 'approved'`, 'c.is_hidden = false']
const typeId = req.query.typeId === 'all' ? null : parseNullableInteger(req.query.typeId)
if (typeId) {
params.push(typeId)
where.push(`c.cloud_type_id = $${params.length}`)
}
const search = parseString(req.query.search)
if (search) {
if (search.startsWith('@')) {
params.push(`%${search.slice(1).trim()}%`)
where.push(`u.username ilike $${params.length}`)
} else {
params.push(`%${search}%`)
where.push(`(ct.name ilike $${params.length} or ct.name_en ilike $${params.length} or c.custom_cloud_type ilike $${params.length})`)
}
}
const whereSql = where.length ? `where ${where.join(' and ')}` : ''
const countResult = await pool.query(`select count(*)::int as count ${cloudJoin} ${whereSql}`, params)
params.push(pageSize, (page - 1) * pageSize)
const dataResult = await pool.query(
`select ${cloudSelect} ${cloudJoin} ${whereSql}
order by c.created_at desc
limit $${params.length - 1} offset $${params.length}`,
params,
)
res.json({ data: dataResult.rows.map(toCloudRow), count: countResult.rows[0].count })
}))
app.get('/api/clouds/map', asyncHandler(async (req, res) => {
const field = req.query.field === 'created_at' ? 'created_at' : 'captured_at'
const start = parseString(req.query.start)
const end = parseString(req.query.end)
if (!start || !end) {
res.status(400).json({ message: '缺少时间范围。' })
return
}
const { rows } = await pool.query(
`select ${cloudSelect} ${cloudJoin}
where c.status = 'approved'
and c.is_hidden = false
and c.latitude is not null
and c.longitude is not null
and c.${field} >= $1
and c.${field} < $2
order by c.${field} asc
limit 1000`,
[start, end],
)
res.json({ data: rows.map(toCloudRow) })
}))
app.get('/api/cloud-types/:id/gallery', asyncHandler(async (req, res) => {
const typeId = Number(String(req.params.id))
const countResult = await pool.query(
`select count(*)::int as count from clouds
where cloud_type_id = $1 and status = 'approved' and is_hidden = false`,
[typeId],
)
const { rows } = await pool.query(
`select ${cloudSelect} ${cloudJoin}
where c.cloud_type_id = $1 and c.status = 'approved' and c.is_hidden = false
order by c.captured_at desc nulls last, c.created_at desc
limit 24`,
[typeId],
)
res.json({ data: rows.map(toCloudRow), count: countResult.rows[0].count })
}))
app.post('/api/clouds', requireAuth, upload.fields([
{ name: 'image', maxCount: 1 },
{ name: 'thumbnail', maxCount: 1 },
]), asyncHandler(async (req, res) => {
const files = req.files as Record<string, Express.Multer.File[]> | undefined
const image = files?.image?.[0]
const thumbnail = files?.thumbnail?.[0]
if (!image || !thumbnail) {
res.status(400).json({ message: '请同时上传原图和缩略图。' })
return
}
let imageFile: Awaited<ReturnType<typeof writeCloudFile>> | null = null
let thumbnailFile: Awaited<ReturnType<typeof writeCloudFile>> | null = null
const client = await pool.connect()
try {
imageFile = await writeCloudFile(req.user!.id, image)
thumbnailFile = await writeCloudFile(req.user!.id, thumbnail, '-thumb')
await client.query('begin')
const insertResult = await client.query(
`insert into clouds (
user_id, cloud_type_id, custom_cloud_type, image_url, thumbnail_url,
latitude, longitude, location_name, description, captured_at, status, is_hidden
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11)
returning id, cloud_type_id, created_at`,
[
req.user!.id,
parseNullableInteger(req.body.cloud_type_id),
parseString(req.body.custom_cloud_type),
imageFile.publicPath,
thumbnailFile.publicPath,
parseNullableNumber(req.body.latitude),
parseNullableNumber(req.body.longitude),
parseString(req.body.location_name),
parseString(req.body.description),
parseString(req.body.captured_at),
parseBoolean(req.body.is_hidden),
],
)
const inserted = insertResult.rows[0]
let unlockedBadge = null
if (inserted.cloud_type_id) {
const collectionResult = await client.query(
`insert into user_collections (user_id, cloud_type_id, first_cloud_id)
values ($1, $2, $3)
on conflict (user_id, cloud_type_id) do nothing
returning cloud_type_id, unlocked_at`,
[req.user!.id, inserted.cloud_type_id, inserted.id],
)
const collection = collectionResult.rows[0]
if (collection) {
const typeResult = await client.query(
'select id,name,name_en,rarity from cloud_types where id = $1',
[collection.cloud_type_id],
)
const type = typeResult.rows[0]
if (type) {
unlockedBadge = {
cloudTypeId: type.id,
cloudName: type.name,
cloudNameEn: type.name_en,
rarity: type.rarity,
unlockedAt: collection.unlocked_at,
}
}
}
}
await client.query('commit')
res.status(201).json({ cloud: inserted, unlockedBadge })
} catch (error) {
await client.query('rollback').catch(() => undefined)
await Promise.all([
removePublicFile(imageFile?.publicPath ?? null),
removePublicFile(thumbnailFile?.publicPath ?? null),
])
throw error
} finally {
client.release()
}
}))
app.patch('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => {
const cloudId = String(req.params.id)
const isAdmin = req.user!.role === 'admin'
const ownerResult = await pool.query('select user_id from clouds where id = $1', [cloudId])
const owner = ownerResult.rows[0]?.user_id
if (!owner) {
res.status(404).json({ message: '图片不存在。' })
return
}
if (!isAdmin && owner !== req.user!.id) {
res.status(403).json({ message: '只能修改自己的图片。' })
return
}
const allowed = ['cloud_type_id', 'custom_cloud_type', 'latitude', 'longitude', 'location_name', 'description', 'captured_at', 'is_hidden', 'status']
const set: string[] = []
const params: unknown[] = []
for (const key of allowed) {
if (!(key in req.body)) continue
if (key === 'status' && !isAdmin) continue
params.push(key === 'is_hidden'
? parseBoolean(req.body[key])
: key === 'cloud_type_id'
? parseNullableInteger(req.body[key])
: key === 'latitude' || key === 'longitude'
? parseNullableNumber(req.body[key])
: parseString(req.body[key]))
set.push(`${key} = $${params.length}`)
}
if (!set.length) {
res.status(400).json({ message: '没有可更新的字段。' })
return
}
params.push(cloudId)
await pool.query(`update clouds set ${set.join(', ')}, updated_at = now() where id = $${params.length}`, params)
const { rows } = await pool.query(`select ${cloudSelect} ${cloudJoin} where c.id = $1`, [cloudId])
res.json({ cloud: toCloudRow(rows[0]) })
}))
app.delete('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => {
const cloudId = String(req.params.id)
const isAdmin = req.user!.role === 'admin'
const { rows } = await pool.query('select id,user_id,image_url,thumbnail_url from clouds where id = $1', [cloudId])
const cloud = rows[0]
if (!cloud) {
res.status(404).json({ message: '图片不存在。' })
return
}
if (!isAdmin && cloud.user_id !== req.user!.id) {
res.status(403).json({ message: '只能删除自己的图片。' })
return
}
await pool.query('delete from clouds where id = $1', [cloudId])
const fileErrors = await Promise.allSettled([
removePublicFile(cloud.image_url),
removePublicFile(cloud.thumbnail_url),
])
const failed = fileErrors.some(result => result.status === 'rejected')
if (failed) console.error('cloud file deletion failed', fileErrors)
res.json({ deleted: [cloud.id], fileCleanupFailed: failed })
}))
app.get('/api/admin/stats', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const { rows } = await pool.query(
`select
(select count(*)::int from users) as users,
(select count(*)::int from clouds) as images,
(select count(*)::int from clouds where created_at >= $1) as "todayUploads",
(select count(*)::int from clouds where status = 'pending') as pending,
(select count(*)::int from clouds where status = 'approved') as approved,
(select count(*)::int from clouds where status = 'rejected') as rejected,
(select count(*)::int from clouds where is_hidden = true) as hidden`,
[today.toISOString()],
)
res.json({ stats: rows[0] })
}))
app.get('/api/admin/users', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
const { rows } = await pool.query(
'select id,username,avatar_url,role,is_disabled,created_at from users order by created_at desc limit 100',
)
res.json({ data: rows.map(publicProfile) })
}))
app.patch('/api/admin/users/:id', requireAuth, requireAdmin, asyncHandler(async (req, res) => {
const userId = String(req.params.id)
if (userId === req.user!.id && (req.body.role !== 'admin' || parseBoolean(req.body.is_disabled))) {
res.status(400).json({ message: '不能移除自己的管理员权限或禁用当前账号。' })
return
}
const set: string[] = []
const params: unknown[] = []
if (req.body.role === 'user' || req.body.role === 'admin') {
params.push(req.body.role)
set.push(`role = $${params.length}`)
}
if ('is_disabled' in req.body) {
params.push(parseBoolean(req.body.is_disabled))
set.push(`is_disabled = $${params.length}`)
}
if (!set.length) {
res.status(400).json({ message: '没有可更新的字段。' })
return
}
params.push(userId)
const { rows } = await pool.query(
`update users set ${set.join(', ')}, updated_at = now()
where id = $${params.length}
returning id,username,avatar_url,role,is_disabled,created_at`,
params,
)
res.json({ profile: publicProfile(rows[0]) })
}))
app.get('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
const { rows } = await pool.query(
`select ${cloudSelect} ${cloudJoin}
order by c.created_at desc
limit 120`,
)
res.json({ data: rows.map(toCloudRow) })
}))
app.patch('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids.filter((id: unknown) => typeof id === 'string') : []
if (!ids.length) {
res.status(400).json({ message: '请选择图片。' })
return
}
const set: string[] = []
const params: unknown[] = []
if (['pending', 'approved', 'rejected'].includes(req.body.status)) {
params.push(req.body.status)
set.push(`status = $${params.length}`)
}
if ('is_hidden' in req.body) {
params.push(parseBoolean(req.body.is_hidden))
set.push(`is_hidden = $${params.length}`)
}
if (!set.length) {
res.status(400).json({ message: '没有可更新的字段。' })
return
}
params.push(ids)
const { rows } = await pool.query(
`update clouds set ${set.join(', ')}, updated_at = now()
where id = any($${params.length}::uuid[])
returning id`,
params,
)
res.json({ updated: rows.map(row => row.id) })
}))
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
const payload = error as { status?: number; message?: string }
const status = payload.status || 500
if (status >= 500) console.error(error)
res.status(status).json({ message: payload.message || '服务器内部错误。' })
})
if (!existsSync(uploadRoot)) {
await mkdir(uploadRoot, { recursive: true })
}
app.listen(port, () => {
console.log(`OpenCloud API listening on http://localhost:${port}`)
})