新增内容引擎文章发布接口与详情页
This commit is contained in:
@@ -6,3 +6,5 @@ DB_NAME=aiameiba_com_new
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=填入本地密码
|
||||
ADMIN_INITIAL_PASSWORD=首次部署时设置的管理员初始密码
|
||||
CONTENT_INGEST_TOKEN=
|
||||
PUBLIC_SITE_URL=https://www.aiameiba.com
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 阿美巴 AI 官网内容发布 API
|
||||
|
||||
用于“阿美巴内容判断引擎”将已经人工确认的终稿发布到 AI 官网知识中心。网站只保存和展示终稿,不会调用 AI 改写正文。
|
||||
|
||||
## 发布目标配置
|
||||
|
||||
```text
|
||||
base_url: https://www.aiameiba.com
|
||||
health_path: /api/internal/content/v1/health
|
||||
upsert_path: /api/internal/content/v1/articles/upsert
|
||||
auth_type: bearer
|
||||
token_environment_name: CONTENT_INGEST_TOKEN
|
||||
site_profile: ai
|
||||
public_url_rule: https://www.aiameiba.com/knowledge/{slug-or-content_id}
|
||||
```
|
||||
|
||||
服务端环境变量:
|
||||
|
||||
```dotenv
|
||||
CONTENT_INGEST_TOKEN=请设置独立的长随机密钥
|
||||
PUBLIC_SITE_URL=https://www.aiameiba.com
|
||||
```
|
||||
|
||||
`CONTENT_INGEST_TOKEN` 只能存放在网站服务端环境变量中,不能放进前端环境变量、请求日志或 Git。
|
||||
|
||||
## 健康检查
|
||||
|
||||
```http
|
||||
GET /api/internal/content/v1/health
|
||||
Authorization: Bearer <CONTENT_INGEST_TOKEN>
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "amaba-ai-website",
|
||||
"site_profile": "ai",
|
||||
"api_version": "v1"
|
||||
}
|
||||
```
|
||||
|
||||
## 新增或更新文章
|
||||
|
||||
```http
|
||||
POST /api/internal/content/v1/articles/upsert
|
||||
Authorization: Bearer <CONTENT_INGEST_TOKEN>
|
||||
Content-Type: application/json
|
||||
Idempotency-Key: <source_task_id>:<source_version_id>:ai
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "publish-request-001",
|
||||
"source_system": "amaba-content-core",
|
||||
"source_task_id": "task-001",
|
||||
"source_version_id": "version-002",
|
||||
"source_version_number": 2,
|
||||
"content_hash": "09d7e1cd4d3f8b3d3748bb8b4b3d6d4df97195d45d3e15f1d47dd38614c0fa11",
|
||||
"site_profile": "ai",
|
||||
"publish_mode": "publish",
|
||||
"article": {
|
||||
"title": "企业如何开始 AI 进化",
|
||||
"body_plain": "第一自然段。\n\n第二自然段。",
|
||||
"summary": "面向企业经营者的 AI 进化起步方法。",
|
||||
"slug": "how-to-start-ai-evolution",
|
||||
"primary_keyword": "企业AI进化",
|
||||
"keywords": ["企业AI", "企业进化"],
|
||||
"content_type": "methodology",
|
||||
"direction": "方法论",
|
||||
"target_audience": "企业经营者与管理者",
|
||||
"author_name": "阿美巴",
|
||||
"cover_image_url": null,
|
||||
"canonical_url": "https://www.ameiba.cn/insights/how-to-start-ai-evolution"
|
||||
},
|
||||
"quality": {
|
||||
"human_confirmed": true,
|
||||
"confirmed_at": "2026-09-11T10:00:00+08:00",
|
||||
"prompt_version": "AmAba-Audit-V1.2",
|
||||
"judgment_version": "AmAba-Judgment-V1.1",
|
||||
"model": "approved-model",
|
||||
"remaining_issue_levels": ["P3"]
|
||||
},
|
||||
"provenance": { "knowledge_refs": [] },
|
||||
"semantic": {
|
||||
"entities": ["阿美巴", "企业进化"],
|
||||
"questions": ["企业应该如何开始AI进化?"],
|
||||
"facts": [],
|
||||
"suggested_schema_type": "Article"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
首次成功发布返回 `201 Created`,更新返回 `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"publication_id": "官网文章ID",
|
||||
"status": "published",
|
||||
"url": "https://www.aiameiba.com/knowledge/how-to-start-ai-evolution",
|
||||
"revision": 1,
|
||||
"content_hash": "09d7e1cd4d3f8b3d3748bb8b4b3d6d4df97195d45d3e15f1d47dd38614c0fa11",
|
||||
"published_at": "2026-09-11T02:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 校验与限制
|
||||
|
||||
- `source_system` 必须为 `amaba-content-core`,`site_profile` 必须为 `ai`。
|
||||
- `quality.human_confirmed` 必须为 `true`;`publish_mode` 仅支持 `draft` 和 `publish`。
|
||||
- 标题最长 255 字符,摘要最长 1,200 字符,正文最长 200,000 字符;数组最多 50 项。
|
||||
- 正文以纯文本保存并按空行渲染为段落,页面不会执行正文 HTML。
|
||||
- `canonical_url` 可指向传统官网的同文 URL;AI 官网仍会保留可访问正文。
|
||||
- 单个 IP 每分钟最多 30 次内部接口请求。
|
||||
|
||||
## 幂等与更新规则
|
||||
|
||||
- `Idempotency-Key` 必须唯一。相同键且相同 `content_hash` 会返回同一文章,不重复创建。
|
||||
- 相同键但不同哈希返回 `409 CONTENT_CONFLICT`。
|
||||
- 同一 `source_system + source_task_id + ai` 的新确认版本更新原文章,保持原详情 URL,并将 `revision` 加一。
|
||||
- 首次发布使用请求中的合法 `slug`;后续更新不会改变已有 URL。
|
||||
|
||||
## 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "字段不合法",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP | code | 含义 |
|
||||
| --- | --- | --- |
|
||||
| 400 | `VALIDATION_ERROR` | 请求字段不合法 |
|
||||
| 401 | `UNAUTHORIZED` | Token 缺失或错误 |
|
||||
| 409 | `CONTENT_CONFLICT` | 幂等键哈希冲突或 slug 冲突 |
|
||||
| 413 | `CONTENT_TOO_LARGE` | 正文或数组超出限制 |
|
||||
| 429 | `RATE_LIMITED` | 请求频率超限 |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | 网站未配置发布 Token |
|
||||
| 500 | `INTERNAL_ERROR` | 临时服务故障,可重试 |
|
||||
|
||||
## 公开读取
|
||||
|
||||
不需要 Token:
|
||||
|
||||
```text
|
||||
GET /api/public/articles
|
||||
GET /api/public/articles/{content_id-or-slug}
|
||||
GET /knowledge/{content_id-or-slug}
|
||||
```
|
||||
|
||||
仅返回已发布文章;不暴露任务 ID、审核模型、提示词、来源内部日志或 Token。
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { articleUrl, getPublishedArticle } from '@/lib/articles';
|
||||
|
||||
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || process.env.PUBLIC_SITE_URL || 'https://www.aiameiba.com').replace(/\/$/, '');
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const paragraphs = (body: string) => body.split(/\n\s*\n/).map(item => item.trim()).filter(Boolean);
|
||||
|
||||
export async function generateMetadata({ params }: { params: { identifier: string } }): Promise<Metadata> {
|
||||
const article = await getPublishedArticle(params.identifier);
|
||||
if (!article) return { title: '文章不存在' };
|
||||
const localUrl = `${siteUrl}${articleUrl(article)}`;
|
||||
const canonical = article.canonical_url || localUrl;
|
||||
return {
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
alternates: { canonical },
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
url: canonical,
|
||||
images: article.cover_image_url ? [{ url: article.cover_image_url }] : undefined,
|
||||
publishedTime: article.published_at,
|
||||
modifiedTime: article.updated_at,
|
||||
authors: [article.author_name],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KnowledgeArticlePage({ params }: { params: { identifier: string } }) {
|
||||
const article = await getPublishedArticle(params.identifier);
|
||||
if (!article) notFound();
|
||||
const localUrl = `${siteUrl}${articleUrl(article)}`;
|
||||
const canonical = article.canonical_url || localUrl;
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.summary,
|
||||
url: canonical,
|
||||
mainEntityOfPage: localUrl,
|
||||
author: { '@type': 'Organization', name: article.author_name },
|
||||
publisher: { '@type': 'Organization', name: '阿美巴AI', url: siteUrl },
|
||||
datePublished: article.published_at,
|
||||
dateModified: article.updated_at,
|
||||
image: article.cover_image_url || undefined,
|
||||
articleSection: article.category,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-narrow">
|
||||
<nav className="breadcrumb" aria-label="面包屑导航">
|
||||
<ol><li><a href="/">首页</a></li><li><a href="/knowledge">知识中心</a></li><li><span aria-current="page">{article.title}</span></li></ol>
|
||||
</nav>
|
||||
<article className="knowledge-article">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<header className="knowledge-article-header">
|
||||
<p className="article-meta"><span>{article.category}</span><span>{article.author_name}</span><time dateTime={article.published_at}>发布于 {formatDate(article.published_at)}</time><time dateTime={article.updated_at}>更新于 {formatDate(article.updated_at)}</time></p>
|
||||
<h1>{article.title}</h1>
|
||||
{article.summary && <p className="knowledge-article-summary">{article.summary}</p>}
|
||||
</header>
|
||||
<div className="knowledge-article-body">
|
||||
{paragraphs(article.body_plain).map((paragraph, index) => <p key={index}>{paragraph}</p>)}
|
||||
</div>
|
||||
<footer className="knowledge-article-footer"><a href="/knowledge">← 返回知识中心</a></footer>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +1,71 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { generateSEO, generateBreadcrumbSchema } from '@/lib/seo';
|
||||
import { getSiteSettings } from '@/lib/settings';
|
||||
import { articleUrl, getPublishedArticles } from '@/lib/articles';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:24731';
|
||||
interface ArticleItem { id: string; title: string; slug: string; category: string; summary: string; author: string; date: string; }
|
||||
|
||||
async function getArticles(): Promise<ArticleItem[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/contents/article?pageSize=100`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.list || []).map((item: any) => ({ ...item, id: item.id || item.content_id, summary: item.summary || item.body || '', date: item.date || item.created_at || '' }));
|
||||
} catch { return []; }
|
||||
}
|
||||
const categories = ['通用深度', '百科认知', '行业观察', '方法论', '问答', '指南干货'];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...generateSEO({
|
||||
title: '知识中心',
|
||||
description: '企业进化的知识体系与方法论,包括企业进化、AI时代企业、企业经营等分类',
|
||||
description: '企业进化的知识体系与方法论,包括通用深度、行业观察、方法论和指南干货。',
|
||||
canonical: '/knowledge',
|
||||
}),
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
export default async function KnowledgePage() {
|
||||
const settings = await getSiteSettings();
|
||||
const articles = await getArticles();
|
||||
const [settings, articles] = await Promise.all([getSiteSettings(), getPublishedArticles()]);
|
||||
const breadcrumbs = [
|
||||
{ name: '首页', url: '/' },
|
||||
{ name: '知识中心', url: '/knowledge' },
|
||||
];
|
||||
|
||||
// 知识分类
|
||||
const categories = [
|
||||
{ id: 'evolution', name: '企业进化', count: 0 },
|
||||
{ id: 'ai-era', name: 'AI时代企业', count: 0 },
|
||||
{ id: 'management', name: '企业经营', count: 0 },
|
||||
{ id: 'organization', name: '组织重塑', count: 0 },
|
||||
{ id: 'values', name: '四大价值', count: 0 },
|
||||
{ id: 'ai-organization', name: 'AI组织', count: 0 },
|
||||
{ id: 'enterprise-management', name: '企业管理', count: 0 },
|
||||
{ id: 'methodology', name: '方法论', count: 0 },
|
||||
{ id: 'cases', name: '案例', count: 0 },
|
||||
{ id: 'observation', name: '企业观察', count: 0 },
|
||||
{ id: 'viewpoint', name: '阿美巴观点', count: 0 },
|
||||
{ id: 'faq', name: 'FAQ', count: 0 },
|
||||
];
|
||||
|
||||
// 文章列表 - 从CMS获取published状态的文章
|
||||
return (
|
||||
<>
|
||||
{/* 结构化数据 */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(generateBreadcrumbSchema(breadcrumbs)),
|
||||
}}
|
||||
/>
|
||||
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(generateBreadcrumbSchema(breadcrumbs)) }} />
|
||||
<div className="container">
|
||||
{/* 面包屑导航 */}
|
||||
<nav className="breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<li key={index}>
|
||||
{index < breadcrumbs.length - 1 ? (
|
||||
<a href={item.url}>{item.name}</a>
|
||||
) : (
|
||||
<span aria-current="page">{item.name}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<ol><li><a href="/">首页</a></li><li><span aria-current="page">知识中心</span></li></ol>
|
||||
</nav>
|
||||
|
||||
<h1>{settings['knowledge.title'] || '知识中心'}</h1>
|
||||
|
||||
<section className="content-section">
|
||||
<h2>{settings['knowledge.systemTitle'] || '知识体系'}</h2>
|
||||
<p>
|
||||
{settings['knowledge.intro'] || '知识中心汇集了企业进化的知识体系与方法论,帮助企业理解和掌握AI时代的经营管理方法。'}
|
||||
</p>
|
||||
<p>{settings['knowledge.intro'] || '知识中心汇集了企业进化的知识体系与方法论,帮助企业理解和掌握AI时代的经营管理方法。'}</p>
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2>{settings['knowledge.categoryTitle'] || '知识分类'}</h2>
|
||||
<section className="content-section" aria-labelledby="knowledge-categories">
|
||||
<h2 id="knowledge-categories">{settings['knowledge.categoryTitle'] || '知识分类'}</h2>
|
||||
<div className="category-grid">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="category-card">
|
||||
<h3>{category.name}</h3>
|
||||
<p>{category.count} 篇文章</p>
|
||||
</div>
|
||||
))}
|
||||
{categories.map((category) => {
|
||||
const count = articles.filter(article => article.category === category).length;
|
||||
return <div key={category} className="category-card"><h3>{category}</h3><p>{count} 篇文章</p></div>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2>{settings['knowledge.articleTitle'] || '最新文章'}</h2>
|
||||
{articles.length > 0 ? (
|
||||
<section className="content-section" aria-labelledby="knowledge-articles">
|
||||
<h2 id="knowledge-articles">{settings['knowledge.articleTitle'] || '最新文章'}</h2>
|
||||
{articles.length ? (
|
||||
<div className="articles-list">
|
||||
{articles.map((article) => (
|
||||
<div key={article.id} className="article-card">
|
||||
<h3>
|
||||
<a href={`/knowledge/${article.slug}`}>{article.title}</a>
|
||||
</h3>
|
||||
<p className="article-meta">
|
||||
<span>{article.category}</span>
|
||||
<span>{article.author}</span>
|
||||
<span>{article.date}</span>
|
||||
</p>
|
||||
<article key={article.content_id} className="article-card">
|
||||
<p className="article-meta"><span>{article.category}</span><span>{article.author_name}</span><span>{formatDate(article.published_at)}</span></p>
|
||||
<h3><a href={articleUrl(article)}>{article.title}</a></h3>
|
||||
<p>{article.summary}</p>
|
||||
</div>
|
||||
<a className="article-read-more" href={articleUrl(article)}>阅读全文</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>暂无文章数据</p>
|
||||
<p>文章内容正在完善中,请稍后再来查看。</p>
|
||||
</div>
|
||||
<div className="empty-state"><p>暂无已发布文章</p><p>文章内容正在完善中,请稍后再来查看。</p></div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="related-section">
|
||||
<h2>相关知识</h2>
|
||||
<ul>
|
||||
<li><a href="/evolution">企业进化</a></li>
|
||||
<li><a href="/value">四大价值</a></li>
|
||||
<li><a href="/concepts">概念</a></li>
|
||||
<li><a href="/faq">FAQ</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getPublishedArticles, articleUrl } from '@/lib/articles';
|
||||
|
||||
export async function GET() {
|
||||
const articles = await getPublishedArticles();
|
||||
const baseUrl = (process.env.PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_SITE_URL || 'https://www.aiameiba.com').replace(/\/$/, '');
|
||||
const lines = [
|
||||
'# 阿美巴AI官网已发布文章索引',
|
||||
'',
|
||||
'以下内容为文章标题、摘要和稳定详情地址;正文请访问对应详情页。',
|
||||
'',
|
||||
...articles.flatMap(article => [
|
||||
`## ${article.title}`,
|
||||
`- 地址:${baseUrl}${articleUrl(article)}`,
|
||||
`- 分类:${article.category}`,
|
||||
`- 摘要:${article.summary}`,
|
||||
'',
|
||||
]),
|
||||
];
|
||||
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getPublishedArticles, articleUrl } from '@/lib/articles';
|
||||
|
||||
export async function GET() {
|
||||
const articles = await getPublishedArticles();
|
||||
const baseUrl = (process.env.PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_SITE_URL || 'https://www.aiameiba.com').replace(/\/$/, '');
|
||||
const lines = [
|
||||
'# 阿美巴AI官网',
|
||||
'',
|
||||
'> AI时代企业进化平台,提供企业进化相关的知识与方法。',
|
||||
'',
|
||||
'## 主要入口',
|
||||
`- [知识中心](${baseUrl}/knowledge): 已发布文章索引。`,
|
||||
`- [站点地图](${baseUrl}/sitemap.xml): 公开页面与文章 URL。`,
|
||||
'',
|
||||
'## 最新文章',
|
||||
...articles.slice(0, 20).map(article => `- [${article.title}](${baseUrl}${articleUrl(article)}): ${article.summary}`),
|
||||
];
|
||||
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { articleUrl, getPublishedArticles } from '@/lib/articles';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://ameiba.com';
|
||||
const BASE_URL = (process.env.NEXT_PUBLIC_SITE_URL || process.env.PUBLIC_SITE_URL || 'https://www.aiameiba.com').replace(/\/$/, '');
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// 静态页面
|
||||
const staticPages = [
|
||||
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly' as const, priority: 1.0 },
|
||||
@@ -27,10 +28,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.6 },
|
||||
];
|
||||
|
||||
// 动态页面(从CMS获取)
|
||||
// 这里需要在实际部署时从API获取数据
|
||||
// 暂时返回静态页面
|
||||
const dynamicPages: MetadataRoute.Sitemap = [];
|
||||
const articles = await getPublishedArticles();
|
||||
const dynamicPages: MetadataRoute.Sitemap = articles.map(article => ({
|
||||
url: `${BASE_URL}${articleUrl(article)}`,
|
||||
lastModified: new Date(article.updated_at || article.published_at),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...dynamicPages];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PublicArticle } from '@/types';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:24731';
|
||||
|
||||
export async function getPublishedArticles(): Promise<PublicArticle[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/public/articles?pageSize=100`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.list || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublishedArticle(identifier: string): Promise<PublicArticle | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/public/articles/${encodeURIComponent(identifier)}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const articleUrl = (article: Pick<PublicArticle, 'content_id' | 'slug'>) =>
|
||||
`/knowledge/${article.slug || article.content_id}`;
|
||||
@@ -610,6 +610,56 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.article-read-more {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-article {
|
||||
padding: 24px 0 80px;
|
||||
}
|
||||
|
||||
.knowledge-article-header {
|
||||
padding: 22px 0 32px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.knowledge-article-header h1 {
|
||||
margin: 16px 0;
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.knowledge-article-summary {
|
||||
margin: 0;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.knowledge-article-body {
|
||||
padding: 38px 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.knowledge-article-body p {
|
||||
margin-bottom: 1.35em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.knowledge-article-footer {
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
padding: 22px 24px;
|
||||
border: 1px solid var(--color-line);
|
||||
|
||||
@@ -56,6 +56,26 @@ export interface Article extends Content {
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface PublicArticle {
|
||||
content_id: string;
|
||||
slug?: string | null;
|
||||
title: string;
|
||||
body_plain: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
author_name: string;
|
||||
cover_image_url?: string | null;
|
||||
canonical_url?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
direction?: string | null;
|
||||
target_audience?: string | null;
|
||||
revision: number;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
entities: string[];
|
||||
questions: string[];
|
||||
}
|
||||
|
||||
export interface Case extends Content {
|
||||
content_type: 'case';
|
||||
client_name?: string;
|
||||
|
||||
@@ -175,6 +175,41 @@ const createTables = () => {
|
||||
)
|
||||
`);
|
||||
|
||||
// 内容判断引擎发布映射:复用既有 contents/articles,而非建立第二套文章系统。
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS integration_publications (
|
||||
publication_id VARCHAR(36) PRIMARY KEY,
|
||||
content_id VARCHAR(36) NOT NULL UNIQUE,
|
||||
source_system VARCHAR(64) NOT NULL,
|
||||
source_task_id VARCHAR(128) NOT NULL,
|
||||
source_version_id VARCHAR(128) NOT NULL,
|
||||
source_version_number INTEGER NOT NULL,
|
||||
site_profile VARCHAR(16) NOT NULL,
|
||||
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
slug VARCHAR(180) UNIQUE,
|
||||
summary TEXT,
|
||||
primary_keyword VARCHAR(255),
|
||||
keywords_json TEXT,
|
||||
source_content_type VARCHAR(64),
|
||||
direction VARCHAR(255),
|
||||
target_audience VARCHAR(255),
|
||||
canonical_url VARCHAR(2048),
|
||||
human_confirmed BOOLEAN NOT NULL DEFAULT 0,
|
||||
confirmed_at DATETIME,
|
||||
knowledge_refs_json TEXT,
|
||||
entities_json TEXT,
|
||||
questions_json TEXT,
|
||||
facts_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
published_at DATETIME,
|
||||
UNIQUE KEY uq_integration_source_task (source_system, source_task_id, site_profile),
|
||||
FOREIGN KEY (content_id) REFERENCES contents(content_id)
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('数据库表创建完成');
|
||||
};
|
||||
|
||||
|
||||
+9
-1
@@ -14,13 +14,14 @@ const entityContentJoinRoutes = require('./routes/entityContentJoinRoutes');
|
||||
const publicApiRoutes = require('./routes/publicApiRoutes');
|
||||
const jsonLdRoutes = require('./routes/jsonLdRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const internalContentRoutes = require('./routes/internalContentRoutes');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 24731;
|
||||
|
||||
// 中间件
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 静态文件服务
|
||||
@@ -34,6 +35,7 @@ app.use('/api/joins', entityContentJoinRoutes);
|
||||
app.use('/api/public', publicApiRoutes);
|
||||
app.use('/api/jsonld', jsonLdRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/internal/content/v1', internalContentRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
@@ -59,6 +61,12 @@ app.get('/', (req, res) => {
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({
|
||||
success: false,
|
||||
error: { code: 'CONTENT_TOO_LARGE', message: '请求正文超出限制', retryable: false },
|
||||
});
|
||||
}
|
||||
console.error('服务器错误:', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { ContentIngestService, ContentIngestError } = require('../services/ContentIngestService');
|
||||
|
||||
const router = express.Router();
|
||||
const requests = new Map();
|
||||
const RATE_LIMIT = 30;
|
||||
const WINDOW_MS = 60 * 1000;
|
||||
|
||||
const sendError = (res, status, code, message, retryable = false) => res.status(status).json({
|
||||
success: false,
|
||||
error: { code, message, retryable },
|
||||
});
|
||||
|
||||
const requireContentToken = (req, res, next) => {
|
||||
const configuredToken = process.env.CONTENT_INGEST_TOKEN;
|
||||
if (!configuredToken) {
|
||||
return sendError(res, 503, 'SERVICE_UNAVAILABLE', '内容发布接口尚未配置', true);
|
||||
}
|
||||
const header = req.get('authorization') || '';
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) return sendError(res, 401, 'UNAUTHORIZED', '缺少或无效的访问凭据');
|
||||
const received = Buffer.from(match[1]);
|
||||
const expected = Buffer.from(configuredToken);
|
||||
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
|
||||
return sendError(res, 401, 'UNAUTHORIZED', '缺少或无效的访问凭据');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
const rateLimit = (req, res, next) => {
|
||||
const key = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
const now = Date.now();
|
||||
const state = requests.get(key) || { start: now, count: 0 };
|
||||
if (now - state.start >= WINDOW_MS) {
|
||||
state.start = now;
|
||||
state.count = 0;
|
||||
}
|
||||
state.count += 1;
|
||||
requests.set(key, state);
|
||||
if (state.count > RATE_LIMIT) {
|
||||
return sendError(res, 429, 'RATE_LIMITED', '请求过于频繁,请稍后重试', true);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
router.use(rateLimit, requireContentToken);
|
||||
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'amaba-ai-website',
|
||||
site_profile: 'ai',
|
||||
api_version: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/articles/upsert', async (req, res) => {
|
||||
try {
|
||||
const result = await ContentIngestService.upsert(req.body, req.get('Idempotency-Key'));
|
||||
res.status(result.statusCode).json(result.body);
|
||||
} catch (error) {
|
||||
if (error instanceof ContentIngestError) {
|
||||
return sendError(res, error.status, error.code, error.message, error.status >= 500);
|
||||
}
|
||||
// 不记录请求正文或 Authorization,避免 Token 和终稿进入日志。
|
||||
console.error('内容发布接口失败:', error.message);
|
||||
return sendError(res, 500, 'INTERNAL_ERROR', '内容发布暂时失败,请稍后重试', true);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const PublicApiService = require('../services/PublicApiService');
|
||||
const { getDb } = require('../config/database');
|
||||
const { ContentIngestService } = require('../services/ContentIngestService');
|
||||
|
||||
// 获取前台展示配置(只读)
|
||||
router.get('/settings', async (req, res) => {
|
||||
@@ -13,6 +14,33 @@ router.get('/settings', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 知识中心公开文章。仅返回已发布文章,不暴露发布任务、审核或模型数据。
|
||||
router.get('/articles', (req, res) => {
|
||||
try {
|
||||
const { category, page = 1, pageSize = 100 } = req.query;
|
||||
const size = Math.min(Math.max(parseInt(pageSize, 10) || 100, 1), 100);
|
||||
const currentPage = Math.max(parseInt(page, 10) || 1, 1);
|
||||
const list = ContentIngestService.getPublishedArticles({
|
||||
category: category || undefined,
|
||||
limit: size,
|
||||
offset: (currentPage - 1) * size,
|
||||
});
|
||||
res.json({ list, page: currentPage, pageSize: size });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: '文章列表暂时不可用' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/articles/:identifier', (req, res) => {
|
||||
try {
|
||||
const article = ContentIngestService.getPublishedArticle(req.params.identifier);
|
||||
if (!article) return res.status(404).json({ error: '文章不存在或尚未发布' });
|
||||
return res.json(article);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: '文章详情暂时不可用' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取已发布的实体列表
|
||||
router.get('/entities', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
const Joi = require('joi');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getDb } = require('../config/database');
|
||||
const Article = require('../models/Article');
|
||||
|
||||
const MAX_BODY_LENGTH = 200000;
|
||||
const CATEGORY_BY_TYPE = {
|
||||
general: '通用深度',
|
||||
encyclopedia: '百科认知',
|
||||
industry: '行业观察',
|
||||
methodology: '方法论',
|
||||
qa: '问答',
|
||||
guide: '指南干货',
|
||||
};
|
||||
|
||||
class ContentIngestError extends Error {
|
||||
constructor(code, message, status = 400) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanText = (value, max, { required = false } = {}) => {
|
||||
if (value === undefined || value === null) {
|
||||
if (required) throw new ContentIngestError('VALIDATION_ERROR', '字段不能为空');
|
||||
return null;
|
||||
}
|
||||
const cleaned = String(value).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').trim();
|
||||
if (required && !cleaned) throw new ContentIngestError('VALIDATION_ERROR', '字段不能为空');
|
||||
if (cleaned.length > max) throw new ContentIngestError('CONTENT_TOO_LARGE', '内容长度超出限制', 413);
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
const cleanArray = (items, name) => {
|
||||
if (!Array.isArray(items)) throw new ContentIngestError('VALIDATION_ERROR', `${name}必须是数组`);
|
||||
if (items.length > 50) throw new ContentIngestError('CONTENT_TOO_LARGE', `${name}数量超出限制`, 413);
|
||||
return items.map(item => cleanText(item, 300, { required: true }));
|
||||
};
|
||||
|
||||
const payloadSchema = Joi.object({
|
||||
request_id: Joi.string().trim().max(128).required(),
|
||||
source_system: Joi.string().valid('amaba-content-core').required(),
|
||||
source_task_id: Joi.string().trim().max(128).required(),
|
||||
source_version_id: Joi.string().trim().max(128).required(),
|
||||
source_version_number: Joi.number().integer().min(1).max(1000000).required(),
|
||||
content_hash: Joi.string().trim().pattern(/^[a-fA-F0-9]{32,128}$/).required(),
|
||||
site_profile: Joi.string().valid('ai').required(),
|
||||
publish_mode: Joi.string().valid('draft', 'publish').required(),
|
||||
article: Joi.object({
|
||||
title: Joi.string().trim().min(1).max(255).required(),
|
||||
body_plain: Joi.string().min(1).max(MAX_BODY_LENGTH).required(),
|
||||
summary: Joi.string().allow('', null).max(1200).default(''),
|
||||
slug: Joi.string().allow('', null).max(180).pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).default(null),
|
||||
primary_keyword: Joi.string().allow('', null).max(255).default(null),
|
||||
keywords: Joi.array().items(Joi.string().trim().min(1).max(120)).max(50).default([]),
|
||||
content_type: Joi.string().trim().max(64).default('general'),
|
||||
direction: Joi.string().allow('', null).max(255).default(null),
|
||||
target_audience: Joi.string().allow('', null).max(255).default(null),
|
||||
author_name: Joi.string().allow('', null).max(120).default('阿美巴'),
|
||||
cover_image_url: Joi.string().uri({ scheme: ['http', 'https'] }).allow('', null).default(null),
|
||||
canonical_url: Joi.string().uri({ scheme: ['http', 'https'] }).allow('', null).max(2048).default(null),
|
||||
}).required(),
|
||||
quality: Joi.object({
|
||||
human_confirmed: Joi.boolean().valid(true).required(),
|
||||
confirmed_at: Joi.date().iso().required(),
|
||||
prompt_version: Joi.string().allow('', null).max(128).default(null),
|
||||
judgment_version: Joi.string().allow('', null).max(128).default(null),
|
||||
model: Joi.string().allow('', null).max(128).default(null),
|
||||
remaining_issue_levels: Joi.array().items(Joi.string().valid('P0', 'P1', 'P2', 'P3')).max(10).default([]),
|
||||
}).required(),
|
||||
provenance: Joi.object({
|
||||
knowledge_refs: Joi.array().items(Joi.object({
|
||||
id: Joi.string().trim().max(128).required(),
|
||||
name: Joi.string().trim().max(255).required(),
|
||||
version: Joi.string().trim().max(64).required(),
|
||||
})).max(50).default([]),
|
||||
}).default({ knowledge_refs: [] }),
|
||||
semantic: Joi.object({
|
||||
entities: Joi.array().items(Joi.string().trim().min(1).max(300)).max(50).default([]),
|
||||
questions: Joi.array().items(Joi.string().trim().min(1).max(600)).max(50).default([]),
|
||||
facts: Joi.array().items(Joi.string().trim().min(1).max(1200)).max(50).default([]),
|
||||
suggested_schema_type: Joi.string().valid('Article').default('Article'),
|
||||
}).default({ entities: [], questions: [], facts: [], suggested_schema_type: 'Article' }),
|
||||
}).unknown(false);
|
||||
|
||||
class ContentIngestService {
|
||||
static validate(payload, idempotencyKey) {
|
||||
if (!idempotencyKey || idempotencyKey.length > 255) {
|
||||
throw new ContentIngestError('VALIDATION_ERROR', 'Idempotency-Key不能为空或过长');
|
||||
}
|
||||
const { error, value } = payloadSchema.validate(payload, { abortEarly: false, convert: true });
|
||||
if (error) {
|
||||
throw new ContentIngestError('VALIDATION_ERROR', error.details.map(item => item.message).join(';'));
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
idempotency_key: idempotencyKey.trim(),
|
||||
article: {
|
||||
...value.article,
|
||||
title: cleanText(value.article.title, 255, { required: true }),
|
||||
body_plain: cleanText(value.article.body_plain, MAX_BODY_LENGTH, { required: true }),
|
||||
summary: cleanText(value.article.summary || '', 1200) || '',
|
||||
primary_keyword: cleanText(value.article.primary_keyword || '', 255),
|
||||
direction: cleanText(value.article.direction || '', 255),
|
||||
target_audience: cleanText(value.article.target_audience || '', 255),
|
||||
author_name: cleanText(value.article.author_name || '阿美巴', 120) || '阿美巴',
|
||||
keywords: cleanArray(value.article.keywords || [], 'keywords'),
|
||||
},
|
||||
semantic: {
|
||||
...value.semantic,
|
||||
entities: cleanArray(value.semantic.entities || [], 'entities'),
|
||||
questions: cleanArray(value.semantic.questions || [], 'questions'),
|
||||
facts: cleanArray(value.semantic.facts || [], 'facts'),
|
||||
},
|
||||
quality: {
|
||||
...value.quality,
|
||||
// sync-mysql 只接受基础类型参数,统一保存为 ISO 时间字符串。
|
||||
confirmed_at: new Date(value.quality.confirmed_at).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getPublicationByIdempotencyKey(idempotencyKey) {
|
||||
return getDb().prepare('SELECT * FROM integration_publications WHERE idempotency_key = ?').get(idempotencyKey);
|
||||
}
|
||||
|
||||
static getPublicationBySource(sourceSystem, taskId) {
|
||||
return getDb().prepare(`
|
||||
SELECT * FROM integration_publications
|
||||
WHERE source_system = ? AND source_task_id = ? AND site_profile = 'ai'
|
||||
`).get(sourceSystem, taskId);
|
||||
}
|
||||
|
||||
static getPublicUrl(publication) {
|
||||
const baseUrl = (process.env.PUBLIC_SITE_URL || 'https://www.aiameiba.com').replace(/\/$/, '');
|
||||
return `${baseUrl}/knowledge/${publication.slug || publication.content_id}`;
|
||||
}
|
||||
|
||||
static response(publication, created = false) {
|
||||
const content = getDb().prepare('SELECT publish_status FROM contents WHERE content_id = ?').get(publication.content_id);
|
||||
return {
|
||||
statusCode: created ? 201 : 200,
|
||||
body: {
|
||||
success: true,
|
||||
publication_id: publication.content_id,
|
||||
status: content?.publish_status || (publication.published_at ? 'published' : 'draft'),
|
||||
url: this.getPublicUrl(publication),
|
||||
revision: Number(publication.revision),
|
||||
content_hash: publication.content_hash,
|
||||
published_at: publication.published_at ? new Date(publication.published_at).toISOString() : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static assertSlugAvailable(slug, publicationId = null) {
|
||||
if (!slug) return;
|
||||
const existing = getDb().prepare('SELECT publication_id FROM integration_publications WHERE slug = ?').get(slug);
|
||||
if (existing && existing.publication_id !== publicationId) {
|
||||
throw new ContentIngestError('CONTENT_CONFLICT', 'slug已被其他文章使用', 409);
|
||||
}
|
||||
}
|
||||
|
||||
static async upsert(rawPayload, idempotencyKey) {
|
||||
const payload = this.validate(rawPayload, idempotencyKey);
|
||||
const db = getDb();
|
||||
const sameRequest = this.getPublicationByIdempotencyKey(payload.idempotency_key);
|
||||
if (sameRequest) {
|
||||
if (sameRequest.content_hash !== payload.content_hash) {
|
||||
throw new ContentIngestError('CONTENT_CONFLICT', '相同幂等键对应的内容哈希不一致', 409);
|
||||
}
|
||||
return this.response(sameRequest);
|
||||
}
|
||||
|
||||
const existing = this.getPublicationBySource(payload.source_system, payload.source_task_id);
|
||||
const now = new Date().toISOString();
|
||||
const nextStatus = payload.publish_mode === 'publish' ? 'published' : 'draft';
|
||||
const category = CATEGORY_BY_TYPE[payload.article.content_type] || '通用深度';
|
||||
const tags = payload.article.keywords.join(',');
|
||||
const publishedAt = nextStatus === 'published' ? (existing?.published_at || now) : null;
|
||||
const requestedSlug = payload.article.slug || null;
|
||||
|
||||
if (existing) {
|
||||
// 已发布文章的 URL 一经建立保持不变,避免更新版本时产生新的 URL。
|
||||
const slug = existing.slug || requestedSlug;
|
||||
this.assertSlugAvailable(slug, existing.publication_id);
|
||||
Article.update(existing.content_id, {
|
||||
title: payload.article.title,
|
||||
body: payload.article.body_plain,
|
||||
publish_status: nextStatus,
|
||||
version_no: Number(existing.revision) + 1,
|
||||
version_note: `内容系统确认版本 ${payload.source_version_number}`,
|
||||
category,
|
||||
tags,
|
||||
cover_image: payload.article.cover_image_url,
|
||||
author: payload.article.author_name,
|
||||
published_at: publishedAt,
|
||||
});
|
||||
db.prepare(`
|
||||
UPDATE integration_publications SET
|
||||
source_version_id = ?, source_version_number = ?, idempotency_key = ?, content_hash = ?,
|
||||
revision = ?, slug = ?, summary = ?, primary_keyword = ?, keywords_json = ?,
|
||||
source_content_type = ?, direction = ?, target_audience = ?, canonical_url = ?,
|
||||
human_confirmed = 1, confirmed_at = ?, knowledge_refs_json = ?, entities_json = ?,
|
||||
questions_json = ?, facts_json = ?, updated_at = ?, published_at = ?
|
||||
WHERE publication_id = ?
|
||||
`).run(
|
||||
payload.source_version_id, payload.source_version_number, payload.idempotency_key, payload.content_hash,
|
||||
Number(existing.revision) + 1, slug, payload.article.summary, payload.article.primary_keyword,
|
||||
JSON.stringify(payload.article.keywords), payload.article.content_type, payload.article.direction,
|
||||
payload.article.target_audience, payload.article.canonical_url, payload.quality.confirmed_at,
|
||||
JSON.stringify(payload.provenance.knowledge_refs), JSON.stringify(payload.semantic.entities),
|
||||
JSON.stringify(payload.semantic.questions), JSON.stringify(payload.semantic.facts), now, publishedAt,
|
||||
existing.publication_id,
|
||||
);
|
||||
return this.response(this.getPublicationByIdempotencyKey(payload.idempotency_key));
|
||||
}
|
||||
|
||||
this.assertSlugAvailable(requestedSlug);
|
||||
const article = Article.create({
|
||||
title: payload.article.title,
|
||||
body: payload.article.body_plain,
|
||||
publish_status: nextStatus,
|
||||
version_no: 1,
|
||||
version_note: `内容系统确认版本 ${payload.source_version_number}`,
|
||||
category,
|
||||
tags,
|
||||
cover_image: payload.article.cover_image_url,
|
||||
author: payload.article.author_name,
|
||||
published_at: publishedAt,
|
||||
});
|
||||
const publicationId = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO integration_publications (
|
||||
publication_id, content_id, source_system, source_task_id, source_version_id, source_version_number,
|
||||
site_profile, idempotency_key, content_hash, revision, slug, summary, primary_keyword, keywords_json,
|
||||
source_content_type, direction, target_audience, canonical_url, human_confirmed, confirmed_at,
|
||||
knowledge_refs_json, entities_json, questions_json, facts_json, created_at, updated_at, published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'ai', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
publicationId, article.content_id, payload.source_system, payload.source_task_id,
|
||||
payload.source_version_id, payload.source_version_number, payload.idempotency_key, payload.content_hash,
|
||||
requestedSlug, payload.article.summary, payload.article.primary_keyword, JSON.stringify(payload.article.keywords),
|
||||
payload.article.content_type, payload.article.direction, payload.article.target_audience,
|
||||
payload.article.canonical_url, payload.quality.confirmed_at, JSON.stringify(payload.provenance.knowledge_refs),
|
||||
JSON.stringify(payload.semantic.entities), JSON.stringify(payload.semantic.questions),
|
||||
JSON.stringify(payload.semantic.facts), now, now, publishedAt,
|
||||
);
|
||||
return this.response(this.getPublicationByIdempotencyKey(payload.idempotency_key), true);
|
||||
}
|
||||
|
||||
static getPublishedArticles({ category, limit = 100, offset = 0 } = {}) {
|
||||
const db = getDb();
|
||||
const params = ['published'];
|
||||
let query = `
|
||||
SELECT c.content_id, c.title, c.body, c.publish_status, c.created_at, c.updated_at,
|
||||
a.category, a.tags, a.cover_image, a.author, a.published_at,
|
||||
p.slug, p.summary, p.primary_keyword, p.canonical_url, p.direction, p.target_audience,
|
||||
p.revision, p.entities_json, p.questions_json
|
||||
FROM contents c
|
||||
INNER JOIN articles a ON a.article_id = c.content_id
|
||||
LEFT JOIN integration_publications p ON p.content_id = c.content_id
|
||||
WHERE c.content_type = 'article' AND c.publish_status = ?
|
||||
`;
|
||||
if (category) {
|
||||
query += ' AND a.category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
query += ' ORDER BY COALESCE(a.published_at, c.created_at) DESC, c.updated_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(Math.min(Number(limit) || 100, 100), Math.max(Number(offset) || 0, 0));
|
||||
return db.prepare(query).all(...params).map(this.serializePublicArticle);
|
||||
}
|
||||
|
||||
static getPublishedArticle(identifier) {
|
||||
const row = getDb().prepare(`
|
||||
SELECT c.content_id, c.title, c.body, c.publish_status, c.created_at, c.updated_at,
|
||||
a.category, a.tags, a.cover_image, a.author, a.published_at,
|
||||
p.slug, p.summary, p.primary_keyword, p.canonical_url, p.direction, p.target_audience,
|
||||
p.revision, p.entities_json, p.questions_json
|
||||
FROM contents c
|
||||
INNER JOIN articles a ON a.article_id = c.content_id
|
||||
LEFT JOIN integration_publications p ON p.content_id = c.content_id
|
||||
WHERE c.content_type = 'article' AND c.publish_status = 'published'
|
||||
AND (c.content_id = ? OR p.slug = ?)
|
||||
LIMIT 1
|
||||
`).get(identifier, identifier);
|
||||
return row ? this.serializePublicArticle(row) : null;
|
||||
}
|
||||
|
||||
static serializePublicArticle(row) {
|
||||
const parse = value => {
|
||||
try { return value ? JSON.parse(value) : []; } catch { return []; }
|
||||
};
|
||||
return {
|
||||
content_id: row.content_id,
|
||||
slug: row.slug || null,
|
||||
title: row.title,
|
||||
body_plain: row.body || '',
|
||||
summary: row.summary || (row.body || '').slice(0, 240),
|
||||
category: row.category || '通用深度',
|
||||
author_name: row.author || '阿美巴',
|
||||
cover_image_url: row.cover_image || null,
|
||||
canonical_url: row.canonical_url || null,
|
||||
primary_keyword: row.primary_keyword || null,
|
||||
direction: row.direction || null,
|
||||
target_audience: row.target_audience || null,
|
||||
revision: Number(row.revision || 1),
|
||||
published_at: row.published_at || row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
entities: parse(row.entities_json),
|
||||
questions: parse(row.questions_json),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ContentIngestService, ContentIngestError, MAX_BODY_LENGTH };
|
||||
@@ -0,0 +1,78 @@
|
||||
const { migrate } = require('../src/database/migrate');
|
||||
const { getDb, closeDb } = require('../src/config/database');
|
||||
const Article = require('../src/models/Article');
|
||||
const { ContentIngestService, ContentIngestError } = require('../src/services/ContentIngestService');
|
||||
|
||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const taskId = `ingest-test-${suffix}`;
|
||||
let contentId;
|
||||
|
||||
const buildPayload = ({ version = 1, hash = 'a'.repeat(64), title = '内容发布服务测试' } = {}) => ({
|
||||
request_id: `request-${version}`,
|
||||
source_system: 'amaba-content-core',
|
||||
source_task_id: taskId,
|
||||
source_version_id: `version-${version}`,
|
||||
source_version_number: version,
|
||||
content_hash: hash,
|
||||
site_profile: 'ai',
|
||||
publish_mode: 'publish',
|
||||
article: {
|
||||
title,
|
||||
body_plain: '第一段测试正文。\n\n第二段测试正文。',
|
||||
summary: '测试摘要。',
|
||||
slug: `ingest-test-${suffix}`,
|
||||
keywords: ['测试'],
|
||||
content_type: 'methodology',
|
||||
author_name: '阿美巴',
|
||||
},
|
||||
quality: { human_confirmed: true, confirmed_at: '2026-09-11T09:00:00+08:00' },
|
||||
provenance: { knowledge_refs: [] },
|
||||
semantic: { entities: [], questions: [], facts: [], suggested_schema_type: 'Article' },
|
||||
});
|
||||
|
||||
beforeAll(() => migrate());
|
||||
afterAll(() => {
|
||||
if (contentId) {
|
||||
getDb().prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
|
||||
Article.delete(contentId);
|
||||
}
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('内容判断引擎发布服务', () => {
|
||||
test('首次发布、重复幂等请求、哈希冲突与新版本更新', async () => {
|
||||
const firstKey = `${taskId}:version-1:ai`;
|
||||
const first = await ContentIngestService.upsert(buildPayload(), firstKey);
|
||||
contentId = first.body.publication_id;
|
||||
expect(first.statusCode).toBe(201);
|
||||
expect(first.body.status).toBe('published');
|
||||
expect(first.body.revision).toBe(1);
|
||||
|
||||
const repeated = await ContentIngestService.upsert(buildPayload(), firstKey);
|
||||
expect(repeated.statusCode).toBe(200);
|
||||
expect(repeated.body.publication_id).toBe(contentId);
|
||||
expect(repeated.body.revision).toBe(1);
|
||||
|
||||
await expect(ContentIngestService.upsert(buildPayload({ hash: 'b'.repeat(64) }), firstKey))
|
||||
.rejects.toMatchObject({ code: 'CONTENT_CONFLICT', status: 409 });
|
||||
|
||||
const updated = await ContentIngestService.upsert(
|
||||
buildPayload({ version: 2, hash: 'c'.repeat(64), title: '内容发布服务测试(更新)' }),
|
||||
`${taskId}:version-2:ai`,
|
||||
);
|
||||
expect(updated.statusCode).toBe(200);
|
||||
expect(updated.body.publication_id).toBe(contentId);
|
||||
expect(updated.body.revision).toBe(2);
|
||||
expect(updated.body.status).toBe('published');
|
||||
});
|
||||
|
||||
test('拒绝错误站点身份与未人工确认的内容', () => {
|
||||
const wrongSite = buildPayload();
|
||||
wrongSite.site_profile = 'human';
|
||||
expect(() => ContentIngestService.validate(wrongSite, 'invalid-site')).toThrow(ContentIngestError);
|
||||
|
||||
const notConfirmed = buildPayload();
|
||||
notConfirmed.quality.human_confirmed = false;
|
||||
expect(() => ContentIngestService.validate(notConfirmed, 'not-confirmed')).toThrow(ContentIngestError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user