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}`)
})
+104
View File
@@ -0,0 +1,104 @@
create extension if not exists pgcrypto;
do $$ begin
create type user_role as enum ('user', 'admin');
exception
when duplicate_object then null;
end $$;
do $$ begin
create type cloud_status as enum ('pending', 'approved', 'rejected');
exception
when duplicate_object then null;
end $$;
do $$ begin
create type cloud_rarity as enum ('common', 'uncommon', 'rare');
exception
when duplicate_object then null;
end $$;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
username text not null unique,
avatar_url text,
role user_role not null default 'user',
is_disabled boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists cloud_types (
id integer primary key,
name text not null,
name_en text not null,
genus text not null,
rarity cloud_rarity not null default 'common',
description text,
icon_url text,
created_at timestamptz not null default now()
);
create table if not exists clouds (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
cloud_type_id integer references cloud_types(id) on delete set null,
custom_cloud_type text,
image_url text not null,
thumbnail_url text,
latitude numeric(9, 6),
longitude numeric(9, 6),
location_name text,
description text,
captured_at timestamptz,
status cloud_status not null default 'pending',
is_hidden boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists user_collections (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
cloud_type_id integer not null references cloud_types(id) on delete cascade,
first_cloud_id uuid references clouds(id) on delete set null,
unlocked_at timestamptz not null default now(),
unique (user_id, cloud_type_id)
);
create index if not exists idx_clouds_public_gallery
on clouds (status, is_hidden, created_at desc);
create index if not exists idx_clouds_type_public
on clouds (cloud_type_id, status, is_hidden, created_at desc);
create index if not exists idx_clouds_map_captured
on clouds (captured_at, status, is_hidden)
where latitude is not null and longitude is not null;
create index if not exists idx_clouds_user_created
on clouds (user_id, created_at desc);
create index if not exists idx_user_collections_user
on user_collections (user_id, unlocked_at);
insert into cloud_types (id, name, name_en, genus, rarity, description)
values
(1, '积云', 'Cumulus', '低云', 'common', '轮廓清晰、底部较平的棉花状云,常见于晴朗天气。'),
(2, '层云', 'Stratus', '低云', 'common', '低而均匀的灰白色云层,常覆盖天空并带来阴沉观感。'),
(3, '卷云', 'Cirrus', '高云', 'common', '纤细如羽毛的高云,由冰晶组成,常预示天气变化。'),
(4, '积雨云', 'Cumulonimbus', '直展云', 'rare', '垂直发展旺盛的雷暴云,可带来强降水、雷电或冰雹。'),
(5, '层积云', 'Stratocumulus', '低云', 'common', '成片或成层的块状云,常有明暗相间的结构。'),
(6, '高积云', 'Altocumulus', '中云', 'uncommon', '中高度的小块状或波状云,常成群排列。'),
(7, '高层云', 'Altostratus', '中云', 'uncommon', '灰蓝色或灰色的中层云幕,常使太阳呈毛玻璃状。'),
(8, '雨层云', 'Nimbostratus', '中云', 'uncommon', '厚重暗灰的降水云层,通常带来持续性降雨或降雪。'),
(9, '卷层云', 'Cirrostratus', '高云', 'uncommon', '薄幕状高云,常覆盖大范围天空并产生日晕或月晕。'),
(10, '卷积云', 'Cirrocumulus', '高云', 'rare', '细小颗粒状高云,常呈鱼鳞状排列。')
on conflict (id) do update set
name = excluded.name,
name_en = excluded.name_en,
genus = excluded.genus,
rarity = excluded.rarity,
description = excluded.description;