feat: 添加项目删除 API 接口和完整 API 文档

- 新增 DELETE /api/projects/:slug 接口,支持根据 slug 删除项目
- 删除操作会级联删除关联的外部链接和标签关系
- 新增 GET /api/projects/:slug 接口,支持查询项目详情
- 添加完整的 API 参考文档(docs/api-reference.md)
- 修复 ProjectDetail 组件的 iframe 嵌入逻辑,添加可嵌入域名白名单
- 删除旧的数据摄入流程文档,由新 API 文档替代
This commit is contained in:
蔡浩涵
2026-01-11 13:58:52 +08:00
parent 7bfdb920a6
commit bef12403fb
4 changed files with 867 additions and 1189 deletions
+161
View File
@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
/**
* DELETE /api/projects/[slug]
*
* 根据项目的 slug 删除项目及其所有关联数据
*
* 由于数据库 schema 配置了 onDelete: Cascade
* 删除项目时会自动删除:
* - 该项目的所有外部链接(ExternalLink
* - 该项目的所有标签关联(ProjectTag)
*
* 注意:Tag 本身不会被删除,只会删除项目与标签的关联关系
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const slug = params.slug
// Verify API Key
const apiKey = request.headers.get('x-api-key') || process.env.WEBHOOK_API_KEY
const validApiKey = process.env.WEBHOOK_API_KEY
if (apiKey !== validApiKey) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Invalid or missing API Key'],
},
{ status: 401 }
)
}
// Check if project exists
const existingProject = await prisma.project.findUnique({
where: { slug },
include: {
links: true,
tags: {
include: {
tag: true,
},
},
},
})
if (!existingProject) {
return NextResponse.json(
{
success: false,
error: 'Not Found',
details: [`Project with slug "${slug}" not found`],
},
{ status: 404 }
)
}
// Delete project (cascade delete will handle links and project_tags)
await prisma.project.delete({
where: { slug },
})
console.warn(
`[API] Deleted project "${existingProject.name}" (slug: ${slug}, id: ${existingProject.id})`
)
return NextResponse.json({
success: true,
message: 'Project deleted successfully',
data: {
project: {
id: existingProject.id,
name: existingProject.name,
nameEn: existingProject.nameEn,
slug: existingProject.slug,
},
deleted: {
linksCount: existingProject.links.length,
tagsCount: existingProject.tags.length,
},
},
})
} catch (error) {
console.error('[API] Error deleting project:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
details: [error instanceof Error ? error.message : 'Unknown error'],
},
{ status: 500 }
)
}
}
/**
* GET /api/projects/[slug]
*
* 根据项目的 slug 获取项目详情
*/
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const slug = params.slug
const project = await prisma.project.findUnique({
where: { slug },
include: {
links: true,
tags: {
include: {
tag: true,
},
},
},
})
if (!project) {
return NextResponse.json(
{
success: false,
error: 'Not Found',
details: [`Project with slug "${slug}" not found`],
},
{ status: 404 }
)
}
// Transform response to match frontend structure
const transformedProject = {
...project,
tags: project.tags.map((pt) => ({
id: pt.tag.id,
name: pt.tag.name,
nameEn: pt.tag.nameEn,
slug: pt.tag.slug,
})),
}
return NextResponse.json({
success: true,
data: transformedProject,
})
} catch (error) {
console.error('[API] Error fetching project:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
details: [error instanceof Error ? error.message : 'Unknown error'],
},
{ status: 500 }
)
}
}
+37 -2
View File
@@ -55,6 +55,41 @@ function isImageUrl(url: string): boolean {
return imageExtensions.some(ext => lowerUrl.includes(ext))
}
// Helper function to check if URL can be embedded in iframe
// Many sites like GitHub, Google, etc. block iframe embedding via CSP
function isEmbeddableUrl(url: string): boolean {
const hostname = new URL(url).hostname.toLowerCase()
// Whitelist of domains that allow iframe embedding
const embeddableDomains = [
'youtube.com',
'youtu.be',
'vimeo.com',
'player.vimeo.com',
'drive.google.com',
'docs.google.com',
'www.figma.com',
'codepen.io',
'jsfiddle.net',
'codesandbox.io',
'stackblitz.com',
'replit.com',
'loom.com',
'wistia.com',
'brightcove.com',
'dailymotion.com',
'twitch.tv',
'soundcloud.com',
'spotify.com',
'canva.com',
'notion.so',
'typeform.com',
]
// Check if hostname matches any embeddable domain
return embeddableDomains.some((domain) => hostname === domain || hostname.endsWith('.' + domain))
}
export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
const t = await getTranslations('project')
@@ -124,14 +159,14 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
alt={`${displayName} demo`}
className="w-full h-auto"
/>
) : (
) : isEmbeddableUrl(project.source) ? (
<iframe
src={project.source}
className="w-full aspect-video"
title={`${displayName} demo`}
allowFullScreen
/>
)}
) : null}
</div>
)}
</header>