Migrate from Supabase to local Express API
This commit is contained in:
+790
@@ -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}`)
|
||||
})
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user